Skip to main content

sqry_core/git/
worktree.rs

1//! Git worktree management for semantic diff operations.
2//!
3//! This module provides RAII-based management of temporary git worktrees,
4//! used for comparing code between different git refs.
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use tempfile::TempDir;
10
11use super::{GitError, Result};
12
13/// Resolves a git ref (commit, branch, tag) to its full commit SHA.
14///
15/// Equivalent to `git -C <repo> rev-parse <ref>^{commit}`. Used by diff
16/// callers to short-circuit when both refs resolve to the same commit
17/// (the worktree-build + graph-build pipeline is expensive on large
18/// repositories — `HEAD HEAD` against a kernel-scale tree blew the
19/// 90s CLI / 120s MCP deadlines before this helper was wired in).
20///
21/// # Errors
22///
23/// Returns `GitError::NotFound` if `git` is not in `PATH`,
24/// `GitError::CommandFailed` if the ref cannot be resolved (unknown ref,
25/// or a tag that does not point at a commit), and `GitError::InvalidOutput`
26/// if git returned non-UTF-8 or empty stdout.
27pub 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/// Manages temporary git worktrees with RAII cleanup guarantees.
64///
65/// Creates two temporary worktrees for comparing different git refs (commits,
66/// branches, or tags). Both worktrees are automatically cleaned up when
67/// the manager is dropped.
68///
69/// # Example
70///
71/// ```no_run
72/// use sqry_core::git::WorktreeManager;
73/// use std::path::Path;
74///
75/// let repo = Path::new("/path/to/repo");
76/// let manager = WorktreeManager::create(repo, "main", "feature-branch")?;
77///
78/// // Access worktree paths
79/// let base_path = manager.base_path();
80/// let target_path = manager.target_path();
81///
82/// // Worktrees are cleaned up when manager goes out of scope
83/// # Ok::<(), sqry_core::git::GitError>(())
84/// ```
85#[derive(Debug)]
86pub struct WorktreeManager {
87    base_dir: TempDir,
88    target_dir: TempDir,
89    repo_path: PathBuf,
90}
91
92impl WorktreeManager {
93    /// Creates two temporary worktrees for the given refs.
94    ///
95    /// # Arguments
96    ///
97    /// * `repo_path` - Path to the git repository root
98    /// * `base_ref` - Git ref for the base version (commit, branch, tag)
99    /// * `target_ref` - Git ref for the target version (commit, branch, tag)
100    ///
101    /// # Errors
102    ///
103    /// Returns error if:
104    /// - Not a git repository
105    /// - Refs don't exist
106    /// - Git worktree creation fails
107    pub fn create(repo_path: &Path, base_ref: &str, target_ref: &str) -> Result<Self> {
108        // Validate that this is a git repository
109        if !repo_path.join(".git").exists() {
110            return Err(GitError::NotARepo(repo_path.to_path_buf()));
111        }
112
113        // Validate refs exist
114        Self::validate_ref(repo_path, base_ref)?;
115        Self::validate_ref(repo_path, target_ref)?;
116
117        // Create temp directories
118        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        // Create worktrees
130        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    /// Returns the path to the base worktree.
149    #[must_use]
150    pub fn base_path(&self) -> &Path {
151        self.base_dir.path()
152    }
153
154    /// Returns the path to the target worktree.
155    #[must_use]
156    pub fn target_path(&self) -> &Path {
157        self.target_dir.path()
158    }
159
160    /// Returns the original repository path.
161    #[must_use]
162    pub fn repo_path(&self) -> &Path {
163        &self.repo_path
164    }
165
166    /// Validates that a git ref exists.
167    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    /// Creates a git worktree at the specified path.
193    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    /// Removes a worktree forcefully.
226    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        // Clean up worktrees
266        // Note: We don't panic in drop, just log warnings if cleanup fails
267        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        // This test requires being in a git repository
281        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        // Use HEAD as both refs (should always exist)
288        let manager = WorktreeManager::create(repo_path, "HEAD", "HEAD")?;
289
290        // Verify directories exist
291        assert!(manager.base_path().exists());
292        assert!(manager.target_path().exists());
293
294        // Verify they contain .git files (worktree marker)
295        assert!(manager.base_path().join(".git").exists());
296        assert!(manager.target_path().join(".git").exists());
297
298        // Cleanup happens automatically on drop
299        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        // Idempotence: resolving HEAD twice yields the same SHA, which is
331        // the invariant that powers the diff fast-path in #213.
332        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            // Manager drops here
372        }
373
374        // Give filesystem a moment to clean up
375        std::thread::sleep(std::time::Duration::from_millis(100));
376
377        // Verify worktrees are no longer listed
378        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}