diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b260bb03d8..1b838661ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * `.gitignore` with UTF-8 BOM can now be parsed correctly. +* jj now prunes Git remotes that have been deleted using the Git CLI. + ## [0.39.0] - 2026-03-04 ### Release highlights diff --git a/lib/src/git.rs b/lib/src/git.rs index 470c1e4f297..ff9d11f5abc 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -551,6 +551,8 @@ pub async fn import_refs( /// /// Only bookmarks and tags whose remote symbol pass the filter will be /// considered for addition, update, or deletion. +/// +/// This includes updating JJ's view of the configured git remotes. pub async fn import_some_refs( mut_repo: &mut MutableRepo, options: &GitImportOptions, @@ -558,18 +560,44 @@ pub async fn import_some_refs( ) -> Result { let git_repo = get_git_repo(mut_repo.store())?; + // We do not deal with remotes in import_refs_inner, so that it may be used + // for refs only. + let configured_remotes: HashSet = iter_remote_names(&git_repo).collect(); + // Allocate views for new remotes configured externally. There may be // remotes with no refs, but the user might still want to "track" absent // remote refs. - for remote_name in iter_remote_names(&git_repo) { - mut_repo.ensure_remote(&remote_name); + for remote_name in &configured_remotes { + mut_repo.ensure_remote(remote_name); } // Exclude real remote tags, which should never be updated by Git. let all_remote_tags = false; let refs_to_import = diff_refs_to_import(mut_repo.view(), &git_repo, all_remote_tags, git_ref_filter)?; - import_refs_inner(mut_repo, refs_to_import, options).await + let stats = import_refs_inner(mut_repo, refs_to_import, options).await?; + + // Prune views for remotes that no longer exist in the git config and + // have no remaining present refs. This handles the case where a remote was + // removed externally. + // + // We must do this after all refs are pruned, so that we have an + // up-to-date view of which remotes are prunable. + let pruned_remotes: Vec = mut_repo + .view() + .remote_views() + .filter(|(name, view)| { + *name != REMOTE_NAME_FOR_LOCAL_GIT_REPO + && !configured_remotes.contains(*name) + && view.all_refs_absent() + }) + .map(|(name, _)| name.to_owned()) + .collect(); + for remote_name in &pruned_remotes { + remove_remote_refs(mut_repo, remote_name); + } + + Ok(stats) } async fn import_refs_inner( diff --git a/lib/src/op_store.rs b/lib/src/op_store.rs index e618901935d..bc118abee61 100644 --- a/lib/src/op_store.rs +++ b/lib/src/op_store.rs @@ -290,6 +290,13 @@ pub struct RemoteView { pub tags: BTreeMap, } +impl RemoteView { + pub fn all_refs_absent(&self) -> bool { + let Self { bookmarks, tags } = self; + bookmarks.values().all(|r| r.is_absent()) && tags.values().all(|r| r.is_absent()) + } +} + /// Iterates pair of local and remote refs by name. pub(crate) fn merge_join_ref_views<'a>( local_refs: &'a BTreeMap, diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index 3f0a4b8aa55..c55e08ca68e 100644 --- a/lib/tests/test_git.rs +++ b/lib/tests/test_git.rs @@ -1089,14 +1089,48 @@ fn test_import_refs_reimport_with_deleted_abandoned_untracked_remote_ref() -> Te #[test] fn test_import_refs_reimport_absent_tracked_remote_bookmarks() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); - let repo = &test_repo.repo; - let git_repo = get_git_repo(repo); let import_options = default_import_options(); let absent_tracked_ref = RemoteRef { target: RefTarget::absent(), state: RemoteRefState::Tracked, }; + // Register remotes in git config so they are not pruned as stale during + // import. Each add_remote must be in a separate transaction with a reload + // in between because gix config snapshots are taken at repo-open time; two + // add_remote calls in the same transaction would share the same stale + // snapshot and the second save would overwrite the first. + let mut tx = test_repo.repo.start_transaction(); + git::add_remote( + tx.repo_mut(), + "origin".as_ref(), + "https://example.com/", + None, + Default::default(), + &StringExpression::all(), + ) + .unwrap(); + tx.commit("test").block_on().unwrap(); + let repo = test_repo + .env + .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); + let mut tx = repo.start_transaction(); + git::add_remote( + tx.repo_mut(), + "upstream".as_ref(), + "https://upstream.example.com/", + None, + Default::default(), + &StringExpression::all(), + ) + .unwrap(); + tx.commit("test").block_on().unwrap(); + // Reload after all git configuration changes. + let repo = &test_repo + .env + .load_repo_at_head(&testutils::user_settings(), test_repo.repo_path()); + let git_repo = get_git_repo(repo); + // Set up absent tracked refs. let mut tx = repo.start_transaction(); let commit1 = write_random_commit(tx.repo_mut()); @@ -5658,6 +5692,65 @@ fn test_remote_remove_refs() -> TestResult { Ok(()) } +/// Tests that `import_refs` prunes remote views for remotes that have been +/// removed from git config without going through `jj git remote remove` (e.g. +/// by using `git remote remove` directly). +#[test] +fn test_import_refs_prunes_stale_remote_view() { + let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git); + let import_options = default_import_options(); + + // Case 1: empty stale remote view (e.g. remote was added to config, never + // fetched, then deleted externally). + let mut tx = test_repo.repo.start_transaction(); + tx.repo_mut().ensure_remote("stale".as_ref()); + let repo = tx.commit("test").block_on().unwrap(); + + assert!(repo.view().get_remote_view("stale".as_ref()).is_some()); + + let mut tx = repo.start_transaction(); + git::import_refs(tx.repo_mut(), &import_options) + .block_on() + .unwrap(); + tx.repo_mut().rebase_descendants().block_on().unwrap(); + let repo = tx.commit("test").block_on().unwrap(); + + assert!(repo.view().get_remote_view("stale".as_ref()).is_none()); + + // Case 2: stale remote view with an absent-tracked bookmark (e.g. a remote + // that was fetched and tracked but then the remote was deleted externally, + // leaving the local bookmark behind). + let mut tx = repo.start_transaction(); + let local_commit = write_random_commit(tx.repo_mut()); + tx.repo_mut().set_local_bookmark_target( + "main".as_ref(), + RefTarget::normal(local_commit.id().clone()), + ); + tx.repo_mut().ensure_remote("stale".as_ref()); + tx.repo_mut().set_remote_bookmark( + remote_symbol("main", "stale"), + RemoteRef { + target: RefTarget::absent(), + state: RemoteRefState::Tracked, + }, + ); + let repo = tx.commit("test").block_on().unwrap(); + + assert!(repo.view().get_remote_view("stale".as_ref()).is_some()); + assert!(repo.view().get_local_bookmark("main".as_ref()).is_present()); + + let mut tx = repo.start_transaction(); + git::import_refs(tx.repo_mut(), &import_options) + .block_on() + .unwrap(); + tx.repo_mut().rebase_descendants().block_on().unwrap(); + let repo = tx.commit("test").block_on().unwrap(); + + assert!(repo.view().get_remote_view("stale".as_ref()).is_none()); + // Local "main" is preserved; only the stale remote view is removed. + assert!(repo.view().get_local_bookmark("main".as_ref()).is_present()); +} + #[test] fn test_remote_rename_refs() -> TestResult { let test_repo = TestRepo::init_with_backend(TestRepoBackend::Git);