1use 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
13pub 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
22fn git_install_dir() -> PathBuf {
24 dirs::home_dir()
25 .unwrap_or_else(|| PathBuf::from("/usr/local"))
26 .join(".local")
27 .join("git")
28}
29
30pub 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
40pub fn ensure_git_installed() -> Result<()> {
42 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 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 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 git_path.exists() {
81 return Ok(());
82 }
83
84 std::fs::create_dir_all(&bin_dir)?;
85
86 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_curl(&download_url, &temp_tarball)?;
106
107 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 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 let _ = std::fs::remove_file(&temp_tarball);
137
138 if bin_dir.join("git").exists() {
140 Ok(())
141 } else {
142 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 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 git_path.exists() {
175 return Ok(());
176 }
177
178 std::fs::create_dir_all(&bin_dir)?;
179
180 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 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 if download_with_curl(&download_url, &temp_tarball).is_err() {
205 let fallback_url = format!("https://git-scm.com/downloads?file=git-{}", tarball_name);
207 download_with_curl(&fallback_url, &temp_tarball)?;
208 }
209
210 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 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 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 git_exe.exists() {
267 return Ok(());
268 }
269
270 std::fs::create_dir_all(&bin_dir)?;
271
272 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 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 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 let extracted_dir = bin_dir.join(format!("MinGit-{}", version));
313 if extracted_dir.exists() {
314 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 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
339fn download_with_curl(url: &str, path: &std::path::Path) -> Result<()> {
341 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 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
377fn 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
393fn 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#[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
421pub 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#[derive(Debug, Clone)]
451pub struct CommitInfo {
452 pub id: String,
453 pub message: String,
454 pub author: String,
455 pub date: String,
456}
457
458pub 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#[derive(Debug, Clone)]
493pub struct BranchInfo {
494 pub name: String,
495 pub is_current: bool,
496}
497
498pub 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
518pub 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
527pub fn delete_branch(repo_path: &Path, name: &str) -> Result<()> {
529 let (success, _, _) = run_git(repo_path, &["branch", "-d", name])?;
531 if success {
532 return Ok(());
533 }
534
535 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#[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
552pub 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
597pub 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
618pub 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
634pub 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
640pub 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#[derive(Debug, Clone)]
654pub struct StashInfo {
655 pub index: usize,
656 pub message: String,
657}
658
659pub 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
681pub 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}