1use 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
42pub const SCOPES_URI: &str = "majordomus://commit/scopes";
44pub const PLAN_URI: &str = "majordomus://commit/plan";
46
47fn root(ctx: &Context) -> std::path::PathBuf {
49 std::path::PathBuf::from(&ctx.index.repository.root)
50}
51
52fn 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
64fn 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
78pub struct ScopesReport {
85 pub vocabulary: ScopeVocabulary,
87 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
98pub(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 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
162#[serde(deny_unknown_fields)]
163pub struct ValidateInput {
171 pub message: String,
174 #[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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
231#[serde(deny_unknown_fields)]
232pub struct HistoryInput {
238 #[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 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 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
324pub 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 #[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 #[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}