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::NotGitRepository(repo_path.to_string()));
42    }
43
44    let worktree_path = git_worktree_path(repo_path, slug);
45    if git_worktree_exists(&worktree_path) {
46        return Ok(worktree_path);
47    }
48
49    if let Some(parent) = Path::new(&worktree_path).parent() {
50        std::fs::create_dir_all(parent)?;
51    }
52
53    let branch = git_branch_name(slug);
54
55    let fetch = Command::new("git")
56        .args(["-C", repo_path, "fetch", "--all", "--prune"])
57        .output()?;
58    if !fetch.status.success() {
59        let stderr = String::from_utf8_lossy(&fetch.stderr);
60        return Err(TrackError::Git(format!("git fetch failed: {stderr}")));
61    }
62
63    let create = if branch_exists(repo_path, &format!("refs/heads/{branch}"))? {
64        Command::new("git")
65            .args(["-C", repo_path, "worktree", "add", &worktree_path, &branch])
66            .output()?
67    } else {
68        Command::new("git")
69            .args([
70                "-C",
71                repo_path,
72                "worktree",
73                "add",
74                "-b",
75                &branch,
76                &worktree_path,
77                base_ref,
78            ])
79            .output()?
80    };
81
82    if !create.status.success() {
83        let stderr = String::from_utf8_lossy(&create.stderr);
84        return Err(TrackError::Git(format!(
85            "git worktree add failed: {stderr}"
86        )));
87    }
88
89    Ok(worktree_path)
90}
91
92pub fn repo_has_uncommitted_changes(repo_path: &str) -> Result<bool> {
93    let output = Command::new("git")
94        .args(["-C", repo_path, "status", "--porcelain"])
95        .output()?;
96    if !output.status.success() {
97        return Err(TrackError::FailedRepoStatusCheck(repo_path.to_string()));
98    }
99    Ok(!output.stdout.is_empty())
100}
101
102/// Returns true when the base repo has changes outside `.worktrees/<slug>/`.
103pub fn base_repo_has_changes(repo_path: &str, slug: &str) -> Result<bool> {
104    let output = Command::new("git")
105        .args(["-C", repo_path, "status", "--porcelain"])
106        .output()?;
107    if !output.status.success() {
108        return Err(TrackError::FailedRepoStatusCheck(repo_path.to_string()));
109    }
110
111    for line in String::from_utf8_lossy(&output.stdout).lines() {
112        if line.len() < 4 {
113            continue;
114        }
115        let path = line[3..].trim();
116        if path.is_empty() {
117            continue;
118        }
119        if !is_task_worktree_path(path, slug) {
120            return Ok(true);
121        }
122    }
123
124    Ok(false)
125}
126
127fn is_task_worktree_path(path: &str, slug: &str) -> bool {
128    path == format!(".worktrees/{slug}") || path.starts_with(&format!(".worktrees/{slug}/"))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn git_branch_and_path() {
137        assert_eq!(git_branch_name("proj-123"), "track/proj-123");
138        assert_eq!(
139            git_worktree_path("/repo/app", "proj-123"),
140            "/repo/app/.worktrees/proj-123"
141        );
142    }
143}