Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 31 additions & 3 deletions lib/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,25 +551,53 @@ 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,
git_ref_filter: impl Fn(GitRefKind, RemoteRefSymbol<'_>) -> bool,
) -> Result<GitImportStats, GitImportError> {
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<RemoteNameBuf> = 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<RemoteNameBuf> = 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(
Expand Down
7 changes: 7 additions & 0 deletions lib/src/op_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,13 @@ pub struct RemoteView {
pub tags: BTreeMap<RefNameBuf, RemoteRef>,
}

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<RefNameBuf, RefTarget>,
Expand Down
97 changes: 95 additions & 2 deletions lib/tests/test_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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);
Expand Down
Loading