Skip to main content

majordomus_cli/capability/builtin/
commit.rs

1//! The `commit` module: what this repository holds a commit to, what the working tree
2//! would commit, and which scope the history gives a set of paths.
3//!
4//! Three read-only capabilities over [`crate::commit`]. Nothing here decides anything: the
5//! grammar, the policy, the judge and the planner are the domain's, and this module is the
6//! declaration that carries them onto the surfaces — the MCP tool, the HTTP route, the
7//! OpenAPI operation, the Cockpit payload and the generated reference — without any of them
8//! holding a second copy of a rule.
9//!
10//! The mutation is deliberately not here. Making a commit is a repository mutation, and the
11//! exposure policy (`command_graph::policy`) stops every machine surface at local mutation:
12//! an agent may ask what a commit *would* be and whether a message passes, and a person at a
13//! terminal is who commits. That is not a limitation worked around, it is the arrangement
14//! this repository chose, and the commit subsystem is shaped to it — which is why the
15//! planner and the judge are pure functions of a tree and a message.
16
17//! ```
18//! use majordomus_cli::capability::builtin::commit::module;
19//!
20//! // The declaration is the only place these names exist; every surface derives from it.
21//! let m = module();
22//! assert_eq!(m.id.as_str(), "commit");
23//! let plan = m.capabilities.iter().find(|e| e.capability.id.as_str() == "commit.plan").unwrap();
24//! let exposure = &plan.capability.exposure;
25//! assert_eq!(exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()), Some("majordomus_commit_plan"));
26//! assert_eq!(exposure.http.as_ref().map(|h| h.path.as_str()), Some("/api/v1/commit/plan"));
27//! ```
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31
32use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
33use crate::capability::handler::{CapabilityError, Context};
34use crate::capability::model::{CliExposure, Exposure, McpExposure, McpResource, Stability};
35use crate::capability::module::ModuleDescriptor;
36use crate::commit::verdict::{HistoryReport, JudgedCommit};
37use crate::commit::{self, CommitPlan, CommitPolicy, CommitVerdict, ScopeVocabulary};
38use crate::{capability, module};
39
40use super::{get, mcp, Empty};
41
42/// The URI under which the scope vocabulary is read as an MCP resource.
43pub const SCOPES_URI: &str = "majordomus://commit/scopes";
44/// The URI under which the plan over the working tree is read as an MCP resource.
45pub const PLAN_URI: &str = "majordomus://commit/plan";
46
47/// The repository root this process serves, as a path.
48fn root(ctx: &Context) -> std::path::PathBuf {
49    std::path::PathBuf::from(&ctx.index.repository.root)
50}
51
52/// The commit policy this repository declares, or the default when the policy cannot be
53/// read. An unreadable policy is `doctor`'s finding; it is not a reason for a validator to
54/// silently stop checking.
55fn policy(ctx: &Context) -> CommitPolicy {
56    let Ok(repo) = crate::repository::Repository::open(&root(ctx)) else {
57        return CommitPolicy::default();
58    };
59    crate::policy::LoadedPolicy::load(&repo)
60        .map(|l| l.policy.commit)
61        .unwrap_or_default()
62}
63
64/// The record ids the layer holds, for resolving what a message names.
65fn records(ctx: &Context) -> Vec<String> {
66    ctx.index
67        .objects
68        .iter()
69        .filter(|o| o.kind == "issue" || o.kind == "milestone")
70        .map(|o| o.identity.clone())
71        .collect()
72}
73
74// ---------------------------------------------------------------- commit.scopes
75
76/// The vocabulary, with the policy that reads it.
77#[derive(Debug, Serialize, Deserialize, JsonSchema)]
78/// ```
79/// use majordomus_cli::capability::builtin::commit::ScopesReport;
80/// use majordomus_cli::commit::ScopeVocabulary;
81/// let r = ScopesReport { vocabulary: ScopeVocabulary::default(), sample: 1500 };
82/// assert_eq!(r.sample, majordomus_cli::commit::scopes::SAMPLE);
83/// ```
84pub struct ScopesReport {
85    /// Every scope the history uses, most used first, with the directories it is used about.
86    pub vocabulary: ScopeVocabulary,
87    /// How many commits back the vocabulary is learned from.
88    pub sample: usize,
89}
90
91pub(super) fn scopes(ctx: &Context, _: Empty) -> Result<ScopesReport, CapabilityError> {
92    Ok(ScopesReport {
93        vocabulary: commit::scopes::derive(&root(ctx)),
94        sample: commit::scopes::SAMPLE,
95    })
96}
97
98// ---------------------------------------------------------------- commit.plan
99
100pub(super) fn plan(ctx: &Context, _: Empty) -> Result<CommitPlan, CapabilityError> {
101    let root = root(ctx);
102    let tree = commit::plan::working_tree(&root).ok_or_else(|| {
103        CapabilityError::NotFound(
104            "this is not a git work tree, or git could not be asked; there is nothing to commit \
105             and that is not the same as a clean tree"
106                .into(),
107        )
108    })?;
109    let identity = crate::worktree::identity::RepositoryIdentity::discover(&root).ok();
110    let (repository, worktree) = match &identity {
111        Some(id) => (
112            id.git_common_dir().real.display().to_string(),
113            id.current_worktree().real.display().to_string(),
114        ),
115        // A repository git can describe but whose worktree topology could not be resolved
116        // still fingerprints; it fingerprints on what is known, and says so by carrying the
117        // root in both fields rather than by inventing an identity.
118        None => (root.display().to_string(), root.display().to_string()),
119    };
120    let fingerprint = commit::PlanFingerprint::of(&repository, &worktree, &tree);
121    let vocabulary = commit::scopes::derive(&root);
122    let staged = tree.staged();
123    let derived = commit::plan::derived_among(&root, &staged);
124    let groups = commit::plan::group(&staged, &vocabulary, &derived);
125    let mut diagnostics = Vec::new();
126    if let Some(what) = &tree.in_progress {
127        diagnostics.push(crate::model::Diagnostic::warning(
128            "commit.in_progress",
129            None,
130            format!("a {what} is in progress; finish or abort it before planning a commit"),
131        ));
132    }
133    if staged.is_empty() && !tree.is_clean() {
134        diagnostics.push(crate::model::Diagnostic::warning(
135            "commit.nothing_staged",
136            None,
137            format!(
138                "{} change(s) in the working tree and nothing staged; a commit now would be empty",
139                tree.changes.len()
140            ),
141        ));
142    }
143    for change in tree.changes.iter().filter(|c| c.partial) {
144        diagnostics.push(crate::model::Diagnostic::warning(
145            "commit.partially_staged",
146            Some(change.path.clone()),
147            "staged and modified again since; the commit would carry the staged half only",
148        ));
149    }
150    Ok(CommitPlan {
151        fingerprint,
152        tree,
153        groups,
154        diagnostics,
155    })
156}
157
158// ---------------------------------------------------------------- commit.validate
159
160/// A message to judge, and what is known about the commit it would make.
161#[derive(Debug, Serialize, Deserialize, JsonSchema)]
162#[serde(deny_unknown_fields)]
163/// ```
164/// use majordomus_cli::capability::builtin::commit::ValidateInput;
165/// // paths are optional: a caller that does not know them gets the judgements that need
166/// // none, rather than a guess
167/// let v: ValidateInput = serde_json::from_str(r#"{"message": "feat(a): a thing"}"#).unwrap();
168/// assert!(v.paths.is_none());
169/// ```
170pub struct ValidateInput {
171    /// The commit message, as it would be stored. Comment lines are ignored, as git ignores
172    /// them, so what is judged is what would be committed.
173    pub message: String,
174    /// The paths the commit would contain. Omit them and the judgements that need them —
175    /// whether a fix carries a test — are not made rather than guessed.
176    #[serde(default)]
177    pub paths: Option<Vec<String>>,
178}
179
180impl BenchmarkCases for ValidateInput {
181    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
182        vec![
183            NamedCase::new(
184                "conventional",
185                ValidateInput {
186                    message: "feat(commit): the rule is decided by a command".into(),
187                    paths: None,
188                },
189            ),
190            NamedCase::new(
191                "not-conventional",
192                ValidateInput {
193                    message: "update stuff".into(),
194                    paths: None,
195                },
196            ),
197            NamedCase::new(
198                "merge",
199                ValidateInput {
200                    message: "Merge pull request #249 from korczis/fix".into(),
201                    paths: None,
202                },
203            ),
204        ]
205    }
206}
207
208pub(super) fn validate(
209    ctx: &Context,
210    input: ValidateInput,
211) -> Result<CommitVerdict, CapabilityError> {
212    if input.message.trim().is_empty() {
213        return Err(CapabilityError::InvalidInput(
214            "the message is empty; there is nothing to judge".into(),
215        ));
216    }
217    let vocabulary = commit::scopes::derive(&root(ctx));
218    let subject = commit::CommitSubject {
219        text: input.message,
220        scopes: (!vocabulary.scopes.is_empty()).then(|| vocabulary.words()),
221        paths: input.paths,
222        records: records(ctx),
223    };
224    Ok(commit::judge(&subject, &policy(ctx)))
225}
226
227// ---------------------------------------------------------------- commit.history
228
229/// Which range of history to judge.
230#[derive(Debug, Serialize, Deserialize, JsonSchema)]
231#[serde(deny_unknown_fields)]
232/// ```
233/// use majordomus_cli::capability::builtin::commit::HistoryInput;
234/// let h: HistoryInput = serde_json::from_str("{}").expect("an empty input");
235/// assert_eq!(h.range, "HEAD", "the whole history reachable from here");
236/// ```
237pub struct HistoryInput {
238    /// Anything `git log` accepts: `origin/master..HEAD`, `v0.6.0..`, a bare `HEAD`.
239    /// Default `HEAD`, which is the whole history reachable from here.
240    #[serde(default = "head")]
241    pub range: String,
242}
243
244fn head() -> String {
245    "HEAD".into()
246}
247
248impl BenchmarkCases for HistoryInput {
249    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
250        // `HEAD`, and deliberately not a bounded range like `HEAD~50..HEAD`.
251        //
252        // The bounded form was the first instinct — a benchmark that reads the whole history
253        // measures how old the repository is rather than how fast the judge is — and it was
254        // wrong for a reason worth recording: a benchmark case is executed against a
255        // *disposable* repository, which has two commits. `HEAD~50` does not resolve there,
256        // the capability correctly refuses the range as invalid input, and the route answers
257        // 400. It failed in `http_serve` behind a job name and a target name that both
258        // matched master's own failures, which is exactly how a defect of one's own gets
259        // read as inherited.
260        //
261        // `HEAD` resolves in every repository that has a commit, which is the property a
262        // benchmark case needs before any other. That its cost grows with the history is not
263        // a flaw in the measurement: judging every commit *is* the operation, and a number
264        // that grows with the subject is the honest one.
265        vec![NamedCase::new(
266            "whole-history",
267            HistoryInput { range: head() },
268        )]
269    }
270}
271
272pub(super) fn history(
273    ctx: &Context,
274    input: HistoryInput,
275) -> Result<HistoryReport, CapabilityError> {
276    let root = root(ctx);
277    let messages = commit::plan::messages_in(&root, &input.range).ok_or_else(|| {
278        CapabilityError::InvalidInput(format!("git does not know the range '{}'", input.range))
279    })?;
280    let vocabulary = commit::scopes::derive(&root);
281    let words = (!vocabulary.scopes.is_empty()).then(|| vocabulary.words());
282    let records = records(ctx);
283    let policy = policy(ctx);
284    let mut report = HistoryReport {
285        range: input.range,
286        total: messages.len(),
287        exempt: 0,
288        failing: 0,
289        commits: Vec::new(),
290    };
291    for (commit_name, text) in messages {
292        let subject = commit::CommitSubject {
293            text,
294            scopes: words.clone(),
295            // A commit's paths are knowable from git, and are deliberately not read: the
296            // judgements that need them are about what a commit *should have carried*, and
297            // asking that of a commit already in the history is asking somebody to rewrite
298            // it. The gate judges what a message says, and the hook — which runs while the
299            // commit is still being made — is where the paths are.
300            paths: None,
301            records: records.clone(),
302        };
303        let verdict = commit::judge(&subject, &policy);
304        if verdict.exempt.is_some() {
305            report.exempt += 1;
306            continue;
307        }
308        if !verdict.passed {
309            report.failing += 1;
310        }
311        if !verdict.findings.is_empty() {
312            report.commits.push(JudgedCommit {
313                commit: commit_name,
314                subject: verdict.message.header.subject.clone(),
315                passed: verdict.passed,
316                exempt: verdict.exempt,
317                findings: verdict.findings,
318            });
319        }
320    }
321    Ok(report)
322}
323
324/// The module.
325/// The module: three read-only capabilities over the commit domain, plus the range judge.
326///
327/// ```
328/// use majordomus_cli::capability::builtin::commit::module;
329/// use majordomus_cli::capability::Effect;
330/// // nothing here writes the repository; making a commit stays with the person
331/// assert!(module().capabilities.iter().all(|e| e.capability.execution.effect == Effect::Read));
332/// ```
333pub fn module() -> ModuleDescriptor {
334    module! {
335        id: "commit",
336        title: "Commit",
337        description: "The commit as a value: the scope vocabulary this repository's own history yields, what the working tree would commit and how it divides, and the verdict on one message against the repository's commit policy.",
338        stability: Stability::BehaviorallyVerified,
339        capabilities: [
340            capability! {
341                id: "commit.scopes",
342                title: "The scope vocabulary",
343                description: "Every scope this repository's commit history uses, how often, and the directories each one is written about — learned from the history rather than declared in a table, so that a subsystem committed today is in the vocabulary today and one nobody has touched falls to the bottom on its own.",
344                input: Empty,
345                output: ScopesReport,
346                stability: Stability::BehaviorallyVerified,
347                exposure: Exposure {
348                    mcp: Some(McpExposure {
349                        tool: Some("majordomus_commit_scopes".into()),
350                        resource: Some(McpResource { uri: SCOPES_URI.into(), name: "commit-scopes".into() }),
351                    }),
352                    http: get("/api/v1/commit/scopes"),
353                    cli: Some(CliExposure { path: vec!["commit".into(), "scopes".into()] }),
354                },
355                tags: ["commit", "git", "introspection"],
356                handler: scopes,
357            },
358            capability! {
359                id: "commit.plan",
360                title: "What the working tree would commit",
361                description: "The working tree as git reports it — branch, upstream, divergence, every staged, unstaged and untracked path, and any merge or rebase in progress — divided into the commits the history's own scoping supports, each with the evidence for it, under a fingerprint of the repository, the worktree, HEAD and the exact change set, so that a plan acted on later can be refused rather than applied to a tree it was not made for.",
362                input: Empty,
363                output: CommitPlan,
364                stability: Stability::BehaviorallyVerified,
365                exposure: Exposure {
366                    mcp: Some(McpExposure {
367                        tool: Some("majordomus_commit_plan".into()),
368                        resource: Some(McpResource { uri: PLAN_URI.into(), name: "commit-plan".into() }),
369                    }),
370                    http: get("/api/v1/commit/plan"),
371                    cli: Some(CliExposure { path: vec!["commit".into(), "plan".into()] }),
372                },
373                tags: ["commit", "git", "plan"],
374                handler: plan,
375            },
376            capability! {
377                id: "commit.validate",
378                title: "Judge one commit message",
379                description: "One message against this repository's commit policy: the grammar, the subject width, whether the scope is one the history uses, whether every record the message names exists, whether a fix carries a test, and whether a breaking change explains itself — as typed findings with severities, or an exemption for a subject git composed. The same verdict the commit-msg hook refuses with and the gate reads the history through.",
380                input: ValidateInput,
381                output: CommitVerdict,
382                stability: Stability::BehaviorallyVerified,
383                exposure: Exposure {
384                    mcp: mcp("majordomus_commit_validate"),
385                    http: get("/api/v1/commit/validate"),
386                    cli: Some(CliExposure { path: vec!["commit".into(), "validate".into()] }),
387                },
388                tags: ["commit", "git", "governance"],
389                handler: validate,
390            },
391            capability! {
392                id: "commit.history",
393                title: "Judge a range of history",
394                description: "Every commit in a git range against the commit policy, in one pass: how many were read, how many git composed and are exempt, how many carry an error, and every commit that has a finding with what it is. What the gate reads to refuse new debt, and what answers `is the history of this branch clean?` without a process per commit.",
395                input: HistoryInput,
396                output: HistoryReport,
397                stability: Stability::BehaviorallyVerified,
398                exposure: Exposure {
399                    mcp: mcp("majordomus_commit_history"),
400                    http: get("/api/v1/commit/history"),
401                    cli: Some(CliExposure { path: vec!["commit".into(), "history".into()] }),
402                },
403                tags: ["commit", "git", "governance"],
404                handler: history,
405            },
406        ],
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    /// The declaration is the only place these names exist; every projection derives from
415    /// it. A refactor that dropped an exposure would still compile and every behavioural
416    /// suite would still pass. This is the assertion that would not.
417    #[test]
418    fn the_declaration_yields_the_projections_it_claims() {
419        let m = module();
420        assert_eq!(m.id.as_str(), "commit");
421        let expected: &[(&str, &str, &str)] = &[
422            (
423                "commit.scopes",
424                "majordomus_commit_scopes",
425                "/api/v1/commit/scopes",
426            ),
427            (
428                "commit.plan",
429                "majordomus_commit_plan",
430                "/api/v1/commit/plan",
431            ),
432            (
433                "commit.validate",
434                "majordomus_commit_validate",
435                "/api/v1/commit/validate",
436            ),
437            (
438                "commit.history",
439                "majordomus_commit_history",
440                "/api/v1/commit/history",
441            ),
442        ];
443        let ids: Vec<&str> = m
444            .capabilities
445            .iter()
446            .map(|e| e.capability.id.as_str())
447            .collect();
448        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
449        assert_eq!(ids, want, "the module declares a different set");
450        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
451            let exposure = &executable.capability.exposure;
452            assert_eq!(
453                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
454                Some(*tool),
455                "{id} lost or renamed its MCP tool"
456            );
457            assert_eq!(
458                exposure.http.as_ref().map(|h| h.path.as_str()),
459                Some(*path),
460                "{id} lost or renamed its HTTP route"
461            );
462        }
463    }
464
465    /// Every capability here reads; none of them commits. The line between asking what a
466    /// commit would be and making one is what lets these reach an agent at all, and it is
467    /// worth an assertion in the file that draws it.
468    #[test]
469    fn nothing_in_this_module_writes_the_repository() {
470        use crate::capability::Effect;
471        for e in module().capabilities {
472            assert_eq!(
473                e.capability.execution.effect,
474                Effect::Read,
475                "{} would mutate; making a commit is the command line's, not a machine surface's",
476                e.capability.id.as_str()
477            );
478        }
479    }
480}