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}