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//!
8//! # The metadata contract
9//!
10//! Every key lives under `wt.<branch>.*` in the repository's git config, and
11//! all of them are optional:
12//!
13//! | Key | Type | Meaning |
14//! | --- | --- | --- |
15//! | `baseRef` | string | The ref the branch was created from |
16//! | `createdByWt` | bool | `wt` created the branch, so `wt` may delete it |
17//! | `prNumber` | integer | The originating pull request |
18//! | `prState` | string | Cached PR state, so listing works offline |
19//! | `prTitle` | string | Cached PR title |
20//! | `prUrl` | string | Cached PR URL |
21//! | `issueNumber` | integer | The linked GitHub issue |
22//! | `issueTitle` | string | Cached issue title |
23//! | `issueUrl` | string | Cached issue URL |
24//! | `issueBrief` | string | The generated implementation brief |
25//!
26//! Two rules make the namespace safe to share with an embedder: [`read_meta`]
27//! maps a missing key to `None`, and it ignores keys it does not know. So
28//! *adding* a key never breaks an older reader, and an embedder may keep its
29//! own keys in its own namespace without `wt` disturbing them. What is **not**
30//! safe is changing what an existing key means — that is what
31//! [`SCHEMA_VERSION`] exists to gate, and why [`ensure_schema_supported`]
32//! should run before reading or writing.
33//!
34//! [`clear_meta`] removes the whole `wt.<branch>` section, so it also removes
35//! keys this build has never heard of.
36
37use std::path::Path;
38
39use crate::error::{Error, Result};
40use crate::git::cli::GitCli;
41
42/// The metadata schema version this build reads and writes (issue #99).
43///
44/// The version is a single repo-level `wt.schema` integer, deliberately
45/// minimal: a repository with no `wt.schema` is version `1` (every repository
46/// initialized to date), readers accept equal-or-lower values, and a *higher*
47/// value is refused with an actionable error — it means the metadata's key
48/// meanings may have changed and reading them could silently misinterpret
49/// them. Purely *additive* keys never need a bump: [`read_meta`] ignores
50/// unknown keys and maps missing keys to `None`. `wt` never writes
51/// `wt.schema` at version 1; the first meaning-changing version will.
52///
53/// Embedders (karet) should compare their supported version against
54/// [`schema_version`] (or just call [`ensure_schema_supported`]) *before*
55/// mutating anything.
56pub const SCHEMA_VERSION: u64 = 1;
57
58/// Reads the repository's `wt.schema`, treating a missing key as version `1`.
59/// A present but non-positive or unparseable value is a configuration error.
60pub fn schema_version(repo: &gix::Repository) -> Result<u64> {
61    let config = repo.config_snapshot();
62    let Some(raw) = config.string("wt.schema") else {
63        return Ok(1);
64    };
65    raw.to_string()
66        .trim()
67        .parse::<u64>()
68        .ok()
69        .filter(|v| *v >= 1)
70        .ok_or_else(|| Error::Config {
71            file: "git config".into(),
72            key: "wt.schema".into(),
73            reason: format!("expected a positive integer, got {raw:?}"),
74        })
75}
76
77/// Fails with [`Error::SchemaTooNew`] when the repository's `wt.schema` is
78/// higher than [`SCHEMA_VERSION`]. Call before reading or writing `wt.*`
79/// metadata.
80pub fn ensure_schema_supported(repo: &gix::Repository) -> Result<()> {
81    let found = schema_version(repo)?;
82    if found > SCHEMA_VERSION {
83        return Err(Error::SchemaTooNew {
84            found,
85            supported: SCHEMA_VERSION,
86        });
87    }
88    Ok(())
89}
90
91/// Per-worktree metadata recorded by `wt`.
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
93pub struct WtMeta {
94    /// Base ref the branch was created from (§3).
95    pub base_ref: Option<String>,
96    /// Originating PR number, for PR-checkout worktrees (§7).
97    pub pr_number: Option<u64>,
98    /// Cached PR state, so `wt list` can show it offline (§3).
99    pub pr_state: Option<String>,
100    /// Cached PR title.
101    pub pr_title: Option<String>,
102    /// Cached PR URL, for the TUI detail pane (§10).
103    pub pr_url: Option<String>,
104    /// Whether the branch/worktree was created by `wt` (§10).
105    pub created_by_wt: bool,
106    /// Linked GitHub issue number, for issue worktrees (issue #100).
107    pub issue_number: Option<u64>,
108    /// Cached issue title, so `wt list` can show it offline.
109    pub issue_title: Option<String>,
110    /// Cached issue URL.
111    pub issue_url: Option<String>,
112    /// The generated implementation brief. Persisted so an embedder (karet)
113    /// can read it instead of regenerating it.
114    pub issue_brief: Option<String>,
115}
116
117/// The config key for `wt.<branch>.<name>`.
118fn key(branch: &str, name: &str) -> String {
119    format!("wt.{branch}.{name}")
120}
121
122/// Reads the `wt.*` metadata for `branch` via `gix`.
123pub fn read_meta(repo: &gix::Repository, branch: &str) -> WtMeta {
124    let config = repo.config_snapshot();
125    let base_ref = config
126        .string(key(branch, "baseRef").as_str())
127        .map(|v| v.to_string());
128    let pr_number = config
129        .string(key(branch, "prNumber").as_str())
130        .and_then(|v| v.to_string().parse::<u64>().ok());
131    let pr_state = config
132        .string(key(branch, "prState").as_str())
133        .map(|v| v.to_string());
134    let pr_title = config
135        .string(key(branch, "prTitle").as_str())
136        .map(|v| v.to_string());
137    let pr_url = config
138        .string(key(branch, "prUrl").as_str())
139        .map(|v| v.to_string());
140    let created_by_wt = config
141        .boolean(key(branch, "createdByWt").as_str())
142        .unwrap_or(false);
143    let issue_number = config
144        .string(key(branch, "issueNumber").as_str())
145        .and_then(|v| v.to_string().parse::<u64>().ok());
146    let issue_title = config
147        .string(key(branch, "issueTitle").as_str())
148        .map(|v| v.to_string());
149    let issue_url = config
150        .string(key(branch, "issueUrl").as_str())
151        .map(|v| v.to_string());
152    let issue_brief = config
153        .string(key(branch, "issueBrief").as_str())
154        .map(|v| v.to_string());
155    WtMeta {
156        base_ref,
157        pr_number,
158        pr_state,
159        pr_title,
160        pr_url,
161        created_by_wt,
162        issue_number,
163        issue_title,
164        issue_url,
165        issue_brief,
166    }
167}
168
169/// Records the full cached PR snapshot (number, state, title) for `branch`.
170pub fn write_pr(
171    git: &dyn GitCli,
172    repo_root: &Path,
173    branch: &str,
174    number: u64,
175    state: &str,
176    title: &str,
177) -> Result<()> {
178    write_pr_number(git, repo_root, branch, number)?;
179    write_pr_state(git, repo_root, branch, state)?;
180    write_pr_title(git, repo_root, branch, title)?;
181    Ok(())
182}
183
184/// Records the cached PR state for `branch`, so `wt list` can show it offline.
185pub fn write_pr_state(git: &dyn GitCli, repo_root: &Path, branch: &str, state: &str) -> Result<()> {
186    git.run(repo_root, &["config", &key(branch, "prState"), state])?;
187    Ok(())
188}
189
190/// Records the cached PR title for `branch`.
191pub fn write_pr_title(git: &dyn GitCli, repo_root: &Path, branch: &str, title: &str) -> Result<()> {
192    git.run(repo_root, &["config", &key(branch, "prTitle"), title])?;
193    Ok(())
194}
195
196/// Records the PR URL for `branch` (shown in the TUI detail pane).
197pub fn write_pr_url(git: &dyn GitCli, repo_root: &Path, branch: &str, url: &str) -> Result<()> {
198    git.run(repo_root, &["config", &key(branch, "prUrl"), url])?;
199    Ok(())
200}
201
202/// Records the base ref for `branch`.
203pub fn write_base_ref(
204    git: &dyn GitCli,
205    repo_root: &Path,
206    branch: &str,
207    base_ref: &str,
208) -> Result<()> {
209    git.run(repo_root, &["config", &key(branch, "baseRef"), base_ref])?;
210    Ok(())
211}
212
213/// Records the originating PR number for `branch`.
214pub fn write_pr_number(
215    git: &dyn GitCli,
216    repo_root: &Path,
217    branch: &str,
218    number: u64,
219) -> Result<()> {
220    git.run(
221        repo_root,
222        &["config", &key(branch, "prNumber"), &number.to_string()],
223    )?;
224    Ok(())
225}
226
227/// Records the linked issue number for `branch`.
228pub fn write_issue_number(
229    git: &dyn GitCli,
230    repo_root: &Path,
231    branch: &str,
232    number: u64,
233) -> Result<()> {
234    git.run(
235        repo_root,
236        &["config", &key(branch, "issueNumber"), &number.to_string()],
237    )?;
238    Ok(())
239}
240
241/// Records the cached issue title for `branch`.
242pub fn write_issue_title(
243    git: &dyn GitCli,
244    repo_root: &Path,
245    branch: &str,
246    title: &str,
247) -> Result<()> {
248    git.run(repo_root, &["config", &key(branch, "issueTitle"), title])?;
249    Ok(())
250}
251
252/// Records the issue URL for `branch`.
253pub fn write_issue_url(git: &dyn GitCli, repo_root: &Path, branch: &str, url: &str) -> Result<()> {
254    git.run(repo_root, &["config", &key(branch, "issueUrl"), url])?;
255    Ok(())
256}
257
258/// Records the generated implementation brief for `branch`.
259pub fn write_issue_brief(
260    git: &dyn GitCli,
261    repo_root: &Path,
262    branch: &str,
263    brief: &str,
264) -> Result<()> {
265    git.run(repo_root, &["config", &key(branch, "issueBrief"), brief])?;
266    Ok(())
267}
268
269/// Marks `branch` as created by `wt`.
270pub fn mark_created_by_wt(git: &dyn GitCli, repo_root: &Path, branch: &str) -> Result<()> {
271    git.run(repo_root, &["config", &key(branch, "createdByWt"), "true"])?;
272    Ok(())
273}
274
275/// Removes all `wt.*` metadata for `branch` (e.g. after removing its worktree).
276/// A missing section is not an error.
277pub fn clear_meta(git: &dyn GitCli, repo_root: &Path, branch: &str) -> Result<()> {
278    let section = format!("wt.{branch}");
279    // `--remove-section` exits non-zero if the section is absent; ignore that.
280    git.run_raw(repo_root, &["config", "--remove-section", &section])?;
281    Ok(())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::git::cli::RealGit;
288    use crate::git::discover::Repo;
289    use crate::testutil::TestRepo;
290
291    fn meta(repo: &TestRepo, branch: &str) -> WtMeta {
292        let r = Repo::discover(repo.root()).unwrap();
293        read_meta(r.gix(), branch)
294    }
295
296    #[test]
297    fn unset_metadata_is_empty() {
298        let repo = TestRepo::init();
299        assert_eq!(meta(&repo, "main"), WtMeta::default());
300    }
301
302    #[test]
303    fn base_ref_round_trips() {
304        let repo = TestRepo::init();
305        write_base_ref(&RealGit, repo.root(), "main", "develop").unwrap();
306        assert_eq!(meta(&repo, "main").base_ref.as_deref(), Some("develop"));
307    }
308
309    #[test]
310    fn pr_number_round_trips() {
311        let repo = TestRepo::init();
312        write_pr_number(&RealGit, repo.root(), "main", 42).unwrap();
313        assert_eq!(meta(&repo, "main").pr_number, Some(42));
314    }
315
316    #[test]
317    fn created_by_wt_round_trips() {
318        let repo = TestRepo::init();
319        assert!(!meta(&repo, "main").created_by_wt);
320        mark_created_by_wt(&RealGit, repo.root(), "main").unwrap();
321        assert!(meta(&repo, "main").created_by_wt);
322    }
323
324    #[test]
325    fn metadata_works_for_slashed_branch_names() {
326        let repo = TestRepo::init();
327        write_base_ref(&RealGit, repo.root(), "feature/login", "main").unwrap();
328        write_pr_number(&RealGit, repo.root(), "feature/login", 7).unwrap();
329        mark_created_by_wt(&RealGit, repo.root(), "feature/login").unwrap();
330        let m = meta(&repo, "feature/login");
331        assert_eq!(m.base_ref.as_deref(), Some("main"));
332        assert_eq!(m.pr_number, Some(7));
333        assert!(m.created_by_wt);
334    }
335
336    #[test]
337    fn write_pr_caches_number_state_and_title() {
338        let repo = TestRepo::init();
339        write_pr(&RealGit, repo.root(), "main", 99, "open", "Add feature").unwrap();
340        let m = meta(&repo, "main");
341        assert_eq!(m.pr_number, Some(99));
342        assert_eq!(m.pr_state.as_deref(), Some("open"));
343        assert_eq!(m.pr_title.as_deref(), Some("Add feature"));
344    }
345
346    /// Opens a fresh gix handle on the repo (config is snapshotted at open).
347    fn gix_of(repo: &TestRepo) -> gix::Repository {
348        gix::discover(repo.root()).unwrap()
349    }
350
351    #[test]
352    fn missing_schema_is_version_one_and_supported() {
353        // Every repository initialized to date has no wt.schema.
354        let repo = TestRepo::init();
355        assert_eq!(schema_version(&gix_of(&repo)).unwrap(), 1);
356        ensure_schema_supported(&gix_of(&repo)).unwrap();
357    }
358
359    #[test]
360    fn equal_schema_is_supported() {
361        let repo = TestRepo::init();
362        repo.git(&["config", "wt.schema", &SCHEMA_VERSION.to_string()]);
363        assert_eq!(schema_version(&gix_of(&repo)).unwrap(), SCHEMA_VERSION);
364        ensure_schema_supported(&gix_of(&repo)).unwrap();
365    }
366
367    #[test]
368    fn future_schema_is_refused_with_an_upgrade_error() {
369        let repo = TestRepo::init();
370        repo.git(&["config", "wt.schema", "2"]);
371        let err = ensure_schema_supported(&gix_of(&repo)).unwrap_err();
372        assert!(matches!(
373            err,
374            Error::SchemaTooNew {
375                found: 2,
376                supported: SCHEMA_VERSION,
377            }
378        ));
379        let message = err.to_string();
380        assert!(message.contains("wt.schema = 2"), "{message}");
381        assert!(message.contains("upgrade wt"), "{message}");
382    }
383
384    #[test]
385    fn garbage_schema_is_a_config_error() {
386        for bad in ["banana", "0", "-3"] {
387            let repo = TestRepo::init();
388            repo.git(&["config", "wt.schema", bad]);
389            let err = schema_version(&gix_of(&repo)).unwrap_err();
390            assert!(
391                matches!(&err, Error::Config { key, .. } if key == "wt.schema"),
392                "{bad}: {err:?}"
393            );
394        }
395    }
396
397    #[test]
398    fn issue_link_round_trips() {
399        let repo = TestRepo::init();
400        write_issue_number(&RealGit, repo.root(), "topic", 7).unwrap();
401        write_issue_title(&RealGit, repo.root(), "topic", "Add login").unwrap();
402        write_issue_url(&RealGit, repo.root(), "topic", "https://example.com/7").unwrap();
403        write_issue_brief(&RealGit, repo.root(), "topic", "Wire up the form.").unwrap();
404        let got = meta(&repo, "topic");
405        assert_eq!(got.issue_number, Some(7));
406        assert_eq!(got.issue_title.as_deref(), Some("Add login"));
407        assert_eq!(got.issue_url.as_deref(), Some("https://example.com/7"));
408        assert_eq!(got.issue_brief.as_deref(), Some("Wire up the form."));
409    }
410
411    #[test]
412    fn issue_keys_are_absent_until_written() {
413        // The issue keys are purely additive: a repository written by an older
414        // build has none of them, and every one must read back as `None` rather
415        // than fail. This is what makes them safe without a `wt.schema` bump.
416        let repo = TestRepo::init();
417        write_base_ref(&RealGit, repo.root(), "topic", "main").unwrap();
418        let got = meta(&repo, "topic");
419        assert_eq!(got.issue_number, None);
420        assert_eq!(got.issue_title, None);
421        assert_eq!(got.issue_url, None);
422        assert_eq!(got.issue_brief, None);
423    }
424
425    #[test]
426    fn clear_removes_all_metadata() {
427        let repo = TestRepo::init();
428        write_base_ref(&RealGit, repo.root(), "topic", "main").unwrap();
429        mark_created_by_wt(&RealGit, repo.root(), "topic").unwrap();
430        // Every key the section can hold, so `--remove-section` is proven to
431        // clear the issue keys too and not just the ones it predates.
432        write_pr(&RealGit, repo.root(), "topic", 42, "open", "Title").unwrap();
433        write_pr_url(&RealGit, repo.root(), "topic", "https://example.com/42").unwrap();
434        write_issue_number(&RealGit, repo.root(), "topic", 7).unwrap();
435        write_issue_title(&RealGit, repo.root(), "topic", "Add login").unwrap();
436        write_issue_url(&RealGit, repo.root(), "topic", "https://example.com/7").unwrap();
437        write_issue_brief(&RealGit, repo.root(), "topic", "Wire up the form.").unwrap();
438        clear_meta(&RealGit, repo.root(), "topic").unwrap();
439        assert_eq!(meta(&repo, "topic"), WtMeta::default());
440        // Clearing again (no section) is not an error.
441        clear_meta(&RealGit, repo.root(), "topic").unwrap();
442    }
443}