Skip to main content

majordomus_cli/commit/
scopes.rs

1//! The scope vocabulary, derived from the repository rather than declared beside it.
2//!
3//! # The table this replaces
4//!
5//! The usual way to answer *"what scope does this change belong to?"* is a table: a list of
6//! path patterns and the scope each one maps to, written once, kept in a document a worker
7//! is told to read. It works on the day it is written. Then a directory is added, or
8//! renamed, or a subsystem is split in two, and the table says a scope that the history
9//! stopped using months ago — with nothing to notice, because a table has no way to be
10//! wrong.
11//!
12//! The repository already knows the answer. Every commit in the history is a worked example
13//! of which scope a set of paths belongs to, decided by whoever made the change and kept by
14//! whoever reviewed it. [`fn@derive`] reads those examples: the scopes the history uses, how
15//! often, and which directories each one is written about. A scope is not configured. It is
16//! observed, with the observation attached, so that the answer can be checked rather than
17//! believed.
18//!
19//! The consequence a person feels is that the vocabulary is never stale and never has to be
20//! maintained: a subsystem that gets its first commit today is in the vocabulary tomorrow,
21//! and one that nobody has touched for a year falls to the bottom of the list on its own.
22//!
23//! # What it costs and where it is allowed to
24//!
25//! Reading names out of the log is not free, and this is deliberately not on any hot path:
26//! nothing about entering the repository, completing a word or answering `--help` reads it.
27//! It is read when a commit is being planned or judged, which is a moment that already
28//! involves a person waiting for git.
29
30//! ```
31//! use majordomus_cli::commit::ScopeVocabulary;
32//!
33//! // A vocabulary is normally learned with `derive`; stated directly it reads the same.
34//! let mut v = ScopeVocabulary::default();
35//! v.learn("commit", "apps/majordomus-cli/src/commit", 40);
36//! v.learn("site", "site/content", 90);
37//!
38//! let s = v.suggest(&["apps/majordomus-cli/src/commit/plan.rs".into()]).expect("an association");
39//! assert_eq!(s.scope, "commit");
40//! assert_eq!(s.commits, 40, "the association, not the vote weight");
41//! assert!(!s.ambiguous);
42//!
43//! // and a path nothing in the history scopes is answered with nothing, never with the
44//! // repository's most popular scope
45//! assert!(v.suggest(&["brand/new/thing.rs".into()]).is_none());
46//! ```
47
48use std::collections::BTreeMap;
49use std::path::Path;
50use std::process::Command;
51
52use schemars::JsonSchema;
53use serde::{Deserialize, Serialize};
54
55use super::CommitHeader;
56
57/// How many commits back the vocabulary is learned from.
58///
59/// Bounded because the cost is linear in it and the value is not: a scope used ten times
60/// last month is in the vocabulary either way, and one last used two thousand commits ago
61/// is history rather than vocabulary.
62pub const SAMPLE: usize = 1500;
63
64/// How many leading path segments an association is recorded at.
65///
66/// Four, measured rather than chosen: this repository's source lives at
67/// `apps/majordomus-cli/src/<subsystem>/`, and three segments stop one level above the
68/// subsystem — where a dozen scopes all have commits and no path can be told from another.
69/// Four reaches the directory that actually determines the scope, and a path with fewer
70/// directories than that simply associates at every depth it has.
71const PREFIX_DEPTH: usize = 4;
72
73/// One scope the history uses, and how much.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75/// ```
76/// use majordomus_cli::commit::scopes::ScopeUse;
77/// let u = ScopeUse { scope: "commit".into(), commits: 40, directories: vec!["src/commit".into()] };
78/// assert_eq!(u.commits, 40);
79/// ```
80pub struct ScopeUse {
81    /// The scope word, as commits spell it.
82    pub scope: String,
83    /// How many of the sampled commits used it.
84    pub commits: usize,
85    /// The directory prefixes those commits touched, most often first, at most five.
86    pub directories: Vec<String>,
87}
88
89/// The vocabulary as observed, with what was observed.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
91/// ```
92/// use majordomus_cli::commit::ScopeVocabulary;
93/// // an empty vocabulary knows nothing and says so, rather than guessing
94/// let v = ScopeVocabulary::default();
95/// assert!(!v.knows("anything"));
96/// assert!(v.words().is_empty());
97/// ```
98pub struct ScopeVocabulary {
99    /// Every scope, by how often it is used, then by name. The order is the answer to
100    /// "which scopes does this repository use", so it is the order a person reads.
101    pub scopes: Vec<ScopeUse>,
102    /// How many commits were read to learn it.
103    pub sampled: usize,
104    /// Set when git could not be asked; the vocabulary is then empty rather than wrong.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub unavailable: Option<String>,
107    /// scope × directory prefix, counted. Not rendered for a person; this is what
108    /// [`ScopeVocabulary::suggest`] reads.
109    #[serde(skip)]
110    association: BTreeMap<(String, String), usize>,
111}
112
113/// Which scope a set of paths belongs to, and the evidence for it.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115/// ```
116/// use majordomus_cli::commit::{ScopeSuggestion, ScopeVocabulary};
117/// let mut v = ScopeVocabulary::default();
118/// // two scopes with equal claim to one directory: reported, never broken silently
119/// v.learn("a", "src", 7);
120/// v.learn("b", "src", 7);
121/// let s: ScopeSuggestion = v.suggest(&["src/x.rs".into()]).expect("a tie");
122/// assert!(s.ambiguous);
123/// ```
124pub struct ScopeSuggestion {
125    /// The scope.
126    pub scope: String,
127    /// How many prior commits associate this scope with directories these paths are in.
128    pub commits: usize,
129    /// The directory prefix the association was strongest at.
130    pub directory: String,
131    /// Whether another scope was as strongly associated. A tie is reported rather than
132    /// broken silently: a planner that guessed would be inventing the one field of a commit
133    /// message that a reader uses to navigate.
134    pub ambiguous: bool,
135}
136
137/// The directory prefixes a path is in, deepest first, at most [`PREFIX_DEPTH`] segments.
138fn prefixes(path: &str) -> Vec<String> {
139    let segments: Vec<&str> = path.split('/').collect();
140    // The file name itself is never a prefix; a directory is.
141    let dirs = segments.len().saturating_sub(1);
142    let mut out = Vec::new();
143    for depth in (1..=dirs.min(PREFIX_DEPTH)).rev() {
144        out.push(segments[..depth].join("/"));
145    }
146    out
147}
148
149/// Learn the vocabulary from the repository's own history.
150///
151/// Never fails: a directory that is not a work tree, a repository with no commits and a
152/// missing `git` all yield an empty vocabulary that says why.
153/// ```
154/// use majordomus_cli::commit::scopes::derive;
155/// // a directory that is not a work tree yields an empty vocabulary that says why,
156/// // never an empty one that looks like a repository with no conventions
157/// let plain = tempfile::tempdir().expect("a temporary directory");
158/// let v = derive(plain.path());
159/// assert!(v.scopes.is_empty());
160/// assert!(v.unavailable.is_some());
161/// ```
162pub fn derive(root: &Path) -> ScopeVocabulary {
163    // One `git log`, formatted so that a subject and the names it touched arrive together.
164    // `%x00` separates the two, and a record separator git will not produce delimits the
165    // commits, because a file name may contain anything a byte can be.
166    let out = Command::new("git")
167        .arg("-C")
168        .arg(root)
169        .args([
170            "log",
171            "--no-merges",
172            "-n",
173            &SAMPLE.to_string(),
174            "--name-only",
175            "--format=%x02%s",
176        ])
177        .output();
178    let out = match out {
179        Ok(o) if o.status.success() => o,
180        Ok(o) => {
181            return ScopeVocabulary {
182                unavailable: Some(String::from_utf8_lossy(&o.stderr).trim().to_string()),
183                ..ScopeVocabulary::default()
184            }
185        }
186        Err(e) => {
187            return ScopeVocabulary {
188                unavailable: Some(e.to_string()),
189                ..ScopeVocabulary::default()
190            }
191        }
192    };
193    let text = String::from_utf8_lossy(&out.stdout);
194    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
195    let mut association: BTreeMap<(String, String), usize> = BTreeMap::new();
196    let mut sampled = 0usize;
197    for record in text.split('\u{2}') {
198        let mut lines = record.lines();
199        let Some(subject) = lines.next() else {
200            continue;
201        };
202        if subject.trim().is_empty() {
203            continue;
204        }
205        sampled += 1;
206        let header = CommitHeader::parse(subject);
207        if !header.is_conventional() {
208            continue;
209        }
210        let Some(scope) = header.scope.filter(|s| !s.trim().is_empty()) else {
211            continue;
212        };
213        *counts.entry(scope.clone()).or_default() += 1;
214        let mut seen: Vec<String> = Vec::new();
215        for path in lines.filter(|l| !l.trim().is_empty()) {
216            for prefix in prefixes(path) {
217                if seen.contains(&prefix) {
218                    continue;
219                }
220                seen.push(prefix.clone());
221                *association.entry((scope.clone(), prefix)).or_default() += 1;
222            }
223        }
224    }
225    let mut scopes: Vec<ScopeUse> = counts
226        .into_iter()
227        .map(|(scope, commits)| {
228            let dirs: Vec<(String, usize)> = association
229                .iter()
230                .filter(|((s, _), _)| *s == scope)
231                .map(|((_, d), n)| (d.clone(), *n))
232                .collect();
233            let mut weighted: Vec<Weighted> = dirs
234                .into_iter()
235                .map(|(name, weight)| Weighted { name, weight })
236                .collect();
237            crate::order::canonical(&mut weighted);
238            let mut dirs: Vec<(String, usize)> =
239                weighted.into_iter().map(|w| (w.name, w.weight)).collect();
240            dirs.truncate(5);
241            ScopeUse {
242                scope,
243                commits,
244                directories: dirs.into_iter().map(|(d, _)| d).collect(),
245            }
246        })
247        .collect();
248    crate::order::canonical(&mut scopes);
249    ScopeVocabulary {
250        scopes,
251        sampled,
252        unavailable: None,
253        association,
254    }
255}
256
257impl ScopeVocabulary {
258    /// Record that `commits` commits scoped changes under `prefix` as `scope`.
259    ///
260    /// [`fn@derive`] is how a vocabulary is normally built, from the history. This is the same
261    /// association stated directly, for a caller that has the evidence by another route —
262    /// and for tests, which must be able to state a history rather than construct one.
263    /// ```
264    /// use majordomus_cli::commit::ScopeVocabulary;
265    /// let mut v = ScopeVocabulary::default();
266    /// v.learn("commit", "src/commit", 12);
267    /// v.learn("commit", "src/commit", 5);
268    /// assert_eq!(v.suggest(&["src/commit/a.rs".into()]).expect("known").commits, 17);
269    /// ```
270    pub fn learn(&mut self, scope: &str, prefix: &str, commits: usize) {
271        *self
272            .association
273            .entry((scope.to_string(), prefix.to_string()))
274            .or_default() += commits;
275    }
276
277    /// Whether the history has used this scope.
278    /// ```
279    /// use majordomus_cli::commit::{scopes::ScopeUse, ScopeVocabulary};
280    /// let mut v = ScopeVocabulary::default();
281    /// v.scopes.push(ScopeUse { scope: "commit".into(), commits: 1, directories: vec![] });
282    /// assert!(v.knows("commit"));
283    /// assert!(!v.knows("nonesuch"));
284    /// ```
285    pub fn knows(&self, scope: &str) -> bool {
286        self.scopes.iter().any(|s| s.scope == scope)
287    }
288
289    /// The scope words alone, in vocabulary order.
290    /// ```
291    /// use majordomus_cli::commit::{scopes::ScopeUse, ScopeVocabulary};
292    /// let mut v = ScopeVocabulary::default();
293    /// v.scopes.push(ScopeUse { scope: "site".into(), commits: 9, directories: vec![] });
294    /// assert_eq!(v.words(), vec!["site".to_string()]);
295    /// ```
296    pub fn words(&self) -> Vec<String> {
297        self.scopes.iter().map(|s| s.scope.clone()).collect()
298    }
299
300    /// Which scope these paths belong to, judged by what prior commits did with the same
301    /// directories.
302    ///
303    /// `None` when nothing in the history associates any scope with any of these paths —
304    /// which is the honest answer for the first commit of a new subsystem, and is better
305    /// than the most popular scope in the repository, which is what a tie-break to
306    /// frequency would produce.
307    /// ```
308    /// use majordomus_cli::commit::ScopeVocabulary;
309    /// let mut v = ScopeVocabulary::default();
310    /// // the deepest association wins over the busiest directory two levels up
311    /// v.learn("commit", "apps/cli/src/commit", 12);
312    /// v.learn("everything", "apps", 900);
313    /// let s = v.suggest(&["apps/cli/src/commit/a.rs".into()]).expect("an association");
314    /// assert_eq!(s.scope, "commit");
315    /// ```
316    pub fn suggest(&self, paths: &[String]) -> Option<ScopeSuggestion> {
317        // Each path votes once, at the deepest prefix that anything is associated with, so
318        // that a change inside one subsystem is not outvoted by the repository's busiest
319        // directory two levels up.
320        let mut votes: BTreeMap<String, (usize, String)> = BTreeMap::new();
321        for path in paths {
322            for prefix in prefixes(path) {
323                let here: Vec<(&String, usize)> = self
324                    .association
325                    .iter()
326                    .filter(|((_, d), _)| *d == prefix)
327                    .map(|((s, _), n)| (s, *n))
328                    .collect();
329                if here.is_empty() {
330                    continue;
331                }
332                for (scope, n) in here {
333                    let entry = votes.entry(scope.clone()).or_insert((0, prefix.clone()));
334                    entry.0 += n;
335                    entry.1 = prefix.clone();
336                }
337                break;
338            }
339        }
340        let ranked: Vec<(String, usize, String)> =
341            votes.into_iter().map(|(s, (n, d))| (s, n, d)).collect();
342        let mut candidates: Vec<Candidate> = ranked
343            .into_iter()
344            .map(|(scope, weight, directory)| Candidate {
345                scope,
346                weight,
347                directory,
348            })
349            .collect();
350        crate::order::canonical(&mut candidates);
351        let ranked: Vec<(String, usize, String)> = candidates
352            .into_iter()
353            .map(|c| (c.scope, c.weight, c.directory))
354            .collect();
355        let (scope, weight, directory) = ranked.first()?.clone();
356        let ambiguous = ranked.get(1).is_some_and(|(_, n, _)| *n == weight);
357        // The weight is what ranked the candidates — every path votes, so it grows with the
358        // number of paths. What a person is told is the association itself: how many commits
359        // scoped *that directory* that way. Reporting the weight would say "80 prior commits"
360        // for two files under a directory forty commits have touched, which is a number
361        // nobody could reconcile with the history.
362        let commits = self
363            .association
364            .get(&(scope.clone(), directory.clone()))
365            .copied()
366            .unwrap_or(weight);
367        Some(ScopeSuggestion {
368            scope,
369            commits,
370            directory,
371            ambiguous,
372        })
373    }
374}
375
376/// A scope is shown most-used first; between equally used scopes, by name.
377impl crate::order::Ordered for ScopeUse {
378    fn order_key(&self) -> crate::order::OrderKey<'_> {
379        crate::order::OrderKey::plain(&self.scope, &self.scope).ranked(-(self.commits as i64))
380    }
381}
382
383/// A directory weighted by how many commits tie it to a scope: heaviest first, then by name.
384struct Weighted {
385    name: String,
386    weight: usize,
387}
388
389impl crate::order::Ordered for Weighted {
390    fn order_key(&self) -> crate::order::OrderKey<'_> {
391        crate::order::OrderKey::plain(&self.name, &self.name).ranked(-(self.weight as i64))
392    }
393}
394
395/// A candidate scope for a set of paths: most votes first, then by name.
396struct Candidate {
397    scope: String,
398    weight: usize,
399    directory: String,
400}
401
402impl crate::order::Ordered for Candidate {
403    fn order_key(&self) -> crate::order::OrderKey<'_> {
404        crate::order::OrderKey::plain(&self.scope, &self.scope).ranked(-(self.weight as i64))
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn a_prefix_is_a_directory_and_never_the_file() {
414        assert_eq!(
415            prefixes("apps/majordomus-cli/src/commit/plan.rs"),
416            vec![
417                "apps/majordomus-cli/src/commit",
418                "apps/majordomus-cli/src",
419                "apps/majordomus-cli",
420                "apps"
421            ],
422            "deepest first, at most PREFIX_DEPTH segments"
423        );
424        assert_eq!(prefixes("docs/COMMIT.md"), vec!["docs"]);
425        // a file at the root is in no directory, so it associates with nothing
426        assert!(prefixes("README.md").is_empty());
427    }
428
429    fn vocabulary(rows: &[(&str, &str, usize)]) -> ScopeVocabulary {
430        let mut v = ScopeVocabulary::default();
431        for (scope, prefix, n) in rows {
432            v.learn(scope, prefix, *n);
433            if !v.knows(scope) {
434                v.scopes.push(ScopeUse {
435                    scope: scope.to_string(),
436                    commits: *n,
437                    directories: vec![prefix.to_string()],
438                });
439            }
440        }
441        v
442    }
443
444    #[test]
445    fn a_path_takes_the_scope_the_history_gave_its_directory() {
446        let v = vocabulary(&[
447            ("commit", "apps/majordomus-cli/src", 40),
448            ("site", "site", 90),
449        ]);
450        let s = v
451            .suggest(&["apps/majordomus-cli/src/commit/plan.rs".into()])
452            .expect("an association");
453        assert_eq!(s.scope, "commit");
454        assert_eq!(s.directory, "apps/majordomus-cli/src");
455        assert!(!s.ambiguous);
456    }
457
458    #[test]
459    fn the_deepest_association_wins_over_the_busiest_directory() {
460        // `apps` is where everything is; `apps/majordomus-cli/src` is where this is. A vote
461        // at the shallow prefix would make every change in the crate take the scope of
462        // whatever the crate's busiest subsystem happens to be.
463        let v = vocabulary(&[
464            ("commit", "apps/majordomus-cli/src", 12),
465            ("everything", "apps", 900),
466        ]);
467        let s = v
468            .suggest(&["apps/majordomus-cli/src/commit/plan.rs".into()])
469            .expect("an association");
470        assert_eq!(s.scope, "commit");
471    }
472
473    #[test]
474    fn a_tie_is_reported_rather_than_broken() {
475        let v = vocabulary(&[("a", "src", 7), ("b", "src", 7)]);
476        let s = v.suggest(&["src/x.rs".into()]).expect("an association");
477        assert!(s.ambiguous, "two scopes are equally associated");
478        // and it is still deterministic: the same input yields the same answer every time
479        assert_eq!(s.scope, v.suggest(&["src/x.rs".into()]).unwrap().scope);
480    }
481
482    #[test]
483    fn nothing_known_is_answered_with_nothing_and_not_with_the_most_popular_scope() {
484        let v = vocabulary(&[("site", "site", 900)]);
485        assert!(v.suggest(&["brand/new/thing.rs".into()]).is_none());
486        assert!(v.suggest(&[]).is_none());
487    }
488
489    #[test]
490    fn a_directory_that_is_not_a_work_tree_yields_an_empty_vocabulary_that_says_why() {
491        let plain = tempfile::tempdir().expect("a temporary directory");
492        let v = derive(plain.path());
493        assert!(v.scopes.is_empty());
494        assert!(
495            v.unavailable.is_some(),
496            "it says why rather than pretending"
497        );
498        assert!(!v.knows("anything"));
499    }
500}