Skip to main content

magi/
worktree_setup.rs

1//! `[worktree] setup`: what a fresh worktree needs beyond its tracked files.
2//!
3//! magi makes its worktrees with plain `git worktree add`, so a `.env` or a
4//! one-off bootstrap that `renri add` would have produced is missing. Each
5//! step runs in order, right after the worktree exists and before any agent
6//! or verify command touches it. A failing step fails that worktree with the
7//! step and its output; nothing here is ever swallowed.
8//!
9//! Three rules keep the competition honest:
10//!
11//! - **Blindness.** `MAGI_RUN` / `MAGI_NODE` are removed from a command's
12//!   environment, and nothing seat- or agent-shaped is exported.
13//! - **Products are not work.** Every path the steps leave untracked is
14//!   recorded in the worktree's own git dir ([`RECORD`]) and hidden from `git
15//!   add -A` by a per-worktree `core.excludesFile`; [`withheld_paths`] is the
16//!   second line, used by the rescue commits.
17//! - **Setup does not edit tracked files.** One that does fails the worktree,
18//!   which also stops a `kata apply`-shaped step: every candidate would carry
19//!   the same diff.
20
21use std::path::{Component, Path, PathBuf};
22use std::time::Duration;
23
24use anyhow::{Context, Result, bail};
25
26use crate::config::{Config, SetupStep};
27use crate::git;
28use crate::proc::Quiet as _;
29
30/// File in the worktree's git dir listing the setup products, NUL-separated.
31pub const RECORD: &str = "magi-setup-paths";
32/// Per-worktree exclude file `core.excludesFile` points at.
33const EXCLUDE: &str = "magi-setup-exclude";
34/// Output kept in an error message.
35const TAIL: usize = 4000;
36
37/// Run `cfg.worktree.setup` in `wt`, a worktree of `repo` (the primary checkout).
38///
39/// A no-op for the default empty list. Idempotent, since a reviewer's worktree
40/// is wiped and set up again every round.
41pub async fn prepare(cfg: &Config, repo: &Path, wt: &Path) -> Result<()> {
42    let steps = &cfg.worktree.setup;
43    if steps.is_empty() {
44        return Ok(());
45    }
46    let total = steps.len();
47    let mut copied: Vec<String> = Vec::new();
48    for (i, step) in steps.iter().enumerate() {
49        let n = i + 1;
50        let what = step.describe();
51        match (&step.copy, &step.run) {
52            (Some(spec), None) => {
53                let made = copy_step(repo, wt, spec, step.optional)
54                    .await
55                    .with_context(|| format!("worktree setup step {n}/{total} ({what}) failed"))?;
56                copied.extend(made);
57            }
58            (None, Some(command)) => {
59                let secs = step
60                    .timeout_secs
61                    .unwrap_or_else(|| cfg.graph.verify_timeout());
62                run_step(&cfg.shell(), command, wt, Duration::from_secs(secs))
63                    .await
64                    .with_context(|| format!("worktree setup step {n}/{total} ({what}) failed"))?;
65            }
66            _ => bail!("worktree setup step {n}/{total} must set exactly one of copy / run"),
67        }
68    }
69    seal(wt, &copied).await.context("worktree setup")
70}
71
72/// `src -> dst`, or just `path` for the same path on both sides.
73fn split_copy(spec: &str) -> (&str, &str) {
74    match spec.split_once("->") {
75        Some((a, b)) => (a.trim(), b.trim()),
76        None => (spec.trim(), spec.trim()),
77    }
78}
79
80/// Is `p` a path that stays inside the worktree, and out of `.git`?
81pub fn valid_destination(p: &str) -> Result<()> {
82    if p.is_empty() {
83        bail!("destination is empty");
84    }
85    let path = Path::new(p);
86    if path.is_absolute() || p.starts_with('/') || p.starts_with('\\') || p.contains(':') {
87        bail!("destination `{p}` must be relative to the worktree");
88    }
89    let mut first = true;
90    for c in path.components() {
91        match c {
92            Component::Normal(s) => {
93                if first && s.eq_ignore_ascii_case(".git") {
94                    bail!("destination `{p}` is inside .git");
95                }
96                first = false;
97            }
98            Component::CurDir => {}
99            _ => bail!("destination `{p}` must not contain `..`"),
100        }
101    }
102    Ok(())
103}
104
105/// Validate a `copy` spec at load time.
106pub fn validate_copy(spec: &str) -> Result<()> {
107    let (src, dst) = split_copy(spec);
108    if src.is_empty() {
109        bail!("copy `{spec}` has no source");
110    }
111    valid_destination(dst)
112}
113
114/// Returns the destination when this call created it.
115async fn copy_step(repo: &Path, wt: &Path, spec: &str, optional: bool) -> Result<Option<String>> {
116    let (src, dst) = split_copy(spec);
117    validate_copy(spec)?;
118    let src_path = if Path::new(src).is_absolute() {
119        PathBuf::from(src)
120    } else {
121        repo.join(src)
122    };
123    let dst_path = wt.join(dst);
124    // A destination reached through a symlink could land outside the worktree.
125    let mut probe = wt.to_path_buf();
126    for c in Path::new(dst).components() {
127        if let Component::Normal(s) = c {
128            probe.push(s);
129            if let Ok(meta) = tokio::fs::symlink_metadata(&probe).await
130                && meta.file_type().is_symlink()
131            {
132                bail!("destination `{dst}` goes through a symlink");
133            }
134        }
135    }
136    let data = match tokio::fs::read(&src_path).await {
137        Ok(d) => d,
138        Err(e) if e.kind() == std::io::ErrorKind::NotFound && optional => return Ok(None),
139        Err(e) => bail!("cannot read source {}: {e}", src_path.display()),
140    };
141    if let Some(parent) = dst_path.parent() {
142        tokio::fs::create_dir_all(parent)
143            .await
144            .with_context(|| format!("create {}", parent.display()))?;
145    }
146    // `create_new`: an existing file in the worktree wins, never overwritten.
147    use tokio::io::AsyncWriteExt as _;
148    match tokio::fs::OpenOptions::new()
149        .write(true)
150        .create_new(true)
151        .open(&dst_path)
152        .await
153    {
154        Ok(mut f) => {
155            f.write_all(&data).await?;
156            f.flush().await?;
157            #[cfg(unix)]
158            if let Ok(meta) = tokio::fs::metadata(&src_path).await {
159                tokio::fs::set_permissions(&dst_path, meta.permissions())
160                    .await
161                    .ok();
162            }
163            Ok(Some(dst.trim_start_matches("./").to_owned()))
164        }
165        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
166        Err(e) => bail!("cannot create {}: {e}", dst_path.display()),
167    }
168}
169
170fn tail(s: &str) -> &str {
171    let s = s.trim();
172    if s.len() <= TAIL {
173        return s;
174    }
175    let mut at = s.len() - TAIL;
176    while !s.is_char_boundary(at) {
177        at += 1;
178    }
179    &s[at..]
180}
181
182async fn run_step(shell: &[String], command: &str, wt: &Path, timeout: Duration) -> Result<()> {
183    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
184    let log = std::env::temp_dir().join(format!(
185        "magi-setup-{}-{}.out",
186        std::process::id(),
187        SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
188    ));
189    let out = std::fs::File::create(&log).context("create output file")?;
190    let err = out.try_clone()?;
191    let mut cmd = tokio::process::Command::new(&shell[0]);
192    cmd.quiet();
193    cmd.args(&shell[1..])
194        .arg(command)
195        .current_dir(wt)
196        .env_remove("MAGI_RUN")
197        .env_remove("MAGI_NODE")
198        .stdin(std::process::Stdio::null())
199        .stdout(out)
200        .stderr(err)
201        .kill_on_drop(true);
202    crate::incremental::configure(&mut cmd, wt)?;
203    let mut running = crate::seat_process::Running::spawn_operation(cmd, None, Vec::new())
204        .with_context(|| format!("spawn `{}` for `{command}`", shell[0]))?;
205    let waited = tokio::time::timeout(timeout, running.child().wait()).await;
206    // Compiler wrappers supervise their own process groups. Join cleanup of
207    // the entire setup tree before returning, on success as well as timeout.
208    // Cancellation transfers the same ownership to Running's cleanup thread.
209    running.finish().await;
210    let body = || tail(&std::fs::read_to_string(&log).unwrap_or_default()).to_owned();
211    let result = match waited {
212        Ok(Ok(status)) if status.success() => Ok(()),
213        Ok(Ok(status)) => Err(anyhow::anyhow!(
214            "`{command}` exited with {status}\n{}",
215            body()
216        )),
217        Ok(Err(e)) => Err(anyhow::anyhow!("`{command}` could not be awaited: {e}")),
218        Err(_) => Err(anyhow::anyhow!(
219            "`{command}` timed out after {}s\n{}",
220            timeout.as_secs(),
221            body()
222        )),
223    };
224    std::fs::remove_file(&log).ok();
225    result
226}
227
228async fn git_dir(wt: &Path) -> Result<PathBuf> {
229    Ok(PathBuf::from(
230        git::git(wt, &["rev-parse", "--absolute-git-dir"])
231            .await?
232            .trim(),
233    ))
234}
235
236/// Paths a worktree's setup recorded, relative to it. Empty when none.
237pub async fn withheld_paths(wt: &Path) -> Vec<String> {
238    let Ok(dir) = git_dir(wt).await else {
239        return Vec::new();
240    };
241    let Ok(raw) = tokio::fs::read(dir.join(RECORD)).await else {
242        return Vec::new();
243    };
244    raw.split(|b| *b == 0)
245        .filter(|p| !p.is_empty())
246        .map(|p| String::from_utf8_lossy(p).into_owned())
247        .collect()
248}
249
250/// Unstage whatever setup produced, so a rescue commit never carries it.
251pub async fn unstage_products(wt: &Path) -> Result<()> {
252    let paths = withheld_paths(wt).await;
253    if paths.is_empty() {
254        return Ok(());
255    }
256    let mut args = vec!["reset", "-q", "--"];
257    args.extend(paths.iter().map(String::as_str));
258    git::git(wt, &args).await.map(|_| ())
259}
260
261/// After the steps: refuse tracked edits, record the untracked products and
262/// hide them from `git add`.
263async fn seal(wt: &Path, copied: &[String]) -> Result<()> {
264    let tracked = git::git(wt, &["diff", "--name-only", "HEAD"]).await?;
265    if !tracked.trim().is_empty() {
266        bail!(
267            "setup must not change tracked files (every seat would carry the same diff), \
268             but it changed:\n{}",
269            tail(&tracked)
270        );
271    }
272    let out = git::git(wt, &["ls-files", "-z", "--others", "--exclude-standard"]).await?;
273    let mut paths: Vec<String> = withheld_paths(wt).await;
274    // Copied files are recorded even when `.gitignore` hides them from
275    // `--exclude-standard`: a later `git add -f` or an edited ignore rule must
276    // still not carry them into a commit.
277    for p in copied
278        .iter()
279        .map(String::as_str)
280        .chain(out.split('\0').filter(|p| !p.is_empty()))
281    {
282        if !paths.iter().any(|q| q == p) {
283            paths.push(p.to_owned());
284        }
285    }
286    if paths.is_empty() {
287        return Ok(());
288    }
289    let dir = git_dir(wt).await?;
290    let mut raw = Vec::new();
291    for p in &paths {
292        raw.extend_from_slice(p.as_bytes());
293        raw.push(0);
294    }
295    tokio::fs::write(dir.join(RECORD), raw).await?;
296    hide(wt, &dir, &paths).await
297}
298
299fn exclude_line(p: &str) -> String {
300    let mut s = String::from("/");
301    for c in p.chars() {
302        if matches!(c, '*' | '?' | '[' | ']' | '\\' | '#' | '!' | ' ') {
303            s.push('\\');
304        }
305        s.push(c);
306    }
307    s
308}
309
310/// Point this worktree's `core.excludesFile` at its products, keeping the
311/// user's own excludes. Needs `extensions.worktreeConfig`, which the caller
312/// takes with `git::acquire_worktree_config`; without it `--worktree` would
313/// write the shared config and change the operator's checkout, so that is a
314/// loud failure, never a fallback.
315async fn hide(wt: &Path, dir: &Path, paths: &[String]) -> Result<()> {
316    let ext = git::git_raw(wt, &["config", "--get", "extensions.worktreeConfig"]).await?;
317    if !(ext.ok() && ext.stdout.trim() == "true") {
318        bail!(
319            "setup left untracked files ({}) but extensions.worktreeConfig is off, so they \
320             cannot be hidden from `git add` per worktree",
321            paths.join(", ")
322        );
323    }
324    let file = dir.join(EXCLUDE);
325    let file_s = file.to_string_lossy().replace('\\', "/");
326    let current = git::git_raw(wt, &["config", "--get", "core.excludesFile"])
327        .await
328        .ok()
329        .filter(|o| o.ok())
330        .map(|o| o.stdout.trim().to_owned())
331        .filter(|s| !s.is_empty());
332    let mut body = match current {
333        Some(c) if c == file_s => std::fs::read_to_string(&file).unwrap_or_default(),
334        Some(c) => {
335            let c = match c.strip_prefix("~/") {
336                Some(rest) => std::env::var_os("HOME")
337                    .map(|h| PathBuf::from(h).join(rest))
338                    .unwrap_or_else(|| PathBuf::from(&c)),
339                None => PathBuf::from(&c),
340            };
341            std::fs::read_to_string(c).unwrap_or_default()
342        }
343        None => String::new(),
344    };
345    if !body.is_empty() && !body.ends_with('\n') {
346        body.push('\n');
347    }
348    for p in paths {
349        let line = exclude_line(p);
350        if !body.lines().any(|l| l == line) {
351            body.push_str(&line);
352            body.push('\n');
353        }
354    }
355    std::fs::write(&file, body).with_context(|| format!("write {}", file.display()))?;
356    git::git(wt, &["config", "--worktree", "core.excludesFile", &file_s])
357        .await
358        .map(|_| ())
359}
360
361impl SetupStep {
362    /// One-line description for errors.
363    pub fn describe(&self) -> String {
364        match (&self.copy, &self.run) {
365            (Some(c), _) => format!("copy {c}"),
366            (_, Some(r)) => format!("run {r}"),
367            _ => "empty".to_owned(),
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::config::SetupStep;
376
377    fn copy(spec: &str) -> SetupStep {
378        SetupStep {
379            copy: Some(spec.to_owned()),
380            ..Default::default()
381        }
382    }
383
384    fn run(cmd: &str) -> SetupStep {
385        SetupStep {
386            run: Some(cmd.to_owned()),
387            ..Default::default()
388        }
389    }
390
391    fn cfg(steps: Vec<SetupStep>) -> Config {
392        let mut c = Config::default();
393        c.worktree.setup = steps;
394        c.verify.shell = Some(vec!["sh".to_owned(), "-c".to_owned()]);
395        c
396    }
397
398    /// A repository with one commit and a detached linked worktree.
399    async fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) {
400        let tmp = tempfile::tempdir().unwrap();
401        let repo = tmp.path().join("repo");
402        std::fs::create_dir_all(&repo).unwrap();
403        for args in [
404            vec!["init", "-b", "main"],
405            vec!["config", "user.name", "t"],
406            vec!["config", "user.email", "t@example.com"],
407        ] {
408            git::git(&repo, &args).await.unwrap();
409        }
410        std::fs::write(repo.join("a.txt"), "a\n").unwrap();
411        git::git(&repo, &["add", "-A"]).await.unwrap();
412        git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
413        let wt = tmp.path().join("wt");
414        git::worktree_add_detached(&repo, &wt, "HEAD")
415            .await
416            .unwrap();
417        // What `prep` and the other callers take before setup runs.
418        git::acquire_worktree_config(&repo).await.unwrap();
419        (tmp, repo, wt)
420    }
421
422    #[tokio::test]
423    async fn default_is_a_no_op() {
424        let (_t, repo, wt) = scratch().await;
425        prepare(&Config::default(), &repo, &wt).await.unwrap();
426        assert!(withheld_paths(&wt).await.is_empty());
427    }
428
429    #[tokio::test]
430    async fn copy_then_run_in_order() {
431        let (_t, repo, wt) = scratch().await;
432        std::fs::write(repo.join(".env.example"), "KEY=1\n").unwrap();
433        let c = cfg(vec![
434            copy(".env.example -> .env"),
435            run("cat .env > seen.txt && echo ran >> seen.txt"),
436        ]);
437        prepare(&c, &repo, &wt).await.unwrap();
438        assert_eq!(
439            std::fs::read_to_string(wt.join("seen.txt")).unwrap(),
440            "KEY=1\nran\n"
441        );
442        let mut got = withheld_paths(&wt).await;
443        got.sort();
444        assert_eq!(got, [".env", "seen.txt"]);
445    }
446
447    #[tokio::test]
448    async fn a_failing_step_stops_and_names_step_and_output() {
449        let (_t, repo, wt) = scratch().await;
450        let c = cfg(vec![run("echo boom >&2; exit 3"), run("touch after.txt")]);
451        let err = format!("{:#}", prepare(&c, &repo, &wt).await.unwrap_err());
452        assert!(err.contains("step 1/2"), "{err}");
453        assert!(err.contains("boom"), "{err}");
454        assert!(!wt.join("after.txt").exists());
455    }
456
457    #[tokio::test]
458    async fn a_timeout_keeps_the_partial_output() {
459        let (_t, repo, wt) = scratch().await;
460        let mut step = run("echo partial; sleep 30");
461        step.timeout_secs = Some(1);
462        let err = format!(
463            "{:#}",
464            prepare(&cfg(vec![step]), &repo, &wt).await.unwrap_err()
465        );
466        assert!(err.contains("timed out"), "{err}");
467        assert!(err.contains("partial"), "{err}");
468    }
469
470    #[tokio::test]
471    async fn copy_never_overwrites() {
472        let (_t, repo, wt) = scratch().await;
473        std::fs::write(repo.join("src.txt"), "new\n").unwrap();
474        std::fs::write(wt.join("dst.txt"), "mine\n").unwrap();
475        prepare(&cfg(vec![copy("src.txt -> dst.txt")]), &repo, &wt)
476            .await
477            .unwrap();
478        assert_eq!(
479            std::fs::read_to_string(wt.join("dst.txt")).unwrap(),
480            "mine\n"
481        );
482    }
483
484    #[tokio::test]
485    async fn a_missing_source_is_an_error_unless_optional() {
486        let (_t, repo, wt) = scratch().await;
487        let err = format!(
488            "{:#}",
489            prepare(&cfg(vec![copy("nope")]), &repo, &wt)
490                .await
491                .unwrap_err()
492        );
493        assert!(err.contains("step 1/1") && err.contains("nope"), "{err}");
494        let mut opt = copy("nope");
495        opt.optional = true;
496        prepare(&cfg(vec![opt]), &repo, &wt).await.unwrap();
497        assert!(!wt.join("nope").exists());
498    }
499
500    #[tokio::test]
501    async fn setup_may_not_edit_tracked_files() {
502        let (_t, repo, wt) = scratch().await;
503        let err = format!(
504            "{:#}",
505            prepare(&cfg(vec![run("echo x >> a.txt")]), &repo, &wt)
506                .await
507                .unwrap_err()
508        );
509        assert!(err.contains("tracked") && err.contains("a.txt"), "{err}");
510    }
511
512    #[tokio::test]
513    async fn the_command_does_not_see_the_run_identity() {
514        let (_t, repo, wt) = scratch().await;
515        // Safe in a unit test: only this process reads them, via the child.
516        unsafe {
517            std::env::set_var("MAGI_RUN", "r1");
518            std::env::set_var("MAGI_NODE", "n1");
519        }
520        prepare(
521            &cfg(vec![run("echo \"[$MAGI_RUN][$MAGI_NODE]\" > ids.txt")]),
522            &repo,
523            &wt,
524        )
525        .await
526        .unwrap();
527        assert_eq!(
528            std::fs::read_to_string(wt.join("ids.txt")).unwrap(),
529            "[][]\n"
530        );
531    }
532
533    #[tokio::test]
534    async fn products_are_kept_out_of_both_commit_paths() {
535        let (_t, repo, wt) = scratch().await;
536        std::fs::write(repo.join("secret"), "s\n").unwrap();
537        prepare(&cfg(vec![copy("secret")]), &repo, &wt)
538            .await
539            .unwrap();
540
541        // Hidden from the agent's own `git add -A` ...
542        git::git(&wt, &["add", "-A"]).await.unwrap();
543        assert!(
544            git::git(&wt, &["diff", "--cached", "--name-only"])
545                .await
546                .unwrap()
547                .is_empty()
548        );
549
550        // ... and a rescue with only the product commits nothing.
551        assert!(!git::commit_all(&wt, "rescue").await.unwrap());
552        let r = git::rescue_commit(&wt, "rescue").await.unwrap();
553        assert!(!r.committed);
554
555        // Real work beside it is committed, the product is not.
556        std::fs::write(wt.join("work.txt"), "w\n").unwrap();
557        assert!(git::commit_all(&wt, "rescue").await.unwrap());
558        let files = git::git(&wt, &["show", "--name-only", "--format=", "HEAD"])
559            .await
560            .unwrap();
561        assert_eq!(files.trim(), "work.txt");
562    }
563
564    #[tokio::test]
565    async fn products_without_worktree_config_fail_loudly_and_leave_shared_config_alone() {
566        let (_t, repo, wt) = scratch().await;
567        git::release_worktree_config(&repo).await.unwrap();
568        std::fs::write(repo.join("secret"), "s\n").unwrap();
569        let err = format!(
570            "{:#}",
571            prepare(&cfg(vec![copy("secret")]), &repo, &wt)
572                .await
573                .unwrap_err()
574        );
575        assert!(err.contains("worktreeConfig"), "{err}");
576        let shared = git::git_raw(&repo, &["config", "--get", "core.excludesFile"])
577            .await
578            .unwrap();
579        assert!(!shared.ok(), "shared config was written: {}", shared.stdout);
580    }
581
582    #[tokio::test]
583    async fn an_ignored_copy_is_still_withheld_from_commits() {
584        let (_t, repo, wt) = scratch().await;
585        std::fs::write(wt.join(".gitignore"), ".env\n").unwrap();
586        git::git(&wt, &["add", ".gitignore"]).await.unwrap();
587        git::git(&wt, &["commit", "-m", "ignore"]).await.unwrap();
588        std::fs::write(repo.join(".env"), "s\n").unwrap();
589        prepare(&cfg(vec![copy(".env")]), &repo, &wt).await.unwrap();
590        assert_eq!(withheld_paths(&wt).await, [".env"]);
591        // The agent force-adds it, then magi rescues.
592        git::git(&wt, &["add", "-f", ".env"]).await.unwrap();
593        std::fs::write(wt.join("work.txt"), "w\n").unwrap();
594        assert!(git::commit_all(&wt, "rescue").await.unwrap());
595        let files = git::git(&wt, &["show", "--name-only", "--format=", "HEAD"])
596            .await
597            .unwrap();
598        assert_eq!(files.trim(), "work.txt");
599    }
600
601    #[test]
602    fn destinations_must_stay_inside() {
603        for bad in ["/etc/x", "../x", "a/../../x", ".git/hooks/x", "C:/x", ""] {
604            assert!(valid_destination(bad).is_err(), "{bad}");
605        }
606        assert!(valid_destination("a/b.env").is_ok());
607    }
608}