Skip to main content

max / makenotwork

15.5 KB · 429 lines History Blame Raw
1 //! Thin shell wrappers around `git`. We avoid pulling in libgit2 — the daemon
2 //! shells out for `cargo build` and friends anyway, and `git` is always on the
3 //! MakeMachine.
4
5 use anyhow::{Context, Result};
6 use std::path::Path;
7 use tokio::process::Command;
8
9 const POST_RECEIVE: &str = include_str!("../../hooks/post-receive");
10
11 pub async fn ensure_bare_repo(path: &Path) -> Result<()> {
12 init_bare_repo(path).await?;
13 install_hook(path).await?;
14 Ok(())
15 }
16
17 /// Like [`ensure_bare_repo`] but installs no `post-receive` hook. For an
18 /// auxiliary repo (e.g. synckit) Sando only ever *fetches* — nobody pushes to
19 /// its bare — so the build-triggering hook would be dead weight, and worse, if
20 /// it ever did fire it would kick an MNW build. Keep the aux bare inert.
21 pub async fn ensure_bare_repo_no_hook(path: &Path) -> Result<()> {
22 init_bare_repo(path).await
23 }
24
25 /// `git init --bare` at `path` if it isn't already a repo. Idempotent.
26 async fn init_bare_repo(path: &Path) -> Result<()> {
27 if !path.join("HEAD").exists() {
28 tokio::fs::create_dir_all(path).await?;
29 let out = Command::new("git")
30 .args(["init", "--bare", "--initial-branch=main"])
31 .arg(path)
32 .output()
33 .await
34 .context("spawning git init")?;
35 anyhow::ensure!(
36 out.status.success(),
37 "git init --bare failed: {}",
38 String::from_utf8_lossy(&out.stderr),
39 );
40 }
41 Ok(())
42 }
43
44 async fn install_hook(bare: &Path) -> Result<()> {
45 let hook = bare.join("hooks").join("post-receive");
46 tokio::fs::create_dir_all(bare.join("hooks")).await?;
47 tokio::fs::write(&hook, POST_RECEIVE).await?;
48 #[cfg(unix)]
49 {
50 use std::os::unix::fs::PermissionsExt;
51 let mut perm = tokio::fs::metadata(&hook).await?.permissions();
52 perm.set_mode(0o755);
53 tokio::fs::set_permissions(&hook, perm).await?;
54 }
55 Ok(())
56 }
57
58 pub async fn resolve_ref(bare: &Path, refname: &str) -> Result<String> {
59 let out = Command::new("git")
60 .arg("--git-dir")
61 .arg(bare)
62 .args(["rev-parse", refname])
63 .output()
64 .await?;
65 anyhow::ensure!(
66 out.status.success(),
67 "git rev-parse {refname} failed: {}",
68 String::from_utf8_lossy(&out.stderr),
69 );
70 Ok(String::from_utf8(out.stdout)?.trim().to_string())
71 }
72
73 /// Fetch the deploy branch from `upstream` into the bare repo so a
74 /// freshly-pushed sha becomes locally resolvable before `checkout_worktree`.
75 /// Force-updates `refs/heads/<branch>` (worktrees are `--detach`, so the
76 /// branch is never checked out). Pull-based deploys: the operator pushes to
77 /// the canonical remote, Sando fetches it here.
78 pub async fn fetch_upstream(bare: &Path, upstream: &str, branch: &str) -> Result<()> {
79 let out = Command::new("git")
80 .arg("--git-dir")
81 .arg(bare)
82 .args(["fetch", "--quiet", upstream])
83 .arg(format!("+refs/heads/{branch}:refs/heads/{branch}"))
84 .output()
85 .await
86 .context("spawning git fetch")?;
87 anyhow::ensure!(
88 out.status.success(),
89 "git fetch {upstream} {branch} failed: {}",
90 String::from_utf8_lossy(&out.stderr),
91 );
92 Ok(())
93 }
94
95 /// True iff `sha` is reachable from `refs/heads/<branch>` in the bare repo.
96 ///
97 /// The same provenance seal `deploy/sando-self-update.sh` enforces before it
98 /// builds, evaluated here so `/self-update` can refuse a non-deploy-branch sha
99 /// synchronously instead of returning `accepted: true` and failing with exit 4
100 /// in the updater unit's journal, where nobody is watching.
101 ///
102 /// Fail-closed: `git merge-base --is-ancestor` exits 1 for a non-ancestor and
103 /// >1 for an unresolvable ref, and both read as "not an ancestor" here.
104 pub async fn is_ancestor(bare: &Path, sha: &str, branch: &str) -> Result<bool> {
105 let out = Command::new("git")
106 .arg("--git-dir")
107 .arg(bare)
108 .args(["merge-base", "--is-ancestor", sha])
109 .arg(format!("refs/heads/{branch}"))
110 .output()
111 .await
112 .context("spawning git merge-base --is-ancestor")?;
113 Ok(out.status.success())
114 }
115
116 /// True if `sha` resolves to a commit object in the bare repo. Used to give a
117 /// clear "push first" error instead of a cryptic `git worktree add` failure.
118 pub async fn sha_present(bare: &Path, sha: &str) -> Result<bool> {
119 let out = Command::new("git")
120 .arg("--git-dir")
121 .arg(bare)
122 .args(["cat-file", "-e"])
123 .arg(format!("{sha}^{{commit}}"))
124 .output()
125 .await
126 .context("spawning git cat-file")?;
127 Ok(out.status.success())
128 }
129
130 pub async fn checkout_worktree(bare: &Path, sha: &str, dest: &Path) -> Result<()> {
131 if dest.exists() {
132 // A dir already exists at `dest`. Trust it ONLY if it is a real git
133 // worktree whose checked-out HEAD is exactly `sha`. Otherwise it is a
134 // stale or partial leftover — a crashed `worktree add`, a half-populated
135 // build dir, or a worktree for a different sha — which would silently
136 // get compiled and shipped as if it were `sha`. Remove and recreate.
137 if worktree_head_matches(bare, dest, sha)
138 .await
139 .unwrap_or(false)
140 {
141 return Ok(());
142 }
143 tracing::warn!(
144 dest = %dest.display(), sha,
145 "existing path is not a valid worktree at this sha; removing and recreating",
146 );
147 remove_worktree(bare, dest).await?;
148 }
149 tokio::fs::create_dir_all(dest.parent().unwrap()).await?;
150 // Drop admin entries for worktrees whose dirs are gone, so `add` can't
151 // collide with a leftover registration for this path.
152 prune_worktrees(bare).await;
153 let out = Command::new("git")
154 .arg("--git-dir")
155 .arg(bare)
156 .args(["worktree", "add", "--detach"])
157 .arg(dest)
158 .arg(sha)
159 .output()
160 .await?;
161 anyhow::ensure!(
162 out.status.success(),
163 "git worktree add failed: {}",
164 String::from_utf8_lossy(&out.stderr),
165 );
166 Ok(())
167 }
168
169 /// True iff `dest` is a git worktree whose checked-out HEAD commit equals the
170 /// commit `sha` resolves to in `bare`. Any failure (not a worktree, unresolvable
171 /// sha) is reported as "no match" rather than an error — the caller recreates.
172 async fn worktree_head_matches(bare: &Path, dest: &Path, sha: &str) -> Result<bool> {
173 let want = resolve_commit(bare, sha).await?;
174 let Ok(have) = worktree_head(dest).await else {
175 return Ok(false);
176 };
177 Ok(have == want)
178 }
179
180 /// Resolve `sha` (short or full) to its full commit id in the bare repo.
181 async fn resolve_commit(bare: &Path, sha: &str) -> Result<String> {
182 let out = Command::new("git")
183 .arg("--git-dir")
184 .arg(bare)
185 .args(["rev-parse", "--verify", "--quiet"])
186 .arg(format!("{sha}^{{commit}}"))
187 .output()
188 .await?;
189 anyhow::ensure!(
190 out.status.success(),
191 "sha {sha} does not resolve to a commit in the bare repo"
192 );
193 Ok(String::from_utf8(out.stdout)?.trim().to_string())
194 }
195
196 /// The full HEAD commit id checked out in a worktree dir. Errors if `dest` is
197 /// not a valid git worktree.
198 async fn worktree_head(dest: &Path) -> Result<String> {
199 let out = Command::new("git")
200 .arg("-C")
201 .arg(dest)
202 .args(["rev-parse", "--verify", "--quiet", "HEAD"])
203 .output()
204 .await?;
205 anyhow::ensure!(
206 out.status.success(),
207 "not a git worktree (rev-parse HEAD failed)"
208 );
209 Ok(String::from_utf8(out.stdout)?.trim().to_string())
210 }
211
212 /// Remove a worktree dir and its admin registration. Prefers `git worktree
213 /// remove --force`; falls back to a filesystem remove + prune when `dest` isn't
214 /// a registered worktree (a partial leftover git won't recognize).
215 async fn remove_worktree(bare: &Path, dest: &Path) -> Result<()> {
216 let out = Command::new("git")
217 .arg("--git-dir")
218 .arg(bare)
219 .args(["worktree", "remove", "--force"])
220 .arg(dest)
221 .output()
222 .await?;
223 if !out.status.success() {
224 if dest.exists() {
225 tokio::fs::remove_dir_all(dest)
226 .await
227 .with_context(|| format!("removing stale worktree dir {}", dest.display()))?;
228 }
229 prune_worktrees(bare).await;
230 }
231 Ok(())
232 }
233
234 /// `git worktree prune` — drop admin entries for worktrees whose dirs are gone.
235 /// Best-effort: a prune failure is logged, never fatal.
236 async fn prune_worktrees(bare: &Path) {
237 match Command::new("git")
238 .arg("--git-dir")
239 .arg(bare)
240 .args(["worktree", "prune"])
241 .output()
242 .await
243 {
244 Ok(o) if !o.status.success() => {
245 tracing::warn!(stderr = %String::from_utf8_lossy(&o.stderr), "git worktree prune failed");
246 }
247 Err(e) => tracing::warn!(error = %e, "spawning git worktree prune failed"),
248 _ => {}
249 }
250 }
251
252 #[cfg(test)]
253 mod tests {
254 use super::*;
255
256 async fn git(repo: &Path, args: &[&str]) -> std::process::Output {
257 Command::new("git")
258 .args(["-c", "user.email=t@t", "-c", "user.name=t"])
259 .current_dir(repo)
260 .args(args)
261 .output()
262 .await
263 .unwrap()
264 }
265
266 async fn rev_parse(repo: &Path, r: &str) -> String {
267 let o = git(repo, &["rev-parse", r]).await;
268 assert!(o.status.success(), "rev-parse {r} failed");
269 String::from_utf8(o.stdout).unwrap().trim().to_string()
270 }
271
272 /// A repo with two commits; returns (tmp, gitdir, sha1, sha2).
273 async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
274 let tmp = tempfile::tempdir().unwrap();
275 let repo = tmp.path().join("repo");
276 tokio::fs::create_dir_all(&repo).await.unwrap();
277 assert!(
278 git(&repo, &["init", "-q", "-b", "main"])
279 .await
280 .status
281 .success()
282 );
283 tokio::fs::write(repo.join("a.txt"), b"one").await.unwrap();
284 assert!(git(&repo, &["add", "."]).await.status.success());
285 assert!(
286 git(&repo, &["commit", "-q", "-m", "one"])
287 .await
288 .status
289 .success()
290 );
291 let sha1 = rev_parse(&repo, "HEAD").await;
292 tokio::fs::write(repo.join("b.txt"), b"two").await.unwrap();
293 assert!(git(&repo, &["add", "."]).await.status.success());
294 assert!(
295 git(&repo, &["commit", "-q", "-m", "two"])
296 .await
297 .status
298 .success()
299 );
300 let sha2 = rev_parse(&repo, "HEAD").await;
301 let gitdir = repo.join(".git");
302 (tmp, gitdir, sha1, sha2)
303 }
304
305 #[tokio::test]
306 async fn ensure_bare_repo_installs_hook_but_no_hook_variant_does_not() {
307 let tmp = tempfile::tempdir().unwrap();
308 let with_hook = tmp.path().join("hooked.git");
309 let without = tmp.path().join("bare.git");
310
311 ensure_bare_repo(&with_hook).await.unwrap();
312 ensure_bare_repo_no_hook(&without).await.unwrap();
313
314 // Both are real bare repos.
315 assert!(with_hook.join("HEAD").exists());
316 assert!(without.join("HEAD").exists());
317 // Only the main-repo variant carries the build-trigger hook.
318 assert!(with_hook.join("hooks/post-receive").exists());
319 assert!(!without.join("hooks/post-receive").exists());
320
321 // Both are idempotent.
322 ensure_bare_repo_no_hook(&without).await.unwrap();
323 assert!(!without.join("hooks/post-receive").exists());
324 }
325
326 #[tokio::test]
327 async fn is_ancestor_seals_the_deploy_branch() {
328 // The provenance seal `/self-update` checks before it triggers the
329 // privileged updater: a sha on the deploy branch passes, anything else
330 // is refused fail-closed. `two_commit_repo` puts both commits on `main`.
331 let (tmp, gitdir, sha1, sha2) = two_commit_repo().await;
332 assert!(is_ancestor(&gitdir, &sha1, "main").await.unwrap());
333 assert!(is_ancestor(&gitdir, &sha2, "main").await.unwrap());
334
335 // A commit that exists but is not on the deploy branch.
336 let repo = tmp.path().join("repo");
337 assert!(
338 git(&repo, &["checkout", "-q", "-b", "side", &sha1])
339 .await
340 .status
341 .success()
342 );
343 tokio::fs::write(repo.join("c.txt"), b"three")
344 .await
345 .unwrap();
346 assert!(git(&repo, &["add", "."]).await.status.success());
347 assert!(
348 git(&repo, &["commit", "-q", "-m", "three"])
349 .await
350 .status
351 .success()
352 );
353 let side = rev_parse(&repo, "HEAD").await;
354 assert!(
355 !is_ancestor(&gitdir, &side, "main").await.unwrap(),
356 "a feature-branch tip is not deployable",
357 );
358
359 // An unresolvable ref is "not an ancestor", never an error that a caller
360 // could mistake for a pass.
361 assert!(
362 !is_ancestor(&gitdir, "0123456789abcdef0123456789abcdef01234567", "main")
363 .await
364 .unwrap()
365 );
366 assert!(!is_ancestor(&gitdir, &sha1, "no-such-branch").await.unwrap());
367 }
368
369 #[tokio::test]
370 async fn checkout_worktree_creates_at_sha() {
371 let (tmp, gitdir, sha1, _sha2) = two_commit_repo().await;
372 let dest = tmp.path().join("wt");
373 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
374 assert!(dest.join("a.txt").exists());
375 assert!(!dest.join("b.txt").exists(), "sha1 predates b.txt");
376 assert_eq!(rev_parse(&dest, "HEAD").await, sha1);
377 }
378
379 #[tokio::test]
380 async fn checkout_worktree_idempotent_reuses_valid_worktree() {
381 let (tmp, gitdir, sha1, _) = two_commit_repo().await;
382 let dest = tmp.path().join("wt");
383 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
384 // A marker that an idempotent re-checkout must NOT wipe.
385 tokio::fs::write(dest.join("marker"), b"x").await.unwrap();
386 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
387 assert!(
388 dest.join("marker").exists(),
389 "valid worktree reused, not recreated"
390 );
391 assert_eq!(rev_parse(&dest, "HEAD").await, sha1);
392 }
393
394 #[tokio::test]
395 async fn checkout_worktree_replaces_partial_leftover() {
396 // dest exists but is NOT a worktree (a crashed mid-add). It must be
397 // replaced with a real checkout, not built as-is.
398 let (tmp, gitdir, sha1, _) = two_commit_repo().await;
399 let dest = tmp.path().join("wt");
400 tokio::fs::create_dir_all(&dest).await.unwrap();
401 tokio::fs::write(dest.join("garbage"), b"partial")
402 .await
403 .unwrap();
404 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
405 assert!(
406 dest.join("a.txt").exists(),
407 "real checkout populated the dir"
408 );
409 assert!(!dest.join("garbage").exists(), "stale leftover removed");
410 assert_eq!(rev_parse(&dest, "HEAD").await, sha1);
411 }
412
413 #[tokio::test]
414 async fn checkout_worktree_recreates_when_existing_sha_differs() {
415 let (tmp, gitdir, sha1, sha2) = two_commit_repo().await;
416 let dest = tmp.path().join("wt");
417 checkout_worktree(&gitdir, &sha2, &dest).await.unwrap();
418 assert!(dest.join("b.txt").exists());
419 // Re-checkout the SAME dest at the older sha — the wrong-sha worktree
420 // must be torn down and rebuilt, not silently reused.
421 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
422 assert_eq!(rev_parse(&dest, "HEAD").await, sha1);
423 assert!(
424 !dest.join("b.txt").exists(),
425 "now at sha1, which predates b.txt"
426 );
427 }
428 }
429