Skip to main content

majordomus_cli/capability/builtin/
worktree.rs

1//! The `worktree` module: the branch-to-worktree topology of this repository, projected.
2//!
3//! Five questions, all read-only, all answered by [`crate::worktree::WorktreeService`] —
4//! the same service the command line renders and the git hooks ask. Declaring them here is
5//! what puts them on MCP (as tools and as a resource), on HTTP, in the OpenAPI document, in
6//! the Swagger UI, in the Cockpit and in the generated reference, without any of those
7//! carrying a route, a schema or a sentence of their own.
8//!
9//! Creating, migrating, repairing and removing are deliberately *not* here. A capability that
10//! writes the repository must declare `Effect::RepositoryMutation`, and the set of those is
11//! pinned by a test in `builtin`; moving worktrees on disk is not a thing the shared MCP
12//! server takes on. They are command-line operations, they call the same service, and the
13//! Cockpit names the exact command for each.
14
15use 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
34/// The URI under which the topology is read as an MCP resource.
35pub const WORKTREES_URI: &str = "majordomus://worktrees";
36
37// ---------------------------------------------------------------- inputs
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields)]
41/// Where to answer from.
42pub struct StatusInput {
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    /// A directory inside one of this repository's worktrees; the answer is about that
45    /// worktree. Relative to the primary checkout when relative (`.` is the primary
46    /// checkout itself). Default: the repository the server was started for. A path in
47    /// another repository is refused: the topology answers only about its own.
48    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)]
67/// A branch to inspect.
68pub struct InspectInput {
69    /// The branch, full name (`feature/improve-cli`). It need not exist: the canonical path
70    /// derives from the name alone.
71    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)]
87/// What to judge uncommitted work against.
88pub struct UncommittedInput {
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    /// The age past which work nothing holds is abandoned, as a person writes it (`90s`,
91    /// `30m`, `12h`, `1d`). Default: the policy's `session.stranded_after`, which is also
92    /// how long an open episode may be silent and still hold the work.
93    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
110// ---------------------------------------------------------------- the service, from a context
111
112fn 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
120/// The one service, over the repository this process serves.
121pub fn service_of(ctx: &Context) -> Result<WorktreeService, CapabilityError> {
122    WorktreeService::open(Path::new(&ctx.index.repository.root)).map_err(refused)
123}
124
125/// The service over a directory the caller named, refused unless it is inside a worktree
126/// of the repository this process serves.
127fn 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    // relative to the primary checkout, never to this process's working directory
133    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
158// ---------------------------------------------------------------- handlers
159
160fn 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
190// ---------------------------------------------------------------- the module
191
192/// The `worktree` module: the topology, projected once.
193pub 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    /// The declaration is the only place these names exist; every projection derives from
292    /// it. This is the assertion a refactor that dropped an exposure would fail.
293    #[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    /// An `older_than` that is not a duration is the caller's error, refused as invalid
358    /// input before any work tree is read.
359    #[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    /// Every capability of the module is read-only: the registry's contract, and the
383    /// reason the mutations live on the command line.
384    #[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}