Skip to main content

wt/config/
wtconfig.rs

1//! Per-worktree metadata stored in Git config under the `wt.*` namespace (spec
2//! §3/§7/§11): the base ref, originating PR number, and a "created by wt" flag.
3//!
4//! Metadata is keyed by branch (`[wt "<branch>"]`), so it is shared across the
5//! repo yet unambiguous per worktree. Reads use `gix`; writes use `git config`
6//! (a sanctioned §4 fallback — `gix`'s config file-writing is not yet stable).
7
8use std::path::Path;
9
10use crate::error::Result;
11use crate::git::cli::GitCli;
12
13/// Per-worktree metadata recorded by `wt`.
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct WtMeta {
16    /// Base ref the branch was created from (§3).
17    pub base_ref: Option<String>,
18    /// Originating PR number, for PR-checkout worktrees (§7).
19    pub pr_number: Option<u64>,
20    /// Cached PR state, so `wt list` can show it offline (§3).
21    pub pr_state: Option<String>,
22    /// Cached PR title.
23    pub pr_title: Option<String>,
24    /// Cached PR URL, for the TUI detail pane (§10).
25    pub pr_url: Option<String>,
26    /// Whether the branch/worktree was created by `wt` (§10).
27    pub created_by_wt: bool,
28}
29
30/// The config key for `wt.<branch>.<name>`.
31fn key(branch: &str, name: &str) -> String {
32    format!("wt.{branch}.{name}")
33}
34
35/// Reads the `wt.*` metadata for `branch` via `gix`.
36pub fn read_meta(repo: &gix::Repository, branch: &str) -> WtMeta {
37    let config = repo.config_snapshot();
38    let base_ref = config
39        .string(key(branch, "baseRef").as_str())
40        .map(|v| v.to_string());
41    let pr_number = config
42        .string(key(branch, "prNumber").as_str())
43        .and_then(|v| v.to_string().parse::<u64>().ok());
44    let pr_state = config
45        .string(key(branch, "prState").as_str())
46        .map(|v| v.to_string());
47    let pr_title = config
48        .string(key(branch, "prTitle").as_str())
49        .map(|v| v.to_string());
50    let pr_url = config
51        .string(key(branch, "prUrl").as_str())
52        .map(|v| v.to_string());
53    let created_by_wt = config
54        .boolean(key(branch, "createdByWt").as_str())
55        .unwrap_or(false);
56    WtMeta {
57        base_ref,
58        pr_number,
59        pr_state,
60        pr_title,
61        pr_url,
62        created_by_wt,
63    }
64}
65
66/// Records the full cached PR snapshot (number, state, title) for `branch`.
67pub fn write_pr(
68    git: &dyn GitCli,
69    repo_root: &Path,
70    branch: &str,
71    number: u64,
72    state: &str,
73    title: &str,
74) -> Result<()> {
75    write_pr_number(git, repo_root, branch, number)?;
76    git.run(repo_root, &["config", &key(branch, "prState"), state])?;
77    git.run(repo_root, &["config", &key(branch, "prTitle"), title])?;
78    Ok(())
79}
80
81/// Records the PR URL for `branch` (shown in the TUI detail pane).
82pub fn write_pr_url(git: &dyn GitCli, repo_root: &Path, branch: &str, url: &str) -> Result<()> {
83    git.run(repo_root, &["config", &key(branch, "prUrl"), url])?;
84    Ok(())
85}
86
87/// Records the base ref for `branch`.
88pub fn write_base_ref(
89    git: &dyn GitCli,
90    repo_root: &Path,
91    branch: &str,
92    base_ref: &str,
93) -> Result<()> {
94    git.run(repo_root, &["config", &key(branch, "baseRef"), base_ref])?;
95    Ok(())
96}
97
98/// Records the originating PR number for `branch`.
99pub fn write_pr_number(
100    git: &dyn GitCli,
101    repo_root: &Path,
102    branch: &str,
103    number: u64,
104) -> Result<()> {
105    git.run(
106        repo_root,
107        &["config", &key(branch, "prNumber"), &number.to_string()],
108    )?;
109    Ok(())
110}
111
112/// Marks `branch` as created by `wt`.
113pub fn mark_created_by_wt(git: &dyn GitCli, repo_root: &Path, branch: &str) -> Result<()> {
114    git.run(repo_root, &["config", &key(branch, "createdByWt"), "true"])?;
115    Ok(())
116}
117
118/// Removes all `wt.*` metadata for `branch` (e.g. after removing its worktree).
119/// A missing section is not an error.
120pub fn clear_meta(git: &dyn GitCli, repo_root: &Path, branch: &str) -> Result<()> {
121    let section = format!("wt.{branch}");
122    // `--remove-section` exits non-zero if the section is absent; ignore that.
123    git.run_raw(repo_root, &["config", "--remove-section", &section])?;
124    Ok(())
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::git::cli::RealGit;
131    use crate::git::discover::Repo;
132    use crate::testutil::TestRepo;
133
134    fn meta(repo: &TestRepo, branch: &str) -> WtMeta {
135        let r = Repo::discover(repo.root()).unwrap();
136        read_meta(r.gix(), branch)
137    }
138
139    #[test]
140    fn unset_metadata_is_empty() {
141        let repo = TestRepo::init();
142        assert_eq!(meta(&repo, "main"), WtMeta::default());
143    }
144
145    #[test]
146    fn base_ref_round_trips() {
147        let repo = TestRepo::init();
148        write_base_ref(&RealGit, repo.root(), "main", "develop").unwrap();
149        assert_eq!(meta(&repo, "main").base_ref.as_deref(), Some("develop"));
150    }
151
152    #[test]
153    fn pr_number_round_trips() {
154        let repo = TestRepo::init();
155        write_pr_number(&RealGit, repo.root(), "main", 42).unwrap();
156        assert_eq!(meta(&repo, "main").pr_number, Some(42));
157    }
158
159    #[test]
160    fn created_by_wt_round_trips() {
161        let repo = TestRepo::init();
162        assert!(!meta(&repo, "main").created_by_wt);
163        mark_created_by_wt(&RealGit, repo.root(), "main").unwrap();
164        assert!(meta(&repo, "main").created_by_wt);
165    }
166
167    #[test]
168    fn metadata_works_for_slashed_branch_names() {
169        let repo = TestRepo::init();
170        write_base_ref(&RealGit, repo.root(), "feature/login", "main").unwrap();
171        write_pr_number(&RealGit, repo.root(), "feature/login", 7).unwrap();
172        mark_created_by_wt(&RealGit, repo.root(), "feature/login").unwrap();
173        let m = meta(&repo, "feature/login");
174        assert_eq!(m.base_ref.as_deref(), Some("main"));
175        assert_eq!(m.pr_number, Some(7));
176        assert!(m.created_by_wt);
177    }
178
179    #[test]
180    fn write_pr_caches_number_state_and_title() {
181        let repo = TestRepo::init();
182        write_pr(&RealGit, repo.root(), "main", 99, "open", "Add feature").unwrap();
183        let m = meta(&repo, "main");
184        assert_eq!(m.pr_number, Some(99));
185        assert_eq!(m.pr_state.as_deref(), Some("open"));
186        assert_eq!(m.pr_title.as_deref(), Some("Add feature"));
187    }
188
189    #[test]
190    fn clear_removes_all_metadata() {
191        let repo = TestRepo::init();
192        write_base_ref(&RealGit, repo.root(), "topic", "main").unwrap();
193        mark_created_by_wt(&RealGit, repo.root(), "topic").unwrap();
194        clear_meta(&RealGit, repo.root(), "topic").unwrap();
195        assert_eq!(meta(&repo, "topic"), WtMeta::default());
196        // Clearing again (no section) is not an error.
197        clear_meta(&RealGit, repo.root(), "topic").unwrap();
198    }
199}