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    #[cfg(unix)]
203    cmd.process_group(0);
204    let mut child = cmd
205        .spawn()
206        .with_context(|| format!("spawn `{}` for `{command}`", shell[0]))?;
207    let pid = child.id();
208    let waited = tokio::time::timeout(timeout, child.wait()).await;
209    let body = || tail(&std::fs::read_to_string(&log).unwrap_or_default()).to_owned();
210    let result = match waited {
211        Ok(Ok(status)) if status.success() => Ok(()),
212        Ok(Ok(status)) => Err(anyhow::anyhow!(
213            "`{command}` exited with {status}\n{}",
214            body()
215        )),
216        Ok(Err(e)) => Err(anyhow::anyhow!("`{command}` could not be awaited: {e}")),
217        Err(_) => {
218            kill_tree(pid, &mut child).await;
219            Err(anyhow::anyhow!(
220                "`{command}` timed out after {}s\n{}",
221                timeout.as_secs(),
222                body()
223            ))
224        }
225    };
226    std::fs::remove_file(&log).ok();
227    result
228}
229
230/// Stop the command and, on unix, the process group it leads.
231async fn kill_tree(pid: Option<u32>, child: &mut tokio::process::Child) {
232    #[cfg(unix)]
233    if let Some(pid) = pid {
234        let mut kill = tokio::process::Command::new("kill");
235        kill.args(["-KILL", "--", &format!("-{pid}")]);
236        kill.status().await.ok();
237    }
238    #[cfg(not(unix))]
239    let _ = pid;
240    child.start_kill().ok();
241    child.wait().await.ok();
242}
243
244async fn git_dir(wt: &Path) -> Result<PathBuf> {
245    Ok(PathBuf::from(
246        git::git(wt, &["rev-parse", "--absolute-git-dir"])
247            .await?
248            .trim(),
249    ))
250}
251
252/// Paths a worktree's setup recorded, relative to it. Empty when none.
253pub async fn withheld_paths(wt: &Path) -> Vec<String> {
254    let Ok(dir) = git_dir(wt).await else {
255        return Vec::new();
256    };
257    let Ok(raw) = tokio::fs::read(dir.join(RECORD)).await else {
258        return Vec::new();
259    };
260    raw.split(|b| *b == 0)
261        .filter(|p| !p.is_empty())
262        .map(|p| String::from_utf8_lossy(p).into_owned())
263        .collect()
264}
265
266/// Unstage whatever setup produced, so a rescue commit never carries it.
267pub async fn unstage_products(wt: &Path) -> Result<()> {
268    let paths = withheld_paths(wt).await;
269    if paths.is_empty() {
270        return Ok(());
271    }
272    let mut args = vec!["reset", "-q", "--"];
273    args.extend(paths.iter().map(String::as_str));
274    git::git(wt, &args).await.map(|_| ())
275}
276
277/// After the steps: refuse tracked edits, record the untracked products and
278/// hide them from `git add`.
279async fn seal(wt: &Path, copied: &[String]) -> Result<()> {
280    let tracked = git::git(wt, &["diff", "--name-only", "HEAD"]).await?;
281    if !tracked.trim().is_empty() {
282        bail!(
283            "setup must not change tracked files (every seat would carry the same diff), \
284             but it changed:\n{}",
285            tail(&tracked)
286        );
287    }
288    let out = git::git(wt, &["ls-files", "-z", "--others", "--exclude-standard"]).await?;
289    let mut paths: Vec<String> = withheld_paths(wt).await;
290    // Copied files are recorded even when `.gitignore` hides them from
291    // `--exclude-standard`: a later `git add -f` or an edited ignore rule must
292    // still not carry them into a commit.
293    for p in copied
294        .iter()
295        .map(String::as_str)
296        .chain(out.split('\0').filter(|p| !p.is_empty()))
297    {
298        if !paths.iter().any(|q| q == p) {
299            paths.push(p.to_owned());
300        }
301    }
302    if paths.is_empty() {
303        return Ok(());
304    }
305    let dir = git_dir(wt).await?;
306    let mut raw = Vec::new();
307    for p in &paths {
308        raw.extend_from_slice(p.as_bytes());
309        raw.push(0);
310    }
311    tokio::fs::write(dir.join(RECORD), raw).await?;
312    hide(wt, &dir, &paths).await
313}
314
315fn exclude_line(p: &str) -> String {
316    let mut s = String::from("/");
317    for c in p.chars() {
318        if matches!(c, '*' | '?' | '[' | ']' | '\\' | '#' | '!' | ' ') {
319            s.push('\\');
320        }
321        s.push(c);
322    }
323    s
324}
325
326/// Point this worktree's `core.excludesFile` at its products, keeping the
327/// user's own excludes. Needs `extensions.worktreeConfig`, which the caller
328/// takes with `git::acquire_worktree_config`; without it `--worktree` would
329/// write the shared config and change the operator's checkout, so that is a
330/// loud failure, never a fallback.
331async fn hide(wt: &Path, dir: &Path, paths: &[String]) -> Result<()> {
332    let ext = git::git_raw(wt, &["config", "--get", "extensions.worktreeConfig"]).await?;
333    if !(ext.ok() && ext.stdout.trim() == "true") {
334        bail!(
335            "setup left untracked files ({}) but extensions.worktreeConfig is off, so they \
336             cannot be hidden from `git add` per worktree",
337            paths.join(", ")
338        );
339    }
340    let file = dir.join(EXCLUDE);
341    let file_s = file.to_string_lossy().replace('\\', "/");
342    let current = git::git_raw(wt, &["config", "--get", "core.excludesFile"])
343        .await
344        .ok()
345        .filter(|o| o.ok())
346        .map(|o| o.stdout.trim().to_owned())
347        .filter(|s| !s.is_empty());
348    let mut body = match current {
349        Some(c) if c == file_s => std::fs::read_to_string(&file).unwrap_or_default(),
350        Some(c) => {
351            let c = match c.strip_prefix("~/") {
352                Some(rest) => std::env::var_os("HOME")
353                    .map(|h| PathBuf::from(h).join(rest))
354                    .unwrap_or_else(|| PathBuf::from(&c)),
355                None => PathBuf::from(&c),
356            };
357            std::fs::read_to_string(c).unwrap_or_default()
358        }
359        None => String::new(),
360    };
361    if !body.is_empty() && !body.ends_with('\n') {
362        body.push('\n');
363    }
364    for p in paths {
365        let line = exclude_line(p);
366        if !body.lines().any(|l| l == line) {
367            body.push_str(&line);
368            body.push('\n');
369        }
370    }
371    std::fs::write(&file, body).with_context(|| format!("write {}", file.display()))?;
372    git::git(wt, &["config", "--worktree", "core.excludesFile", &file_s])
373        .await
374        .map(|_| ())
375}
376
377impl SetupStep {
378    /// One-line description for errors.
379    pub fn describe(&self) -> String {
380        match (&self.copy, &self.run) {
381            (Some(c), _) => format!("copy {c}"),
382            (_, Some(r)) => format!("run {r}"),
383            _ => "empty".to_owned(),
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::config::SetupStep;
392
393    fn copy(spec: &str) -> SetupStep {
394        SetupStep {
395            copy: Some(spec.to_owned()),
396            ..Default::default()
397        }
398    }
399
400    fn run(cmd: &str) -> SetupStep {
401        SetupStep {
402            run: Some(cmd.to_owned()),
403            ..Default::default()
404        }
405    }
406
407    fn cfg(steps: Vec<SetupStep>) -> Config {
408        let mut c = Config::default();
409        c.worktree.setup = steps;
410        c.verify.shell = Some(vec!["sh".to_owned(), "-c".to_owned()]);
411        c
412    }
413
414    /// A repository with one commit and a detached linked worktree.
415    async fn scratch() -> (tempfile::TempDir, PathBuf, PathBuf) {
416        let tmp = tempfile::tempdir().unwrap();
417        let repo = tmp.path().join("repo");
418        std::fs::create_dir_all(&repo).unwrap();
419        for args in [
420            vec!["init", "-b", "main"],
421            vec!["config", "user.name", "t"],
422            vec!["config", "user.email", "t@example.com"],
423        ] {
424            git::git(&repo, &args).await.unwrap();
425        }
426        std::fs::write(repo.join("a.txt"), "a\n").unwrap();
427        git::git(&repo, &["add", "-A"]).await.unwrap();
428        git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
429        let wt = tmp.path().join("wt");
430        git::worktree_add_detached(&repo, &wt, "HEAD")
431            .await
432            .unwrap();
433        // What `prep` and the other callers take before setup runs.
434        git::acquire_worktree_config(&repo).await.unwrap();
435        (tmp, repo, wt)
436    }
437
438    #[tokio::test]
439    async fn default_is_a_no_op() {
440        let (_t, repo, wt) = scratch().await;
441        prepare(&Config::default(), &repo, &wt).await.unwrap();
442        assert!(withheld_paths(&wt).await.is_empty());
443    }
444
445    #[tokio::test]
446    async fn copy_then_run_in_order() {
447        let (_t, repo, wt) = scratch().await;
448        std::fs::write(repo.join(".env.example"), "KEY=1\n").unwrap();
449        let c = cfg(vec![
450            copy(".env.example -> .env"),
451            run("cat .env > seen.txt && echo ran >> seen.txt"),
452        ]);
453        prepare(&c, &repo, &wt).await.unwrap();
454        assert_eq!(
455            std::fs::read_to_string(wt.join("seen.txt")).unwrap(),
456            "KEY=1\nran\n"
457        );
458        let mut got = withheld_paths(&wt).await;
459        got.sort();
460        assert_eq!(got, [".env", "seen.txt"]);
461    }
462
463    #[tokio::test]
464    async fn a_failing_step_stops_and_names_step_and_output() {
465        let (_t, repo, wt) = scratch().await;
466        let c = cfg(vec![run("echo boom >&2; exit 3"), run("touch after.txt")]);
467        let err = format!("{:#}", prepare(&c, &repo, &wt).await.unwrap_err());
468        assert!(err.contains("step 1/2"), "{err}");
469        assert!(err.contains("boom"), "{err}");
470        assert!(!wt.join("after.txt").exists());
471    }
472
473    #[tokio::test]
474    async fn a_timeout_keeps_the_partial_output() {
475        let (_t, repo, wt) = scratch().await;
476        let mut step = run("echo partial; sleep 30");
477        step.timeout_secs = Some(1);
478        let err = format!(
479            "{:#}",
480            prepare(&cfg(vec![step]), &repo, &wt).await.unwrap_err()
481        );
482        assert!(err.contains("timed out"), "{err}");
483        assert!(err.contains("partial"), "{err}");
484    }
485
486    #[tokio::test]
487    async fn copy_never_overwrites() {
488        let (_t, repo, wt) = scratch().await;
489        std::fs::write(repo.join("src.txt"), "new\n").unwrap();
490        std::fs::write(wt.join("dst.txt"), "mine\n").unwrap();
491        prepare(&cfg(vec![copy("src.txt -> dst.txt")]), &repo, &wt)
492            .await
493            .unwrap();
494        assert_eq!(
495            std::fs::read_to_string(wt.join("dst.txt")).unwrap(),
496            "mine\n"
497        );
498    }
499
500    #[tokio::test]
501    async fn a_missing_source_is_an_error_unless_optional() {
502        let (_t, repo, wt) = scratch().await;
503        let err = format!(
504            "{:#}",
505            prepare(&cfg(vec![copy("nope")]), &repo, &wt)
506                .await
507                .unwrap_err()
508        );
509        assert!(err.contains("step 1/1") && err.contains("nope"), "{err}");
510        let mut opt = copy("nope");
511        opt.optional = true;
512        prepare(&cfg(vec![opt]), &repo, &wt).await.unwrap();
513        assert!(!wt.join("nope").exists());
514    }
515
516    #[tokio::test]
517    async fn setup_may_not_edit_tracked_files() {
518        let (_t, repo, wt) = scratch().await;
519        let err = format!(
520            "{:#}",
521            prepare(&cfg(vec![run("echo x >> a.txt")]), &repo, &wt)
522                .await
523                .unwrap_err()
524        );
525        assert!(err.contains("tracked") && err.contains("a.txt"), "{err}");
526    }
527
528    #[tokio::test]
529    async fn the_command_does_not_see_the_run_identity() {
530        let (_t, repo, wt) = scratch().await;
531        // Safe in a unit test: only this process reads them, via the child.
532        unsafe {
533            std::env::set_var("MAGI_RUN", "r1");
534            std::env::set_var("MAGI_NODE", "n1");
535        }
536        prepare(
537            &cfg(vec![run("echo \"[$MAGI_RUN][$MAGI_NODE]\" > ids.txt")]),
538            &repo,
539            &wt,
540        )
541        .await
542        .unwrap();
543        assert_eq!(
544            std::fs::read_to_string(wt.join("ids.txt")).unwrap(),
545            "[][]\n"
546        );
547    }
548
549    #[tokio::test]
550    async fn products_are_kept_out_of_both_commit_paths() {
551        let (_t, repo, wt) = scratch().await;
552        std::fs::write(repo.join("secret"), "s\n").unwrap();
553        prepare(&cfg(vec![copy("secret")]), &repo, &wt)
554            .await
555            .unwrap();
556
557        // Hidden from the agent's own `git add -A` ...
558        git::git(&wt, &["add", "-A"]).await.unwrap();
559        assert!(
560            git::git(&wt, &["diff", "--cached", "--name-only"])
561                .await
562                .unwrap()
563                .is_empty()
564        );
565
566        // ... and a rescue with only the product commits nothing.
567        assert!(!git::commit_all(&wt, "rescue").await.unwrap());
568        let r = git::rescue_commit(&wt, "rescue").await.unwrap();
569        assert!(!r.committed);
570
571        // Real work beside it is committed, the product is not.
572        std::fs::write(wt.join("work.txt"), "w\n").unwrap();
573        assert!(git::commit_all(&wt, "rescue").await.unwrap());
574        let files = git::git(&wt, &["show", "--name-only", "--format=", "HEAD"])
575            .await
576            .unwrap();
577        assert_eq!(files.trim(), "work.txt");
578    }
579
580    #[tokio::test]
581    async fn products_without_worktree_config_fail_loudly_and_leave_shared_config_alone() {
582        let (_t, repo, wt) = scratch().await;
583        git::release_worktree_config(&repo).await.unwrap();
584        std::fs::write(repo.join("secret"), "s\n").unwrap();
585        let err = format!(
586            "{:#}",
587            prepare(&cfg(vec![copy("secret")]), &repo, &wt)
588                .await
589                .unwrap_err()
590        );
591        assert!(err.contains("worktreeConfig"), "{err}");
592        let shared = git::git_raw(&repo, &["config", "--get", "core.excludesFile"])
593            .await
594            .unwrap();
595        assert!(!shared.ok(), "shared config was written: {}", shared.stdout);
596    }
597
598    #[tokio::test]
599    async fn an_ignored_copy_is_still_withheld_from_commits() {
600        let (_t, repo, wt) = scratch().await;
601        std::fs::write(wt.join(".gitignore"), ".env\n").unwrap();
602        git::git(&wt, &["add", ".gitignore"]).await.unwrap();
603        git::git(&wt, &["commit", "-m", "ignore"]).await.unwrap();
604        std::fs::write(repo.join(".env"), "s\n").unwrap();
605        prepare(&cfg(vec![copy(".env")]), &repo, &wt).await.unwrap();
606        assert_eq!(withheld_paths(&wt).await, [".env"]);
607        // The agent force-adds it, then magi rescues.
608        git::git(&wt, &["add", "-f", ".env"]).await.unwrap();
609        std::fs::write(wt.join("work.txt"), "w\n").unwrap();
610        assert!(git::commit_all(&wt, "rescue").await.unwrap());
611        let files = git::git(&wt, &["show", "--name-only", "--format=", "HEAD"])
612            .await
613            .unwrap();
614        assert_eq!(files.trim(), "work.txt");
615    }
616
617    #[test]
618    fn destinations_must_stay_inside() {
619        for bad in ["/etc/x", "../x", "a/../../x", ".git/hooks/x", "C:/x", ""] {
620            assert!(valid_destination(bad).is_err(), "{bad}");
621        }
622        assert!(valid_destination("a/b.env").is_ok());
623    }
624}