run: Add --restore-descendants option

This commit is contained in:
Stephen Jennings
2026-06-12 09:40:16 -07:00
parent d9d98b4cf9
commit 4c615c0715
3 changed files with 179 additions and 28 deletions

View File

@@ -548,8 +548,9 @@ async fn rewrite_commit(
/// Run a command across a set of revisions.
///
/// Checks out each revision in an isolated working copy, runs the command, then
/// amends the revision with the resulting working copy. Descendants are rebased
/// on top of the amended revisions.
/// amends the revision with the resulting working copy. By default, descendants
/// are rebased on top of the amended revisions, propagating the diff. Use
/// `--restore-descendants` to keep descendants' content unchanged instead.
///
/// The command is executed with the following environment variables set:
///
@@ -604,6 +605,10 @@ pub struct RunArgs {
/// freshly checked-out tree.
#[arg(long)]
clean: bool,
/// Preserve the content (not the diff) when rebasing descendants
#[arg(long)]
restore_descendants: bool,
}
/// Precedence: `--jobs`, `run.jobs` config, 1.
@@ -780,42 +785,64 @@ pub async fn cmd_run(
}
// The command did something, so rewrite the commits.
let restore_descendants = args.restore_descendants;
let mut count: u32 = 0;
// TODO: handle the `--reparent` case here.
let mut num_reparented: u32 = 0;
tx.repo_mut()
.transform_descendants(
resolved_commits.iter().ids().cloned().collect_vec(),
async |rewriter| {
let old_id = rewriter.old_commit().id().clone();
let builder = rewriter.rebase().await?;
// Only rewrite the tree if the command changed it. Descendants
// that weren't part of the input set still need to be rebased
// but keep their original tree.
if let Some((old_tree, new_tree)) = rewritten_commits.get(&old_id) {
// Apply only the diff the command introduced (new_tree -
// old_tree) on top of the rebased tree. This propagates
// changes into descendants via the normal rebase merge
// rather than being replaced.
let rebased_tree = builder.tree();
let merged = MergedTree::merge(Merge::from_vec(vec![
(
MergedTree::resolved(store.clone(), new_tree.id().clone()),
"command result".to_owned(),
),
(old_tree.clone(), "original commit".to_owned()),
(rebased_tree, "rebased".to_owned()),
]))
.await?;
count += 1;
builder.set_tree(merged).write().await?;
} else {
builder.write().await?;
match (rewritten_commits.get(&old_id), restore_descendants) {
(Some((_, new_tree)), true) => {
let builder = rewriter.rebase().await?;
count += 1;
// Use the command result on top of the commit's
// original tree, ignoring rewrites of its ancestors.
builder
.set_tree(MergedTree::resolved(store.clone(), new_tree.id().clone()))
.write()
.await?;
}
(Some((old_tree, new_tree)), false) => {
let builder = rewriter.rebase().await?;
count += 1;
// Apply the diff the command introduced (new_tree -
// old_tree) on top of the rebased tree, propagating
// ancestor rewrites via the normal rebase merge.
let rebased_tree = builder.tree();
let merged = MergedTree::merge(Merge::from_vec(vec![
(
MergedTree::resolved(store.clone(), new_tree.id().clone()),
"command result".to_owned(),
),
(old_tree.clone(), "original commit".to_owned()),
(rebased_tree, "rebased".to_owned()),
]))
.await?;
builder.set_tree(merged).write().await?;
}
(None, true) => {
// Descendant outside the run set — keep its content.
rewriter.reparent().write().await?;
num_reparented += 1;
}
(None, false) => {
// Default: propagate the diff into descendants.
rewriter.rebase().await?.write().await?;
}
}
Ok(())
},
)
.await?;
writeln!(ui.stderr(), "Rewrote {count} commits")?;
if restore_descendants && num_reparented > 0 {
writeln!(
ui.stderr(),
"Rebased {num_reparented} descendant commits (while preserving their content)"
)?;
}
tx.finish(ui, format!("run: rewrite {count} commits"))
.await?;

View File

@@ -2968,8 +2968,9 @@ Show the current workspace root directory (shortcut for `jj workspace root`)
Run a command across a set of revisions.
Checks out each revision in an isolated working copy, runs the command, then
amends the revision with the resulting working copy. Descendants are rebased
on top of the amended revisions.
amends the revision with the resulting working copy. By default, descendants
are rebased on top of the amended revisions, propagating the diff. Use
`--restore-descendants` to keep descendants' content unchanged instead.
The command is executed with the following environment variables set:
@@ -3003,6 +3004,7 @@ $ jj run -j 4 -- pre-commit run .github/pre-commit.yaml
* `--clean` — Delete each working copy before running the command
By default `jj run` reuses working copies between invocations so build artifacts are preserved. With `--clean`, every commit starts from a freshly checked-out tree.
* `--restore-descendants` — Preserve the content (not the diff) when rebasing descendants

View File

@@ -19,6 +19,7 @@ use insta::assert_snapshot;
use crate::common::TestEnvironment;
use crate::common::TestWorkDir;
use crate::common::create_commit_with_files;
#[test]
fn test_run_simple() {
@@ -1139,6 +1140,127 @@ fn test_run_pool_failed_command_does_not_poison_slot() {
);
}
#[test]
fn test_run_restore_descendants_preserves_content() {
let mut test_env = TestEnvironment::default();
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
let fake_formatter = assert_cmd::cargo::cargo_bin("fake-formatter");
assert!(fake_formatter.is_file());
let fake_formatter_path = fake_formatter.to_string_lossy().into_owned();
test_env.add_paths_to_normalize(fake_formatter.clone(), "$FAKE_FORMATTER_PATH");
let work_dir = test_env.work_dir("repo");
create_commit_with_files(&work_dir, "a", &[], &[("file", "a\n")]);
create_commit_with_files(&work_dir, "b", &["a"], &[("file", "b\n")]);
create_commit_with_files(&work_dir, "c", &["b"], &[("file", "c\n")]);
let command = if cfg!(windows) {
format!("{fake_formatter_path} --tee ran-%JJ_CHANGE_ID%.txt")
} else {
format!("{fake_formatter_path} --tee ran-$JJ_CHANGE_ID.txt")
};
let args: &[&str] = if cfg!(windows) {
&[
"run",
"-r",
"a::b",
"--restore-descendants",
"--",
"cmd",
"/c",
command.as_str(),
]
} else {
&[
"run",
"-r",
"a::b",
"--restore-descendants",
"--",
"sh",
"-c",
command.as_str(),
]
};
let output = work_dir.run_jj(args).success();
assert_snapshot!(output.stderr, @r"
Rewrote 2 commits
Rebased 1 descendant commits (while preserving their content)
Working copy (@) now at: royxmykx a741a7d3 c | c
Parent commit (@-) : zsuskuln 43c5a714 b | b
[EOF]
");
assert_snapshot!(
work_dir
.run_jj(&["file", "list", "-r", "a"])
.success()
.stdout,
@r"
file
ran-rlvkpnrzqnoowoytxnquwvuryrwnrmlp.txt
[EOF]
"
);
assert_snapshot!(
work_dir
.run_jj(&["file", "list", "-r", "b"])
.success()
.stdout,
@r"
file
ran-zsuskulnrvyrovkzqrwmxqlsskqntxvp.txt
[EOF]
"
);
assert_snapshot!(
work_dir
.run_jj(&["file", "list", "-r", "c"])
.success()
.stdout,
@r"
file
[EOF]
"
);
assert_snapshot!(
work_dir.run_jj(&["diff", "--from=a", "--to=b", "--git"]).success().stdout,
@r"
diff --git a/file b/file
index 7898192261..6178079822 100644
--- a/file
+++ b/file
@@ -1,1 +1,1 @@
-a
+b
diff --git a/ran-rlvkpnrzqnoowoytxnquwvuryrwnrmlp.txt b/ran-rlvkpnrzqnoowoytxnquwvuryrwnrmlp.txt
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/ran-zsuskulnrvyrovkzqrwmxqlsskqntxvp.txt b/ran-zsuskulnrvyrovkzqrwmxqlsskqntxvp.txt
new file mode 100644
index 0000000000..e69de29bb2
[EOF]
"
);
assert_snapshot!(
work_dir.run_jj(&["diff", "--from=b", "--to=c", "--git"]).success().stdout,
@r"
diff --git a/file b/file
index 6178079822..f2ad6c76f0 100644
--- a/file
+++ b/file
@@ -1,1 +1,1 @@
-b
+c
diff --git a/ran-zsuskulnrvyrovkzqrwmxqlsskqntxvp.txt b/ran-zsuskulnrvyrovkzqrwmxqlsskqntxvp.txt
deleted file mode 100644
index e69de29bb2..0000000000
[EOF]
"
);
}
#[test]
fn test_run_failure_shows_output() {
let test_env = TestEnvironment::default();