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