1#[cfg(test)]
2#[path = "worktree_tests.rs"]
3mod tests;
4
5use crate::config::Config;
6use anyhow::{Context, Result, bail};
7use log::debug;
8use std::path::{Path, PathBuf};
9
10pub fn worktree_base_dir(repo_root: &Path) -> PathBuf {
12 let sanitized = Config::sanitize_path(&repo_root.to_string_lossy());
13 dirs::home_dir()
14 .unwrap_or_else(|| PathBuf::from("."))
15 .join(".zag")
16 .join("worktrees")
17 .join(sanitized)
18}
19
20pub fn git_repo_root(from: Option<&str>) -> Result<PathBuf> {
22 let mut cmd = std::process::Command::new("git");
23 cmd.args(["rev-parse", "--show-toplevel"]);
24 if let Some(dir) = from {
25 cmd.current_dir(dir);
26 }
27 let output = cmd
28 .output()
29 .context("Failed to run git rev-parse --show-toplevel")?;
30 if !output.status.success() {
31 bail!("--worktree requires a git repository");
32 }
33 let root = String::from_utf8(output.stdout)
34 .context("Invalid UTF-8 in git output")?
35 .trim()
36 .to_string();
37 Ok(PathBuf::from(root))
38}
39
40pub fn generate_name() -> String {
42 use std::time::{SystemTime, UNIX_EPOCH};
43 let seed = SystemTime::now()
44 .duration_since(UNIX_EPOCH)
45 .unwrap_or_default()
46 .as_nanos();
47 let hash = seed ^ (std::process::id() as u128);
49 format!("zag-{:08x}", (hash & 0xFFFF_FFFF) as u32)
50}
51
52pub fn create_worktree(repo_root: &Path, name: &str) -> Result<PathBuf> {
55 let worktree_path = worktree_base_dir(repo_root).join(name);
56
57 debug!("Creating worktree at {}", worktree_path.display());
58
59 let output = std::process::Command::new("git")
60 .current_dir(repo_root)
61 .args([
62 "worktree",
63 "add",
64 worktree_path.to_str().unwrap(),
65 "--detach",
66 ])
67 .output()
68 .context("Failed to run git worktree add")?;
69
70 if !output.status.success() {
71 let stderr = String::from_utf8_lossy(&output.stderr);
72 bail!("Failed to create worktree: {}", stderr.trim());
73 }
74
75 debug!("Worktree created at {}", worktree_path.display());
76 Ok(worktree_path)
77}
78
79pub fn has_changes(path: &Path) -> Result<bool> {
81 let output = std::process::Command::new("git")
82 .current_dir(path)
83 .args(["status", "--porcelain"])
84 .output()
85 .context("Failed to run git status --porcelain")?;
86
87 if !output.status.success() {
88 let stderr = String::from_utf8_lossy(&output.stderr);
89 bail!("git status failed: {}", stderr.trim());
90 }
91
92 let stdout = String::from_utf8_lossy(&output.stdout);
93 Ok(!stdout.trim().is_empty())
94}
95
96pub fn has_unpushed_commits(path: &Path) -> Result<bool> {
98 let output = std::process::Command::new("git")
99 .current_dir(path)
100 .args(["log", "--oneline", "HEAD", "--not", "--remotes"])
101 .output()
102 .context("Failed to run git log")?;
103
104 if !output.status.success() {
105 let stderr = String::from_utf8_lossy(&output.stderr);
106 bail!("git log failed: {}", stderr.trim());
107 }
108
109 let stdout = String::from_utf8_lossy(&output.stdout);
110 Ok(!stdout.trim().is_empty())
111}
112
113pub fn remove_worktree(path: &Path) -> Result<()> {
115 debug!("Removing worktree at {}", path.display());
116
117 let output = std::process::Command::new("git")
118 .args(["worktree", "remove", path.to_str().unwrap(), "--force"])
119 .output()
120 .context("Failed to run git worktree remove")?;
121
122 if !output.status.success() {
123 let stderr = String::from_utf8_lossy(&output.stderr);
124 bail!("Failed to remove worktree: {}", stderr.trim());
125 }
126
127 debug!("Worktree removed at {}", path.display());
128 Ok(())
129}