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}