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/// Manages temporary git worktrees with RAII cleanup guarantees.
14///
15/// Creates two temporary worktrees for comparing different git refs (commits,
16/// branches, or tags). Both worktrees are automatically cleaned up when
17/// the manager is dropped.
18///
19/// # Example
20///
21/// ```no_run
22/// use sqry_core::git::WorktreeManager;
23/// use std::path::Path;
24///
25/// let repo = Path::new("/path/to/repo");
26/// let manager = WorktreeManager::create(repo, "main", "feature-branch")?;
27///
28/// // Access worktree paths
29/// let base_path = manager.base_path();
30/// let target_path = manager.target_path();
31///
32/// // Worktrees are cleaned up when manager goes out of scope
33/// # Ok::<(), sqry_core::git::GitError>(())
34/// ```
35#[derive(Debug)]
36pub struct WorktreeManager {
37    base_dir: TempDir,
38    target_dir: TempDir,
39    repo_path: PathBuf,
40}
41
42impl WorktreeManager {
43    /// Creates two temporary worktrees for the given refs.
44    ///
45    /// # Arguments
46    ///
47    /// * `repo_path` - Path to the git repository root
48    /// * `base_ref` - Git ref for the base version (commit, branch, tag)
49    /// * `target_ref` - Git ref for the target version (commit, branch, tag)
50    ///
51    /// # Errors
52    ///
53    /// Returns error if:
54    /// - Not a git repository
55    /// - Refs don't exist
56    /// - Git worktree creation fails
57    pub fn create(repo_path: &Path, base_ref: &str, target_ref: &str) -> Result<Self> {
58        // Validate that this is a git repository
59        if !repo_path.join(".git").exists() {
60            return Err(GitError::NotARepo(repo_path.to_path_buf()));
61        }
62
63        // Validate refs exist
64        Self::validate_ref(repo_path, base_ref)?;
65        Self::validate_ref(repo_path, target_ref)?;
66
67        // Create temp directories
68        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        // Create worktrees
80        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    /// Returns the path to the base worktree.
99    #[must_use]
100    pub fn base_path(&self) -> &Path {
101        self.base_dir.path()
102    }
103
104    /// Returns the path to the target worktree.
105    #[must_use]
106    pub fn target_path(&self) -> &Path {
107        self.target_dir.path()
108    }
109
110    /// Returns the original repository path.
111    #[must_use]
112    pub fn repo_path(&self) -> &Path {
113        &self.repo_path
114    }
115
116    /// Validates that a git ref exists.
117    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    /// Creates a git worktree at the specified path.
143    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    /// Removes a worktree forcefully.
176    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        // Clean up worktrees
216        // Note: We don't panic in drop, just log warnings if cleanup fails
217        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        // This test requires being in a git repository
231        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        // Use HEAD as both refs (should always exist)
238        let manager = WorktreeManager::create(repo_path, "HEAD", "HEAD")?;
239
240        // Verify directories exist
241        assert!(manager.base_path().exists());
242        assert!(manager.target_path().exists());
243
244        // Verify they contain .git files (worktree marker)
245        assert!(manager.base_path().join(".git").exists());
246        assert!(manager.target_path().join(".git").exists());
247
248        // Cleanup happens automatically on drop
249        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            // Manager drops here
285        }
286
287        // Give filesystem a moment to clean up
288        std::thread::sleep(std::time::Duration::from_millis(100));
289
290        // Verify worktrees are no longer listed
291        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}