1use 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
17pub struct WorktreeGuard {
25 repo_root: PathBuf,
27
28 worktree_path: PathBuf,
30
31 git_ref: String,
33
34 created: bool,
36
37 warnings: Vec<ExtractionWarning>,
40}
41
42impl WorktreeGuard {
43 pub fn new(
58 repo: &Path,
59 git_ref: &str,
60 build_command: Option<&str>,
61 ) -> Result<Self, WorktreeError> {
62 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_git_repo(repo)?;
76
77 validate_git_ref(repo, git_ref)?;
79
80 let worktree_path = worktree_path_for(repo, git_ref);
82
83 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 let parent = worktree_path
94 .parent()
95 .expect("worktree path should have a parent");
96 std::fs::create_dir_all(parent)?;
97
98 create_worktree(repo, git_ref, &worktree_path)?;
100 guard.created = true;
101
102 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 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 match tsc::run_tsc_declaration(&worktree_path, git_ref) {
120 Ok(tsc::TscOutcome::Success) => {
121 }
123 Ok(tsc::TscOutcome::Partial { succeeded, failed }) => {
124 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 }
134 Err(e) => {
135 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 tracing::warn!(error = %e, "tsc failed completely, trying project build as fallback");
150 match tsc::run_project_build(&worktree_path, None) {
151 Ok(()) => {
152 guard
154 .warnings
155 .push(ExtractionWarning::TscFailedBuildSucceeded {
156 tsc_error: e.to_string(),
157 });
158 }
159 Err(build_err) => {
160 tracing::warn!(error = %build_err, "Project build also failed");
162 return Err(e);
163 }
164 }
165 }
166 }
167
168 Ok(guard)
169 }
170
171 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 pub fn warnings(&self) -> &[ExtractionWarning] {
214 &self.warnings
215 }
216
217 pub fn path(&self) -> &Path {
219 &self.worktree_path
220 }
221
222 pub fn git_ref(&self) -> &str {
224 &self.git_ref
225 }
226
227 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 let _ = std::fs::remove_dir_all(&path);
257 cleaned += 1;
258 }
259 }
260 }
261
262 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 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
296fn 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
313fn 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
330fn create_worktree(repo: &Path, git_ref: &str, worktree_path: &Path) -> Result<(), WorktreeError> {
332 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
363fn 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
389fn 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 #[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 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 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 #[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 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 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 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 let guard = WorktreeGuard::create_only(repo, "v1.0.0").unwrap();
564 let worktree_path = guard.path().to_path_buf();
565
566 std::mem::forget(guard);
568 assert!(worktree_path.exists(), "leaked worktree should still exist");
569
570 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 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 let guard2 = WorktreeGuard::create_only(repo, "v1.0.0").unwrap();
621 assert!(guard2.path().exists());
622 assert_eq!(guard2.path(), path1); }
626
627 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 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 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 }
690}