Skip to main content

zag_agent/
worktree.rs

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
10/// Compute the base directory for worktrees: `~/.zag/worktrees/<sanitized-repo-path>/`.
11pub 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
20/// Get the git repository root from a given directory (or current directory).
21pub 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
40/// Generate a random worktree name like `zag-a1b2c3d4`.
41pub 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    // Simple hash-like hex from timestamp + pid
48    let hash = seed ^ (std::process::id() as u128);
49    format!("zag-{:08x}", (hash & 0xFFFF_FFFF) as u32)
50}
51
52/// Create a git worktree at `~/.zag/worktrees/<sanitized-repo-path>/<name>` using detached HEAD.
53/// Returns the path to the new worktree directory.
54pub 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
79/// Check if a worktree has any uncommitted changes (staged, unstaged, or untracked).
80pub 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
96/// Check if a worktree has commits not present on any remote-tracking branch.
97pub 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
113/// Remove a git worktree at the given path.
114pub 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}