Skip to main content

magi/
refs.rs

1//! References a task makes to work that already exists in the repository.
2//!
3//! A task such as "land the fix that already lives on `magi/27b2/A`" is about
4//! a change that is not on the base branch. Started from the base alone, the
5//! candidates have nothing to land: the run ends with an empty branch. So the
6//! task text is scanned for `magi/<run>/<label>` branch names and commit
7//! shas, each is checked against the repository, and the ones that name
8//! unmerged work seed the candidates (`Runner::prep`).
9//!
10//! Every judgement here is a question git can answer, and a question git
11//! cannot answer (a shallow clone, a failing command) is reported as
12//! unresolved rather than read as "not merged".
13
14use std::path::Path;
15
16use serde::{Deserialize, Serialize};
17
18use crate::git;
19
20/// What a reference turned out to be, relative to the base commit.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SeedKind {
24    /// A commit that exists but is not reachable from the base.
25    Unmerged,
26    /// A commit the base already contains.
27    AlreadyMerged,
28    /// Could not be resolved or judged.
29    Unresolved,
30}
31
32/// One reference found in a task, and what the repository says about it.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Seed {
35    /// The text as written in the task.
36    pub token: String,
37    /// What the repository says about it.
38    pub kind: SeedKind,
39    /// Full commit id; empty for [`SeedKind::Unresolved`].
40    #[serde(default)]
41    pub sha: String,
42    /// Was the token a branch name (as opposed to a bare sha)?
43    #[serde(default)]
44    pub branch: bool,
45    /// Branches that contain the commit (unmerged), or why it is unresolved.
46    #[serde(default)]
47    pub detail: String,
48}
49
50/// A candidate reference lifted out of prose, not yet checked.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Token {
53    /// The reference as written (shas lower-cased).
54    pub text: String,
55    /// A branch name rather than a sha.
56    pub branch: bool,
57}
58
59fn is_word(c: char) -> bool {
60    c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '.')
61}
62
63/// Pull `magi/<run>/<label>` branch names and 7-40 digit hex shas out of
64/// `text`, branch names first, then in order of first appearance, without
65/// duplicates. A sha must
66/// contain a digit, which drops words such as `defaced` and `facade`.
67pub fn scan(text: &str) -> Vec<Token> {
68    let mut out: Vec<Token> = Vec::new();
69    let mut push = |t: Token| {
70        if !out.iter().any(|o| o.text == t.text) {
71            out.push(t);
72        }
73    };
74    let mut word = String::new();
75    let flush = |word: &mut String, hash: bool, push: &mut dyn FnMut(Token)| {
76        let w = word.trim_matches(|c| matches!(c, '.' | '-' | '/' | '_'));
77        if let Some(rest) = w.strip_prefix("magi/") {
78            let parts: Vec<&str> = rest.split('/').collect();
79            if parts.len() == 2 && parts.iter().all(|p| !p.is_empty()) {
80                push(Token {
81                    text: w.to_owned(),
82                    branch: true,
83                });
84            }
85        } else if !hash
86            && (7..=40).contains(&w.len())
87            && w.chars().all(|c| c.is_ascii_hexdigit())
88            && w.chars().any(|c| c.is_ascii_digit())
89        {
90            push(Token {
91                text: w.to_ascii_lowercase(),
92                branch: false,
93            });
94        }
95        word.clear();
96    };
97    // `#deadbeef` is a colour (or an issue-style tag), not a commit.
98    let mut hash = false;
99    let mut prev = ' ';
100    for c in text.chars() {
101        if is_word(c) {
102            if word.is_empty() {
103                hash = prev == '#';
104            }
105            word.push(c);
106        } else {
107            flush(&mut word, hash, &mut push);
108        }
109        prev = c;
110    }
111    flush(&mut word, hash, &mut push);
112    // Branch names first: a sha that is one of them is then the same
113    // reference, and the name is the one worth keeping.
114    out.sort_by_key(|t| !t.branch);
115    out
116}
117
118/// Check every reference in `text` against `repo`, relative to `base_commit`.
119///
120/// A `magi/...` branch or a mixed letter-and-digit hex string that names
121/// nothing is kept as [`SeedKind::Unresolved`] so the run says so. An
122/// all-digit token (a date, an id) is dropped silently: it was almost never a
123/// sha, and refusing a task over it would be worse than ignoring it.
124pub async fn resolve(repo: &Path, base_commit: &str, remote: &str, text: &str) -> Vec<Seed> {
125    let mut seeds: Vec<Seed> = Vec::new();
126    for token in scan(text) {
127        let found = match commit_for(repo, remote, &token).await {
128            Some(sha) => sha,
129            None => {
130                if token.branch || token.text.chars().any(|c| c.is_ascii_alphabetic()) {
131                    seeds.push(Seed {
132                        token: token.text.clone(),
133                        kind: SeedKind::Unresolved,
134                        sha: String::new(),
135                        branch: token.branch,
136                        detail: format!("no branch or commit named {}", token.text),
137                    });
138                }
139                continue;
140            }
141        };
142        // A sha that is some named branch's tip is the same reference.
143        if seeds.iter().any(|s| s.sha == found) {
144            continue;
145        }
146        let seed = match git::ancestry(repo, &found, base_commit).await {
147            Ok(true) => {
148                let holders = git::branches_containing(repo, &found)
149                    .await
150                    .map(|b| b.join(", "))
151                    .unwrap_or_default();
152                Seed {
153                    token: token.text,
154                    kind: SeedKind::AlreadyMerged,
155                    sha: found,
156                    branch: token.branch,
157                    detail: holders,
158                }
159            }
160            Ok(false) => {
161                let holders = git::branches_containing(repo, &found)
162                    .await
163                    .map(|b| b.join(", "))
164                    .unwrap_or_default();
165                Seed {
166                    token: token.text,
167                    kind: SeedKind::Unmerged,
168                    sha: found,
169                    branch: token.branch,
170                    detail: holders,
171                }
172            }
173            Err(e) => Seed {
174                token: token.text,
175                kind: SeedKind::Unresolved,
176                sha: found,
177                branch: token.branch,
178                detail: format!("could not tell whether the base contains it: {e}"),
179            },
180        };
181        seeds.push(seed);
182    }
183    seeds
184}
185
186async fn commit_for(repo: &Path, remote: &str, token: &Token) -> Option<String> {
187    // A remote-tracking ref is only believed right after a fetch that
188    // succeeded: if the remote branch was deleted or the fetch failed, what is
189    // left is a stale copy and must not seed a run. A branch that only exists
190    // locally makes the fetch fail harmlessly.
191    let fetched = token.branch
192        && git::fetch(repo, remote, &token.text)
193            .await
194            .is_ok_and(|o| o.ok());
195    let local = match git::commit_of(repo, &token.text).await {
196        // `magi/x/A` must be a branch, not e.g. a tag or path-like revision.
197        Some(sha)
198            if !token.branch || git::branch_exists(repo, &token.text).await.unwrap_or(false) =>
199        {
200            Some(sha)
201        }
202        _ => None,
203    };
204    if !token.branch {
205        return local;
206    }
207    let tracked = if fetched {
208        git::commit_of(repo, &format!("refs/remotes/{remote}/{}", token.text)).await
209    } else {
210        None
211    };
212    match (local, tracked) {
213        // The remote has moved on from the local branch: the newer tip is the
214        // work the task means.
215        (Some(l), Some(t)) if l != t && git::is_ancestor(repo, &l, &t).await => Some(t),
216        (Some(l), _) => Some(l),
217        (None, t) => t,
218    }
219}
220
221/// Where the candidates start and what is applied on top.
222#[derive(Debug, Clone, PartialEq, Eq, Default)]
223pub struct Plan {
224    /// Commit to branch the candidates from, in place of the base.
225    pub start: Option<String>,
226    /// Unmerged commits to cherry-pick onto the start, oldest first.
227    pub picks: Vec<String>,
228}
229
230/// Turn resolved seeds into a starting point. Unmerged branches must form a
231/// single line of history (each one contained in the next); two that diverge
232/// cannot both be the start and guessing which the task meant is refused.
233/// Unmerged bare shas already contained in the start are not picked twice,
234/// and are ordered oldest first whatever order the task listed them in.
235///
236/// An unresolved reference does not refuse the task: a hex word or a
237/// `magi/...` path in prose is often not a reference at all. It stays in the
238/// facts the candidates read, and a candidate that ends up empty is refused at
239/// merge time.
240pub async fn plan(repo: &Path, seeds: &[Seed]) -> anyhow::Result<Plan> {
241    let mut start: Option<&Seed> = None;
242    for s in seeds
243        .iter()
244        .filter(|s| s.kind == SeedKind::Unmerged && s.branch)
245    {
246        match start {
247            None => start = Some(s),
248            Some(cur) => {
249                if git::ancestry(repo, &cur.sha, &s.sha).await? {
250                    start = Some(s);
251                } else if !git::ancestry(repo, &s.sha, &cur.sha).await? {
252                    anyhow::bail!(
253                        "the task names {} and {}, which have diverged; name one, \
254                         or the commits to take, so magi does not have to guess",
255                        cur.token,
256                        s.token
257                    );
258                }
259            }
260        }
261    }
262    let mut picks = Vec::new();
263    for s in seeds
264        .iter()
265        .filter(|s| s.kind == SeedKind::Unmerged && !s.branch)
266    {
267        if let Some(st) = start
268            && git::ancestry(repo, &s.sha, &st.sha).await?
269        {
270            continue;
271        }
272        if picks.contains(&s.sha) {
273            continue;
274        }
275        // Insert before the first pick this one is an ancestor of, so
276        // dependent commits apply in history order.
277        let mut at = picks.len();
278        for (i, p) in picks.iter().enumerate() {
279            if git::ancestry(repo, &s.sha, p).await? {
280                at = i;
281                break;
282            }
283        }
284        picks.insert(at, s.sha.clone());
285    }
286    Ok(Plan {
287        start: start.map(|s| s.sha.clone()),
288        picks,
289    })
290}
291
292fn short(sha: &str) -> &str {
293    &sha[..sha.len().min(8)]
294}
295
296/// One line per reference, as plain fact. `None` when there is nothing to say.
297pub fn describe(seeds: &[Seed]) -> Option<String> {
298    if seeds.is_empty() {
299        return None;
300    }
301    let lines: Vec<String> = seeds
302        .iter()
303        .map(|s| match s.kind {
304            SeedKind::Unmerged => format!(
305                "- `{}` ({}) is NOT on the base branch{}",
306                s.token,
307                short(&s.sha),
308                if s.detail.is_empty() {
309                    String::new()
310                } else {
311                    format!("; contained in: {}", s.detail)
312                }
313            ),
314            SeedKind::AlreadyMerged => format!(
315                "- `{}` ({}) is already contained in the base branch{}",
316                s.token,
317                short(&s.sha),
318                if s.detail.is_empty() {
319                    String::new()
320                } else {
321                    format!("; contained in: {}", s.detail)
322                }
323            ),
324            SeedKind::Unresolved => format!("- `{}` could not be resolved: {}", s.token, s.detail),
325        })
326        .collect();
327    Some(lines.join("\n"))
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::proc::Quiet as _;
334
335    #[test]
336    fn scan_finds_branches_and_shas() {
337        let t = scan(
338            "Land the fix dc2e888 from `magi/27b2/A`, see also magi/27b2/A. \
339             Not facade, not defaced, not magi/x.",
340        );
341        let texts: Vec<&str> = t.iter().map(|t| t.text.as_str()).collect();
342        assert_eq!(texts, vec!["magi/27b2/A", "dc2e888"]);
343        assert!(t[0].branch && !t[1].branch);
344    }
345
346    #[test]
347    fn scan_ignores_hex_colours() {
348        assert!(scan("Change the color to #deadbeef and #0a1b2c3d").is_empty());
349    }
350
351    #[test]
352    fn scan_ignores_short_or_long_hex() {
353        assert!(scan("abc123 and 0123456789abcdef0123456789abcdef012345678").is_empty());
354    }
355
356    fn sh(dir: &Path, args: &[&str]) {
357        let out = std::process::Command::new("git")
358            .quiet()
359            .args(args)
360            .current_dir(dir)
361            .output()
362            .expect("spawn git");
363        assert!(
364            out.status.success(),
365            "git {args:?}: {}",
366            String::from_utf8_lossy(&out.stderr)
367        );
368    }
369
370    fn commit_file(dir: &Path, name: &str, body: &str) -> String {
371        std::fs::write(dir.join(name), body).unwrap();
372        sh(dir, &["add", "-A"]);
373        sh(
374            dir,
375            &[
376                "-c",
377                "user.name=t",
378                "-c",
379                "user.email=t@localhost",
380                "commit",
381                "-q",
382                "-m",
383                name,
384            ],
385        );
386        let out = std::process::Command::new("git")
387            .quiet()
388            .args(["rev-parse", "HEAD"])
389            .current_dir(dir)
390            .output()
391            .unwrap();
392        String::from_utf8_lossy(&out.stdout).trim().to_owned()
393    }
394
395    fn repo_with_side_branch() -> (
396        tempfile::TempDir,
397        std::path::PathBuf,
398        String,
399        String,
400        String,
401    ) {
402        let tmp = tempfile::tempdir().unwrap();
403        let repo = tmp.path().join("repo");
404        std::fs::create_dir_all(&repo).unwrap();
405        sh(&repo, &["init", "-q", "-b", "main"]);
406        let merged = commit_file(&repo, "a.txt", "a\n");
407        sh(&repo, &["checkout", "-q", "-b", "magi/27b2/A"]);
408        let fix = commit_file(&repo, "fix.txt", "fix\n");
409        sh(&repo, &["checkout", "-q", "main"]);
410        let base = merged.clone();
411        (tmp, repo, base, fix, merged)
412    }
413
414    #[tokio::test]
415    async fn an_unmerged_branch_is_the_start_and_a_merged_sha_is_reported_as_such() {
416        let (_g, repo, base, fix, merged) = repo_with_side_branch();
417        let text = format!(
418            "Land the fix {} from magi/27b2/A; {} is old",
419            &fix[..8],
420            &merged[..8]
421        );
422        let seeds = resolve(&repo, &base, "origin", &text).await;
423
424        // The sha and the branch name are one reference.
425        assert_eq!(seeds.len(), 2, "{seeds:?}");
426        let branch = &seeds[0];
427        assert_eq!(branch.token, "magi/27b2/A");
428        assert_eq!(branch.kind, SeedKind::Unmerged);
429        assert_eq!(branch.sha, fix);
430        assert!(branch.detail.contains("magi/27b2/A"), "{}", branch.detail);
431        assert_eq!(seeds[1].kind, SeedKind::AlreadyMerged);
432        assert_eq!(seeds[1].sha, merged);
433
434        let plan = plan(&repo, &seeds).await.unwrap();
435        assert_eq!(plan.start.as_deref(), Some(fix.as_str()));
436        assert!(plan.picks.is_empty());
437        let facts = describe(&seeds).unwrap();
438        assert!(facts.contains("NOT on the base branch"), "{facts}");
439        assert!(facts.contains("already contained"), "{facts}");
440    }
441
442    #[tokio::test]
443    async fn a_bare_unmerged_sha_is_cherry_picked_and_an_unknown_branch_is_reported() {
444        let (_g, repo, base, fix, _) = repo_with_side_branch();
445        let seeds = resolve(
446            &repo,
447            &base,
448            "origin",
449            &format!("take {} and magi/zzzz/A", &fix[..10]),
450        )
451        .await;
452        assert_eq!(seeds.len(), 2, "{seeds:?}");
453        assert_eq!(seeds[0].kind, SeedKind::Unresolved);
454        assert_eq!(seeds[1].kind, SeedKind::Unmerged);
455        let ghost = resolve(&repo, &base, "origin", "cherry-pick abc1234f").await;
456        assert_eq!(ghost.len(), 1, "{ghost:?}");
457        assert_eq!(ghost[0].kind, SeedKind::Unresolved);
458        assert!(
459            resolve(&repo, &base, "origin", "on 20260930")
460                .await
461                .is_empty()
462        );
463        assert!(!seeds[1].branch);
464
465        let plan = plan(&repo, &seeds).await.unwrap();
466        assert_eq!(plan.start, None);
467        assert_eq!(plan.picks, vec![fix.clone()]);
468
469        sh(&repo, &["checkout", "-q", "-b", "cand"]);
470        git::cherry_pick(&repo, &fix).await.unwrap();
471        assert!(repo.join("fix.txt").is_file());
472    }
473
474    #[tokio::test]
475    async fn a_task_that_names_nothing_resolves_to_nothing() {
476        let (_g, repo, base, _, _) = repo_with_side_branch();
477        assert!(
478            resolve(&repo, &base, "origin", "just fix the bug")
479                .await
480                .is_empty()
481        );
482    }
483}