sqry_core/git/
worktree.rs1use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use tempfile::TempDir;
10
11use super::{GitError, Result};
12
13#[derive(Debug)]
36pub struct WorktreeManager {
37 base_dir: TempDir,
38 target_dir: TempDir,
39 repo_path: PathBuf,
40}
41
42impl WorktreeManager {
43 pub fn create(repo_path: &Path, base_ref: &str, target_ref: &str) -> Result<Self> {
58 if !repo_path.join(".git").exists() {
60 return Err(GitError::NotARepo(repo_path.to_path_buf()));
61 }
62
63 Self::validate_ref(repo_path, base_ref)?;
65 Self::validate_ref(repo_path, target_ref)?;
66
67 let base_dir = TempDir::new().map_err(|e| {
69 GitError::Io(std::io::Error::other(format!(
70 "Failed to create temporary directory for base worktree: {e}"
71 )))
72 })?;
73 let target_dir = TempDir::new().map_err(|e| {
74 GitError::Io(std::io::Error::other(format!(
75 "Failed to create temporary directory for target worktree: {e}"
76 )))
77 })?;
78
79 Self::create_worktree(repo_path, base_ref, base_dir.path())?;
81 Self::create_worktree(repo_path, target_ref, target_dir.path())?;
82
83 tracing::debug!(
84 base_ref = %base_ref,
85 target_ref = %target_ref,
86 base_path = %base_dir.path().display(),
87 target_path = %target_dir.path().display(),
88 "Created git worktrees"
89 );
90
91 Ok(Self {
92 base_dir,
93 target_dir,
94 repo_path: repo_path.to_path_buf(),
95 })
96 }
97
98 #[must_use]
100 pub fn base_path(&self) -> &Path {
101 self.base_dir.path()
102 }
103
104 #[must_use]
106 pub fn target_path(&self) -> &Path {
107 self.target_dir.path()
108 }
109
110 #[must_use]
112 pub fn repo_path(&self) -> &Path {
113 &self.repo_path
114 }
115
116 fn validate_ref(repo_path: &Path, git_ref: &str) -> Result<()> {
118 let output = Command::new("git")
119 .current_dir(repo_path)
120 .args(["rev-parse", "--verify", git_ref])
121 .output()
122 .map_err(|e| {
123 if e.kind() == std::io::ErrorKind::NotFound {
124 GitError::NotFound
125 } else {
126 GitError::Io(e)
127 }
128 })?;
129
130 if !output.status.success() {
131 let stderr = String::from_utf8_lossy(&output.stderr);
132 return Err(GitError::CommandFailed {
133 message: format!("Git ref '{git_ref}' does not exist or is invalid"),
134 stdout: String::new(),
135 stderr: stderr.trim().to_string(),
136 });
137 }
138
139 Ok(())
140 }
141
142 fn create_worktree(repo_path: &Path, git_ref: &str, worktree_path: &Path) -> Result<()> {
144 let worktree_str = worktree_path.to_str().ok_or_else(|| {
145 GitError::InvalidOutput(format!(
146 "Invalid worktree path: {}",
147 worktree_path.display()
148 ))
149 })?;
150
151 let output = Command::new("git")
152 .current_dir(repo_path)
153 .args(["worktree", "add", "--detach", worktree_str, git_ref])
154 .output()
155 .map_err(|e| {
156 if e.kind() == std::io::ErrorKind::NotFound {
157 GitError::NotFound
158 } else {
159 GitError::Io(e)
160 }
161 })?;
162
163 if !output.status.success() {
164 let stderr = String::from_utf8_lossy(&output.stderr);
165 return Err(GitError::CommandFailed {
166 message: format!("Git worktree creation failed for ref '{git_ref}'"),
167 stdout: String::new(),
168 stderr: stderr.trim().to_string(),
169 });
170 }
171
172 Ok(())
173 }
174
175 fn remove_worktree(repo_path: &Path, worktree_path: &Path) {
177 let result = Command::new("git")
178 .current_dir(repo_path)
179 .args([
180 "worktree",
181 "remove",
182 "--force",
183 worktree_path.to_str().unwrap_or(""),
184 ])
185 .output();
186
187 match result {
188 Ok(output) if output.status.success() => {
189 tracing::debug!(
190 path = %worktree_path.display(),
191 "Removed git worktree"
192 );
193 }
194 Ok(output) => {
195 let stderr = String::from_utf8_lossy(&output.stderr);
196 tracing::warn!(
197 path = %worktree_path.display(),
198 error = %stderr.trim(),
199 "Failed to remove git worktree"
200 );
201 }
202 Err(e) => {
203 tracing::warn!(
204 path = %worktree_path.display(),
205 error = %e,
206 "Failed to execute git worktree remove"
207 );
208 }
209 }
210 }
211}
212
213impl Drop for WorktreeManager {
214 fn drop(&mut self) {
215 Self::remove_worktree(&self.repo_path, self.base_dir.path());
218 Self::remove_worktree(&self.repo_path, self.target_dir.path());
219
220 tracing::debug!("WorktreeManager dropped, worktrees cleaned up");
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn test_worktree_create_valid_refs() -> Result<()> {
230 let repo_path = Path::new(".");
232 if !repo_path.join(".git").exists() {
233 eprintln!("Skipping test: not in a git repository");
234 return Ok(());
235 }
236
237 let manager = WorktreeManager::create(repo_path, "HEAD", "HEAD")?;
239
240 assert!(manager.base_path().exists());
242 assert!(manager.target_path().exists());
243
244 assert!(manager.base_path().join(".git").exists());
246 assert!(manager.target_path().join(".git").exists());
247
248 Ok(())
250 }
251
252 #[test]
253 fn test_worktree_create_invalid_ref() {
254 let repo_path = Path::new(".");
255 if !repo_path.join(".git").exists() {
256 eprintln!("Skipping test: not in a git repository");
257 return;
258 }
259
260 let result = WorktreeManager::create(repo_path, "this-ref-does-not-exist-12345", "HEAD");
261
262 assert!(result.is_err());
263 }
264
265 #[test]
266 fn test_worktree_cleanup_on_drop() -> Result<()> {
267 let repo_path = Path::new(".");
268 if !repo_path.join(".git").exists() {
269 eprintln!("Skipping test: not in a git repository");
270 return Ok(());
271 }
272
273 let base_path: PathBuf;
274 let target_path: PathBuf;
275
276 {
277 let manager = WorktreeManager::create(repo_path, "HEAD", "HEAD")?;
278 base_path = manager.base_path().to_path_buf();
279 target_path = manager.target_path().to_path_buf();
280
281 assert!(base_path.exists());
282 assert!(target_path.exists());
283
284 }
286
287 std::thread::sleep(std::time::Duration::from_millis(100));
289
290 let output = Command::new("git")
292 .current_dir(repo_path)
293 .args(["worktree", "list"])
294 .output()?;
295
296 let worktree_list = String::from_utf8_lossy(&output.stdout);
297 assert!(!worktree_list.contains(&base_path.to_string_lossy().to_string()));
298 assert!(!worktree_list.contains(&target_path.to_string_lossy().to_string()));
299
300 Ok(())
301 }
302}