Skip to main content

a3s_code_core/
git.rs

1//! Git tool — Uses system git with auto-installation support
2//!
3//! Provides Git operations using the external git command.
4//! If git is not installed, downloads and installs it automatically on Windows, macOS, and Linux.
5
6use anyhow::{anyhow, Result};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use tokio::sync::OnceCell;
10
11static GIT_AVAILABLE: OnceCell<bool> = OnceCell::const_new();
12
13/// Check if git is available on the system.
14pub fn is_git_available() -> bool {
15    Command::new("git")
16        .arg("--version")
17        .output()
18        .map(|o| o.status.success())
19        .unwrap_or(false)
20}
21
22/// Get the git installation directory in user's home.
23fn git_install_dir() -> PathBuf {
24    dirs::home_dir()
25        .unwrap_or_else(|| PathBuf::from("/usr/local"))
26        .join(".local")
27        .join("git")
28}
29
30/// Check if a path is inside a git repository.
31pub fn is_git_repo(path: &Path) -> bool {
32    Command::new("git")
33        .args(["-C", &path.display().to_string()])
34        .args(["rev-parse", "--git-dir"])
35        .output()
36        .map(|o| o.status.success())
37        .unwrap_or(false)
38}
39
40/// Ensure git is installed. Downloads and installs git if not found.
41pub fn ensure_git_installed() -> Result<()> {
42    // Fast path: already checked and available
43    if GIT_AVAILABLE.get().copied().unwrap_or(false) {
44        return Ok(());
45    }
46
47    if is_git_available() {
48        let _ = GIT_AVAILABLE.set(true);
49        return Ok(());
50    }
51
52    // Try to download and install git
53    let install_result = if cfg!(target_os = "macos") {
54        install_git_macos()
55    } else if cfg!(target_os = "linux") {
56        install_git_linux()
57    } else if cfg!(target_os = "windows") {
58        install_git_windows()
59    } else {
60        Err(anyhow!(
61            "Unsupported platform: {}. Please install git manually from https://git-scm.com",
62            std::env::consts::OS
63        ))
64    };
65
66    if install_result.is_ok() {
67        let _ = GIT_AVAILABLE.set(true);
68    }
69
70    install_result
71}
72
73fn install_git_macos() -> Result<()> {
74    // Download official macOS git installer
75    let install_dir = git_install_dir();
76    let bin_dir = install_dir.join("bin");
77    let git_path = bin_dir.join("git");
78
79    // If already installed, just verify
80    if git_path.exists() {
81        return Ok(());
82    }
83
84    std::fs::create_dir_all(&bin_dir)?;
85
86    // Download git-2.39.3-arm64-bin.tar.gz (Apple Silicon)
87    // and git-2.39.3-x86_64-bin.tar.gz (Intel)
88    let arch = if std::process::Command::new("uname")
89        .arg("-m")
90        .output()
91        .map(|o| String::from_utf8_lossy(&o.stdout).contains("arm"))
92        .unwrap_or(false)
93    {
94        "arm64"
95    } else {
96        "x86_64"
97    };
98
99    let tarball_name = format!("git-2.39.3-{}-bin.tar.gz", arch);
100    let download_url = format!("https://git-scm.com/download/mac/{}", tarball_name);
101
102    let temp_tarball = std::env::temp_dir().join(&tarball_name);
103
104    // Download with retry
105    download_with_curl(&download_url, &temp_tarball)?;
106
107    // Extract tarball
108    let output = Command::new("tar")
109        .args([
110            "-xzf",
111            &temp_tarball.display().to_string(),
112            "-C",
113            &bin_dir.display().to_string(),
114        ])
115        .output()?;
116
117    if !output.status.success() {
118        // Try alternative extraction
119        let output = Command::new("bash")
120            .args([
121                "-c",
122                &format!(
123                    "cd {} && tar -xzf {}",
124                    bin_dir.display(),
125                    temp_tarball.display()
126                ),
127            ])
128            .output()?;
129
130        if !output.status.success() {
131            return Err(anyhow!("Failed to extract git tarball"));
132        }
133    }
134
135    // Clean up
136    let _ = std::fs::remove_file(&temp_tarball);
137
138    // Verify installation
139    if bin_dir.join("git").exists() {
140        Ok(())
141    } else {
142        // Try extracting directly to install_dir
143        let output = Command::new("tar")
144            .args(["-xzf", &temp_tarball.display().to_string()])
145            .current_dir(&install_dir)
146            .output()?;
147
148        if output.status.success() {
149            // Move contents from bin to our bin dir
150            let extracted_bin = install_dir.join("usr").join("bin");
151            if extracted_bin.exists() {
152                for entry in std::fs::read_dir(&extracted_bin)?.flatten() {
153                    let _ = std::fs::rename(entry.path(), bin_dir.join(entry.file_name()));
154                }
155            }
156        }
157
158        if bin_dir.join("git").exists() {
159            Ok(())
160        } else {
161            Err(anyhow!(
162                "Failed to install git. Please download from https://git-scm.com/download/mac"
163            ))
164        }
165    }
166}
167
168fn install_git_linux() -> Result<()> {
169    let install_dir = git_install_dir();
170    let bin_dir = install_dir.join("bin");
171    let git_path = bin_dir.join("git");
172
173    // If already installed, verify and return
174    if git_path.exists() {
175        return Ok(());
176    }
177
178    std::fs::create_dir_all(&bin_dir)?;
179
180    // Try to download static git binary for Linux
181    // Use the official git releases from GitHub
182    let version = "2.39.3";
183    let arch = if std::process::Command::new("uname")
184        .arg("-m")
185        .output()
186        .map(|o| String::from_utf8_lossy(&o.stdout).contains("x86_64"))
187        .unwrap_or(true)
188    {
189        "amd64"
190    } else {
191        "386"
192    };
193
194    // Try GitHub releases first (has portable binaries)
195    let tarball_name = format!("git-{}-linux-{}.tar.gz", version, arch);
196    let download_url = format!(
197        "https://github.com/git/git/releases/download/v{}/{}",
198        version, tarball_name
199    );
200
201    let temp_tarball = std::env::temp_dir().join(&tarball_name);
202
203    // Try downloading from GitHub
204    if download_with_curl(&download_url, &temp_tarball).is_err() {
205        // Fallback: try official download page
206        let fallback_url = format!("https://git-scm.com/downloads?file=git-{}", tarball_name);
207        download_with_curl(&fallback_url, &temp_tarball)?;
208    }
209
210    // Extract
211    let output = Command::new("tar")
212        .args([
213            "-xzf",
214            &temp_tarball.display().to_string(),
215            "-C",
216            &bin_dir.display().to_string(),
217        ])
218        .output()?;
219
220    if !output.status.success() {
221        // Alternative: extract to temp and move
222        let temp_dir = std::env::temp_dir().join("git-extract");
223        let _ = std::fs::create_dir_all(&temp_dir);
224
225        let output = Command::new("tar")
226            .args([
227                "-xzf",
228                &temp_tarball.display().to_string(),
229                "-C",
230                &temp_dir.display().to_string(),
231            ])
232            .output()?;
233
234        if output.status.success() {
235            // Find and move git binary
236            for path in walkdir(&temp_dir) {
237                if let Some(name) = path.file_name() {
238                    let name_str = name.to_string_lossy();
239                    if name_str.starts_with("git-") && path.extension().is_none() {
240                        let _ = std::fs::copy(&path, bin_dir.join("git"));
241                    }
242                }
243            }
244        }
245    }
246
247    let _ = std::fs::remove_file(&temp_tarball);
248
249    if bin_dir.join("git").exists() {
250        Ok(())
251    } else {
252        Err(anyhow!(
253            "Failed to install git automatically.\n\n\
254            Please install git via your system's package manager or download from:\n\
255            https://git-scm.com/download/linux"
256        ))
257    }
258}
259
260fn install_git_windows() -> Result<()> {
261    let install_dir = git_install_dir();
262    let bin_dir = install_dir.join("bin");
263    let git_exe = bin_dir.join("git.exe");
264
265    // If already installed, verify and return
266    if git_exe.exists() {
267        return Ok(());
268    }
269
270    std::fs::create_dir_all(&bin_dir)?;
271
272    // Download portable Git for Windows (MinGit)
273    let version = "2.39.3.windows.1";
274    let zip_name = format!("MinGit-{}-portable.zip", version);
275    let download_url = format!(
276        "https://github.com/git-for-windows/git/releases/download/{}/{}",
277        version, zip_name
278    );
279
280    let temp_zip = std::env::temp_dir().join(&zip_name);
281
282    download_with_curl(&download_url, &temp_zip)?;
283
284    // Extract zip
285    let output = Command::new("tar")
286        .args([
287            "-xf",
288            &temp_zip.display().to_string(),
289            "-C",
290            &bin_dir.display().to_string(),
291        ])
292        .output()?;
293
294    if !output.status.success() {
295        // Try with PowerShell Expand-Archive on Windows
296        let ps_script = format!(
297            "Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
298            temp_zip.display(),
299            bin_dir.display()
300        );
301
302        let output = Command::new("powershell")
303            .args(["-Command", &ps_script])
304            .output()?;
305
306        if !output.status.success() {
307            return Err(anyhow!("Failed to extract git zip archive"));
308        }
309    }
310
311    // Find git.exe in the extracted contents
312    let extracted_dir = bin_dir.join(format!("MinGit-{}", version));
313    if extracted_dir.exists() {
314        // Move contents up one level
315        for entry in std::fs::read_dir(&extracted_dir)?.flatten() {
316            let dest = bin_dir.join(entry.file_name());
317            let _ = std::fs::rename(entry.path(), dest);
318        }
319        let _ = std::fs::remove_dir(&extracted_dir);
320    }
321
322    let _ = std::fs::remove_file(&temp_zip);
323
324    if git_exe.exists() {
325        // Also copy git.exe to git-bash.exe and cmd/git.exe
326        let cmd_dir = bin_dir.join("cmd");
327        std::fs::create_dir_all(&cmd_dir)?;
328        let _ = std::fs::copy(&git_exe, cmd_dir.join("git.exe"));
329        Ok(())
330    } else {
331        Err(anyhow!(
332            "Failed to install git automatically.\n\n\
333            Please download and install Git from:\n\
334            https://git-scm.com/download/win"
335        ))
336    }
337}
338
339/// Download a file using curl with retry.
340fn download_with_curl(url: &str, path: &std::path::Path) -> Result<()> {
341    // Try curl first
342    let output = Command::new("curl")
343        .args([
344            "-L",
345            "--fail",
346            "--retry",
347            "3",
348            "--retry-delay",
349            "2",
350            "-o",
351            &path.display().to_string(),
352            url,
353        ])
354        .output()?;
355
356    if output.status.success() && path.exists() {
357        return Ok(());
358    }
359
360    // Fallback: try wget
361    let output = Command::new("wget")
362        .args(["-O", &path.display().to_string(), url])
363        .output()?;
364
365    if output.status.success() && path.exists() {
366        return Ok(());
367    }
368
369    Err(anyhow!(
370        "Failed to download git from {}.\n\
371        Please check your internet connection and try again.\n\
372        Or download manually from https://git-scm.com",
373        url
374    ))
375}
376
377/// Walk directory recursively (simple implementation).
378fn walkdir(dir: &Path) -> Vec<std::path::PathBuf> {
379    let mut files = Vec::new();
380    if let Ok(entries) = std::fs::read_dir(dir) {
381        for entry in entries.filter_map(|e| e.ok()) {
382            let path = entry.path();
383            if path.is_dir() {
384                files.extend(walkdir(&path));
385            } else {
386                files.push(path);
387            }
388        }
389    }
390    files
391}
392
393/// Run a git command.
394fn run_git(repo_path: &Path, args: &[&str]) -> Result<(bool, String, String)> {
395    ensure_git_installed()?;
396
397    let output = Command::new("git")
398        .args(["-C", &repo_path.display().to_string()])
399        .args(args)
400        .output()
401        .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
402
403    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
404    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
405
406    Ok((output.status.success(), stdout, stderr))
407}
408
409// ==================== Git Operations ====================
410
411/// Repository status information.
412#[derive(Debug, Clone)]
413pub struct RepoStatus {
414    pub branch: String,
415    pub commit: String,
416    pub is_worktree: bool,
417    pub is_dirty: bool,
418    pub dirty_count: usize,
419}
420
421/// Get repository status.
422pub fn get_status(repo_path: &Path) -> Result<RepoStatus> {
423    let (success, stdout, _) = run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
424    let branch = if success {
425        stdout.trim().to_string()
426    } else {
427        "(detached)".to_string()
428    };
429
430    let (_, commit, _) = run_git(repo_path, &["log", "--oneline", "-1", "--no-decorate"])?;
431    let commit = commit.trim().to_string();
432
433    let (_, git_dir, _) = run_git(repo_path, &["rev-parse", "--git-dir"])?;
434    let is_worktree = git_dir.trim().contains(".git/worktrees");
435
436    let (_, status_output, _) = run_git(repo_path, &["status", "--porcelain", "--short"])?;
437    let dirty_count = status_output.lines().filter(|l| !l.is_empty()).count();
438    let is_dirty = dirty_count > 0;
439
440    Ok(RepoStatus {
441        branch,
442        commit,
443        is_worktree,
444        is_dirty,
445        dirty_count,
446    })
447}
448
449/// Commit information for log display.
450#[derive(Debug, Clone)]
451pub struct CommitInfo {
452    pub id: String,
453    pub message: String,
454    pub author: String,
455    pub date: String,
456}
457
458/// Get commit log.
459pub fn get_log(repo_path: &Path, max_count: usize) -> Result<Vec<CommitInfo>> {
460    let format = "%H|%s|%an|%ad";
461    let date_format = "%Y-%m-%d %H:%M";
462    let args = [
463        "log",
464        &format!("--format={}", format),
465        &format!("--date=format:{}", date_format),
466        &format!("-{}", max_count),
467    ];
468
469    let (_, stdout, _) = run_git(repo_path, &args)?;
470
471    let commits: Vec<CommitInfo> = stdout
472        .lines()
473        .filter_map(|line| {
474            let parts: Vec<&str> = line.splitn(4, '|').collect();
475            if parts.len() >= 4 {
476                Some(CommitInfo {
477                    id: parts[0].to_string(),
478                    message: parts[1].to_string(),
479                    author: parts[2].to_string(),
480                    date: parts[3].to_string(),
481                })
482            } else {
483                None
484            }
485        })
486        .collect();
487
488    Ok(commits)
489}
490
491/// Branch information.
492#[derive(Debug, Clone)]
493pub struct BranchInfo {
494    pub name: String,
495    pub is_current: bool,
496}
497
498/// List all local branches.
499pub fn list_branches(repo_path: &Path) -> Result<Vec<BranchInfo>> {
500    let (_, stdout, _) = run_git(repo_path, &["branch"])?;
501
502    let branches: Vec<BranchInfo> = stdout
503        .lines()
504        .filter_map(|line| {
505            let line = line.trim();
506            if line.is_empty() {
507                return None;
508            }
509            let is_current = line.starts_with('*');
510            let name = line.trim_start_matches(['*', ' ']).to_string();
511            Some(BranchInfo { name, is_current })
512        })
513        .collect();
514
515    Ok(branches)
516}
517
518/// Create a new branch.
519pub fn create_branch(repo_path: &Path, name: &str, base: &str) -> Result<()> {
520    let (success, _, stderr) = run_git(repo_path, &["checkout", "-b", name, base])?;
521    if !success && !stderr.is_empty() {
522        return Err(anyhow!("Failed to create branch: {}", stderr));
523    }
524    Ok(())
525}
526
527/// Delete a branch.
528pub fn delete_branch(repo_path: &Path, name: &str) -> Result<()> {
529    // Try -d first (safe delete)
530    let (success, _, _) = run_git(repo_path, &["branch", "-d", name])?;
531    if success {
532        return Ok(());
533    }
534
535    // Try force delete
536    let (success, _, stderr) = run_git(repo_path, &["branch", "-D", name])?;
537    if !success {
538        return Err(anyhow!("Failed to delete branch: {}", stderr));
539    }
540    Ok(())
541}
542
543/// Worktree information.
544#[derive(Debug, Clone)]
545pub struct WorktreeInfo {
546    pub path: String,
547    pub branch: String,
548    pub is_bare: bool,
549    pub is_detached: bool,
550}
551
552/// List all worktrees.
553pub fn list_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>> {
554    let (_, stdout, _) = run_git(repo_path, &["worktree", "list", "--porcelain"])?;
555
556    let mut worktrees = Vec::new();
557    let mut current_path = String::new();
558    let mut current_branch = String::new();
559    let mut is_bare = false;
560    let mut is_detached = false;
561
562    for line in stdout.lines() {
563        if let Some(path) = line.strip_prefix("worktree ") {
564            if !current_path.is_empty() {
565                worktrees.push(WorktreeInfo {
566                    path: current_path.clone(),
567                    branch: current_branch.clone(),
568                    is_bare,
569                    is_detached,
570                });
571            }
572            current_path = path.to_string();
573            current_branch.clear();
574            is_bare = false;
575            is_detached = false;
576        } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
577            current_branch = branch.to_string();
578        } else if line == "bare" {
579            is_bare = true;
580        } else if line == "detached" {
581            is_detached = true;
582        }
583    }
584
585    if !current_path.is_empty() {
586        worktrees.push(WorktreeInfo {
587            path: current_path,
588            branch: current_branch,
589            is_bare,
590            is_detached,
591        });
592    }
593
594    Ok(worktrees)
595}
596
597/// Create a new worktree.
598pub fn create_worktree(
599    repo_path: &Path,
600    branch: &str,
601    path: &Path,
602    new_branch: bool,
603) -> Result<()> {
604    let path_str = path.display().to_string();
605    let args: Vec<&str> = if new_branch {
606        vec!["worktree", "add", "-b", branch, &path_str]
607    } else {
608        vec!["worktree", "add", &path_str, branch]
609    };
610
611    let (success, _, stderr) = run_git(repo_path, &args)?;
612    if !success {
613        return Err(anyhow!("Failed to create worktree: {}", stderr));
614    }
615    Ok(())
616}
617
618/// Remove a worktree.
619pub fn remove_worktree(repo_path: &Path, path: &Path, force: bool) -> Result<()> {
620    let path_str = path.display().to_string();
621    let args: Vec<&str> = if force {
622        vec!["worktree", "remove", "--force", &path_str]
623    } else {
624        vec!["worktree", "remove", &path_str]
625    };
626
627    let (success, _, stderr) = run_git(repo_path, &args)?;
628    if !success {
629        return Err(anyhow!("Failed to remove worktree: {}", stderr));
630    }
631    Ok(())
632}
633
634/// Get the git directory path.
635pub fn get_git_dir(repo_path: &Path) -> Result<String> {
636    let (_, stdout, _) = run_git(repo_path, &["rev-parse", "--git-dir"])?;
637    Ok(stdout.trim().to_string())
638}
639
640/// Get diff output.
641pub fn get_diff(repo_path: &Path, target: Option<&str>) -> Result<String> {
642    let args: Vec<&str> = if let Some(t) = target {
643        vec!["diff", t]
644    } else {
645        vec!["diff", "--stat"]
646    };
647
648    let (_, stdout, _) = run_git(repo_path, &args)?;
649    Ok(stdout)
650}
651
652/// Stash information.
653#[derive(Debug, Clone)]
654pub struct StashInfo {
655    pub index: usize,
656    pub message: String,
657}
658
659/// List stashes.
660pub fn list_stashes(repo_path: &Path) -> Result<Vec<StashInfo>> {
661    let (_, stdout, _) = run_git(repo_path, &["stash", "list", "--format=%H|%gd|%s"])?;
662
663    let stashes: Vec<StashInfo> = stdout
664        .lines()
665        .filter_map(|line| {
666            let parts: Vec<&str> = line.splitn(3, '|').collect();
667            if parts.len() >= 3 {
668                Some(StashInfo {
669                    index: parts[1].parse().unwrap_or(0),
670                    message: parts[2].to_string(),
671                })
672            } else {
673                None
674            }
675        })
676        .collect();
677
678    Ok(stashes)
679}
680
681/// Create a stash.
682pub fn stash(repo_path: &Path, message: Option<&str>, include_untracked: bool) -> Result<()> {
683    let mut args = vec!["stash", "push"];
684    if include_untracked {
685        args.push("-u");
686    }
687    if let Some(msg) = message {
688        args.push("-m");
689        args.push(msg);
690    }
691
692    let (success, _, stderr) = run_git(repo_path, &args)?;
693    if !success {
694        return Err(anyhow!("Failed to stash: {}", stderr));
695    }
696    Ok(())
697}