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