git_gardener/git/
worktree.rs1use git2::{Repository, BranchType};
2use std::path::{Path, PathBuf};
3use crate::error::{GitGardenerError, Result};
4use super::status::{GitStatus, WorktreeStatus};
5
6#[derive(Clone)]
7pub struct WorktreeInfo {
8 pub name: String,
9 pub path: PathBuf,
10 pub branch: String,
11 pub is_prunable: bool,
12 pub status: Option<GitStatus>,
13}
14
15#[derive(Clone)]
17pub struct WorktreeInfoWithStatus {
18 pub name: String,
19 pub path: PathBuf,
20 pub branch: String,
21 pub is_prunable: bool,
22 pub status: GitStatus,
23}
24
25pub struct GitWorktree {
26 repo: Repository,
27}
28
29impl GitWorktree {
30 pub fn new() -> Result<Self> {
31 let repo = Repository::open_from_env()
32 .map_err(|_| GitGardenerError::NotInRepository)?;
33 Ok(Self { repo })
34 }
35
36 pub fn from_path(path: &Path) -> Result<Self> {
37 let repo = Repository::open(path)
38 .map_err(|_| GitGardenerError::NotInRepository)?;
39 Ok(Self { repo })
40 }
41
42 pub fn create_worktree(
43 &self,
44 _name: &str,
45 path: &Path,
46 branch_name: &str,
47 create_branch: bool,
48 ) -> Result<()> {
49 if create_branch && !self.branch_exists(branch_name)? {
51 let head = self.repo.head()?;
53 let commit = head.peel_to_commit()?;
54 let _branch = self.repo.branch(branch_name, &commit, false)?;
55 }
56
57 let output = std::process::Command::new("git")
59 .args(&["worktree", "add", &path.to_string_lossy(), branch_name])
60 .output()
61 .map_err(|e| GitGardenerError::Custom(format!("Failed to execute git worktree add: {}", e)))?;
62
63 if !output.status.success() {
64 let error_msg = String::from_utf8_lossy(&output.stderr);
65 return Err(GitGardenerError::Custom(format!("git worktree add failed: {}", error_msg)));
66 }
67
68 Ok(())
69 }
70
71 pub fn create_worktree_with_commit(
72 &self,
73 _name: &str,
74 path: &Path,
75 branch_name: &str,
76 create_branch: bool,
77 commit: Option<&str>,
78 ) -> Result<()> {
79 let mut args = vec!["worktree", "add"];
81
82 if create_branch {
83 args.push("-b");
84 args.push(branch_name);
85 }
86
87 let path_str = path.to_string_lossy();
88 args.push(&path_str);
89
90 if !create_branch {
91 args.push(branch_name);
92 } else if let Some(commit) = commit {
93 args.push(commit);
94 }
95
96 let output = std::process::Command::new("git")
97 .args(&args)
98 .output()
99 .map_err(|e| GitGardenerError::Custom(format!("Failed to execute git worktree add: {}", e)))?;
100
101 if !output.status.success() {
102 let error_msg = String::from_utf8_lossy(&output.stderr);
103 return Err(GitGardenerError::Custom(format!("git worktree add failed: {}", error_msg)));
104 }
105
106 Ok(())
107 }
108
109 pub fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
110 let worktrees = self.repo.worktrees()?;
111 let mut infos = Vec::new();
112
113 for worktree_name in worktrees.iter().flatten() {
114 if let Ok(worktree) = self.repo.find_worktree(worktree_name) {
115 let path = worktree.path();
116 let is_prunable = worktree.is_prunable(None).unwrap_or(false);
117
118 let branch = self.get_worktree_branch(&worktree)?;
119
120 let status = GitStatus::from_path(&path).ok();
122
123 infos.push(WorktreeInfo {
124 name: worktree_name.to_string(),
125 path: path.to_path_buf(),
126 branch,
127 is_prunable,
128 status,
129 });
130 }
131 }
132
133 Ok(infos)
134 }
135
136 pub fn remove_worktree(&self, name: &str, force: bool) -> Result<()> {
137 let worktrees = self.list_worktrees()?;
139 let worktree_info = worktrees
140 .iter()
141 .find(|w| w.name == name)
142 .ok_or_else(|| GitGardenerError::WorktreeNotFound {
143 name: name.to_string()
144 })?;
145
146 let path_str = worktree_info.path.to_string_lossy();
148 let mut args = vec!["worktree", "remove"];
149 if force {
150 args.push("--force");
151 }
152 args.push(&path_str);
153
154 let output = std::process::Command::new("git")
155 .args(&args)
156 .output()
157 .map_err(|e| GitGardenerError::Custom(format!("Failed to execute git worktree remove: {}", e)))?;
158
159 if !output.status.success() {
160 let error_msg = String::from_utf8_lossy(&output.stderr);
161 return Err(GitGardenerError::Custom(format!("git worktree remove failed: {}", error_msg)));
162 }
163
164 Ok(())
165 }
166
167 pub fn get_repository_root(&self) -> Result<PathBuf> {
168 Ok(self.repo.workdir()
169 .ok_or_else(|| GitGardenerError::Custom(
170 "Could not determine repository root".to_string()
171 ))?
172 .to_path_buf())
173 }
174
175 fn get_worktree_branch(&self, worktree: &git2::Worktree) -> Result<String> {
176 let worktree_repo = Repository::open(worktree.path())?;
177
178 if let Ok(head) = worktree_repo.head() {
179 if let Some(name) = head.shorthand() {
180 return Ok(name.to_string());
181 }
182 }
183
184 Ok("(unknown)".to_string())
185 }
186
187 pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
188 let branches = self.repo.branches(Some(BranchType::Local))?;
189
190 for branch_result in branches {
191 if let Ok((branch, _)) = branch_result {
192 if let Some(name) = branch.name()? {
193 if name == branch_name {
194 return Ok(true);
195 }
196 }
197 }
198 }
199
200 Ok(false)
201 }
202
203 pub fn is_branch_merged(&self, branch_name: &str, base_branch: &str) -> Result<bool> {
205 let branch_ref = format!("refs/heads/{}", branch_name);
207 let base_ref = format!("refs/heads/{}", base_branch);
208
209 let branch_commit = match self.repo.find_reference(&branch_ref) {
210 Ok(reference) => {
211 let oid = reference.target().ok_or_else(|| {
212 GitGardenerError::Custom(format!("Branch {} has no target", branch_name))
213 })?;
214 self.repo.find_commit(oid)?
215 }
216 Err(_) => {
217 return Ok(false); }
219 };
220
221 let base_commit = match self.repo.find_reference(&base_ref) {
222 Ok(reference) => {
223 let oid = reference.target().ok_or_else(|| {
224 GitGardenerError::Custom(format!("Base branch {} has no target", base_branch))
225 })?;
226 self.repo.find_commit(oid)?
227 }
228 Err(_) => {
229 return Ok(false); }
231 };
232
233 let is_ancestor = self.repo.graph_descendant_of(base_commit.id(), branch_commit.id())?;
235
236 Ok(is_ancestor)
237 }
238
239 pub fn is_worktree_stale(&self, branch_name: &str, days: u32) -> Result<bool> {
241 let branch_ref = format!("refs/heads/{}", branch_name);
243
244 let branch_commit = match self.repo.find_reference(&branch_ref) {
245 Ok(reference) => {
246 let oid = reference.target().ok_or_else(|| {
247 GitGardenerError::Custom(format!("Branch {} has no target", branch_name))
248 })?;
249 self.repo.find_commit(oid)?
250 }
251 Err(_) => {
252 return Ok(false);
254 }
255 };
256
257 let commit_time = branch_commit.time().seconds();
259
260 let now = std::time::SystemTime::now()
262 .duration_since(std::time::UNIX_EPOCH)
263 .unwrap()
264 .as_secs() as i64;
265 let threshold_time = now - (days as i64 * 24 * 60 * 60);
266
267 Ok(commit_time < threshold_time)
269 }
270
271 pub fn list_worktrees_with_status(&self) -> Result<Vec<WorktreeInfoWithStatus>> {
273 let worktrees = self.list_worktrees()?;
274 let mut result = Vec::new();
275
276 for worktree in worktrees {
277 let status = GitStatus::from_path(&worktree.path).unwrap_or_else(|_| {
279 GitStatus {
281 working_tree_status: WorktreeStatus::Clean,
282 has_staged_changes: false,
283 has_unstaged_changes: false,
284 last_commit_time: None,
285 ahead_count: 0,
286 behind_count: 0,
287 }
288 });
289
290 result.push(WorktreeInfoWithStatus {
291 name: worktree.name,
292 path: worktree.path,
293 branch: worktree.branch,
294 is_prunable: worktree.is_prunable,
295 status,
296 });
297 }
298
299 Ok(result)
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use tempfile::tempdir;
307
308 #[test]
309 fn test_not_in_repository() {
310 let temp_dir = tempdir().unwrap();
311 std::env::set_current_dir(&temp_dir).unwrap();
312
313 let result = GitWorktree::new();
314 assert!(matches!(result, Err(GitGardenerError::NotInRepository)));
315 }
316}