1use std::path::Path;
38
39use crate::error::{Error, Result};
40use crate::git::cli::GitCli;
41
42pub const SCHEMA_VERSION: u64 = 1;
57
58pub 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
77pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
93pub struct WtMeta {
94 pub base_ref: Option<String>,
96 pub pr_number: Option<u64>,
98 pub pr_state: Option<String>,
100 pub pr_title: Option<String>,
102 pub pr_url: Option<String>,
104 pub created_by_wt: bool,
106 pub issue_number: Option<u64>,
108 pub issue_title: Option<String>,
110 pub issue_url: Option<String>,
112 pub issue_brief: Option<String>,
115}
116
117fn key(branch: &str, name: &str) -> String {
119 format!("wt.{branch}.{name}")
120}
121
122pub 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
169pub 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
184pub 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
190pub 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
196pub 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
202pub 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
213pub 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
227pub 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
241pub 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
252pub 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
258pub 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
269pub 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
275pub fn clear_meta(git: &dyn GitCli, repo_root: &Path, branch: &str) -> Result<()> {
278 let section = format!("wt.{branch}");
279 git.run_raw(repo_root, &["config", "--remove-section", §ion])?;
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 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 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 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 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 clear_meta(&RealGit, repo.root(), "topic").unwrap();
442 }
443}