1use std::path::Path;
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
21use crate::capability::handler::{CapabilityError, Context};
22use crate::capability::model::{
23 CachePolicy, CliExposure, Exposure, McpExposure, McpResource, Stability,
24};
25use crate::capability::module::ModuleDescriptor;
26use crate::worktree::{
27 migrate, Detail, InspectReport, MigrationPlan, RepositoryTopology, StatusReport, Threshold,
28 UncommittedReport, WorktreeError, WorktreeService,
29};
30use crate::{capability, module};
31
32use super::{get, mcp, Empty};
33
34pub const WORKTREES_URI: &str = "majordomus://worktrees";
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields)]
41pub struct StatusInput {
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub path: Option<String>,
49}
50
51impl BenchmarkCases for StatusInput {
52 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
53 vec![
54 NamedCase::new("default", StatusInput { path: None }),
55 NamedCase::new(
56 "primary-checkout",
57 StatusInput {
58 path: Some(".".into()),
59 },
60 ),
61 ]
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
66#[serde(deny_unknown_fields)]
67pub struct InspectInput {
69 pub branch: String,
72}
73
74impl BenchmarkCases for InspectInput {
75 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
76 vec![NamedCase::new(
77 "feature-branch",
78 InspectInput {
79 branch: "feature/example".into(),
80 },
81 )]
82 }
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
86#[serde(deny_unknown_fields)]
87pub struct UncommittedInput {
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub older_than: Option<String>,
94}
95
96impl BenchmarkCases for UncommittedInput {
97 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
98 vec![
99 NamedCase::new("policy-threshold", UncommittedInput { older_than: None }),
100 NamedCase::new(
101 "one-hour",
102 UncommittedInput {
103 older_than: Some("1h".into()),
104 },
105 ),
106 ]
107 }
108}
109
110fn refused(e: WorktreeError) -> CapabilityError {
113 match e.exit_code() {
114 crate::worktree::EXIT_MISSING => CapabilityError::NotFound(e.to_string()),
115 crate::worktree::EXIT_REFUSED => CapabilityError::Refused(e.to_string()),
116 _ => CapabilityError::Internal(e.to_string()),
117 }
118}
119
120pub fn service_of(ctx: &Context) -> Result<WorktreeService, CapabilityError> {
122 WorktreeService::open(Path::new(&ctx.index.repository.root)).map_err(refused)
123}
124
125fn service_at(ctx: &Context, path: Option<&str>) -> Result<WorktreeService, CapabilityError> {
128 let own = service_of(ctx)?;
129 let Some(path) = path else {
130 return Ok(own);
131 };
132 let path = {
134 let p = Path::new(path);
135 if p.is_absolute() {
136 p.to_path_buf()
137 } else {
138 Path::new(&ctx.index.repository.root).join(p)
139 }
140 };
141 let other = WorktreeService::open(&path).map_err(refused)?;
142 if !other
143 .identity()
144 .git_common_dir()
145 .same_as(own.identity().git_common_dir())
146 {
147 return Err(CapabilityError::Refused(
148 WorktreeError::NotThisRepository {
149 path,
150 git_common_dir: own.identity().git_common_dir().path.clone(),
151 }
152 .to_string(),
153 ));
154 }
155 Ok(other)
156}
157
158fn worktree_topology(ctx: &Context, _: Empty) -> Result<RepositoryTopology, CapabilityError> {
161 service_of(ctx)?.topology(Detail::Full).map_err(refused)
162}
163
164fn worktree_status(ctx: &Context, input: StatusInput) -> Result<StatusReport, CapabilityError> {
165 service_at(ctx, input.path.as_deref())?
166 .status()
167 .map_err(refused)
168}
169
170fn worktree_inspect(ctx: &Context, input: InspectInput) -> Result<InspectReport, CapabilityError> {
171 service_of(ctx)?.inspect(&input.branch).map_err(refused)
172}
173
174fn worktree_migration_plan(ctx: &Context, _: Empty) -> Result<MigrationPlan, CapabilityError> {
175 migrate::plan(&service_of(ctx)?).map_err(refused)
176}
177
178fn worktree_uncommitted(
179 ctx: &Context,
180 input: UncommittedInput,
181) -> Result<UncommittedReport, CapabilityError> {
182 let threshold = Threshold::declared_at(
183 Path::new(&ctx.index.repository.root),
184 input.older_than.as_deref(),
185 )
186 .map_err(CapabilityError::InvalidInput)?;
187 service_of(ctx)?.uncommitted(&threshold).map_err(refused)
188}
189
190pub fn module() -> ModuleDescriptor {
194 module! {
195 id: "worktree",
196 title: "Worktree topology",
197 description: "Where every linked git worktree of this repository belongs and where each one is. The container is the primary checkout's sibling named with `-wt`, the path under it is the branch name with its hierarchy kept, and both are derived from git's own identity — the common directory, the registered worktrees, the branches — never from a registry, a configuration or the current directory. A worktree somewhere else is a typed diagnostic with a remedy; the migration that repairs it is a command-line operation of the same service.",
198 stability: Stability::BehaviorallyVerified,
199 capabilities: [
200 capability! {
201 id: "worktree.topology",
202 title: "The whole topology",
203 description: "The repository, the container, the trunk and how it was decided, every registered worktree with its standing (primary, canonical, misplaced, detached, missing), its uncommitted work, its upstream distance and the issue its branch provably names, every local branch with or without a worktree and whether it is eligible for cleanup, every diagnostic with its code and remedy, and the tallies. Read from git on every call: the topology changes outside this process.",
204 input: Empty,
205 output: RepositoryTopology,
206 stability: Stability::BehaviorallyVerified,
207 exposure: Exposure {
208 mcp: Some(McpExposure {
209 tool: Some("majordomus_worktrees".into()),
210 resource: Some(McpResource { uri: WORKTREES_URI.into(), name: "worktrees".into() }),
211 }),
212 http: get("/api/v1/worktrees"),
213 cli: Some(CliExposure { path: vec!["worktree".into(), "topology".into()] }),
214 },
215 tags: ["worktree", "git", "topology", "introspection"],
216 cache: CachePolicy::Disabled,
217 handler: worktree_topology,
218 },
219 capability! {
220 id: "worktree.status",
221 title: "Where this is, and whether that is where it belongs",
222 description: "One worktree — the repository's own, or the one holding the directory the caller names — with its standing, its branch, its canonical path, its uncommitted work counted, whether it is where it belongs, and how many errors the whole topology carries. What an agent reads before it starts, and what the guard decides on.",
223 input: StatusInput,
224 output: StatusReport,
225 stability: Stability::BehaviorallyVerified,
226 exposure: Exposure {
227 mcp: mcp("majordomus_worktree_status"),
228 http: get("/api/v1/worktrees/status"),
229 cli: Some(CliExposure { path: vec!["worktree".into(), "status".into()] }),
230 },
231 tags: ["worktree", "git", "topology"],
232 cache: CachePolicy::Disabled,
233 handler: worktree_status,
234 },
235 capability! {
236 id: "worktree.inspect",
237 title: "One branch: where its worktree belongs and what is there",
238 description: "The canonical path of a branch, derived from its name alone, whether the branch exists, whether something occupies that path, the worktree holding the branch when one does, and what stands in the way of creating or migrating it. The answer for a branch that does not exist yet is the path `worktree create` would use.",
239 input: InspectInput,
240 output: InspectReport,
241 stability: Stability::BehaviorallyVerified,
242 exposure: Exposure {
243 mcp: mcp("majordomus_worktree_inspect"),
244 http: get("/api/v1/worktrees/inspect"),
245 cli: Some(CliExposure { path: vec!["worktree".into(), "inspect".into()] }),
246 },
247 tags: ["worktree", "git", "topology"],
248 cache: CachePolicy::Disabled,
249 handler: worktree_inspect,
250 },
251 capability! {
252 id: "worktree.migration_plan",
253 title: "What it would take to bring every worktree home",
254 description: "One step per misplaced worktree with a branch: where it is, where it belongs, how it would move, the uncommitted work that moves with it, and what blocks it; plus the exceptions the migration cannot address by design — detached worktrees, stale registrations, the primary checkout off the trunk — each with what a person does about it. Planning changes nothing; `majordomus worktree migrate` applies it with a fingerprint taken before and after every move.",
255 input: Empty,
256 output: MigrationPlan,
257 stability: Stability::BehaviorallyVerified,
258 exposure: Exposure {
259 mcp: mcp("majordomus_worktree_migration_plan"),
260 http: get("/api/v1/worktrees/migration"),
261 cli: Some(CliExposure { path: vec!["worktree".into(), "migrate".into()] }),
262 },
263 tags: ["worktree", "git", "topology", "migration"],
264 cache: CachePolicy::Disabled,
265 handler: worktree_migration_plan,
266 },
267 capability! {
268 id: "worktree.uncommitted",
269 title: "Uncommitted work in the linked worktrees, and what holds it",
270 description: "Every linked worktree whose index or working tree holds changes no commit carries: how many paths (staged, unstaged, untracked, conflicted), when the oldest change was made (the earliest modification time among the changed paths, the index file's standing in for a deleted one), and what holds the work — an active task record, an open episode that is not stranded, or a process whose working directory is inside. Work nothing holds and older than the threshold is `abandoned`, with the command that would put it on a remote `rescue/` ref; it is suggested, never run. The threshold is the policy's `session.stranded_after` unless `older_than` names another. Reads every worktree and writes none: every status runs without optional locks, so no index is refreshed.",
271 input: UncommittedInput,
272 output: UncommittedReport,
273 stability: Stability::BehaviorallyVerified,
274 exposure: Exposure {
275 mcp: mcp("majordomus_worktree_uncommitted"),
276 http: get("/api/v1/worktrees/uncommitted"),
277 cli: Some(CliExposure { path: vec!["worktree".into(), "uncommitted".into()] }),
278 },
279 tags: ["worktree", "git", "continuity", "introspection"],
280 cache: CachePolicy::Disabled,
281 handler: worktree_uncommitted,
282 },
283 ],
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
294 fn the_declaration_yields_the_projections_it_claims() {
295 let m = module();
296 assert_eq!(m.id.as_str(), "worktree");
297 let expected: &[(&str, &str, &str)] = &[
298 (
299 "worktree.topology",
300 "majordomus_worktrees",
301 "/api/v1/worktrees",
302 ),
303 (
304 "worktree.status",
305 "majordomus_worktree_status",
306 "/api/v1/worktrees/status",
307 ),
308 (
309 "worktree.inspect",
310 "majordomus_worktree_inspect",
311 "/api/v1/worktrees/inspect",
312 ),
313 (
314 "worktree.migration_plan",
315 "majordomus_worktree_migration_plan",
316 "/api/v1/worktrees/migration",
317 ),
318 (
319 "worktree.uncommitted",
320 "majordomus_worktree_uncommitted",
321 "/api/v1/worktrees/uncommitted",
322 ),
323 ];
324 let ids: Vec<&str> = m
325 .capabilities
326 .iter()
327 .map(|e| e.capability.id.as_str())
328 .collect();
329 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
330 assert_eq!(ids, want);
331 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
332 let exposure = &executable.capability.exposure;
333 assert_eq!(
334 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
335 Some(*tool),
336 "{id} lost or renamed its MCP tool"
337 );
338 assert_eq!(
339 exposure.http.as_ref().map(|h| h.path.as_str()),
340 Some(*path),
341 "{id} lost or renamed its HTTP route"
342 );
343 assert!(
344 !executable.capability.cache.is_enabled(),
345 "{id} must not cache: the topology changes outside this process"
346 );
347 }
348 let resource = m.capabilities[0]
349 .capability
350 .exposure
351 .mcp
352 .as_ref()
353 .and_then(|m| m.resource.as_ref());
354 assert_eq!(resource.map(|r| r.uri.as_str()), Some(WORKTREES_URI));
355 }
356
357 #[test]
360 fn an_uncommitted_reading_against_no_duration_is_invalid_input() {
361 let fixture = crate::synthetic::SyntheticRepository::small().unwrap();
362 let app = crate::app::App::load(&crate::cli::RepoArgs {
363 repo: Some(fixture.root().to_path_buf()),
364 discovery: crate::cli::DiscoveryMode::Filesystem,
365 share: Some(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share")),
366 ..Default::default()
367 })
368 .unwrap();
369 let refused = app
370 .context
371 .execute(
372 "worktree.uncommitted",
373 serde_json::json!({ "older_than": "soon" }),
374 )
375 .unwrap_err();
376 assert!(
377 matches!(&refused, CapabilityError::InvalidInput(m) if m.contains("older_than")),
378 "{refused:?}"
379 );
380 }
381
382 #[test]
385 fn every_capability_is_a_query() {
386 for e in module().capabilities {
387 assert!(
388 e.capability.kind.is_read_only(),
389 "{} writes",
390 e.capability.id
391 );
392 }
393 }
394}