sqry_core/git/
worktree.rs1use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use tempfile::TempDir;
10
11use super::{GitError, Result};
12
13pub fn resolve_ref_to_commit(repo_path: &Path, git_ref: &str) -> Result<String> {
28 let output = Command::new("git")
29 .current_dir(repo_path)
30 .args(["rev-parse", "--verify", &format!("{git_ref}^{{commit}}")])
31 .output()
32 .map_err(|e| {
33 if e.kind() == std::io::ErrorKind::NotFound {
34 GitError::NotFound
35 } else {
36 GitError::Io(e)
37 }
38 })?;
39
40 if !output.status.success() {
41 let stderr = String::from_utf8_lossy(&output.stderr);
42 return Err(GitError::CommandFailed {
43 message: format!("Failed to resolve git ref '{git_ref}' to a commit"),
44 stdout: String::new(),
45 stderr: stderr.trim().to_string(),
46 });
47 }
48
49 let stdout = String::from_utf8(output.stdout).map_err(|e| {
50 GitError::InvalidOutput(format!(
51 "git rev-parse returned non-UTF-8 output for ref '{git_ref}': {e}"
52 ))
53 })?;
54 let sha = stdout.trim();
55 if sha.is_empty() {
56 return Err(GitError::InvalidOutput(format!(
57 "git rev-parse returned empty output for ref '{git_ref}'"
58 )));
59 }
60 Ok(sha.to_string())
61}
62
63#[derive(Debug)]
86pub struct WorktreeManager {
87 base_dir: TempDir,
88 target_dir: TempDir,
89 repo_path: PathBuf,
90}
91
92impl WorktreeManager {
93 pub fn create(repo_path: &Path, base_ref: &str, target_ref: &str) -> Result<Self> {
108 if !repo_path.join(".git").exists() {
110 return Err(GitError::NotARepo(repo_path.to_path_buf()));
111 }
112
113 Self::validate_ref(repo_path, base_ref)?;
115 Self::validate_ref(repo_path, target_ref)?;
116
117 let base_dir = TempDir::new().map_err(|e| {
119 GitError::Io(std::io::Error::other(format!(
120 "Failed to create temporary directory for base worktree: {e}"
121 )))
122 })?;
123 let target_dir = TempDir::new().map_err(|e| {
124 GitError::Io(std::io::Error::other(format!(
125 "Failed to create temporary directory for target worktree: {e}"
126 )))
127 })?;
128
129 Self::create_worktree(repo_path, base_ref, base_dir.path())?;
131 Self::create_worktree(repo_path, target_ref, target_dir.path())?;
132
133 tracing::debug!(
134 base_ref = %base_ref,
135 target_ref = %target_ref,
136 base_path = %base_dir.path().display(),
137 target_path = %target_dir.path().display(),
138 "Created git worktrees"
139 );
140
141 Ok(Self {
142 base_dir,
143 target_dir,
144 repo_path: repo_path.to_path_buf(),
145 })
146 }
147
148 #[must_use]
150 pub fn base_path(&self) -> &Path {
151 self.base_dir.path()
152 }
153
154 #[must_use]
156 pub fn target_path(&self) -> &Path {
157 self.target_dir.path()
158 }
159
160 #[must_use]
162 pub fn repo_path(&self) -> &Path {
163 &self.repo_path
164 }
165
166 fn validate_ref(repo_path: &Path, git_ref: &str) -> Result<()> {
168 let output = Command::new("git")
169 .current_dir(repo_path)
170 .args(["rev-parse", "--verify", git_ref])
171 .output()
172 .map_err(|e| {
173 if e.kind() == std::io::ErrorKind::NotFound {
174 GitError::NotFound
175 } else {
176 GitError::Io(e)
177 }
178 })?;
179
180 if !output.status.success() {
181 let stderr = String::from_utf8_lossy(&output.stderr);
182 return Err(GitError::CommandFailed {
183 message: format!("Git ref '{git_ref}' does not exist or is invalid"),
184 stdout: String::new(),
185 stderr: stderr.trim().to_string(),
186 });
187 }
188
189 Ok(())
190 }
191
192 fn create_worktree(repo_path: &Path, git_ref: &str, worktree_path: &Path) -> Result<()> {
194 let worktree_str = worktree_path.to_str().ok_or_else(|| {
195 GitError::InvalidOutput(format!(
196 "Invalid worktree path: {}",
197 worktree_path.display()
198 ))
199 })?;
200
201 let output = Command::new("git")
202 .current_dir(repo_path)
203 .args(["worktree", "add", "--detach", worktree_str, git_ref])
204 .output()
205 .map_err(|e| {
206 if e.kind() == std::io::ErrorKind::NotFound {
207 GitError::NotFound
208 } else {
209 GitError::Io(e)
210 }
211 })?;
212
213 if !output.status.success() {
214 let stderr = String::from_utf8_lossy(&output.stderr);
215 return Err(GitError::CommandFailed {
216 message: format!("Git worktree creation failed for ref '{git_ref}'"),
217 stdout: String::new(),
218 stderr: stderr.trim().to_string(),
219 });
220 }
221
222 Ok(())
223 }
224
225 fn remove_worktree(repo_path: &Path, worktree_path: &Path) {
227 let result = Command::new("git")
228 .current_dir(repo_path)
229 .args([
230 "worktree",
231 "remove",
232 "--force",
233 worktree_path.to_str().unwrap_or(""),
234 ])
235 .output();
236
237 match result {
238 Ok(output) if output.status.success() => {
239 tracing::debug!(
240 path = %worktree_path.display(),
241 "Removed git worktree"
242 );
243 }
244 Ok(output) => {
245 let stderr = String::from_utf8_lossy(&output.stderr);
246 tracing::warn!(
247 path = %worktree_path.display(),
248 error = %stderr.trim(),
249 "Failed to remove git worktree"
250 );
251 }
252 Err(e) => {
253 tracing::warn!(
254 path = %worktree_path.display(),
255 error = %e,
256 "Failed to execute git worktree remove"
257 );
258 }
259 }
260 }
261}
262
263impl Drop for WorktreeManager {
264 fn drop(&mut self) {
265 Self::remove_worktree(&self.repo_path, self.base_dir.path());
268 Self::remove_worktree(&self.repo_path, self.target_dir.path());
269
270 tracing::debug!("WorktreeManager dropped, worktrees cleaned up");
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn test_worktree_create_valid_refs() -> Result<()> {
280 let repo_path = Path::new(".");
282 if !repo_path.join(".git").exists() {
283 eprintln!("Skipping test: not in a git repository");
284 return Ok(());
285 }
286
287 let manager = WorktreeManager::create(repo_path, "HEAD", "HEAD")?;
289
290 assert!(manager.base_path().exists());
292 assert!(manager.target_path().exists());
293
294 assert!(manager.base_path().join(".git").exists());
296 assert!(manager.target_path().join(".git").exists());
297
298 Ok(())
300 }
301
302 #[test]
303 fn test_worktree_create_invalid_ref() {
304 let repo_path = Path::new(".");
305 if !repo_path.join(".git").exists() {
306 eprintln!("Skipping test: not in a git repository");
307 return;
308 }
309
310 let result = WorktreeManager::create(repo_path, "this-ref-does-not-exist-12345", "HEAD");
311
312 assert!(result.is_err());
313 }
314
315 #[test]
316 fn test_resolve_ref_to_commit_returns_full_sha() -> Result<()> {
317 let repo_path = Path::new(".");
318 if !repo_path.join(".git").exists() {
319 eprintln!("Skipping test: not in a git repository");
320 return Ok(());
321 }
322
323 let sha = resolve_ref_to_commit(repo_path, "HEAD")?;
324 assert_eq!(sha.len(), 40, "expected 40-char SHA-1, got: {sha:?}");
325 assert!(
326 sha.chars().all(|c| c.is_ascii_hexdigit()),
327 "expected hex SHA, got: {sha:?}"
328 );
329
330 let sha2 = resolve_ref_to_commit(repo_path, "HEAD")?;
333 assert_eq!(sha, sha2);
334 Ok(())
335 }
336
337 #[test]
338 fn test_resolve_ref_to_commit_unknown_ref_errors() {
339 let repo_path = Path::new(".");
340 if !repo_path.join(".git").exists() {
341 eprintln!("Skipping test: not in a git repository");
342 return;
343 }
344
345 let err = resolve_ref_to_commit(repo_path, "definitely-not-a-real-ref-zz12").unwrap_err();
346 assert!(
347 matches!(err, GitError::CommandFailed { .. }),
348 "expected CommandFailed for unknown ref, got: {err:?}"
349 );
350 }
351
352 #[test]
353 fn test_worktree_cleanup_on_drop() -> Result<()> {
354 let repo_path = Path::new(".");
355 if !repo_path.join(".git").exists() {
356 eprintln!("Skipping test: not in a git repository");
357 return Ok(());
358 }
359
360 let base_path: PathBuf;
361 let target_path: PathBuf;
362
363 {
364 let manager = WorktreeManager::create(repo_path, "HEAD", "HEAD")?;
365 base_path = manager.base_path().to_path_buf();
366 target_path = manager.target_path().to_path_buf();
367
368 assert!(base_path.exists());
369 assert!(target_path.exists());
370
371 }
373
374 std::thread::sleep(std::time::Duration::from_millis(100));
376
377 let output = Command::new("git")
379 .current_dir(repo_path)
380 .args(["worktree", "list"])
381 .output()?;
382
383 let worktree_list = String::from_utf8_lossy(&output.stdout);
384 assert!(!worktree_list.contains(&base_path.to_string_lossy().to_string()));
385 assert!(!worktree_list.contains(&target_path.to_string_lossy().to_string()));
386
387 Ok(())
388 }
389}