Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
extracted from the `.doc` field of the alias definition if it is a table
with `.doc` and `.definition` properties.

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

### Fixed bugs

* `jj bookmark forget` no longer prints `Forgot N local bookmarks.` when no
Expand Down
7 changes: 7 additions & 0 deletions cli/src/command_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,12 @@ mod git {

impl From<GitImportError> for CommandError {
fn from(err: GitImportError) -> Self {
// `GitImportError::DivergentChanges` only originates from
// `jj git fetch --rebase`; that command intercepts the error to
// render commit summaries via the workspace's
// `commit_summary_template`. The generic conversion here is just a
// fallback for any other call site that ever surfaces the error
// through `?` — the Display impl still includes a useful summary.
let hint = match &err {
GitImportError::MissingHeadTarget { .. }
| GitImportError::MissingRefAncestor { .. } => Some(
Expand All @@ -551,6 +557,7 @@ jj currently does not support partial clones. To use jj with this repository, tr
the full repository contents."
.to_string(),
),
GitImportError::DivergentChanges { .. } => None,
GitImportError::Backend(_) => None,
GitImportError::Index(_) => None,
GitImportError::RevsetEvaluation(_) => None,
Expand Down
33 changes: 31 additions & 2 deletions cli/src/commands/git/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,25 @@ pub struct GitFetchArgs {
/// Fetch from all remotes
#[arg(long, conflicts_with = "remotes")]
all_remotes: bool,

/// Rewrite changes from upstream in place instead of creating divergent
/// changes
///
/// 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.
///
/// 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.
#[arg(long)]
rebase: bool,
}

#[tracing::instrument(skip_all)]
Expand Down Expand Up @@ -230,7 +249,8 @@ pub async fn cmd_git_fetch(
}

let git_settings = GitSettings::from_settings(tx.settings())?;
let import_options = load_git_import_options(ui, &git_settings, &remote_settings)?;
let mut import_options = load_git_import_options(ui, &git_settings, &remote_settings)?;
import_options.replace_divergent_changes = args.rebase;
let mut git_fetch = GitFetch::new(
tx.repo_mut(),
git_settings.to_subprocess_options(),
Expand All @@ -245,7 +265,16 @@ pub async fn cmd_git_fetch(
git_fetch.fetch(remote, expanded, &mut callback, None, fetch_tags)?;
}

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

if let Some(bookmark_expr) = &common_bookmark_expr {
Expand Down
70 changes: 70 additions & 0 deletions cli/src/git_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ use futures::future::try_join_all;
use indoc::writedoc;
use itertools::Itertools as _;
use jj_lib::git;
use jj_lib::git::DivergentChangeKind;
use jj_lib::git::DivergentChangeProblem;
use jj_lib::git::FailedRefExportReason;
use jj_lib::git::GitExportStats;
use jj_lib::git::GitImportError;
use jj_lib::git::GitImportOptions;
use jj_lib::git::GitImportStats;
use jj_lib::git::GitProgress;
Expand All @@ -55,6 +58,7 @@ use crate::cli_util::print_updated_commits;
use crate::command_error::CommandError;
use crate::command_error::cli_error;
use crate::command_error::user_error;
use crate::command_error::user_error_with_message;
use crate::formatter::Formatter;
use crate::formatter::FormatterExt as _;
use crate::revset_util::parse_remote_auto_track_bookmarks_map;
Expand Down Expand Up @@ -213,6 +217,7 @@ pub fn load_git_import_options(
auto_local_bookmark: git_settings.auto_local_bookmark,
abandon_unreachable_commits: git_settings.abandon_unreachable_commits,
remote_auto_track_bookmarks: parse_remote_auto_track_bookmarks_map(ui, remote_settings)?,
replace_divergent_changes: false,
})
}

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

/// Builds the user-visible error for a `jj git fetch --rebase` that produced
/// one or more divergent-change problems. Each problem becomes a formatted
/// hint listing the affected commits via the workspace's `commit_summary`
/// template, followed by action hints for each kind that appears.
pub async fn divergent_changes_error(
tx: &WorkspaceCommandTransaction<'_>,
problems: Vec<DivergentChangeProblem>,
) -> Result<CommandError, CommandError> {
let template = tx.commit_summary_template();
let mut cmd_err = user_error_with_message(
"Failed to import refs from underlying Git repo",
GitImportError::DivergentChanges {
problems: problems.clone(),
},
);
let mut has_pre_existing = false;
let mut has_newly_introduced = false;
for problem in &problems {
let (header, commit_ids) = match &problem.kind {
DivergentChangeKind::PreExisting { existing_commits } => {
has_pre_existing = true;
(
format!(
"Already divergent ({} visible local commits):",
existing_commits.len(),
),
existing_commits.clone(),
)
}
DivergentChangeKind::NewlyIntroduced { new_commits } => {
has_newly_introduced = true;
(
format!(
"Fetch-introduced divergence ({} incoming commits):",
new_commits.len(),
),
new_commits.clone(),
)
}
};
let commits = try_join_all(
commit_ids
.iter()
.map(|id| tx.repo().store().get_commit_async(id)),
)
.await?;
cmd_err.add_formatted_hint_with(|formatter| {
writeln!(formatter, "{header}")?;
print_updated_commits(formatter, &template, &commits)
});
}
if has_pre_existing {
cmd_err.add_hint(
"Resolve already-divergent changes (e.g. with `jj abandon` or `jj duplicate`) and \
re-run with `--rebase`.",
);
}
if has_newly_introduced {
cmd_err.add_hint(
"Re-run without `--rebase` to accept the fetch-introduced divergent change(s).",
);
}
Ok(cmd_err)
}

/// Prints only the summary of git import stats (abandoned count, failed refs).
/// Use this when a WorkspaceCommandTransaction is not available.
pub fn print_git_import_stats_summary(ui: &Ui, stats: &GitImportStats) -> Result<(), CommandError> {
Expand Down
5 changes: 5 additions & 0 deletions cli/tests/cli-reference@.md.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1674,6 +1674,11 @@ If a working-copy commit gets abandoned, it will be given a new, empty commit. T

[string pattern syntax]: https://docs.jj-vcs.dev/latest/revsets/#string-patterns
* `--all-remotes` — Fetch from all remotes
* `--rebase` — Rewrite changes from upstream in place instead of creating divergent changes

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.

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.



Expand Down
54 changes: 54 additions & 0 deletions cli/tests/test_git_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2363,3 +2363,57 @@ fn test_git_fetch_auto_track_bookmarks() {
[EOF]
");
}

// The --rebase logic is covered at the lib level in
// lib/tests/test_git.rs (`test_import_refs_rebase_*`). The single CLI test
// below covers only the user-visible rendering of the divergent-changes error
// (the `divergent_changes_error` helper in `cli/src/git_util.rs`), which the
// lib layer can't reach. The lib tests pin the `GitImportError::Display`
// strings for all three `summary()` branches (pre-only / new-only / mixed);
// this test pins the formatter structure — hint header, indented commit list
// rendered through `commit_summary_template`, and the trailing hint — using
// the pre-existing case as a representative. New tests for --rebase behavior
// belong in lib/tests/test_git.rs.
#[test]
fn test_git_fetch_rebase_divergent_changes_error_rendering() {
let test_env = TestEnvironment::default();
test_env.add_config("remotes.origin.auto-track-bookmarks = '*'");
test_env.add_config(r#"revset-aliases."immutable_heads()" = "none()""#);

test_env
.run_jj_in(".", ["git", "init", "--colocate", "source"])
.success();
let source = test_env.work_dir("source");
create_commit(&source, "feat", &[]);

test_env.run_jj_in(".", ["git", "init", "target"]).success();
let target = test_env.work_dir("target");
target
.run_jj(["git", "remote", "add", "origin", "../source/.git"])
.success();
target.run_jj(["git", "fetch"]).success();

// Pin the initial upstream commit with a local bookmark so the next plain
// fetch can't hide it via `abandon-unreachable-commits` (which only
// considers local bookmarks/tags as pins). Two amend + fetch cycles then
// produce pre-existing divergence the third --rebase fetch must reject.
target
.run_jj(["bookmark", "create", "-r=feat@origin", "keepalive"])
.success();
source.run_jj(["describe", "feat", "-m=feat v2"]).success();
target.run_jj(["git", "fetch"]).success();
source.run_jj(["describe", "feat", "-m=feat v3"]).success();

let output = target.run_jj(["git", "fetch", "--rebase"]);
insta::assert_snapshot!(output, @r#"
------- stderr -------
Error: Failed to import refs from underlying Git repo
Caused by: Cannot rewrite changes in place: 1 already-divergent change(s) in the repo
Hint: Already divergent (2 visible local commits):
rlvkpnrz/1 abf1fac5 (divergent) feat v2
rlvkpnrz/2 00d0d0ee keepalive* | (divergent) feat
Hint: Resolve already-divergent changes (e.g. with `jj abandon` or `jj duplicate`) and re-run with `--rebase`.
[EOF]
[exit status: 1]
"#);
}
Loading