choir_queue/git.rs
1//! The git speculator (D5): the second implementation of
2//! [`Speculator`], and the one a production caller wants.
3//!
4//! A state is a commit id and a merge is `git merge --no-ff` in a
5//! worktree this owns. That makes the queue's window, its
6//! dependency-aware ejection and its refusal to land a change twice
7//! apply to a real repository instead of to one file's text, which is
8//! the whole reason the seam exists.
9//!
10//! **This owns the worktree it is given.** Every [`Speculator::step`]
11//! detaches it and forces it to a speculative state, so a repository
12//! anyone else is working in is the wrong argument. A clone made for
13//! the queue is the right one.
14//!
15//! Nothing here is speculative in git's sense of the word: the commits
16//! it writes are ordinary objects in that repository, unreferenced by
17//! any branch until a caller decides to move one. Refusing to move a
18//! branch is not this type's job; it never touches a ref.
19
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22
23use choir_hash::ContentHash;
24
25use crate::speculate::{Speculator, Step};
26use crate::Change;
27
28/// A speculator over a real repository: states are commit ids.
29pub struct GitSpeculator {
30 repo: PathBuf,
31}
32
33/// Runs git in `dir` under a fixed identity, returning stdout.
34///
35/// The identity is pinned, and signing is off, because these commits
36/// are the queue's own and are made without a person present. A machine
37/// inheriting the operator's `user.email` would author speculative
38/// merges under a human's name, which is the D60 rule one layer down.
39fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
40 let out = Command::new("git")
41 .arg("-c")
42 .arg("user.name=choir-queue")
43 .arg("-c")
44 .arg("user.email=queue@choir.invalid")
45 .arg("-c")
46 .arg("commit.gpgsign=false")
47 .args(args)
48 .current_dir(dir)
49 .env("GIT_TERMINAL_PROMPT", "0")
50 .stdin(Stdio::null())
51 .output()
52 .map_err(|e| format!("spawn git: {e}"))?;
53 if out.status.success() {
54 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
55 } else {
56 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
57 }
58}
59
60impl GitSpeculator {
61 /// A speculator over `repo`, a non-bare clone whose worktree it owns.
62 #[must_use]
63 pub fn new(repo: PathBuf) -> Self {
64 Self { repo }
65 }
66
67 /// Whether `state` names a commit this repository holds.
68 ///
69 /// The queue's base comes from its caller, and a base that is not a
70 /// commit would otherwise be discovered as a merge failure against
71 /// the first change submitted — reported as that author's problem.
72 ///
73 /// # Errors
74 ///
75 /// The oid is unknown, unparseable, or not a commit.
76 pub fn verify(&self, state: &str) -> Result<String, String> {
77 git(
78 &self.repo,
79 &[
80 "rev-parse",
81 "--verify",
82 "--quiet",
83 &format!("{state}^{{commit}}"),
84 ],
85 )
86 .map(|out| out.trim().to_string())
87 .map_err(|why| {
88 if why.is_empty() {
89 format!("{state} is not a commit in this repository")
90 } else {
91 why
92 }
93 })
94 }
95}
96
97impl Speculator for GitSpeculator {
98 fn name(&self) -> &'static str {
99 "git"
100 }
101
102 /// `base` is ignored: git finds the merge base itself, and the one
103 /// it finds is better than the one the caller declared. The
104 /// argument exists for the text implementation, which has no way to
105 /// find it.
106 fn step(&mut self, _base: &str, onto: &str, proposed: &str) -> Step {
107 // `--force` is load-bearing and not belt-and-braces: it is what
108 // clears a `MERGE_HEAD` an earlier run left behind. Without it
109 // git refuses the next merge with "you have not concluded your
110 // merge" -- the same nonzero exit a conflict gets -- and
111 // somebody else's debris would be reported as this author's
112 // conflict. Verified rather than assumed; an explicit `merge
113 // --abort` here was removed after a mutation showed it changed
114 // nothing.
115 if let Err(why) = git(&self.repo, &["checkout", "-q", "--detach", "--force", onto]) {
116 return Step::Unavailable(format!("cannot detach at {onto}: {why}"));
117 }
118 let message = format!("choir queue: speculative merge of {proposed}");
119 match git(
120 &self.repo,
121 &["merge", "--no-ff", "-q", "-m", &message, proposed],
122 ) {
123 Ok(_) => match git(&self.repo, &["rev-parse", "HEAD"]) {
124 Ok(oid) => Step::Advanced(oid.trim().to_string()),
125 Err(why) => Step::Unavailable(format!("merge left no head: {why}")),
126 },
127 Err(why) => {
128 // git exits nonzero for a conflict and for being handed
129 // an oid it does not have, and the queue's response to
130 // those must differ: one evicts an author, the other is
131 // ours. `MERGE_HEAD` exists only in the first case, so
132 // it is the question to ask rather than the stderr text,
133 // which is localized and version-dependent.
134 let conflicted = git(
135 &self.repo,
136 &["rev-parse", "--verify", "--quiet", "MERGE_HEAD"],
137 )
138 .is_ok();
139 let _ = git(&self.repo, &["merge", "--abort"]);
140 if conflicted {
141 Step::Conflict
142 } else {
143 Step::Unavailable(format!("cannot merge {proposed}: {why}"))
144 }
145 }
146 }
147 }
148
149 /// The patch identity of `base..proposed`, which is what survives a
150 /// rebase: the train rewriting the tip under an author gives the
151 /// same edit a new commit id and the same identity here.
152 ///
153 /// An unreadable pair yields a unique identity rather than a shared
154 /// one. Two changes we could not read are not thereby the same
155 /// change, and collapsing them would make the queue refuse the
156 /// second as already landed.
157 fn identity(&self, change: &Change) -> String {
158 let unknown = || {
159 ContentHash::blake3(
160 format!("unreadable:{}:{}", change.base, change.proposed).as_bytes(),
161 )
162 .to_hex()
163 };
164 let Ok(diff) = git(
165 &self.repo,
166 &["diff", "--no-color", "-U0", &change.base, &change.proposed],
167 ) else {
168 return unknown();
169 };
170 // Deliberately the same normalization
171 // [`choir_merge::normalized_diff`] performs for text, reached
172 // with git's own diff: keep the added and removed lines and the
173 // path they belong to, drop everything positional. Dropping
174 // context is not incidental -- a three-line file rebased onto a
175 // change to its last line has different context for the same
176 // edit, so keeping it would break identity in exactly the case
177 // identity exists for. The filter is what enforces that, since
178 // a context line starts with a space; `-U0` only stops git
179 // producing bytes we would discard, and a mutation removing it
180 // is correctly invisible.
181 //
182 // Computed from our own bytes rather than by piping into `git
183 // patch-id`: the diff is already in hand, and a second process
184 // reading a pipe we are still writing is a deadlock shape this
185 // workspace has been bitten by before.
186 let stripped: String = diff
187 .lines()
188 .filter(|l| {
189 l.starts_with("diff --git ")
190 || ((l.starts_with('+') || l.starts_with('-'))
191 && !l.starts_with("+++")
192 && !l.starts_with("---"))
193 })
194 .map(|l| format!("{l}\n"))
195 .collect();
196 ContentHash::blake3(stripped.as_bytes()).to_hex()
197 }
198
199 /// The commit id, as a git-codec [`ContentHash`].
200 ///
201 /// # Panics
202 ///
203 /// If `state` is not a git object id. Every state after the first
204 /// is one this type produced with `rev-parse`; the first is the
205 /// caller's base, which [`GitSpeculator::verify`] exists to check
206 /// before a queue is built on it.
207 fn subject(&self, state: &str) -> ContentHash {
208 ContentHash::from_git_oid(state)
209 .unwrap_or_else(|| panic!("speculative state `{state}` is not a git object id"))
210 }
211}