Skip to main content

semver_analyzer_ts/worktree/
guard.rs

1//! RAII guard for git worktree lifecycle management.
2//!
3//! Creates a temporary worktree, installs dependencies, runs tsc,
4//! and cleans up on drop (even on panic or early return).
5
6use super::error::WorktreeError;
7use super::package_manager::PackageManager;
8use super::tsc;
9use super::ExtractionWarning;
10#[cfg(test)]
11use semver_analyzer_core::git::sanitize_ref_name;
12use semver_analyzer_core::git::worktree_path_for;
13use semver_analyzer_core::traits::WorktreeAccess;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17/// RAII guard that manages a git worktree's lifecycle.
18///
19/// On construction: creates a worktree, installs dependencies, runs tsc.
20/// On drop: removes the worktree (even on panic or early return).
21///
22/// Implements `WorktreeAccess` so it can be wrapped in `Arc` and shared
23/// between TD and SD pipelines via `std::sync::mpsc::channel`.
24pub struct WorktreeGuard {
25    /// Path to the repository root.
26    repo_root: PathBuf,
27
28    /// Path to the created worktree directory.
29    worktree_path: PathBuf,
30
31    /// The git ref this worktree was created for.
32    git_ref: String,
33
34    /// Whether the worktree was successfully created (controls cleanup).
35    created: bool,
36
37    /// Non-fatal issues encountered during setup (partial tsc, fallbacks).
38    /// Inspected by the caller to record degradation.
39    warnings: Vec<ExtractionWarning>,
40}
41
42impl WorktreeGuard {
43    /// Create a new worktree for the given git ref, install dependencies,
44    /// and run `tsc --declaration`.
45    ///
46    /// This is the primary entry point. It performs the full worktree lifecycle:
47    /// 1. Validate the repo and ref
48    /// 2. Create the worktree via `git worktree add`
49    /// 3. Detect and run the package manager install
50    /// 4. Run `tsc --declaration --emitDeclarationOnly`
51    /// 5. If tsc fails partially, try the project build as a fallback
52    ///
53    /// An optional `build_command` can be provided to customize the build step.
54    /// If not provided and tsc fails, the project's `build` script is tried.
55    ///
56    /// On any failure, the worktree is cleaned up before the error propagates.
57    pub fn new(
58        repo: &Path,
59        git_ref: &str,
60        build_command: Option<&str>,
61    ) -> Result<Self, WorktreeError> {
62        // Canonicalize repo path to avoid relative path mismatches between
63        // git (which resolves paths relative to its CWD) and Rust filesystem
64        // calls (which resolve relative to the process CWD).
65        let repo = repo.canonicalize().map_err(|e| {
66            WorktreeError::CommandFailed(format!(
67                "Failed to canonicalize repo path {}: {}",
68                repo.display(),
69                e
70            ))
71        })?;
72        let repo = repo.as_path();
73
74        // Validate repo is a git repository
75        validate_git_repo(repo)?;
76
77        // Validate the ref exists
78        validate_git_ref(repo, git_ref)?;
79
80        // Determine worktree path
81        let worktree_path = worktree_path_for(repo, git_ref);
82
83        // Create the guard (Drop will handle cleanup even if later steps fail)
84        let mut guard = Self {
85            repo_root: repo.to_path_buf(),
86            worktree_path: worktree_path.clone(),
87            git_ref: git_ref.to_string(),
88            created: false,
89            warnings: Vec::new(),
90        };
91
92        // Ensure parent directory exists
93        let parent = worktree_path
94            .parent()
95            .expect("worktree path should have a parent");
96        std::fs::create_dir_all(parent)?;
97
98        // Create the worktree
99        create_worktree(repo, git_ref, &worktree_path)?;
100        guard.created = true;
101
102        // Detect and install dependencies
103        let pm = PackageManager::detect(&worktree_path).ok_or_else(|| {
104            WorktreeError::NoLockfileFound {
105                git_ref: git_ref.to_string(),
106            }
107        })?;
108
109        run_package_install(&worktree_path, pm)?;
110
111        // If user provided a build command, run it instead of tsc
112        if let Some(cmd) = build_command {
113            tracing::info!("Running user-provided build command");
114            tsc::run_project_build(&worktree_path, Some(cmd))?;
115            return Ok(guard);
116        }
117
118        // Run tsc --declaration (tries solution tsconfig, then per-package)
119        match tsc::run_tsc_declaration(&worktree_path, git_ref) {
120            Ok(tsc::TscOutcome::Success) => {
121                // Full success — all packages compiled
122            }
123            Ok(tsc::TscOutcome::Partial { succeeded, failed }) => {
124                // Partial success — try project build for better coverage
125                tracing::warn!(
126                    succeeded = succeeded,
127                    failed = failed,
128                    "tsc partial success, trying project build"
129                );
130                match tsc::run_project_build(&worktree_path, None) {
131                    Ok(()) => {
132                        // Project build succeeded — should have better coverage now
133                    }
134                    Err(e) => {
135                        // Project build also failed — proceed with partial tsc output
136                        tracing::warn!(error = %e, succeeded = succeeded, "Project build fallback failed, proceeding with partial tsc output");
137                        guard
138                            .warnings
139                            .push(ExtractionWarning::PartialTscBuildFailed {
140                                succeeded,
141                                failed,
142                                build_error: e.to_string(),
143                            });
144                    }
145                }
146            }
147            Err(e) => {
148                // Total tsc failure — try project build as last resort
149                tracing::warn!(error = %e, "tsc failed completely, trying project build as fallback");
150                match tsc::run_project_build(&worktree_path, None) {
151                    Ok(()) => {
152                        // Project build succeeded as fallback
153                        guard
154                            .warnings
155                            .push(ExtractionWarning::TscFailedBuildSucceeded {
156                                tsc_error: e.to_string(),
157                            });
158                    }
159                    Err(build_err) => {
160                        // Both tsc and project build failed — fatal
161                        tracing::warn!(error = %build_err, "Project build also failed");
162                        return Err(e);
163                    }
164                }
165            }
166        }
167
168        Ok(guard)
169    }
170
171    /// Create a worktree without installing dependencies or running tsc.
172    ///
173    /// This is useful for testing the RAII cleanup behavior, and as a
174    /// building block for `new()`.
175    pub fn create_only(repo: &Path, git_ref: &str) -> Result<Self, WorktreeError> {
176        let repo = repo.canonicalize().map_err(|e| {
177            WorktreeError::CommandFailed(format!(
178                "Failed to canonicalize repo path {}: {}",
179                repo.display(),
180                e
181            ))
182        })?;
183        let repo = repo.as_path();
184
185        validate_git_repo(repo)?;
186        validate_git_ref(repo, git_ref)?;
187
188        let worktree_path = worktree_path_for(repo, git_ref);
189
190        let mut guard = Self {
191            repo_root: repo.to_path_buf(),
192            worktree_path: worktree_path.clone(),
193            git_ref: git_ref.to_string(),
194            created: false,
195            warnings: Vec::new(),
196        };
197
198        let parent = worktree_path
199            .parent()
200            .expect("worktree path should have a parent");
201        std::fs::create_dir_all(parent)?;
202
203        create_worktree(repo, git_ref, &worktree_path)?;
204        guard.created = true;
205
206        Ok(guard)
207    }
208
209    /// Non-fatal issues encountered during worktree setup.
210    ///
211    /// The caller should inspect these after a successful `new()` and
212    /// record them on the `DegradationTracker` for the end-of-run summary.
213    pub fn warnings(&self) -> &[ExtractionWarning] {
214        &self.warnings
215    }
216
217    /// Path to the worktree directory.
218    pub fn path(&self) -> &Path {
219        &self.worktree_path
220    }
221
222    /// The git ref this worktree was created for.
223    pub fn git_ref(&self) -> &str {
224        &self.git_ref
225    }
226
227    /// Scan for and remove stale worktrees from previous crashed runs.
228    ///
229    /// Looks in `<tmp>/semver-worktrees/<repo-hash>/` for any existing
230    /// directories and attempts to clean them up via `git worktree remove`.
231    pub fn cleanup_stale(repo: &Path) -> Result<usize, WorktreeError> {
232        let repo = repo.canonicalize().map_err(|e| {
233            WorktreeError::CommandFailed(format!(
234                "Failed to canonicalize repo path {}: {}",
235                repo.display(),
236                e
237            ))
238        })?;
239        let repo = repo.as_path();
240        let worktree_dir = semver_analyzer_core::git::worktree_dir_for(repo);
241        if !worktree_dir.exists() {
242            return Ok(0);
243        }
244
245        let mut cleaned = 0;
246        let entries = std::fs::read_dir(&worktree_dir)?;
247
248        for entry in entries.flatten() {
249            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
250                let path = entry.path();
251                tracing::info!(path = %path.display(), "Cleaning up stale worktree");
252                if remove_worktree(repo, &path).is_ok() {
253                    cleaned += 1;
254                } else {
255                    // If git worktree remove fails, try force-removing the directory
256                    let _ = std::fs::remove_dir_all(&path);
257                    cleaned += 1;
258                }
259            }
260        }
261
262        // Remove the parent directory if it's now empty
263        if std::fs::read_dir(&worktree_dir)
264            .map(|mut d| d.next().is_none())
265            .unwrap_or(true)
266        {
267            let _ = std::fs::remove_dir(&worktree_dir);
268        }
269
270        Ok(cleaned)
271    }
272}
273
274impl Drop for WorktreeGuard {
275    fn drop(&mut self) {
276        if self.created {
277            if let Err(e) = remove_worktree(&self.repo_root, &self.worktree_path) {
278                tracing::warn!(
279                    path = %self.worktree_path.display(),
280                    error = %e,
281                    "Failed to remove worktree"
282                );
283                // Last resort: force remove the directory
284                let _ = std::fs::remove_dir_all(&self.worktree_path);
285            }
286        }
287    }
288}
289
290impl WorktreeAccess for WorktreeGuard {
291    fn path(&self) -> &Path {
292        &self.worktree_path
293    }
294}
295
296/// Validate that the given path is a git repository.
297fn validate_git_repo(repo: &Path) -> Result<(), WorktreeError> {
298    let output = Command::new("git")
299        .args(["rev-parse", "--git-dir"])
300        .current_dir(repo)
301        .output()
302        .map_err(|e| WorktreeError::CommandFailed(format!("Failed to run git: {e}")))?;
303
304    if output.status.success() {
305        Ok(())
306    } else {
307        Err(WorktreeError::NotAGitRepo {
308            path: repo.to_path_buf(),
309        })
310    }
311}
312
313/// Validate that a git ref exists in the repository.
314fn validate_git_ref(repo: &Path, git_ref: &str) -> Result<(), WorktreeError> {
315    let output = Command::new("git")
316        .args(["rev-parse", "--verify", git_ref])
317        .current_dir(repo)
318        .output()
319        .map_err(|e| WorktreeError::CommandFailed(format!("Failed to run git: {e}")))?;
320
321    if output.status.success() {
322        Ok(())
323    } else {
324        Err(WorktreeError::RefNotFound {
325            git_ref: git_ref.to_string(),
326        })
327    }
328}
329
330/// Create a git worktree at the given path for the given ref.
331fn create_worktree(repo: &Path, git_ref: &str, worktree_path: &Path) -> Result<(), WorktreeError> {
332    // Remove any existing directory at this path (stale from a previous run)
333    if worktree_path.exists() {
334        let _ = remove_worktree(repo, worktree_path);
335        let _ = std::fs::remove_dir_all(worktree_path);
336    }
337
338    let output = Command::new("git")
339        .args([
340            "worktree",
341            "add",
342            "--detach",
343            &worktree_path.to_string_lossy(),
344            git_ref,
345        ])
346        .current_dir(repo)
347        .output()
348        .map_err(|e| {
349            WorktreeError::CommandFailed(format!("Failed to run git worktree add: {e}"))
350        })?;
351
352    if output.status.success() {
353        Ok(())
354    } else {
355        let stderr = String::from_utf8_lossy(&output.stderr);
356        Err(WorktreeError::WorktreeCreationFailed {
357            path: worktree_path.to_path_buf(),
358            reason: stderr.trim().to_string(),
359        })
360    }
361}
362
363/// Remove a git worktree.
364fn remove_worktree(repo: &Path, worktree_path: &Path) -> Result<(), WorktreeError> {
365    let output = Command::new("git")
366        .args([
367            "worktree",
368            "remove",
369            "--force",
370            &worktree_path.to_string_lossy(),
371        ])
372        .current_dir(repo)
373        .output()
374        .map_err(|e| {
375            WorktreeError::CommandFailed(format!("Failed to run git worktree remove: {e}"))
376        })?;
377
378    if output.status.success() {
379        Ok(())
380    } else {
381        let stderr = String::from_utf8_lossy(&output.stderr);
382        Err(WorktreeError::WorktreeRemovalFailed {
383            path: worktree_path.to_path_buf(),
384            reason: stderr.trim().to_string(),
385        })
386    }
387}
388
389/// Run the package manager install command in the worktree directory.
390fn run_package_install(worktree_dir: &Path, pm: PackageManager) -> Result<(), WorktreeError> {
391    let (cmd, args) = pm.install_command(worktree_dir);
392    let display_cmd = format!("{cmd} {}", args.join(" "));
393
394    let output = Command::new(cmd)
395        .args(args)
396        .current_dir(worktree_dir)
397        .output()
398        .map_err(|e| WorktreeError::PackageInstallFailed {
399            command: display_cmd.clone(),
400            reason: format!("Failed to execute: {e}"),
401        })?;
402
403    if output.status.success() {
404        Ok(())
405    } else {
406        let stderr = String::from_utf8_lossy(&output.stderr);
407        Err(WorktreeError::PackageInstallFailed {
408            command: display_cmd,
409            reason: stderr.trim().to_string(),
410        })
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use std::process::Command as StdCommand;
418    use tempfile::TempDir;
419
420    // -- Pure unit tests (no git needed) --
421
422    #[test]
423    fn sanitize_simple_ref() {
424        assert_eq!(sanitize_ref_name("v1.0.0"), "v1.0.0");
425    }
426
427    #[test]
428    fn sanitize_ref_with_slashes() {
429        assert_eq!(sanitize_ref_name("feature/my-branch"), "feature_my-branch");
430    }
431
432    #[test]
433    fn sanitize_ref_with_special_chars() {
434        assert_eq!(
435            sanitize_ref_name("ref:with*special?chars"),
436            "ref_with_special_chars"
437        );
438    }
439
440    #[test]
441    fn sanitize_long_ref_truncated() {
442        let long_ref = "a".repeat(150);
443        let result = sanitize_ref_name(&long_ref);
444        assert_eq!(result.len(), 100);
445    }
446
447    #[test]
448    fn worktree_path_in_tmp_dir() {
449        let repo = Path::new("/repos/my-project");
450        let path = worktree_path_for(repo, "v1.0.0");
451        // Should be in the system temp dir, not inside the repo
452        assert!(!path.starts_with(repo));
453        assert!(path.ends_with("v1.0.0"));
454    }
455
456    #[test]
457    fn worktree_path_sanitizes_ref() {
458        let repo = Path::new("/repos/my-project");
459        let path = worktree_path_for(repo, "feature/branch");
460        assert!(path.ends_with("feature_branch"));
461        assert!(!path.starts_with(repo));
462    }
463
464    // -- Helper: create a temporary git repo with a commit and tag --
465
466    /// Run a git command isolated from global/system config.
467    ///
468    /// Sets `GIT_CONFIG_NOSYSTEM=1` and `GIT_CONFIG_GLOBAL=/dev/null` to
469    /// prevent global settings (e.g. `commit.gpgsign=true`) from interfering
470    /// with test repos, and disables GPG signing explicitly.
471    fn run_git(repo: &Path, args: &[&str]) {
472        let output = StdCommand::new("git")
473            .args(args)
474            .current_dir(repo)
475            .env("GIT_CONFIG_NOSYSTEM", "1")
476            .env("GIT_CONFIG_GLOBAL", "/dev/null")
477            .env("GIT_COMMITTER_NAME", "Test")
478            .env("GIT_COMMITTER_EMAIL", "test@test.com")
479            .output()
480            .expect("failed to spawn git");
481        assert!(
482            output.status.success(),
483            "git {:?} failed (exit {}):\nstdout: {}\nstderr: {}",
484            args,
485            output.status,
486            String::from_utf8_lossy(&output.stdout),
487            String::from_utf8_lossy(&output.stderr),
488        );
489    }
490
491    fn create_test_repo() -> TempDir {
492        let dir = TempDir::new().unwrap();
493        let repo = dir.path();
494
495        run_git(repo, &["init", "-b", "main"]);
496        run_git(repo, &["config", "user.email", "test@test.com"]);
497        run_git(repo, &["config", "user.name", "Test"]);
498        run_git(repo, &["config", "commit.gpgsign", "false"]);
499
500        std::fs::write(repo.join("file.txt"), "hello").unwrap();
501
502        run_git(repo, &["add", "."]);
503        run_git(repo, &["commit", "-m", "initial"]);
504        run_git(repo, &["tag", "v1.0.0"]);
505
506        dir
507    }
508
509    // -- Integration tests: worktree lifecycle --
510
511    #[test]
512    fn worktree_created_and_cleaned_up_on_drop() {
513        let repo_dir = create_test_repo();
514        let repo = repo_dir.path();
515
516        let worktree_path;
517        {
518            let guard = WorktreeGuard::create_only(repo, "v1.0.0").unwrap();
519            worktree_path = guard.path().to_path_buf();
520
521            // Worktree should exist while guard is alive
522            assert!(
523                worktree_path.exists(),
524                "worktree should exist after creation"
525            );
526            assert!(
527                worktree_path.join("file.txt").exists(),
528                "worktree should contain repo files"
529            );
530        }
531        // Guard dropped here -- worktree should be removed
532
533        assert!(
534            !worktree_path.exists(),
535            "worktree should be removed after guard is dropped"
536        );
537    }
538
539    #[test]
540    fn worktree_cleaned_up_on_early_drop() {
541        let repo_dir = create_test_repo();
542        let repo = repo_dir.path();
543
544        let guard = WorktreeGuard::create_only(repo, "v1.0.0").unwrap();
545        let worktree_path = guard.path().to_path_buf();
546        assert!(worktree_path.exists());
547
548        // Explicitly drop early (simulates error path / early return)
549        drop(guard);
550
551        assert!(
552            !worktree_path.exists(),
553            "worktree should be removed after explicit drop"
554        );
555    }
556
557    #[test]
558    fn cleanup_stale_removes_leftover_worktrees() {
559        let repo_dir = create_test_repo();
560        let repo = repo_dir.path();
561
562        // Create a worktree, then "leak" it by forgetting the guard
563        let guard = WorktreeGuard::create_only(repo, "v1.0.0").unwrap();
564        let worktree_path = guard.path().to_path_buf();
565
566        // Prevent Drop from running -- simulate a crash
567        std::mem::forget(guard);
568        assert!(worktree_path.exists(), "leaked worktree should still exist");
569
570        // cleanup_stale should find and remove it
571        let cleaned = WorktreeGuard::cleanup_stale(repo).unwrap();
572        assert_eq!(cleaned, 1, "should have cleaned up 1 stale worktree");
573        assert!(
574            !worktree_path.exists(),
575            "stale worktree should be removed after cleanup"
576        );
577    }
578
579    #[test]
580    fn cleanup_stale_returns_zero_when_nothing_to_clean() {
581        let repo_dir = create_test_repo();
582        let cleaned = WorktreeGuard::cleanup_stale(repo_dir.path()).unwrap();
583        assert_eq!(cleaned, 0);
584    }
585
586    #[test]
587    fn create_only_fails_for_nonexistent_ref() {
588        let repo_dir = create_test_repo();
589        let result = WorktreeGuard::create_only(repo_dir.path(), "nonexistent-ref");
590        assert!(matches!(result, Err(WorktreeError::RefNotFound { .. })));
591    }
592
593    #[test]
594    fn create_only_fails_for_non_git_dir() {
595        let dir = TempDir::new().unwrap();
596        let result = WorktreeGuard::create_only(dir.path(), "v1.0.0");
597        assert!(matches!(result, Err(WorktreeError::NotAGitRepo { .. })));
598    }
599
600    #[test]
601    fn git_ref_accessor_returns_correct_ref() {
602        let repo_dir = create_test_repo();
603        let guard = WorktreeGuard::create_only(repo_dir.path(), "v1.0.0").unwrap();
604        assert_eq!(guard.git_ref(), "v1.0.0");
605    }
606
607    #[test]
608    fn second_worktree_for_same_ref_replaces_stale() {
609        let repo_dir = create_test_repo();
610        let repo = repo_dir.path();
611
612        // Create first worktree and leak it
613        let guard1 = WorktreeGuard::create_only(repo, "v1.0.0").unwrap();
614        let path1 = guard1.path().to_path_buf();
615        std::mem::forget(guard1);
616        assert!(path1.exists());
617
618        // Creating a second worktree for the same ref should succeed
619        // (it removes the stale one first)
620        let guard2 = WorktreeGuard::create_only(repo, "v1.0.0").unwrap();
621        assert!(guard2.path().exists());
622        assert_eq!(guard2.path(), path1); // same path
623
624        // Cleanup: let guard2 drop normally
625    }
626
627    /// Create a test repo under the current working directory with a lockfile.
628    ///
629    /// Uses `tempdir_in(".")` so we can derive a relative path via
630    /// `strip_prefix` without changing the process-global CWD (which would
631    /// be flaky under parallel test execution).
632    fn create_test_repo_in_cwd() -> TempDir {
633        let dir = tempfile::Builder::new()
634            .prefix("test-repo-")
635            .tempdir_in(".")
636            .unwrap();
637        let repo = dir.path();
638
639        run_git(repo, &["init", "-b", "main"]);
640        run_git(repo, &["config", "user.email", "test@test.com"]);
641        run_git(repo, &["config", "user.name", "Test"]);
642        run_git(repo, &["config", "commit.gpgsign", "false"]);
643
644        std::fs::write(repo.join("file.txt"), "hello").unwrap();
645        std::fs::write(repo.join("package-lock.json"), "{}").unwrap();
646        std::fs::write(
647            repo.join("package.json"),
648            r#"{"name":"test","version":"1.0.0"}"#,
649        )
650        .unwrap();
651
652        run_git(repo, &["add", "."]);
653        run_git(repo, &["commit", "-m", "initial"]);
654        run_git(repo, &["tag", "v1.0.0"]);
655
656        dir
657    }
658
659    #[test]
660    fn relative_repo_path_finds_lockfile_in_worktree() {
661        // Regression test for #1: when repo is a relative path,
662        // WorktreeGuard::new() must find the lockfile in the worktree it
663        // created, not miss it because of a double-nested path.
664        //
665        // The repo is created under CWD so we can derive a relative path
666        // via strip_prefix — no set_current_dir needed.
667        let repo_dir = create_test_repo_in_cwd();
668        let cwd = std::env::current_dir().unwrap();
669        let relative_repo = repo_dir
670            .path()
671            .strip_prefix(&cwd)
672            .expect("repo should be under CWD since we used tempdir_in(\".\")");
673
674        // Call new() — the actual path from the bug report.
675        // With the fix, PackageManager::detect() finds package-lock.json
676        // and proceeds to `npm ci`, which fails in the test environment.
677        // Without the fix, it fails with NoLockfileFound because git
678        // created the worktree at a double-nested path.
679        let result = WorktreeGuard::new(relative_repo, "v1.0.0", None);
680
681        if let Err(WorktreeError::NoLockfileFound { .. }) = result {
682            panic!(
683                "relative path caused double-nested worktree path; \
684                 lockfile not found at expected location"
685            );
686        }
687        // Any other outcome (PackageInstallFailed, TscFailed, NoTsconfigFound,
688        // or even Ok) means the lockfile WAS found — the fix works.
689    }
690}