Skip to main content

majordomus_cli/worktree/
model.rs

1//! The typed topology: one Rust source of truth that serde, JSON Schema, OpenAPI, MCP, the
2//! command line and the Cockpit all render. Nothing in here is decided; it is what the
3//! service decided, written down.
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8/// The `schema` every topology document carries.
9pub const SCHEMA: &str = "majordomus/worktree-topology/v1";
10
11/// Whether a work tree is the repository's own checkout or one linked to it.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "lowercase")]
14pub enum WorktreeKind {
15    /// The main work tree. It hosts the trunk, is never moved and is never removed.
16    Primary,
17    /// A linked work tree. Belongs at its branch's canonical path.
18    Linked,
19}
20
21impl WorktreeKind {
22    /// The word this kind is reported under.
23    pub fn as_str(self) -> &'static str {
24        match self {
25            WorktreeKind::Primary => "primary",
26            WorktreeKind::Linked => "linked",
27        }
28    }
29}
30
31/// Where a work tree stands against the topology.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34pub enum Standing {
35    /// The primary checkout: exempt from the path rule, held to the trunk rule.
36    Primary,
37    /// A linked work tree at exactly its branch's canonical path.
38    Canonical,
39    /// A linked work tree somewhere else. Migration brings it home.
40    Misplaced,
41    /// A linked work tree with no branch. It has no canonical path and is never moved.
42    Detached,
43    /// A linked work tree under the operating system's temporary directory or under the
44    /// primary checkout's `.claude/worktrees/`: a session's scratch checkout, owned and
45    /// removed by the harness that made it. Reported; migrated only on request.
46    Ephemeral,
47    /// A registration whose directory is gone. Repair drops it.
48    Missing,
49}
50
51impl Standing {
52    /// The word this standing is reported under.
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Standing::Primary => "primary",
56            Standing::Canonical => "canonical",
57            Standing::Misplaced => "misplaced",
58            Standing::Detached => "detached",
59            Standing::Ephemeral => "ephemeral",
60            Standing::Missing => "missing",
61        }
62    }
63
64    /// Whether this standing satisfies the path rule. `Primary` and `Detached` are not
65    /// subject to it; `Missing` fails it because a stale registration is a defect;
66    /// `Ephemeral` fails it — a branch is being worked on where it does not belong — while
67    /// the topology as a whole stays valid, because the harness that made the checkout
68    /// removes it.
69    pub fn is_acceptable(self) -> bool {
70        !matches!(
71            self,
72            Standing::Misplaced | Standing::Missing | Standing::Ephemeral
73        )
74    }
75}
76
77/// How serious a diagnostic is: the executable's one severity vocabulary, reused rather than
78/// redefined. `Error` means the topology is invalid while it stands and the guard refuses;
79/// `Warning` is worth acting on; `Info` is a fact worth showing.
80pub use crate::model::Severity;
81
82/// The stable machine name of everything that can be wrong with the topology. One code per
83/// condition, reused by the command line, the API, MCP, the Cockpit, the tests and the
84/// documentation; the prose beside a code is rendered from the typed state.
85#[derive(
86    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
87)]
88pub enum DiagnosticCode {
89    /// A linked work tree is not at its branch's canonical path.
90    #[serde(rename = "worktree.path_mismatch")]
91    PathMismatch,
92    /// A linked work tree occupies the container path itself.
93    #[serde(rename = "worktree.container_occupied")]
94    ContainerOccupied,
95    /// A linked work tree sits inside the primary checkout or inside another work tree.
96    #[serde(rename = "worktree.nested")]
97    Nested,
98    /// The canonical path of a branch is occupied by something that is not its work tree.
99    #[serde(rename = "worktree.destination_conflict")]
100    DestinationConflict,
101    /// A registered work tree's directory does not exist.
102    #[serde(rename = "worktree.missing")]
103    Missing,
104    /// Git reports the registration as prunable.
105    #[serde(rename = "worktree.stale_registration")]
106    StaleRegistration,
107    /// A branch is checked out somewhere other than its canonical path.
108    #[serde(rename = "worktree.branch_already_checked_out")]
109    BranchAlreadyCheckedOut,
110    /// A linked work tree has no branch.
111    #[serde(rename = "worktree.detached")]
112    Detached,
113    /// A linked work tree is a session's scratch checkout: under the temporary directory or
114    /// under the primary checkout's `.claude/worktrees/`.
115    #[serde(rename = "worktree.ephemeral")]
116    Ephemeral,
117    /// The primary checkout holds a branch that is not the trunk.
118    #[serde(rename = "worktree.primary_on_non_trunk")]
119    PrimaryOnNonTrunk,
120    /// The trunk is checked out in a linked work tree rather than the primary checkout.
121    #[serde(rename = "worktree.trunk_in_linked_worktree")]
122    TrunkInLinkedWorktree,
123    /// A derived path would leave the container. Cannot happen for a valid branch name.
124    #[serde(rename = "worktree.path_escape")]
125    PathEscape,
126    /// A branch name git accepted that this executable cannot derive a path for.
127    #[serde(rename = "worktree.invalid_branch_name")]
128    InvalidBranchName,
129    /// A work tree is locked, so it cannot be moved until it is unlocked.
130    #[serde(rename = "worktree.locked")]
131    Locked,
132    /// A move happened and the fingerprint after it differs from the one before.
133    #[serde(rename = "worktree.migration_verification_failed")]
134    MigrationVerificationFailed,
135    /// The trunk could not be determined.
136    #[serde(rename = "worktree.trunk_unknown")]
137    TrunkUnknown,
138    /// Two branch names map to one directory on a case-insensitive filesystem.
139    #[serde(rename = "worktree.case_collision")]
140    CaseCollision,
141    /// A move crossed devices and was made by copy, repair and verification.
142    #[serde(rename = "worktree.cross_device")]
143    CrossDevice,
144}
145
146impl DiagnosticCode {
147    /// The dotted code, as serialised.
148    ///
149    /// ```
150    /// use majordomus_cli::worktree::DiagnosticCode;
151    /// assert_eq!(DiagnosticCode::PathMismatch.as_str(), "worktree.path_mismatch");
152    /// ```
153    pub fn as_str(self) -> &'static str {
154        match self {
155            DiagnosticCode::PathMismatch => "worktree.path_mismatch",
156            DiagnosticCode::ContainerOccupied => "worktree.container_occupied",
157            DiagnosticCode::Nested => "worktree.nested",
158            DiagnosticCode::DestinationConflict => "worktree.destination_conflict",
159            DiagnosticCode::Missing => "worktree.missing",
160            DiagnosticCode::StaleRegistration => "worktree.stale_registration",
161            DiagnosticCode::BranchAlreadyCheckedOut => "worktree.branch_already_checked_out",
162            DiagnosticCode::Detached => "worktree.detached",
163            DiagnosticCode::Ephemeral => "worktree.ephemeral",
164            DiagnosticCode::PrimaryOnNonTrunk => "worktree.primary_on_non_trunk",
165            DiagnosticCode::TrunkInLinkedWorktree => "worktree.trunk_in_linked_worktree",
166            DiagnosticCode::PathEscape => "worktree.path_escape",
167            DiagnosticCode::InvalidBranchName => "worktree.invalid_branch_name",
168            DiagnosticCode::Locked => "worktree.locked",
169            DiagnosticCode::MigrationVerificationFailed => "worktree.migration_verification_failed",
170            DiagnosticCode::TrunkUnknown => "worktree.trunk_unknown",
171            DiagnosticCode::CaseCollision => "worktree.case_collision",
172            DiagnosticCode::CrossDevice => "worktree.cross_device",
173        }
174    }
175
176    /// Every code, for the documentation and the tests that hold it complete.
177    pub const ALL: &'static [DiagnosticCode] = &[
178        DiagnosticCode::PathMismatch,
179        DiagnosticCode::ContainerOccupied,
180        DiagnosticCode::Nested,
181        DiagnosticCode::DestinationConflict,
182        DiagnosticCode::Missing,
183        DiagnosticCode::StaleRegistration,
184        DiagnosticCode::BranchAlreadyCheckedOut,
185        DiagnosticCode::Detached,
186        DiagnosticCode::Ephemeral,
187        DiagnosticCode::PrimaryOnNonTrunk,
188        DiagnosticCode::TrunkInLinkedWorktree,
189        DiagnosticCode::PathEscape,
190        DiagnosticCode::InvalidBranchName,
191        DiagnosticCode::Locked,
192        DiagnosticCode::MigrationVerificationFailed,
193        DiagnosticCode::TrunkUnknown,
194        DiagnosticCode::CaseCollision,
195        DiagnosticCode::CrossDevice,
196    ];
197}
198
199/// One thing wrong with, or worth knowing about, the topology.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
201pub struct TopologyDiagnostic {
202    /// The stable code.
203    pub code: DiagnosticCode,
204    /// How serious it is.
205    pub severity: Severity,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    /// The work tree involved.
208    pub path: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    /// The branch involved.
211    pub branch: Option<String>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    /// Where the work tree belongs.
214    pub expected: Option<String>,
215    /// What is wrong, in one line.
216    pub message: String,
217    /// The command that addresses it.
218    pub remedy: String,
219}
220
221/// Uncommitted work in a work tree, counted from `git status --porcelain`. Untracked
222/// content counts: it is exactly what a careless move loses.
223#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
224pub struct DirtyState {
225    /// Entries with a change in the index.
226    pub staged: usize,
227    /// Entries with a change in the work tree that is not in the index.
228    pub unstaged: usize,
229    /// Untracked, not ignored, files.
230    pub untracked: usize,
231    /// Entries with an unresolved merge conflict.
232    pub conflicted: usize,
233    /// Nothing above is non-zero.
234    pub clean: bool,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    /// A merge, rebase, cherry-pick, revert or bisect is in progress here.
237    pub in_progress: Option<String>,
238}
239
240impl DirtyState {
241    /// Every count, in words, for a message.
242    pub fn summary(&self) -> String {
243        if self.clean {
244            return "clean".into();
245        }
246        let mut parts = Vec::new();
247        if self.staged > 0 {
248            parts.push(format!("{} staged", self.staged));
249        }
250        if self.unstaged > 0 {
251            parts.push(format!("{} unstaged", self.unstaged));
252        }
253        if self.untracked > 0 {
254            parts.push(format!("{} untracked", self.untracked));
255        }
256        if self.conflicted > 0 {
257            parts.push(format!("{} conflicted", self.conflicted));
258        }
259        if let Some(op) = &self.in_progress {
260            parts.push(format!("{op} in progress"));
261        }
262        parts.join(", ")
263    }
264}
265
266/// A branch's upstream and how far the two have moved apart, from `for-each-ref` in one
267/// subprocess for every branch, never a fetch.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
269pub struct UpstreamState {
270    /// The upstream ref, short (`origin/master`).
271    pub name: String,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    /// Commits here that the upstream lacks.
274    pub ahead: Option<usize>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    /// Commits upstream that this branch lacks.
277    pub behind: Option<usize>,
278    /// The upstream ref no longer exists.
279    pub gone: bool,
280}
281
282/// One work tree, as the topology sees it.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
284pub struct WorktreeState {
285    /// Absolute, as git holds it.
286    pub path: String,
287    /// Primary or linked.
288    pub kind: WorktreeKind,
289    /// Where it stands against the topology.
290    pub standing: Standing,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    /// The branch checked out here, short. Absent when detached.
293    pub branch: Option<String>,
294    /// The name it is shown under: the branch, or `detached/<short commit>`.
295    pub label: String,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    /// The commit checked out here.
298    pub head: Option<String>,
299    /// HEAD is detached here.
300    pub detached: bool,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    /// Where this branch's work tree belongs. Absent when detached, or for the primary
303    /// checkout on the trunk.
304    pub expected_path: Option<String>,
305    /// The directory git registered still exists on disk.
306    pub exists: bool,
307    /// The call came from inside this work tree.
308    pub current: bool,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    /// Locked, with git's reason; an empty string when it recorded none.
311    pub locked: Option<String>,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    /// Prunable, with git's reason.
314    pub prunable: Option<String>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    /// Uncommitted work here. Absent when it was not asked for: it costs one subprocess per
317    /// work tree, and a topology check does not need it.
318    pub dirty: Option<DirtyState>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    /// The branch's upstream and its distance from it.
321    pub upstream: Option<UpstreamState>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    /// The issue this branch provably names: a path component of the branch equal to an
324    /// issue id of `.ai/repo/project/issues/`, or beginning with it and a hyphen. Nothing
325    /// is inferred from similarity.
326    pub issue: Option<String>,
327    /// What is wrong with this work tree, if anything.
328    pub diagnostics: Vec<TopologyDiagnostic>,
329}
330
331/// A branch state is the branch it describes, ordered by the same name, so the topology
332/// listing and the trace listing read in the same sequence.
333impl crate::order::Ordered for BranchState {
334    fn order_key(&self) -> crate::order::OrderKey<'_> {
335        crate::order::OrderKey::plain(&self.name, &self.name)
336    }
337}
338
339/// One local branch, with or without a work tree.
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
341pub struct BranchState {
342    /// The branch, short.
343    pub name: String,
344    /// The commit it points at.
345    pub head: String,
346    /// This branch is the trunk.
347    pub trunk: bool,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    /// Where its work tree belongs.
350    pub expected_path: Option<String>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    /// Where it is checked out, when it is.
353    pub worktree: Option<String>,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    /// The upstream and its distance.
356    pub upstream: Option<UpstreamState>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    /// Reachable from the trunk. Absent when the trunk is unknown.
359    pub merged_into_trunk: Option<bool>,
360    /// Merged into the trunk, not the trunk, and either not checked out or checked out in a
361    /// work tree known to be clean. Derived state, never acted on automatically.
362    pub cleanup_eligible: bool,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    /// The issue this branch provably names.
365    pub issue: Option<String>,
366}
367
368/// The repository's identity, as the topology reports it.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
370pub struct RepositoryView {
371    /// The primary checkout: what the container is named after.
372    pub primary_worktree: String,
373    /// The common git directory: the repository's identity.
374    pub git_common_dir: String,
375    /// The primary checkout's directory name.
376    pub name: String,
377}
378
379/// The container, as the topology reports it.
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
381pub struct ContainerView {
382    /// The derived path.
383    pub path: String,
384    /// The suffix it was derived with.
385    pub suffix: String,
386    /// It exists on disk. Created by the first worktree that needs it.
387    pub exists: bool,
388}
389
390/// The trunk, as the topology reports it.
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
392pub struct TrunkView {
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    /// The branch, when known.
395    pub branch: Option<String>,
396    /// How it was decided.
397    pub source: super::identity::TrunkSource,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    /// Where it is checked out.
400    pub checked_out_at: Option<String>,
401}
402
403/// How many work trees and branches are in each state.
404#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
405pub struct TopologyTallies {
406    /// Every registered work tree, primary included.
407    pub worktrees: usize,
408    /// Linked work trees at their canonical path.
409    pub canonical: usize,
410    /// Linked work trees somewhere else.
411    pub misplaced: usize,
412    /// Linked work trees without a branch.
413    pub detached: usize,
414    /// Scratch checkouts of a session.
415    pub ephemeral: usize,
416    /// Registrations whose directory is gone.
417    pub missing: usize,
418    /// Work trees git reports as locked.
419    pub locked: usize,
420    /// Work trees with uncommitted work, when it was asked for.
421    pub dirty: usize,
422    /// Local branches.
423    pub branches: usize,
424    /// Local branches with no work tree.
425    pub branches_without_worktree: usize,
426    /// Branches eligible for cleanup.
427    pub cleanup_eligible: usize,
428    /// Error-level diagnostics.
429    pub errors: usize,
430    /// Warning-level diagnostics.
431    pub warnings: usize,
432}
433
434/// The whole topology: what `worktree list`, the MCP resource, the HTTP route and the
435/// Cockpit all read.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
437pub struct RepositoryTopology {
438    /// [`SCHEMA`].
439    pub schema: String,
440    /// The repository.
441    pub repository: RepositoryView,
442    /// The container every linked work tree belongs under.
443    pub container: ContainerView,
444    /// The trunk.
445    pub trunk: TrunkView,
446    /// The work tree the call was answered from.
447    pub observed_from: String,
448    /// Every registered work tree, primary first.
449    pub worktrees: Vec<WorktreeState>,
450    /// Every local branch, by name.
451    pub branches: Vec<BranchState>,
452    /// Everything wrong with the topology, work trees first, then repository-wide.
453    pub diagnostics: Vec<TopologyDiagnostic>,
454    /// The counts.
455    pub tallies: TopologyTallies,
456    /// No error-level diagnostic stands.
457    pub valid: bool,
458}
459
460/// The answer to "where am I, and is that where I belong".
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
462pub struct StatusReport {
463    /// [`SCHEMA`].
464    pub schema: String,
465    /// The repository.
466    pub repository: RepositoryView,
467    /// The container.
468    pub container: ContainerView,
469    /// The trunk.
470    pub trunk: TrunkView,
471    /// The work tree the call came from, with its uncommitted work counted.
472    pub worktree: WorktreeState,
473    /// Whether this work tree is where it belongs: canonical, or the primary checkout on
474    /// the trunk, or detached.
475    pub canonical: bool,
476    /// How many error-level diagnostics the whole topology carries.
477    pub repository_errors: usize,
478}
479
480/// What the guard decided, for a hook or a mutation that must not proceed out of place.
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
482pub struct GuardVerdict {
483    /// Proceed.
484    pub ok: bool,
485    /// The work tree judged.
486    pub path: String,
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    /// Its branch.
489    pub branch: Option<String>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    /// Where the branch belongs.
492    pub expected_path: Option<String>,
493    /// Where it stands.
494    pub standing: Standing,
495    /// Not subject to the rule: detached, or the trunk in the primary checkout.
496    pub exempt: bool,
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    /// Why not, when not.
499    pub reason: Option<TopologyDiagnostic>,
500}
501
502/// One branch, inspected: where its work tree belongs and what is there.
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
504pub struct InspectReport {
505    /// The branch asked about.
506    pub branch: String,
507    /// The branch exists locally.
508    pub branch_exists: bool,
509    /// Where its work tree belongs.
510    pub expected_path: String,
511    /// Something exists at that path.
512    pub destination_exists: bool,
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    /// The work tree that holds the branch, when one does.
515    pub worktree: Option<WorktreeState>,
516    /// The branch is checked out at its canonical path.
517    pub canonical: bool,
518    /// What stands in the way, if anything.
519    pub diagnostics: Vec<TopologyDiagnostic>,
520}
521
522/// What a repair did.
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
524pub struct RepairReport {
525    /// Registrations git dropped, or would drop, because their directory is gone.
526    pub pruned: Vec<String>,
527    /// What `git worktree repair` reported.
528    pub repaired: Vec<String>,
529    /// Which hooks each existing linked worktree runs once repaired, by its path: a
530    /// worktree moved by hand keeps a per-worktree `core.hooksPath` naming its old path,
531    /// and one made by plain `git worktree add` may run the primary checkout's (I2718).
532    /// Empty on a dry run.
533    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
534    pub hooks: std::collections::BTreeMap<String, super::hooks::HooksPath>,
535    /// Whether anything was changed.
536    pub applied: bool,
537}