track/services/
git_worktree.rs1use crate::utils::{Result, TrackError};
2use std::path::Path;
3use std::process::Command;
4
5pub fn git_branch_name(slug: &str) -> String {
7 format!("track/{slug}")
8}
9
10pub 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
38pub 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
102pub 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}