Skip to main content

max / makenotwork

13.0 KB · 365 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 if `sha` resolves to a commit object in the bare repo. Used to give a
96 /// clear "push first" error instead of a cryptic `git worktree add` failure.
97 pub async fn sha_present(bare: &Path, sha: &str) -> Result<bool> {
98 let out = Command::new("git")
99 .arg("--git-dir")
100 .arg(bare)
101 .args(["cat-file", "-e"])
102 .arg(format!("{sha}^{{commit}}"))
103 .output()
104 .await
105 .context("spawning git cat-file")?;
106 Ok(out.status.success())
107 }
108
109 pub async fn checkout_worktree(bare: &Path, sha: &str, dest: &Path) -> Result<()> {
110 if dest.exists() {
111 // A dir already exists at `dest`. Trust it ONLY if it is a real git
112 // worktree whose checked-out HEAD is exactly `sha`. Otherwise it is a
113 // stale or partial leftover — a crashed `worktree add`, a half-populated
114 // build dir, or a worktree for a different sha — which would silently
115 // get compiled and shipped as if it were `sha`. Remove and recreate.
116 if worktree_head_matches(bare, dest, sha)
117 .await
118 .unwrap_or(false)
119 {
120 return Ok(());
121 }
122 tracing::warn!(
123 dest = %dest.display(), sha,
124 "existing path is not a valid worktree at this sha; removing and recreating",
125 );
126 remove_worktree(bare, dest).await?;
127 }
128 tokio::fs::create_dir_all(dest.parent().unwrap()).await?;
129 // Drop admin entries for worktrees whose dirs are gone, so `add` can't
130 // collide with a leftover registration for this path.
131 prune_worktrees(bare).await;
132 let out = Command::new("git")
133 .arg("--git-dir")
134 .arg(bare)
135 .args(["worktree", "add", "--detach"])
136 .arg(dest)
137 .arg(sha)
138 .output()
139 .await?;
140 anyhow::ensure!(
141 out.status.success(),
142 "git worktree add failed: {}",
143 String::from_utf8_lossy(&out.stderr),
144 );
145 Ok(())
146 }
147
148 /// True iff `dest` is a git worktree whose checked-out HEAD commit equals the
149 /// commit `sha` resolves to in `bare`. Any failure (not a worktree, unresolvable
150 /// sha) is reported as "no match" rather than an error — the caller recreates.
151 async fn worktree_head_matches(bare: &Path, dest: &Path, sha: &str) -> Result<bool> {
152 let want = resolve_commit(bare, sha).await?;
153 let Ok(have) = worktree_head(dest).await else {
154 return Ok(false);
155 };
156 Ok(have == want)
157 }
158
159 /// Resolve `sha` (short or full) to its full commit id in the bare repo.
160 async fn resolve_commit(bare: &Path, sha: &str) -> Result<String> {
161 let out = Command::new("git")
162 .arg("--git-dir")
163 .arg(bare)
164 .args(["rev-parse", "--verify", "--quiet"])
165 .arg(format!("{sha}^{{commit}}"))
166 .output()
167 .await?;
168 anyhow::ensure!(
169 out.status.success(),
170 "sha {sha} does not resolve to a commit in the bare repo"
171 );
172 Ok(String::from_utf8(out.stdout)?.trim().to_string())
173 }
174
175 /// The full HEAD commit id checked out in a worktree dir. Errors if `dest` is
176 /// not a valid git worktree.
177 async fn worktree_head(dest: &Path) -> Result<String> {
178 let out = Command::new("git")
179 .arg("-C")
180 .arg(dest)
181 .args(["rev-parse", "--verify", "--quiet", "HEAD"])
182 .output()
183 .await?;
184 anyhow::ensure!(
185 out.status.success(),
186 "not a git worktree (rev-parse HEAD failed)"
187 );
188 Ok(String::from_utf8(out.stdout)?.trim().to_string())
189 }
190
191 /// Remove a worktree dir and its admin registration. Prefers `git worktree
192 /// remove --force`; falls back to a filesystem remove + prune when `dest` isn't
193 /// a registered worktree (a partial leftover git won't recognize).
194 async fn remove_worktree(bare: &Path, dest: &Path) -> Result<()> {
195 let out = Command::new("git")
196 .arg("--git-dir")
197 .arg(bare)
198 .args(["worktree", "remove", "--force"])
199 .arg(dest)
200 .output()
201 .await?;
202 if !out.status.success() {
203 if dest.exists() {
204 tokio::fs::remove_dir_all(dest)
205 .await
206 .with_context(|| format!("removing stale worktree dir {}", dest.display()))?;
207 }
208 prune_worktrees(bare).await;
209 }
210 Ok(())
211 }
212
213 /// `git worktree prune` — drop admin entries for worktrees whose dirs are gone.
214 /// Best-effort: a prune failure is logged, never fatal.
215 async fn prune_worktrees(bare: &Path) {
216 match Command::new("git")
217 .arg("--git-dir")
218 .arg(bare)
219 .args(["worktree", "prune"])
220 .output()
221 .await
222 {
223 Ok(o) if !o.status.success() => {
224 tracing::warn!(stderr = %String::from_utf8_lossy(&o.stderr), "git worktree prune failed");
225 }
226 Err(e) => tracing::warn!(error = %e, "spawning git worktree prune failed"),
227 _ => {}
228 }
229 }
230
231 #[cfg(test)]
232 mod tests {
233 use super::*;
234
235 async fn git(repo: &Path, args: &[&str]) -> std::process::Output {
236 Command::new("git")
237 .args(["-c", "user.email=t@t", "-c", "user.name=t"])
238 .current_dir(repo)
239 .args(args)
240 .output()
241 .await
242 .unwrap()
243 }
244
245 async fn rev_parse(repo: &Path, r: &str) -> String {
246 let o = git(repo, &["rev-parse", r]).await;
247 assert!(o.status.success(), "rev-parse {r} failed");
248 String::from_utf8(o.stdout).unwrap().trim().to_string()
249 }
250
251 /// A repo with two commits; returns (tmp, gitdir, sha1, sha2).
252 async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
253 let tmp = tempfile::tempdir().unwrap();
254 let repo = tmp.path().join("repo");
255 tokio::fs::create_dir_all(&repo).await.unwrap();
256 assert!(
257 git(&repo, &["init", "-q", "-b", "main"])
258 .await
259 .status
260 .success()
261 );
262 tokio::fs::write(repo.join("a.txt"), b"one").await.unwrap();
263 assert!(git(&repo, &["add", "."]).await.status.success());
264 assert!(
265 git(&repo, &["commit", "-q", "-m", "one"])
266 .await
267 .status
268 .success()
269 );
270 let sha1 = rev_parse(&repo, "HEAD").await;
271 tokio::fs::write(repo.join("b.txt"), b"two").await.unwrap();
272 assert!(git(&repo, &["add", "."]).await.status.success());
273 assert!(
274 git(&repo, &["commit", "-q", "-m", "two"])
275 .await
276 .status
277 .success()
278 );
279 let sha2 = rev_parse(&repo, "HEAD").await;
280 let gitdir = repo.join(".git");
281 (tmp, gitdir, sha1, sha2)
282 }
283
284 #[tokio::test]
285 async fn ensure_bare_repo_installs_hook_but_no_hook_variant_does_not() {
286 let tmp = tempfile::tempdir().unwrap();
287 let with_hook = tmp.path().join("hooked.git");
288 let without = tmp.path().join("bare.git");
289
290 ensure_bare_repo(&with_hook).await.unwrap();
291 ensure_bare_repo_no_hook(&without).await.unwrap();
292
293 // Both are real bare repos.
294 assert!(with_hook.join("HEAD").exists());
295 assert!(without.join("HEAD").exists());
296 // Only the main-repo variant carries the build-trigger hook.
297 assert!(with_hook.join("hooks/post-receive").exists());
298 assert!(!without.join("hooks/post-receive").exists());
299
300 // Both are idempotent.
301 ensure_bare_repo_no_hook(&without).await.unwrap();
302 assert!(!without.join("hooks/post-receive").exists());
303 }
304
305 #[tokio::test]
306 async fn checkout_worktree_creates_at_sha() {
307 let (tmp, gitdir, sha1, _sha2) = two_commit_repo().await;
308 let dest = tmp.path().join("wt");
309 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
310 assert!(dest.join("a.txt").exists());
311 assert!(!dest.join("b.txt").exists(), "sha1 predates b.txt");
312 assert_eq!(rev_parse(&dest, "HEAD").await, sha1);
313 }
314
315 #[tokio::test]
316 async fn checkout_worktree_idempotent_reuses_valid_worktree() {
317 let (tmp, gitdir, sha1, _) = two_commit_repo().await;
318 let dest = tmp.path().join("wt");
319 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
320 // A marker that an idempotent re-checkout must NOT wipe.
321 tokio::fs::write(dest.join("marker"), b"x").await.unwrap();
322 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
323 assert!(
324 dest.join("marker").exists(),
325 "valid worktree reused, not recreated"
326 );
327 assert_eq!(rev_parse(&dest, "HEAD").await, sha1);
328 }
329
330 #[tokio::test]
331 async fn checkout_worktree_replaces_partial_leftover() {
332 // dest exists but is NOT a worktree (a crashed mid-add). It must be
333 // replaced with a real checkout, not built as-is.
334 let (tmp, gitdir, sha1, _) = two_commit_repo().await;
335 let dest = tmp.path().join("wt");
336 tokio::fs::create_dir_all(&dest).await.unwrap();
337 tokio::fs::write(dest.join("garbage"), b"partial")
338 .await
339 .unwrap();
340 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
341 assert!(
342 dest.join("a.txt").exists(),
343 "real checkout populated the dir"
344 );
345 assert!(!dest.join("garbage").exists(), "stale leftover removed");
346 assert_eq!(rev_parse(&dest, "HEAD").await, sha1);
347 }
348
349 #[tokio::test]
350 async fn checkout_worktree_recreates_when_existing_sha_differs() {
351 let (tmp, gitdir, sha1, sha2) = two_commit_repo().await;
352 let dest = tmp.path().join("wt");
353 checkout_worktree(&gitdir, &sha2, &dest).await.unwrap();
354 assert!(dest.join("b.txt").exists());
355 // Re-checkout the SAME dest at the older sha — the wrong-sha worktree
356 // must be torn down and rebuilt, not silently reused.
357 checkout_worktree(&gitdir, &sha1, &dest).await.unwrap();
358 assert_eq!(rev_parse(&dest, "HEAD").await, sha1);
359 assert!(
360 !dest.join("b.txt").exists(),
361 "now at sha1, which predates b.txt"
362 );
363 }
364 }
365