| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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"])
|