Skip to main content

majordomus_cli/worktree/
trace.rs

1//! The work graph above the branch, derived from git and stored nowhere.
2//!
3//! [`state::issue_of`](super::state::issue_of) reads an issue id out of a branch name, and
4//! until now that was the whole model: a branch knew which issue it served and nothing knew
5//! which commits served it. This module continues the same edge upwards and downwards
6//! without adding a second store of truth.
7//!
8//! ```text
9//! issue  ──names──▶  branch  ──contains──▶  commit
10//!   ▲                   │                      │
11//!   └───────────────────┴──────────────────────┘
12//!            the same edge, read backwards
13//! ```
14//!
15//! # What is derived from what
16//!
17//! An issue's branches are the refs — local and remote-tracking — with a path component
18//! that names it. A branch's commits are the commits it holds and the trunk did not:
19//! `branch ^trunk` while the branch is open, and `branch ^merge^1` once a merge commit has
20//! brought it in, where the merge commit is the earliest trunk commit descended from the
21//! branch. A commit's issue is the issue whose branch set contains it. Nothing is written
22//! down, because git already holds every one of those facts and a record that repeated one
23//! would be a second truth to keep current.
24//!
25//! # What git cannot answer, and says so
26//!
27//! Three states are honest answers rather than failures, and each is reported by name:
28//!
29//! * [`Integration::Absorbed`] — the branch reached the trunk with no merge commit of its
30//!   own (a fast-forward, or a rebase-and-merge). Its commits are indistinguishable from
31//!   the trunk's, so none are claimed.
32//! * [`Attribution::Unattributed`] — no ref naming an issue contains the commit. A branch
33//!   deleted after its merge takes its own name with it; the head branch of a merged pull
34//!   request survives on GitHub, which is why the pull-request half of this derivation is
35//!   `scripts/traceability` and not this file — the executable makes no network call.
36//! * [`Attribution::Ambiguous`] — two branches naming two different issues contain it.
37//!
38//! An unattributed commit is reported, never omitted: work with no execution contract is
39//! exactly what a traceability report exists to make visible.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::path::{Path, PathBuf};
43
44use schemars::JsonSchema;
45use serde::{Deserialize, Serialize};
46
47use super::error::Result;
48use super::git;
49use super::identity::RepositoryIdentity;
50use super::state::{issue_ids, issue_of};
51
52/// The most commits one branch claims. A branch with more than this is a history import,
53/// not a piece of work, and the trace says it was cut rather than pretending it was not.
54pub const MAX_COMMITS_PER_BRANCH: usize = 1000;
55
56/// How many trunk commits a report attributes when the caller names no number.
57pub const DEFAULT_REPORT_COMMITS: usize = 50;
58
59/// The most trunk commits one report attributes.
60pub const MAX_REPORT_COMMITS: usize = 2000;
61
62/// The separator between the fields of one commit; a commit message may hold anything else.
63const SEP: char = '\u{0}';
64
65/// The pretty format every commit in this module is read with.
66const FORMAT: &str = "--format=%H%x00%h%x00%an%x00%aI%x00%s";
67
68/// One commit, exactly as git names it. Nothing here is stored anywhere: the whole record
69/// is re-read from the object database on every call.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71pub struct CommitRef {
72    /// The full object name.
73    pub id: String,
74    /// The abbreviated object name, as this repository abbreviates it.
75    pub short: String,
76    /// The author, as the commit records them.
77    pub author: String,
78    /// The author date, ISO 8601 as the commit records it.
79    pub date: String,
80    /// The subject line.
81    pub subject: String,
82}
83
84/// Parse one commit line of `FORMAT`. A line with fewer fields is not a commit.
85///
86/// ```
87/// use majordomus_cli::worktree::parse_commit;
88/// let c = parse_commit("abc123\0abc\0A Person\02026-09-09T10:00:00+02:00\0feat: a thing").unwrap();
89/// assert_eq!(c.short, "abc");
90/// assert_eq!(c.subject, "feat: a thing");
91/// assert!(parse_commit("abc123").is_none());
92/// ```
93pub fn parse_commit(line: &str) -> Option<CommitRef> {
94    let f: Vec<&str> = line.split(SEP).collect();
95    if f.len() < 5 || f[0].is_empty() {
96        return None;
97    }
98    Some(CommitRef {
99        id: f[0].to_string(),
100        short: f[1].to_string(),
101        author: f[2].to_string(),
102        date: f[3].to_string(),
103        subject: f[4..].join(""),
104    })
105}
106
107/// Every commit in the output of a `git log` run with [`FORMAT`].
108fn parse_commits(text: &str) -> Vec<CommitRef> {
109    text.lines().filter_map(parse_commit).collect()
110}
111
112/// How a branch stands to the trunk, which is what decides which commits are its own.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[serde(rename_all = "snake_case")]
115pub enum Integration {
116    /// Not reachable from the trunk: its commits are the ones the trunk does not have.
117    Open,
118    /// Reachable from the trunk through a merge commit, which the trace names: its commits
119    /// are the ones that merge brought in.
120    Merged,
121    /// Reachable from the trunk with no merge commit of its own — fast-forwarded, or
122    /// rebased onto it. Its commits cannot be told from the trunk's and none are claimed.
123    Absorbed,
124    /// The trunk is unknown, so there is nothing to measure the branch against.
125    Unknown,
126}
127
128/// One branch that names an issue, with the commits it holds.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
130pub struct BranchTrace {
131    /// The ref, short: `feature/I1305-traceability`, or `origin/feature/I1305-traceability`
132    /// when only the remote still has it.
133    pub name: String,
134    /// True when the ref is a remote-tracking one and no local branch of the same name
135    /// stands for it.
136    pub remote: bool,
137    /// The commit the ref points at.
138    pub head: String,
139    /// How it stands to the trunk.
140    pub integration: Integration,
141    /// The merge commit that brought it into the trunk, when one did.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub merge_commit: Option<String>,
144    /// Why the commit list is what it is, when it is worth a sentence.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub note: Option<String>,
147    /// The commits this branch holds and the trunk did not, newest first, merges excluded.
148    pub commits: Vec<CommitRef>,
149}
150
151/// Everything git knows about one issue's realisation.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
153pub struct IssueTrace {
154    /// The issue id, as the project model spells it.
155    pub issue: String,
156    /// True when the project model declares this id. False is not an error and not an
157    /// empty answer: it says the id was looked for and the model does not have it, which a
158    /// caller must be able to tell apart from "declared, and nothing has realised it yet".
159    /// A repository with no project model at all answers `false` for every id rather than
160    /// refusing, because the branches naming an id are still derivable there.
161    pub declared: bool,
162    /// The milestone the canonical issue record names. Git does not hold this edge and
163    /// nothing here derives it: the caller with the index fills it in, and it is `null` in
164    /// a repository whose issue record does not name one.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub milestone: Option<String>,
167    /// The trunk every branch was measured against.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub trunk: Option<String>,
170    /// The branches that name it, local first.
171    pub branches: Vec<BranchTrace>,
172    /// How many distinct commits the branches hold between them.
173    pub commits: usize,
174    /// True when every branch's commits could be derived. False when at least one reached
175    /// the trunk without a merge commit, so part of the work is not distinguishable.
176    pub complete: bool,
177}
178
179/// What is known about the contract one commit served.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "snake_case")]
182pub enum Attribution {
183    /// Exactly one issue's branches contain it.
184    Attributed,
185    /// No branch naming an issue contains it.
186    Unattributed,
187    /// Branches naming more than one issue contain it.
188    Ambiguous,
189}
190
191/// One commit and the contract it served, or the fact that none can be found.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
193pub struct CommitAttribution {
194    /// The commit.
195    pub commit: CommitRef,
196    /// What is known.
197    pub attribution: Attribution,
198    /// The issue, when exactly one claims it.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub issue: Option<String>,
201    /// The milestone that issue belongs to, filled by the caller that has the index.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub milestone: Option<String>,
204    /// Every issue whose branches contain it: one when attributed, none when unattributed,
205    /// more than one when ambiguous.
206    pub issues: Vec<String>,
207    /// The branches that contain it, by the names their refs carry.
208    pub branches: Vec<String>,
209    /// Why the verdict is what it is, in one line a person can act on.
210    pub reason: String,
211}
212
213/// The whole traceability answer: every issue git can say something about, and every commit
214/// of a stretch of the trunk with the contract it served or the fact that it has none.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
216pub struct TraceReport {
217    /// The trunk every branch and commit was measured against.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub trunk: Option<String>,
220    /// How many trunk commits were attributed.
221    pub examined: usize,
222    /// The issues that at least one branch names, in id order.
223    pub issues: Vec<IssueTrace>,
224    /// The issue ids the project model declares that no ref names. Not a fault: an issue
225    /// nobody has started has no branch yet.
226    pub without_branch: Vec<String>,
227    /// The examined trunk commits, newest first.
228    pub commits: Vec<CommitAttribution>,
229    /// The counts, so a caller need not add them up.
230    pub tallies: TraceTallies,
231}
232
233/// The counts of one report.
234#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
235pub struct TraceTallies {
236    /// Issue ids the project model declares.
237    pub issues_declared: usize,
238    /// Of those, the ones at least one ref names.
239    pub issues_with_branch: usize,
240    /// Branches naming an issue.
241    pub branches: usize,
242    /// Distinct commits those branches claim.
243    pub branch_commits: usize,
244    /// Examined trunk commits with exactly one issue.
245    pub attributed: usize,
246    /// Examined trunk commits no branch naming an issue contains.
247    pub unattributed: usize,
248    /// Examined trunk commits more than one issue claims.
249    pub ambiguous: usize,
250}
251
252/// A ref is presented by its name, which is unique in the namespace it was listed from.
253/// `feature/x-2` sorts before `feature/x-10` here, which is what a person reading a branch
254/// listing expects and not what the bytes give.
255impl crate::order::Ordered for IssueRef {
256    fn order_key(&self) -> crate::order::OrderKey<'_> {
257        crate::order::OrderKey::plain(&self.name, &self.name)
258    }
259}
260
261/// One ref that names an issue, as `for-each-ref` reported it.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct IssueRef {
264    /// The ref, short.
265    pub name: String,
266    /// The commit it points at.
267    pub head: String,
268    /// True when it is a remote-tracking ref standing in for a branch this clone has no
269    /// local copy of.
270    pub remote: bool,
271    /// The issue its name provably contains.
272    pub issue: String,
273}
274
275/// The reader. Opened from anywhere inside the repository; every question is answered by
276/// running git again, because the history changes outside this process.
277#[derive(Debug, Clone)]
278pub struct Tracer {
279    root: PathBuf,
280    trunk: Option<String>,
281    issues: Vec<String>,
282}
283
284impl Tracer {
285    /// Open the tracer for the repository holding `start`. Git runs in the work tree the
286    /// caller named: every ref and every commit of the repository is visible from any of
287    /// them, so the answer does not depend on which one it is, while reading the project
288    /// model from somewhere else would answer about a plan this checkout does not have.
289    /// The trunk is the repository's, discovered the way the topology discovers it.
290    pub fn open(start: &Path) -> Result<Self> {
291        let identity = RepositoryIdentity::discover(start)?;
292        let root = identity.current_worktree().path.clone();
293        let trunk = identity.trunk().branch.clone();
294        // the file names under .ai/repo/project/issues/, of this work tree; a caller that
295        // has already parsed the project model replaces them with `with_issues`
296        let issues = issue_ids(&root);
297        Ok(Tracer {
298            root,
299            trunk,
300            issues,
301        })
302    }
303
304    /// The issue ids to look for in branch names, from a caller that has already read the
305    /// project model — the index, which parsed every issue record, rather than a second
306    /// directory listing that could disagree with it.
307    pub fn with_issues(mut self, issues: Vec<String>) -> Self {
308        self.issues = issues;
309        self
310    }
311
312    /// The trunk every measurement is against, when one was discovered.
313    pub fn trunk(&self) -> Option<&str> {
314        self.trunk.as_deref()
315    }
316
317    /// The issue ids the project model declares.
318    pub fn issues(&self) -> &[String] {
319        &self.issues
320    }
321
322    /// Every ref — local branch first, then remote-tracking — whose name provably contains
323    /// one of `ids`. A remote-tracking ref whose local counterpart is already in the list is
324    /// dropped: it is the same branch seen twice, and counting its commits twice would
325    /// inflate every tally that follows.
326    fn issue_refs(&self, ids: &[String]) -> Result<Vec<IssueRef>> {
327        let out = git::run(
328            &self.root,
329            &[
330                "for-each-ref",
331                "refs/heads",
332                "refs/remotes",
333                "--format=%(refname:short)%00%(objectname)%00%(refname)",
334            ],
335        )?;
336        let text = String::from_utf8_lossy(&out.stdout).to_string();
337        Ok(parse_issue_refs(&text, ids))
338    }
339
340    /// The commits one ref holds and the trunk does not, with how it reached the trunk.
341    fn branch_trace(&self, r: &IssueRef) -> Result<BranchTrace> {
342        let Some(trunk) = self.trunk.as_deref() else {
343            return Ok(BranchTrace {
344                name: r.name.clone(),
345                remote: r.remote,
346                head: r.head.clone(),
347                integration: Integration::Unknown,
348                merge_commit: None,
349                note: Some(
350                    "the trunk could not be determined, so the branch has nothing to be \
351                     measured against"
352                        .into(),
353                ),
354                commits: Vec::new(),
355            });
356        };
357        // The trunk as a ref this repository actually has: the local branch, or the
358        // remote-tracking one in a clone that never checked it out.
359        let trunk_ref = if git::rev_exists(&self.root, trunk)? {
360            trunk.to_string()
361        } else if git::rev_exists(&self.root, &format!("origin/{trunk}"))? {
362            format!("origin/{trunk}")
363        } else {
364            return Ok(BranchTrace {
365                name: r.name.clone(),
366                remote: r.remote,
367                head: r.head.clone(),
368                integration: Integration::Unknown,
369                merge_commit: None,
370                note: Some(format!(
371                    "the trunk `{trunk}` does not resolve in this clone, so the branch has \
372                     nothing to be measured against"
373                )),
374                commits: Vec::new(),
375            });
376        };
377
378        let merged = crate::git::is_ancestor(&self.root, &r.head, &trunk_ref).unwrap_or(false);
379        if !merged {
380            let commits = self.log(&[r.name.clone(), format!("^{trunk_ref}")])?;
381            return Ok(cut(BranchTrace {
382                name: r.name.clone(),
383                remote: r.remote,
384                head: r.head.clone(),
385                integration: Integration::Open,
386                merge_commit: None,
387                note: None,
388                commits,
389            }));
390        }
391
392        // Merged. The earliest trunk commit descended from the branch is the commit that
393        // brought it in; `--ancestry-path` is what makes "descended from" the question
394        // rather than "committed after".
395        let merge = self.integration_commit(&r.head, &trunk_ref)?;
396        let Some((merge, first_parent)) = merge else {
397            return Ok(BranchTrace {
398                name: r.name.clone(),
399                remote: r.remote,
400                head: r.head.clone(),
401                integration: Integration::Absorbed,
402                merge_commit: None,
403                note: Some(
404                    "the branch reached the trunk with no merge commit of its own — \
405                     fast-forwarded, or rebased onto it — so its commits cannot be told \
406                     from the trunk's and none are claimed"
407                        .into(),
408                ),
409                commits: Vec::new(),
410            });
411        };
412        let commits = self.log(&[r.name.clone(), format!("^{first_parent}")])?;
413        Ok(cut(BranchTrace {
414            name: r.name.clone(),
415            remote: r.remote,
416            head: r.head.clone(),
417            integration: Integration::Merged,
418            merge_commit: Some(merge),
419            note: None,
420            commits,
421        }))
422    }
423
424    /// The merge commit that brought `head` into `trunk_ref`, with its first parent — the
425    /// trunk as it stood before the merge, which is what the branch's commits are measured
426    /// against. `None` when the branch was absorbed without a merge commit.
427    fn integration_commit(&self, head: &str, trunk_ref: &str) -> Result<Option<(String, String)>> {
428        // Two runs on purpose. `--ancestry-path` is a history-simplification option, and
429        // `--parents` under simplification prints *rewritten* parents — which would name a
430        // commit inside the filtered set rather than the trunk as it stood. The candidate
431        // is found under simplification; its parents are read back without it.
432        let out = git::run(
433            &self.root,
434            &[
435                "rev-list",
436                "--ancestry-path",
437                "--reverse",
438                &format!("{head}..{trunk_ref}"),
439            ],
440        )?;
441        let text = String::from_utf8_lossy(&out.stdout).to_string();
442        let Some(candidate) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
443            return Ok(None);
444        };
445        let parents = git::run(&self.root, &["rev-list", "--parents", "-n", "1", candidate])?;
446        Ok(first_merge(&String::from_utf8_lossy(&parents.stdout)))
447    }
448
449    /// `git log` over a revision specification, in [`FORMAT`], merges excluded.
450    fn log(&self, spec: &[String]) -> Result<Vec<CommitRef>> {
451        let mut args: Vec<String> = vec![
452            "log".into(),
453            "--no-merges".into(),
454            FORMAT.into(),
455            format!("--max-count={}", MAX_COMMITS_PER_BRANCH + 1),
456        ];
457        args.extend(spec.iter().cloned());
458        let out = git::run(&self.root, &args)?;
459        Ok(parse_commits(&String::from_utf8_lossy(&out.stdout)))
460    }
461
462    /// Every issue at least one ref names, in id order, each with its branches and commits.
463    pub fn traces(&self) -> Result<Vec<IssueTrace>> {
464        let refs = self.issue_refs(&self.issues)?;
465        let mut by_issue: BTreeMap<String, Vec<BranchTrace>> = BTreeMap::new();
466        for r in &refs {
467            by_issue
468                .entry(r.issue.clone())
469                .or_default()
470                .push(self.branch_trace(r)?);
471        }
472        Ok(by_issue
473            .into_iter()
474            .map(|(issue, branches)| assemble(issue, self.trunk.clone(), branches))
475            .collect())
476    }
477
478    /// One issue: its branches and their commits. An id no ref names is a trace with no
479    /// branches, not an error — an issue nobody has started yet is a legitimate answer.
480    pub fn trace(&self, issue: &str) -> Result<IssueTrace> {
481        let ids = vec![issue.to_string()];
482        let refs = self.issue_refs(&ids)?;
483        let mut branches = Vec::new();
484        for r in &refs {
485            branches.push(self.branch_trace(r)?);
486        }
487        let mut t = assemble(issue.to_string(), self.trunk.clone(), branches);
488        t.declared = self.issues.iter().any(|i| i == issue);
489        Ok(t)
490    }
491
492    /// Resolve a revision to the commit it names, with the fields a report shows. `None`
493    /// when nothing in this repository answers to it.
494    pub fn commit(&self, rev: &str) -> Result<Option<CommitRef>> {
495        let out = git::try_run(
496            &self.root,
497            &["log", "--no-walk", FORMAT, &format!("{rev}^{{commit}}")],
498        )?;
499        if out.status != Some(0) {
500            return Ok(None);
501        }
502        Ok(parse_commits(&String::from_utf8_lossy(&out.stdout))
503            .into_iter()
504            .next())
505    }
506
507    /// The newest `limit` commits of the trunk, merges excluded: the work a report
508    /// attributes. Merge commits are integration events rather than work, and each is
509    /// already named as the `merge_commit` of the branch it brought in.
510    pub fn trunk_commits(&self, limit: usize) -> Result<Vec<CommitRef>> {
511        let Some(trunk) = self.trunk.as_deref() else {
512            return Ok(Vec::new());
513        };
514        let trunk_ref = if git::rev_exists(&self.root, trunk)? {
515            trunk.to_string()
516        } else if git::rev_exists(&self.root, &format!("origin/{trunk}"))? {
517            format!("origin/{trunk}")
518        } else {
519            return Ok(Vec::new());
520        };
521        let out = git::run(
522            &self.root,
523            &[
524                "log",
525                "--no-merges",
526                FORMAT,
527                &format!("--max-count={}", limit.min(MAX_REPORT_COMMITS)),
528                &trunk_ref,
529            ],
530        )?;
531        Ok(parse_commits(&String::from_utf8_lossy(&out.stdout)))
532    }
533
534    /// The whole answer: every issue with a branch, every issue without one, and the newest
535    /// `limit` trunk commits each attributed to the issue whose branches contain it or
536    /// reported as having none.
537    pub fn report(&self, limit: usize) -> Result<TraceReport> {
538        let issues = self.traces()?;
539        let named: BTreeSet<&str> = issues.iter().map(|t| t.issue.as_str()).collect();
540        let without_branch: Vec<String> = self
541            .issues
542            .iter()
543            .filter(|id| !named.contains(id.as_str()))
544            .cloned()
545            .collect();
546        let commits = attribute(&self.trunk_commits(limit)?, &issues);
547        let mut tallies = TraceTallies {
548            issues_declared: self.issues.len(),
549            issues_with_branch: issues.len(),
550            branches: issues.iter().map(|t| t.branches.len()).sum(),
551            branch_commits: distinct_commits(&issues),
552            ..TraceTallies::default()
553        };
554        for c in &commits {
555            match c.attribution {
556                Attribution::Attributed => tallies.attributed += 1,
557                Attribution::Unattributed => tallies.unattributed += 1,
558                Attribution::Ambiguous => tallies.ambiguous += 1,
559            }
560        }
561        Ok(TraceReport {
562            trunk: self.trunk.clone(),
563            examined: commits.len(),
564            issues,
565            without_branch,
566            commits,
567            tallies,
568        })
569    }
570}
571
572/// The refs of `text` that name one of `ids`, local branches first, each remote-tracking
573/// ref dropped when a local branch already stands for the same name.
574///
575/// ```
576/// use majordomus_cli::worktree::parse_issue_refs;
577/// let ids = vec!["I1305".to_string()];
578/// let text = "feature/I1305-x\0aaa\0refs/heads/feature/I1305-x\n\
579///             origin/feature/I1305-x\0aaa\0refs/remotes/origin/feature/I1305-x\n\
580///             origin/feature/I1305-y\0bbb\0refs/remotes/origin/feature/I1305-y\n\
581///             master\0ccc\0refs/heads/master\n";
582/// let refs = parse_issue_refs(text, &ids);
583/// assert_eq!(refs.len(), 2);
584/// assert_eq!(refs[0].name, "feature/I1305-x");
585/// assert!(!refs[0].remote);
586/// assert_eq!(refs[1].name, "origin/feature/I1305-y");
587/// assert!(refs[1].remote);
588/// ```
589pub fn parse_issue_refs(text: &str, ids: &[String]) -> Vec<IssueRef> {
590    parse_refs(text, ids)
591}
592
593/// The typed half of [`parse_issue_refs`].
594fn parse_refs(text: &str, ids: &[String]) -> Vec<IssueRef> {
595    let mut local: Vec<IssueRef> = Vec::new();
596    let mut remote: Vec<IssueRef> = Vec::new();
597    let mut local_names: BTreeSet<String> = BTreeSet::new();
598    for line in text.lines() {
599        let f: Vec<&str> = line.split(SEP).collect();
600        if f.len() < 3 || f[0].is_empty() {
601            continue;
602        }
603        let (name, head, full) = (f[0], f[1], f[2]);
604        // `origin/HEAD` is a symbolic pointer at another ref, never a branch of its own
605        if name.ends_with("/HEAD") {
606            continue;
607        }
608        let is_remote = full.starts_with("refs/remotes/");
609        if !is_remote {
610            local_names.insert(name.to_string());
611        }
612        let Some(issue) = issue_of(name, ids) else {
613            continue;
614        };
615        let r = IssueRef {
616            name: name.to_string(),
617            head: head.to_string(),
618            remote: is_remote,
619            issue,
620        };
621        if is_remote {
622            remote.push(r);
623        } else {
624            local.push(r);
625        }
626    }
627    // a remote-tracking ref is the same branch as its local counterpart; keep one
628    remote.retain(|r| {
629        !r.name
630            .split_once('/')
631            .is_some_and(|(_, rest)| local_names.contains(rest))
632    });
633    crate::order::canonical(&mut local);
634    crate::order::canonical(&mut remote);
635    local.extend(remote);
636    local
637}
638
639/// The first merge commit of a `rev-list --parents --reverse --ancestry-path` listing, with
640/// its first parent. A listing whose first entry has one parent is a branch that reached the
641/// trunk without a merge of its own.
642///
643/// ```
644/// use majordomus_cli::worktree::first_merge;
645/// assert_eq!(first_merge("m p1 p2\nx m\n"), Some(("m".into(), "p1".into())));
646/// assert!(first_merge("ff p\n").is_none());
647/// assert!(first_merge("").is_none());
648/// ```
649pub fn first_merge(text: &str) -> Option<(String, String)> {
650    let line = text.lines().find(|l| !l.trim().is_empty())?;
651    let mut fields = line.split_whitespace();
652    let commit = fields.next()?.to_string();
653    let first_parent = fields.next()?.to_string();
654    // one parent only: not a merge
655    fields.next()?;
656    Some((commit, first_parent))
657}
658
659/// Cut a branch's commit list at [`MAX_COMMITS_PER_BRANCH`] and say so when it was cut.
660fn cut(mut b: BranchTrace) -> BranchTrace {
661    if b.commits.len() > MAX_COMMITS_PER_BRANCH {
662        b.commits.truncate(MAX_COMMITS_PER_BRANCH);
663        b.note = Some(format!(
664            "more than {MAX_COMMITS_PER_BRANCH} commits; the list is cut at that and the \
665             branch is a history import rather than a piece of work"
666        ));
667    }
668    b
669}
670
671/// Assemble one issue's trace from its branches.
672fn assemble(issue: String, trunk: Option<String>, branches: Vec<BranchTrace>) -> IssueTrace {
673    let declared = true;
674    let distinct: BTreeSet<&str> = branches
675        .iter()
676        .flat_map(|b| b.commits.iter().map(|c| c.id.as_str()))
677        .collect();
678    let complete = branches
679        .iter()
680        .all(|b| b.integration != Integration::Absorbed && b.integration != Integration::Unknown);
681    IssueTrace {
682        issue,
683        declared,
684        milestone: None,
685        trunk,
686        commits: distinct.len(),
687        complete,
688        branches,
689    }
690}
691
692/// How many distinct commits a set of traces claims between them.
693fn distinct_commits(traces: &[IssueTrace]) -> usize {
694    traces
695        .iter()
696        .flat_map(|t| t.branches.iter())
697        .flat_map(|b| b.commits.iter().map(|c| c.id.as_str()))
698        .collect::<BTreeSet<&str>>()
699        .len()
700}
701
702/// Attribute every commit to the issue whose branches contain it, or report that none does.
703///
704/// This is the reverse of the same edge and reads nothing else: the branch sets were derived
705/// from git a moment ago, and a commit is attributed because a branch that names an issue
706/// holds it, never because a record said so.
707pub fn attribute(commits: &[CommitRef], traces: &[IssueTrace]) -> Vec<CommitAttribution> {
708    let mut owners: BTreeMap<&str, BTreeSet<(&str, &str)>> = BTreeMap::new();
709    // A commit belongs to the issue whose branch carries it — and a merge commit belongs to
710    // the issue whose branch it merged, though no branch contains it. The merge commit is
711    // the one a reader is most likely to hold: it is what lands on the trunk and what a
712    // release note names. Leaving it unattributed answered "which outcome did this serve?"
713    // with silence for exactly the commit the question is usually asked about.
714    for t in traces {
715        for b in &t.branches {
716            for c in &b.commits {
717                owners
718                    .entry(c.id.as_str())
719                    .or_default()
720                    .insert((t.issue.as_str(), b.name.as_str()));
721            }
722            if let Some(m) = b.merge_commit.as_deref() {
723                owners
724                    .entry(m)
725                    .or_default()
726                    .insert((t.issue.as_str(), b.name.as_str()));
727            }
728        }
729    }
730    let milestone_of = |issue: &str| {
731        traces
732            .iter()
733            .find(|t| t.issue == issue)
734            .and_then(|t| t.milestone.clone())
735    };
736    commits
737        .iter()
738        .map(|c| {
739            let found = owners.get(c.id.as_str());
740            let issues: Vec<String> = found
741                .map(|s| {
742                    s.iter()
743                        .map(|(i, _)| (*i).to_string())
744                        .collect::<BTreeSet<String>>()
745                        .into_iter()
746                        .collect()
747                })
748                .unwrap_or_default();
749            let branches: Vec<String> = found
750                .map(|s| s.iter().map(|(_, b)| (*b).to_string()).collect())
751                .unwrap_or_default();
752            let (attribution, issue, reason) = match issues.len() {
753                0 => (
754                    Attribution::Unattributed,
755                    None,
756                    "no ref naming an issue contains it; either it was committed without an \
757                     execution contract, or the branch that carried it has been deleted — a \
758                     deleted head branch survives on GitHub, which `scripts/traceability` \
759                     reads and this executable does not"
760                        .to_string(),
761                ),
762                1 => (
763                    Attribution::Attributed,
764                    Some(issues[0].clone()),
765                    format!("{} contains it", branches.join(", ")),
766                ),
767                _ => (
768                    Attribution::Ambiguous,
769                    None,
770                    format!(
771                        "branches naming {} issues contain it ({}); the branch-name edge \
772                         cannot decide which contract it served",
773                        issues.len(),
774                        branches.join(", ")
775                    ),
776                ),
777            };
778            let milestone = issue.as_deref().and_then(milestone_of);
779            CommitAttribution {
780                commit: c.clone(),
781                attribution,
782                issue,
783                milestone,
784                issues,
785                branches,
786                reason,
787            }
788        })
789        .collect()
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    fn commit(id: &str) -> CommitRef {
797        CommitRef {
798            id: id.into(),
799            short: id[..3.min(id.len())].into(),
800            author: "A".into(),
801            date: "2026-09-09T00:00:00+00:00".into(),
802            subject: format!("work {id}"),
803        }
804    }
805
806    fn trace(issue: &str, milestone: Option<&str>, branch: &str, ids: &[&str]) -> IssueTrace {
807        let mut t = assemble(
808            issue.into(),
809            Some("master".into()),
810            vec![BranchTrace {
811                name: branch.into(),
812                remote: false,
813                head: ids.last().copied().unwrap_or("x").into(),
814                integration: Integration::Open,
815                merge_commit: None,
816                note: None,
817                commits: ids.iter().map(|i| commit(i)).collect(),
818            }],
819        );
820        t.milestone = milestone.map(str::to_string);
821        t
822    }
823
824    #[test]
825    fn a_commit_no_issue_branch_holds_is_reported_not_omitted() {
826        let traces = vec![trace(
827            "I1305",
828            Some("work-graph"),
829            "feature/I1305-x",
830            &["aaa"],
831        )];
832        let out = attribute(&[commit("aaa"), commit("zzz")], &traces);
833        assert_eq!(out.len(), 2, "every examined commit is in the answer");
834        assert_eq!(out[0].attribution, Attribution::Attributed);
835        assert_eq!(out[0].issue.as_deref(), Some("I1305"));
836        assert_eq!(out[0].milestone.as_deref(), Some("work-graph"));
837        assert_eq!(out[1].attribution, Attribution::Unattributed);
838        assert!(out[1].issue.is_none());
839        assert!(out[1].reason.contains("no ref naming an issue"));
840    }
841
842    #[test]
843    fn two_issues_claiming_one_commit_is_ambiguous_not_a_guess() {
844        let traces = vec![
845            trace("I1305", None, "feature/I1305-x", &["aaa"]),
846            trace("I1306", None, "feature/I1306-y", &["aaa"]),
847        ];
848        let out = attribute(&[commit("aaa")], &traces);
849        assert_eq!(out[0].attribution, Attribution::Ambiguous);
850        assert!(out[0].issue.is_none());
851        assert_eq!(out[0].issues, vec!["I1305", "I1306"]);
852    }
853
854    #[test]
855    fn a_remote_ref_whose_local_branch_exists_is_the_same_branch_once() {
856        let ids = vec!["I1305".to_string()];
857        let text = "feature/I1305-x\u{0}aaa\u{0}refs/heads/feature/I1305-x\n\
858                    origin/feature/I1305-x\u{0}aaa\u{0}refs/remotes/origin/feature/I1305-x\n";
859        let refs = parse_refs(text, &ids);
860        assert_eq!(refs.len(), 1);
861        assert!(!refs[0].remote);
862    }
863
864    #[test]
865    fn a_branch_only_the_remote_still_has_is_kept_and_marked() {
866        let ids = vec!["I1305".to_string()];
867        let text = "origin/feature/I1305-x\u{0}aaa\u{0}refs/remotes/origin/feature/I1305-x\n\
868                    origin/HEAD\u{0}aaa\u{0}refs/remotes/origin/HEAD\n";
869        let refs = parse_refs(text, &ids);
870        assert_eq!(refs.len(), 1);
871        assert!(refs[0].remote);
872        assert_eq!(refs[0].issue, "I1305");
873    }
874
875    #[test]
876    fn an_absorbed_branch_claims_nothing_rather_than_claiming_the_trunk() {
877        assert!(first_merge("ff parent-only\n").is_none());
878        let t = assemble(
879            "I1305".into(),
880            Some("master".into()),
881            vec![BranchTrace {
882                name: "feature/I1305-x".into(),
883                remote: false,
884                head: "aaa".into(),
885                integration: Integration::Absorbed,
886                merge_commit: None,
887                note: Some("absorbed".into()),
888                commits: Vec::new(),
889            }],
890        );
891        assert_eq!(t.commits, 0);
892        assert!(!t.complete, "an absorbed branch makes the trace incomplete");
893    }
894
895    #[test]
896    fn one_commit_on_two_branches_of_one_issue_is_counted_once() {
897        let t = assemble(
898            "I1305".into(),
899            Some("master".into()),
900            vec![
901                BranchTrace {
902                    name: "feature/I1305-x".into(),
903                    remote: false,
904                    head: "aaa".into(),
905                    integration: Integration::Open,
906                    merge_commit: None,
907                    note: None,
908                    commits: vec![commit("aaa")],
909                },
910                BranchTrace {
911                    name: "origin/feature/I1305-z".into(),
912                    remote: true,
913                    head: "aaa".into(),
914                    integration: Integration::Open,
915                    merge_commit: None,
916                    note: None,
917                    commits: vec![commit("aaa")],
918                },
919            ],
920        );
921        assert_eq!(t.commits, 1);
922        assert!(t.complete);
923    }
924
925    #[test]
926    fn a_subject_holding_the_separator_cannot_break_a_commit_line() {
927        // git never writes a NUL into a subject; the parser still keeps the whole tail
928        let c = parse_commit("a\u{0}b\u{0}c\u{0}d\u{0}sub\u{0}ject").unwrap();
929        assert_eq!(c.subject, "subject");
930    }
931}