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 // This can happen in Docker containers, CI environments, or after unclean
553 // shutdowns where git's worktree metadata gets out of sync with filesystem.
554 // Recovery strategy:
555 // 1. Remove invalid worktree directory if it exists without .git file
556 // 2. Run `git worktree prune` to clean stale registrations
557 // 3. Retry with `git worktree add --force`
558 //
559 // NOTE: We only run prune in this error recovery path (not speculatively)
560 // to minimize race conditions with concurrent worktree operations.
561 if error_str.contains("missing but already registered worktree") {
562 // Git reports "missing but already registered" when the worktree
563 // state is inconsistent. This can happen when:
564 // - The .git file exists but is broken/empty
565 // - The worktree was partially created
566 // - Docker/CI environments had filesystem state corruption
567 //
568 // Since git explicitly tells us this worktree is INVALID, we can
569 // safely remove it. Git wouldn't report this error for a valid
570 // worktree that other processes might be using.
571 if worktree_path.exists() {
572 let _ = tokio::fs::remove_dir_all(worktree_path).await;
573 }
574
575 // Prune stale worktree registrations. This is safe in the recovery path
576 // since we already failed once. In Docker/CI environments, --force alone
577 // may not be sufficient to override stale registrations.
578 let mut prune_cmd =
579 GitCommand::new().args(["worktree", "prune"]).current_dir(&self.path);
580 if let Some(ctx) = context {
581 prune_cmd = prune_cmd.with_context(ctx);
582 }
583 let _ = prune_cmd.execute_success().await;
584
585 // Ensure parent directory exists before force add.
586 // This handles the case where the temp directory was partially cleaned up,
587 // leaving Git's worktree metadata pointing to a non-existent path.
588 if let Some(parent) = worktree_path.parent() {
589 let _ = tokio::fs::create_dir_all(parent).await;
590 }
591
592 // Use `git worktree add --force` after pruning stale entries
593 let worktree_path_str = worktree_path.display().to_string();
594 let mut args = vec![
595 "worktree".to_string(),
596 "add".to_string(),
597 "--force".to_string(),
598 worktree_path_str,
599 ];
600 if let Some(r) = effective_ref {
601 args.push(r.to_string());
602 }
603
604 let mut force_cmd = GitCommand::new().args(args).current_dir(&self.path);
605 if let Some(ctx) = context {
606 force_cmd = force_cmd.with_context(ctx);
607 }
608
609 match force_cmd.execute_success().await {
610 Ok(()) => {
611 // Initialize and update submodules in the new worktree
612 let worktree_repo = Self::new(worktree_path);
613
614 let mut init_cmd = GitCommand::new()
615 .args(["submodule", "init"])
616 .current_dir(worktree_path);
617 if let Some(ctx) = context {
618 init_cmd = init_cmd.with_context(ctx);
619 }
620 let _ = init_cmd.execute_success().await;
621
622 let mut update_cmd = GitCommand::new()
623 .args(["submodule", "update", "--recursive"])
624 .current_dir(worktree_path);
625 if let Some(ctx) = context {
626 update_cmd = update_cmd.with_context(ctx);
627 }
628 let _ = update_cmd.execute_success().await;
629
630 return Ok(worktree_repo);
631 }
632 Err(e2) => {
633 // Fall through to other recovery paths with the original error context
634 // but include the forced attempt error as context
635 return Err(e).with_context(|| {
636 format!(
637 "Failed to create worktree at {} from {} (forced add failed: {})",
638 worktree_path.display(),
639 self.path.display(),
640 e2
641 )
642 });
643 }
644 }
645 }
646
647 // If no reference was provided and the command failed, it might be because
648 // the bare repo doesn't have a default branch set. Try with explicit HEAD
649 if reference.is_none() && retry_count == 0 {
650 let mut head_cmd = GitCommand::worktree_add(worktree_path, Some("HEAD"))
651 .current_dir(&self.path);
652
653 if let Some(ctx) = context {
654 head_cmd = head_cmd.with_context(ctx);
655 }
656
657 let head_result = head_cmd.execute_success().await;
658
659 match head_result {
660 Ok(()) => {
661 // Initialize and update submodules in the new worktree
662 let worktree_repo = Self::new(worktree_path);
663
664 // Initialize submodules
665 let mut init_cmd = GitCommand::new()
666 .args(["submodule", "init"])
667 .current_dir(worktree_path);
668
669 if let Some(ctx) = context {
670 init_cmd = init_cmd.with_context(ctx);
671 }
672
673 if let Err(e) = init_cmd.execute_success().await {
674 let error_str = e.to_string();
675 // Only ignore errors indicating no submodules are present
676 if !error_str.contains("No submodule mapping found")
677 && !error_str.contains("no submodule")
678 {
679 // For other errors, return them
680 return Err(e).context("Failed to initialize submodules");
681 }
682 }
683
684 // Update submodules
685 let mut update_cmd = GitCommand::new()
686 .args(["submodule", "update", "--recursive"])
687 .current_dir(worktree_path);
688
689 if let Some(ctx) = context {
690 update_cmd = update_cmd.with_context(ctx);
691 }
692
693 if let Err(e) = update_cmd.execute_success().await {
694 let error_str = e.to_string();
695 // Ignore errors related to no submodules
696 if !error_str.contains("No submodule mapping found")
697 && !error_str.contains("no submodule")
698 {
699 return Err(e).context("Failed to update submodules");
700 }
701 }
702
703 return Ok(worktree_repo);
704 }
705 Err(head_err) => {
706 // If HEAD also fails, return the original error
707 return Err(e).with_context(|| {
708 format!(
709 "Failed to create worktree at {} from {} (also tried HEAD: {})",
710 worktree_path.display(),
711 self.path.display(),
712 head_err
713 )
714 });
715 }
716 }
717 }
718
719 // Check if the error is likely due to an invalid reference
720 let error_str = e.to_string();
721 if let Some(ref_name) = reference
722 && (error_str.contains("pathspec")
723 || error_str.contains("not found")
724 || error_str.contains("ambiguous")
725 || error_str.contains("invalid")
726 || error_str.contains("unknown revision"))
727 {
728 return Err(anyhow::anyhow!(
729 "Invalid version or reference '{ref_name}': Failed to checkout reference - the specified version/tag/branch does not exist in the repository"
730 ));
731 }
732
733 return Err(e).with_context(|| {
734 format!(
735 "Failed to create worktree at {} from {}",
736 worktree_path.display(),
737 self.path.display()
738 )
739 });
740 }
741 }
742 }
743 }
744
745 /// Remove a worktree associated with this repository.
746 ///
747 /// # Arguments
748 ///
749 /// * `worktree_path` - The path to the worktree to remove
750 pub async fn remove_worktree(&self, worktree_path: impl AsRef<Path>) -> Result<()> {
751 let worktree_path = worktree_path.as_ref();
752
753 GitCommand::worktree_remove(worktree_path)
754 .current_dir(&self.path)
755 .execute_success()
756 .await
757 .with_context(|| format!("Failed to remove worktree at {}", worktree_path.display()))?;
758
759 // Also try to remove the directory if it still exists
760 if worktree_path.exists() {
761 tokio::fs::remove_dir_all(worktree_path).await.ok(); // Ignore errors as git worktree remove may have already cleaned it
762 }
763
764 Ok(())
765 }
766
767 /// List all worktrees associated with this repository.
768 ///
769 pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>> {
770 let output = GitCommand::worktree_list().current_dir(&self.path).execute_stdout().await?;
771
772 let mut worktrees = Vec::new();
773 let mut current_worktree: Option<PathBuf> = None;
774
775 for line in output.lines() {
776 if line.starts_with("worktree ") {
777 if let Some(path) = line.strip_prefix("worktree ") {
778 current_worktree = Some(PathBuf::from(path));
779 }
780 } else if line == "bare" {
781 // Skip bare repository entry
782 current_worktree = None;
783 } else if line.is_empty()
784 && current_worktree.is_some()
785 && let Some(path) = current_worktree.take()
786 {
787 worktrees.push(path);
788 }
789 }
790
791 // Add the last worktree if there is one
792 if let Some(path) = current_worktree {
793 worktrees.push(path);
794 }
795
796 Ok(worktrees)
797 }
798
799 /// Prune stale worktree administrative files.
800 ///
801 pub async fn prune_worktrees(&self) -> Result<()> {
802 GitCommand::worktree_prune()
803 .current_dir(&self.path)
804 .execute_success()
805 .await
806 .with_context(|| "Failed to prune worktrees")?;
807
808 Ok(())
809 }
810
811 /// Check if this repository is a bare repository.
812 ///
813 pub async fn is_bare(&self) -> Result<bool> {
814 let output = GitCommand::new()
815 .args(["config", "--get", "core.bare"])
816 .current_dir(&self.path)
817 .execute_stdout()
818 .await?;
819
820 Ok(output.trim() == "true")
821 }
822
823 /// Get the current commit SHA of the repository.
824 ///
825 /// # Returns
826 ///
827 /// # Errors
828 ///
829 /// - The repository is not valid
830 /// - HEAD is not pointing to a valid commit
831 /// - Git command fails
832 pub async fn get_current_commit(&self) -> Result<String> {
833 GitCommand::current_commit()
834 .current_dir(&self.path)
835 .execute_stdout()
836 .await
837 .context("Failed to get current commit")
838 }
839
840 /// Batch resolve multiple refs to SHAs in a single git process.
841 ///
842 /// Uses `git rev-parse <ref1> <ref2> ...` to resolve all refs at once, reducing
843 /// process spawn overhead from O(n) to O(1). This is significantly faster
844 /// for Windows where process spawning has high overhead.
845 ///
846 /// # Arguments
847 ///
848 /// * `refs` - Slice of ref specifications to resolve
849 ///
850 /// # Returns
851 ///
852 /// HashMap mapping each input ref to its resolved SHA (or None if not found)
853 ///
854 /// # Performance
855 ///
856 /// - Single process for all refs vs one per ref
857 /// - Reduces 100 refs from ~5-10 seconds to ~0.5 seconds on Windows
858 ///
859 /// # Examples
860 ///
861 /// ```rust,ignore
862 /// use agpm_cli::git::GitRepo;
863 ///
864 /// # async fn example() -> anyhow::Result<()> {
865 /// let repo = GitRepo::new("/path/to/repo");
866 /// let refs = vec!["v1.0.0", "main", "abc1234"];
867 /// let results = repo.resolve_refs_batch(&refs).await?;
868 ///
869 /// for (ref_name, sha) in results {
870 /// if let Some(sha) = sha {
871 /// println!("{} -> {}", ref_name, sha);
872 /// } else {
873 /// println!("{} not found", ref_name);
874 /// }
875 /// }
876 /// # Ok(())
877 /// # }
878 /// ```
879 pub async fn resolve_refs_batch(
880 &self,
881 refs: &[&str],
882 ) -> Result<std::collections::HashMap<String, Option<String>>> {
883 use std::collections::HashMap;
884
885 if refs.is_empty() {
886 return Ok(HashMap::new());
887 }
888
889 // Partition refs: already-SHAs vs need-resolution
890 let (already_shas, to_resolve): (Vec<&str>, Vec<&str>) =
891 refs.iter().partition(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()));
892
893 let mut results: HashMap<String, Option<String>> = HashMap::new();
894
895 // Add already-resolved SHAs directly
896 for sha in already_shas {
897 results.insert(sha.to_string(), Some(sha.to_string()));
898 }
899
900 if to_resolve.is_empty() {
901 return Ok(results);
902 }
903
904 // Build arguments for git rev-parse: ["rev-parse", "ref1", "ref2", ...]
905 // This resolves all refs in a single git process
906 let mut args = vec!["rev-parse"];
907 args.extend(to_resolve.iter().copied());
908
909 // Execute batch resolution
910 let output = GitCommand::new().args(args).current_dir(&self.path).execute().await;
911
912 match output {
913 Ok(cmd_output) => {
914 // Parse output (one SHA per line, in order)
915 let shas: Vec<&str> = cmd_output.stdout.lines().collect();
916
917 for (i, ref_name) in to_resolve.iter().enumerate() {
918 let sha = shas.get(i).and_then(|s| {
919 let trimmed = s.trim();
920 // Only accept valid SHA output (40 hex chars)
921 if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
922 Some(trimmed.to_string())
923 } else {
924 None
925 }
926 });
927 results.insert(ref_name.to_string(), sha);
928 }
929 }
930 Err(e) => {
931 // If batch fails (e.g., one ref is invalid), fall back to individual resolution
932 tracing::debug!(
933 target: "git",
934 "Batch rev-parse failed, falling back to individual resolution: {}",
935 e
936 );
937
938 for ref_name in to_resolve {
939 let sha = GitCommand::rev_parse(ref_name)
940 .current_dir(&self.path)
941 .execute_stdout()
942 .await
943 .ok();
944 results.insert(ref_name.to_string(), sha);
945 }
946 }
947 }
948
949 Ok(results)
950 }
951
952 /// Resolves a Git reference (tag, branch, commit) to its full SHA-1 hash.
953 ///
954 /// # Arguments
955 ///
956 /// * `ref_spec` - The Git reference to resolve (tag, branch, short/full SHA, or None for HEAD)
957 /// # Returns
958 ///
959 /// # Errors
960 ///
961 /// - The reference doesn't exist in the repository
962 /// - The repository is invalid or corrupted
963 /// - Git command execution fails
964 pub async fn resolve_to_sha(&self, ref_spec: Option<&str>) -> Result<String> {
965 let reference = ref_spec.unwrap_or("HEAD");
966
967 // Optimization: if it's already a full SHA, return it directly
968 if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
969 return Ok(reference.to_string());
970 }
971
972 // Determine the reference to resolve based on type (tag vs branch)
973 let ref_to_resolve = if !reference.contains('/') && reference != "HEAD" {
974 // Check if this is a tag (uses cached tag list for performance)
975 let is_tag = self
976 .list_tags()
977 .await
978 .map(|tags| tags.contains(&reference.to_string()))
979 .unwrap_or(false);
980
981 if is_tag {
982 // It's a tag - use it directly
983 reference.to_string()
984 } else {
985 // Assume it's a branch name - try to resolve origin/branch first to get the latest from remote
986 // This ensures we get the most recent commit after a fetch
987 let origin_ref = format!("origin/{reference}");
988 if GitCommand::rev_parse(&origin_ref)
989 .current_dir(&self.path)
990 .execute_stdout()
991 .await
992 .is_ok()
993 {
994 origin_ref
995 } else {
996 // Fallback to the original reference (might be a local branch)
997 reference.to_string()
998 }
999 }
1000 } else {
1001 reference.to_string()
1002 };
1003
1004 // Use rev-parse to get the full SHA
1005 let sha = GitCommand::rev_parse(&ref_to_resolve)
1006 .current_dir(&self.path)
1007 .execute_stdout()
1008 .await
1009 .with_context(|| format!("Failed to resolve reference '{reference}' to SHA"))?;
1010
1011 // Ensure we have a full SHA (sometimes rev-parse can return short SHAs)
1012 if sha.len() < 40 {
1013 // Request the full SHA explicitly
1014 let full_sha = GitCommand::new()
1015 .args(["rev-parse", "--verify", &format!("{reference}^{{commit}}")])
1016 .current_dir(&self.path)
1017 .execute_stdout()
1018 .await
1019 .with_context(|| format!("Failed to get full SHA for reference '{reference}'"))?;
1020 Ok(full_sha)
1021 } else {
1022 Ok(sha)
1023 }
1024 }
1025
1026 pub async fn get_current_branch(&self) -> Result<String> {
1027 let branch = GitCommand::current_branch()
1028 .current_dir(&self.path)
1029 .execute_stdout()
1030 .await
1031 .context("Failed to get current branch")?;
1032
1033 if branch.is_empty() {
1034 // Fallback for very old Git or repos without commits
1035 Ok("master".to_string())
1036 } else {
1037 Ok(branch)
1038 }
1039 }
1040
1041 /// Gets the default branch name for the repository.
1042 ///
1043 /// # Returns
1044 ///
1045 /// # Errors
1046 ///
1047 /// - Git commands fail with non-recoverable errors
1048 /// - Lock conflicts occur (propagated for caller to retry)
1049 /// - Default branch cannot be determined
1050 pub async fn get_default_branch(&self) -> Result<String> {
1051 let result = GitCommand::new()
1052 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
1053 .current_dir(&self.path)
1054 .execute_stdout()
1055 .await;
1056
1057 match result {
1058 Ok(symbolic_ref) => {
1059 if let Some(branch) = symbolic_ref.strip_prefix("refs/remotes/origin/") {
1060 return Ok(branch.to_string());
1061 }
1062 // If parsing fails, fall through to the next method.
1063 }
1064 Err(e) => {
1065 let error_str = e.to_string();
1066 // If the ref is not found, it's not a fatal error, just fall back.
1067 // Any other error (like a lock file) should be propagated.
1068 if !error_str.contains("not a symbolic ref") && !error_str.contains("not found") {
1069 return Err(e).context("Failed to get default branch via symbolic-ref");
1070 }
1071 }
1072 }
1073
1074 // Fallback: try to get current branch (for non-bare repos or if symbolic-ref fails)
1075 self.get_current_branch().await
1076 }
1077}
1078
1079// Module-level helper functions for Git environment management and URL processing
1080
1081/// Checks if Git is installed and accessible on the system.
1082///
1083/// # Return Value
1084///
1085/// - `true` if Git is installed and responding to `--version` commands
1086/// - `false` if Git is not found, not in PATH, or not executable
1087///
1088#[must_use]
1089pub fn is_git_installed() -> bool {
1090 // For synchronous checking, we still use std::process::Command directly
1091 std::process::Command::new(crate::utils::platform::get_git_command())
1092 .arg("--version")
1093 .output()
1094 .map(|output| output.status.success())
1095 .unwrap_or(false)
1096}
1097
1098/// Ensures Git is available on the system or returns a detailed error.
1099///
1100/// # Return Value
1101///
1102/// - `Ok(())` if Git is properly installed and accessible
1103/// - `Err(AgpmError::GitNotFound)` if Git is not available
1104///
1105pub fn ensure_git_available() -> Result<()> {
1106 if !is_git_installed() {
1107 return Err(AgpmError::GitNotFound.into());
1108 }
1109 Ok(())
1110}
1111
1112/// Checks if a path contains a Git repository (regular or bare).
1113///
1114/// # Arguments
1115///
1116/// * `path` - The path to check for a Git repository
1117/// # Returns
1118///
1119/// * `true` if the path is a valid Git repository (regular or bare)
1120/// * `false` if neither repository marker exists
1121#[must_use]
1122pub fn is_git_repository(path: &Path) -> bool {
1123 // Check for regular repository (.git directory) or bare repository (HEAD file)
1124 path.join(".git").exists() || path.join("HEAD").exists()
1125}
1126
1127/// Checks if a directory contains a valid Git repository.
1128///
1129/// # Arguments
1130///
1131/// * `path` - The directory path to check for Git repository validity
1132/// # Return Value
1133///
1134/// - `true` if the path contains a `.git` subdirectory
1135/// - `false` if the `.git` subdirectory is missing or the path doesn't exist
1136///
1137#[must_use]
1138pub fn is_valid_git_repo(path: &Path) -> bool {
1139 is_git_repository(path)
1140}
1141
1142/// Ensures a directory contains a valid Git repository or returns a detailed error.
1143///
1144/// # Arguments
1145///
1146/// * `path` - The directory path to validate as a Git repository
1147/// # Return Value
1148///
1149/// - `Ok(())` if the path contains a valid `.git` directory
1150/// - `Err(AgpmError::GitRepoInvalid)` if the path is not a Git repository
1151///
1152pub fn ensure_valid_git_repo(path: &Path) -> Result<()> {
1153 if !is_valid_git_repo(path) {
1154 return Err(AgpmError::GitRepoInvalid {
1155 path: path.display().to_string(),
1156 }
1157 .into());
1158 }
1159 Ok(())
1160}
1161
1162/// Parses a Git URL into owner and repository name components.
1163///
1164/// # Arguments
1165///
1166/// * `url` - The Git repository URL to parse
1167/// # Return Value
1168///
1169/// - `owner` is the user, organization, or "local" for local repositories
1170/// - `repository_name` is the repository name (with `.git` suffix removed)
1171///
1172/// # Errors
1173///
1174/// - The URL format is not recognized
1175/// - The URL doesn't contain sufficient path components
1176/// - The URL structure doesn't match expected patterns
1177///
1178pub fn parse_git_url(url: &str) -> Result<(String, String)> {
1179 use std::path::Path;
1180
1181 // Handle file:// URLs
1182 if url.starts_with("file://") {
1183 let path_str = url.trim_start_matches("file://");
1184 let path = Path::new(path_str);
1185 let repo_name = path
1186 .file_name()
1187 .and_then(|n| n.to_str())
1188 .map(|s| s.trim_end_matches(".git"))
1189 .unwrap_or(path_str);
1190 return Ok(("local".to_string(), repo_name.to_string()));
1191 }
1192
1193 // Handle plain local paths (absolute or relative)
1194 if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
1195 let path = Path::new(url);
1196 let repo_name = path
1197 .file_name()
1198 .and_then(|n| n.to_str())
1199 .map(|s| s.trim_end_matches(".git"))
1200 .unwrap_or(url);
1201 return Ok(("local".to_string(), repo_name.to_string()));
1202 }
1203
1204 // Handle SSH URLs like git@github.com:user/repo.git
1205 if url.contains('@')
1206 && url.contains(':')
1207 && !url.starts_with("ssh://")
1208 && let Some(colon_pos) = url.find(':')
1209 {
1210 let path = &url[colon_pos + 1..];
1211 let path = path.trim_end_matches(".git");
1212 if let Some(slash_pos) = path.find('/') {
1213 return Ok((path[..slash_pos].to_string(), path[slash_pos + 1..].to_string()));
1214 }
1215 }
1216
1217 // Handle HTTPS URLs
1218 if url.contains("github.com") || url.contains("gitlab.com") || url.contains("bitbucket.org") {
1219 let parts: Vec<&str> = url.split('/').collect();
1220 if parts.len() >= 2 {
1221 let repo = parts[parts.len() - 1].trim_end_matches(".git");
1222 let owner = parts[parts.len() - 2];
1223 return Ok((owner.to_string(), repo.to_string()));
1224 }
1225 }
1226
1227 Err(anyhow::anyhow!("Could not parse repository owner and name from URL"))
1228}
1229
1230/// Strips authentication information from a Git URL for safe display or logging.
1231///
1232/// # Arguments
1233///
1234/// * `url` - The Git URL that may contain authentication information
1235/// # Return Value
1236///
1237/// - HTTPS URLs: Removes `user:token@` prefix
1238/// - SSH URLs: Returned unchanged (no embedded auth to strip)
1239/// - Other formats: Returned unchanged if no auth detected
1240///
1241pub fn strip_auth_from_url(url: &str) -> Result<String> {
1242 if url.starts_with("https://") || url.starts_with("http://") {
1243 // Find the @ symbol that marks the end of authentication
1244 if let Some(at_pos) = url.find('@') {
1245 let protocol_end = if url.starts_with("https://") {
1246 "https://".len()
1247 } else {
1248 "http://".len()
1249 };
1250
1251 // Check if @ is part of auth (comes before first /)
1252 let first_slash = url[protocol_end..].find('/').map(|p| p + protocol_end);
1253 if first_slash.is_none() || at_pos < first_slash.unwrap() {
1254 // Extract protocol and the part after @
1255 let protocol = &url[..protocol_end];
1256 let after_auth = &url[at_pos + 1..];
1257 return Ok(format!("{protocol}{after_auth}"));
1258 }
1259 }
1260 }
1261
1262 // Return URL as-is if no auth found or not HTTP(S)
1263 Ok(url.to_string())
1264}