Skip to main content

wt/
model.rs

1//! Domain model: the worktree row and its JSON schema (spec §7), plus the
2//! sort and column enums used by `list`/`status`.
3//!
4//! [`Worktree`] serializes to exactly the stable schema documented in §7. The
5//! `Option` fields encode the spec's null semantics: `ahead`/`behind` are
6//! `None` (→ JSON `null`) when there is no upstream; the working-tree fields and
7//! `commit` are `None` for a missing worktree; `branch`/`slug` are `None` for a
8//! detached HEAD. `None` serializes as `null` (the fields are never omitted).
9
10use std::path::PathBuf;
11
12use serde::Serialize;
13
14use crate::error::{Error, Result};
15
16/// The current `--json` schema version (spec §7/§13). Bumped only on a breaking
17/// change so consumers can detect incompatibility.
18pub const SCHEMA_VERSION: u32 = 1;
19
20/// One worktree row — the stable §7 JSON schema shared by `list`, `status`, and
21/// the `new`/`pr`/`remove` result objects.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct Worktree {
24    /// Schema version (always [`SCHEMA_VERSION`]).
25    pub schema_version: u32,
26    /// Absolute path of the worktree.
27    pub path: PathBuf,
28    /// Full branch name, or `None` for a detached HEAD.
29    pub branch: Option<String>,
30    /// Filesystem-safe slug of the branch, or `None` when detached.
31    pub slug: Option<String>,
32    /// Whether this is the current worktree.
33    pub is_current: bool,
34    /// Whether this is the primary worktree.
35    pub is_main: bool,
36    /// Whether the worktree's directory has been deleted externally.
37    pub is_missing: bool,
38    /// Whether the worktree has a detached HEAD.
39    pub is_detached: bool,
40    /// Whether tracked files are modified/staged; `None` when missing.
41    pub dirty: Option<bool>,
42    /// Whether untracked files are present; `None` when missing.
43    pub has_untracked: Option<bool>,
44    /// Commits ahead of upstream; `None` when no upstream or missing.
45    pub ahead: Option<u32>,
46    /// Commits behind upstream; `None` when no upstream or missing.
47    pub behind: Option<u32>,
48    /// Upstream tracking branch (e.g. `origin/feature/login`); `None` if unset.
49    pub upstream: Option<String>,
50    /// Base ref recorded at creation; `None` if unset.
51    pub base_ref: Option<String>,
52    /// Tip commit metadata; `None` when missing.
53    pub commit: Option<Commit>,
54    /// Recorded pull request; `None` when none.
55    pub pr: Option<Pr>,
56    /// Whether a checked-out worktree exists for this row. `false` marks a
57    /// "branch row": a local branch with no worktree, listed beneath the real
58    /// worktrees with its ahead/behind relative to its base (issue #47). Not part
59    /// of the §7 JSON schema (where every row is a real worktree), so it is skipped
60    /// during serialization. Branch rows are normally TUI-only, but `wt sync
61    /// <branch>` of a worktree-less branch emits one in `--json`; such a row's
62    /// `path` is the `branch://<branch>` sentinel rather than a filesystem path,
63    /// since no checkout exists.
64    #[serde(skip)]
65    pub has_worktree: bool,
66    /// Up to the last five commits, for the TUI detail pane only. Not part of
67    /// the §7 JSON schema (which carries only the tip `commit`), so it is skipped
68    /// during serialization.
69    #[serde(skip)]
70    pub recent_commits: Vec<Commit>,
71    /// The recorded PR URL, for the TUI detail pane only. Not part of the §7
72    /// `pr` object, so it is skipped during serialization.
73    #[serde(skip)]
74    pub pr_url: Option<String>,
75    /// Offline merge/tracking state, for delete-safety messaging in the TUI
76    /// only. `None` until enrichment runs (and for a missing worktree, where it
77    /// cannot be computed). Not part of the §7 JSON schema, so it is skipped
78    /// during serialization.
79    #[serde(skip)]
80    pub merge_state: Option<MergeState>,
81}
82
83impl Worktree {
84    /// Builds a worktree row with the given absolute path and all other fields
85    /// at their defaults (no branch, all flags false, all optionals `None`, and
86    /// `has_worktree` true — a real checkout). Callers populate the remaining
87    /// fields.
88    pub fn new(path: PathBuf) -> Self {
89        Worktree {
90            schema_version: SCHEMA_VERSION,
91            path,
92            branch: None,
93            slug: None,
94            is_current: false,
95            is_main: false,
96            is_missing: false,
97            is_detached: false,
98            dirty: None,
99            has_untracked: None,
100            ahead: None,
101            behind: None,
102            upstream: None,
103            base_ref: None,
104            commit: None,
105            pr: None,
106            has_worktree: true,
107            recent_commits: Vec::new(),
108            pr_url: None,
109            merge_state: None,
110        }
111    }
112
113    /// Serializes this row to a single-line JSON string (no trailing newline),
114    /// for the newline-delimited `--json` framing of `list`/`status`.
115    pub fn to_json_line(&self) -> Result<String> {
116        Ok(serde_json::to_string(self)?)
117    }
118}
119
120/// How a branch's commits relate to the rest of the repo, for delete-safety
121/// messaging in the TUI. Computed offline (no fetch): from ancestry against the
122/// base/default branch, a recorded merged PR, and whether the configured
123/// upstream's tracking ref is gone.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum MergeState {
126    /// Fully merged, so deletion is safe. `into` names the ref it merged into
127    /// (e.g. `main`); `None` means only a merged PR proves it (a squash/rebase
128    /// merge, whose commit hash differs so ancestry cannot confirm it).
129    Merged {
130        /// The ref the branch merged into, or `None` when only a merged PR
131        /// proves the merge.
132        into: Option<String>,
133    },
134    /// An upstream was configured but its remote-tracking ref is gone and the
135    /// merge could not be confirmed — most likely merged with the remote branch
136    /// auto-deleted afterwards.
137    UpstreamGone,
138    /// No upstream was ever configured and the branch is not merged: genuinely
139    /// local-only work that would be lost on deletion.
140    NoUpstreamLocal,
141    /// A live upstream exists; the ahead/behind counts carry the detail.
142    Tracked,
143}
144
145/// Tip-commit metadata for display (spec §7).
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct Commit {
148    /// Short commit hash (honoring `core.abbrev`).
149    pub hash: String,
150    /// Commit subject (first line of the message).
151    pub subject: String,
152    /// Author name.
153    pub author: String,
154    /// Author timestamp as an ISO-8601 UTC string (e.g. `2024-01-15T10:30:00Z`).
155    pub timestamp: String,
156}
157
158/// A recorded pull request (spec §7).
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
160pub struct Pr {
161    /// PR number.
162    pub number: u64,
163    /// PR state.
164    pub state: PrState,
165    /// PR title.
166    pub title: String,
167}
168
169/// Pull-request state, mirroring `gh` (spec §7).
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
171#[serde(rename_all = "lowercase")]
172pub enum PrState {
173    /// An open PR.
174    Open,
175    /// A closed (unmerged) PR.
176    Closed,
177    /// A merged PR.
178    Merged,
179    /// A draft PR.
180    Draft,
181}
182
183impl PrState {
184    /// The lowercase string form (matches the JSON serialization).
185    pub fn as_str(self) -> &'static str {
186        match self {
187            PrState::Open => "open",
188            PrState::Closed => "closed",
189            PrState::Merged => "merged",
190            PrState::Draft => "draft",
191        }
192    }
193
194    /// Parses a lowercase state string, or `None` if unknown.
195    pub fn parse(s: &str) -> Option<PrState> {
196        Some(match s {
197            "open" => PrState::Open,
198            "closed" => PrState::Closed,
199            "merged" => PrState::Merged,
200            "draft" => PrState::Draft,
201            _ => return None,
202        })
203    }
204}
205
206/// The `remove` result object: the worktree row plus a `removed` flag.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
208pub struct RemovedResult {
209    /// The removed worktree's row, flattened into this object.
210    #[serde(flatten)]
211    pub worktree: Worktree,
212    /// Always `true` (the worktree was removed).
213    pub removed: bool,
214}
215
216/// A field to sort `wt list` by (spec §7 `--sort`).
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SortKey {
219    /// Sort by branch name (the default).
220    Branch,
221    /// Modified/staged first, then untracked-only, then clean.
222    Dirty,
223    /// Sort by ahead count.
224    Ahead,
225    /// Sort by behind count.
226    Behind,
227    /// Most-recent commit first.
228    Activity,
229    /// Sort by path.
230    Path,
231}
232
233impl SortKey {
234    /// Parses a sort field name, or `None` if unknown.
235    pub fn parse(name: &str) -> Option<SortKey> {
236        Some(match name {
237            "branch" => SortKey::Branch,
238            "dirty" => SortKey::Dirty,
239            "ahead" => SortKey::Ahead,
240            "behind" => SortKey::Behind,
241            "activity" => SortKey::Activity,
242            "path" => SortKey::Path,
243            _ => return None,
244        })
245    }
246}
247
248/// A sort field plus direction (spec §7; a `-` prefix means descending).
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub struct SortSpec {
251    /// The field to sort by.
252    pub key: SortKey,
253    /// Whether to sort in descending order.
254    pub descending: bool,
255}
256
257impl Default for SortSpec {
258    fn default() -> Self {
259        SortSpec {
260            key: SortKey::Branch,
261            descending: false,
262        }
263    }
264}
265
266impl SortSpec {
267    /// Parses a `--sort` argument such as `branch`, `ahead`, or `-ahead`.
268    pub fn parse(value: &str) -> Result<SortSpec> {
269        let (descending, name) = match value.strip_prefix('-') {
270            Some(rest) => (true, rest),
271            None => (false, value),
272        };
273        let key = SortKey::parse(name)
274            .ok_or_else(|| Error::usage(format!("unknown sort field: {name:?}")))?;
275        Ok(SortSpec { key, descending })
276    }
277}
278
279/// A `wt list` display column (spec §11 `list.columns`).
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum Column {
282    /// Status marker (`*`/`!`/`~`/space).
283    Status,
284    /// Dirty marker (`M`/`?`).
285    Dirty,
286    /// Branch name.
287    Branch,
288    /// Path relative to the repo root.
289    Path,
290    /// Ahead/behind counts.
291    AheadBehind,
292    /// Commit summary.
293    Commit,
294    /// PR number and state.
295    Pr,
296}
297
298impl Column {
299    /// The full, ordered set of columns (the default `list.columns`).
300    pub const ALL: [Column; 7] = [
301        Column::Status,
302        Column::Dirty,
303        Column::Branch,
304        Column::Path,
305        Column::AheadBehind,
306        Column::Commit,
307        Column::Pr,
308    ];
309
310    /// Parses a column identifier, or `None` if unknown.
311    pub fn parse(identifier: &str) -> Option<Column> {
312        Some(match identifier {
313            "status" => Column::Status,
314            "dirty" => Column::Dirty,
315            "branch" => Column::Branch,
316            "path" => Column::Path,
317            "ahead-behind" => Column::AheadBehind,
318            "commit" => Column::Commit,
319            "pr" => Column::Pr,
320            _ => return None,
321        })
322    }
323
324    /// The identifier string for this column.
325    pub fn identifier(self) -> &'static str {
326        match self {
327            Column::Status => "status",
328            Column::Dirty => "dirty",
329            Column::Branch => "branch",
330            Column::Path => "path",
331            Column::AheadBehind => "ahead-behind",
332            Column::Commit => "commit",
333            Column::Pr => "pr",
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    /// The exact §7 schema example.
343    const SPEC_EXAMPLE: &str = r#"{
344        "schema_version": 1,
345        "path": "/absolute/path",
346        "branch": "feature/login",
347        "slug": "feature-login",
348        "is_current": true,
349        "is_main": false,
350        "is_missing": false,
351        "is_detached": false,
352        "dirty": true,
353        "has_untracked": false,
354        "ahead": 2,
355        "behind": 0,
356        "upstream": "origin/feature/login",
357        "base_ref": "main",
358        "commit": {
359            "hash": "abc1234",
360            "subject": "Add login page",
361            "author": "Alice",
362            "timestamp": "2024-01-15T10:30:00Z"
363        },
364        "pr": { "number": 42, "state": "open", "title": "Add login page" }
365    }"#;
366
367    fn spec_example_worktree() -> Worktree {
368        Worktree {
369            schema_version: 1,
370            path: PathBuf::from("/absolute/path"),
371            branch: Some("feature/login".into()),
372            slug: Some("feature-login".into()),
373            is_current: true,
374            is_main: false,
375            is_missing: false,
376            is_detached: false,
377            dirty: Some(true),
378            has_untracked: Some(false),
379            ahead: Some(2),
380            behind: Some(0),
381            upstream: Some("origin/feature/login".into()),
382            base_ref: Some("main".into()),
383            commit: Some(Commit {
384                hash: "abc1234".into(),
385                subject: "Add login page".into(),
386                author: "Alice".into(),
387                timestamp: "2024-01-15T10:30:00Z".into(),
388            }),
389            pr: Some(Pr {
390                number: 42,
391                state: PrState::Open,
392                title: "Add login page".into(),
393            }),
394            has_worktree: true,
395            recent_commits: Vec::new(),
396            pr_url: None,
397            merge_state: None,
398        }
399    }
400
401    #[test]
402    fn serializes_to_spec_schema() {
403        let got: serde_json::Value = serde_json::to_value(spec_example_worktree()).unwrap();
404        let want: serde_json::Value = serde_json::from_str(SPEC_EXAMPLE).unwrap();
405        assert_eq!(got, want);
406    }
407
408    #[test]
409    fn behind_zero_is_not_null() {
410        let v = serde_json::to_value(spec_example_worktree()).unwrap();
411        assert_eq!(v["behind"], serde_json::json!(0));
412        assert!(!v["behind"].is_null());
413    }
414
415    #[test]
416    fn missing_worktree_nulls_working_tree_fields() {
417        let mut wt = Worktree::new(PathBuf::from("/gone"));
418        wt.branch = Some("feature/x".into());
419        wt.slug = Some("feature-x".into());
420        wt.is_missing = true;
421        wt.base_ref = Some("main".into());
422        let v = serde_json::to_value(&wt).unwrap();
423        assert!(v["dirty"].is_null());
424        assert!(v["has_untracked"].is_null());
425        assert!(v["ahead"].is_null());
426        assert!(v["behind"].is_null());
427        assert!(v["commit"].is_null());
428        // Admin-derived fields remain populated.
429        assert_eq!(v["branch"], serde_json::json!("feature/x"));
430        assert_eq!(v["base_ref"], serde_json::json!("main"));
431        assert_eq!(v["is_missing"], serde_json::json!(true));
432    }
433
434    #[test]
435    fn has_worktree_defaults_true_and_is_not_serialized() {
436        // A fresh row is a real worktree, and the TUI-only flag never leaks into
437        // the stable §7 JSON schema (issue #47).
438        let wt = Worktree::new(PathBuf::from("/r"));
439        assert!(wt.has_worktree);
440        let v = serde_json::to_value(&wt).unwrap();
441        assert!(v.get("has_worktree").is_none());
442    }
443
444    #[test]
445    fn detached_head_has_null_branch() {
446        let mut wt = Worktree::new(PathBuf::from("/d"));
447        wt.is_detached = true;
448        let v = serde_json::to_value(&wt).unwrap();
449        assert!(v["branch"].is_null());
450        assert!(v["slug"].is_null());
451        assert_eq!(v["is_detached"], serde_json::json!(true));
452    }
453
454    #[test]
455    fn no_upstream_nulls_ahead_behind() {
456        let mut wt = Worktree::new(PathBuf::from("/n"));
457        wt.branch = Some("topic".into());
458        let v = serde_json::to_value(&wt).unwrap();
459        assert!(v["ahead"].is_null());
460        assert!(v["behind"].is_null());
461        assert!(v["upstream"].is_null());
462        assert!(v["pr"].is_null());
463    }
464
465    #[test]
466    fn pr_states_serialize_lowercase() {
467        for (state, text) in [
468            (PrState::Open, "open"),
469            (PrState::Closed, "closed"),
470            (PrState::Merged, "merged"),
471            (PrState::Draft, "draft"),
472        ] {
473            assert_eq!(
474                serde_json::to_value(state).unwrap(),
475                serde_json::json!(text)
476            );
477            assert_eq!(state.as_str(), text);
478            assert_eq!(PrState::parse(text), Some(state));
479        }
480        assert_eq!(PrState::parse("bogus"), None);
481    }
482
483    #[test]
484    fn json_line_is_single_line() {
485        let line = spec_example_worktree().to_json_line().unwrap();
486        assert!(!line.contains('\n'));
487        assert!(line.starts_with('{') && line.ends_with('}'));
488    }
489
490    #[test]
491    fn removed_result_flattens_worktree_plus_flag() {
492        let result = RemovedResult {
493            worktree: Worktree::new(PathBuf::from("/x")),
494            removed: true,
495        };
496        let v = serde_json::to_value(&result).unwrap();
497        assert_eq!(v["removed"], serde_json::json!(true));
498        assert_eq!(v["path"], serde_json::json!("/x"));
499        assert_eq!(v["schema_version"], serde_json::json!(1));
500    }
501
502    #[test]
503    fn sort_spec_parsing() {
504        assert_eq!(SortSpec::default().key, SortKey::Branch);
505        assert!(!SortSpec::default().descending);
506        assert_eq!(
507            SortSpec::parse("ahead").unwrap(),
508            SortSpec {
509                key: SortKey::Ahead,
510                descending: false
511            }
512        );
513        let desc = SortSpec::parse("-activity").unwrap();
514        assert_eq!(desc.key, SortKey::Activity);
515        assert!(desc.descending);
516        for f in ["branch", "dirty", "ahead", "behind", "activity", "path"] {
517            assert!(SortSpec::parse(f).is_ok());
518        }
519        let err = SortSpec::parse("bogus").unwrap_err();
520        assert_eq!(err.exit_code(), 2);
521    }
522
523    #[test]
524    fn column_parse_roundtrip() {
525        for col in Column::ALL {
526            assert_eq!(Column::parse(col.identifier()), Some(col));
527        }
528        assert_eq!(Column::parse("bogus"), None);
529        assert_eq!(Column::ALL.len(), 7);
530    }
531}