Skip to main content

max / makenotwork

Discard what a build wrote before restoring the branch A release rewrites Cargo.lock under the [patch] block, so the tree is dirty when the build ends and `git checkout <branch>` refuses to carry the edit across. The checkout then stays detached at the tag and the next release is refused for a detached HEAD or an uncommitted change nobody made. Three releases on astra on 2026-08-23, three manual cleanups. The preflight now records the repository's dirty set once the tag is checked out, and the restore discards only what appeared after that. An edit that predates the release is left alone and still blocks the restore, which is correct: this is the build putting back what it moved, not a licence to clean somebody's tree. A baseline that could not be read discards nothing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 16:36 UTC
Signed with PGP, not checked
Commit: 30b9b1a7f05f1ca2ba4a77eea69063a8d0fde63c
Parent: fc65520
2 files changed, +414 insertions, -17 deletions
@@ -1150,13 +1150,7 @@
1150 1150 let lines: Vec<String> = status
1151 1151 .lines()
1152 1152 .filter_map(|l| {
1153 - // `XY path`, or `XY old -> new` for a rename. The destination is the
1154 - // path that exists in the working tree, so it is the one to name.
1155 - let path = l.get(3..)?.trim();
1156 - let path = path.rsplit(" -> ").next()?.trim();
1157 - if path.is_empty() {
1158 - return None;
1159 - }
1153 + let path = porcelain_path(l)?;
1160 1154 if !prefix.is_empty() && !path.starts_with(prefix) {
1161 1155 return Some(format!("{path} (outside {prefix}, not part of this app)"));
1162 1156 }
@@ -1166,6 +1160,71 @@
1166 1160 (!lines.is_empty()).then(|| lines.join("\n "))
1167 1161 }
1168 1162
1163 + /// The path one `git status --porcelain` line is about, or `None` for a line
1164 + /// that names none.
1165 + ///
1166 + /// `XY path`, or `XY old -> new` for a rename: the destination is the path that
1167 + /// exists in the working tree, so it is the one every caller wants. A path git
1168 + /// had to quote (one carrying a newline, a quote or a non-UTF-8 byte) arrives
1169 + /// C-quoted and surrounded by `"`, and is returned as git wrote it — see
1170 + /// [`build_written_paths`], which refuses to act on one rather than unescaping
1171 + /// it wrongly.
1172 + fn porcelain_path(line: &str) -> Option<&str> {
1173 + let path = line.get(3..)?.trim();
1174 + let path = path.rsplit(" -> ").next()?.trim();
1175 + (!path.is_empty()).then_some(path)
1176 + }
1177 +
1178 + /// Every tracked path with local changes, repo-root relative, from
1179 + /// [`git_repo_dirty_cmd`] output.
1180 + pub fn dirty_paths(status: &str) -> Vec<String> {
1181 + status
1182 + .lines()
1183 + .filter_map(porcelain_path)
1184 + .map(str::to_string)
1185 + .collect()
1186 + }
1187 +
1188 + /// What the build itself wrote: dirty now, and clean when the release pinned the
1189 + /// tag.
1190 + ///
1191 + /// This is the whole safety argument for discarding anything. The preflight
1192 + /// records the repository's dirty set at pin time, so a file already carrying
1193 + /// somebody's edits before the release started is in `baseline` and is left
1194 + /// alone; what is left is only what appeared while the recipe ran. In practice
1195 + /// that is `Cargo.lock`, which every build rewrites under the `[patch]` block in
1196 + /// `~/Code/.cargo/config.toml` and which is meaningless churn — but the argument
1197 + /// is about provenance, not about the filename, so nothing here knows what a
1198 + /// lockfile is.
1199 + ///
1200 + /// A C-quoted path (git quotes one holding a newline, a quote or a non-UTF-8
1201 + /// byte) is dropped rather than unescaped: getting that wrong means discarding
1202 + /// a file nobody asked about. Dropping it leaves the tree dirty and the restore
1203 + /// then fails loudly, which is the safe direction.
1204 + pub fn build_written_paths(baseline: &[String], now: &[String]) -> Vec<String> {
1205 + now.iter()
1206 + .filter(|p| !p.starts_with('"'))
1207 + .filter(|p| !baseline.contains(p))
1208 + .cloned()
1209 + .collect()
1210 + }
1211 +
1212 + /// Discard the build's own edits, so the tree can go back to its branch.
1213 + ///
1214 + /// `paths` are repo-root relative and are spelled with git's `:/` pathspec
1215 + /// prefix, which is root-anchored whatever directory `-C` names. That matters
1216 + /// for the repos holding several products: `repo` for pom is `~/Code/MNW/pom`,
1217 + /// and the lockfile a pom build rewrites is `pom/Cargo.lock` from the root.
1218 + ///
1219 + /// `checkout HEAD --` rather than `checkout --`, so a change that somehow
1220 + /// reached the index is undone too. HEAD here is the release tag, which is what
1221 + /// the build compiled; the branch checkout that follows moves these files on to
1222 + /// the branch's content.
1223 + pub fn git_discard_paths_cmd(repo: &str, paths: &[String]) -> String {
1224 + let specs: Vec<String> = paths.iter().map(|p| format!("\":/{p}\"")).collect();
1225 + format!("git -C {repo} checkout HEAD -- {}", specs.join(" "))
1226 + }
1227 +
1169 1228 /// Uncommitted changes to TRACKED files, one `XY path` line each, empty when the
1170 1229 /// tree is clean.
1171 1230 ///
@@ -3550,4 +3609,50 @@
3550 3609 assert!(missing.contains("does not exist"), "{missing}");
3551 3610 assert!(!missing.contains("Cargo.lock"), "{missing}");
3552 3611 }
3612 +
3613 + #[test]
3614 + fn dirty_paths_reads_porcelain_including_renames() {
3615 + let status = " M pom/Cargo.lock\nM server/src/main.rs\nR old.rs -> new.rs\n\n";
3616 + assert_eq!(
3617 + dirty_paths(status),
3618 + vec!["pom/Cargo.lock", "server/src/main.rs", "new.rs"]
3619 + );
3620 + assert!(dirty_paths("").is_empty(), "a clean tree names no files");
3621 + }
3622 +
3623 + /// Only what appeared while the build ran is the build's, and a path git had
3624 + /// to quote is nobody's business to discard.
3625 + #[test]
3626 + fn build_written_paths_subtracts_the_pin_time_baseline() {
3627 + let baseline = vec!["server/notes.txt".to_string()];
3628 + let now = vec![
3629 + "server/notes.txt".to_string(),
3630 + "pom/Cargo.lock".to_string(),
3631 + "\"weird\\nname\"".to_string(),
3632 + ];
3633 + assert_eq!(
3634 + build_written_paths(&baseline, &now),
3635 + vec!["pom/Cargo.lock"],
3636 + "the pre-existing edit stays, and the quoted path is left alone"
3637 + );
3638 + assert!(
3639 + build_written_paths(&baseline, &baseline).is_empty(),
3640 + "a build that wrote nothing has nothing discarded"
3641 + );
3642 + }
3643 +
3644 + /// The pathspecs are root-anchored, which is what makes this correct for the
3645 + /// repos holding several products: `repo` is `~/Code/MNW/pom` and the path
3646 + /// is `pom/Cargo.lock` from the root.
3647 + #[test]
3648 + fn git_discard_paths_cmd_anchors_pathspecs_at_the_repo_root() {
3649 + let cmd = git_discard_paths_cmd(
3650 + "~/Code/MNW/pom",
3651 + &["pom/Cargo.lock".to_string(), "wam/Cargo.lock".to_string()],
3652 + );
3653 + assert_eq!(
3654 + cmd,
3655 + "git -C ~/Code/MNW/pom checkout HEAD -- \":/pom/Cargo.lock\" \":/wam/Cargo.lock\""
3656 + );
3657 + }
3553 3658 }
@@ -36,7 +36,7 @@
36 36 /// Comparing only the hosts that answered proves agreement among those, which is
37 37 /// not the claim the barrier is making.
38 38 ///
39 - /// Returns the branch each host was on before it was pinned, for
39 + /// Returns what each host looked like before it was pinned, for
40 40 /// [`restore_branches`] to put back once the build settles, and the commit they
41 41 /// all agreed on. A host that is ALREADY detached has no branch to return to,
42 42 /// and is refused: that state means some earlier release never cleaned up, and
@@ -59,7 +59,7 @@
59 59 version: &Version,
60 60 targets: &[Target],
61 61 ) -> Result<Pinned> {
62 - let mut branches: Vec<(String, String)> = Vec::new();
62 + let mut branches: Vec<PinnedHost> = Vec::new();
63 63 match pin_hosts(state, app, version, targets, &mut branches).await {
64 64 Ok(sha) => Ok(Pinned { branches, sha }),
65 65 Err(e) => {
@@ -83,7 +83,7 @@
83 83 app: &AppId,
84 84 version: &Version,
85 85 targets: &[Target],
86 - branches: &mut Vec<(String, String)>,
86 + branches: &mut Vec<PinnedHost>,
87 87 ) -> Result<String> {
88 88 let cfg = state
89 89 .topo
@@ -153,7 +153,11 @@
153 153 else. Reattach it (`git checkout <branch>`, fast-forwarding if the commits \
154 154 should be kept) before releasing."
155 155 );
156 - branches.push((host.clone(), branch));
156 + branches.push(PinnedHost {
157 + host: host.clone(),
158 + branch,
159 + dirty_before: None,
160 + });
157 161
158 162 // A dirty tree defeats the pin silently, which is worse than not pinning
159 163 // at all. `git checkout <tag>` does not fail on local modifications to
@@ -212,6 +216,33 @@
212 216 );
213 217 }
214 218
219 + // The tree as the build inherits it. Whatever is dirty HERE was dirty
220 + // before the release and belongs to whoever wrote it; anything dirty at
221 + // the end that is not in this set was written by the build, and is the
222 + // only thing `restore_branches` is allowed to discard. Recorded after
223 + // the checkout rather than before it, because the checkout is what
224 + // decides which of the pre-existing edits survive onto the tag.
225 + //
226 + // Repo-wide, not app-scoped: `git checkout <branch>` on the way back is
227 + // repo-wide too, so a file outside the app is exactly as capable of
228 + // holding the restore up. pom's own releases dirtied `pom/Cargo.lock`
229 + // and one earlier one left `wam/Cargo.lock` behind.
230 + let dirty_now = OpStep::shell(Action::Build, engine::git_repo_dirty_cmd(repo));
231 + let baseline = match exec.run_streaming(&dirty_now, &mut sink).await {
232 + Ok(out) if out.status.success() => {
233 + Some(engine::dirty_paths(&String::from_utf8_lossy(&out.stdout)))
234 + }
235 + // A baseline that could not be read is not a reason to refuse a
236 + // release, but it must not be mistaken for "nothing was dirty":
237 + // that reading would let the restore discard somebody's edit. `None`
238 + // discards nothing, which is what this did before it discarded
239 + // anything at all.
240 + _ => None,
241 + };
242 + if let Some(pinned) = branches.last_mut() {
243 + pinned.dirty_before = baseline;
244 + }
245 +
215 246 let rev = OpStep::shell(Action::Build, engine::git_rev_parse_cmd(repo));
216 247 let out = exec
217 248 .run_streaming(&rev, &mut sink)
@@ -272,12 +303,25 @@
272 303 /// What the preflight established: where each host's checkout was, and the one
273 304 /// commit every host is now on.
274 305 struct Pinned {
275 - /// `(host, branch)` for [`restore_branches`].
276 - branches: Vec<(String, String)>,
306 + /// One entry per host the preflight moved, for [`restore_branches`].
307 + branches: Vec<PinnedHost>,
277 308 /// The commit all hosts agreed on. Empty when pinning is off (tests).
278 309 sha: String,
279 310 }
280 311
312 + /// One host's checkout as the preflight found it, and everything
313 + /// [`restore_branches`] needs to put it back.
314 + #[derive(Clone)]
315 + struct PinnedHost {
316 + host: String,
317 + /// The branch it was on before the tag was checked out.
318 + branch: String,
319 + /// Tracked files already carrying local changes once the tag was checked
320 + /// out, repo-root relative. `None` when the tree could not be read, which
321 + /// makes the restore discard nothing rather than guess.
322 + dirty_before: Option<Vec<String>>,
323 + }
324 +
281 325 /// Which tracked files in `repo`'s whole repository have local changes, rendered
282 326 /// for an operator and marked where they fall outside the app's own directory.
283 327 ///
@@ -299,17 +343,40 @@
299 343 engine::dirty_paths_blocking_checkout(&status, &prefix)
300 344 }
301 345
302 - /// Put every host's checkout back on the branch [`pin_release`] found it on.
346 + /// Put every host's checkout back the way [`pin_release`] found it: on its
347 + /// branch, and with the build's own leavings discarded.
303 348 ///
304 349 /// Best-effort and non-fatal: the release itself is already decided by the time
305 350 /// this runs, and failing a green build because a `git checkout` did not take
306 351 /// would be worse than the detached tree it is cleaning up. A failure is logged
307 352 /// loudly instead, because the state it leaves behind is the silent one.
308 - async fn restore_branches(state: &AppState, app: &AppId, branches: &[(String, String)]) {
353 + ///
354 + /// The discard is the half added on 2026-08-24, and it is why a release used to
355 + /// need a person between it and the next one. `cargo build` rewrites
356 + /// `Cargo.lock` under the `[patch]` block in `~/Code/.cargo/config.toml` — churn
357 + /// CLAUDE.md documents as expected and meaningless — which leaves the tree dirty
358 + /// and makes the branch checkout below fail. The tree then stays detached at the
359 + /// tag, and the next release is refused for a detached HEAD or an uncommitted
360 + /// change that nobody made. Measured across three releases on astra on
361 + /// 2026-08-23: three releases, three manual cleanups, none of the diffs meaning
362 + /// anything.
363 + ///
364 + /// What is discarded is only what the build wrote — see
365 + /// [`engine::build_written_paths`], which subtracts the dirty set the preflight
366 + /// recorded once the tag was checked out. An edit that was already there is
367 + /// left alone and will hold the restore up exactly as before, which is correct:
368 + /// this is not a licence to clean a tree, it is the build putting back what it
369 + /// moved.
370 + async fn restore_branches(state: &AppState, app: &AppId, branches: &[PinnedHost]) {
309 371 let Some(cfg) = state.topo.app(app) else {
310 372 return;
311 373 };
312 - for (host, branch) in branches {
374 + for PinnedHost {
375 + host,
376 + branch,
377 + dirty_before,
378 + } in branches
379 + {
313 380 let Some(exec) = state.executors.get(host) else {
314 381 continue;
315 382 };
@@ -324,6 +391,7 @@
324 391 // path on a host that keeps its checkout elsewhere would leave the real
325 392 // one detached and report success.
326 393 let repo = cfg.repo_for(host);
394 + discard_what_the_build_wrote(exec.as_ref(), host, repo, dirty_before.as_deref()).await;
327 395 let step = OpStep::shell(Action::Build, engine::git_restore_branch_cmd(repo, branch));
328 396 match exec.run_streaming(&step, &mut sink).await {
329 397 Ok(out) if out.status.success() => {
@@ -352,6 +420,55 @@
352 420 }
353 421 }
354 422
423 + /// Discard the tracked files this release's build rewrote, and nothing else.
424 + ///
425 + /// `baseline` is what was already dirty when the preflight put this host on the
426 + /// tag. `None` means it could not be read, and then nothing is discarded: a
427 + /// restore that fails is recoverable by hand, and a discarded edit somebody
428 + /// meant to keep is not.
429 + ///
430 + /// Failing to discard is not fatal either. The branch checkout that follows is
431 + /// the operation that matters, and it reports its own failure with the file list
432 + /// attached — so a discard that did not take shows up there rather than being
433 + /// swallowed here.
434 + async fn discard_what_the_build_wrote(
435 + exec: &dyn Executor,
436 + host: &str,
437 + repo: &str,
438 + baseline: Option<&[String]>,
439 + ) {
440 + let Some(baseline) = baseline else {
441 + tracing::debug!(
442 + %host, %repo,
443 + "no pin-time dirty baseline for this host; leaving the working tree as the build left it"
444 + );
445 + return;
446 + };
447 + let mut sink = DiscardSink;
448 + let status = OpStep::shell(Action::Build, engine::git_repo_dirty_cmd(repo));
449 + let now = match exec.run_streaming(&status, &mut sink).await {
450 + Ok(out) if out.status.success() => {
451 + engine::dirty_paths(&String::from_utf8_lossy(&out.stdout))
452 + }
453 + _ => return,
454 + };
455 + let written = engine::build_written_paths(baseline, &now);
456 + if written.is_empty() {
457 + return;
458 + }
459 + let step = OpStep::shell(Action::Build, engine::git_discard_paths_cmd(repo, &written));
460 + let files = written.join(", ");
461 + match exec.run_streaming(&step, &mut sink).await {
462 + Ok(out) if out.status.success() => {
463 + tracing::info!(%host, %repo, %files, "discarded what the build rewrote");
464 + }
465 + _ => tracing::warn!(
466 + %host, %repo, %files,
467 + "could not discard what the build rewrote; the branch restore below will say so"
468 + ),
469 + }
470 + }
471 +
355 472 /// First 12 chars of a sha for a readable error.
356 473 fn short(sha: &str) -> &str {
357 474 sha.get(..12).unwrap_or(sha)
@@ -879,7 +996,7 @@
879 996 build_id: i64,
880 997 mut set: tokio::task::JoinSet<()>,
881 998 app: AppId,
882 - pinned_branches: Vec<(String, String)>,
999 + pinned_branches: Vec<PinnedHost>,
883 1000 ) {
884 1001 const MAX_WAIT: std::time::Duration = std::time::Duration::from_hours(6);
885 1002 let deadline = tokio::time::Instant::now() + MAX_WAIT;
@@ -2870,6 +2987,145 @@
2870 2987 assert_eq!(builds, 0, "a refused preflight writes no build row");
2871 2988 }
2872 2989
2990 + /// A build that rewrites a tracked file still leaves the checkout clean and
2991 + /// on its branch.
2992 + ///
2993 + /// This is the loop that cost three manual cleanups in one afternoon on
2994 + /// astra (2026-08-23). `cargo build` rewrites `Cargo.lock` under the
2995 + /// `[patch]` block, the tree is dirty when the release ends, `git checkout
2996 + /// <branch>` refuses to carry the edit across, and the tree stays DETACHED
2997 + /// at the tag — so the next release is refused for a detached HEAD or for an
2998 + /// uncommitted change nobody made.
2999 + ///
3000 + /// The recipe here appends to a tracked file that the tag and the branch
3001 + /// disagree about, which is exactly the shape that makes the restore fail:
3002 + /// a file identical in both commits is carried across and never blocked
3003 + /// anything.
3004 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3005 + async fn a_release_that_rewrites_a_tracked_file_still_ends_on_its_branch() {
3006 + let tmp = tempfile::tempdir().unwrap();
3007 + let repo = tmp.path().join("demo");
3008 + // The lockfile is tracked in the tag, which is the case that bites: a
3009 + // file the build creates from nothing is untracked and invisible to the
3010 + // dirty gate either way.
3011 + std::fs::create_dir_all(&repo).unwrap();
3012 + std::fs::write(repo.join("Cargo.lock"), "version = 4\n").unwrap();
3013 + // The recipe stands in for `cargo build` regenerating a lockfile.
3014 + init_git_app_with_recipe(
3015 + &repo,
3016 + "0.0.1",
3017 + Some("v0.0.1"),
3018 + "step(\"build\");\nsh_ok(build_host(), \"echo churn >> \" + repo() + \"/Cargo.lock\");\n",
3019 + );
3020 + let git = git_in(&repo);
3021 + // Give the tag and the branch different content for that file, so the
3022 + // restore has something to refuse to carry.
3023 + std::fs::write(repo.join("Cargo.lock"), "version = 4\n# moved on\n").unwrap();
3024 + git(&["add", "-A"]);
3025 + git(&["commit", "-q", "-m", "lock moves on"]);
3026 + let branch_before = current_branch(&repo);
3027 + assert!(!branch_before.is_empty(), "test repo starts on a branch");
3028 +
3029 + let mut cfg = Config::for_tests(tmp.path());
3030 + cfg.pin_release_sha = true;
3031 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
3032 + let state = test_state(pool.clone(), one_host_topo(&repo), cfg);
3033 + let build_id = start_build(
3034 + state,
3035 + AppId::new("demo"),
3036 + Version::parse("0.0.1").unwrap(),
3037 + vec!["linux/x86_64".parse().unwrap()],
3038 + )
3039 + .await
3040 + .unwrap();
3041 + let (status, error) = await_target(&pool, build_id).await;
3042 + assert_eq!(status, "ok", "the build itself should pass ({error})");
3043 + // finalize_build restores after the targets settle, so give it the beat
3044 + // it needs; the assertions below are the point of the test.
3045 + for _ in 0..100 {
3046 + if current_branch(&repo) == branch_before && repo_status(&repo).is_empty() {
3047 + break;
3048 + }
3049 + tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3050 + }
3051 + assert_eq!(
3052 + current_branch(&repo),
3053 + branch_before,
3054 + "the checkout must be back on its branch, not left detached at the tag"
3055 + );
3056 + assert_eq!(
3057 + repo_status(&repo),
3058 + "",
3059 + "what the build rewrote must be discarded, or the next release is refused for it"
3060 + );
3061 + }
3062 +
3063 + /// An edit that was already there when the release started is left alone.
3064 + ///
3065 + /// The restore discards what the BUILD wrote, which it knows by subtracting
3066 + /// the dirty set the preflight recorded once the tag was checked out. Any
3067 + /// wider reading of "clean the tree" would throw away work: the app-scoped
3068 + /// dirty gate deliberately tolerates edits elsewhere in a repo holding
3069 + /// several products, so those edits reach this code every time.
3070 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3071 + async fn an_edit_that_predates_the_release_is_not_discarded() {
3072 + let tmp = tempfile::tempdir().unwrap();
3073 + let root = tmp.path().join("monorepo");
3074 + let app = root.join("pom");
3075 + init_git_app_with_recipe(
3076 + &app,
3077 + "0.0.1",
3078 + None,
3079 + "step(\"build\");\nsh_ok(build_host(), \"echo churn >> \" + repo() + \"/Cargo.lock\");\n",
3080 + );
3081 + // `init_git_app_with_recipe` made `pom/` its own repo; the monorepo is
3082 + // the parent, so drop that and re-init one `.git` over both products.
3083 + std::fs::remove_dir_all(app.join(".git")).unwrap();
3084 + std::fs::create_dir_all(root.join("server")).unwrap();
3085 + std::fs::write(root.join("server/notes.txt"), "upstream\n").unwrap();
3086 + std::fs::write(app.join("Cargo.lock"), "version = 4\n").unwrap();
3087 + let git = git_in(&root);
3088 + git(&["init", "-q"]);
3089 + git(&["add", "-A"]);
3090 + git(&["commit", "-q", "-m", "init"]);
3091 + git(&["tag", "v0.0.1"]);
3092 + // Somebody's work in progress, outside the app being released. Identical
3093 + // in the tag and on the branch, so the pin carries it across and the
3094 + // app-scoped gate never sees it.
3095 + std::fs::write(root.join("server/notes.txt"), "upstream\nmine, unsaved\n").unwrap();
3096 +
3097 + let mut cfg = Config::for_tests(tmp.path());
3098 + cfg.pin_release_sha = true;
3099 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
3100 + let state = test_state(pool.clone(), one_host_topo(&app), cfg);
3101 + let build_id = start_build(
3102 + state,
3103 + AppId::new("demo"),
3104 + Version::parse("0.0.1").unwrap(),
3105 + vec!["linux/x86_64".parse().unwrap()],
3106 + )
3107 + .await
3108 + .unwrap();
3109 + let (status, error) = await_target(&pool, build_id).await;
3110 + assert_eq!(status, "ok", "the build itself should pass ({error})");
3111 + for _ in 0..100 {
3112 + if repo_status(&root) == " M server/notes.txt" {
3113 + break;
3114 + }
3115 + tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3116 + }
3117 + assert_eq!(
3118 + std::fs::read_to_string(root.join("server/notes.txt")).unwrap(),
3119 + "upstream\nmine, unsaved\n",
3120 + "an edit that predates the release must survive it"
3121 + );
3122 + assert_eq!(
3123 + repo_status(&root),
3124 + " M server/notes.txt",
3125 + "the build's own leavings go, and nothing else does"
3126 + );
3127 + }
3128 +
2873 3129 /// A preflight that refuses on a LATER host puts the earlier ones back.
2874 3130 ///
2875 3131 /// The hosts are pinned one at a time, so by the time the second is refused
@@ -3018,6 +3274,42 @@
3018 3274 }
3019 3275
3020 3276 /// The branch `repo` is on, empty on a detached HEAD.
3277 + /// Tracked files with local changes, as `git status --porcelain` writes
3278 + /// them, joined by newlines and empty for a clean tree.
3279 + fn repo_status(repo: &std::path::Path) -> String {
3280 + let out = std::process::Command::new("git")
3281 + .args(["status", "--porcelain", "--untracked-files=no"])
3282 + .current_dir(repo)
3283 + .env("GIT_CONFIG_GLOBAL", "/dev/null")
3284 + .env("GIT_CONFIG_SYSTEM", "/dev/null")
3285 + .output()
3286 + .expect("git runs");
3287 + String::from_utf8_lossy(&out.stdout).trim_end().to_string()
3288 + }
3289 +
3290 + /// Run git in `dir`, isolated from the dev's global config and with an
3291 + /// identity, panicking with git's own stderr on failure.
3292 + fn git_in(dir: &std::path::Path) -> impl Fn(&[&str]) + '_ {
3293 + move |args: &[&str]| {
3294 + let out = std::process::Command::new("git")
3295 + .args(args)
3296 + .current_dir(dir)
3297 + .env("GIT_CONFIG_GLOBAL", "/dev/null")
3298 + .env("GIT_CONFIG_SYSTEM", "/dev/null")
3299 + .env("GIT_AUTHOR_NAME", "t")
3300 + .env("GIT_AUTHOR_EMAIL", "t@t")
3301 + .env("GIT_COMMITTER_NAME", "t")
3302 + .env("GIT_COMMITTER_EMAIL", "t@t")
3303 + .output()
3304 + .expect("git runs");
3305 + assert!(
3306 + out.status.success(),
3307 + "git {args:?}: {}",
3308 + String::from_utf8_lossy(&out.stderr)
3309 + );
3310 + }
3311 + }
3312 +
3021 3313 fn current_branch(repo: &std::path::Path) -> String {
3022 3314 let out = std::process::Command::new("git")
3023 3315 .args(["symbolic-ref", "-q", "--short", "HEAD"])