Skip to content

Commit 1d33da0

Browse files
committed
git fetch: add --rebase to rewrite upstream changes in place
When upstream rewrites a change (e.g. amends or rebases) and you fetch the new revision, a normal `jj git fetch` keeps your old revision alongside the new one as a divergent change. With `--rebase`, the incoming revision rewrites your existing one in place: bookmarks, the working copy, and descendants are moved onto the new revision, and the old revision is hidden -- the same outcome jj produces when the rewrite happens locally. If a change already has multiple visible revisions in the repo before the fetch, there is no unambiguous old-to-new mapping, so the import aborts with an error and a hint pointing at `jj abandon` / `jj duplicate`. `abandon_unreachable_commits` now takes an exclusion set so that commits already recorded as rewritten don't have their `Rewritten` entry in `parent_mapping` overwritten with `Abandoned`, which would otherwise reparent descendants onto the old commit's parents instead of onto the rewrite target. git import: rewrite stacks and reject newly-introduced divergence Address two related gaps in --rebase from the previous commit: * Stack rebases: `replace_divergent_changes` only iterated head commits, so when upstream rebased a stack of N changes only the head was paired with its local counterpart. Now we walk every commit that became newly visible in jj (head plus newly-imported ancestors, stopping at commits already reachable from a pre-transaction head) and pair each one's change id. * Fetch-introduced divergence: when one fetch brought in multiple new commits sharing a change id with a single existing local commit, the per-commit loop observed the first new commit as already-visible while processing the second and aborted with `PreExistingDivergentChange`, which is misleading. The rewrite logic now groups by change id and matches old/new candidates by cardinality, and the new `NewlyDivergentChange` error signals the fetch itself was the source. git fetch: shift --rebase coverage into lib tests Move the bulk of --rebase verification out of `cli/tests/test_git_fetch.rs` (where each case needs a colocated source repo and a full CLI roundtrip) into `lib/tests/test_git.rs`, which exercises `git::import_refs` directly against a TestRepo. The CLI-only assertions that lib can't reach — the `Display` for `GitImportError::DivergentChanges` and the exact contents of each problem's commit lists — are pinned at the lib level via `err.to_string()` and sorted commit-id checks. The remote-tracking ref is also asserted in the happy-path lib test so the rewrite covers both `feat` and `feat@origin`. A single CLI snapshot remains for the divergent-changes error renderer (`cli/src/git_util.rs::divergent_changes_error`): it pins the formatted hint header, the indented commit list rendered through `commit_summary_template`, and the trailing hint. The pre-existing case is enough — the newly-introduced path uses the same code with different strings, and those strings are pinned by the lib `err.to_string()` assertions.
1 parent ff905ef commit 1d33da0

8 files changed

Lines changed: 1315 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2323
extracted from the `.doc` field of the alias definition if it is a table
2424
with `.doc` and `.definition` properties.
2525

26+
* `jj git fetch` now supports `--rebase` to treat an upstream rewrite of an
27+
existing change as a rewrite of the local revision rather than as a
28+
divergent change. Bookmarks, the working copy, and descendants are moved
29+
onto the new revision, and the old revision is hidden. Rewrite mappings
30+
are recorded for every change in an upstream-rebased stack, not only the
31+
head. The import aborts if any change is already divergent before the
32+
fetch, or if the fetch itself introduces multiple new revisions for the
33+
same change; every such problem is reported in one error so you can
34+
resolve them together before retrying.
35+
2636
### Fixed bugs
2737

2838
* `jj bookmark forget` no longer prints `Forgot N local bookmarks.` when no

cli/src/command_error.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,12 @@ mod git {
542542

543543
impl From<GitImportError> for CommandError {
544544
fn from(err: GitImportError) -> Self {
545+
// `GitImportError::DivergentChanges` only originates from
546+
// `jj git fetch --rebase`; that command intercepts the error to
547+
// render commit summaries via the workspace's
548+
// `commit_summary_template`. The generic conversion here is just a
549+
// fallback for any other call site that ever surfaces the error
550+
// through `?` — the Display impl still includes a useful summary.
545551
let hint = match &err {
546552
GitImportError::MissingHeadTarget { .. }
547553
| GitImportError::MissingRefAncestor { .. } => Some(
@@ -551,6 +557,7 @@ jj currently does not support partial clones. To use jj with this repository, tr
551557
the full repository contents."
552558
.to_string(),
553559
),
560+
GitImportError::DivergentChanges { .. } => None,
554561
GitImportError::Backend(_) => None,
555562
GitImportError::Index(_) => None,
556563
GitImportError::Git(_) => None,

cli/src/commands/git/fetch.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,25 @@ pub struct GitFetchArgs {
126126
/// Fetch from all remotes
127127
#[arg(long, conflicts_with = "remotes")]
128128
all_remotes: bool,
129+
130+
/// Rewrite changes from upstream in place instead of creating divergent
131+
/// changes
132+
///
133+
/// When upstream rewrites a change (e.g. amends or rebases) and you fetch
134+
/// the new revision, the default behavior is to keep your old revision
135+
/// alongside the new one as a divergent change. With `--rebase`, the
136+
/// incoming revision rewrites your existing revision in place: bookmarks,
137+
/// the working copy, and descendants are moved onto the new revision, and
138+
/// the old revision is hidden.
139+
///
140+
/// If upstream rewrites a stack of changes, rewrite mappings are recorded
141+
/// for every change in the stack, not only the head. Aborts if any change
142+
/// is already divergent in your repo before the fetch, or if the fetch
143+
/// itself introduces multiple new revisions for the same change. All such
144+
/// problems are reported in one error so you can resolve them together
145+
/// before retrying.
146+
#[arg(long)]
147+
rebase: bool,
129148
}
130149

131150
#[tracing::instrument(skip_all)]
@@ -230,7 +249,8 @@ pub async fn cmd_git_fetch(
230249
}
231250

232251
let git_settings = GitSettings::from_settings(tx.settings())?;
233-
let import_options = load_git_import_options(ui, &git_settings, &remote_settings)?;
252+
let mut import_options = load_git_import_options(ui, &git_settings, &remote_settings)?;
253+
import_options.replace_divergent_changes = args.rebase;
234254
let mut git_fetch = GitFetch::new(
235255
tx.repo_mut(),
236256
git_settings.to_subprocess_options(),
@@ -245,7 +265,16 @@ pub async fn cmd_git_fetch(
245265
git_fetch.fetch(remote, expanded, &mut callback, None, fetch_tags)?;
246266
}
247267

248-
let import_stats = git_fetch.import_refs().await?;
268+
let import_stats = match git_fetch.import_refs().await {
269+
Ok(stats) => stats,
270+
// `--rebase` produces a structured DivergentChanges error; render it
271+
// here so the per-problem hints can include commit summaries pretty-
272+
// printed via the workspace's commit_summary template.
273+
Err(jj_lib::git::GitImportError::DivergentChanges { problems }) => {
274+
return Err(crate::git_util::divergent_changes_error(&tx, problems).await?);
275+
}
276+
Err(err) => return Err(err.into()),
277+
};
249278
print_git_import_stats(ui, &tx, &import_stats).await?;
250279

251280
if let Some(bookmark_expr) = &common_bookmark_expr {

cli/src/git_util.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ use futures::future::try_join_all;
3030
use indoc::writedoc;
3131
use itertools::Itertools as _;
3232
use jj_lib::git;
33+
use jj_lib::git::DivergentChangeKind;
34+
use jj_lib::git::DivergentChangeProblem;
3335
use jj_lib::git::FailedRefExportReason;
3436
use jj_lib::git::GitExportStats;
37+
use jj_lib::git::GitImportError;
3538
use jj_lib::git::GitImportOptions;
3639
use jj_lib::git::GitImportStats;
3740
use jj_lib::git::GitProgress;
@@ -55,6 +58,7 @@ use crate::cli_util::print_updated_commits;
5558
use crate::command_error::CommandError;
5659
use crate::command_error::cli_error;
5760
use crate::command_error::user_error;
61+
use crate::command_error::user_error_with_message;
5862
use crate::formatter::Formatter;
5963
use crate::formatter::FormatterExt as _;
6064
use crate::revset_util::parse_remote_auto_track_bookmarks_map;
@@ -213,6 +217,7 @@ pub fn load_git_import_options(
213217
auto_local_bookmark: git_settings.auto_local_bookmark,
214218
abandon_unreachable_commits: git_settings.abandon_unreachable_commits,
215219
remote_auto_track_bookmarks: parse_remote_auto_track_bookmarks_map(ui, remote_settings)?,
220+
replace_divergent_changes: false,
216221
})
217222
}
218223

@@ -298,6 +303,71 @@ fn print_failed_git_import(ui: &Ui, stats: &GitImportStats) -> Result<(), Comman
298303
Ok(())
299304
}
300305

306+
/// Builds the user-visible error for a `jj git fetch --rebase` that produced
307+
/// one or more divergent-change problems. Each problem becomes a formatted
308+
/// hint listing the affected commits via the workspace's `commit_summary`
309+
/// template, followed by action hints for each kind that appears.
310+
pub async fn divergent_changes_error(
311+
tx: &WorkspaceCommandTransaction<'_>,
312+
problems: Vec<DivergentChangeProblem>,
313+
) -> Result<CommandError, CommandError> {
314+
let template = tx.commit_summary_template();
315+
let mut cmd_err = user_error_with_message(
316+
"Failed to import refs from underlying Git repo",
317+
GitImportError::DivergentChanges {
318+
problems: problems.clone(),
319+
},
320+
);
321+
let mut has_pre_existing = false;
322+
let mut has_newly_introduced = false;
323+
for problem in &problems {
324+
let (header, commit_ids) = match &problem.kind {
325+
DivergentChangeKind::PreExisting { existing_commits } => {
326+
has_pre_existing = true;
327+
(
328+
format!(
329+
"Already divergent ({} visible local commits):",
330+
existing_commits.len(),
331+
),
332+
existing_commits.clone(),
333+
)
334+
}
335+
DivergentChangeKind::NewlyIntroduced { new_commits } => {
336+
has_newly_introduced = true;
337+
(
338+
format!(
339+
"Fetch-introduced divergence ({} incoming commits):",
340+
new_commits.len(),
341+
),
342+
new_commits.clone(),
343+
)
344+
}
345+
};
346+
let commits = try_join_all(
347+
commit_ids
348+
.iter()
349+
.map(|id| tx.repo().store().get_commit_async(id)),
350+
)
351+
.await?;
352+
cmd_err.add_formatted_hint_with(|formatter| {
353+
writeln!(formatter, "{header}")?;
354+
print_updated_commits(formatter, &template, &commits)
355+
});
356+
}
357+
if has_pre_existing {
358+
cmd_err.add_hint(
359+
"Resolve already-divergent changes (e.g. with `jj abandon` or `jj duplicate`) and \
360+
re-run with `--rebase`.",
361+
);
362+
}
363+
if has_newly_introduced {
364+
cmd_err.add_hint(
365+
"Re-run without `--rebase` to accept the fetch-introduced divergent change(s).",
366+
);
367+
}
368+
Ok(cmd_err)
369+
}
370+
301371
/// Prints only the summary of git import stats (abandoned count, failed refs).
302372
/// Use this when a WorkspaceCommandTransaction is not available.
303373
pub fn print_git_import_stats_summary(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {

cli/tests/cli-reference@.md.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1674,6 +1674,11 @@ If a working-copy commit gets abandoned, it will be given a new, empty commit. T
16741674

16751675
[string pattern syntax]: https://docs.jj-vcs.dev/latest/revsets/#string-patterns
16761676
* `--all-remotes` — Fetch from all remotes
1677+
* `--rebase` — Rewrite changes from upstream in place instead of creating divergent changes
1678+
1679+
When upstream rewrites a change (e.g. amends or rebases) and you fetch the new revision, the default behavior is to keep your old revision alongside the new one as a divergent change. With `--rebase`, the incoming revision rewrites your existing revision in place: bookmarks, the working copy, and descendants are moved onto the new revision, and the old revision is hidden.
1680+
1681+
If upstream rewrites a stack of changes, rewrite mappings are recorded for every change in the stack, not only the head. Aborts if any change is already divergent in your repo before the fetch, or if the fetch itself introduces multiple new revisions for the same change. All such problems are reported in one error so you can resolve them together before retrying.
16771682

16781683

16791684

cli/tests/test_git_fetch.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2363,3 +2363,57 @@ fn test_git_fetch_auto_track_bookmarks() {
23632363
[EOF]
23642364
");
23652365
}
2366+
2367+
// The --rebase logic is covered at the lib level in
2368+
// lib/tests/test_git.rs (`test_import_refs_rebase_*`). The single CLI test
2369+
// below covers only the user-visible rendering of the divergent-changes error
2370+
// (the `divergent_changes_error` helper in `cli/src/git_util.rs`), which the
2371+
// lib layer can't reach. The lib tests pin the `GitImportError::Display`
2372+
// strings for all three `summary()` branches (pre-only / new-only / mixed);
2373+
// this test pins the formatter structure — hint header, indented commit list
2374+
// rendered through `commit_summary_template`, and the trailing hint — using
2375+
// the pre-existing case as a representative. New tests for --rebase behavior
2376+
// belong in lib/tests/test_git.rs.
2377+
#[test]
2378+
fn test_git_fetch_rebase_divergent_changes_error_rendering() {
2379+
let test_env = TestEnvironment::default();
2380+
test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");
2381+
test_env.add_config(r#"revset-aliases."immutable_heads()" = "none()""#);
2382+
2383+
test_env
2384+
.run_jj_in(".", ["git", "init", "--colocate", "source"])
2385+
.success();
2386+
let source = test_env.work_dir("source");
2387+
create_commit(&source, "feat", &[]);
2388+
2389+
test_env.run_jj_in(".", ["git", "init", "target"]).success();
2390+
let target = test_env.work_dir("target");
2391+
target
2392+
.run_jj(["git", "remote", "add", "origin", "../source/.git"])
2393+
.success();
2394+
target.run_jj(["git", "fetch"]).success();
2395+
2396+
// Pin the initial upstream commit with a local bookmark so the next plain
2397+
// fetch can't hide it via `abandon-unreachable-commits` (which only
2398+
// considers local bookmarks/tags as pins). Two amend + fetch cycles then
2399+
// produce pre-existing divergence the third --rebase fetch must reject.
2400+
target
2401+
.run_jj(["bookmark", "create", "-r=feat@origin", "keepalive"])
2402+
.success();
2403+
source.run_jj(["describe", "feat", "-m=feat v2"]).success();
2404+
target.run_jj(["git", "fetch"]).success();
2405+
source.run_jj(["describe", "feat", "-m=feat v3"]).success();
2406+
2407+
let output = target.run_jj(["git", "fetch", "--rebase"]);
2408+
insta::assert_snapshot!(output, @r#"
2409+
------- stderr -------
2410+
Error: Failed to import refs from underlying Git repo
2411+
Caused by: Cannot rewrite changes in place: 1 already-divergent change(s) in the repo
2412+
Hint: Already divergent (2 visible local commits):
2413+
rlvkpnrz/1 abf1fac5 (divergent) feat v2
2414+
rlvkpnrz/2 00d0d0ee keepalive* | (divergent) feat
2415+
Hint: Resolve already-divergent changes (e.g. with `jj abandon` or `jj duplicate`) and re-run with `--rebase`.
2416+
[EOF]
2417+
[exit status: 1]
2418+
"#);
2419+
}

0 commit comments

Comments
 (0)