use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
fn run_git(args: &[&str], verbose: bool, workdir: Option<&Path>) -> Result<String> {
if verbose {
eprintln!(" $ git {}", args.join(" "));
}
let mut cmd = Command::new("git");
cmd.args(args);
if let Some(dir) = workdir {
cmd.current_dir(dir);
}
let output = cmd
.output()
.with_context(|| format!("failed to execute: git {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"git {} failed (exit {}):\n{}",
args.join(" "),
output.status,
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn run_cmd(bin: &str, args: &[&str], verbose: bool, workdir: Option<&Path>) -> Result<String> {
if verbose {
eprintln!(" $ {} {}", bin, args.join(" "));
}
let mut cmd = Command::new(bin);
cmd.args(args);
if let Some(dir) = workdir {
cmd.current_dir(dir);
}
let output = cmd
.output()
.with_context(|| format!("failed to execute: {} {}", bin, args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"{} {} failed (exit {}):\n{}",
bin,
args.join(" "),
output.status,
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn worktrunk_available() -> bool {
Command::new("wt")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
}
pub struct Git {
verbose: bool,
workdir: Option<std::path::PathBuf>,
}
impl Git {
pub fn new(verbose: bool) -> Self {
Self {
verbose,
workdir: None,
}
}
#[cfg(test)]
pub fn with_workdir(verbose: bool, workdir: &Path) -> Self {
Self {
verbose,
workdir: Some(workdir.to_path_buf()),
}
}
fn run(&self, args: &[&str]) -> Result<String> {
run_git(args, self.verbose, self.workdir.as_deref())
}
fn run_wt(&self, args: &[&str]) -> Result<String> {
run_cmd("wt", args, self.verbose, self.workdir.as_deref())
}
pub fn current_branch(&self) -> Result<String> {
self.run(&["rev-parse", "--abbrev-ref", "HEAD"])
}
pub fn remotes(&self) -> Result<Vec<String>> {
let out = self.run(&["remote"])?;
Ok(out.lines().map(|l| l.to_string()).collect())
}
pub fn remote_update_prune(&self) -> Result<()> {
self.run(&["remote", "update", "--prune"])?;
Ok(())
}
pub fn merged_branches(&self, target: &str) -> Result<Vec<String>> {
let out = self.run(&["branch", "--merged", target])?;
Ok(parse_branch_list(&out))
}
pub fn local_branches(&self) -> Result<Vec<String>> {
let out = self.run(&["branch", "--format=%(refname:short)"])?;
Ok(out
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect())
}
pub fn merged_remote_branches(&self, target: &str, remote: &str) -> Result<Vec<String>> {
let out = self.run(&["branch", "-r", "--merged", target])?;
let prefix = format!("{remote}/");
Ok(out
.lines()
.map(|l| l.trim())
.filter(|l| l.starts_with(&prefix) && !l.contains("->"))
.map(|l| l.strip_prefix(&prefix).unwrap_or(l).to_string())
.collect())
}
pub fn cherry_merged(&self, upstream: &str, branch: &str) -> Result<bool> {
let out = self.run(&["cherry", upstream, branch])?;
Ok(!out.is_empty() && out.lines().all(|l| l.starts_with('-')))
}
pub fn branch_delete(&self, branch: &str) -> Result<()> {
self.run(&["branch", "-D", branch])?;
Ok(())
}
pub fn push_delete(&self, remote: &str, branch: &str) -> Result<()> {
self.run(&["push", "--delete", "--force-with-lease", remote, branch])?;
Ok(())
}
pub fn worktree_list(&self) -> Result<Vec<Worktree>> {
let out = self.run(&["worktree", "list", "--porcelain"])?;
Ok(parse_worktree_list(&out))
}
pub fn worktree_remove(&self, path: &str) -> Result<()> {
self.run(&["worktree", "remove", path])?;
Ok(())
}
pub fn worktrunk_config_exists(&self) -> Result<bool> {
self.config_section_exists("worktrunk")
}
pub fn worktrunk_remove(&self, branch: &str) -> Result<()> {
self.run_wt(&[
"remove",
branch,
"--foreground",
"--yes",
"--no-delete-branch",
])?;
Ok(())
}
pub fn worktrunk_remove_by_path(&self, path: &str) -> Result<()> {
self.run_wt(&[
"remove",
path,
"--foreground",
"--yes",
"--no-delete-branch",
])?;
Ok(())
}
pub fn config_get_all(&self, key: &str) -> Result<Vec<String>> {
match self.run(&["config", "--get-all", key]) {
Ok(out) => Ok(out
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect()),
Err(_) => Ok(vec![]),
}
}
pub fn config_get(&self, key: &str) -> Result<Option<String>> {
match self.run(&["config", "--get", key]) {
Ok(val) if !val.is_empty() => Ok(Some(val)),
_ => Ok(None),
}
}
pub fn config_set(&self, key: &str, value: &str) -> Result<()> {
self.run(&["config", "--local", key, value])?;
Ok(())
}
pub fn config_add(&self, key: &str, value: &str) -> Result<()> {
self.run(&["config", "--local", "--add", key, value])?;
Ok(())
}
pub fn config_unset_all(&self, key: &str) -> Result<()> {
let _ = self.run(&["config", "--local", "--unset-all", key]);
Ok(())
}
pub fn config_section_exists(&self, section: &str) -> Result<bool> {
match self.run(&["config", "--get-regexp", &format!("^{section}\\.")]) {
Ok(out) => Ok(!out.is_empty()),
Err(_) => Ok(false),
}
}
pub fn branch_protected_list(&self) -> Result<Vec<String>> {
let pattern = r"^branch\..*\.sync-protected$";
match self.run(&["config", "--get-regexp", pattern]) {
Ok(out) => {
let mut branches = Vec::new();
for line in out.lines().filter(|l| !l.is_empty()) {
let mut parts = line.splitn(2, ' ');
if let (Some(key), Some(value)) = (parts.next(), parts.next())
&& value.trim().eq_ignore_ascii_case("true")
{
if let Some(name) = key
.strip_prefix("branch.")
.and_then(|s| s.strip_suffix(".sync-protected"))
{
branches.push(name.to_string());
}
}
}
Ok(branches)
}
Err(_) => Ok(vec![]),
}
}
pub fn set_branch_protected(&self, branch: &str, protected: bool) -> Result<()> {
let key = format!("branch.{branch}.sync-protected");
if protected {
self.run(&["config", "--local", &key, "true"])?;
} else {
let _ = self.run(&["config", "--local", "--unset", &key]);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Worktree {
pub path: String,
pub head: Option<String>,
pub branch: Option<String>,
pub is_bare: bool,
}
fn parse_branch_list(output: &str) -> Vec<String> {
output
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('*'))
.map(|l| l.strip_prefix("+ ").unwrap_or(l).to_string())
.collect()
}
fn parse_worktree_list(output: &str) -> Vec<Worktree> {
let mut worktrees = Vec::new();
let mut current: Option<Worktree> = None;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(wt) = current.take() {
worktrees.push(wt);
}
current = Some(Worktree {
path: path.to_string(),
head: None,
branch: None,
is_bare: false,
});
} else if let Some(head) = line.strip_prefix("HEAD ") {
if let Some(ref mut wt) = current {
wt.head = Some(head.to_string());
}
} else if let Some(branch) = line.strip_prefix("branch ") {
if let Some(ref mut wt) = current {
wt.branch = Some(
branch
.strip_prefix("refs/heads/")
.unwrap_or(branch)
.to_string(),
);
}
} else if line == "bare"
&& let Some(ref mut wt) = current
{
wt.is_bare = true;
}
}
if let Some(wt) = current {
worktrees.push(wt);
}
worktrees
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_branch_list() {
let output = " feature/foo\n* main\n bugfix/bar\n";
let branches = parse_branch_list(output);
assert_eq!(branches, vec!["feature/foo", "bugfix/bar"]);
}
#[test]
fn test_parse_branch_list_strips_worktree_marker() {
let output = " feature/foo\n* main\n+ feature/wt\n bugfix/bar\n";
let branches = parse_branch_list(output);
assert_eq!(branches, vec!["feature/foo", "feature/wt", "bugfix/bar"]);
}
#[test]
fn test_parse_branch_list_empty() {
let branches = parse_branch_list("");
assert!(branches.is_empty());
}
#[test]
fn test_parse_worktree_list() {
let output = "\
worktree /home/user/project
HEAD abc1234
branch refs/heads/main
worktree /home/user/project-feature
HEAD def5678
branch refs/heads/feature/foo
worktree /home/user/project-bare
HEAD 000000
bare
";
let worktrees = parse_worktree_list(output);
assert_eq!(worktrees.len(), 3);
assert_eq!(worktrees[0].path, "/home/user/project");
assert_eq!(worktrees[0].branch.as_deref(), Some("main"));
assert!(!worktrees[0].is_bare);
assert_eq!(worktrees[1].path, "/home/user/project-feature");
assert_eq!(worktrees[1].branch.as_deref(), Some("feature/foo"));
assert_eq!(worktrees[2].path, "/home/user/project-bare");
assert!(worktrees[2].is_bare);
}
#[test]
fn test_parse_worktree_list_empty() {
let worktrees = parse_worktree_list("");
assert!(worktrees.is_empty());
}
#[test]
fn test_git_in_temp_repo() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()?;
let git = Git::with_workdir(false, path);
assert_eq!(git.current_branch()?, "main");
let branches = git.local_branches()?;
assert_eq!(branches, vec!["main"]);
Command::new("git")
.args(["checkout", "-b", "feature/test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("feature.txt"), "feature")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "feature"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["checkout", "main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["merge", "feature/test"])
.current_dir(path)
.output()?;
let merged = git.merged_branches("main")?;
assert!(merged.contains(&"feature/test".to_string()));
git.config_add("sync.protected", "main")?;
git.config_add("sync.protected", "release/*")?;
let protected = git.config_get_all("sync.protected")?;
assert_eq!(protected, vec!["main", "release/*"]);
assert!(git.config_section_exists("sync")?);
assert!(!git.config_section_exists("nonexistent")?);
Ok(())
}
#[test]
fn test_branch_delete() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["checkout", "-b", "feature/to-delete"])
.current_dir(path)
.output()?;
std::fs::write(path.join("f.txt"), "feature")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "feature"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["checkout", "main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["merge", "feature/to-delete"])
.current_dir(path)
.output()?;
let git = Git::with_workdir(false, path);
let branches = git.local_branches()?;
assert!(branches.contains(&"feature/to-delete".to_string()));
git.branch_delete("feature/to-delete")?;
let branches = git.local_branches()?;
assert!(!branches.contains(&"feature/to-delete".to_string()));
Ok(())
}
#[test]
fn test_remotes_empty() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()?;
let git = Git::with_workdir(false, path);
let remotes = git.remotes()?;
assert!(remotes.is_empty());
Ok(())
}
#[test]
fn test_cherry_merged() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["checkout", "-b", "feature/cherry-test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("cherry.txt"), "cherry content")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "cherry commit"])
.current_dir(path)
.output()?;
let sha_output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(path)
.output()?;
let sha = String::from_utf8_lossy(&sha_output.stdout)
.trim()
.to_string();
Command::new("git")
.args(["checkout", "main"])
.current_dir(path)
.output()?;
std::fs::write(path.join("diverge.txt"), "diverge")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "diverge"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["cherry-pick", &sha])
.current_dir(path)
.output()?;
let git = Git::with_workdir(false, path);
assert!(git.cherry_merged("main", "feature/cherry-test")?);
Command::new("git")
.args(["checkout", "-b", "feature/not-cherry"])
.current_dir(path)
.output()?;
std::fs::write(path.join("not-cherry.txt"), "not cherry")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "not cherry-picked"])
.current_dir(path)
.output()?;
assert!(!git.cherry_merged("main", "feature/not-cherry")?);
Ok(())
}
#[test]
fn test_worktree_list_integration() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["branch", "feature/wt"])
.current_dir(path)
.output()?;
let wt_path = path.join("wt-dir");
Command::new("git")
.args(["worktree", "add", wt_path.to_str().unwrap(), "feature/wt"])
.current_dir(path)
.output()?;
let git = Git::with_workdir(false, path);
let worktrees = git.worktree_list()?;
assert!(worktrees.len() >= 2);
let wt_branches: Vec<Option<&str>> =
worktrees.iter().map(|wt| wt.branch.as_deref()).collect();
assert!(wt_branches.contains(&Some("main")));
assert!(wt_branches.contains(&Some("feature/wt")));
Ok(())
}
#[test]
fn test_branch_protected_list_empty() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()?;
let git = Git::with_workdir(false, path);
let protected = git.branch_protected_list()?;
assert!(protected.is_empty());
Ok(())
}
#[test]
fn test_branch_protected_list() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "branch.develop.sync-protected", "true"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "branch.staging.sync-protected", "true"])
.current_dir(path)
.output()?;
let git = Git::with_workdir(false, path);
let mut protected = git.branch_protected_list()?;
protected.sort();
assert_eq!(protected, vec!["develop", "staging"]);
Ok(())
}
#[test]
fn test_set_branch_protected_and_unset() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()?;
std::fs::write(path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()?;
let git = Git::with_workdir(false, path);
git.set_branch_protected("develop", true)?;
let protected = git.branch_protected_list()?;
assert_eq!(protected, vec!["develop"]);
git.set_branch_protected("develop", false)?;
let protected = git.branch_protected_list()?;
assert!(protected.is_empty());
git.set_branch_protected("nonexistent", false)?;
Ok(())
}
fn init_repo_with_worktree_config()
-> Result<(tempfile::TempDir, std::path::PathBuf, std::path::PathBuf)> {
let dir = tempfile::tempdir()?;
let main_path = dir.path().join("main-repo");
std::fs::create_dir_all(&main_path)?;
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&main_path)
.output()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&main_path)
.output()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&main_path)
.output()?;
std::fs::write(main_path.join("README.md"), "# test")?;
Command::new("git")
.args(["add", "."])
.current_dir(&main_path)
.output()?;
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&main_path)
.output()?;
Command::new("git")
.args(["config", "extensions.worktreeConfig", "true"])
.current_dir(&main_path)
.output()?;
Command::new("git")
.args(["branch", "feature/wt"])
.current_dir(&main_path)
.output()?;
let wt_path = dir.path().join("linked-wt");
Command::new("git")
.args(["worktree", "add", wt_path.to_str().unwrap(), "feature/wt"])
.current_dir(&main_path)
.output()?;
Ok((dir, main_path, wt_path))
}
#[test]
fn test_config_set_from_linked_worktree_writes_to_shared_config() -> Result<()> {
let (_dir, main_path, wt_path) = init_repo_with_worktree_config()?;
let git_wt = Git::with_workdir(false, &wt_path);
git_wt.config_set("sync.worktrunk", "true")?;
let git_main = Git::with_workdir(false, &main_path);
let val = git_main.config_get("sync.worktrunk")?;
assert_eq!(val.as_deref(), Some("true"));
Ok(())
}
#[test]
fn test_config_add_from_linked_worktree_writes_to_shared_config() -> Result<()> {
let (_dir, main_path, wt_path) = init_repo_with_worktree_config()?;
let git_wt = Git::with_workdir(false, &wt_path);
git_wt.config_add("sync.protected", "main")?;
git_wt.config_add("sync.protected", "release/*")?;
let git_main = Git::with_workdir(false, &main_path);
let protected = git_main.config_get_all("sync.protected")?;
assert_eq!(protected, vec!["main", "release/*"]);
Ok(())
}
#[test]
fn test_config_unset_all_from_linked_worktree_clears_shared_config() -> Result<()> {
let (_dir, main_path, wt_path) = init_repo_with_worktree_config()?;
let git_main = Git::with_workdir(false, &main_path);
git_main.config_add("sync.protected", "main")?;
git_main.config_add("sync.protected", "develop")?;
let git_wt = Git::with_workdir(false, &wt_path);
git_wt.config_unset_all("sync.protected")?;
let protected = git_main.config_get_all("sync.protected")?;
assert!(protected.is_empty());
Ok(())
}
#[test]
fn test_set_branch_protected_from_linked_worktree() -> Result<()> {
let (_dir, main_path, wt_path) = init_repo_with_worktree_config()?;
let git_wt = Git::with_workdir(false, &wt_path);
git_wt.set_branch_protected("develop", true)?;
let git_main = Git::with_workdir(false, &main_path);
let protected = git_main.branch_protected_list()?;
assert_eq!(protected, vec!["develop"]);
git_wt.set_branch_protected("develop", false)?;
let protected = git_main.branch_protected_list()?;
assert!(protected.is_empty());
Ok(())
}
#[test]
fn test_config_section_exists_across_worktrees() -> Result<()> {
let (_dir, main_path, wt_path) = init_repo_with_worktree_config()?;
let git_wt = Git::with_workdir(false, &wt_path);
git_wt.config_add("sync.protected", "main")?;
assert!(git_wt.config_section_exists("sync")?);
let git_main = Git::with_workdir(false, &main_path);
assert!(git_main.config_section_exists("sync")?);
Ok(())
}
}