Skip to main content

majordomus_cli/commit/
plan.rs

1//! The plan: what is in the working tree, how it divides into commits, and the fingerprint
2//! that says the plan is still about this tree.
3//!
4//! # Why a plan is a value with a fingerprint on it
5//!
6//! A plan is derived from a working tree at a moment. In a repository worked by one person
7//! at a time that is a detail; in this one it is the correctness question. Several workers
8//! share a checkout, more share a repository, and a plan computed at one HEAD and executed
9//! at another commits files somebody else staged, under a message about work that is no
10//! longer what changed.
11//!
12//! [`PlanFingerprint`] is what makes that detectable rather than merely unlikely: the
13//! repository, the worktree, HEAD, and the exact set of paths and states the plan was
14//! derived from. A plan carries it, and anything that acts on a plan compares it with the
15//! tree in front of it first. The comparison is cheap, it is not a lock, and it does not
16//! prevent the race — it refuses to be the one that loses it silently.
17//!
18//! # What grouping is and is not
19//!
20//! Grouping is a *recommendation with its reasoning attached*, never an automatic split.
21//! The evidence is what the repository can see: which directories changed, which scope the
22//! history gives them, and which files are derived from which. Where that evidence supports
23//! one commit it says one; where it supports several it says several and says why; where it
24//! supports nothing it says so, which is the honest answer for a tree of unrelated edits
25//! that only a person can divide.
26
27//! ```
28//! use majordomus_cli::commit::{ChangeStage, PlanFingerprint, WorkingTreeState};
29//! use majordomus_cli::commit::plan::PathChange;
30//!
31//! let change = |path: &str, stage| PathChange {
32//!     path: path.into(), stage, status: "M.".into(), partial: false,
33//! };
34//! let tree = WorkingTreeState {
35//!     head: Some("a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".into()),
36//!     changes: vec![change("src/a.rs", ChangeStage::Staged)],
37//!     ..WorkingTreeState::default()
38//! };
39//!
40//! // The same tree fingerprints the same way every time.
41//! let made = PlanFingerprint::of("/repo/.git", "/repo", &tree);
42//! assert!(made.differs_from(&PlanFingerprint::of("/repo/.git", "/repo", &tree)).is_none());
43//!
44//! // Another worktree of the same repository is a different subject, and is named as one.
45//! let elsewhere = PlanFingerprint::of("/repo/.git", "/repo-wt/feature/x", &tree);
46//! let why = made.differs_from(&elsewhere).expect("another worktree");
47//! assert!(why.contains("another worktree"), "{why}");
48//! ```
49
50use std::collections::BTreeMap;
51use std::path::Path;
52use std::process::Command;
53
54use schemars::JsonSchema;
55use serde::{Deserialize, Serialize};
56use sha2::{Digest, Sha256};
57
58use super::scopes::{ScopeSuggestion, ScopeVocabulary};
59use super::{CommitHeader, CommitMessage};
60use crate::model::Diagnostic;
61use crate::release::ChangeKind;
62
63/// Where one path stands between the index and the working tree.
64#[derive(
65    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
66)]
67#[serde(rename_all = "snake_case")]
68/// ```
69/// use majordomus_cli::commit::ChangeStage;
70/// // ordered so that what would be in the next commit sorts first
71/// assert!(ChangeStage::Staged < ChangeStage::Unstaged);
72/// assert!(ChangeStage::Unstaged < ChangeStage::Untracked);
73/// ```
74pub enum ChangeStage {
75    /// Different in the index from HEAD: it would be in the next commit.
76    Staged,
77    /// Different in the working tree from the index: it would not.
78    Unstaged,
79    /// Not tracked at all.
80    Untracked,
81}
82
83/// One path, and where it stands.
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
85/// ```
86/// use majordomus_cli::commit::ChangeStage;
87/// use majordomus_cli::commit::plan::PathChange;
88/// // staged and modified again since: the commit would carry the staged half only
89/// let c = PathChange {
90///     path: "src/a.rs".into(), stage: ChangeStage::Staged, status: "MM".into(), partial: true,
91/// };
92/// assert!(c.partial);
93/// ```
94pub struct PathChange {
95    /// Repository-relative, forward slashes.
96    pub path: String,
97    /// Whether it is staged, unstaged or untracked. A path modified both in the index and
98    /// in the working tree appears once, as [`ChangeStage::Staged`], with `partial` set.
99    pub stage: ChangeStage,
100    /// The two-letter status git reported, verbatim.
101    pub status: String,
102    /// Set when the path is staged *and* modified again since: committing it commits the
103    /// staged half only, which is a thing a person should be told rather than discover.
104    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
105    pub partial: bool,
106}
107
108/// What git says about the working tree right now.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
110/// ```
111/// use majordomus_cli::commit::WorkingTreeState;
112/// let clean = WorkingTreeState::default();
113/// assert!(clean.is_clean());
114/// assert!(clean.staged().is_empty());
115/// ```
116pub struct WorkingTreeState {
117    /// The branch, or `None` when HEAD is detached.
118    pub branch: Option<String>,
119    /// HEAD's full name, or `None` in a repository with no commits.
120    pub head: Option<String>,
121    /// The upstream ref, when the branch has one.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub upstream: Option<String>,
124    /// Commits this branch has that its upstream does not.
125    pub ahead: usize,
126    /// Commits the upstream has that this branch does not.
127    pub behind: usize,
128    /// A merge, rebase, cherry-pick or bisect in progress, when there is one.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub in_progress: Option<String>,
131    /// Every change, ordered by stage then path.
132    pub changes: Vec<PathChange>,
133}
134
135impl WorkingTreeState {
136    /// The paths that would be in a commit made right now.
137    /// ```
138    /// use majordomus_cli::commit::{ChangeStage, WorkingTreeState};
139    /// use majordomus_cli::commit::plan::PathChange;
140    /// let c = |p: &str, s| PathChange { path: p.into(), stage: s, status: "M.".into(), partial: false };
141    /// let tree = WorkingTreeState {
142    ///     changes: vec![c("a.rs", ChangeStage::Staged), c("b.rs", ChangeStage::Unstaged)],
143    ///     ..WorkingTreeState::default()
144    /// };
145    /// assert_eq!(tree.staged(), vec!["a.rs".to_string()]);
146    /// assert!(!tree.is_clean());
147    /// ```
148    pub fn staged(&self) -> Vec<String> {
149        self.changes
150            .iter()
151            .filter(|c| c.stage == ChangeStage::Staged)
152            .map(|c| c.path.clone())
153            .collect()
154    }
155
156    /// Whether there is nothing to commit.
157    pub fn is_clean(&self) -> bool {
158        self.changes.is_empty()
159    }
160}
161
162/// What a plan was derived from.
163///
164/// Compared, never trusted: a plan whose fingerprint differs from the tree in front of it
165/// describes a tree that no longer exists.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
167/// ```
168/// use majordomus_cli::commit::{PlanFingerprint, WorkingTreeState};
169/// let f = PlanFingerprint::of("/repo/.git", "/repo", &WorkingTreeState::default());
170/// assert_eq!(f.head, "unborn", "a repository with no commits says so");
171/// ```
172pub struct PlanFingerprint {
173    /// The repository: git's common directory, which every worktree of one repository
174    /// shares and no two repositories do.
175    pub repository: String,
176    /// The worktree: this checkout's own path. Two worktrees of one repository have the
177    /// same repository and different worktrees, which is what keeps their plans apart.
178    pub worktree: String,
179    /// HEAD when the plan was derived, or `unborn`.
180    pub head: String,
181    /// SHA-256 over every change, its stage and its status, in order. Restaging one file
182    /// changes it; touching a file the plan does not contain does not.
183    pub changes: String,
184}
185
186impl PlanFingerprint {
187    /// The fingerprint of a tree as it stands.
188    /// ```
189    /// use majordomus_cli::commit::{PlanFingerprint, WorkingTreeState};
190    /// let a = PlanFingerprint::of("/r/.git", "/r", &WorkingTreeState::default());
191    /// let b = PlanFingerprint::of("/r/.git", "/r", &WorkingTreeState::default());
192    /// assert_eq!(a, b, "deterministic over one tree");
193    /// ```
194    pub fn of(repository: &str, worktree: &str, tree: &WorkingTreeState) -> PlanFingerprint {
195        let mut hasher = Sha256::new();
196        for c in &tree.changes {
197            hasher.update(c.path.as_bytes());
198            hasher.update([0]);
199            hasher.update(format!("{:?}", c.stage).as_bytes());
200            hasher.update([0]);
201            hasher.update(c.status.as_bytes());
202            hasher.update([0]);
203        }
204        PlanFingerprint {
205            repository: repository.to_string(),
206            worktree: worktree.to_string(),
207            head: tree.head.clone().unwrap_or_else(|| "unborn".into()),
208            changes: format!("{:x}", hasher.finalize()),
209        }
210    }
211
212    /// Why this fingerprint does not describe `now`, or `None` when it does.
213    ///
214    /// A sentence rather than a boolean, because "the plan is stale" is not an answer
215    /// anybody can act on and "HEAD moved from a1b2c3d to e4f5a6b" is.
216    /// ```
217    /// use majordomus_cli::commit::{PlanFingerprint, WorkingTreeState};
218    /// let mut then = WorkingTreeState::default();
219    /// then.head = Some("a1b2c3d4e5f6".into());
220    /// let mut now = then.clone();
221    /// now.head = Some("ffffffffffff".into());
222    /// let why = PlanFingerprint::of("/r/.git", "/r", &then)
223    ///     .differs_from(&PlanFingerprint::of("/r/.git", "/r", &now))
224    ///     .expect("HEAD moved");
225    /// // a sentence, because "the plan is stale" is not something anybody can act on
226    /// assert!(why.contains("HEAD moved"), "{why}");
227    /// ```
228    pub fn differs_from(&self, now: &PlanFingerprint) -> Option<String> {
229        if self.repository != now.repository {
230            return Some(format!(
231                "the plan is of another repository ({} rather than {})",
232                self.repository, now.repository
233            ));
234        }
235        if self.worktree != now.worktree {
236            return Some(format!(
237                "the plan is of another worktree ({} rather than {})",
238                self.worktree, now.worktree
239            ));
240        }
241        if self.head != now.head {
242            return Some(format!(
243                "HEAD moved from {} to {} since the plan was made",
244                short(&self.head),
245                short(&now.head)
246            ));
247        }
248        if self.changes != now.changes {
249            return Some(
250                "the working tree changed since the plan was made: what is staged, or what is modified, is not what it was".into(),
251            );
252        }
253        None
254    }
255}
256
257fn short(name: &str) -> &str {
258    &name[..9.min(name.len())]
259}
260
261/// One commit a plan proposes.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263/// One commit a plan proposes: its message, its paths, and why those paths are one commit.
264///
265/// ```
266/// use majordomus_cli::commit::{plan::group, CommitGroup, ScopeVocabulary};
267/// let mut v = ScopeVocabulary::default();
268/// v.learn("commit", "src/commit", 40);
269/// let g: Vec<CommitGroup> = group(&["src/commit/a.rs".into()], &v, &[]);
270/// // the subject is empty on purpose: what a change did is the one thing no evidence in
271/// // the tree can state
272/// assert_eq!(g[0].message.header.subject, "");
273/// assert_eq!(g[0].message.header.scope.as_deref(), Some("commit"));
274/// assert!(g[0].rationale.contains("40 prior commit"), "{}", g[0].rationale);
275/// ```
276pub struct CommitGroup {
277    /// The message as proposed. The summary is the one field a person is expected to
278    /// replace: nothing here can know *why* a change was made, and a planner that wrote a
279    /// confident sentence about it would be writing fiction into the history.
280    pub message: CommitMessage,
281    /// The paths, in order.
282    pub paths: Vec<String>,
283    /// Why these paths are one commit, in evidence a reader can check.
284    pub rationale: String,
285    /// The scope suggestion and what it was derived from, when there was one.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub scope_from: Option<ScopeSuggestion>,
288}
289
290/// A plan over a working tree.
291#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
292/// ```
293/// use majordomus_cli::commit::{CommitPlan, PlanFingerprint, WorkingTreeState};
294/// let plan = CommitPlan {
295///     fingerprint: PlanFingerprint::of("/r/.git", "/r", &WorkingTreeState::default()),
296///     tree: WorkingTreeState::default(),
297///     groups: Vec::new(),
298///     diagnostics: Vec::new(),
299/// };
300/// assert!(plan.groups.is_empty(), "a clean tree proposes nothing");
301/// ```
302pub struct CommitPlan {
303    /// What the plan was derived from, and what makes it stale.
304    pub fingerprint: PlanFingerprint,
305    /// The tree as it stood.
306    pub tree: WorkingTreeState,
307    /// The commits proposed, in the order they should be made.
308    pub groups: Vec<CommitGroup>,
309    /// What a person should know before acting on it.
310    pub diagnostics: Vec<Diagnostic>,
311}
312
313/// Read `git status --porcelain=v2 --branch`, which reports the branch, the upstream and
314/// the divergence in the same call as the paths.
315///
316/// Returns `None` when git could not be asked or the directory is not a work tree; the
317/// caller reports that, because "there is no working tree" is a different answer from "the
318/// working tree is clean" and a plan that confused the two would propose committing nothing
319/// in a directory that is not a repository.
320/// ```
321/// use majordomus_cli::commit::plan::working_tree;
322/// // "there is no working tree" and "the working tree is clean" are different answers
323/// let plain = tempfile::tempdir().expect("a temporary directory");
324/// assert!(working_tree(plain.path()).is_none());
325/// ```
326pub fn working_tree(root: &Path) -> Option<WorkingTreeState> {
327    let out = Command::new("git")
328        .arg("-C")
329        .arg(root)
330        .args([
331            "status",
332            "--porcelain=v2",
333            "--branch",
334            "--untracked-files=all",
335            "--no-renames",
336        ])
337        .output()
338        .ok()?;
339    if !out.status.success() {
340        return None;
341    }
342    let mut tree = WorkingTreeState::default();
343    let mut both: Vec<String> = Vec::new();
344    for line in String::from_utf8_lossy(&out.stdout).lines() {
345        if let Some(rest) = line.strip_prefix("# branch.") {
346            let (key, value) = rest.split_once(' ').unwrap_or((rest, ""));
347            match key {
348                "oid" if value != "(initial)" => tree.head = Some(value.to_string()),
349                "head" if value != "(detached)" => tree.branch = Some(value.to_string()),
350                "upstream" => tree.upstream = Some(value.to_string()),
351                "ab" => {
352                    let mut parts = value.split_whitespace();
353                    tree.ahead = parts
354                        .next()
355                        .and_then(|s| s.trim_start_matches('+').parse().ok())
356                        .unwrap_or(0);
357                    tree.behind = parts
358                        .next()
359                        .and_then(|s| s.trim_start_matches('-').parse().ok())
360                        .unwrap_or(0);
361                }
362                _ => {}
363            }
364            continue;
365        }
366        // `1 XY sub mH mI mW hH hI path` for an ordinary change, `? path` for untracked,
367        // `u ...` for unmerged. The status letters are the two after the record kind.
368        let mut fields = line.splitn(2, ' ');
369        let kind = fields.next().unwrap_or_default();
370        let rest = fields.next().unwrap_or_default();
371        match kind {
372            "?" => tree.changes.push(PathChange {
373                path: rest.to_string(),
374                stage: ChangeStage::Untracked,
375                status: "??".into(),
376                partial: false,
377            }),
378            "1" | "2" | "u" => {
379                let mut parts = rest.split(' ');
380                let xy = parts.next().unwrap_or("  ");
381                let path = rest.split(' ').next_back().unwrap_or_default().to_string();
382                if path.is_empty() {
383                    continue;
384                }
385                let index = xy.chars().next().unwrap_or('.');
386                let worktree = xy.chars().nth(1).unwrap_or('.');
387                let staged = index != '.';
388                if staged && worktree != '.' {
389                    both.push(path.clone());
390                }
391                tree.changes.push(PathChange {
392                    path,
393                    stage: if staged {
394                        ChangeStage::Staged
395                    } else {
396                        ChangeStage::Unstaged
397                    },
398                    status: xy.to_string(),
399                    partial: staged && worktree != '.',
400                });
401            }
402            _ => {}
403        }
404    }
405    tree.in_progress = in_progress(root);
406    crate::order::canonical(&mut tree.changes);
407    tree.changes
408        .dedup_by(|a, b| a.path == b.path && a.stage == b.stage);
409    Some(tree)
410}
411
412/// A merge, rebase, cherry-pick, revert or bisect in progress, by the file git leaves
413/// behind for it. Asked of the *git directory*, which in a linked worktree is not `.git/`
414/// in the checkout but a directory under the repository's common one — `git rev-parse` is
415/// what knows which, so nothing here assumes.
416fn in_progress(root: &Path) -> Option<String> {
417    let out = Command::new("git")
418        .arg("-C")
419        .arg(root)
420        .args(["rev-parse", "--absolute-git-dir"])
421        .output()
422        .ok()?;
423    if !out.status.success() {
424        return None;
425    }
426    let dir = Path::new(String::from_utf8_lossy(&out.stdout).trim()).to_path_buf();
427    for (file, what) in [
428        ("MERGE_HEAD", "merge"),
429        ("rebase-merge", "rebase"),
430        ("rebase-apply", "rebase"),
431        ("CHERRY_PICK_HEAD", "cherry-pick"),
432        ("REVERT_HEAD", "revert"),
433        ("BISECT_LOG", "bisect"),
434    ] {
435        if dir.join(file).exists() {
436            return Some(what.to_string());
437        }
438    }
439    None
440}
441
442/// The kind a set of paths suggests, from what the paths *are*.
443///
444/// Only the cases where the paths decide it on their own: a change that is entirely tests
445/// is a test change, one that is entirely documentation is a documentation change, one that
446/// is entirely pipeline is a pipeline change. Everything else is left unset, because the
447/// difference between a feature, a fix and a refactor is in the diff's meaning and not in
448/// its file names, and a planner that guessed would be putting the wrong word in front of
449/// every commit in the history.
450/// ```
451/// use majordomus_cli::commit::plan::kind_of;
452/// use majordomus_cli::release::ChangeKind;
453/// assert_eq!(kind_of(&["docs/COMMIT.md".into()]), Some(ChangeKind::Docs));
454/// // a source change could be a feature, a fix or a refactor; the file names do not say
455/// assert_eq!(kind_of(&["src/a.rs".into()]), None);
456/// ```
457pub fn kind_of(paths: &[String]) -> Option<ChangeKind> {
458    if paths.is_empty() {
459        return None;
460    }
461    let all = |f: fn(&str) -> bool| paths.iter().all(|p| f(p));
462    if all(|p| p.starts_with("docs/") || p.ends_with(".md")) {
463        return Some(ChangeKind::Docs);
464    }
465    if all(|p| p.starts_with("test/") || p.starts_with("tests/") || p.contains("/tests/")) {
466        return Some(ChangeKind::Test);
467    }
468    if all(|p| p.starts_with(".github/") || p.starts_with("scripts/ci/")) {
469        return Some(ChangeKind::Ci);
470    }
471    None
472}
473
474/// Which of `paths` this repository declares derived, asked of git rather than parsed.
475///
476/// `.gitattributes` marks every generated file `merge=derived`, and `git check-attr` is the
477/// reader that already understands its precedence, its globs and its negations. A second
478/// parser here would be a second opinion about which files are generated, which is the class
479/// of defect this repository spends its time removing — and one that has already cost it
480/// once, when a hand-written glob claimed forty-five files of which the generator writes
481/// nine.
482/// ```
483/// use majordomus_cli::commit::plan::derived_among;
484/// let plain = tempfile::tempdir().expect("a temporary directory");
485/// // nothing to ask about is nothing derived, and git is never run
486/// assert!(derived_among(plain.path(), &[]).is_empty());
487/// ```
488pub fn derived_among(root: &Path, paths: &[String]) -> Vec<String> {
489    if paths.is_empty() {
490        return Vec::new();
491    }
492    let out = Command::new("git")
493        .arg("-C")
494        .arg(root)
495        .args(["check-attr", "merge", "--"])
496        .args(paths)
497        .output();
498    let Ok(out) = out else { return Vec::new() };
499    if !out.status.success() {
500        return Vec::new();
501    }
502    // `<path>: merge: derived`, one line each. A path may contain a colon, so the match is
503    // against the fixed suffix rather than a split from the left.
504    String::from_utf8_lossy(&out.stdout)
505        .lines()
506        .filter_map(|l| l.strip_suffix(": merge: derived"))
507        .map(str::to_string)
508        .collect()
509}
510
511/// Group a set of paths into the commits the evidence supports.
512///
513/// The evidence is the scope vocabulary: paths the history scopes the same way are one
514/// commit. Paths the vocabulary knows nothing about are one further group rather than one
515/// group each, because the alternative — a commit per unrecognised file — is never what
516/// somebody wanted and is the failure that makes a planner untrustworthy on its first run
517/// in a new repository.
518///
519/// `derived` is the subset of `paths` this repository generates. Its presence collapses the
520/// plan to one commit, and the reason is `project.derived-files-regenerated`: a generated
521/// file is a projection of the whole tree, so only the last of several commits could carry a
522/// current one and every earlier commit would be stale by construction — which the
523/// `derived-current` hook then refuses, one commit at a time, after the split has already
524/// been made. The planner found this on its first run against its own change set, where it
525/// proposed putting twenty-three generated files in a commit of their own.
526/// ```
527/// use majordomus_cli::commit::{plan::group, ScopeVocabulary};
528/// let mut v = ScopeVocabulary::default();
529/// v.learn("alpha", "src/alpha", 40);
530/// v.learn("derive", "docs/generated", 180);
531/// let paths: Vec<String> = vec!["src/alpha/a.rs".into(), "docs/generated/x.json".into()];
532/// // without the derived-file rule the scopes alone would make two commits
533/// assert_eq!(group(&paths, &v, &[]).len(), 2);
534/// // with one of them generated, it is one, and the rationale names the rule that decided
535/// let one = group(&paths, &v, &["docs/generated/x.json".into()]);
536/// assert_eq!(one.len(), 1);
537/// assert!(one[0].rationale.contains("derived-files-regenerated"), "{}", one[0].rationale);
538/// ```
539pub fn group(
540    paths: &[String],
541    vocabulary: &ScopeVocabulary,
542    derived: &[String],
543) -> Vec<CommitGroup> {
544    let natural = group_by_scope(paths, vocabulary);
545    if !derived.is_empty() && natural.len() > 1 {
546        // The scope of the one commit is the sources' scope, not the generated tree's: what
547        // the change is about is what was written, and the projection follows it.
548        let sources: Vec<String> = paths
549            .iter()
550            .filter(|p| !derived.contains(p))
551            .cloned()
552            .collect();
553        let scope_from = vocabulary.suggest(&sources).filter(|s| !s.ambiguous);
554        let kind = kind_of(&sources);
555        let mut all = paths.to_vec();
556        crate::order::canonical_strings(&mut all);
557        all.dedup();
558        let would_have = natural.len();
559        return vec![CommitGroup {
560            message: CommitMessage {
561                header: CommitHeader {
562                    word: kind.and_then(|k| k.word()).unwrap_or_default().to_string(),
563                    kind: kind.unwrap_or(ChangeKind::Other),
564                    scope: scope_from.as_ref().map(|s| s.scope.clone()),
565                    breaking: false,
566                    subject: String::new(),
567                },
568                body: String::new(),
569                trailers: Vec::new(),
570            },
571            paths: all,
572            rationale: format!(
573                "one commit, not {would_have}: {} of these {} file(s) are generated, and a generated file is a projection of the whole tree — split, every commit but the last would carry derived data older than its own sources (project.derived-files-regenerated)",
574                derived.len(),
575                paths.len()
576            ),
577            scope_from,
578        }];
579    }
580    natural
581}
582
583/// The grouping the scope vocabulary alone supports, before the derived-file rule applies.
584fn group_by_scope(paths: &[String], vocabulary: &ScopeVocabulary) -> Vec<CommitGroup> {
585    if paths.is_empty() {
586        return Vec::new();
587    }
588    let mut by_scope: BTreeMap<Option<String>, Vec<String>> = BTreeMap::new();
589    // Why a path got no scope, so that the group can say which: a repository that has never
590    // committed anything about this directory, and a directory a dozen scopes have equal
591    // claim to, are different situations and lead a reader to different next steps.
592    let mut ambiguous = 0usize;
593    let mut unknown = 0usize;
594    for path in paths {
595        let suggestion = vocabulary.suggest(std::slice::from_ref(path));
596        match &suggestion {
597            Some(s) if s.ambiguous => ambiguous += 1,
598            None => unknown += 1,
599            _ => {}
600        }
601        let scope = suggestion.filter(|s| !s.ambiguous).map(|s| s.scope);
602        by_scope.entry(scope).or_default().push(path.clone());
603    }
604    // Paths within a group are ordered by name, so that a plan is a function of the tree
605    // and not of the order git happened to report it in.
606    for group in by_scope.values_mut() {
607        crate::order::canonical_strings(group);
608        group.dedup();
609    }
610    // Deterministic: named scopes first, in vocabulary order, then the unrecognised group.
611    let order = vocabulary.words();
612    let mut keys: Vec<ScopeKey> = by_scope
613        .keys()
614        .map(|k| ScopeKey {
615            position: k
616                .as_ref()
617                .and_then(|s| order.iter().position(|w| w == s))
618                .map_or(i64::MAX, |p| p as i64),
619            scope: k.clone(),
620        })
621        .collect();
622    crate::order::canonical(&mut keys);
623    let keys: Vec<Option<String>> = keys.into_iter().map(|k| k.scope).collect();
624    keys.into_iter()
625        .map(|scope| {
626            let paths = by_scope.remove(&scope).unwrap_or_default();
627            let scope_from = scope
628                .as_ref()
629                .and_then(|_| vocabulary.suggest(&paths));
630            let kind = kind_of(&paths);
631            let rationale = match (&scope, &scope_from) {
632                (Some(s), Some(from)) => format!(
633                    "{} file(s) under {}, which {} prior commit(s) scoped `{s}`",
634                    paths.len(),
635                    from.directory,
636                    from.commits
637                ),
638                _ if ambiguous > 0 && unknown > 0 => format!(
639                    "{} file(s) with no scope of their own: {unknown} in directories the history has never scoped, {ambiguous} in directories several scopes have equal claim to. One group rather than one commit each",
640                    paths.len()
641                ),
642                _ if ambiguous > 0 => format!(
643                    "{} file(s) in directories several scopes have equal claim to; the history does not decide between them, so they are one group and the scope is yours to name",
644                    paths.len()
645                ),
646                _ => format!(
647                    "{} file(s) in directories this history has never scoped; they are one group rather than one commit each",
648                    paths.len()
649                ),
650            };
651            CommitGroup {
652                message: CommitMessage {
653                    header: CommitHeader {
654                        word: kind.and_then(|k| k.word()).unwrap_or_default().to_string(),
655                        kind: kind.unwrap_or(ChangeKind::Other),
656                        scope: scope.clone(),
657                        breaking: false,
658                        // Deliberately empty: what the change *did* is the one thing no
659                        // evidence in the tree can state, and the plan says so rather than
660                        // inventing a sentence.
661                        subject: String::new(),
662                    },
663                    body: String::new(),
664                    trailers: Vec::new(),
665                },
666                paths,
667                rationale,
668                scope_from,
669            }
670        })
671        .collect()
672}
673
674/// A plan's changes in canonical order: by path, then by the status git reported, so the
675/// halves of one path stay adjacent and `dedup_by` sees them together.
676impl crate::order::Ordered for PathChange {
677    fn order_key(&self) -> crate::order::OrderKey<'_> {
678        crate::order::OrderKey::plain(&self.path, &self.status)
679    }
680}
681
682/// A group's scope with its place in the vocabulary: named scopes keep the vocabulary's
683/// order, and the unrecognised group comes last.
684struct ScopeKey {
685    scope: Option<String>,
686    position: i64,
687}
688
689impl crate::order::Ordered for ScopeKey {
690    fn order_key(&self) -> crate::order::OrderKey<'_> {
691        match &self.scope {
692            Some(s) => crate::order::OrderKey::grouped("named", s, s).ranked(self.position),
693            None => crate::order::OrderKey::plain("", ""),
694        }
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use crate::commit::scopes::ScopeUse;
702
703    fn tree(changes: &[(&str, ChangeStage, &str)]) -> WorkingTreeState {
704        WorkingTreeState {
705            head: Some("a1b2c3d4e5f60718293a4b5c6d7e8f9012345678".into()),
706            changes: changes
707                .iter()
708                .map(|(p, s, st)| PathChange {
709                    path: p.to_string(),
710                    stage: *s,
711                    status: st.to_string(),
712                    partial: false,
713                })
714                .collect(),
715            ..WorkingTreeState::default()
716        }
717    }
718
719    #[test]
720    fn a_fingerprint_is_stable_for_one_tree_and_moves_with_it() {
721        let a = tree(&[("src/a.rs", ChangeStage::Staged, "M.")]);
722        let f = PlanFingerprint::of("/repo/.git", "/wt", &a);
723        assert_eq!(
724            f,
725            PlanFingerprint::of("/repo/.git", "/wt", &a),
726            "deterministic"
727        );
728        assert!(f
729            .differs_from(&PlanFingerprint::of("/repo/.git", "/wt", &a))
730            .is_none());
731    }
732
733    #[test]
734    fn a_moved_head_is_named_rather_than_merely_reported() {
735        let a = tree(&[("src/a.rs", ChangeStage::Staged, "M.")]);
736        let mut b = a.clone();
737        b.head = Some("ffffffffffffffffffffffffffffffffffffffff".into());
738        let why = PlanFingerprint::of("/repo/.git", "/wt", &a)
739            .differs_from(&PlanFingerprint::of("/repo/.git", "/wt", &b))
740            .expect("it moved");
741        assert!(why.contains("HEAD moved"), "{why}");
742        assert!(why.contains("a1b2c3d4e"), "the sha it was at: {why}");
743    }
744
745    #[test]
746    fn restaging_makes_a_plan_stale() {
747        let before = tree(&[("src/a.rs", ChangeStage::Unstaged, ".M")]);
748        let after = tree(&[("src/a.rs", ChangeStage::Staged, "M.")]);
749        let why = PlanFingerprint::of("/r", "/w", &before)
750            .differs_from(&PlanFingerprint::of("/r", "/w", &after))
751            .expect("what is staged changed");
752        assert!(why.contains("working tree changed"), "{why}");
753    }
754
755    #[test]
756    fn two_worktrees_of_one_repository_do_not_share_a_plan() {
757        // The failure this prevents: a plan derived in one checkout executing in another,
758        // where the same branch name and the same HEAD would otherwise look identical.
759        let t = tree(&[("src/a.rs", ChangeStage::Staged, "M.")]);
760        let a = PlanFingerprint::of("/repo/.git", "/repo", &t);
761        let b = PlanFingerprint::of("/repo/.git", "/repo-wt/feature/x", &t);
762        assert_eq!(a.repository, b.repository, "one repository");
763        let why = a.differs_from(&b).expect("another worktree");
764        assert!(why.contains("another worktree"), "{why}");
765    }
766
767    #[test]
768    fn a_plan_of_another_repository_is_refused_before_anything_else() {
769        let t = tree(&[]);
770        let why = PlanFingerprint::of("/a/.git", "/a", &t)
771            .differs_from(&PlanFingerprint::of("/b/.git", "/b", &t))
772            .expect("another repository");
773        assert!(why.contains("another repository"), "{why}");
774    }
775
776    fn vocabulary(rows: &[(&str, &str, usize)]) -> ScopeVocabulary {
777        let mut v = ScopeVocabulary::default();
778        for (scope, prefix, n) in rows {
779            v.learn(scope, prefix, *n);
780            if !v.knows(scope) {
781                v.scopes.push(ScopeUse {
782                    scope: scope.to_string(),
783                    commits: *n,
784                    directories: vec![prefix.to_string()],
785                });
786            }
787        }
788        v
789    }
790
791    #[test]
792    fn paths_the_history_scopes_alike_are_one_commit() {
793        let v = vocabulary(&[("commit", "apps/cli/src", 40), ("site", "site/content", 30)]);
794        let groups = group_by_scope(
795            &[
796                "apps/cli/src/commit/plan.rs".into(),
797                "apps/cli/src/commit/verdict.rs".into(),
798                "site/content/a.md".into(),
799            ],
800            &v,
801        );
802        assert_eq!(groups.len(), 2);
803        assert_eq!(groups[0].message.header.scope.as_deref(), Some("commit"));
804        assert_eq!(groups[0].paths.len(), 2);
805        assert_eq!(groups[1].message.header.scope.as_deref(), Some("site"));
806        // and the reason is evidence a reader can check, not an assertion
807        assert!(
808            groups[0].rationale.contains("40 prior commit"),
809            "{}",
810            groups[0].rationale
811        );
812    }
813
814    #[test]
815    fn unrecognised_paths_are_one_group_and_not_one_commit_each() {
816        let v = vocabulary(&[("commit", "apps/cli/src", 40)]);
817        let groups = group(&["brand/new/a.rs".into(), "brand/new/b.rs".into()], &v, &[]);
818        assert_eq!(groups.len(), 1);
819        assert_eq!(groups[0].paths.len(), 2);
820        assert!(groups[0].message.header.scope.is_none());
821        assert!(
822            groups[0].rationale.contains("never scoped"),
823            "{}",
824            groups[0].rationale
825        );
826    }
827
828    #[test]
829    fn a_group_proposes_no_subject_because_nothing_in_the_tree_knows_why() {
830        let v = vocabulary(&[("commit", "apps/cli/src", 40)]);
831        let groups = group_by_scope(&["apps/cli/src/commit/plan.rs".into()], &v);
832        assert_eq!(groups[0].message.header.subject, "");
833    }
834
835    #[test]
836    fn a_kind_is_taken_from_the_paths_only_when_the_paths_decide_it() {
837        assert_eq!(kind_of(&["docs/COMMIT.md".into()]), Some(ChangeKind::Docs));
838        assert_eq!(kind_of(&["test/cases/1.sh".into()]), Some(ChangeKind::Test));
839        assert_eq!(
840            kind_of(&[".github/workflows/ci.yml".into()]),
841            Some(ChangeKind::Ci)
842        );
843        // a source change could be a feature, a fix or a refactor; the file names do not say
844        assert_eq!(kind_of(&["src/a.rs".into()]), None);
845        // and a mixed set decides nothing
846        assert_eq!(kind_of(&["docs/a.md".into(), "src/a.rs".into()]), None);
847        assert_eq!(kind_of(&[]), None);
848    }
849
850    #[test]
851    fn grouping_is_deterministic_whatever_order_the_paths_arrive_in() {
852        let v = vocabulary(&[("commit", "apps/cli/src", 40), ("site", "site/content", 30)]);
853        let forward: Vec<String> = vec![
854            "apps/cli/src/a.rs".into(),
855            "site/content/b.md".into(),
856            "apps/cli/src/c.rs".into(),
857        ];
858        let mut reversed = forward.clone();
859        reversed.reverse();
860        let a = group(&forward, &v, &[]);
861        let b = group(&reversed, &v, &[]);
862        assert_eq!(
863            a.iter().map(|g| g.paths.clone()).collect::<Vec<_>>(),
864            b.iter().map(|g| g.paths.clone()).collect::<Vec<_>>()
865        );
866    }
867
868    #[test]
869    fn a_directory_that_is_not_a_work_tree_is_not_a_clean_tree() {
870        // The distinction that matters: "nothing to commit" and "this is not a repository"
871        // are different answers, and a planner that returned the first for the second would
872        // propose committing nothing rather than say where it is.
873        let plain = tempfile::tempdir().expect("a temporary directory");
874        assert!(working_tree(plain.path()).is_none());
875    }
876}
877
878/// Every commit in `range`, as a subject and a body, newest first.
879///
880/// The same separators the changelog reads the log with, for the same reason: a commit body
881/// may contain any arrangement of newlines, and a field separator git will not produce is
882/// the only way to tell where one ends.
883/// ```
884/// use majordomus_cli::commit::plan::messages_in;
885/// // a range git cannot resolve is None, not an empty history
886/// let plain = tempfile::tempdir().expect("a temporary directory");
887/// assert!(messages_in(plain.path(), "HEAD").is_none());
888/// ```
889pub fn messages_in(root: &Path, range: &str) -> Option<Vec<(String, String)>> {
890    const FIELD: &str = "\u{1}";
891    const RECORD: &str = "\u{2}";
892    let out = Command::new("git")
893        .arg("-C")
894        .arg(root)
895        .args(["log", &format!("--format=%H{FIELD}%B{RECORD}"), range])
896        .output()
897        .ok()?;
898    if !out.status.success() {
899        return None;
900    }
901    Some(
902        String::from_utf8_lossy(&out.stdout)
903            .split(RECORD)
904            .filter_map(|record| {
905                let mut parts = record.trim_start_matches('\n').splitn(2, FIELD);
906                let commit = parts.next()?.trim();
907                if commit.is_empty() {
908                    return None;
909                }
910                Some((
911                    commit.to_string(),
912                    parts.next().unwrap_or_default().trim_end().to_string(),
913                ))
914            })
915            .collect(),
916    )
917}
918
919#[cfg(test)]
920mod derived_tests {
921    use super::*;
922    use crate::commit::scopes::ScopeUse;
923
924    fn vocabulary(rows: &[(&str, &str, usize)]) -> ScopeVocabulary {
925        let mut v = ScopeVocabulary::default();
926        for (scope, prefix, n) in rows {
927            v.learn(scope, prefix, *n);
928            if !v.knows(scope) {
929                v.scopes.push(ScopeUse {
930                    scope: scope.to_string(),
931                    commits: *n,
932                    directories: vec![prefix.to_string()],
933                });
934            }
935        }
936        v
937    }
938
939    /// The defect this repository's own change set found on the planner's first real run: it
940    /// proposed a commit of twenty-three generated files, which `project.derived-files-
941    /// regenerated` forbids and the `derived-current` hook would have refused one commit at
942    /// a time, after the split.
943    #[test]
944    fn a_change_set_carrying_generated_files_is_one_commit_and_says_why() {
945        let v = vocabulary(&[
946            ("alpha", "apps/cli/src", 40),
947            ("derive", "docs/generated", 180),
948        ]);
949        let paths: Vec<String> = vec![
950            "apps/cli/src/alpha/a.rs".into(),
951            "docs/generated/registry.json".into(),
952            "docs/generated/cli.md".into(),
953        ];
954        let derived: Vec<String> = vec![
955            "docs/generated/registry.json".into(),
956            "docs/generated/cli.md".into(),
957        ];
958        // Without the rule the scopes alone would make two commits.
959        assert_eq!(group_by_scope(&paths, &v).len(), 2);
960        let groups = group(&paths, &v, &derived);
961        assert_eq!(
962            groups.len(),
963            1,
964            "generated files may not be split from their source"
965        );
966        assert_eq!(groups[0].paths.len(), 3);
967        assert!(
968            groups[0].rationale.contains("one commit, not 2"),
969            "{}",
970            groups[0].rationale
971        );
972        assert!(
973            groups[0].rationale.contains("derived-files-regenerated"),
974            "the rationale names the rule that decided: {}",
975            groups[0].rationale
976        );
977        // and the scope is the source's, not the generated tree's
978        assert_eq!(groups[0].message.header.scope.as_deref(), Some("alpha"));
979    }
980
981    #[test]
982    fn a_change_set_that_would_be_one_commit_anyway_is_not_relabelled() {
983        let v = vocabulary(&[("alpha", "apps/cli/src", 40)]);
984        let paths: Vec<String> = vec![
985            "apps/cli/src/alpha/a.rs".into(),
986            "apps/cli/src/alpha/b.rs".into(),
987        ];
988        let groups = group(&paths, &v, &["apps/cli/src/alpha/b.rs".into()]);
989        assert_eq!(groups.len(), 1);
990        assert!(
991            !groups[0].rationale.contains("one commit, not"),
992            "nothing was collapsed, so nothing should claim it was: {}",
993            groups[0].rationale
994        );
995    }
996
997    #[test]
998    fn a_change_set_with_no_generated_file_splits_as_the_history_says() {
999        let v = vocabulary(&[("alpha", "apps/cli/src", 40), ("site", "site/content", 30)]);
1000        let paths: Vec<String> = vec!["apps/cli/src/alpha/a.rs".into(), "site/content/b.md".into()];
1001        assert_eq!(group(&paths, &v, &[]).len(), 2);
1002    }
1003}