Skip to main content

magi/
already.rs

1//! Is a branch's change already in the base under a different commit id?
2//!
3//! The same change can reach the base twice over: a candidate's commit is
4//! cherry-picked or squashed in by another task, and from then on the
5//! original branch is a pile of commits `main` has no ancestry link to. Base
6//! sync would try to rebase it and report a conflict with itself, and a pull
7//! request or queue task for it would sit open for ever because the forge
8//! never saw *that* branch merge.
9//!
10//! [`already_in`] is the pure decision and [`classify`] feeds it from git.
11//! Two proofs are accepted, and nothing else:
12//!
13//! - **patch-id**: (together with the tree check below, so a later revert on
14//!   the base is not mistaken for presence) every commit the branch adds has a `-` line in
15//!   `git cherry <base> <branch>`, i.e. the base carries a commit with the
16//!   same diff. A merge commit has no patch-id, so a branch with one is never
17//!   proven this way.
18//! - **tree**: merging the branch into the base changes nothing, which also
19//!   covers a squash of several commits into one.
20//!
21//! A branch that is only partly in the base, or whose re-land needed a
22//! conflict resolution (so its patch-id changed), is [`AlreadyIn::No`]: a miss
23//! costs an operator a look, a false positive would close live work.
24
25use std::path::Path;
26use std::process::Stdio;
27
28use anyhow::{Context as _, Result};
29use serde::{Deserialize, Serialize};
30use tokio::io::AsyncWriteExt as _;
31use tokio::process::Command;
32
33use crate::git;
34use crate::proc::Quiet as _;
35
36/// How many base commits are scanned for the twin of a matched branch commit.
37/// Past this the proof still stands; only the pairing in the report is lost.
38const PAIRING_LIMIT: usize = 1000;
39
40/// Which proof established that a branch is already in the base.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Proof {
44    /// Every added commit has a patch-id twin on the base.
45    PatchId,
46    /// Merging the branch into the base yields the base's own tree.
47    Tree,
48    /// The branch tip is itself an ancestor of the base.
49    Ancestry,
50}
51
52impl Proof {
53    /// The word reports and events use.
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::PatchId => "patch-id",
57            Self::Tree => "tree",
58            Self::Ancestry => "ancestry",
59        }
60    }
61}
62
63/// The verdict of [`already_in`].
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum AlreadyIn {
66    /// Everything the branch adds is represented on the base.
67    Yes(Proof),
68    /// Not proven: partly present, genuinely different, or nothing added.
69    No,
70}
71
72/// Pure decision. `added` is every commit in `<merge-base>..<branch>`;
73/// `unmatched` and `matched` are the `+` and `-` lines of `git cherry`;
74/// `tree_unchanged` says a merge of the branch into the base left the base's
75/// tree as it was.
76pub fn already_in(
77    added: &[String],
78    unmatched: &[String],
79    matched: &[String],
80    tree_unchanged: bool,
81) -> AlreadyIn {
82    if added.is_empty() {
83        return AlreadyIn::No;
84    }
85    // `git cherry` skips merge commits, so a commit it did not mention is one
86    // whose equivalence was never tested.
87    let untested = added
88        .iter()
89        .any(|c| !unmatched.contains(c) && !matched.contains(c));
90    // A twin on the base says the change landed once, not that it is still
91    // there: a later revert keeps every twin and drops the work. The tree
92    // check is what says it is present now, so it gates both proofs.
93    if !tree_unchanged {
94        return AlreadyIn::No;
95    }
96    if unmatched.is_empty() && !matched.is_empty() && !untested {
97        return AlreadyIn::Yes(Proof::PatchId);
98    }
99    AlreadyIn::Yes(Proof::Tree)
100}
101
102/// Where the branch's change lives on the base.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct Evidence {
105    /// How it was proven.
106    pub proof: Proof,
107    /// The base tip the check ran against.
108    pub tip: String,
109    /// The base commits carrying the same change. Empty for a tree proof, or
110    /// when the twins could not be paired up; `tip` is then the only pointer.
111    #[serde(default)]
112    pub commits: Vec<String>,
113}
114
115impl Evidence {
116    /// The commit to name: the first twin, else the base tip.
117    pub fn commit(&self) -> &str {
118        self.commits
119            .first()
120            .map_or(self.tip.as_str(), String::as_str)
121    }
122
123    /// Short ids of everything named, for one line of prose.
124    pub fn names(&self) -> String {
125        if self.commits.is_empty() {
126            return short_sha(&self.tip).to_owned();
127        }
128        self.commits
129            .iter()
130            .map(|c| short_sha(c))
131            .collect::<Vec<_>>()
132            .join(", ")
133    }
134}
135
136/// The first seven characters of an object id.
137pub fn short_sha(sha: &str) -> &str {
138    sha.get(..7).unwrap_or(sha)
139}
140
141/// Ask git whether `head` is already represented in `base`. Both are pinned
142/// to ids first so a ref that moves mid-check cannot split the answer.
143///
144/// `start` is the commit the branch was cut from, when known: a branch still
145/// sitting on it adds nothing and is not "contained" in any meaningful sense,
146/// even though it is an ancestor of a base that has moved on.
147pub async fn classify(
148    repo: &Path,
149    base: &str,
150    head: &str,
151    start: Option<&str>,
152) -> Result<Option<Evidence>> {
153    let base = git::rev_parse(repo, base).await?;
154    let head = git::rev_parse(repo, head).await?;
155    if start.is_some_and(|s| s == head) {
156        return Ok(None);
157    }
158    if git::is_ancestor(repo, &head, &base).await {
159        // Already part of the base's history; nothing is added over the
160        // merge-base, which would otherwise read as "nothing to judge".
161        return Ok(Some(Evidence {
162            proof: Proof::Ancestry,
163            tip: base,
164            commits: vec![head],
165        }));
166    }
167    let merge_base = git::git(repo, &["merge-base", &base, &head]).await?;
168    let range = format!("{merge_base}..{head}");
169    let added: Vec<String> = git::git(repo, &["rev-list", &range])
170        .await?
171        .lines()
172        .map(str::to_owned)
173        .collect();
174    if added.is_empty() {
175        return Ok(None);
176    }
177    let (unmatched, matched) = git::cherry(repo, &base, &head).await?;
178    let tree_unchanged = tree_unchanged(repo, &base, &head).await;
179    let proof = match already_in(&added, &unmatched, &matched, tree_unchanged) {
180        AlreadyIn::Yes(p) => p,
181        AlreadyIn::No => return Ok(None),
182    };
183    let commits = match proof {
184        Proof::PatchId => twins(repo, &merge_base, &base, &head, &matched)
185            .await
186            .unwrap_or_default(),
187        Proof::Tree | Proof::Ancestry => Vec::new(),
188    };
189    Ok(Some(Evidence {
190        proof,
191        tip: base,
192        commits,
193    }))
194}
195
196/// Does merging `head` into `base` give exactly `base`'s tree? A conflict, an
197/// old git without `merge-tree --write-tree`, or any error is "no".
198async fn tree_unchanged(repo: &Path, base: &str, head: &str) -> bool {
199    let Ok(merged) = git::git_raw(repo, &["merge-tree", "--write-tree", base, head]).await else {
200        return false;
201    };
202    if !merged.ok() {
203        return false;
204    }
205    let Some(tree) = merged.stdout.lines().next() else {
206        return false;
207    };
208    let base_tree = format!("{base}^{{tree}}");
209    git::rev_parse(repo, &base_tree)
210        .await
211        .is_ok_and(|t| t == tree.trim())
212}
213
214/// Patch-ids of every non-merge commit in `range`, as `(patch_id, commit)`.
215async fn patch_ids(repo: &Path, range: &str) -> Result<Vec<(String, String)>> {
216    let log = git::git(repo, &["log", "-p", "--no-merges", "--no-color", range]).await?;
217    let mut child = Command::new("git")
218        .args(["patch-id", "--stable"])
219        .current_dir(repo)
220        .quiet()
221        .stdin(Stdio::piped())
222        .stdout(Stdio::piped())
223        .stderr(Stdio::null())
224        .spawn()
225        .context("spawn git patch-id")?;
226    let mut stdin = child.stdin.take().context("git patch-id stdin")?;
227    let feed = tokio::spawn(async move {
228        let _ = stdin.write_all(log.as_bytes()).await;
229        let _ = stdin.write_all(b"\n").await;
230    });
231    let out = child.wait_with_output().await.context("git patch-id")?;
232    let _ = feed.await;
233    Ok(String::from_utf8_lossy(&out.stdout)
234        .lines()
235        .filter_map(|l| {
236            let (p, c) = l.split_once(' ')?;
237            Some((p.to_owned(), c.trim().to_owned()))
238        })
239        .collect())
240}
241
242/// The base commits whose patch-id equals one of `matched` (branch commits).
243async fn twins(
244    repo: &Path,
245    merge_base: &str,
246    base: &str,
247    head: &str,
248    matched: &[String],
249) -> Result<Vec<String>> {
250    let count = git::commits_ahead(repo, merge_base, base).await?;
251    if count > PAIRING_LIMIT {
252        return Ok(Vec::new());
253    }
254    let ours = patch_ids(repo, &format!("{merge_base}..{head}")).await?;
255    let theirs = patch_ids(repo, &format!("{merge_base}..{base}")).await?;
256    let mut out: Vec<String> = Vec::new();
257    for (pid, commit) in &ours {
258        if !matched.contains(commit) {
259            continue;
260        }
261        if let Some((_, twin)) = theirs.iter().find(|(p, _)| p == pid)
262            && !out.contains(twin)
263        {
264            out.push(twin.clone());
265        }
266    }
267    Ok(out)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn v(items: &[&str]) -> Vec<String> {
275        items.iter().map(|s| (*s).to_owned()).collect()
276    }
277
278    #[test]
279    fn every_commit_matched_is_already_in() {
280        assert_eq!(
281            already_in(&v(&["a", "b"]), &[], &v(&["a", "b"]), true),
282            AlreadyIn::Yes(Proof::PatchId)
283        );
284    }
285
286    #[test]
287    fn a_reverted_twin_is_not_already_in() {
288        assert_eq!(
289            already_in(&v(&["a", "b"]), &[], &v(&["a", "b"]), false),
290            AlreadyIn::No
291        );
292    }
293
294    #[test]
295    fn a_partial_overlap_is_not_already_in() {
296        assert_eq!(
297            already_in(&v(&["a", "b"]), &v(&["b"]), &v(&["a"]), false),
298            AlreadyIn::No
299        );
300    }
301
302    #[test]
303    fn a_merge_commit_cherry_never_saw_blocks_the_patch_id_proof() {
304        assert_eq!(
305            already_in(&v(&["a", "m"]), &[], &v(&["a"]), false),
306            AlreadyIn::No
307        );
308        assert_eq!(
309            already_in(&v(&["a", "m"]), &[], &v(&["a"]), true),
310            AlreadyIn::Yes(Proof::Tree)
311        );
312    }
313
314    #[test]
315    fn a_squash_is_proven_by_the_tree() {
316        assert_eq!(
317            already_in(&v(&["a", "b"]), &v(&["a", "b"]), &[], true),
318            AlreadyIn::Yes(Proof::Tree)
319        );
320    }
321
322    #[test]
323    fn nothing_added_is_never_already_in() {
324        assert_eq!(already_in(&[], &[], &[], true), AlreadyIn::No);
325    }
326
327    #[test]
328    fn evidence_names_the_twin_or_else_the_tip() {
329        let e = Evidence {
330            proof: Proof::PatchId,
331            tip: "1234567890".into(),
332            commits: v(&["abcdef0123"]),
333        };
334        assert_eq!(e.commit(), "abcdef0123");
335        assert_eq!(e.names(), "abcdef0");
336        let t = Evidence {
337            proof: Proof::Tree,
338            tip: "1234567890".into(),
339            commits: Vec::new(),
340        };
341        assert_eq!(t.commit(), "1234567890");
342        assert_eq!(t.names(), "1234567");
343    }
344
345    fn sh(dir: &Path, args: &[&str]) -> String {
346        let out = std::process::Command::new("git")
347            .args(args)
348            .current_dir(dir)
349            .quiet()
350            .output()
351            .unwrap();
352        assert!(
353            out.status.success(),
354            "git {args:?}: {}",
355            String::from_utf8_lossy(&out.stderr)
356        );
357        String::from_utf8_lossy(&out.stdout).trim().to_owned()
358    }
359
360    fn commit(dir: &Path, file: &str, body: &str, msg: &str) -> String {
361        std::fs::write(dir.join(file), body).unwrap();
362        sh(dir, &["add", "-A"]);
363        sh(dir, &["commit", "-q", "-m", msg]);
364        sh(dir, &["rev-parse", "HEAD"])
365    }
366
367    fn repo() -> (tempfile::TempDir, std::path::PathBuf) {
368        let dir = tempfile::tempdir().unwrap();
369        let repo = dir.path().to_path_buf();
370        sh(&repo, &["init", "-q", "-b", "main"]);
371        sh(&repo, &["config", "user.name", "t"]);
372        sh(&repo, &["config", "user.email", "t@example.com"]);
373        commit(&repo, "base.txt", "base\n", "base");
374        (dir, repo)
375    }
376
377    #[tokio::test]
378    async fn a_cherry_picked_reland_is_detected_and_names_the_twin() {
379        let (_d, repo) = repo();
380        sh(&repo, &["switch", "-q", "-c", "work"]);
381        let orig = commit(&repo, "feat.txt", "feature\n", "feature");
382        sh(&repo, &["switch", "-q", "main"]);
383        commit(&repo, "other.txt", "other\n", "unrelated");
384        sh(&repo, &["cherry-pick", &orig]);
385        let twin = sh(&repo, &["rev-parse", "HEAD"]);
386        assert_ne!(twin, orig);
387        let e = classify(&repo, "main", "work", None)
388            .await
389            .unwrap()
390            .expect("already in");
391        assert_eq!(e.proof, Proof::PatchId);
392        assert_eq!(e.commits, vec![twin]);
393    }
394
395    #[tokio::test]
396    async fn a_squashed_reland_is_detected_by_tree() {
397        let (_d, repo) = repo();
398        sh(&repo, &["switch", "-q", "-c", "work"]);
399        commit(&repo, "a.txt", "a\n", "one");
400        commit(&repo, "b.txt", "b\n", "two");
401        sh(&repo, &["switch", "-q", "main"]);
402        sh(&repo, &["merge", "--squash", "work"]);
403        sh(&repo, &["commit", "-q", "-m", "squashed"]);
404        commit(&repo, "later.txt", "later\n", "later");
405        let e = classify(&repo, "main", "work", None)
406            .await
407            .unwrap()
408            .expect("already in");
409        assert_eq!(e.proof, Proof::Tree);
410        assert!(e.commits.is_empty());
411    }
412
413    #[tokio::test]
414    async fn a_partly_present_branch_is_not_already_in() {
415        let (_d, repo) = repo();
416        sh(&repo, &["switch", "-q", "-c", "work"]);
417        let first = commit(&repo, "a.txt", "a\n", "one");
418        commit(&repo, "b.txt", "b\n", "two");
419        sh(&repo, &["switch", "-q", "main"]);
420        sh(&repo, &["cherry-pick", &first]);
421        assert!(
422            classify(&repo, "main", "work", None)
423                .await
424                .unwrap()
425                .is_none()
426        );
427    }
428
429    #[tokio::test]
430    async fn a_conflicting_branch_is_not_already_in() {
431        let (_d, repo) = repo();
432        sh(&repo, &["switch", "-q", "-c", "work"]);
433        commit(&repo, "base.txt", "mine\n", "mine");
434        sh(&repo, &["switch", "-q", "main"]);
435        commit(&repo, "base.txt", "theirs\n", "theirs");
436        assert!(
437            classify(&repo, "main", "work", None)
438                .await
439                .unwrap()
440                .is_none()
441        );
442    }
443
444    #[tokio::test]
445    async fn a_branch_already_in_the_base_history_is_detected_unless_it_is_the_start() {
446        let (_d, repo) = repo();
447        let start = sh(&repo, &["rev-parse", "HEAD"]);
448        sh(&repo, &["switch", "-q", "-c", "work"]);
449        let tip = commit(&repo, "a.txt", "a\n", "one");
450        sh(&repo, &["switch", "-q", "main"]);
451        sh(&repo, &["merge", "-q", "--ff-only", "work"]);
452        commit(&repo, "later.txt", "later\n", "later");
453        let e = classify(&repo, "main", "work", Some(&start))
454            .await
455            .unwrap()
456            .expect("already in");
457        assert_eq!(e.proof, Proof::Ancestry);
458        assert_eq!(e.commits, vec![tip]);
459        // A branch that never left its start adds nothing.
460        sh(&repo, &["branch", "idle", &start]);
461        assert!(
462            classify(&repo, "main", "idle", Some(&start))
463                .await
464                .unwrap()
465                .is_none()
466        );
467    }
468}