Repository: jj-vcs/jj
Pull request #5553: git: port remote management to `gix`
Author: login: emilazy
Created: 2025-02-02T02:54:44Z | Merged: 2025-03-09T21:32:47Z

(Plus one more change to remove the other remaining `git_util::get_git_repo` caller.)

I’m not wholly confident in the first commit here, but the rest should hopefully be good. It feels like there might be awkward race conditions in here but I think Git is just like that.

@Byron 👋 I thought you might be interested in some API UX feedback I came up with while working on this. If you’d like me to open issues/discussions on the `gix` side for any of these please let me know.

* It’s awkward that `gix::Remote::save_to` handles the fussy details of removing the other sections for that remote from the config, but leaves you to reimplement it yourself if you need it in any other context. It would be nice if the removal logic was exposed separately, or perhaps if there was a specific way to tell `gix` that you’re renaming a remote and the old config should go away.

* You can change the name of a `gix::Remote`, and you can set its push URL, but you can’t change its fetch URL. In `set_remote_url` I have to manually copy over all the data from the existing remote to achieve this. Not sure if this is a fundamental constraint of the model or just a missing API.

* It’s a little surprising that there’s a rich representation of parsed refspecs but the remote API expects you to pass in a `BStr` and deal with potentially‐impossible parse errors.

* The borrowing model of configuration and remotes interact awkwardly. In a previous version of this PR where I tried to persist the remote configuration changes to the `gix::Repository`, I had to clone the entire thing so that I could hold a `gix::Remote` and a `gix::config::SnapshotMut` at the same time (otherwise the immutable and mutable borrows conflicted). That felt awkward for a task as simple as adding a remote to the configuration. (In general when using the `gix` API I kind of wish there was less borrowing and more refcounting/cloning, though I realize that there’s a subjective element to the trade‐off here and that you can wrap a borrow‐happy API in a clone‐happy one but not vice versa.)

* Relatedly, there’s this comment in the code:

  ```rust
  // TODO: make it possible to load snapshots with reloading via .config() and write mutated snapshots back to disk which should be the way
  //       to affect all instances of a repo, probably via `config_mut()` and `config_mut_at()`.
  ```

  That would be quite nice for us!

In general there’s more convoluted logic here in the move from `git2` to `gix`, but thankfully most of that is just dealing with lower‐level APIs that don’t bundle configuration changes, ref renames, etc. into one porcelain call. Other than the things listed above, the process went quite smoothly; thanks again for all your work!

<!--
There's no need to add anything here, but feel free to add a personal message.
Please describe the changes in this PR in the commit message(s) instead, with
each commit representing one logical change. Address code review comments by
rewriting the commits rather than adding commits on top. Use force-push when
pushing the updated commits (`jj git push` does that automatically when you
rewrite commits). Merge the PR at will once it's been approved. See
https://github.com/jj-vcs/jj/blob/main/docs/contributing.md for details.
Note that you need to sign Google's CLA to contribute.
-->

# Checklist

If applicable:
- [ ] I have updated `CHANGELOG.md`
- [ ] I have updated the documentation (README.md, docs/, demos/)
- [ ] I have updated the config schema (cli/src/config-schema.json)
- [ ] I have added tests to cover my changes



Diff:
diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs
index 04d88db3ffc..8b73bf25a60 100644
--- a/cli/src/command_error.rs
+++ b/cli/src/command_error.rs
@@ -491,12 +491,6 @@ mod git {
 
     use super::*;
 
-    impl From<git2::Error> for CommandError {
-        fn from(err: git2::Error) -> Self {
-            user_error_with_message("Git operation failed", err)
-        }
-    }
-
     impl From<GitImportError> for CommandError {
         fn from(err: GitImportError) -> Self {
             let hint = match &err {
diff --git a/cli/src/commands/git/clone.rs b/cli/src/commands/git/clone.rs
index 0cc656866a0..5d363acc8a0 100644
--- a/cli/src/commands/git/clone.rs
+++ b/cli/src/commands/git/clone.rs
@@ -34,7 +34,6 @@ use crate::command_error::user_error_with_message;
 use crate::command_error::CommandError;
 use crate::commands::git::maybe_add_gitignore;
 use crate::git_util::absolute_git_url;
-use crate::git_util::get_git_repo;
 use crate::git_util::print_git_import_stats;
 use crate::git_util::with_remote_git_callbacks;
 use crate::ui::Ui;
@@ -193,8 +192,7 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    git::add_remote(workspace_command.repo().store(), remote_name, source)?;
     // Reload workspace to apply new remote configuration to
     // gix::ThreadSafeRepository behind the store.
     let workspace = command.load_workspace_at(
diff --git a/cli/src/commands/git/init.rs b/cli/src/commands/git/init.rs
index 4ec9705bf24..8e155de62b2 100644
--- a/cli/src/commands/git/init.rs
+++ b/cli/src/commands/git/init.rs
@@ -15,6 +15,7 @@
 use std::io::Write;
 use std::path::Path;
 use std::path::PathBuf;
+use std::str;
 use std::sync::Arc;
 
 use jj_lib::file_util;
@@ -32,11 +33,11 @@ use crate::cli_util::start_repo_transaction;
 use crate::cli_util::CommandHelper;
 use crate::cli_util::WorkspaceCommandHelper;
 use crate::command_error::cli_error;
+use crate::command_error::internal_error;
 use crate::command_error::user_error_with_hint;
 use crate::command_error::user_error_with_message;
 use crate::command_error::CommandError;
 use crate::commands::git::maybe_add_gitignore;
-use crate::git_util::get_git_repo;
 use crate::git_util::is_colocated_git_workspace;
 use crate::git_util::print_failed_git_export;
 use crate::git_util::print_git_import_stats;
@@ -237,10 +238,16 @@ pub fn maybe_set_repository_level_trunk_alias(
     ui: &Ui,
     workspace_command: &WorkspaceCommandHelper,
 ) -> Result<(), CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    if let Ok(reference) = git_repo.find_reference("refs/remotes/origin/HEAD") {
-        if let Some(reference_name) = reference.symbolic_target() {
-            if let Some(RefName::RemoteBranch(symbol)) = parse_git_ref(reference_name) {
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
+    if let Some(reference) = git_repo
+        .try_find_reference("refs/remotes/origin/HEAD")
+        .map_err(internal_error)?
+    {
+        if let Some(reference_name) = reference.target().try_name() {
+            if let Some(RefName::RemoteBranch(symbol)) = str::from_utf8(reference_name.as_bstr())
+                .ok()
+                .and_then(parse_git_ref)
+            {
                 // TODO: Can we assume the symbolic target points to the same remote?
                 let symbol = RemoteRefSymbol {
                     name: &symbol.name,
diff --git a/cli/src/commands/git/remote/add.rs b/cli/src/commands/git/remote/add.rs
index a468e171b75..72ef0c75564 100644
--- a/cli/src/commands/git/remote/add.rs
+++ b/cli/src/commands/git/remote/add.rs
@@ -18,7 +18,6 @@ use jj_lib::repo::Repo;
 use crate::cli_util::CommandHelper;
 use crate::command_error::CommandError;
 use crate::git_util::absolute_git_url;
-use crate::git_util::get_git_repo;
 use crate::ui::Ui;
 
 /// Add a Git remote
@@ -39,9 +38,7 @@ pub fn cmd_git_remote_add(
     args: &GitRemoteAddArgs,
 ) -> Result<(), CommandError> {
     let workspace_command = command.workspace_helper(ui)?;
-    let repo = workspace_command.repo();
-    let git_repo = get_git_repo(repo.store())?;
     let url = absolute_git_url(command.cwd(), &args.url)?;
-    git::add_remote(&git_repo, &args.remote, &url)?;
+    git::add_remote(workspace_command.repo().store(), &args.remote, &url)?;
     Ok(())
 }
diff --git a/cli/src/commands/git/remote/remove.rs b/cli/src/commands/git/remote/remove.rs
index 850ebdba9a4..9147c11ac96 100644
--- a/cli/src/commands/git/remote/remove.rs
+++ b/cli/src/commands/git/remote/remove.rs
@@ -14,12 +14,10 @@
 
 use clap_complete::ArgValueCandidates;
 use jj_lib::git;
-use jj_lib::repo::Repo;
 
 use crate::cli_util::CommandHelper;
 use crate::command_error::CommandError;
 use crate::complete;
-use crate::git_util::get_git_repo;
 use crate::ui::Ui;
 
 /// Remove a Git remote and forget its bookmarks
@@ -36,10 +34,8 @@ pub fn cmd_git_remote_remove(
     args: &GitRemoteRemoveArgs,
 ) -> Result<(), CommandError> {
     let mut workspace_command = command.workspace_helper(ui)?;
-    let repo = workspace_command.repo();
-    let git_repo = get_git_repo(repo.store())?;
     let mut tx = workspace_command.start_transaction();
-    git::remove_remote(tx.repo_mut(), &git_repo, &args.remote)?;
+    git::remove_remote(tx.repo_mut(), &args.remote)?;
     if tx.repo().has_changes() {
         tx.finish(ui, format!("remove git remote {}", &args.remote))
     } else {
diff --git a/cli/src/commands/git/remote/rename.rs b/cli/src/commands/git/remote/rename.rs
index ff8cc0a6c0b..1f8f3449dcd 100644
--- a/cli/src/commands/git/remote/rename.rs
+++ b/cli/src/commands/git/remote/rename.rs
@@ -14,12 +14,10 @@
 
 use clap_complete::ArgValueCandidates;
 use jj_lib::git;
-use jj_lib::repo::Repo;
 
 use crate::cli_util::CommandHelper;
 use crate::command_error::CommandError;
 use crate::complete;
-use crate::git_util::get_git_repo;
 use crate::ui::Ui;
 
 /// Rename a Git remote
@@ -38,10 +36,8 @@ pub fn cmd_git_remote_rename(
     args: &GitRemoteRenameArgs,
 ) -> Result<(), CommandError> {
     let mut workspace_command = command.workspace_helper(ui)?;
-    let repo = workspace_command.repo();
-    let git_repo = get_git_repo(repo.store())?;
     let mut tx = workspace_command.start_transaction();
-    git::rename_remote(tx.repo_mut(), &git_repo, &args.old, &args.new)?;
+    git::rename_remote(tx.repo_mut(), &args.old, &args.new)?;
     if tx.repo().has_changes() {
         tx.finish(
             ui,
diff --git a/cli/src/commands/git/remote/set_url.rs b/cli/src/commands/git/remote/set_url.rs
index f1a77130a73..8c7cca14b22 100644
--- a/cli/src/commands/git/remote/set_url.rs
+++ b/cli/src/commands/git/remote/set_url.rs
@@ -20,7 +20,6 @@ use crate::cli_util::CommandHelper;
 use crate::command_error::CommandError;
 use crate::complete;
 use crate::git_util::absolute_git_url;
-use crate::git_util::get_git_repo;
 use crate::ui::Ui;
 
 /// Set the URL of a Git remote
@@ -42,9 +41,7 @@ pub fn cmd_git_remote_set_url(
     args: &GitRemoteSetUrlArgs,
 ) -> Result<(), CommandError> {
     let workspace_command = command.workspace_helper(ui)?;
-    let repo = workspace_command.repo();
-    let git_repo = get_git_repo(repo.store())?;
     let url = absolute_git_url(command.cwd(), &args.url)?;
-    git::set_remote_url(&git_repo, &args.remote, &url)?;
+    git::set_remote_url(workspace_command.repo().store(), &args.remote, &url)?;
     Ok(())
 }
diff --git a/cli/src/git_util.rs b/cli/src/git_util.rs
index 4cb29902a09..cdf9c17bfa8 100644
--- a/cli/src/git_util.rs
+++ b/cli/src/git_util.rs
@@ -39,7 +39,6 @@ use jj_lib::op_store::RefTarget;
 use jj_lib::op_store::RemoteRef;
 use jj_lib::repo::ReadonlyRepo;
 use jj_lib::repo::Repo;
-use jj_lib::store::Store;
 use jj_lib::workspace::Workspace;
 use unicode_width::UnicodeWidthStr;
 
@@ -51,11 +50,6 @@ use crate::formatter::Formatter;
 use crate::ui::ProgressOutput;
 use crate::ui::Ui;
 
-// TODO: migrate to gitoxide or subprocess, and remove this function
-pub(crate) fn get_git_repo(store: &Store) -> Result<git2::Repository, CommandError> {
-    Ok(git::get_git_backend(store)?.open_git_repo()?)
-}
-
 pub fn is_colocated_git_workspace(workspace: &Workspace, repo: &ReadonlyRepo) -> bool {
     let Ok(git_backend) = git::get_git_backend(repo.store()) else {
         return false;
diff --git a/cli/tests/test_git_remotes.rs b/cli/tests/test_git_remotes.rs
index 0f94c16fb17..92542026760 100644
--- a/cli/tests/test_git_remotes.rs
+++ b/cli/tests/test_git_remotes.rs
@@ -13,12 +13,34 @@
 // limitations under the License.
 
 use std::fs;
+use std::io::Write;
+use std::path::Path;
 use std::path::PathBuf;
 
 use testutils::git;
 
 use crate::common::TestEnvironment;
 
+fn read_git_config(repo_path: &Path) -> String {
+    let git_config = fs::read_to_string(repo_path.join(".jj/repo/store/git/config"))
+        .or_else(|_| fs::read_to_string(repo_path.join(".git/config")))
+        .unwrap();
+    git_config
+        .split_inclusive('\n')
+        .filter(|line| {
+            // Filter out non‐portable values.
+            [
+                "\tfilemode =",
+                "\tsymlinks =",
+                "\tignorecase =",
+                "\tprecomposeunicode =",
+            ]
+            .iter()
+            .all(|prefix| !line.to_ascii_lowercase().starts_with(prefix))
+        })
+        .collect()
+}
+
 #[test]
 fn test_git_remotes() {
     let test_env = TestEnvironment::default();
@@ -58,6 +80,15 @@ fn test_git_remotes() {
     [EOF]
     [exit status: 1]
     ");
+    insta::assert_snapshot!(read_git_config(&repo_path), @r#"
+    [core]
+    	repositoryformatversion = 0
+    	bare = true
+    	logallrefupdates = false
+    [remote "bar"]
+    	url = http://example.com/repo/bar
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
 
 #[test]
@@ -165,6 +196,15 @@ fn test_git_remote_set_url() {
     foo http://example.com/repo/bar
     [EOF]
     ");
+    insta::assert_snapshot!(read_git_config(&repo_path), @r#"
+    [core]
+    	repositoryformatversion = 0
+    	bare = true
+    	logallrefupdates = false
+    [remote "foo"]
+    	url = http://example.com/repo/bar
+    	fetch = +refs/heads/*:refs/remotes/foo/*
+    "#);
 }
 
 #[test]
@@ -248,6 +288,18 @@ fn test_git_remote_rename() {
     baz http://example.com/repo/baz
     [EOF]
     ");
+    insta::assert_snapshot!(read_git_config(&repo_path), @r#"
+    [core]
+    	repositoryformatversion = 0
+    	bare = true
+    	logallrefupdates = false
+    [remote "baz"]
+    	url = http://example.com/repo/baz
+    	fetch = +refs/heads/*:refs/remotes/baz/*
+    [remote "bar"]
+    	url = http://example.com/repo/foo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
 
 #[test]
@@ -273,6 +325,15 @@ fn test_git_remote_named_git() {
     bar http://example.com/repo/repo
     [EOF]
     ");
+    insta::assert_snapshot!(read_git_config(&repo_path), @r#"
+    [core]
+    	repositoryformatversion = 0
+    	bare = false
+    	logallrefupdates = true
+    [remote "bar"]
+    	url = http://example.com/repo/repo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
     // @git bookmark shouldn't be renamed.
     let output = test_env.run_jj_in(&repo_path, ["log", "-rmain@git", "-Tbookmarks"]);
     insta::assert_snapshot!(output, @r"
@@ -297,12 +358,27 @@ fn test_git_remote_named_git() {
     test_env
         .run_jj_in(&repo_path, ["git", "init", "--git-repo=."])
         .success();
+    insta::assert_snapshot!(read_git_config(&repo_path), @r#"
+    [core]
+    	repositoryformatversion = 0
+    	bare = false
+    	logallrefupdates = true
+    [remote "git"]
+    	url = http://example.com/repo/repo
+    	fetch = +refs/heads/*:refs/remotes/git/*
+    "#);
 
     // The remote can also be removed.
     let output = test_env.run_jj_in(&repo_path, ["git", "remote", "remove", "git"]);
     insta::assert_snapshot!(output, @"");
     let output = test_env.run_jj_in(&repo_path, ["git", "remote", "list"]);
     insta::assert_snapshot!(output, @"");
+    insta::assert_snapshot!(read_git_config(&repo_path), @r#"
+    [core]
+    	repositoryformatversion = 0
+    	bare = false
+    	logallrefupdates = true
+    "#);
     // @git bookmark shouldn't be removed.
     let output = test_env.run_jj_in(&repo_path, ["log", "-rmain@git", "-Tbookmarks"]);
     insta::assert_snapshot!(output, @r"
@@ -396,3 +472,45 @@ fn test_git_remote_with_slashes() {
     [EOF]
     ");
 }
+
+#[test]
+fn test_git_remote_with_branch_config() {
+    let test_env = TestEnvironment::default();
+
+    test_env
+        .run_jj_in(test_env.env_root(), &["git", "init", "repo"])
+        .success();
+    let repo_path = test_env.env_root().join("repo");
+
+    let output = test_env.run_jj_in(
+        &repo_path,
+        &["git", "remote", "add", "foo", "http://example.com/repo"],
+    );
+    insta::assert_snapshot!(output, @"");
+
+    let mut config_file = fs::OpenOptions::new()
+        .append(true)
+        .open(repo_path.join(".jj/repo/store/git/config"))
+        .unwrap();
+    // `git clone` adds branch configuration like this.
+    writeln!(config_file, "[branch \"test\"]").unwrap();
+    writeln!(config_file, "\tremote = foo").unwrap();
+    writeln!(config_file, "\tmerge = refs/heads/test").unwrap();
+    drop(config_file);
+
+    let output = test_env.run_jj_in(&repo_path, &["git", "remote", "rename", "foo", "bar"]);
+    insta::assert_snapshot!(output, @"");
+
+    insta::assert_snapshot!(read_git_config(&repo_path), @r#"
+    [core]
+    	repositoryformatversion = 0
+    	bare = true
+    	logallrefupdates = false
+    [branch "test"]
+    	remote = bar
+    	merge = refs/heads/test
+    [remote "bar"]
+    	url = http://example.com/repo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
+}
diff --git a/lib/src/git.rs b/lib/src/git.rs
index 031c0c9641a..72b199b280e 100644
--- a/lib/src/git.rs
+++ b/lib/src/git.rs
@@ -20,12 +20,14 @@ use std::collections::HashMap;
 use std::collections::HashSet;
 use std::default::Default;
 use std::fmt;
+use std::fs::File;
 use std::io::Read;
 use std::num::NonZeroU32;
 use std::path::PathBuf;
 use std::str;
 
 use bstr::BStr;
+use bstr::BString;
 use itertools::Itertools;
 use tempfile::NamedTempFile;
 use thiserror::Error;
@@ -1324,8 +1326,20 @@ pub enum GitRemoteManagementError {
     RemoteAlreadyExists(String),
     #[error(transparent)]
     RemoteName(#[from] GitRemoteNameError),
+    #[error("Git remote named '{0}' has nonstandard configuration")]
+    NonstandardConfiguration(String),
+    #[error("Error saving Git configuration")]
+    GitConfigSaveError(#[source] std::io::Error),
+    #[error("Unexpected Git error when managing remotes")]
+    InternalGitError(#[source] Box<dyn std::error::Error + Send + Sync>),
     #[error(transparent)]
-    InternalGitError(git2::Error),
+    UnexpectedBackend(#[from] UnexpectedGitBackendError),
+}
+
+impl GitRemoteManagementError {
+    fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
+        GitRemoteManagementError::InternalGitError(source.into())
+    }
 }
 
 fn is_remote_not_found_err(err: &git2::Error) -> bool {
@@ -1338,13 +1352,6 @@ fn is_remote_not_found_err(err: &git2::Error) -> bool {
     )
 }
 
-fn is_remote_exists_err(err: &git2::Error) -> bool {
-    matches!(
-        (err.class(), err.code()),
-        (git2::ErrorClass::Config, git2::ErrorCode::Exists)
-    )
-}
-
 /// Determine, by its name, if a remote refers to the special local-only "git"
 /// remote that is used in the Git backend.
 ///
@@ -1353,6 +1360,148 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn git_config_branch_section_ids_by_remote(
+    config: &gix::config::File,
+    remote_name: &str,
+) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
+    config
+        .sections_by_name("branch")
+        .into_iter()
+        .flatten()
+        .filter_map(|section| {
+            let remote_values = section.values("remote");
+            let push_remote_values = section.values("pushRemote");
+            if !remote_values
+                .iter()
+                .chain(push_remote_values.iter())
+                .any(|branch_remote_name| **branch_remote_name == remote_name.as_bytes())
+            {
+                return None;
+            }
+            if remote_values.len() > 1
+                || push_remote_values.len() > 1
+                || section.value_names().any(|name| {
+                    !name.eq_ignore_ascii_case(b"remote") && !name.eq_ignore_ascii_case(b"merge")
+                })
+            {
+                return Some(Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                )));
+            }
+            Some(Ok(section.id()))
+        })
+        .collect()
+}
+
+fn rename_remote_in_git_branch_config_sections(
+    config: &mut gix::config::File,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    for id in git_config_branch_section_ids_by_remote(config, old_remote_name)? {
+        config
+            .section_mut_by_id(id)
+            .expect("found section to exist")
+            .set(
+                "remote"
+                    .try_into()
+                    .expect("'remote' to be a valid value name"),
+                BStr::new(new_remote_name),
+            );
+    }
+    Ok(())
+}
+
+fn remove_remote_git_branch_config_sections(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    for id in git_config_branch_section_ids_by_remote(config, remote_name)? {
+        config
+            .remove_section_by_id(id)
+            .expect("removed section to exist");
+    }
+    Ok(())
+}
+
+fn remove_remote_git_config_sections(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    let section_ids_to_remove = config
+        .sections_by_name("remote")
+        .into_iter()
+        .flatten()
+        .filter(|section| section.header().subsection_name() == Some(BStr::new(remote_name)))
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"url") && !name.eq_ignore_ascii_case(b"fetch")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect::<Result<Vec<_>, _>>()?
+        .into_iter();
+    for id in section_ids_to_remove {
+        config
+            .remove_section_by_id(id)
+            .expect("removed section to exist");
+    }
+    Ok(())
+}
+
 /// Returns a sorted list of configured remote names.
 pub fn get_all_remote_names(store: &Store) -> Result<Vec<String>, UnexpectedGitBackendError> {
     let git_repo = get_git_repo(store)?;
@@ -1367,38 +1516,77 @@ pub fn get_all_remote_names(store: &Store) -> Result<Vec<String>, UnexpectedGitB
     Ok(names)
 }
 
-// TODO(git2): migrate to gitoxide
 pub fn add_remote(
-    git_repo: &git2::Repository,
+    store: &Store,
     remote_name: &str,
     url: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let git_repo = get_git_repo(store)?;
+
     validate_remote_name(remote_name)?;
-    git_repo.remote(remote_name, url).map_err(|err| {
-        if is_remote_exists_err(&err) {
-            GitRemoteManagementError::RemoteAlreadyExists(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+
+    if git_repo.try_find_remote(remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            remote_name.to_owned(),
+        ));
+    }
+
+    let mut remote = git_repo
+        .remote_at(url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
     Ok(())
 }
 
 pub fn remove_remote(
     mut_repo: &mut MutableRepo,
-    git_repo: &git2::Repository,
     remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    git_repo.remote_delete(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
+    if git_repo.try_find_remote(remote_name).is_none() {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+
+    let mut config = git_repo.config_snapshot().clone();
+    remove_remote_git_branch_config_sections(&mut config, remote_name)?;
+    remove_remote_git_config_sections(&mut config, remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    remove_remote_git_refs(&mut git_repo, remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         remove_remote_refs(mut_repo, remote_name);
     }
+
+    Ok(())
+}
+
+fn remove_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(format!("refs/remotes/{remote_name}/"))?
+            .map_ok(remove_ref)
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
     Ok(())
 }
 
@@ -1419,49 +1607,168 @@ fn remove_remote_refs(mut_repo: &mut MutableRepo, remote_name: &str) {
 
 pub fn rename_remote(
     mut_repo: &mut MutableRepo,
-    git_repo: &git2::Repository,
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config_sections(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_git_config_sections(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
     Ok(())
 }
 
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map_ok(|old_ref| {
+                let new_name = BString::new(
+                    [
+                        new_prefix.as_bytes(),
+                        &old_ref.name().as_bstr()[old_prefix.len()..],
+                    ]
+                    .concat(),
+                );
+                [
+                    add_ref(
+                        new_name.try_into().expect("new ref name to be valid"),
+                        old_ref.target().into_owned(),
+                        ref_log_message.clone(),
+                    ),
+                    remove_ref(old_ref),
+                ]
+            })
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
+    Ok(())
+}
+
+/// Set the `url` to be used when fetching data from a remote.
+///
+/// Shim for the missing `gix::Remote::fetch_url` API.
+///
+/// **TODO:** Upstream an implementation of this to `gix`.
+fn gix_remote_with_fetch_url<Url, E>(
+    remote: gix::Remote,
+    url: Url,
+) -> Result<gix::Remote, gix::remote::init::Error>
+where
+    Url: TryInto<gix::Url, Error = E>,
+    gix::url::parse::Error: From<E>,
+{
+    let mut new_remote = remote.repo().remote_at(url)?;
+    // Copy the existing data from `remote`.
+    //
+    // We don’t copy the push URL, as there does not seem to be any way to reliably
+    // detect whether one is present with the current API, and `jj git remote
+    // set-url` refuses to work with them anyway.
+    new_remote = new_remote.with_fetch_tags(remote.fetch_tags());
+    for direction in [gix::remote::Direction::Fetch, gix::remote::Direction::Push] {
+        new_remote
+            .replace_refspecs(
+                remote
+                    .refspecs(direction)
+                    .iter()
+                    .map(|refspec| refspec.to_ref().to_bstring()),
+                direction,
+            )
+            .expect("existing refspecs to be valid");
+    }
+    Ok(new_remote)
+}
+
 pub fn set_remote_url(
-    git_repo: &git2::Repository,
+    store: &Store,
     remote_name: &str,
     new_remote_url: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let git_repo = get_git_repo(store)?;
+
     validate_remote_name(remote_name)?;
 
-    // Repository::remote_set_url() doesn't ensure the remote exists, it just
-    // creates it if it's missing.
-    // Therefore ensure it exists first
-    git_repo.find_remote(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let Some(result) = git_repo.try_find_remote_without_url_rewrite(remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if remote.url(gix::remote::Direction::Push) != remote.url(gix::remote::Direction::Fetch) {
+        return Err(GitRemoteManagementError::NonstandardConfiguration(
+            remote_name.to_owned(),
+        ));
+    }
+
+    remote = gix_remote_with_fetch_url(remote, new_remote_url)
+        .map_err(GitRemoteManagementError::from_git)?;
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
 
-    git_repo
-        .remote_set_url(remote_name, new_remote_url)
-        .map_err(GitRemoteManagementError::InternalGitError)?;
     Ok(())
 }
 
diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs
index d81dc382309..3dfcfd784d0 100644
--- a/lib/tests/test_git.rs
+++ b/lib/tests/test_git.rs
@@ -4131,3 +4131,90 @@ fn test_shallow_commits_lack_parents() {
     // FIXME: new ancestors should be indexed
     assert!(!repo.index().has_id(&jj_id(a)));
 }
+
+#[test]
+fn test_remote_remove_refs() {
+    let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git);
+    let repo = &test_repo.repo;
+
+    git::add_remote(repo.store(), "foo", "https://example.com/").unwrap();
+    // Reload after Git configuration change.
+    let repo = &test_repo
+        .env
+        .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path());
+
+    let git_repo = get_git_repo(repo);
+    empty_git_commit(&git_repo, "refs/remotes/foo/a", &[]);
+    empty_git_commit(&git_repo, "refs/remotes/foo/x/y", &[]);
+    let commit_foobar_a = empty_git_commit(&git_repo, "refs/remotes/foobar/a", &[]);
+
+    let mut tx = repo.start_transaction();
+    git::remove_remote(tx.repo_mut(), "foo").unwrap();
+    let repo = &tx.commit("remove").unwrap();
+
+    let git_repo = get_git_repo(repo);
+    assert!(git_repo
+        .try_find_reference("refs/remotes/foo/a")
+        .unwrap()
+        .is_none());
+    assert!(git_repo
+        .try_find_reference("refs/remotes/foo/x/y")
+        .unwrap()
+        .is_none());
+    assert_eq!(
+        git_repo
+            .find_reference("refs/remotes/foobar/a")
+            .unwrap()
+            .id(),
+        commit_foobar_a,
+    );
+}
+
+#[test]
+fn test_remote_rename_refs() {
+    let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git);
+    let repo = &test_repo.repo;
+
+    git::add_remote(repo.store(), "foo", "https://example.com/").unwrap();
+    // Reload after Git configuration change.
+    let repo = &test_repo
+        .env
+        .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path());
+
+    let git_repo = get_git_repo(repo);
+    let commit_foo_a = empty_git_commit(&git_repo, "refs/remotes/foo/a", &[]);
+    let commit_foo_x_y = empty_git_commit(&git_repo, "refs/remotes/foo/x/y", &[]);
+    let commit_foobar_a = empty_git_commit(&git_repo, "refs/remotes/foobar/a", &[]);
+
+    let mut tx = repo.start_transaction();
+    git::rename_remote(tx.repo_mut(), "foo", "bar").unwrap();
+    let repo = &tx.commit("rename").unwrap();
+
+    let git_repo = get_git_repo(repo);
+    assert!(git_repo
+        .try_find_reference("refs/remotes/foo/a")
+        .unwrap()
+        .is_none());
+    assert!(git_repo
+        .try_find_reference("refs/remotes/foo/x/y")
+        .unwrap()
+        .is_none());
+    assert_eq!(
+        git_repo.find_reference("refs/remotes/bar/a").unwrap().id(),
+        commit_foo_a,
+    );
+    assert_eq!(
+        git_repo
+            .find_reference("refs/remotes/bar/x/y")
+            .unwrap()
+            .id(),
+        commit_foo_x_y,
+    );
+    assert_eq!(
+        git_repo
+            .find_reference("refs/remotes/foobar/a")
+            .unwrap()
+            .id(),
+        commit_foobar_a,
+    );
+}


Reviews:
user: login: bsdinis
body: Thank you so much for this. Amazing effort! 
I'm still working through the patch, will pick it back up tomorrow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-02T03:17:50Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-02T03:21:29Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-02T03:22:19Z
user: login: bsdinis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-02T03:23:50Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-02T03:28:24Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-02T07:33:38Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:22:46Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:23:30Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:24:22Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:36:51Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:38:29Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:38:52Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:40:00Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:42:32Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T01:44:18Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T03:04:51Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T03:07:53Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T03:32:23Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T03:33:17Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-03T03:39:54Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-10T04:29:24Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-10T04:31:40Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-10T04:32:40Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-10T04:34:42Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-10T04:39:38Z
user: login: yuja
body: Looks good, thanks.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-02-11T08:16:18Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-12T20:14:11Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-12T20:14:37Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-12T20:14:52Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-12T20:15:09Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-12T20:15:19Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-13T01:33:16Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-13T02:14:31Z
user: login: bsdinis
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-02-17T17:34:07Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-23T04:21:31Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-23T04:21:36Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-23T07:48:28Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T19:46:43Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T19:48:07Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-08T19:48:11Z
user: login: bsdinis
body: Thank you so much. Left a couple of nits and Qs, but I'd be fine with it being merged as is.
That being said, I think @yuja would have a better understanding here
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T03:53:36Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T04:27:28Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T04:27:51Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T04:28:34Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T04:33:14Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T04:33:32Z
user: login: yuja
body: LG, thanks!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T11:16:13Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T21:05:14Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T21:05:25Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T21:05:34Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T21:05:44Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T21:05:51Z
user: login: emilazy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T21:06:48Z
user: login: yuja
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T01:59:43Z

Review comments:
diff_hunk: @@ -203,13 +203,25 @@ fn to_remote_branch<'a>(parsed_ref: &'a RefName, remote_name: &str) -> Option<&'
     }
 }
 
+/// Returns true if the `parsed_ref`’s remote name is in `remote_names`.
+///
+/// Use this as a `git_ref_filter` to be passed in to
+/// `import_some_refs()`.
+pub fn is_git_remote_ref<'a>(remote_names: &'a [&str]) -> impl Fn(&RefName) -> bool + 'a {
path: lib/src/git.rs
user: login: bsdinis
body: nit: i find this name a bit confusing

something like `remote_ref_filter` would make it more obvious that this generates a filter based on the remote.
not super important
created_at: 2025-02-02T02:59:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: bsdinis
body: nit: the only point i see for getting instantiating the git repo in the cli is to use it directly.
If we are constructing it just to sink it back into a `lib` API, might just be best to send a `store` directly to the API
created_at: 2025-02-02T03:01:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,9 +38,8 @@ pub fn cmd_git_remote_add(
     args: &GitRemoteAddArgs,
 ) -> Result<(), CommandError> {
     let workspace_command = command.workspace_helper(ui)?;
-    let repo = workspace_command.repo();
-    let git_repo = get_git_repo(repo.store())?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
     let url = absolute_git_url(command.cwd(), &args.url)?;
-    git::add_remote(&git_repo, &args.remote, &url)?;
+    git::add_remote(git_repo, &args.remote, &url)?;
path: cli/src/commands/git/remote/add.rs
user: login: bsdinis
body: same here
created_at: 2025-02-02T03:02:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,11 +35,10 @@ pub fn cmd_git_remote_remove(
     args: &GitRemoteRemoveArgs,
 ) -> Result<(), CommandError> {
     let mut workspace_command = command.workspace_helper(ui)?;
-    let repo = workspace_command.repo();
-    let git_repo = get_git_repo(repo.store())?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
     let mut tx = workspace_command.start_transaction();
     let git_settings = tx.settings().git_settings()?;
-    git::remove_remote(tx.repo_mut(), &git_repo, &git_settings, &args.remote)?;
+    git::remove_remote(tx.repo_mut(), git_repo, &git_settings, &args.remote)?;
path: cli/src/commands/git/remote/remove.rs
user: login: bsdinis
body: same here
created_at: 2025-02-02T03:02:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,16 +38,10 @@ pub fn cmd_git_remote_rename(
 ) -> Result<(), CommandError> {
     let mut workspace_command = command.workspace_helper(ui)?;
     let repo = workspace_command.repo();
-    let git_repo = get_git_repo(repo.store())?;
+    let git_repo = git::get_git_repo(repo.store())?;
     let mut tx = workspace_command.start_transaction();
     let git_settings = tx.settings().git_settings()?;
-    git::rename_remote(
-        tx.repo_mut(),
-        &git_repo,
-        &git_settings,
-        &args.old,
-        &args.new,
-    )?;
+    git::rename_remote(tx.repo_mut(), git_repo, &git_settings, &args.old, &args.new)?;
path: cli/src/commands/git/remote/rename.rs
user: login: bsdinis
body: ditto
created_at: 2025-02-02T03:02:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,8 +42,8 @@ pub fn cmd_git_remote_set_url(
 ) -> Result<(), CommandError> {
     let workspace_command = command.workspace_helper(ui)?;
     let repo = workspace_command.repo();
-    let git_repo = get_git_repo(repo.store())?;
+    let git_repo = git::get_git_repo(repo.store())?;
     let url = absolute_git_url(command.cwd(), &args.url)?;
-    git::set_remote_url(&git_repo, &args.remote, &url)?;
+    git::set_remote_url(git_repo, &args.remote, &url)?;
path: cli/src/commands/git/remote/set_url.rs
user: login: bsdinis
body: ditto
created_at: 2025-02-02T03:02:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: emilazy
body: We could just drop the argument entirely and get the Git repository out of the `MutableRepo`. I wasn’t really thinking about the API design here, just matching the previous version, but it seems like a good idea. Will probably make that change tomorrow unless anyone objects.
created_at: 2025-02-02T03:21:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -203,13 +203,25 @@ fn to_remote_branch<'a>(parsed_ref: &'a RefName, remote_name: &str) -> Option<&'
     }
 }
 
+/// Returns true if the `parsed_ref`’s remote name is in `remote_names`.
+///
+/// Use this as a `git_ref_filter` to be passed in to
+/// `import_some_refs()`.
+pub fn is_git_remote_ref<'a>(remote_names: &'a [&str]) -> impl Fn(&RefName) -> bool + 'a {
path: lib/src/git.rs
user: login: emilazy
body: I think it reads naturally in the context of `import_some_refs` calls, but I suppose I might just be too used to curried functions. Happy to adjust this. (We might not need a helper at all, but it seemed nicer than without.)
created_at: 2025-02-02T03:22:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: bsdinis
body: that's even better!
created_at: 2025-02-02T03:23:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: emilazy
body: Actually we’d have to include `UnexpectedGitBackendError` in the error `enum` to handle the case of calling it on a repository with another backend. Maybe it’s better to rule that out in the type like currently? Not sure.
created_at: 2025-02-02T03:28:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1414,7 +1416,12 @@ pub fn rename_remote(
             }
         })?;
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
-        rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
+        mut_repo.rename_remote(old_remote_name, new_remote_name);
+        import_some_refs(
+            mut_repo,
+            git_settings,
+            is_git_remote_ref(&[old_remote_name, new_remote_name]),
+        )?;
path: lib/src/git.rs
user: login: yuja
body: > I am not entirely sure the semantics here are exactly the same,

There are various differences, and some of them can be disabled by config knobs (e.g. `abandon_unreachable_commits = false`, but I'm not sure if we can make `import_some_refs()` fully compatible with rename use case. Renamed remote refs shouldn't revive hidden commits, but `import_some_refs()` would do, etc.

I wouldn't try merging these because `import_some_refs()` is already complex, and renaming/deleting refs should be simple operation.
created_at: 2025-02-02T07:08:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: yuja
body: > we’d have to include UnexpectedGitBackendError in the error enum

That's more consistent with other `git::` API, so I like the idea of passing `repo`/`mut_repo`/`store` instead of `git_repo`.
created_at: 2025-02-02T07:12:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1299,6 +1303,80 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration. Functions using this API should ideally take a
+/// [`gix::Repository`] by value to hint this to the caller.
+pub struct GitConfigEditor {
+    config: gix::config::File<'static>,
+    config_meta: gix::config::file::Metadata,
+    repo_path: PathBuf,
+}
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        let repo_path = git_repo.path().to_owned();
+        let config = git_repo.config_snapshot().clone();
+        let config_meta = Arc::unwrap_or_clone(config.meta_owned());
+        Self {
+            config,
+            config_meta,
+            repo_path,
+        }
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
+        let mut temp_config_file = NamedTempFile::with_prefix_in("config", self.repo_path)?;
+        self.config
+            .write_to_filter(&mut temp_config_file, |section| {
+                section.meta() == &self.config_meta
+            })?;
+        temp_config_file
+            .persist(
+                self.config_meta
+                    .path
+                    .expect("Git repository to have a config file"),
+            )
+            .map_err(|err| err.error)?;
path: lib/src/git.rs
user: login: yuja
body: I'm not sure if we should use atomic rename. If the existing file was a symlink, the link would be replaced with a normal file.
created_at: 2025-02-02T07:18:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1299,6 +1303,80 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration. Functions using this API should ideally take a
+/// [`gix::Repository`] by value to hint this to the caller.
+pub struct GitConfigEditor {
+    config: gix::config::File<'static>,
+    config_meta: gix::config::file::Metadata,
+    repo_path: PathBuf,
+}
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        let repo_path = git_repo.path().to_owned();
+        let config = git_repo.config_snapshot().clone();
+        let config_meta = Arc::unwrap_or_clone(config.meta_owned());
+        Self {
+            config,
+            config_meta,
+            repo_path,
+        }
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
+        let mut temp_config_file = NamedTempFile::with_prefix_in("config", self.repo_path)?;
+        self.config
+            .write_to_filter(&mut temp_config_file, |section| {
+                section.meta() == &self.config_meta
+            })?;
+        temp_config_file
+            .persist(
+                self.config_meta
+                    .path
+                    .expect("Git repository to have a config file"),
+            )
+            .map_err(|err| err.error)?;
+        Ok(())
+    }
+}
+
+impl Deref for GitConfigEditor {
path: lib/src/git.rs
user: login: yuja
body: nit: suppose `GitConfigEditor` is mainly designed for local use, the API ergonomics provided by `Deref` wouldn't be needed?
created_at: 2025-02-02T07:21:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1341,6 +1420,38 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn edit_prefixed_refs<Edits>(
+    git_repo: &mut gix::Repository,
+    prefix: impl AsRef<std::path::Path>,
+    mut editor: impl FnMut(gix::Reference) -> Edits,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
+where
+    Edits: IntoIterator<Item = gix::refs::transaction::RefEdit>,
+{
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(prefix)?
+            .map(|result| result.map(&mut editor))
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
path: lib/src/git.rs
user: login: yuja
body: nit: might be better to add a function that returns `Vec<RefEdit>`? It's probably simpler, and caller can concatenate more `edit`s if needed.

I think it's also good to inline this helper function to caller to make it clear that `.strip_prefix()` with the same prefix will never panic.
created_at: 2025-02-02T07:27:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1423,32 +1616,50 @@ pub fn rename_remote(
             is_git_remote_ref(&[old_remote_name, new_remote_name]),
         )?;
     }
+
     Ok(())
 }
 
 pub fn set_remote_url(
-    git_repo: &git2::Repository,
+    git_repo: gix::Repository,
     remote_name: &str,
     new_remote_url: &str,
 ) -> Result<(), GitRemoteManagementError> {
     if remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
 
-    // Repository::remote_set_url() doesn't ensure the remote exists, it just
-    // creates it if it's missing.
-    // Therefore ensure it exists first
-    git_repo.find_remote(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let Some(result) = git_repo.try_find_remote(remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+    let remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    let mut new_remote = git_repo
+        .remote_at(new_remote_url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_fetch_tags(remote.fetch_tags());
+    for direction in [gix::remote::Direction::Fetch, gix::remote::Direction::Push] {
path: lib/src/git.rs
user: login: yuja
body: Perhaps, the old git2 implementation wouldn't update the "push" URL.
created_at: 2025-02-02T07:30:56Z
author_association: CONTRIBUTOR
path: cli/src/commands/git/clone.rs
user: login: yuja
body: Since new functions aren't trivial wrappers, I think it's better to insert some snapshots of `.git/config` to CLI tests.
created_at: 2025-02-02T07:33:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -203,13 +203,25 @@ fn to_remote_branch<'a>(parsed_ref: &'a RefName, remote_name: &str) -> Option<&'
     }
 }
 
+/// Returns true if the `parsed_ref`’s remote name is in `remote_names`.
+///
+/// Use this as a `git_ref_filter` to be passed in to
+/// `import_some_refs()`.
+pub fn is_git_remote_ref<'a>(remote_names: &'a [&str]) -> impl Fn(&RefName) -> bool + 'a {
path: lib/src/git.rs
user: login: emilazy
body: No longer relevant given the dropped commit.
created_at: 2025-02-03T01:22:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: emilazy
body: The functions now all take `&Arc<ReadonlyRepo>` or `&mut MutableRepo`, though sometimes only to get a `gix::Repository` out of them; let me know if this is what you had in mind.
created_at: 2025-02-03T01:23:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1414,7 +1416,12 @@ pub fn rename_remote(
             }
         })?;
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
-        rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
+        mut_repo.rename_remote(old_remote_name, new_remote_name);
+        import_some_refs(
+            mut_repo,
+            git_settings,
+            is_git_remote_ref(&[old_remote_name, new_remote_name]),
+        )?;
path: lib/src/git.rs
user: login: emilazy
body: Right. I was annoyed at the duplication between the Git reference edits and the corresponding ones in Jujutsu’s mirror of them, but I can’t think of anything that seems to reliably simplify the logic, so I’ve just dropped that commit.
created_at: 2025-02-03T01:24:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: emilazy
body: Maybe it should be `&dyn Repo` for the former?
created_at: 2025-02-03T01:36:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1423,32 +1616,50 @@ pub fn rename_remote(
             is_git_remote_ref(&[old_remote_name, new_remote_name]),
         )?;
     }
+
     Ok(())
 }
 
 pub fn set_remote_url(
-    git_repo: &git2::Repository,
+    git_repo: gix::Repository,
     remote_name: &str,
     new_remote_url: &str,
 ) -> Result<(), GitRemoteManagementError> {
     if remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
 
-    // Repository::remote_set_url() doesn't ensure the remote exists, it just
-    // creates it if it's missing.
-    // Therefore ensure it exists first
-    git_repo.find_remote(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let Some(result) = git_repo.try_find_remote(remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+    let remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    let mut new_remote = git_repo
+        .remote_at(new_remote_url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_fetch_tags(remote.fetch_tags());
+    for direction in [gix::remote::Direction::Fetch, gix::remote::Direction::Push] {
path: lib/src/git.rs
user: login: emilazy
body: This isn’t URLs, it’s refspecs. This is just copying the data from the old remote wholesale (after creating a new one with the right URL), because for some reason the `gix` API doesn’t allow you to set the URL of an existing `Remote`, only create a new one. I assume `libgit2` doesn’t have that limitation and updates the fetch URL in place.
created_at: 2025-02-03T01:38:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1299,6 +1303,80 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration. Functions using this API should ideally take a
+/// [`gix::Repository`] by value to hint this to the caller.
+pub struct GitConfigEditor {
+    config: gix::config::File<'static>,
+    config_meta: gix::config::file::Metadata,
+    repo_path: PathBuf,
+}
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        let repo_path = git_repo.path().to_owned();
+        let config = git_repo.config_snapshot().clone();
+        let config_meta = Arc::unwrap_or_clone(config.meta_owned());
+        Self {
+            config,
+            config_meta,
+            repo_path,
+        }
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
+        let mut temp_config_file = NamedTempFile::with_prefix_in("config", self.repo_path)?;
+        self.config
+            .write_to_filter(&mut temp_config_file, |section| {
+                section.meta() == &self.config_meta
+            })?;
+        temp_config_file
+            .persist(
+                self.config_meta
+                    .path
+                    .expect("Git repository to have a config file"),
+            )
+            .map_err(|err| err.error)?;
path: lib/src/git.rs
user: login: emilazy
body: I’ve moved this to regular `File::create`. Not sure if that’s what we want, or if we should worry about `fsync`, or what.
created_at: 2025-02-03T01:38:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1299,6 +1303,80 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration. Functions using this API should ideally take a
+/// [`gix::Repository`] by value to hint this to the caller.
+pub struct GitConfigEditor {
+    config: gix::config::File<'static>,
+    config_meta: gix::config::file::Metadata,
+    repo_path: PathBuf,
+}
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        let repo_path = git_repo.path().to_owned();
+        let config = git_repo.config_snapshot().clone();
+        let config_meta = Arc::unwrap_or_clone(config.meta_owned());
+        Self {
+            config,
+            config_meta,
+            repo_path,
+        }
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
+        let mut temp_config_file = NamedTempFile::with_prefix_in("config", self.repo_path)?;
+        self.config
+            .write_to_filter(&mut temp_config_file, |section| {
+                section.meta() == &self.config_meta
+            })?;
+        temp_config_file
+            .persist(
+                self.config_meta
+                    .path
+                    .expect("Git repository to have a config file"),
+            )
+            .map_err(|err| err.error)?;
+        Ok(())
+    }
+}
+
+impl Deref for GitConfigEditor {
path: lib/src/git.rs
user: login: emilazy
body: I found that it helped a bit with the functions that were already quite noisy from the error handling. My expectation was that we’d end up having cause to modify the Git configuration more in future and so a nice API would be helpful, but I don’t know how likely that is. I can drop it if it’s unwanted, although now the type is just a wrapper around `gix::config::File` (so it could maybe even be an extension trait instead or something).
created_at: 2025-02-03T01:40:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1341,6 +1420,38 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn edit_prefixed_refs<Edits>(
+    git_repo: &mut gix::Repository,
+    prefix: impl AsRef<std::path::Path>,
+    mut editor: impl FnMut(gix::Reference) -> Edits,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
+where
+    Edits: IntoIterator<Item = gix::refs::transaction::RefEdit>,
+{
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(prefix)?
+            .map(|result| result.map(&mut editor))
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
path: lib/src/git.rs
user: login: emilazy
body: Can you explain what you mean by this? There are two callers of this function and only one of them uses `.strip_prefix()`, but they both have the same structure of “iterate through prefixed references → perform one or more edits (specifically, at least `delete_ref` plus a potential update for a rename) → commit reference edits”. Factoring it out made it a lot nicer (especially with the “iterator of `Result`s” thing `gix` forces on us here).
created_at: 2025-02-03T01:42:32Z
author_association: CONTRIBUTOR
path: cli/src/commands/git/clone.rs
user: login: emilazy
body: I can try to add those; do you mean in e.g. `cli/tests/test_git_remotes.rs`? (Is this related to the changes in `cli/src/commands/git/clone.rs` specifically or just a more general remark?)

FWIW I think it is the refs stuff I am less confident about having gotten completely correct relative to the `libgit2` behaviour; the configuration manipulation was relatively simple by comparison.
created_at: 2025-02-03T01:44:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: yuja
body: Yes, `&dyn Repo` is more common.
created_at: 2025-02-03T03:04:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1423,32 +1616,50 @@ pub fn rename_remote(
             is_git_remote_ref(&[old_remote_name, new_remote_name]),
         )?;
     }
+
     Ok(())
 }
 
 pub fn set_remote_url(
-    git_repo: &git2::Repository,
+    git_repo: gix::Repository,
     remote_name: &str,
     new_remote_url: &str,
 ) -> Result<(), GitRemoteManagementError> {
     if remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
 
-    // Repository::remote_set_url() doesn't ensure the remote exists, it just
-    // creates it if it's missing.
-    // Therefore ensure it exists first
-    git_repo.find_remote(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let Some(result) = git_repo.try_find_remote(remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+    let remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    let mut new_remote = git_repo
+        .remote_at(new_remote_url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_fetch_tags(remote.fetch_tags());
+    for direction in [gix::remote::Direction::Fetch, gix::remote::Direction::Push] {
path: lib/src/git.rs
user: login: yuja
body: Right, I didn't notice that. It might be easier to use the config API, but I'm not sure.
created_at: 2025-02-03T03:07:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1341,6 +1420,38 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn edit_prefixed_refs<Edits>(
+    git_repo: &mut gix::Repository,
+    prefix: impl AsRef<std::path::Path>,
+    mut editor: impl FnMut(gix::Reference) -> Edits,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
+where
+    Edits: IntoIterator<Item = gix::refs::transaction::RefEdit>,
+{
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(prefix)?
+            .map(|result| result.map(&mut editor))
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
path: lib/src/git.rs
user: login: yuja
body: If I were to extract helper function, I would add:
```rust
fn build_prefixed_refs_edits<Edits>(
    git_repo: &gix::Repository,
    prefix: impl AsRef<std::path::Path>,
    mut editor: impl FnMut(gix::Reference) -> Edits,
) -> Result<Vec<gix::refs::transaction::RefEdit>, Box<dyn std::error::Error + Send + Sync>>
where
    Edits: IntoIterator<Item = gix::refs::transaction::RefEdit>,
{
    git_repo
        .references()?
        .prefixed(prefix)?
        .map_ok(&mut editor)
        .flatten_ok()
        .try_collect()
}
```
The reason is that callers can potentially add more `RefEdit`s to the list and issue them all at once.

However, there are only two callers and of them doesn't need `.flatten_ok()`, so I would just duplicate this to callers. In order to map error types to `Box<dyn ..>`, I might extract the inlined code blocks to separate functions or closures (e.g. `rename_remote_git_refs()` and `remove_remote_git_refs()`.)

I might add `copy_ref(..) -> RefEdit` function (like `delete_ref()`) because the construction of `RefEdit` is noisy.
created_at: 2025-02-03T03:32:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1299,6 +1303,80 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration. Functions using this API should ideally take a
+/// [`gix::Repository`] by value to hint this to the caller.
+pub struct GitConfigEditor {
+    config: gix::config::File<'static>,
+    config_meta: gix::config::file::Metadata,
+    repo_path: PathBuf,
+}
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        let repo_path = git_repo.path().to_owned();
+        let config = git_repo.config_snapshot().clone();
+        let config_meta = Arc::unwrap_or_clone(config.meta_owned());
+        Self {
+            config,
+            config_meta,
+            repo_path,
+        }
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
+        let mut temp_config_file = NamedTempFile::with_prefix_in("config", self.repo_path)?;
+        self.config
+            .write_to_filter(&mut temp_config_file, |section| {
+                section.meta() == &self.config_meta
+            })?;
+        temp_config_file
+            .persist(
+                self.config_meta
+                    .path
+                    .expect("Git repository to have a config file"),
+            )
+            .map_err(|err| err.error)?;
+        Ok(())
+    }
+}
+
+impl Deref for GitConfigEditor {
path: lib/src/git.rs
user: login: yuja
body: or a couple of free functions? I don't feel strongly, but I tend to avoid using `Deref` unless the type is like smart pointers. Hopefully there will be a higher-level abstraction in gix in future.
created_at: 2025-02-03T03:33:17Z
author_association: CONTRIBUTOR
path: cli/src/commands/git/clone.rs
user: login: yuja
body: Yes, I meant `test_git_remotes.rs` and to test the refspec updates.

> FWIW I think it is the refs stuff I am less confident about having gotten completely correct relative to the libgit2 behaviour; the configuration manipulation was relatively simple by comparison.

Yeah, it might also be good to add jj-lib tests for that, but I'm not sure what should be tested. I'll need to review libgit2 and/or git implementation.

Another totally different approach is to shell out `git remote` command instead of porting to `gix`.
created_at: 2025-02-03T03:39:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1287,6 +1293,62 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+pub struct GitConfigEditor(gix::config::File<'static>);
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        Self(git_repo.config_snapshot().clone())
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
path: lib/src/git.rs
user: login: bsdinis
body: nit: Should `save` consume `self`?
From a UX standpoint, it might be good to take a `&mut self` to reuse the config editor if multiple changes need to be persisted at different times
created_at: 2025-02-07T21:47:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1287,6 +1293,62 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+pub struct GitConfigEditor(gix::config::File<'static>);
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        Self(git_repo.config_snapshot().clone())
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
path: lib/src/git.rs
user: login: bsdinis
body: But maybe we do want to force users to reload the config?
created_at: 2025-02-07T21:48:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,8 +194,8 @@ fn configure_remote(
     remote_name: &str,
     source: &str,
 ) -> Result<WorkspaceCommandHelper, CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    git::add_remote(&git_repo, remote_name, source)?;
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
path: cli/src/commands/git/clone.rs
user: login: emilazy
body: I’ve actually made them use `&Store` for consistency with `get_all_remote_names`.
created_at: 2025-02-10T04:29:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1299,6 +1303,80 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration. Functions using this API should ideally take a
+/// [`gix::Repository`] by value to hint this to the caller.
+pub struct GitConfigEditor {
+    config: gix::config::File<'static>,
+    config_meta: gix::config::file::Metadata,
+    repo_path: PathBuf,
+}
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        let repo_path = git_repo.path().to_owned();
+        let config = git_repo.config_snapshot().clone();
+        let config_meta = Arc::unwrap_or_clone(config.meta_owned());
+        Self {
+            config,
+            config_meta,
+            repo_path,
+        }
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
+        let mut temp_config_file = NamedTempFile::with_prefix_in("config", self.repo_path)?;
+        self.config
+            .write_to_filter(&mut temp_config_file, |section| {
+                section.meta() == &self.config_meta
+            })?;
+        temp_config_file
+            .persist(
+                self.config_meta
+                    .path
+                    .expect("Git repository to have a config file"),
+            )
+            .map_err(|err| err.error)?;
+        Ok(())
+    }
+}
+
+impl Deref for GitConfigEditor {
path: lib/src/git.rs
user: login: emilazy
body: Yeah, I’ve replaced it with a couple free functions, since it kinda ended up withering to nothing. It used to be more elaborate.
created_at: 2025-02-10T04:31:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1341,6 +1420,38 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn edit_prefixed_refs<Edits>(
+    git_repo: &mut gix::Repository,
+    prefix: impl AsRef<std::path::Path>,
+    mut editor: impl FnMut(gix::Reference) -> Edits,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
+where
+    Edits: IntoIterator<Item = gix::refs::transaction::RefEdit>,
+{
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(prefix)?
+            .map(|result| result.map(&mut editor))
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
path: lib/src/git.rs
user: login: emilazy
body: I’ve inlined this into the callers. It feels a little more bureaucratic to me on the whole (having to name the `dyn` type multiple times isn’t so fun, mostly), but not as bad as I was worried. I abstracted away the `RefEdit` construction since as you said it’s painfully noisy.
created_at: 2025-02-10T04:32:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1423,32 +1616,50 @@ pub fn rename_remote(
             is_git_remote_ref(&[old_remote_name, new_remote_name]),
         )?;
     }
+
     Ok(())
 }
 
 pub fn set_remote_url(
-    git_repo: &git2::Repository,
+    git_repo: gix::Repository,
     remote_name: &str,
     new_remote_url: &str,
 ) -> Result<(), GitRemoteManagementError> {
     if remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
 
-    // Repository::remote_set_url() doesn't ensure the remote exists, it just
-    // creates it if it's missing.
-    // Therefore ensure it exists first
-    git_repo.find_remote(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let Some(result) = git_repo.try_find_remote(remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+    let remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    let mut new_remote = git_repo
+        .remote_at(new_remote_url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_fetch_tags(remote.fetch_tags());
+    for direction in [gix::remote::Direction::Fetch, gix::remote::Direction::Push] {
path: lib/src/git.rs
user: login: emilazy
body: I’ve abstracted this out into a `remote_fetch_url` function to shim the missing `gix` API and added an explanatory comment for the data copying. Since @Byron says that this is just a missing API on the `gix` side, I think doing it this way is nicest, as once it grows the missing API the `set_remote_url` code will remain very simple. `gix` implements a fair amount of logic to handle existing config sections, so I think it’s best to use the higher‐level API when possible, although I don’t know if it’s specifically relevant in this place.
created_at: 2025-02-10T04:34:42Z
author_association: CONTRIBUTOR
path: cli/src/commands/git/clone.rs
user: login: emilazy
body: I’ve added Git config snapshot tests to `cli/tests/test_git_remotes.rs`, and some testing of the ref removals/renames to `lib/tests/test_git.rs`. Let me know what you think!

I think it would be unfortunate to resort to shelling out here. `gix` doesn’t have push functionality, and fetching and pushing have a lot of complex customization points and interactions with the rest of the system. Remotes are a comparatively simple config and ref management job, so I hope we can avoid adding further uses of subprocessing in favour of using and extending `gix`.

@bsdinis Sorry for more tests that will need converting; I followed the convention of the existing tests and helpers in the file :) These should be easy to port over, but if your PR lands first I’m happy to rebase this and do the port of the new ones myself.
created_at: 2025-02-10T04:39:38Z
author_association: CONTRIBUTOR
path: cli/src/commands/git/clone.rs
user: login: bsdinis
body: yeah, no big deal, most of the porting is trivial :-)
in fact, I actually have a almost full (i think one test is missing or sth) port of the remaining test files that is hinging on this PR anyway (and it works!)
created_at: 2025-02-10T07:21:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -204,6 +225,15 @@ fn test_git_remote_rename() {
     bar http://example.com/repo/foo
     baz http://example.com/repo/baz
     "###);
+    let git_config = std::fs::read_to_string(repo_path.join(".jj/repo/store/git/config")).unwrap();
+    insta::assert_snapshot!(git_config[git_config.find("[remote ").unwrap()..], @r#"
+    [remote "baz"]
+    	url = http://example.com/repo/baz
+    	fetch = +refs/heads/*:refs/remotes/baz/*
+    [remote "bar"]
+    	url = http://example.com/repo/foo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
path: cli/tests/test_git_remotes.rs
user: login: yuja
body: Can you also add rename tests with `[branch ".."]` section and custom `fetch` refspecs?

`jj` doesn't use them, but colocated git repo may have configuration for `git` CLI. `libgit2` updates them.

https://github.com/libgit2/libgit2/blob/b042e574064859c01cb902a3f4f418a3706eb78d/src/libgit2/remote.c#L2558
created_at: 2025-02-11T08:04:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1395,53 +1509,157 @@ fn remove_remote_refs(mut_repo: &mut MutableRepo, remote_name: &str) {
 
 pub fn rename_remote(
     mut_repo: &mut MutableRepo,
-    git_repo: &git2::Repository,
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     if new_remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
path: lib/src/git.rs
user: login: yuja
body: I think we should keep the original refspec if it doesn't match the default refspec. There may be negative fetch spec for example.
created_at: 2025-02-11T08:11:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1395,53 +1509,157 @@ fn remove_remote_refs(mut_repo: &mut MutableRepo, remote_name: &str) {
 
 pub fn rename_remote(
     mut_repo: &mut MutableRepo,
-    git_repo: &git2::Repository,
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     if new_remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+    remote
+        .replace_refspecs::<&BStr>([], gix::remote::Direction::Push)
+        .expect("empty refspecs to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    remove_remote_git_config_sections(&mut config, old_remote_name);
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
     Ok(())
 }
 
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let mut new_name = BString::from(new_prefix.as_bytes());
+                    new_name.extend_from_slice(
+                        old_ref
+                            .name()
+                            .as_bstr()
+                            .strip_prefix(old_prefix.as_bytes())
+                            .expect("old ref name to have old prefix"),
path: lib/src/git.rs
user: login: yuja
body: nit: maybe it's okay to just remove the prefix by `[old_prefix.len()..]`?
created_at: 2025-02-11T08:12:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1395,53 +1509,157 @@ fn remove_remote_refs(mut_repo: &mut MutableRepo, remote_name: &str) {
 
 pub fn rename_remote(
     mut_repo: &mut MutableRepo,
-    git_repo: &git2::Repository,
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     if new_remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+    remote
+        .replace_refspecs::<&BStr>([], gix::remote::Direction::Push)
+        .expect("empty refspecs to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    remove_remote_git_config_sections(&mut config, old_remote_name);
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
     Ok(())
 }
 
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let mut new_name = BString::from(new_prefix.as_bytes());
+                    new_name.extend_from_slice(
+                        old_ref
+                            .name()
+                            .as_bstr()
+                            .strip_prefix(old_prefix.as_bytes())
+                            .expect("old ref name to have old prefix"),
+                    );
+                    [
+                        add_ref(
+                            new_name.try_into().expect("new ref name to be valid"),
+                            old_ref.target().into_owned(),
+                            ref_log_message.clone(),
+                        ),
+                        remove_ref(old_ref),
+                    ]
+                })
+            })
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
+    Ok(())
+}
+
+/// Set the `url` to be used when fetching data from a remote.
+///
+/// Shim for the missing `gix::Remote::fetch_url` API.
+///
+/// **TODO:** Upstream an implementation of this to `gix`.
+fn remote_fetch_url<Url, E>(
path: lib/src/git.rs
user: login: yuja
body: nit: rename to `remote_with_fetch_url`? `remote_fetch_url` sounds like returning a URL.
created_at: 2025-02-11T08:13:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3954,3 +3957,78 @@ fn test_shallow_commits_lack_parents() {
     // FIXME: new ancestors should be indexed
     assert!(!repo.index().has_id(&jj_id(&a)));
 }
+
+#[test]
+fn test_remote_remove_refs() {
+    let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git);
+    let repo = &test_repo.repo;
+
+    add_remote(repo.store(), "foo", "https://example.com/").unwrap();
+    // Reload after Git configuration change.
+    let repo = &test_repo
+        .env
+        .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path());
+
+    let git_repo = get_git_repo(repo);
+    empty_git_commit(&git_repo, "refs/remotes/foo/a", &[]);
+    empty_git_commit(&git_repo, "refs/remotes/foo/b", &[]);
+
path: lib/tests/test_git.rs
user: login: yuja
body: I think we should add more refs to test edge case behavior. e.g. `refs/remotes/foobar/a`, `refs/remotes/foo/x/y`, etc.

(also applies to rename test)
created_at: 2025-02-11T08:15:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1395,53 +1509,157 @@ fn remove_remote_refs(mut_repo: &mut MutableRepo, remote_name: &str) {
 
 pub fn rename_remote(
     mut_repo: &mut MutableRepo,
-    git_repo: &git2::Repository,
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     if new_remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+    remote
+        .replace_refspecs::<&BStr>([], gix::remote::Direction::Push)
+        .expect("empty refspecs to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    remove_remote_git_config_sections(&mut config, old_remote_name);
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
     Ok(())
 }
 
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let mut new_name = BString::from(new_prefix.as_bytes());
+                    new_name.extend_from_slice(
+                        old_ref
+                            .name()
+                            .as_bstr()
+                            .strip_prefix(old_prefix.as_bytes())
+                            .expect("old ref name to have old prefix"),
+                    );
+                    [
+                        add_ref(
+                            new_name.try_into().expect("new ref name to be valid"),
+                            old_ref.target().into_owned(),
+                            ref_log_message.clone(),
+                        ),
+                        remove_ref(old_ref),
+                    ]
+                })
+            })
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
+    Ok(())
+}
+
+/// Set the `url` to be used when fetching data from a remote.
+///
+/// Shim for the missing `gix::Remote::fetch_url` API.
+///
+/// **TODO:** Upstream an implementation of this to `gix`.
+fn remote_fetch_url<Url, E>(
path: lib/src/git.rs
user: login: emilazy
body: Done.
created_at: 2025-02-12T20:14:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3954,3 +3957,78 @@ fn test_shallow_commits_lack_parents() {
     // FIXME: new ancestors should be indexed
     assert!(!repo.index().has_id(&jj_id(&a)));
 }
+
+#[test]
+fn test_remote_remove_refs() {
+    let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git);
+    let repo = &test_repo.repo;
+
+    add_remote(repo.store(), "foo", "https://example.com/").unwrap();
+    // Reload after Git configuration change.
+    let repo = &test_repo
+        .env
+        .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path());
+
+    let git_repo = get_git_repo(repo);
+    empty_git_commit(&git_repo, "refs/remotes/foo/a", &[]);
+    empty_git_commit(&git_repo, "refs/remotes/foo/b", &[]);
+
path: lib/tests/test_git.rs
user: login: emilazy
body: Done, and split the commit adding the tests so that we can see what changes in the `.git/config` output too.
created_at: 2025-02-12T20:14:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1395,53 +1509,157 @@ fn remove_remote_refs(mut_repo: &mut MutableRepo, remote_name: &str) {
 
 pub fn rename_remote(
     mut_repo: &mut MutableRepo,
-    git_repo: &git2::Repository,
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     if new_remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+    remote
+        .replace_refspecs::<&BStr>([], gix::remote::Direction::Push)
+        .expect("empty refspecs to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    remove_remote_git_config_sections(&mut config, old_remote_name);
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
     Ok(())
 }
 
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let mut new_name = BString::from(new_prefix.as_bytes());
+                    new_name.extend_from_slice(
+                        old_ref
+                            .name()
+                            .as_bstr()
+                            .strip_prefix(old_prefix.as_bytes())
+                            .expect("old ref name to have old prefix"),
path: lib/src/git.rs
user: login: emilazy
body: Done.
created_at: 2025-02-12T20:14:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -204,6 +225,15 @@ fn test_git_remote_rename() {
     bar http://example.com/repo/foo
     baz http://example.com/repo/baz
     "###);
+    let git_config = std::fs::read_to_string(repo_path.join(".jj/repo/store/git/config")).unwrap();
+    insta::assert_snapshot!(git_config[git_config.find("[remote ").unwrap()..], @r#"
+    [remote "baz"]
+    	url = http://example.com/repo/baz
+    	fetch = +refs/heads/*:refs/remotes/baz/*
+    [remote "bar"]
+    	url = http://example.com/repo/foo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
path: cli/tests/test_git_remotes.rs
user: login: emilazy
body: I read the `libgit2` code and did some tests. My findings:

1. `jj git remote rename …` crashes with a `libgit2` error if negative refspecs are used.
2. `jj git remote rename …` crashes with a `libgit2` error if there is more than one fetch refspec – I’m not sure why, as the implementation seems like it is meant to handle multiple.
3. `libgit2` doesn’t update push refspecs under any circumstances, and only updates fetch refspecs if they’re a byte‐for‐byte match for the default.

The `remote_rename` API we were using before returns refspecs that don’t match the default for the caller to handle; we’ve been ignoring its return value.

So the only change we could make here to improve compatibility with the previous implementation would be to preserve non‐default fetch refspecs and leave push refspecs untouched, which would make the ref renaming somewhat weird. Do you want me to do that, or just add a test to show that the behaviour has changed?

I’m not sure it’s something we should really try to support in general, especially since the existing `libgit2` behaviour is weird and limited anyway. People with really complicated Git remote configuration should probably be using `git(1)` directly (which I’m guessing behaves differently to – probably better than – our current `libgit2` behaviour).
created_at: 2025-02-12T20:15:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1395,53 +1509,157 @@ fn remove_remote_refs(mut_repo: &mut MutableRepo, remote_name: &str) {
 
 pub fn rename_remote(
     mut_repo: &mut MutableRepo,
-    git_repo: &git2::Repository,
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     if new_remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         return Err(GitRemoteManagementError::RemoteReservedForLocalGitRepo);
     }
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
path: lib/src/git.rs
user: login: emilazy
body: See https://github.com/jj-vcs/jj/pull/5553#discussion_r1953335348.
created_at: 2025-02-12T20:15:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -204,6 +225,15 @@ fn test_git_remote_rename() {
     bar http://example.com/repo/foo
     baz http://example.com/repo/baz
     "###);
+    let git_config = std::fs::read_to_string(repo_path.join(".jj/repo/store/git/config")).unwrap();
+    insta::assert_snapshot!(git_config[git_config.find("[remote ").unwrap()..], @r#"
+    [remote "baz"]
+    	url = http://example.com/repo/baz
+    	fetch = +refs/heads/*:refs/remotes/baz/*
+    [remote "bar"]
+    	url = http://example.com/repo/foo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
path: cli/tests/test_git_remotes.rs
user: login: emilazy
body: Sorry, I missed that this was about `branch.*` rather than `remote.*`. I’ll take a look at those. I think what I said still stands about weird refspecs in `remote.*`, though.
created_at: 2025-02-13T01:33:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -204,6 +225,15 @@ fn test_git_remote_rename() {
     bar http://example.com/repo/foo
     baz http://example.com/repo/baz
     "###);
+    let git_config = std::fs::read_to_string(repo_path.join(".jj/repo/store/git/config")).unwrap();
+    insta::assert_snapshot!(git_config[git_config.find("[remote ").unwrap()..], @r#"
+    [remote "baz"]
+    	url = http://example.com/repo/baz
+    	fetch = +refs/heads/*:refs/remotes/baz/*
+    [remote "bar"]
+    	url = http://example.com/repo/foo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
path: cli/tests/test_git_remotes.rs
user: login: yuja
body: > So the only change we could make here to improve compatibility with the previous implementation would be to preserve non‐default fetch refspecs and leave push refspecs untouched, which would make the ref renaming somewhat weird. Do you want me to do that, or just add a test to show that the behaviour has changed?

It's probably better to improve compatibility with `git` CLI, not with `libgit2`.

I know `libgit2` doesn't parse negative refspecs and errors out. It wouldn't silently remove unsupported refspecs, though.
created_at: 2025-02-13T02:14:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -204,6 +225,15 @@ fn test_git_remote_rename() {
     bar http://example.com/repo/foo
     baz http://example.com/repo/baz
     "###);
+    let git_config = std::fs::read_to_string(repo_path.join(".jj/repo/store/git/config")).unwrap();
+    insta::assert_snapshot!(git_config[git_config.find("[remote ").unwrap()..], @r#"
+    [remote "baz"]
+    	url = http://example.com/repo/baz
+    	fetch = +refs/heads/*:refs/remotes/baz/*
+    [remote "bar"]
+    	url = http://example.com/repo/foo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
path: cli/tests/test_git_remotes.rs
user: login: emilazy
body: After thinking about it some more, I’ve changed the PR to detect when it would potentially clobber non‐standard configuration and error out instead; it shouldn’t silently throw away odd configuration any more. I think this is preferable to adding more logic to handle Git configuration that Jujutsu isn’t really compatible with anyway. People who have complex non‐standard Git remote configuration for use with a coloated repository should prefer to use `git remote` directly. Maybe it’d be good for us to support some of it in future (e.g. `pushurl`), but it seems out of scope for this PR. Let me know what you think and if you want me to test the failures cases here.
created_at: 2025-02-23T04:21:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1287,6 +1293,62 @@ fn build_index_from_merged_tree(
     Ok(index)
 }
 
+/// Convenience wrapper to edit Git configuration files.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+pub struct GitConfigEditor(gix::config::File<'static>);
+
+impl GitConfigEditor {
+    pub fn new(git_repo: &gix::Repository) -> Self {
+        Self(git_repo.config_snapshot().clone())
+    }
+
+    pub fn remove_remote_sections(&mut self, remote_name: &str) {
+        let Some(iter) = self.sections_and_ids_by_name("remote") else {
+            return;
+        };
+        // Note: This removes entire sections rather than just the basic Git options, so
+        // this can clobber user data. Ideally `gix` would expose the more complex logic
+        // it uses for changing existing remotes.
+        let section_ids_to_remove: Vec<_> = iter
+            .filter(|(section, _)| {
+                section.header().subsection_name() == Some(BStr::new(remote_name))
+            })
+            .map(|(_, id)| id)
+            .collect();
+        for id in section_ids_to_remove {
+            self.remove_section_by_id(id)
+                .expect("removed section to exist");
+        }
+    }
+
+    pub fn save(self) -> std::io::Result<()> {
path: lib/src/git.rs
user: login: emilazy
body: (No longer relevant since the API was changed.)
created_at: 2025-02-23T04:21:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,10 +13,18 @@
 // limitations under the License.
 
 use std::fs;
+use std::path::Path;
 use std::path::PathBuf;
 
 use crate::common::TestEnvironment;
 
+fn read_git_remote_config(repo_path: &Path) -> String {
+    let git_config = fs::read_to_string(repo_path.join(".jj/repo/store/git/config"))
+        .or_else(|_| fs::read_to_string(repo_path.join(".git/config")))
+        .unwrap();
+    git_config[git_config.find("[remote ").unwrap()..].to_owned()
path: cli/tests/test_git_remotes.rs
user: login: yuja
body: nit: I think it's better to mask out platform-dependent values explicitly instead of skipping until `[remote `. It'll ensure that non-remote tables are retained.

BTW, thanks for splitting the first two patches. It's pleasant to review this PR.
created_at: 2025-02-23T07:38:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1331,6 +1336,101 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn remove_remote_git_config_sections(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    if config
+        .strings_by("branch", None, "remote")
path: lib/src/git.rs
user: login: yuja
body: This appears not working. Maybe `None` doesn't mean "any sub section", but "no subsection"?
created_at: 2025-02-23T07:40:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -204,6 +225,15 @@ fn test_git_remote_rename() {
     bar http://example.com/repo/foo
     baz http://example.com/repo/baz
     "###);
+    let git_config = std::fs::read_to_string(repo_path.join(".jj/repo/store/git/config")).unwrap();
+    insta::assert_snapshot!(git_config[git_config.find("[remote ").unwrap()..], @r#"
+    [remote "baz"]
+    	url = http://example.com/repo/baz
+    	fetch = +refs/heads/*:refs/remotes/baz/*
+    [remote "bar"]
+    	url = http://example.com/repo/foo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
path: cli/tests/test_git_remotes.rs
user: login: yuja
body: > After thinking about it some more, I’ve changed the PR to detect when it would potentially clobber non‐standard configuration and error out instead; it shouldn’t silently throw away odd configuration any more. I think this is preferable to adding more logic to handle Git configuration that Jujutsu isn’t really compatible with anyway.

I agree that non-standard refspecs can be an error. However, upstream branches can be set by `git clone` and other GUI tooling. It would be annoying if `jj git remote` can't rename/delete remote because of the existence of `[branch ]`s.
created_at: 2025-02-23T07:48:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -204,6 +225,15 @@ fn test_git_remote_rename() {
     bar http://example.com/repo/foo
     baz http://example.com/repo/baz
     "###);
+    let git_config = std::fs::read_to_string(repo_path.join(".jj/repo/store/git/config")).unwrap();
+    insta::assert_snapshot!(git_config[git_config.find("[remote ").unwrap()..], @r#"
+    [remote "baz"]
+    	url = http://example.com/repo/baz
+    	fetch = +refs/heads/*:refs/remotes/baz/*
+    [remote "bar"]
+    	url = http://example.com/repo/foo
+    	fetch = +refs/heads/*:refs/remotes/bar/*
+    "#);
 }
path: cli/tests/test_git_remotes.rs
user: login: emilazy
body: This now handles basic branch configuration. I think the state after this PR is still weird (bookmark renames break branch config, remote renames don’t), and I’m not a huge fan of the extra complexity, but it at least keeps us working with `git clone`‐generated configs in the same way as before the PR and rejects anything more elaborate than that. Let me know what you think.

We also don’t have tests to ensure that non‐standard configurations get rejected. I’m not sure if it’s worth it and I’m quite eager to finally get this PR landed, but I can add them if you’d like.
created_at: 2025-03-08T19:46:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,10 +13,18 @@
 // limitations under the License.
 
 use std::fs;
+use std::path::Path;
 use std::path::PathBuf;
 
 use crate::common::TestEnvironment;
 
+fn read_git_remote_config(repo_path: &Path) -> String {
+    let git_config = fs::read_to_string(repo_path.join(".jj/repo/store/git/config"))
+        .or_else(|_| fs::read_to_string(repo_path.join(".git/config")))
+        .unwrap();
+    git_config[git_config.find("[remote ").unwrap()..].to_owned()
path: cli/tests/test_git_remotes.rs
user: login: emilazy
body: Done.

And thank you! I realized it’d be good to see the diff from the `git2` → `gix` switch in the commits :)
created_at: 2025-03-08T19:48:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1331,6 +1336,101 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn remove_remote_git_config_sections(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    if config
+        .strings_by("branch", None, "remote")
path: lib/src/git.rs
user: login: emilazy
body: Right. Fixed.
created_at: 2025-03-08T19:48:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1355,6 +1360,135 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn git_config_branch_section_ids_by_remote(
+    config: &gix::config::File,
+    remote_name: &str,
+) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
+    config
+        .sections_by_name("branch")
+        .into_iter()
+        .flatten()
+        .filter(|section| {
+            let remote_values = section.values("remote").into_iter();
+            let push_remote_values = section.values("pushRemote").into_iter();
+            remote_values
+                .chain(push_remote_values)
+                .any(|branch_remote_name| *branch_remote_name == remote_name.as_bytes())
+        })
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"remote") && !name.eq_ignore_ascii_case(b"merge")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect()
+}
+
+fn rename_remote_in_git_branch_config(
+    config: &mut gix::config::File,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    for id in git_config_branch_section_ids_by_remote(config, old_remote_name)? {
+        config
+            .section_mut_by_id(id)
+            .expect("found section to exist")
+            .set(
+                "remote"
+                    .try_into()
+                    .expect("'remote' to be a valid value name"),
+                BStr::new(new_remote_name),
+            );
+    }
+    Ok(())
+}
+
+fn remove_remote_from_git_config(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    let section_ids_to_remove = config
+        .sections_by_name("remote")
+        .into_iter()
+        .flatten()
+        .filter(|section| section.header().subsection_name() == Some(BStr::new(remote_name)))
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"url") && !name.eq_ignore_ascii_case(b"fetch")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect::<Result<Vec<_>, _>>()?
+        .into_iter()
+        .chain(git_config_branch_section_ids_by_remote(
+            config,
+            remote_name,
+        )?);
+    for id in section_ids_to_remove {
+        config
+            .remove_section_by_id(id)
+            .expect("removed section to exist");
+    }
+    Ok(())
+}
+
path: lib/src/git.rs
user: login: bsdinis
body: nit: I wonder if these would be better off in a `lib/git_gitoxide.rs` or `lib/gitoxide.rs` file, since they are pretty much porcelain around `gix`. 

Moreover, I've found that `gitoxide` has a weird persistency model (it's very much unclear to me when it syncs to disk, or at least it is non-trivial). Maybe some libtests for these functions would be in order?
created_at: 2025-03-09T03:39:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1324,12 +1326,22 @@ pub enum GitRemoteManagementError {
     RemoteAlreadyExists(String),
     #[error(transparent)]
     RemoteName(#[from] GitRemoteNameError),
-    #[error(transparent)]
-    InternalGitError(git2::Error),
+    #[error("Git remote named '{0}' has nonstandard configuration")]
+    NonstandardConfiguration(String),
+    #[error("Error saving Git configuration")]
+    GitConfigSaveError(#[source] std::io::Error),
+    #[error("Unexpected Git error when managing remotes")]
+    InternalGitError(#[source] Box<dyn std::error::Error + Send + Sync>),
     #[error(transparent)]
     UnexpectedBackend(#[from] UnexpectedGitBackendError),
 }
 
+impl GitRemoteManagementError {
+    fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
path: lib/src/git.rs
user: login: bsdinis
body: nit: `from_gix` sounds better to me, since, AFAICT this is mean to be used to box `gix` errors
created_at: 2025-03-09T03:42:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1369,43 +1503,76 @@ pub fn get_all_remote_names(store: &Store) -> Result<Vec<String>, UnexpectedGitB
     Ok(names)
 }
 
-// TODO(git2): migrate to gitoxide
 pub fn add_remote(
     store: &Store,
     remote_name: &str,
     url: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(store)?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let git_repo = get_git_repo(store)?;
+
     validate_remote_name(remote_name)?;
-    git_repo.remote(remote_name, url).map_err(|err| {
-        if is_remote_exists_err(&err) {
-            GitRemoteManagementError::RemoteAlreadyExists(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+
+    if git_repo.try_find_remote(remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            remote_name.to_owned(),
+        ));
+    }
+
+    let mut remote = git_repo
+        .remote_at(url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
     Ok(())
 }
 
 pub fn remove_remote(
     mut_repo: &mut MutableRepo,
     remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
-    git_repo.remote_delete(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
+    if git_repo.try_find_remote(remote_name).is_none() {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+
+    let mut config = git_repo.config_snapshot().clone();
+    remove_remote_from_git_config(&mut config, remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    remove_remote_git_refs(&mut git_repo, remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
path: lib/src/git.rs
user: login: bsdinis
body: nit: remove the refs before saving the config? this would make the change more atomic-y
created_at: 2025-03-09T03:44:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
path: lib/src/git.rs
user: login: bsdinis
body: nit: same thing here (rename refs before saving the config?)
created_at: 2025-03-09T03:46:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
+    Ok(())
+}
+
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let new_name = BString::new(
+                        [
+                            new_prefix.as_bytes(),
+                            &old_ref.name().as_bstr()[old_prefix.len()..],
+                        ]
+                        .concat(),
+                    );
+                    [
+                        add_ref(
+                            new_name.try_into().expect("new ref name to be valid"),
+                            old_ref.target().into_owned(),
+                            ref_log_message.clone(),
+                        ),
+                        remove_ref(old_ref),
+                    ]
+                })
+            })
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
     Ok(())
 }
 
+/// Set the `url` to be used when fetching data from a remote.
+///
+/// Shim for the missing `gix::Remote::fetch_url` API.
+///
+/// **TODO:** Upstream an implementation of this to `gix`.
path: lib/src/git.rs
user: login: bsdinis
body: super nit (very ignorable): maybe the TODO shouldn't be a doc-comment? (also, would remove the bold markdown, in keeping with the standard in the codebase)
created_at: 2025-03-09T03:48:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
+    Ok(())
+}
+
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let new_name = BString::new(
+                        [
+                            new_prefix.as_bytes(),
+                            &old_ref.name().as_bstr()[old_prefix.len()..],
+                        ]
+                        .concat(),
+                    );
+                    [
+                        add_ref(
+                            new_name.try_into().expect("new ref name to be valid"),
+                            old_ref.target().into_owned(),
+                            ref_log_message.clone(),
+                        ),
+                        remove_ref(old_ref),
+                    ]
+                })
+            })
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
     Ok(())
 }
 
+/// Set the `url` to be used when fetching data from a remote.
+///
+/// Shim for the missing `gix::Remote::fetch_url` API.
+///
+/// **TODO:** Upstream an implementation of this to `gix`.
+fn gix_remote_with_fetch_url<Url, E>(
+    remote: gix::Remote,
+    url: Url,
+) -> Result<gix::Remote, gix::remote::init::Error>
+where
+    Url: TryInto<gix::Url, Error = E>,
+    gix::url::parse::Error: From<E>,
+{
+    let mut new_remote = remote.repo().remote_at(url)?;
+    // Copy the existing data from `remote`.
+    //
+    // We don’t copy the push URL, as there does not seem to be any way to reliably
+    // detect whether one is present with the current API, and `jj git remote
+    // set-url` refuses to work with them anyway.
+    new_remote = new_remote.with_fetch_tags(remote.fetch_tags());
+    for direction in [gix::remote::Direction::Fetch, gix::remote::Direction::Push] {
+        new_remote
+            .replace_refspecs(
+                remote
+                    .refspecs(direction)
+                    .iter()
+                    .map(|refspec| refspec.to_ref().to_bstring()),
+                direction,
+            )
+            .expect("existing refspecs to be valid");
+    }
+    Ok(new_remote)
+}
+
 pub fn set_remote_url(
     store: &Store,
     remote_name: &str,
     new_remote_url: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(store)?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let git_repo = get_git_repo(store)?;
 
     validate_remote_name(remote_name)?;
 
-    // Repository::remote_set_url() doesn't ensure the remote exists, it just
-    // creates it if it's missing.
-    // Therefore ensure it exists first
-    git_repo.find_remote(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let Some(result) = git_repo.try_find_remote_without_url_rewrite(remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if remote.url(gix::remote::Direction::Push) != remote.url(gix::remote::Direction::Fetch) {
+        return Err(GitRemoteManagementError::NonstandardConfiguration(
+            remote_name.to_owned(),
+        ));
+    }
path: lib/src/git.rs
user: login: bsdinis
body: Q: why is this a big problem for us? I feel like this would be a semi-normal setup? (e.g., having the `fetch` from an upstream where you don't have write perms and the `push` to a fork, which you don't pull from but just exists to open PR's into the upstream? I don't personally use this but could see it happen)
created_at: 2025-03-09T03:50:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
+    Ok(())
+}
+
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let new_name = BString::new(
+                        [
+                            new_prefix.as_bytes(),
+                            &old_ref.name().as_bstr()[old_prefix.len()..],
+                        ]
+                        .concat(),
+                    );
+                    [
+                        add_ref(
+                            new_name.try_into().expect("new ref name to be valid"),
+                            old_ref.target().into_owned(),
+                            ref_log_message.clone(),
+                        ),
+                        remove_ref(old_ref),
+                    ]
+                })
+            })
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
     Ok(())
 }
 
+/// Set the `url` to be used when fetching data from a remote.
+///
+/// Shim for the missing `gix::Remote::fetch_url` API.
+///
+/// **TODO:** Upstream an implementation of this to `gix`.
+fn gix_remote_with_fetch_url<Url, E>(
+    remote: gix::Remote,
+    url: Url,
+) -> Result<gix::Remote, gix::remote::init::Error>
+where
+    Url: TryInto<gix::Url, Error = E>,
+    gix::url::parse::Error: From<E>,
+{
+    let mut new_remote = remote.repo().remote_at(url)?;
+    // Copy the existing data from `remote`.
+    //
+    // We don’t copy the push URL, as there does not seem to be any way to reliably
+    // detect whether one is present with the current API, and `jj git remote
+    // set-url` refuses to work with them anyway.
+    new_remote = new_remote.with_fetch_tags(remote.fetch_tags());
+    for direction in [gix::remote::Direction::Fetch, gix::remote::Direction::Push] {
+        new_remote
+            .replace_refspecs(
+                remote
+                    .refspecs(direction)
+                    .iter()
+                    .map(|refspec| refspec.to_ref().to_bstring()),
+                direction,
+            )
+            .expect("existing refspecs to be valid");
+    }
+    Ok(new_remote)
+}
+
 pub fn set_remote_url(
     store: &Store,
     remote_name: &str,
     new_remote_url: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(store)?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let git_repo = get_git_repo(store)?;
 
     validate_remote_name(remote_name)?;
 
-    // Repository::remote_set_url() doesn't ensure the remote exists, it just
-    // creates it if it's missing.
-    // Therefore ensure it exists first
-    git_repo.find_remote(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let Some(result) = git_repo.try_find_remote_without_url_rewrite(remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if remote.url(gix::remote::Direction::Push) != remote.url(gix::remote::Direction::Fetch) {
+        return Err(GitRemoteManagementError::NonstandardConfiguration(
+            remote_name.to_owned(),
+        ));
+    }
path: lib/src/git.rs
user: login: bsdinis
body: maybe it's because gitoxide or someone else doesn't support it?
created_at: 2025-03-09T03:50:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1355,6 +1360,135 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn git_config_branch_section_ids_by_remote(
+    config: &gix::config::File,
+    remote_name: &str,
+) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
+    config
+        .sections_by_name("branch")
+        .into_iter()
+        .flatten()
+        .filter(|section| {
+            let remote_values = section.values("remote").into_iter();
+            let push_remote_values = section.values("pushRemote").into_iter();
+            remote_values
+                .chain(push_remote_values)
+                .any(|branch_remote_name| *branch_remote_name == remote_name.as_bytes())
+        })
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"remote") && !name.eq_ignore_ascii_case(b"merge")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect()
+}
+
+fn rename_remote_in_git_branch_config(
+    config: &mut gix::config::File,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    for id in git_config_branch_section_ids_by_remote(config, old_remote_name)? {
+        config
+            .section_mut_by_id(id)
+            .expect("found section to exist")
+            .set(
+                "remote"
+                    .try_into()
+                    .expect("'remote' to be a valid value name"),
+                BStr::new(new_remote_name),
+            );
+    }
+    Ok(())
+}
+
+fn remove_remote_from_git_config(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    let section_ids_to_remove = config
+        .sections_by_name("remote")
+        .into_iter()
+        .flatten()
+        .filter(|section| section.header().subsection_name() == Some(BStr::new(remote_name)))
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"url") && !name.eq_ignore_ascii_case(b"fetch")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect::<Result<Vec<_>, _>>()?
+        .into_iter()
+        .chain(git_config_branch_section_ids_by_remote(
+            config,
+            remote_name,
+        )?);
+    for id in section_ids_to_remove {
+        config
+            .remove_section_by_id(id)
+            .expect("removed section to exist");
+    }
+    Ok(())
+}
+
path: lib/src/git.rs
user: login: emilazy
body: I don‘t mind splitting these up if desired, but this was already a big file with a lot of helper code even before this PR.

`.snapshot()` (rather than `.snapshot_mut()`) is explicitly documented to take an immutable config snapshot and `gix::config::File` has explicit persistence methods, so I‘m not sure what the ambiguity is here or what there is to test? But I might be misunderstanding what you mean.
created_at: 2025-03-09T04:27:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1324,12 +1326,22 @@ pub enum GitRemoteManagementError {
     RemoteAlreadyExists(String),
     #[error(transparent)]
     RemoteName(#[from] GitRemoteNameError),
-    #[error(transparent)]
-    InternalGitError(git2::Error),
+    #[error("Git remote named '{0}' has nonstandard configuration")]
+    NonstandardConfiguration(String),
+    #[error("Error saving Git configuration")]
+    GitConfigSaveError(#[source] std::io::Error),
+    #[error("Unexpected Git error when managing remotes")]
+    InternalGitError(#[source] Box<dyn std::error::Error + Send + Sync>),
     #[error(transparent)]
     UnexpectedBackend(#[from] UnexpectedGitBackendError),
 }
 
+impl GitRemoteManagementError {
+    fn from_git(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
path: lib/src/git.rs
user: login: emilazy
body: This matches the existing naming convention for all the error types that already embed `gix` errors.
created_at: 2025-03-09T04:27:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1369,43 +1503,76 @@ pub fn get_all_remote_names(store: &Store) -> Result<Vec<String>, UnexpectedGitB
     Ok(names)
 }
 
-// TODO(git2): migrate to gitoxide
 pub fn add_remote(
     store: &Store,
     remote_name: &str,
     url: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(store)?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let git_repo = get_git_repo(store)?;
+
     validate_remote_name(remote_name)?;
-    git_repo.remote(remote_name, url).map_err(|err| {
-        if is_remote_exists_err(&err) {
-            GitRemoteManagementError::RemoteAlreadyExists(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+
+    if git_repo.try_find_remote(remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            remote_name.to_owned(),
+        ));
+    }
+
+    let mut remote = git_repo
+        .remote_at(url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
     Ok(())
 }
 
 pub fn remove_remote(
     mut_repo: &mut MutableRepo,
     remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
-    git_repo.remote_delete(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
+    if git_repo.try_find_remote(remote_name).is_none() {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+
+    let mut config = git_repo.config_snapshot().clone();
+    remove_remote_from_git_config(&mut config, remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    remove_remote_git_refs(&mut git_repo, remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
path: lib/src/git.rs
user: login: emilazy
body: But then it could successfully remove the refs but fail to persist the configuration, right? IIRC this is the order `libgit2` does it in. If there‘s an advantage to doing it the other way I could switch this.
created_at: 2025-03-09T04:28:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
+    Ok(())
+}
+
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let new_name = BString::new(
+                        [
+                            new_prefix.as_bytes(),
+                            &old_ref.name().as_bstr()[old_prefix.len()..],
+                        ]
+                        .concat(),
+                    );
+                    [
+                        add_ref(
+                            new_name.try_into().expect("new ref name to be valid"),
+                            old_ref.target().into_owned(),
+                            ref_log_message.clone(),
+                        ),
+                        remove_ref(old_ref),
+                    ]
+                })
+            })
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
     Ok(())
 }
 
+/// Set the `url` to be used when fetching data from a remote.
+///
+/// Shim for the missing `gix::Remote::fetch_url` API.
+///
+/// **TODO:** Upstream an implementation of this to `gix`.
+fn gix_remote_with_fetch_url<Url, E>(
+    remote: gix::Remote,
+    url: Url,
+) -> Result<gix::Remote, gix::remote::init::Error>
+where
+    Url: TryInto<gix::Url, Error = E>,
+    gix::url::parse::Error: From<E>,
+{
+    let mut new_remote = remote.repo().remote_at(url)?;
+    // Copy the existing data from `remote`.
+    //
+    // We don’t copy the push URL, as there does not seem to be any way to reliably
+    // detect whether one is present with the current API, and `jj git remote
+    // set-url` refuses to work with them anyway.
+    new_remote = new_remote.with_fetch_tags(remote.fetch_tags());
+    for direction in [gix::remote::Direction::Fetch, gix::remote::Direction::Push] {
+        new_remote
+            .replace_refspecs(
+                remote
+                    .refspecs(direction)
+                    .iter()
+                    .map(|refspec| refspec.to_ref().to_bstring()),
+                direction,
+            )
+            .expect("existing refspecs to be valid");
+    }
+    Ok(new_remote)
+}
+
 pub fn set_remote_url(
     store: &Store,
     remote_name: &str,
     new_remote_url: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(store)?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let git_repo = get_git_repo(store)?;
 
     validate_remote_name(remote_name)?;
 
-    // Repository::remote_set_url() doesn't ensure the remote exists, it just
-    // creates it if it's missing.
-    // Therefore ensure it exists first
-    git_repo.find_remote(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let Some(result) = git_repo.try_find_remote_without_url_rewrite(remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if remote.url(gix::remote::Direction::Push) != remote.url(gix::remote::Direction::Fetch) {
+        return Err(GitRemoteManagementError::NonstandardConfiguration(
+            remote_name.to_owned(),
+        ));
+    }
path: lib/src/git.rs
user: login: emilazy
body: That’s `pushurl`, not this refspecs stuff. It‘s complicated to correctly rewrite arbitrary refspecs for a new remote name. `libgit2` mostly doesn‘t bother trying. Since our fetch/push code doesn‘t handle them anyway, it‘s not worth the implementation effort in this PR, and maybe not in general. The conservative behaviour here ensures we never throw away information while not bothering to support more than the standard configuration that `git(1)` etc. creates. There may be an argument for supporting more, but that would be feature request territory that I’d rather not derail this PR with. If people have fancy Git‐specific configuration they should probably just use `git(1)` directly for those operations. (I do use `pushInsteadOf` in my global Git configuration, which works.)
created_at: 2025-03-09T04:33:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
path: lib/src/git.rs
user: login: emilazy
body: (Same reply as other case.)
created_at: 2025-03-09T04:33:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1355,6 +1360,135 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn git_config_branch_section_ids_by_remote(
+    config: &gix::config::File,
+    remote_name: &str,
+) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
+    config
+        .sections_by_name("branch")
+        .into_iter()
+        .flatten()
+        .filter(|section| {
+            let remote_values = section.values("remote").into_iter();
+            let push_remote_values = section.values("pushRemote").into_iter();
path: lib/src/git.rs
user: login: yuja
body: nit: Maybe better to compare the last `.value()`, and detect multiple `remote`s as an error? It's unclear what would be overwritten by `.set("remote", ...)`, but I assume either the last value or all values.
created_at: 2025-03-09T11:08:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1355,6 +1360,135 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn git_config_branch_section_ids_by_remote(
+    config: &gix::config::File,
+    remote_name: &str,
+) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
+    config
+        .sections_by_name("branch")
+        .into_iter()
+        .flatten()
+        .filter(|section| {
+            let remote_values = section.values("remote").into_iter();
+            let push_remote_values = section.values("pushRemote").into_iter();
+            remote_values
+                .chain(push_remote_values)
+                .any(|branch_remote_name| *branch_remote_name == remote_name.as_bytes())
+        })
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"remote") && !name.eq_ignore_ascii_case(b"merge")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect()
+}
+
+fn rename_remote_in_git_branch_config(
+    config: &mut gix::config::File,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    for id in git_config_branch_section_ids_by_remote(config, old_remote_name)? {
+        config
+            .section_mut_by_id(id)
+            .expect("found section to exist")
+            .set(
+                "remote"
+                    .try_into()
+                    .expect("'remote' to be a valid value name"),
+                BStr::new(new_remote_name),
+            );
+    }
+    Ok(())
+}
+
+fn remove_remote_from_git_config(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    let section_ids_to_remove = config
+        .sections_by_name("remote")
+        .into_iter()
+        .flatten()
+        .filter(|section| section.header().subsection_name() == Some(BStr::new(remote_name)))
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"url") && !name.eq_ignore_ascii_case(b"fetch")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect::<Result<Vec<_>, _>>()?
+        .into_iter()
+        .chain(git_config_branch_section_ids_by_remote(
+            config,
+            remote_name,
+        )?);
path: lib/src/git.rs
user: login: yuja
body: I think this should be a separate function. `rename_remote()` shouldn't attempt to delete branch configuration, though it appears to be noop.
created_at: 2025-03-09T11:11:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1369,43 +1503,76 @@ pub fn get_all_remote_names(store: &Store) -> Result<Vec<String>, UnexpectedGitB
     Ok(names)
 }
 
-// TODO(git2): migrate to gitoxide
 pub fn add_remote(
     store: &Store,
     remote_name: &str,
     url: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(store)?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let git_repo = get_git_repo(store)?;
+
     validate_remote_name(remote_name)?;
-    git_repo.remote(remote_name, url).map_err(|err| {
-        if is_remote_exists_err(&err) {
-            GitRemoteManagementError::RemoteAlreadyExists(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+
+    if git_repo.try_find_remote(remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            remote_name.to_owned(),
+        ));
+    }
+
+    let mut remote = git_repo
+        .remote_at(url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
     Ok(())
 }
 
 pub fn remove_remote(
     mut_repo: &mut MutableRepo,
     remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
-    git_repo.remote_delete(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
+    if git_repo.try_find_remote(remote_name).is_none() {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+
+    let mut config = git_repo.config_snapshot().clone();
+    remove_remote_from_git_config(&mut config, remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    remove_remote_git_refs(&mut git_repo, remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         remove_remote_refs(mut_repo, remote_name);
     }
+
+    Ok(())
+}
+
+fn remove_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(format!("refs/remotes/{remote_name}/"))?
+            .map(|result| result.map(remove_ref))
path: lib/src/git.rs
user: login: yuja
body: nit: `.map_ok()` for short
created_at: 2025-03-09T11:13:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
+    Ok(())
+}
+
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let new_name = BString::new(
path: lib/src/git.rs
user: login: yuja
body: nit: `.map_ok()`
created_at: 2025-03-09T11:13:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -237,10 +238,16 @@ pub fn maybe_set_repository_level_trunk_alias(
     ui: &Ui,
     workspace_command: &WorkspaceCommandHelper,
 ) -> Result<(), CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    if let Ok(reference) = git_repo.find_reference("refs/remotes/origin/HEAD") {
-        if let Some(reference_name) = reference.symbolic_target() {
-            if let Some(RefName::RemoteBranch(symbol)) = parse_git_ref(reference_name) {
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
+    if let Some(reference) = git_repo
+        .try_find_reference("refs/remotes/origin/HEAD")
+        .map_err(internal_error)?
+    {
+        if let gix::refs::TargetRef::Symbolic(reference_name) = reference.target() {
path: cli/src/commands/git/init.rs
user: login: yuja
body: nit: `Some(reference_name) = reference.target().try_name()` also works.
created_at: 2025-03-09T11:14:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1355,6 +1360,135 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn git_config_branch_section_ids_by_remote(
+    config: &gix::config::File,
+    remote_name: &str,
+) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
+    config
+        .sections_by_name("branch")
+        .into_iter()
+        .flatten()
+        .filter(|section| {
+            let remote_values = section.values("remote").into_iter();
+            let push_remote_values = section.values("pushRemote").into_iter();
path: lib/src/git.rs
user: login: emilazy
body: IIRC I checked that `git(1)` handles it like this. I’ve added a check to error out in this case though, just in case.
created_at: 2025-03-09T21:05:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1355,6 +1360,135 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn git_config_branch_section_ids_by_remote(
+    config: &gix::config::File,
+    remote_name: &str,
+) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
+    config
+        .sections_by_name("branch")
+        .into_iter()
+        .flatten()
+        .filter(|section| {
+            let remote_values = section.values("remote").into_iter();
+            let push_remote_values = section.values("pushRemote").into_iter();
+            remote_values
+                .chain(push_remote_values)
+                .any(|branch_remote_name| *branch_remote_name == remote_name.as_bytes())
+        })
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"remote") && !name.eq_ignore_ascii_case(b"merge")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect()
+}
+
+fn rename_remote_in_git_branch_config(
+    config: &mut gix::config::File,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    for id in git_config_branch_section_ids_by_remote(config, old_remote_name)? {
+        config
+            .section_mut_by_id(id)
+            .expect("found section to exist")
+            .set(
+                "remote"
+                    .try_into()
+                    .expect("'remote' to be a valid value name"),
+                BStr::new(new_remote_name),
+            );
+    }
+    Ok(())
+}
+
+fn remove_remote_from_git_config(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    let section_ids_to_remove = config
+        .sections_by_name("remote")
+        .into_iter()
+        .flatten()
+        .filter(|section| section.header().subsection_name() == Some(BStr::new(remote_name)))
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"url") && !name.eq_ignore_ascii_case(b"fetch")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect::<Result<Vec<_>, _>>()?
+        .into_iter()
+        .chain(git_config_branch_section_ids_by_remote(
+            config,
+            remote_name,
+        )?);
path: lib/src/git.rs
user: login: emilazy
body: Done.
created_at: 2025-03-09T21:05:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1369,43 +1503,76 @@ pub fn get_all_remote_names(store: &Store) -> Result<Vec<String>, UnexpectedGitB
     Ok(names)
 }
 
-// TODO(git2): migrate to gitoxide
 pub fn add_remote(
     store: &Store,
     remote_name: &str,
     url: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(store)?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let git_repo = get_git_repo(store)?;
+
     validate_remote_name(remote_name)?;
-    git_repo.remote(remote_name, url).map_err(|err| {
-        if is_remote_exists_err(&err) {
-            GitRemoteManagementError::RemoteAlreadyExists(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+
+    if git_repo.try_find_remote(remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            remote_name.to_owned(),
+        ));
+    }
+
+    let mut remote = git_repo
+        .remote_at(url)
+        .map_err(GitRemoteManagementError::from_git)?
+        .with_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
     Ok(())
 }
 
 pub fn remove_remote(
     mut_repo: &mut MutableRepo,
     remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
-    git_repo.remote_delete(remote_name).map_err(|err| {
-        if is_remote_not_found_err(&err) {
-            GitRemoteManagementError::NoSuchRemote(remote_name.to_owned())
-        } else {
-            GitRemoteManagementError::InternalGitError(err)
-        }
-    })?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
+    if git_repo.try_find_remote(remote_name).is_none() {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            remote_name.to_owned(),
+        ));
+    };
+
+    let mut config = git_repo.config_snapshot().clone();
+    remove_remote_from_git_config(&mut config, remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    remove_remote_git_refs(&mut git_repo, remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         remove_remote_refs(mut_repo, remote_name);
     }
+
+    Ok(())
+}
+
+fn remove_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(format!("refs/remotes/{remote_name}/"))?
+            .map(|result| result.map(remove_ref))
path: lib/src/git.rs
user: login: emilazy
body: Done, thanks!
created_at: 2025-03-09T21:05:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
+    Ok(())
+}
+
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let new_name = BString::new(
path: lib/src/git.rs
user: login: emilazy
body: Done, thanks!
created_at: 2025-03-09T21:05:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -237,10 +238,16 @@ pub fn maybe_set_repository_level_trunk_alias(
     ui: &Ui,
     workspace_command: &WorkspaceCommandHelper,
 ) -> Result<(), CommandError> {
-    let git_repo = get_git_repo(workspace_command.repo().store())?;
-    if let Ok(reference) = git_repo.find_reference("refs/remotes/origin/HEAD") {
-        if let Some(reference_name) = reference.symbolic_target() {
-            if let Some(RefName::RemoteBranch(symbol)) = parse_git_ref(reference_name) {
+    let git_repo = git::get_git_repo(workspace_command.repo().store())?;
+    if let Some(reference) = git_repo
+        .try_find_reference("refs/remotes/origin/HEAD")
+        .map_err(internal_error)?
+    {
+        if let gix::refs::TargetRef::Symbolic(reference_name) = reference.target() {
path: cli/src/commands/git/init.rs
user: login: emilazy
body: Done.
created_at: 2025-03-09T21:05:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1429,52 +1596,167 @@ pub fn rename_remote(
     old_remote_name: &str,
     new_remote_name: &str,
 ) -> Result<(), GitRemoteManagementError> {
-    let git_repo = get_git_backend(mut_repo.store())?
-        .open_git_repo()
-        .map_err(GitRemoteManagementError::InternalGitError)?;
+    let mut git_repo = get_git_repo(mut_repo.store())?;
+
     validate_remote_name(new_remote_name)?;
-    git_repo
-        .remote_rename(old_remote_name, new_remote_name)
-        .map_err(|err| {
-            if is_remote_not_found_err(&err) {
-                GitRemoteManagementError::NoSuchRemote(old_remote_name.to_owned())
-            } else if is_remote_exists_err(&err) {
-                GitRemoteManagementError::RemoteAlreadyExists(new_remote_name.to_owned())
-            } else {
-                GitRemoteManagementError::InternalGitError(err)
-            }
-        })?;
+
+    let Some(result) = git_repo.try_find_remote(old_remote_name) else {
+        return Err(GitRemoteManagementError::NoSuchRemote(
+            old_remote_name.to_owned(),
+        ));
+    };
+    let mut remote = result.map_err(GitRemoteManagementError::from_git)?;
+
+    if git_repo.try_find_remote(new_remote_name).is_some() {
+        return Err(GitRemoteManagementError::RemoteAlreadyExists(
+            new_remote_name.to_owned(),
+        ));
+    }
+
+    match (
+        remote.refspecs(gix::remote::Direction::Fetch),
+        remote.refspecs(gix::remote::Direction::Push),
+    ) {
+        ([refspec], [])
+            if refspec.to_ref().to_bstring()
+                == format!("+refs/heads/*:refs/remotes/{old_remote_name}/*").as_bytes() => {}
+        _ => {
+            return Err(GitRemoteManagementError::NonstandardConfiguration(
+                old_remote_name.to_owned(),
+            ))
+        }
+    }
+
+    remote
+        .replace_refspecs(
+            [format!("+refs/heads/*:refs/remotes/{new_remote_name}/*").as_bytes()],
+            gix::remote::Direction::Fetch,
+        )
+        .expect("default refspec to be valid");
+
+    let mut config = git_repo.config_snapshot().clone();
+    remote
+        .save_as_to(new_remote_name, &mut config)
+        .map_err(GitRemoteManagementError::from_git)?;
+    rename_remote_in_git_branch_config(&mut config, old_remote_name, new_remote_name)?;
+    remove_remote_from_git_config(&mut config, old_remote_name)?;
+    save_git_config(&config).map_err(GitRemoteManagementError::GitConfigSaveError)?;
+
+    rename_remote_git_refs(&mut git_repo, old_remote_name, new_remote_name)
+        .map_err(GitRemoteManagementError::from_git)?;
+
     if old_remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO {
         rename_remote_refs(mut_repo, old_remote_name, new_remote_name);
     }
+
+    Ok(())
+}
+
+fn rename_remote_git_refs(
+    git_repo: &mut gix::Repository,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
+    let old_prefix = format!("refs/remotes/{old_remote_name}/");
+    let new_prefix = format!("refs/remotes/{new_remote_name}/");
+    let ref_log_message = BString::from(format!(
+        "renamed remote {old_remote_name} to {new_remote_name}"
+    ));
+
+    git_repo.edit_references(
+        git_repo
+            .references()?
+            .prefixed(old_prefix.clone())?
+            .map(|result| {
+                result.map(|old_ref| {
+                    let new_name = BString::new(
+                        [
+                            new_prefix.as_bytes(),
+                            &old_ref.name().as_bstr()[old_prefix.len()..],
+                        ]
+                        .concat(),
+                    );
+                    [
+                        add_ref(
+                            new_name.try_into().expect("new ref name to be valid"),
+                            old_ref.target().into_owned(),
+                            ref_log_message.clone(),
+                        ),
+                        remove_ref(old_ref),
+                    ]
+                })
+            })
+            .flatten_ok()
+            .collect::<Result<Vec<_>, _>>()?,
+    )?;
     Ok(())
 }
 
+/// Set the `url` to be used when fetching data from a remote.
+///
+/// Shim for the missing `gix::Remote::fetch_url` API.
+///
+/// **TODO:** Upstream an implementation of this to `gix`.
path: lib/src/git.rs
user: login: emilazy
body: Decided to ignore this one since it should be temporary anyway, sorry 😅
created_at: 2025-03-09T21:06:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1355,6 +1360,135 @@ pub fn is_special_git_remote(remote: &str) -> bool {
     remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
 }
 
+fn add_ref(
+    name: gix::refs::FullName,
+    target: gix::refs::Target,
+    message: BString,
+) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Update {
+            log: gix::refs::transaction::LogChange {
+                mode: gix::refs::transaction::RefLog::AndReference,
+                force_create_reflog: false,
+                message,
+            },
+            expected: gix::refs::transaction::PreviousValue::MustNotExist,
+            new: target,
+        },
+        name,
+        deref: false,
+    }
+}
+
+fn remove_ref(reference: gix::Reference) -> gix::refs::transaction::RefEdit {
+    gix::refs::transaction::RefEdit {
+        change: gix::refs::transaction::Change::Delete {
+            expected: gix::refs::transaction::PreviousValue::MustExistAndMatch(
+                reference.target().into_owned(),
+            ),
+            log: gix::refs::transaction::RefLog::AndReference,
+        },
+        name: reference.name().to_owned(),
+        deref: false,
+    }
+}
+
+/// Save an edited [`gix::config::File`] to its original location on disk.
+///
+/// Note that the resulting configuration changes are *not* persisted to the
+/// originating [`gix::Repository`]! The repository must be reloaded with the
+/// new configuration if necessary.
+fn save_git_config(config: &gix::config::File) -> std::io::Result<()> {
+    let mut config_file = File::create(
+        config
+            .meta()
+            .path
+            .as_ref()
+            .expect("Git repository to have a config file"),
+    )?;
+    config.write_to_filter(&mut config_file, |section| section.meta() == config.meta())
+}
+
+fn git_config_branch_section_ids_by_remote(
+    config: &gix::config::File,
+    remote_name: &str,
+) -> Result<Vec<gix::config::file::SectionId>, GitRemoteManagementError> {
+    config
+        .sections_by_name("branch")
+        .into_iter()
+        .flatten()
+        .filter(|section| {
+            let remote_values = section.values("remote").into_iter();
+            let push_remote_values = section.values("pushRemote").into_iter();
+            remote_values
+                .chain(push_remote_values)
+                .any(|branch_remote_name| *branch_remote_name == remote_name.as_bytes())
+        })
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"remote") && !name.eq_ignore_ascii_case(b"merge")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect()
+}
+
+fn rename_remote_in_git_branch_config(
+    config: &mut gix::config::File,
+    old_remote_name: &str,
+    new_remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    for id in git_config_branch_section_ids_by_remote(config, old_remote_name)? {
+        config
+            .section_mut_by_id(id)
+            .expect("found section to exist")
+            .set(
+                "remote"
+                    .try_into()
+                    .expect("'remote' to be a valid value name"),
+                BStr::new(new_remote_name),
+            );
+    }
+    Ok(())
+}
+
+fn remove_remote_from_git_config(
+    config: &mut gix::config::File,
+    remote_name: &str,
+) -> Result<(), GitRemoteManagementError> {
+    let section_ids_to_remove = config
+        .sections_by_name("remote")
+        .into_iter()
+        .flatten()
+        .filter(|section| section.header().subsection_name() == Some(BStr::new(remote_name)))
+        .map(|section| {
+            if section.value_names().any(|name| {
+                !name.eq_ignore_ascii_case(b"url") && !name.eq_ignore_ascii_case(b"fetch")
+            }) {
+                return Err(GitRemoteManagementError::NonstandardConfiguration(
+                    remote_name.to_owned(),
+                ));
+            }
+            Ok(section.id())
+        })
+        .collect::<Result<Vec<_>, _>>()?
+        .into_iter()
+        .chain(git_config_branch_section_ids_by_remote(
+            config,
+            remote_name,
+        )?);
+    for id in section_ids_to_remove {
+        config
+            .remove_section_by_id(id)
+            .expect("removed section to exist");
+    }
+    Ok(())
+}
+
path: lib/src/git.rs
user: login: yuja
body: It might make sense to split git.rs into sub modules (e.g. git/config.rs, git/push.rs, git/subprocess.rs, ...) I wouldn't add git/gitoxide.rs because the glue code to be hosted there would ideally be minimal.
created_at: 2025-03-10T01:59:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: Byron
created_at: 2025-02-09T10:21:13Z
body: Thanks for reeling me in, and sorry for the late response!

> * It’s awkward that `gix::Remote::save_to` handles the fussy details of removing the other sections for that remote from the config, but leaves you to reimplement it yourself if you need it in any other context. It would be nice if the removal logic was exposed separately, or perhaps if there was a specific way to tell `gix` that you’re renaming a remote and the old config should go away

Indeed, renaming isn't supported yet, and having some of the existing logic available would help implementing it.
Alternatively it would be nice if one could just rename a remote while handling the configuration automatically.

> * You can change the name of a `gix::Remote`, and you can set its push URL, but you can’t change its fetch URL. In `set_remote_url` I have to manually copy over all the data from the existing remote to achieve this. Not sure if this is a fundamental constraint of the model or just a missing API.

I don't think this is more than an incomplete API.

> * It’s a little surprising that there’s a rich representation of parsed refspecs but the remote API expects you to pass in a `BStr` and deal with potentially‐impossible parse errors.

I think this is me avoiding hassle. What it really wants to do is to allow passing various inputs, including strings, and parse it when needed. Right now there probably is only one API that does this (`find_reference()`), and that alone gave me quite some trouble already. It's probably still not working intuitively in all common cases.
With that said, other crates are able to pull it off, so I am sure there is a way.
In any case, that's the reason for resorting to `AsRef<BStr>` (*and seeing this tells me that this also is a workaround as typically one would do `Into<BStr>` if one wants it to work for `&str`. Probably some tests found `AsRef` more convenient for their input*).

> * The borrowing model of configuration and remotes interact awkwardly. In a previous version of this PR where I tried to persist the remote configuration changes to the `gix::Repository`, I had to clone the entire thing so that I could hold a `gix::Remote` and a `gix::config::SnapshotMut` at the same time (otherwise the immutable and mutable borrows conflicted). That felt awkward for a task as simple as adding a remote to the configuration. (In general when using the `gix` API I kind of wish there was less borrowing and more refcounting/cloning, though I realize that there’s a subjective element to the trade‐off here and that you can wrap a borrow‐happy API in a clone‐happy one but not vice versa.)

The clone-happy APIs in `jj` are really nice and makes the code so much more 'to the point', and simple-looking. I am loving it.
With that said, `gix` can still change and maybe it should, to fully embed an `Rc/Arc` `gix::Repository` instead of a reference, to fix the issue we see today with `&mut` and `&` interactions.
However, I'd hope there can be other ways of dealing with this, and usually I fix these issues once I encounter them myself. After all, the main 'components' of a repository are shared and ref-counted already, so certain types could embed them directly if that avoids some `&mut` hassle.

> In general there’s more convoluted logic here in the move from `git2` to `gix`, but thankfully most of that is just dealing with lower‐level APIs that don’t bundle configuration changes, ref renames, etc. into one porcelain call.

And I am really sorry for this being so half-done right now.
Strangely, these days I find myself working on application code of GitButler and have to resort myself to plumbing code to write Git configuration, for example, or to downright having to use `git2` if `gitoxide` doesn't have the feature yet. One would think that I'd have the time to 'just' add the missing feature, but not really.

Probably for you it's very much the same, but if not, or if there is some room to breathe once the first version of a feature is delivered in `jj`, maybe there is a chance you could add the API you'd want to solve the problem nicely with `gix`.
Client code is a great driver for this, it just takes an extra step to get there.

I will try to make these steps for GitButler eventually (sprinkling even more TODO's there 😁), and encourage you to do the same.
author_association: NONE
user: login: emilazy
created_at: 2025-02-10T01:13:51Z
body: Thanks for the detailed reply, @Byron!

> Indeed, renaming isn't supported yet, and having some of the existing logic available would help implementing it. Alternatively it would be nice if one could just rename a remote while handling the configuration automatically.

Higher‐level porcelain APIs would definitely be welcome! But I understand that Git has many layers and there’s a lot of subtlety with higher‐level operations, so just having the lower‐level ones factored in a more reusable way would be great here.

> I don't think this is more than an incomplete API.

That’s good to know; I’ll leave a comment to that effect in the code so that it can hopefully be cleaned up later.

> I think this is me avoiding hassle. What it really wants to do is to allow passing various inputs, including strings, and parse it when needed. Right now there probably is only one API that does this (`find_reference()`), and that alone gave me quite some trouble already. It's probably still not working intuitively in all common cases. With that said, other crates are able to pull it off, so I am sure there is a way. In any case, that's the reason for resorting to `AsRef<BStr>` (_and seeing this tells me that this also is a workaround as typically one would do `Into<BStr>` if one wants it to work for `&str`. Probably some tests found `AsRef` more convenient for their input_).

Right. I guess you’d want a trait for things that can be converted to refspecs, or maybe you can use `TryInto` and we could rely on `Infallible` for the identity case or such…

That said, I think it’d be fine if the remote API just only took pre‐parsed refspecs and you had to explicitly `.try_into()` if you wanted to parse a string representation of them.

> The clone-happy APIs in `jj` are really nice and makes the code so much more 'to the point', and simple-looking. I am loving it. With that said, `gix` can still change and maybe it should, to fully embed an `Rc/Arc` `gix::Repository` instead of a reference, to fix the issue we see today with `&mut` and `&` interactions. However, I'd hope there can be other ways of dealing with this, and usually I fix these issues once I encounter them myself. After all, the main 'components' of a repository are shared and ref-counted already, so certain types could embed them directly if that avoids some `&mut` hassle.

Right. In this case we get around it by not actually mutating the in‐memory repository configuration (since it isn’t reflected in the `ThreadSafeRepository` anyway), but otherwise it’s awkward to have a remote wanting a `&` reference to the repo but the mutable config snapshot needing a `&mut`. If the remotes could avoid depending on the repository’s lifetime, or if config editing used interior mutability (as I guess it might have to in order to write to a `ThreadSafeRepository` in the future anyway), then that conflict wouldn’t exist.

In general I think that a bit of refcounting/cloning is usually cheap and can make an API simpler and more general enough that it’s worth it; I would say that lifetimes and error types are probably the trickiest parts of using gitoxide in my experience. But I can also understand wanting to exploit Rust’s capabilities to the fullest and avoid any unnecessary copying.

> And I am really sorry for this being so half-done right now. Strangely, these days I find myself working on application code of GitButler and have to resort myself to plumbing code to write Git configuration, for example, or to downright having to use `git2` if `gitoxide` doesn't have the feature yet. One would think that I'd have the time to 'just' add the missing feature, but not really.
> 
> Probably for you it's very much the same, but if not, or if there is some room to breathe once the first version of a feature is delivered in `jj`, maybe there is a chance you could add the API you'd want to solve the problem nicely with `gix`. Client code is a great driver for this, it just takes an extra step to get there.
> 
> I will try to make these steps for GitButler eventually (sprinkling even more TODO's there 😁), and encourage you to do the same.

Yes, the temptation to just dive into the gitoxide code base is strong, but the more I let myself get carried away with such things the less likely I am to actually finish what I promised to do in the first place :) I’m also not too familiar with gitoxide’s codebase and haven’t fully wrapped my head around the code organization yet.

Some of these things like the missing API to set the fetch URL seem simple, though, so I’ll see if I find the time to send a PR for that. If not, maybe someone else will see the TODO comment and take up the mantle in future!

Thanks again for all your work on gitoxide and for your thoughts on my feedback; a Git reimplementation is a mammoth undertaking and I don’t think you have anything to be sorry for it not yet being perfectly polished! Now that we have code to shell out to `git(1)` for fetches and pushes, being able to drop our `git2` dependency and rely on gitoxide (+ shelling out) alone is in sight. Hopefully that will help increase the synergy between Jujutsu and gitoxide development further :)
author_association: CONTRIBUTOR
user: login: bsdinis
created_at: 2025-02-20T02:19:05Z
body: Are there any major blockers here at the moment? No pressure if you haven't found time for the last bits, but happy to help carry this through in any way I can
author_association: CONTRIBUTOR
user: login: Byron
created_at: 2025-03-10T15:04:40Z
body: It's great to see this PR merged, and I am sorry to probably have made the impression of being disinterested in what's going on it. That's not the case, fortunately, I just didn't have bandwidth and won't have bandwidth to do what I'd want to do: read each comment carefully and see if `gitoxide` documentation and/or APIs could be improved based on them.

So all I can do here is to encourage opening issues on `gitoxide` at least or PRs even while the pain is fresh, so there is a chance to eventually get the improvements that we deserve :).

Thanks and cheers
author_association: NONE
user: login: emilazy
created_at: 2025-03-10T15:55:07Z
body: Not at all, I appreciate you popping in :) We have some TODOs for things to upstream already, which I will try to get around to if time permits.
author_association: CONTRIBUTOR