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::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
104pub 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}