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}