use std::collections::HashSet;
use std::path::Path;
use crate::error::{Error, Result};
use crate::git::cli::GitCli;
pub const SCHEMA_VERSION: u64 = 1;
pub fn schema_version(repo: &gix::Repository) -> Result<u64> {
let config = repo.config_snapshot();
let Some(raw) = config.string("wt.schema") else {
return Ok(1);
};
raw.to_string()
.trim()
.parse::<u64>()
.ok()
.filter(|v| *v >= 1)
.ok_or_else(|| Error::Config {
file: "git config".into(),
key: "wt.schema".into(),
reason: format!("expected a positive integer, got {raw:?}"),
})
}
pub fn ensure_schema_supported(repo: &gix::Repository) -> Result<()> {
let found = schema_version(repo)?;
if found > SCHEMA_VERSION {
return Err(Error::SchemaTooNew {
found,
supported: SCHEMA_VERSION,
});
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WtMeta {
pub base_ref: Option<String>,
pub pr_number: Option<u64>,
pub pr_state: Option<String>,
pub pr_title: Option<String>,
pub pr_url: Option<String>,
pub created_by_wt: bool,
pub issue_number: Option<u64>,
pub issue_title: Option<String>,
pub issue_url: Option<String>,
pub issue_brief: Option<String>,
}
fn key(branch: &str, name: &str) -> String {
format!("wt.{branch}.{name}")
}
pub fn read_meta(repo: &gix::Repository, branch: &str) -> WtMeta {
let config = repo.config_snapshot();
let base_ref = config
.string(key(branch, "baseRef").as_str())
.map(|v| v.to_string());
let pr_number = config
.string(key(branch, "prNumber").as_str())
.and_then(|v| v.to_string().parse::<u64>().ok());
let pr_state = config
.string(key(branch, "prState").as_str())
.map(|v| v.to_string());
let pr_title = config
.string(key(branch, "prTitle").as_str())
.map(|v| v.to_string());
let pr_url = config
.string(key(branch, "prUrl").as_str())
.map(|v| v.to_string());
let created_by_wt = config
.boolean(key(branch, "createdByWt").as_str())
.unwrap_or(false);
let issue_number = config
.string(key(branch, "issueNumber").as_str())
.and_then(|v| v.to_string().parse::<u64>().ok());
let issue_title = config
.string(key(branch, "issueTitle").as_str())
.map(|v| v.to_string());
let issue_url = config
.string(key(branch, "issueUrl").as_str())
.map(|v| v.to_string());
let issue_brief = config
.string(key(branch, "issueBrief").as_str())
.map(|v| v.to_string());
WtMeta {
base_ref,
pr_number,
pr_state,
pr_title,
pr_url,
created_by_wt,
issue_number,
issue_title,
issue_url,
issue_brief,
}
}
pub fn write_pr(
git: &dyn GitCli,
repo_root: &Path,
branch: &str,
number: u64,
state: &str,
title: &str,
) -> Result<()> {
write_pr_number(git, repo_root, branch, number)?;
write_pr_state(git, repo_root, branch, state)?;
write_pr_title(git, repo_root, branch, title)?;
Ok(())
}
pub fn write_pr_state(git: &dyn GitCli, repo_root: &Path, branch: &str, state: &str) -> Result<()> {
git.run(repo_root, &["config", &key(branch, "prState"), state])?;
Ok(())
}
pub fn write_pr_title(git: &dyn GitCli, repo_root: &Path, branch: &str, title: &str) -> Result<()> {
git.run(repo_root, &["config", &key(branch, "prTitle"), title])?;
Ok(())
}
pub fn write_pr_url(git: &dyn GitCli, repo_root: &Path, branch: &str, url: &str) -> Result<()> {
git.run(repo_root, &["config", &key(branch, "prUrl"), url])?;
Ok(())
}
pub fn write_base_ref(
git: &dyn GitCli,
repo_root: &Path,
branch: &str,
base_ref: &str,
) -> Result<()> {
git.run(repo_root, &["config", &key(branch, "baseRef"), base_ref])?;
Ok(())
}
pub fn write_pr_number(
git: &dyn GitCli,
repo_root: &Path,
branch: &str,
number: u64,
) -> Result<()> {
git.run(
repo_root,
&["config", &key(branch, "prNumber"), &number.to_string()],
)?;
Ok(())
}
pub fn write_issue_number(
git: &dyn GitCli,
repo_root: &Path,
branch: &str,
number: u64,
) -> Result<()> {
git.run(
repo_root,
&["config", &key(branch, "issueNumber"), &number.to_string()],
)?;
Ok(())
}
pub fn write_issue_title(
git: &dyn GitCli,
repo_root: &Path,
branch: &str,
title: &str,
) -> Result<()> {
git.run(repo_root, &["config", &key(branch, "issueTitle"), title])?;
Ok(())
}
pub fn write_issue_url(git: &dyn GitCli, repo_root: &Path, branch: &str, url: &str) -> Result<()> {
git.run(repo_root, &["config", &key(branch, "issueUrl"), url])?;
Ok(())
}
pub fn write_issue_brief(
git: &dyn GitCli,
repo_root: &Path,
branch: &str,
brief: &str,
) -> Result<()> {
git.run(repo_root, &["config", &key(branch, "issueBrief"), brief])?;
Ok(())
}
pub fn mark_created_by_wt(git: &dyn GitCli, repo_root: &Path, branch: &str) -> Result<()> {
git.run(repo_root, &["config", &key(branch, "createdByWt"), "true"])?;
Ok(())
}
pub fn clear_meta(git: &dyn GitCli, repo_root: &Path, branch: &str) -> Result<()> {
let section = format!("wt.{branch}");
git.run_raw(repo_root, &["config", "--remove-section", §ion])?;
Ok(())
}
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
pub(crate) fn meta_branches(git: &dyn GitCli, repo_root: &Path) -> Result<HashSet<String>> {
let out = git.run_raw(
repo_root,
&[
"config",
"--local",
"-z",
"--name-only",
"--get-regexp",
r"^wt\.",
],
)?;
if !out.success && !(out.stdout.is_empty() && out.stderr.trim().is_empty()) {
return Err(Error::Subprocess {
program: "git".into(),
stderr: out.stderr.trim().to_string(),
});
}
Ok(out
.stdout
.split('\0')
.filter_map(|key| key.strip_prefix("wt.")?.rsplit_once('.'))
.map(|(branch, _)| branch.to_string())
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::cli::RealGit;
use crate::git::discover::Repo;
use crate::testutil::TestRepo;
fn meta(repo: &TestRepo, branch: &str) -> WtMeta {
let r = Repo::discover(repo.root()).unwrap();
read_meta(r.gix(), branch)
}
#[test]
fn unset_metadata_is_empty() {
let repo = TestRepo::init();
assert_eq!(meta(&repo, "main"), WtMeta::default());
}
#[test]
fn base_ref_round_trips() {
let repo = TestRepo::init();
write_base_ref(&RealGit, repo.root(), "main", "develop").unwrap();
assert_eq!(meta(&repo, "main").base_ref.as_deref(), Some("develop"));
}
#[test]
fn pr_number_round_trips() {
let repo = TestRepo::init();
write_pr_number(&RealGit, repo.root(), "main", 42).unwrap();
assert_eq!(meta(&repo, "main").pr_number, Some(42));
}
#[test]
fn created_by_wt_round_trips() {
let repo = TestRepo::init();
assert!(!meta(&repo, "main").created_by_wt);
mark_created_by_wt(&RealGit, repo.root(), "main").unwrap();
assert!(meta(&repo, "main").created_by_wt);
}
#[test]
fn metadata_works_for_slashed_branch_names() {
let repo = TestRepo::init();
write_base_ref(&RealGit, repo.root(), "feature/login", "main").unwrap();
write_pr_number(&RealGit, repo.root(), "feature/login", 7).unwrap();
mark_created_by_wt(&RealGit, repo.root(), "feature/login").unwrap();
let m = meta(&repo, "feature/login");
assert_eq!(m.base_ref.as_deref(), Some("main"));
assert_eq!(m.pr_number, Some(7));
assert!(m.created_by_wt);
}
#[test]
fn write_pr_caches_number_state_and_title() {
let repo = TestRepo::init();
write_pr(&RealGit, repo.root(), "main", 99, "open", "Add feature").unwrap();
let m = meta(&repo, "main");
assert_eq!(m.pr_number, Some(99));
assert_eq!(m.pr_state.as_deref(), Some("open"));
assert_eq!(m.pr_title.as_deref(), Some("Add feature"));
}
fn gix_of(repo: &TestRepo) -> gix::Repository {
gix::discover(repo.root()).unwrap()
}
#[test]
fn missing_schema_is_version_one_and_supported() {
let repo = TestRepo::init();
assert_eq!(schema_version(&gix_of(&repo)).unwrap(), 1);
ensure_schema_supported(&gix_of(&repo)).unwrap();
}
#[test]
fn equal_schema_is_supported() {
let repo = TestRepo::init();
repo.git(&["config", "wt.schema", &SCHEMA_VERSION.to_string()]);
assert_eq!(schema_version(&gix_of(&repo)).unwrap(), SCHEMA_VERSION);
ensure_schema_supported(&gix_of(&repo)).unwrap();
}
#[test]
fn future_schema_is_refused_with_an_upgrade_error() {
let repo = TestRepo::init();
repo.git(&["config", "wt.schema", "2"]);
let err = ensure_schema_supported(&gix_of(&repo)).unwrap_err();
assert!(matches!(
err,
Error::SchemaTooNew {
found: 2,
supported: SCHEMA_VERSION,
}
));
let message = err.to_string();
assert!(message.contains("wt.schema = 2"), "{message}");
assert!(message.contains("upgrade wt"), "{message}");
}
#[test]
fn garbage_schema_is_a_config_error() {
for bad in ["banana", "0", "-3"] {
let repo = TestRepo::init();
repo.git(&["config", "wt.schema", bad]);
let err = schema_version(&gix_of(&repo)).unwrap_err();
assert!(
matches!(&err, Error::Config { key, .. } if key == "wt.schema"),
"{bad}: {err:?}"
);
}
}
#[test]
fn issue_link_round_trips() {
let repo = TestRepo::init();
write_issue_number(&RealGit, repo.root(), "topic", 7).unwrap();
write_issue_title(&RealGit, repo.root(), "topic", "Add login").unwrap();
write_issue_url(&RealGit, repo.root(), "topic", "https://example.com/7").unwrap();
write_issue_brief(&RealGit, repo.root(), "topic", "Wire up the form.").unwrap();
let got = meta(&repo, "topic");
assert_eq!(got.issue_number, Some(7));
assert_eq!(got.issue_title.as_deref(), Some("Add login"));
assert_eq!(got.issue_url.as_deref(), Some("https://example.com/7"));
assert_eq!(got.issue_brief.as_deref(), Some("Wire up the form."));
}
#[test]
fn issue_keys_are_absent_until_written() {
let repo = TestRepo::init();
write_base_ref(&RealGit, repo.root(), "topic", "main").unwrap();
let got = meta(&repo, "topic");
assert_eq!(got.issue_number, None);
assert_eq!(got.issue_title, None);
assert_eq!(got.issue_url, None);
assert_eq!(got.issue_brief, None);
}
#[test]
fn clear_removes_all_metadata() {
let repo = TestRepo::init();
write_base_ref(&RealGit, repo.root(), "topic", "main").unwrap();
mark_created_by_wt(&RealGit, repo.root(), "topic").unwrap();
write_pr(&RealGit, repo.root(), "topic", 42, "open", "Title").unwrap();
write_pr_url(&RealGit, repo.root(), "topic", "https://example.com/42").unwrap();
write_issue_number(&RealGit, repo.root(), "topic", 7).unwrap();
write_issue_title(&RealGit, repo.root(), "topic", "Add login").unwrap();
write_issue_url(&RealGit, repo.root(), "topic", "https://example.com/7").unwrap();
write_issue_brief(&RealGit, repo.root(), "topic", "Wire up the form.").unwrap();
clear_meta(&RealGit, repo.root(), "topic").unwrap();
assert_eq!(meta(&repo, "topic"), WtMeta::default());
clear_meta(&RealGit, repo.root(), "topic").unwrap();
}
#[test]
fn meta_branches_names_each_branch_with_a_section() {
let repo = TestRepo::init();
assert!(meta_branches(&RealGit, repo.root()).unwrap().is_empty());
write_base_ref(&RealGit, repo.root(), "feat/v1.2", "main").unwrap();
mark_created_by_wt(&RealGit, repo.root(), "feat/v1.2").unwrap();
mark_created_by_wt(&RealGit, repo.root(), "Topic").unwrap();
let got = meta_branches(&RealGit, repo.root()).unwrap();
assert_eq!(
got,
HashSet::from(["feat/v1.2".to_string(), "Topic".to_string()])
);
clear_meta(&RealGit, repo.root(), "Topic").unwrap();
assert_eq!(
meta_branches(&RealGit, repo.root()).unwrap(),
HashSet::from(["feat/v1.2".to_string()])
);
}
#[test]
fn meta_branches_errors_on_an_unreadable_config() {
let repo = TestRepo::init();
let config = repo.root().join(".git/config");
let mut text = std::fs::read_to_string(&config).unwrap();
text.push_str("[broken\n");
std::fs::write(&config, text).unwrap();
assert!(meta_branches(&RealGit, repo.root()).is_err());
}
}