Skip to main content

choir_queue/
worktree.rs

1//! An executor that materializes what it is asked about (D18).
2//!
3//! [`crate::local::LocalRunner`] runs a command in a directory somebody
4//! else prepared. That is fine for one train commit and wrong for a
5//! train: [`crate::MergeQueue`] tests every member against *its own*
6//! speculative state, so a batch of jobs is a batch of different trees,
7//! and running them all in one checkout tests the last one N times and
8//! reports the answer under N different subjects. The failure is silent
9//! and every verdict is well-formed, which is the shape of a defect
10//! nobody finds.
11//!
12//! [`Job::directory`] already spells the way out. `None` means the
13//! provider materializes [`Job::subject`] itself — written for a
14//! microVM, and true of anything that can turn a content address into a
15//! tree. A git repository can, for the subjects
16//! [`crate::git::GitSpeculator`] produces, because those are its own
17//! commit ids.
18//!
19//! So this is the executor that closes the loop: `git worktree add
20//! --detach` per job, the command in that worktree, the worktree
21//! removed afterwards. A job that *does* name a directory is run there
22//! untouched, because a caller who prepared a tree has said what they
23//! want and the subject is then not ours to interpret.
24
25use std::path::{Path, PathBuf};
26use std::process::{Command, Stdio};
27
28use crate::executor::{CiExecutor, ExecutorError, ExecutorInfo, Job, Verdict, PROTOCOL};
29use crate::local::LocalRunner;
30
31/// Runs each job in a throwaway worktree of `repo` at the job's subject.
32#[derive(Debug)]
33pub struct WorktreeRunner {
34    repo: PathBuf,
35    root: PathBuf,
36    inner: LocalRunner,
37}
38
39/// Runs git in `dir`, returning stderr on failure.
40fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
41    let out = Command::new("git")
42        .args(args)
43        .current_dir(dir)
44        .env("GIT_TERMINAL_PROMPT", "0")
45        .stdin(Stdio::null())
46        .output()
47        .map_err(|e| format!("spawn git: {e}"))?;
48    if out.status.success() {
49        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
50    } else {
51        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
52    }
53}
54
55impl WorktreeRunner {
56    /// An executor over `repo`, checking out under `root`.
57    ///
58    /// `root` is created on demand and its children are removed as each
59    /// batch finishes. It must not be inside `repo`'s worktree: a
60    /// checkout there would appear as untracked files to every job.
61    #[must_use]
62    pub fn new(repo: PathBuf, root: PathBuf) -> Self {
63        Self {
64            repo,
65            root,
66            inner: LocalRunner::new(),
67        }
68    }
69
70    /// Checks out `job`'s subject and returns the job rewritten to run
71    /// there, plus the path to clean up.
72    fn provision(&self, job: &Job, slot: usize) -> Result<(Job, PathBuf), String> {
73        let oid = job
74            .subject
75            .git_oid()
76            .ok_or_else(|| "subject is not a git object id".to_string())?;
77        std::fs::create_dir_all(&self.root).map_err(|e| format!("create checkout root: {e}"))?;
78        // Slot as well as oid: two members of one train can legitimately
79        // carry the same subject -- a change whose merge was a no-op --
80        // and `worktree add` refuses a path that exists.
81        let path = self.root.join(format!("{oid}-{slot}"));
82        // A run that died between `worktree add` and the cleanup left a
83        // checkout at this path, and `worktree add` refuses a path that
84        // exists -- so without this, one crash makes every later batch
85        // fault at provisioning until somebody cleans up by hand.
86        let _ = self.discard(&path);
87        let target = path
88            .to_str()
89            .ok_or_else(|| format!("checkout path {path:?} is not UTF-8"))?;
90        git(
91            &self.repo,
92            &["worktree", "add", "--detach", "--quiet", target, &oid],
93        )?;
94        let mut prepared = job.clone();
95        prepared.directory = Some(path.clone());
96        Ok((prepared, path))
97    }
98
99    /// Removes a checkout, by git's bookkeeping and then by force.
100    ///
101    /// Both, because what is at the path is not always a worktree git
102    /// knows about. Its own are removed by the first call; the debris a
103    /// crashed run left is a directory git has no record of, which the
104    /// first call refuses and the second deletes. Deleting alone would
105    /// leave git's administrative entry pointing at nothing, which is
106    /// why the order is this way round and not the other.
107    fn discard(&self, path: &Path) -> Result<(), String> {
108        let target = path
109            .to_str()
110            .ok_or_else(|| format!("checkout path {path:?} is not UTF-8"))?;
111        let by_git = git(&self.repo, &["worktree", "remove", "--force", target]);
112        if by_git.is_err() {
113            let _ = std::fs::remove_dir_all(path);
114            let _ = git(&self.repo, &["worktree", "prune"]);
115        }
116        Ok(())
117    }
118}
119
120impl CiExecutor for WorktreeRunner {
121    fn info(&mut self) -> Result<ExecutorInfo, ExecutorError> {
122        Ok(ExecutorInfo {
123            name: "worktree".to_string(),
124            protocol: PROTOCOL,
125        })
126    }
127
128    fn run(&mut self, jobs: &[Job]) -> Result<Vec<Verdict>, ExecutorError> {
129        // Index alignment is the seam's whole contract, so a job we
130        // could not check out keeps its slot with an `Errored` in it
131        // rather than being dropped from the batch. `Errored` and not
132        // `Failed`: a checkout we could not make is our fault, and the
133        // queue must not eject an author over it.
134        let mut prepared: Vec<Option<Job>> = Vec::with_capacity(jobs.len());
135        let mut faults: Vec<Option<String>> = Vec::with_capacity(jobs.len());
136        let mut checkouts: Vec<PathBuf> = Vec::new();
137        for (slot, job) in jobs.iter().enumerate() {
138            if job.directory.is_some() {
139                // Somebody already prepared a tree and said so. The
140                // subject is then their statement about what that tree
141                // is, not an instruction to us.
142                prepared.push(Some(job.clone()));
143                faults.push(None);
144                continue;
145            }
146            match self.provision(job, slot) {
147                Ok((job, path)) => {
148                    checkouts.push(path);
149                    prepared.push(Some(job));
150                    faults.push(None);
151                }
152                Err(why) => {
153                    prepared.push(None);
154                    faults.push(Some(why));
155                }
156            }
157        }
158
159        let batch: Vec<Job> = prepared.iter().flatten().cloned().collect();
160        let result = self.inner.run(&batch);
161        for path in &checkouts {
162            let _ = self.discard(path);
163        }
164        let mut ran = result?.into_iter();
165
166        let verdicts = prepared
167            .iter()
168            .zip(faults)
169            .map(|(job, fault)| match (job, fault) {
170                (Some(_), _) => ran.next().unwrap_or(Verdict::Errored {
171                    provider: "worktree".to_string(),
172                    detail: "the inner runner answered short".to_string(),
173                }),
174                (None, fault) => Verdict::Errored {
175                    provider: "worktree".to_string(),
176                    detail: fault.unwrap_or_else(|| "no checkout".to_string()),
177                },
178            })
179            .collect();
180        Ok(verdicts)
181    }
182}