Skip to main content

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}