agpm_cli/git/mod.rs
1//! Git operations wrapper for AGPM.
2//!
3//! This module provides an async wrapper around the system `git` command. Uses system Git
4//! (not libgit2) for maximum compatibility with authentication, configurations, and platforms.
5//!
6//! # Core Features
7//!
8//! - **Async operations**: Non-blocking I/O using Tokio
9//! - **Worktree support**: Parallel package installation via Git worktrees
10//! - **Authentication**: HTTPS tokens, SSH keys, credential helpers
11//! - **Cross-platform**: Windows, macOS, Linux support
12//! - **Progress reporting**: User feedback during long operations
13//! - **Tag caching**: Per-instance caching for performance (v0.4.11+)
14//!
15//! # Security
16//!
17//! - Command injection prevention via proper argument passing
18//! - Credentials never logged or exposed in errors
19//! - HTTPS verification enabled by default
20
21pub mod command_builder;
22#[cfg(test)]
23mod tests;
24
25use crate::core::AgpmError;
26use crate::git::command_builder::GitCommand;
27use anyhow::{Context, Result};
28use std::path::{Path, PathBuf};
29use std::sync::OnceLock;
30
31/// A Git repository handle providing async operations via CLI commands.
32///
33#[derive(Debug, Clone)]
34pub struct GitRepo {
35 /// The local filesystem path to the Git repository.
36 ///
37 /// This path should point to the root directory of a Git repository
38 /// (the directory containing `.git/` subdirectory).
39 path: PathBuf,
40
41 /// Cached list of tags for performance optimization.
42 ///
43 /// Tags are cached after the first `list_tags()` call to avoid repeated
44 /// `git tag -l` operations within a single command execution. This is
45 /// particularly important for version constraint resolution where the same
46 /// tag list may be queried hundreds of times.
47 ///
48 /// Uses Arc to enable sharing the cache across cloned instances, which is
49 /// critical for parallel dependency resolution where multiple tasks access
50 /// the same repository.
51 tag_cache: std::sync::Arc<OnceLock<Vec<String>>>,
52}
53
54impl GitRepo {
55 /// Creates a new `GitRepo` instance for an existing local repository.
56 ///
57 /// # Arguments
58 ///
59 /// * `path` - The filesystem path to the Git repository root directory
60 pub fn new(path: impl AsRef<Path>) -> Self {
61 Self {
62 path: path.as_ref().to_path_buf(),
63 tag_cache: std::sync::Arc::new(OnceLock::new()),
64 }
65 }
66
67 /// Clones a Git repository from a remote URL to a local path.
68 ///
69 /// # Arguments
70 ///
71 /// * `url` - The remote repository URL (HTTPS, SSH, or file://)
72 /// * `target` - The local directory where the repository will be cloned
73 /// * `progress` - Optional progress bar for user feedback
74 ///
75 /// # Errors
76 ///
77 /// - The URL is invalid or unreachable
78 /// - Authentication fails
79 /// - The target directory already exists and is not empty
80 /// - Network connectivity issues
81 /// - Insufficient disk space
82 pub async fn clone(url: &str, target: impl AsRef<Path>) -> Result<Self> {
83 let target_path = target.as_ref();
84
85 // Use command builder for consistent clone operations
86 let mut cmd = GitCommand::clone(url, target_path);
87
88 // For file:// URLs, clone with all branches to ensure commit availability
89 if url.starts_with("file://") {
90 cmd = GitCommand::clone_local(url, target_path);
91 }
92
93 // Execute will handle error context properly
94 cmd.execute().await?;
95
96 Ok(Self::new(target_path))
97 }
98
99 /// Fetches updates from the remote repository without modifying the working tree.
100 ///
101 /// # Arguments
102 ///
103 /// * `auth_url` - Optional URL with authentication for private repositories
104 /// * `progress` - Optional progress bar for network operation feedback
105 ///
106 /// # Errors
107 ///
108 /// - Network connectivity fails
109 /// - Authentication is rejected
110 /// - The remote repository is unavailable
111 /// - The local repository is in an invalid state
112 pub async fn fetch(&self, auth_url: Option<&str>) -> Result<()> {
113 // Note: file:// URLs are local repositories, but we still need to fetch
114 // from them to get updates from the source repository
115
116 // Use git fetch with authentication from global config URL if provided
117 if let Some(url) = auth_url {
118 // Temporarily update the remote URL with auth for this fetch
119 GitCommand::set_remote_url(url).current_dir(&self.path).execute_success().await?;
120 }
121
122 // Now fetch with the potentially updated URL
123 GitCommand::fetch().current_dir(&self.path).execute_success().await?;
124
125 Ok(())
126 }
127
128 /// Checks out a specific Git reference (branch, tag, or commit hash).
129 ///
130 /// # Arguments
131 ///
132 /// * `ref_name` - The Git reference to checkout (branch, tag, or commit)
133 ///
134 /// # Errors
135 ///
136 /// - The reference doesn't exist in the repository
137 /// - The repository is in an invalid state
138 /// - File system permissions prevent checkout
139 /// - The working directory is locked by another process
140 pub async fn checkout(&self, ref_name: &str) -> Result<()> {
141 // Reset to clean state before checkout
142 let reset_result = GitCommand::reset_hard().current_dir(&self.path).execute().await;
143
144 if let Err(e) = reset_result {
145 // Only warn if it's not a detached HEAD situation (which is normal)
146 let error_str = e.to_string();
147 if !error_str.contains("HEAD detached") {
148 eprintln!("Warning: git reset failed: {error_str}");
149 }
150 }
151
152 // Check if this ref exists as a remote branch
153 // If it does, always use -B to ensure we get the latest
154 let remote_ref = format!("origin/{ref_name}");
155 let check_remote =
156 GitCommand::verify_ref(&remote_ref).current_dir(&self.path).execute().await;
157
158 if check_remote.is_ok() {
159 // Remote branch exists, use -B to force update to latest
160 if GitCommand::checkout_branch(ref_name, &remote_ref)
161 .current_dir(&self.path)
162 .execute_success()
163 .await
164 .is_ok()
165 {
166 return Ok(());
167 }
168 }
169
170 // Not a remote branch, try direct checkout (works for tags and commits)
171 GitCommand::checkout(ref_name).current_dir(&self.path).execute_success().await.map_err(
172 |e| {
173 // If it's already a GitCheckoutFailed error, return as-is
174 // Otherwise wrap it
175 if let Some(agpm_err) = e.downcast_ref::<AgpmError>()
176 && matches!(agpm_err, AgpmError::GitCheckoutFailed { .. })
177 {
178 return e;
179 }
180 AgpmError::GitCheckoutFailed {
181 reference: ref_name.to_string(),
182 reason: e.to_string(),
183 }
184 .into()
185 },
186 )
187 }
188
189 /// Lists all tags in the repository, sorted by Git's default ordering.
190 ///
191 /// # Return Value
192 ///
193 /// # Errors
194 ///
195 /// - The repository path doesn't exist
196 /// - The directory is not a valid Git repository
197 /// - Git command execution fails
198 /// - File system permissions prevent access
199 /// - Lock conflicts persist after retry attempts
200 pub async fn list_tags(&self) -> Result<Vec<String>> {
201 if let Some(cached_tags) = self.tag_cache.get() {
202 return Ok(cached_tags.clone());
203 }
204
205 if !self.path.exists() {
206 return Err(anyhow::anyhow!("Repository path does not exist: {:?}", self.path));
207 }
208 if !self.path.join(".git").exists() && !self.path.join("HEAD").exists() {
209 return Err(anyhow::anyhow!("Not a git repository: {:?}", self.path));
210 }
211
212 const MAX_RETRIES: u32 = 3;
213 const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150);
214 let mut last_error = None;
215
216 for attempt in 0..MAX_RETRIES {
217 let result = GitCommand::list_tags().current_dir(&self.path).execute_stdout().await;
218
219 match result {
220 Ok(stdout) => {
221 let tags: Vec<String> = stdout
222 .lines()
223 .filter(|line| !line.is_empty())
224 .map(std::string::ToString::to_string)
225 .collect();
226 let _ = self.tag_cache.set(tags.clone());
227 return Ok(tags);
228 }
229 Err(e) => {
230 let error_str = e.to_string();
231 if error_str.contains("lock") {
232 last_error = Some(e);
233 tokio::time::sleep(RETRY_DELAY * (attempt + 1)).await; // Exponential backoff
234 continue;
235 }
236 // For non-lock errors, fail immediately
237 return Err(e).context(format!("Failed to list git tags in {:?}", self.path));
238 }
239 }
240 }
241
242 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Exhausted retries for list_tags")))
243 .context(format!(
244 "Failed to list git tags in {:?} after {} retries",
245 self.path, MAX_RETRIES
246 ))
247 }
248
249 /// Retrieves the URL of the remote 'origin' repository.
250 ///
251 /// # Return Value
252 ///
253 /// - HTTPS: `https://github.com/user/repo.git`
254 /// - SSH: `git@github.com:user/repo.git`
255 /// - File: `file:///path/to/repo.git`
256 ///
257 /// # Errors
258 ///
259 /// - No 'origin' remote is configured
260 /// - The repository is not a valid Git repository
261 /// - Git command execution fails
262 /// - File system access is denied
263 pub async fn get_remote_url(&self) -> Result<String> {
264 GitCommand::remote_url().current_dir(&self.path).execute_stdout().await
265 }
266
267 /// Checks if the directory contains a valid Git repository.\n ///
268 ///
269 #[must_use]
270 pub fn is_git_repo(&self) -> bool {
271 is_git_repository(&self.path)
272 }
273
274 /// Returns the filesystem path to the Git repository.
275 ///
276 /// # Return Value
277 ///
278 #[must_use]
279 pub fn path(&self) -> &Path {
280 &self.path
281 }
282
283 /// Verifies that a Git repository URL is accessible without performing a full clone.
284 ///
285 /// # Arguments
286 ///
287 /// * `url` - The repository URL to verify
288 ///
289 /// # Errors
290 ///
291 /// - **Network issues**: DNS resolution, connectivity, timeouts
292 /// - **Authentication failures**: Invalid credentials, expired tokens
293 /// - **Repository issues**: Repository doesn't exist, access denied
294 /// - **Local path issues**: File doesn't exist (for `file://` URLs)
295 /// - **URL format issues**: Malformed or unsupported URL schemes
296 pub async fn verify_url(url: &str) -> Result<()> {
297 // For file:// URLs, just check if the path exists
298 if url.starts_with("file://") {
299 let path = url.strip_prefix("file://").unwrap();
300 return if std::path::Path::new(path).exists() {
301 Ok(())
302 } else {
303 Err(anyhow::anyhow!("Local path does not exist: {path}"))
304 };
305 }
306
307 // For all other URLs, use ls-remote to verify
308 GitCommand::ls_remote(url)
309 .execute_success()
310 .await
311 .context("Failed to verify remote repository")
312 }
313
314 /// Fetch updates for a bare repository with logging context.
315 async fn ensure_bare_repo_has_refs_with_context(&self, context: Option<&str>) -> Result<()> {
316 // Try to fetch to ensure we have refs
317 let mut fetch_cmd = GitCommand::fetch().current_dir(&self.path);
318
319 if let Some(ctx) = context {
320 fetch_cmd = fetch_cmd.with_context(ctx);
321 }
322
323 let fetch_result = fetch_cmd.execute_success().await;
324
325 if fetch_result.is_err() {
326 // If fetch fails, it might be because there's no remote
327 // Just check if we have any refs at all
328 let mut check_cmd =
329 GitCommand::new().args(["show-ref", "--head"]).current_dir(&self.path);
330
331 if let Some(ctx) = context {
332 check_cmd = check_cmd.with_context(ctx);
333 }
334
335 check_cmd
336 .execute_success()
337 .await
338 .map_err(|e| anyhow::anyhow!("Bare repository has no refs available: {e}"))?;
339 }
340
341 Ok(())
342 }
343
344 /// Clone a repository as a bare repository (no working directory).
345 ///
346 /// # Arguments
347 ///
348 /// * `url` - The remote repository URL
349 /// * `target` - The local directory where the bare repository will be stored
350 /// * `progress` - Optional progress bar for user feedback
351 /// # Returns
352 ///
353 pub async fn clone_bare(url: &str, target: impl AsRef<Path>) -> Result<Self> {
354 Self::clone_bare_with_context(url, target, None).await
355 }
356
357 /// Clone a repository as a bare repository with logging context.
358 ///
359 /// # Arguments
360 ///
361 /// * `url` - The remote repository URL
362 /// * `target` - The local directory where the bare repository will be stored
363 /// * `progress` - Optional progress bar for user feedback
364 /// * `context` - Optional context for logging (e.g., dependency name)
365 /// # Returns
366 ///
367 pub async fn clone_bare_with_context(
368 url: &str,
369 target: impl AsRef<Path>,
370 context: Option<&str>,
371 ) -> Result<Self> {
372 let target_path = target.as_ref();
373
374 let mut cmd = GitCommand::clone_bare(url, target_path);
375
376 if let Some(ctx) = context {
377 cmd = cmd.with_context(ctx);
378 }
379
380 cmd.execute_success().await?;
381
382 let repo = Self::new(target_path);
383
384 // Configure the fetch refspec to ensure all branches are fetched as remote tracking branches
385 // This is crucial for file:// URLs and ensures we can resolve origin/branch after fetching
386 let _ = GitCommand::new()
387 .args(["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"])
388 .current_dir(repo.path())
389 .execute_success()
390 .await;
391
392 // Ensure bare repo has refs available for worktree creation
393 // This fetch is necessary after clone to set up remote tracking branches
394 // Note: The cache layer tracks this fetch so worktree creation won't re-fetch
395 repo.ensure_bare_repo_has_refs_with_context(context).await.ok();
396
397 Ok(repo)
398 }
399
400 /// Create a new worktree from this repository.
401 ///
402 /// # Arguments
403 ///
404 /// * `worktree_path` - The path where the worktree will be created
405 /// * `reference` - Optional Git reference (branch/tag/commit) to checkout
406 /// # Returns
407 ///
408 pub async fn create_worktree(
409 &self,
410 worktree_path: impl AsRef<Path>,
411 reference: Option<&str>,
412 ) -> Result<Self> {
413 self.create_worktree_with_context(worktree_path, reference, None).await
414 }
415
416 /// Create a new worktree from this repository with logging context.
417 ///
418 /// # Arguments
419 ///
420 /// * `worktree_path` - The path where the worktree will be created
421 /// * `reference` - Optional Git reference (branch/tag/commit) to checkout
422 /// * `context` - Optional context for logging (e.g., dependency name)
423 /// # Returns
424 ///
425 pub async fn create_worktree_with_context(
426 &self,
427 worktree_path: impl AsRef<Path>,
428 reference: Option<&str>,
429 context: Option<&str>,
430 ) -> Result<Self> {
431 let worktree_path = worktree_path.as_ref();
432
433 // Ensure parent directory exists
434 if let Some(parent) = worktree_path.parent() {
435 tokio::fs::create_dir_all(parent).await.with_context(|| {
436 format!("Failed to create parent directory for worktree: {parent:?}")
437 })?;
438 }
439
440 // Retry logic for worktree creation to handle concurrent operations
441 let max_retries = 3;
442 let mut retry_count = 0;
443
444 loop {
445 // For bare repositories, we may need to handle the case where no default branch exists yet
446 // If no reference provided, try to use the default branch
447 let default_branch = if reference.is_none() && retry_count == 0 {
448 // Try to get the default branch
449 GitCommand::new()
450 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
451 .current_dir(&self.path)
452 .execute_stdout()
453 .await
454 .ok()
455 .and_then(|s| s.strip_prefix("refs/remotes/origin/").map(String::from))
456 .or_else(|| Some("main".to_string()))
457 } else {
458 None
459 };
460
461 let effective_ref = if let Some(ref branch) = default_branch {
462 Some(branch.as_str())
463 } else {
464 reference
465 };
466
467 let mut cmd =
468 GitCommand::worktree_add(worktree_path, effective_ref).current_dir(&self.path);
469
470 if let Some(ctx) = context {
471 cmd = cmd.with_context(ctx);
472 }
473
474 let result = cmd.execute_success().await;
475
476 match result {
477 Ok(()) => {
478 // Initialize and update submodules in the new worktree
479 let worktree_repo = Self::new(worktree_path);
480
481 // Initialize submodules
482 let mut init_cmd =
483 GitCommand::new().args(["submodule", "init"]).current_dir(worktree_path);
484
485 if let Some(ctx) = context {
486 init_cmd = init_cmd.with_context(ctx);
487 }
488
489 if let Err(e) = init_cmd.execute_success().await {
490 let error_str = e.to_string();
491 // Only ignore errors indicating no submodules are present
492 if !error_str.contains("No submodule mapping found")
493 && !error_str.contains("no submodule")
494 {
495 // For other errors, return them
496 return Err(e).context("Failed to initialize submodules");
497 }
498 }
499
500 // Update submodules
501 let mut update_cmd = GitCommand::new()
502 .args(["submodule", "update", "--recursive"])
503 .current_dir(worktree_path);
504
505 if let Some(ctx) = context {
506 update_cmd = update_cmd.with_context(ctx);
507 }
508
509 if let Err(e) = update_cmd.execute_success().await {
510 let error_str = e.to_string();
511 // Ignore errors related to no submodules
512 if !error_str.contains("No submodule mapping found")
513 && !error_str.contains("no submodule")
514 {
515 return Err(e).context("Failed to update submodules");
516 }
517 }
518
519 return Ok(worktree_repo);
520 }
521 Err(e) => {
522 let error_str = e.to_string();
523
524 // Check if this is a concurrent access issue
525 // The "commondir" error occurs when Git scans existing worktrees during
526 // concurrent creation - another thread's worktree entry may be partially
527 // written, causing "failed to read worktrees/<name>/commondir: Undefined error: 0"
528 if error_str.contains("already exists")
529 || error_str.contains("is already checked out")
530 || error_str.contains("fatal: could not create directory")
531 || (error_str.contains("failed to read") && error_str.contains("commondir"))
532 {
533 retry_count += 1;
534 if retry_count >= max_retries {
535 return Err(e).with_context(|| {
536 format!(
537 "Failed to create worktree at {} from {} after {} retries",
538 worktree_path.display(),
539 self.path.display(),
540 max_retries
541 )
542 });
543 }
544
545 // Wait a bit before retrying
546 tokio::time::sleep(tokio::time::Duration::from_millis(100 * retry_count))
547 .await;
548 continue;
549 }
550
551 // Handle stale registration: "missing but already registered worktree"
552 // IMPORTANT: Do NOT call prune_worktrees() here - it scans ALL worktrees
553 // and causes race conditions when multiple processes create worktrees
554 // concurrently from the same bare repo. Instead, use targeted recovery:
555 // 1. Only remove worktree if it's INVALID (missing .git file)
556 // 2. Use `git worktree add --force` which handles stale registrations
557 if error_str.contains("missing but already registered worktree") {
558 // Only remove the worktree if it's INVALID (missing .git file).
559 // A valid worktree has a .git file pointing back to the bare repo.
560 // Never remove a valid worktree - other processes may be reading from it!
561 let worktree_git_file = worktree_path.join(".git");
562 let is_invalid_worktree =
563 worktree_path.exists() && !worktree_git_file.exists();
564
565 if is_invalid_worktree {
566 let _ = tokio::fs::remove_dir_all(worktree_path).await;
567 }
568
569 // Ensure parent directory exists before force add.
570 // This handles the case where the temp directory was partially cleaned up,
571 // leaving Git's worktree metadata pointing to a non-existent path.
572 if let Some(parent) = worktree_path.parent() {
573 let _ = tokio::fs::create_dir_all(parent).await;
574 }
575
576 // Use `git worktree add --force` which can handle stale registrations
577 // by overwriting them. No need to prune first.
578 let worktree_path_str = worktree_path.display().to_string();
579 let mut args = vec![
580 "worktree".to_string(),
581 "add".to_string(),
582 "--force".to_string(),
583 worktree_path_str,
584 ];
585 if let Some(r) = effective_ref {
586 args.push(r.to_string());
587 }
588
589 let mut force_cmd = GitCommand::new().args(args).current_dir(&self.path);
590 if let Some(ctx) = context {
591 force_cmd = force_cmd.with_context(ctx);
592 }
593
594 match force_cmd.execute_success().await {
595 Ok(()) => {
596 // Initialize and update submodules in the new worktree
597 let worktree_repo = Self::new(worktree_path);
598
599 let mut init_cmd = GitCommand::new()
600 .args(["submodule", "init"])
601 .current_dir(worktree_path);
602 if let Some(ctx) = context {
603 init_cmd = init_cmd.with_context(ctx);
604 }
605 let _ = init_cmd.execute_success().await;
606
607 let mut update_cmd = GitCommand::new()
608 .args(["submodule", "update", "--recursive"])
609 .current_dir(worktree_path);
610 if let Some(ctx) = context {
611 update_cmd = update_cmd.with_context(ctx);
612 }
613 let _ = update_cmd.execute_success().await;
614
615 return Ok(worktree_repo);
616 }
617 Err(e2) => {
618 // Fall through to other recovery paths with the original error context
619 // but include the forced attempt error as context
620 return Err(e).with_context(|| {
621 format!(
622 "Failed to create worktree at {} from {} (forced add failed: {})",
623 worktree_path.display(),
624 self.path.display(),
625 e2
626 )
627 });
628 }
629 }
630 }
631
632 // If no reference was provided and the command failed, it might be because
633 // the bare repo doesn't have a default branch set. Try with explicit HEAD
634 if reference.is_none() && retry_count == 0 {
635 let mut head_cmd = GitCommand::worktree_add(worktree_path, Some("HEAD"))
636 .current_dir(&self.path);
637
638 if let Some(ctx) = context {
639 head_cmd = head_cmd.with_context(ctx);
640 }
641
642 let head_result = head_cmd.execute_success().await;
643
644 match head_result {
645 Ok(()) => {
646 // Initialize and update submodules in the new worktree
647 let worktree_repo = Self::new(worktree_path);
648
649 // Initialize submodules
650 let mut init_cmd = GitCommand::new()
651 .args(["submodule", "init"])
652 .current_dir(worktree_path);
653
654 if let Some(ctx) = context {
655 init_cmd = init_cmd.with_context(ctx);
656 }
657
658 if let Err(e) = init_cmd.execute_success().await {
659 let error_str = e.to_string();
660 // Only ignore errors indicating no submodules are present
661 if !error_str.contains("No submodule mapping found")
662 && !error_str.contains("no submodule")
663 {
664 // For other errors, return them
665 return Err(e).context("Failed to initialize submodules");
666 }
667 }
668
669 // Update submodules
670 let mut update_cmd = GitCommand::new()
671 .args(["submodule", "update", "--recursive"])
672 .current_dir(worktree_path);
673
674 if let Some(ctx) = context {
675 update_cmd = update_cmd.with_context(ctx);
676 }
677
678 if let Err(e) = update_cmd.execute_success().await {
679 let error_str = e.to_string();
680 // Ignore errors related to no submodules
681 if !error_str.contains("No submodule mapping found")
682 && !error_str.contains("no submodule")
683 {
684 return Err(e).context("Failed to update submodules");
685 }
686 }
687
688 return Ok(worktree_repo);
689 }
690 Err(head_err) => {
691 // If HEAD also fails, return the original error
692 return Err(e).with_context(|| {
693 format!(
694 "Failed to create worktree at {} from {} (also tried HEAD: {})",
695 worktree_path.display(),
696 self.path.display(),
697 head_err
698 )
699 });
700 }
701 }
702 }
703
704 // Check if the error is likely due to an invalid reference
705 let error_str = e.to_string();
706 if let Some(ref_name) = reference
707 && (error_str.contains("pathspec")
708 || error_str.contains("not found")
709 || error_str.contains("ambiguous")
710 || error_str.contains("invalid")
711 || error_str.contains("unknown revision"))
712 {
713 return Err(anyhow::anyhow!(
714 "Invalid version or reference '{ref_name}': Failed to checkout reference - the specified version/tag/branch does not exist in the repository"
715 ));
716 }
717
718 return Err(e).with_context(|| {
719 format!(
720 "Failed to create worktree at {} from {}",
721 worktree_path.display(),
722 self.path.display()
723 )
724 });
725 }
726 }
727 }
728 }
729
730 /// Remove a worktree associated with this repository.
731 ///
732 /// # Arguments
733 ///
734 /// * `worktree_path` - The path to the worktree to remove
735 pub async fn remove_worktree(&self, worktree_path: impl AsRef<Path>) -> Result<()> {
736 let worktree_path = worktree_path.as_ref();
737
738 GitCommand::worktree_remove(worktree_path)
739 .current_dir(&self.path)
740 .execute_success()
741 .await
742 .with_context(|| format!("Failed to remove worktree at {}", worktree_path.display()))?;
743
744 // Also try to remove the directory if it still exists
745 if worktree_path.exists() {
746 tokio::fs::remove_dir_all(worktree_path).await.ok(); // Ignore errors as git worktree remove may have already cleaned it
747 }
748
749 Ok(())
750 }
751
752 /// List all worktrees associated with this repository.
753 ///
754 pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>> {
755 let output = GitCommand::worktree_list().current_dir(&self.path).execute_stdout().await?;
756
757 let mut worktrees = Vec::new();
758 let mut current_worktree: Option<PathBuf> = None;
759
760 for line in output.lines() {
761 if line.starts_with("worktree ") {
762 if let Some(path) = line.strip_prefix("worktree ") {
763 current_worktree = Some(PathBuf::from(path));
764 }
765 } else if line == "bare" {
766 // Skip bare repository entry
767 current_worktree = None;
768 } else if line.is_empty()
769 && current_worktree.is_some()
770 && let Some(path) = current_worktree.take()
771 {
772 worktrees.push(path);
773 }
774 }
775
776 // Add the last worktree if there is one
777 if let Some(path) = current_worktree {
778 worktrees.push(path);
779 }
780
781 Ok(worktrees)
782 }
783
784 /// Prune stale worktree administrative files.
785 ///
786 pub async fn prune_worktrees(&self) -> Result<()> {
787 GitCommand::worktree_prune()
788 .current_dir(&self.path)
789 .execute_success()
790 .await
791 .with_context(|| "Failed to prune worktrees")?;
792
793 Ok(())
794 }
795
796 /// Check if this repository is a bare repository.
797 ///
798 pub async fn is_bare(&self) -> Result<bool> {
799 let output = GitCommand::new()
800 .args(["config", "--get", "core.bare"])
801 .current_dir(&self.path)
802 .execute_stdout()
803 .await?;
804
805 Ok(output.trim() == "true")
806 }
807
808 /// Get the current commit SHA of the repository.
809 ///
810 /// # Returns
811 ///
812 /// # Errors
813 ///
814 /// - The repository is not valid
815 /// - HEAD is not pointing to a valid commit
816 /// - Git command fails
817 pub async fn get_current_commit(&self) -> Result<String> {
818 GitCommand::current_commit()
819 .current_dir(&self.path)
820 .execute_stdout()
821 .await
822 .context("Failed to get current commit")
823 }
824
825 /// Batch resolve multiple refs to SHAs in a single git process.
826 ///
827 /// Uses `git rev-parse <ref1> <ref2> ...` to resolve all refs at once, reducing
828 /// process spawn overhead from O(n) to O(1). This is significantly faster
829 /// for Windows where process spawning has high overhead.
830 ///
831 /// # Arguments
832 ///
833 /// * `refs` - Slice of ref specifications to resolve
834 ///
835 /// # Returns
836 ///
837 /// HashMap mapping each input ref to its resolved SHA (or None if not found)
838 ///
839 /// # Performance
840 ///
841 /// - Single process for all refs vs one per ref
842 /// - Reduces 100 refs from ~5-10 seconds to ~0.5 seconds on Windows
843 ///
844 /// # Examples
845 ///
846 /// ```rust,ignore
847 /// use agpm_cli::git::GitRepo;
848 ///
849 /// # async fn example() -> anyhow::Result<()> {
850 /// let repo = GitRepo::new("/path/to/repo");
851 /// let refs = vec!["v1.0.0", "main", "abc1234"];
852 /// let results = repo.resolve_refs_batch(&refs).await?;
853 ///
854 /// for (ref_name, sha) in results {
855 /// if let Some(sha) = sha {
856 /// println!("{} -> {}", ref_name, sha);
857 /// } else {
858 /// println!("{} not found", ref_name);
859 /// }
860 /// }
861 /// # Ok(())
862 /// # }
863 /// ```
864 pub async fn resolve_refs_batch(
865 &self,
866 refs: &[&str],
867 ) -> Result<std::collections::HashMap<String, Option<String>>> {
868 use std::collections::HashMap;
869
870 if refs.is_empty() {
871 return Ok(HashMap::new());
872 }
873
874 // Partition refs: already-SHAs vs need-resolution
875 let (already_shas, to_resolve): (Vec<&str>, Vec<&str>) =
876 refs.iter().partition(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()));
877
878 let mut results: HashMap<String, Option<String>> = HashMap::new();
879
880 // Add already-resolved SHAs directly
881 for sha in already_shas {
882 results.insert(sha.to_string(), Some(sha.to_string()));
883 }
884
885 if to_resolve.is_empty() {
886 return Ok(results);
887 }
888
889 // Build arguments for git rev-parse: ["rev-parse", "ref1", "ref2", ...]
890 // This resolves all refs in a single git process
891 let mut args = vec!["rev-parse"];
892 args.extend(to_resolve.iter().copied());
893
894 // Execute batch resolution
895 let output = GitCommand::new().args(args).current_dir(&self.path).execute().await;
896
897 match output {
898 Ok(cmd_output) => {
899 // Parse output (one SHA per line, in order)
900 let shas: Vec<&str> = cmd_output.stdout.lines().collect();
901
902 for (i, ref_name) in to_resolve.iter().enumerate() {
903 let sha = shas.get(i).and_then(|s| {
904 let trimmed = s.trim();
905 // Only accept valid SHA output (40 hex chars)
906 if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
907 Some(trimmed.to_string())
908 } else {
909 None
910 }
911 });
912 results.insert(ref_name.to_string(), sha);
913 }
914 }
915 Err(e) => {
916 // If batch fails (e.g., one ref is invalid), fall back to individual resolution
917 tracing::debug!(
918 target: "git",
919 "Batch rev-parse failed, falling back to individual resolution: {}",
920 e
921 );
922
923 for ref_name in to_resolve {
924 let sha = GitCommand::rev_parse(ref_name)
925 .current_dir(&self.path)
926 .execute_stdout()
927 .await
928 .ok();
929 results.insert(ref_name.to_string(), sha);
930 }
931 }
932 }
933
934 Ok(results)
935 }
936
937 /// Resolves a Git reference (tag, branch, commit) to its full SHA-1 hash.
938 ///
939 /// # Arguments
940 ///
941 /// * `ref_spec` - The Git reference to resolve (tag, branch, short/full SHA, or None for HEAD)
942 /// # Returns
943 ///
944 /// # Errors
945 ///
946 /// - The reference doesn't exist in the repository
947 /// - The repository is invalid or corrupted
948 /// - Git command execution fails
949 pub async fn resolve_to_sha(&self, ref_spec: Option<&str>) -> Result<String> {
950 let reference = ref_spec.unwrap_or("HEAD");
951
952 // Optimization: if it's already a full SHA, return it directly
953 if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
954 return Ok(reference.to_string());
955 }
956
957 // Determine the reference to resolve based on type (tag vs branch)
958 let ref_to_resolve = if !reference.contains('/') && reference != "HEAD" {
959 // Check if this is a tag (uses cached tag list for performance)
960 let is_tag = self
961 .list_tags()
962 .await
963 .map(|tags| tags.contains(&reference.to_string()))
964 .unwrap_or(false);
965
966 if is_tag {
967 // It's a tag - use it directly
968 reference.to_string()
969 } else {
970 // Assume it's a branch name - try to resolve origin/branch first to get the latest from remote
971 // This ensures we get the most recent commit after a fetch
972 let origin_ref = format!("origin/{reference}");
973 if GitCommand::rev_parse(&origin_ref)
974 .current_dir(&self.path)
975 .execute_stdout()
976 .await
977 .is_ok()
978 {
979 origin_ref
980 } else {
981 // Fallback to the original reference (might be a local branch)
982 reference.to_string()
983 }
984 }
985 } else {
986 reference.to_string()
987 };
988
989 // Use rev-parse to get the full SHA
990 let sha = GitCommand::rev_parse(&ref_to_resolve)
991 .current_dir(&self.path)
992 .execute_stdout()
993 .await
994 .with_context(|| format!("Failed to resolve reference '{reference}' to SHA"))?;
995
996 // Ensure we have a full SHA (sometimes rev-parse can return short SHAs)
997 if sha.len() < 40 {
998 // Request the full SHA explicitly
999 let full_sha = GitCommand::new()
1000 .args(["rev-parse", "--verify", &format!("{reference}^{{commit}}")])
1001 .current_dir(&self.path)
1002 .execute_stdout()
1003 .await
1004 .with_context(|| format!("Failed to get full SHA for reference '{reference}'"))?;
1005 Ok(full_sha)
1006 } else {
1007 Ok(sha)
1008 }
1009 }
1010
1011 pub async fn get_current_branch(&self) -> Result<String> {
1012 let branch = GitCommand::current_branch()
1013 .current_dir(&self.path)
1014 .execute_stdout()
1015 .await
1016 .context("Failed to get current branch")?;
1017
1018 if branch.is_empty() {
1019 // Fallback for very old Git or repos without commits
1020 Ok("master".to_string())
1021 } else {
1022 Ok(branch)
1023 }
1024 }
1025
1026 /// Gets the default branch name for the repository.
1027 ///
1028 /// # Returns
1029 ///
1030 /// # Errors
1031 ///
1032 /// - Git commands fail with non-recoverable errors
1033 /// - Lock conflicts occur (propagated for caller to retry)
1034 /// - Default branch cannot be determined
1035 pub async fn get_default_branch(&self) -> Result<String> {
1036 let result = GitCommand::new()
1037 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
1038 .current_dir(&self.path)
1039 .execute_stdout()
1040 .await;
1041
1042 match result {
1043 Ok(symbolic_ref) => {
1044 if let Some(branch) = symbolic_ref.strip_prefix("refs/remotes/origin/") {
1045 return Ok(branch.to_string());
1046 }
1047 // If parsing fails, fall through to the next method.
1048 }
1049 Err(e) => {
1050 let error_str = e.to_string();
1051 // If the ref is not found, it's not a fatal error, just fall back.
1052 // Any other error (like a lock file) should be propagated.
1053 if !error_str.contains("not a symbolic ref") && !error_str.contains("not found") {
1054 return Err(e).context("Failed to get default branch via symbolic-ref");
1055 }
1056 }
1057 }
1058
1059 // Fallback: try to get current branch (for non-bare repos or if symbolic-ref fails)
1060 self.get_current_branch().await
1061 }
1062}
1063
1064// Module-level helper functions for Git environment management and URL processing
1065
1066/// Checks if Git is installed and accessible on the system.
1067///
1068/// # Return Value
1069///
1070/// - `true` if Git is installed and responding to `--version` commands
1071/// - `false` if Git is not found, not in PATH, or not executable
1072///
1073#[must_use]
1074pub fn is_git_installed() -> bool {
1075 // For synchronous checking, we still use std::process::Command directly
1076 std::process::Command::new(crate::utils::platform::get_git_command())
1077 .arg("--version")
1078 .output()
1079 .map(|output| output.status.success())
1080 .unwrap_or(false)
1081}
1082
1083/// Ensures Git is available on the system or returns a detailed error.
1084///
1085/// # Return Value
1086///
1087/// - `Ok(())` if Git is properly installed and accessible
1088/// - `Err(AgpmError::GitNotFound)` if Git is not available
1089///
1090pub fn ensure_git_available() -> Result<()> {
1091 if !is_git_installed() {
1092 return Err(AgpmError::GitNotFound.into());
1093 }
1094 Ok(())
1095}
1096
1097/// Checks if a path contains a Git repository (regular or bare).
1098///
1099/// # Arguments
1100///
1101/// * `path` - The path to check for a Git repository
1102/// # Returns
1103///
1104/// * `true` if the path is a valid Git repository (regular or bare)
1105/// * `false` if neither repository marker exists
1106#[must_use]
1107pub fn is_git_repository(path: &Path) -> bool {
1108 // Check for regular repository (.git directory) or bare repository (HEAD file)
1109 path.join(".git").exists() || path.join("HEAD").exists()
1110}
1111
1112/// Checks if a directory contains a valid Git repository.
1113///
1114/// # Arguments
1115///
1116/// * `path` - The directory path to check for Git repository validity
1117/// # Return Value
1118///
1119/// - `true` if the path contains a `.git` subdirectory
1120/// - `false` if the `.git` subdirectory is missing or the path doesn't exist
1121///
1122#[must_use]
1123pub fn is_valid_git_repo(path: &Path) -> bool {
1124 is_git_repository(path)
1125}
1126
1127/// Ensures a directory contains a valid Git repository or returns a detailed error.
1128///
1129/// # Arguments
1130///
1131/// * `path` - The directory path to validate as a Git repository
1132/// # Return Value
1133///
1134/// - `Ok(())` if the path contains a valid `.git` directory
1135/// - `Err(AgpmError::GitRepoInvalid)` if the path is not a Git repository
1136///
1137pub fn ensure_valid_git_repo(path: &Path) -> Result<()> {
1138 if !is_valid_git_repo(path) {
1139 return Err(AgpmError::GitRepoInvalid {
1140 path: path.display().to_string(),
1141 }
1142 .into());
1143 }
1144 Ok(())
1145}
1146
1147/// Parses a Git URL into owner and repository name components.
1148///
1149/// # Arguments
1150///
1151/// * `url` - The Git repository URL to parse
1152/// # Return Value
1153///
1154/// - `owner` is the user, organization, or "local" for local repositories
1155/// - `repository_name` is the repository name (with `.git` suffix removed)
1156///
1157/// # Errors
1158///
1159/// - The URL format is not recognized
1160/// - The URL doesn't contain sufficient path components
1161/// - The URL structure doesn't match expected patterns
1162///
1163pub fn parse_git_url(url: &str) -> Result<(String, String)> {
1164 use std::path::Path;
1165
1166 // Handle file:// URLs
1167 if url.starts_with("file://") {
1168 let path_str = url.trim_start_matches("file://");
1169 let path = Path::new(path_str);
1170 let repo_name = path
1171 .file_name()
1172 .and_then(|n| n.to_str())
1173 .map(|s| s.trim_end_matches(".git"))
1174 .unwrap_or(path_str);
1175 return Ok(("local".to_string(), repo_name.to_string()));
1176 }
1177
1178 // Handle plain local paths (absolute or relative)
1179 if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
1180 let path = Path::new(url);
1181 let repo_name = path
1182 .file_name()
1183 .and_then(|n| n.to_str())
1184 .map(|s| s.trim_end_matches(".git"))
1185 .unwrap_or(url);
1186 return Ok(("local".to_string(), repo_name.to_string()));
1187 }
1188
1189 // Handle SSH URLs like git@github.com:user/repo.git
1190 if url.contains('@')
1191 && url.contains(':')
1192 && !url.starts_with("ssh://")
1193 && let Some(colon_pos) = url.find(':')
1194 {
1195 let path = &url[colon_pos + 1..];
1196 let path = path.trim_end_matches(".git");
1197 if let Some(slash_pos) = path.find('/') {
1198 return Ok((path[..slash_pos].to_string(), path[slash_pos + 1..].to_string()));
1199 }
1200 }
1201
1202 // Handle HTTPS URLs
1203 if url.contains("github.com") || url.contains("gitlab.com") || url.contains("bitbucket.org") {
1204 let parts: Vec<&str> = url.split('/').collect();
1205 if parts.len() >= 2 {
1206 let repo = parts[parts.len() - 1].trim_end_matches(".git");
1207 let owner = parts[parts.len() - 2];
1208 return Ok((owner.to_string(), repo.to_string()));
1209 }
1210 }
1211
1212 Err(anyhow::anyhow!("Could not parse repository owner and name from URL"))
1213}
1214
1215/// Strips authentication information from a Git URL for safe display or logging.
1216///
1217/// # Arguments
1218///
1219/// * `url` - The Git URL that may contain authentication information
1220/// # Return Value
1221///
1222/// - HTTPS URLs: Removes `user:token@` prefix
1223/// - SSH URLs: Returned unchanged (no embedded auth to strip)
1224/// - Other formats: Returned unchanged if no auth detected
1225///
1226pub fn strip_auth_from_url(url: &str) -> Result<String> {
1227 if url.starts_with("https://") || url.starts_with("http://") {
1228 // Find the @ symbol that marks the end of authentication
1229 if let Some(at_pos) = url.find('@') {
1230 let protocol_end = if url.starts_with("https://") {
1231 "https://".len()
1232 } else {
1233 "http://".len()
1234 };
1235
1236 // Check if @ is part of auth (comes before first /)
1237 let first_slash = url[protocol_end..].find('/').map(|p| p + protocol_end);
1238 if first_slash.is_none() || at_pos < first_slash.unwrap() {
1239 // Extract protocol and the part after @
1240 let protocol = &url[..protocol_end];
1241 let after_auth = &url[at_pos + 1..];
1242 return Ok(format!("{protocol}{after_auth}"));
1243 }
1244 }
1245 }
1246
1247 // Return URL as-is if no auth found or not HTTP(S)
1248 Ok(url.to_string())
1249}