run: Add --clean flag to wipe working copies before each job

--clean deletes each workspace's working_copy/ and state/ directories
after acquiring the lock, so every commit starts from a freshly
checked-out tree. Build artifacts from previous runs are not reused.
This commit is contained in:
Stephen Jennings
2026-06-11 17:04:58 -07:00
parent 5a9b9c3b95
commit 425881c360
3 changed files with 55 additions and 61 deletions

View File

@@ -167,6 +167,9 @@ struct WorkspacePool {
/// rewritten commit. Loaded once from `snapshot.auto-track`; essentially
/// the user's `.gitignore` story for what counts as a build artifact.
auto_tracking_matcher: Box<dyn Matcher>,
/// When true, wipe each slot's working copy on acquisition so every commit
/// starts from a freshly checked-out tree (no artifact reuse).
clean: bool,
}
impl WorkspacePool {
@@ -174,6 +177,7 @@ impl WorkspacePool {
repo_path: &Path,
size: NonZeroUsize,
auto_tracking_matcher: Box<dyn Matcher>,
clean: bool,
) -> Result<Self, RunError> {
// The parent() call is needed to not write under `.jj/repo/`.
let base_path = repo_path.parent().unwrap().join("run").join("default");
@@ -182,6 +186,7 @@ impl WorkspacePool {
base_path,
size,
auto_tracking_matcher,
clean,
})
}
@@ -209,7 +214,7 @@ impl WorkspacePool {
let is_reused_workspace = tree_state_path.exists();
let settings = default_tree_state_settings();
let mut tree_state = if is_reused_workspace {
let mut tree_state = if !self.clean && is_reused_workspace {
// Load the persisted tree state so `check_out` below can diff
// against it, only touching files that changed and removing files
// no longer present in the new tree.
@@ -227,10 +232,15 @@ impl WorkspacePool {
fs::remove_file(&tree_state_path)?;
ts
} else {
// First use, or the previous job crashed before saving. Wipe any
// leftover working copy so we start from a clean slate, then use
// an in-memory empty tree state. `tree_state` stays absent on disk
// until a job writes it via `persist()`.
// This is the first use of the workspace, the previous job crashed,
// or --clean was passed. Wipe any leftover working copy so we start
// from a clean slate, then use an in-memory empty tree state.
// `tree_state` stays absent on disk until a successful job writes
// it via `persist()`.
fs::remove_file(&tree_state_path).or_else(|e| match e {
e if e.kind() == io::ErrorKind::NotFound => Ok(()),
e => Err(RunError::PathDeletionFailure(tree_state_path.clone(), e)),
})?;
fs::remove_dir_all(&working_copy_dir).or_else(|e| match e {
e if e.kind() == io::ErrorKind::NotFound => Ok(()),
e => Err(RunError::PathDeletionFailure(working_copy_dir.clone(), e)),
@@ -586,6 +596,14 @@ pub struct RunArgs {
/// from the subdirectory `jj run` was invoked from.
#[arg(long)]
root: bool,
/// 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.
#[arg(long)]
clean: bool,
}
/// Precedence: `--jobs`, `run.jobs` config, 1.
@@ -680,7 +698,12 @@ pub async fn cmd_run(
let mut done_commits = HashSet::new();
let (sender_tx, mut receiver) = mpsc::channel(jobs.get());
let pool = Arc::new(WorkspacePool::new(&repo_path, jobs, auto_tracking_matcher)?);
let pool = Arc::new(WorkspacePool::new(
&repo_path,
jobs,
auto_tracking_matcher,
args.clean,
)?);
let stored_len = resolved_commits.len();
let spec = Arc::new(CommandSpec {

View File

@@ -3000,6 +3000,9 @@ $ jj run -j 4 -- pre-commit run .github/pre-commit.yaml
Overrides the `run.jobs` config setting. Defaults to 1 if neither is set.
* `--root` — Run the command from the working-copy root in each commit instead of from the subdirectory `jj run` was invoked from
* `--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.

View File

@@ -947,61 +947,6 @@ fn test_run_pool_removes_file_absent_in_next_commit() {
);
}
/// Files tracked into a commit by run 1 must not reappear in a different
/// commit processed by the same pool slot in run 2.
///
/// Run 1 rewrites commit2 (adding artifact.txt). The slot's saved tree_state
/// therefore records {seed.txt, artifact.txt}. Run 2 targets commit1 whose
/// tree is {seed.txt}; the checkout diff removes artifact.txt from the slot,
/// so commit1's rewrite must not contain it.
#[test]
fn test_run_pool_no_file_leak_between_invocations() {
let test_env = TestEnvironment::default();
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
let work_dir = test_env.work_dir("repo");
// commit1: seed.txt only.
work_dir.write_file("seed.txt", "seed");
work_dir.run_jj(&["commit", "-m", "commit1"]).success();
// commit2: descends from commit1, no extra files (same tree).
work_dir.run_jj(&["commit", "-m", "commit2"]).success();
// Stack: root → commit1 (seed.txt) → commit2 (seed.txt) → @
// Run 1: produce artifact.txt in commit2 (@-) so the slot's saved
// tree_state records {seed.txt, artifact.txt}.
// Stack before run 1: root → commit1 (@--) → commit2 (@-) → @
work_dir
.run_jj(&[
"run",
"--config",
"run.jobs=1",
"-r",
"@-",
"--",
"touch",
"artifact.txt",
])
.success();
// After run 1: root → commit1 (@--) → commit2' (@-) → @ (rebased).
// commit2' now has {seed.txt, artifact.txt}.
// Run 2: no-op on commit1 (@--). Pool reuses the slot whose tree_state
// says {seed.txt, artifact.txt}. The checkout diff removes artifact.txt,
// so commit1's rewrite must not contain it.
work_dir
.run_jj(&["run", "--config", "run.jobs=1", "-r", "@--", "--", "true"])
.success();
// After run 2: root → commit1' (@--) → commit2'' (@-) → @ (rebased).
assert_snapshot!(
work_dir.run_jj(&["file", "list", "-r", "@--"]).success().stdout,
@r"
seed.txt
[EOF]
",
);
}
/// When a pool slot is reused, files left on disk that the previous commit's
/// .gitignore excluded must be removed before the next commit runs. Otherwise
/// the next commit (which may not ignore the same paths) would pick them up at
@@ -1077,6 +1022,29 @@ fn test_run_pool_removes_previously_ignored_files() {
// unnecessarily remove files that are still ignored
assert!(fs::exists(run_wc.join("always_ignored.txt")).unwrap());
assert!(!fs::exists(run_wc.join("ignored.txt")).unwrap());
// Passing --clean removes even always-ignored files
work_dir
.run_jj(&[
"run",
"-r=a",
"--clean",
"--",
&fake_formatter_path,
"--stdout",
"done",
])
.success();
assert_snapshot!(
work_dir.run_jj(&["file", "list", "-r", "a"]),
@r"
.gitignore
seed.txt
[EOF]
"
);
assert!(!fs::exists(run_wc.join("always_ignored.txt")).unwrap());
assert!(!fs::exists(run_wc.join("ignored.txt")).unwrap());
}
/// A slot whose tree_state is absent (simulating a crash mid-job) should be