Skip to main content

track/services/
git_worktree.rs

1use crate::utils::{Result, TrackError};
2use std::path::Path;
3use std::process::Command;
4
5/// Git branch name for a task slug.
6pub fn git_branch_name(slug: &str) -> String {
7    format!("track/{slug}")
8}
9
10/// Expected git worktree path (`.worktrees/<slug>/`).
11pub fn git_worktree_path(repo_path: &str, slug: &str) -> String {
12    Path::new(repo_path)
13        .join(".worktrees")
14        .join(slug)
15        .to_string_lossy()
16        .into_owned()
17}
18
19pub fn is_git_repository(repo_path: &str) -> bool {
20    Command::new("git")
21        .args(["-C", repo_path, "rev-parse", "--git-dir"])
22        .output()
23        .map(|output| output.status.success())
24        .unwrap_or(false)
25}
26
27pub fn git_worktree_exists(path: &str) -> bool {
28    Path::new(path).is_dir()
29}
30
31pub fn branch_exists(repo_path: &str, branch: &str) -> Result<bool> {
32    let output = Command::new("git")
33        .args(["-C", repo_path, "show-ref", "--verify", "--quiet", branch])
34        .output()?;
35    Ok(output.status.success())
36}
37
38/// Create a git worktree and branch for a task slug.
39pub fn create_git_worktree(repo_path: &str, slug: &str, base_ref: &str) -> Result<String> {
40    if !is_git_repository(repo_path) {
41        return Err(TrackError::Other(format!(
42            "Not a git repository: {repo_path}"
43        )));
44    }
45
46    let worktree_path = git_worktree_path(repo_path, slug);
47    if git_worktree_exists(&worktree_path) {
48        return Ok(worktree_path);
49    }
50
51    if let Some(parent) = Path::new(&worktree_path).parent() {
52        std::fs::create_dir_all(parent)?;
53    }
54
55    let branch = git_branch_name(slug);
56
57    let fetch = Command::new("git")
58        .args(["-C", repo_path, "fetch", "--all", "--prune"])
59        .output()?;
60    if !fetch.status.success() {
61        let stderr = String::from_utf8_lossy(&fetch.stderr);
62        return Err(TrackError::Other(format!("git fetch failed: {stderr}")));
63    }
64
65    let create = if branch_exists(repo_path, &format!("refs/heads/{branch}"))? {
66        Command::new("git")
67            .args(["-C", repo_path, "worktree", "add", &worktree_path, &branch])
68            .output()?
69    } else {
70        Command::new("git")
71            .args([
72                "-C",
73                repo_path,
74                "worktree",
75                "add",
76                "-b",
77                &branch,
78                &worktree_path,
79                base_ref,
80            ])
81            .output()?
82    };
83
84    if !create.status.success() {
85        let stderr = String::from_utf8_lossy(&create.stderr);
86        return Err(TrackError::Other(format!(
87            "git worktree add failed: {stderr}"
88        )));
89    }
90
91    Ok(worktree_path)
92}
93
94pub fn repo_has_uncommitted_changes(repo_path: &str) -> Result<bool> {
95    let output = Command::new("git")
96        .args(["-C", repo_path, "status", "--porcelain"])
97        .output()?;
98    if !output.status.success() {
99        return Err(TrackError::FailedRepoStatusCheck(repo_path.to_string()));
100    }
101    Ok(!output.stdout.is_empty())
102}
103
104/// Returns true when the base repo has changes outside `.worktrees/<slug>/`.
105pub fn base_repo_has_changes(repo_path: &str, slug: &str) -> Result<bool> {
106    let output = Command::new("git")
107        .args(["-C", repo_path, "status", "--porcelain"])
108        .output()?;
109    if !output.status.success() {
110        return Err(TrackError::FailedRepoStatusCheck(repo_path.to_string()));
111    }
112
113    for line in String::from_utf8_lossy(&output.stdout).lines() {
114        if line.len() < 4 {
115            continue;
116        }
117        let path = line[3..].trim();
118        if path.is_empty() {
119            continue;
120        }
121        if !is_task_worktree_path(path, slug) {
122            return Ok(true);
123        }
124    }
125
126    Ok(false)
127}
128
129fn is_task_worktree_path(path: &str, slug: &str) -> bool {
130    path == format!(".worktrees/{slug}") || path.starts_with(&format!(".worktrees/{slug}/"))
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn git_branch_and_path() {
139        assert_eq!(git_branch_name("proj-123"), "track/proj-123");
140        assert_eq!(
141            git_worktree_path("/repo/app", "proj-123"),
142            "/repo/app/.worktrees/proj-123"
143        );
144    }
145}