1use crate::config::Config;
2use std::process::Command;
3
4pub fn status(cfg: &Config) -> anyhow::Result<()> {
5 if cfg.repos.tracked.is_empty() {
6 eprintln!("No repos tracked. Add repos to [repos] in config.toml");
7 return Ok(());
8 }
9 eprint!("Fetching remotes...");
11 for repo in &cfg.repos.tracked {
12 let path = format!("{}/{}", cfg.repos.root, repo);
13 if std::path::Path::new(&path).exists() {
14 let _ = Command::new("git")
15 .args(["-C", &path, "fetch", "--quiet"])
16 .status();
17 }
18 }
19 eprintln!(" done.\n");
20
21 println!(
22 "{:<20} {:<10} {:<8} {:<8} {}",
23 "REPO", "BRANCH", "AHEAD", "BEHIND", "STATUS"
24 );
25 println!("{}", "-".repeat(65));
26
27 let mut dirty_repos = 0;
28 let mut unpushed_repos = 0;
29
30 for repo in &cfg.repos.tracked {
31 let path = format!("{}/{}", cfg.repos.root, repo);
32 if !std::path::Path::new(&path).exists() {
33 println!("{:<20} {:<10} {:<8} {:<8} NOT FOUND", repo, "-", "-", "-");
34 continue;
35 }
36 let branch = git_output(&path, &["branch", "--show-current"]);
37 let branch = branch.trim();
38
39 let ab_output = git_output(
41 &path,
42 &[
43 "rev-list",
44 "--left-right",
45 "--count",
46 &format!("{}...@{{u}}", branch),
47 ],
48 );
49 let (ahead, behind) = parse_ahead_behind(&ab_output);
50
51 let dirty = git_output(&path, &["status", "--porcelain"]);
53 let dirty_count = dirty.lines().filter(|l| !l.is_empty()).count();
54
55 let mut status_parts = Vec::new();
56 if dirty_count > 0 {
57 status_parts.push(format!("{} dirty", dirty_count));
58 dirty_repos += 1;
59 }
60 if ahead > 0 {
61 status_parts.push(format!("{} to push", ahead));
62 unpushed_repos += 1;
63 }
64 if behind > 0 {
65 status_parts.push(format!("{} to pull", behind));
66 }
67 let status_str = if status_parts.is_empty() {
68 "clean".to_string()
69 } else {
70 status_parts.join(", ")
71 };
72
73 let ahead_str = if ahead > 0 {
74 format!("+{}", ahead)
75 } else {
76 "-".to_string()
77 };
78 let behind_str = if behind > 0 {
79 format!("-{}", behind)
80 } else {
81 "-".to_string()
82 };
83
84 println!(
85 "{:<20} {:<10} {:<8} {:<8} {}",
86 repo, branch, ahead_str, behind_str, status_str
87 );
88 }
89
90 println!("{}", "-".repeat(65));
92 let total = cfg.repos.tracked.len();
93 let clean = total - dirty_repos.max(unpushed_repos);
94 println!(
95 "{} repos: {} clean, {} dirty, {} unpushed",
96 total, clean, dirty_repos, unpushed_repos
97 );
98
99 Ok(())
100}
101
102fn parse_ahead_behind(output: &str) -> (usize, usize) {
103 let parts: Vec<&str> = output.trim().split('\t').collect();
104 if parts.len() == 2 {
105 let ahead = parts[0].parse().unwrap_or(0);
106 let behind = parts[1].parse().unwrap_or(0);
107 (ahead, behind)
108 } else {
109 (0, 0)
110 }
111}
112
113pub fn sync(cfg: &Config, dry_run: bool) -> anyhow::Result<()> {
114 if cfg.repos.tracked.is_empty() {
115 anyhow::bail!("No repos tracked. Add repos to [repos] in config.toml");
116 }
117 for repo in &cfg.repos.tracked {
118 let path = format!("{}/{}", cfg.repos.root, repo);
119 if !std::path::Path::new(&path).exists() {
120 eprintln!("{}: NOT FOUND, skipping", repo);
121 continue;
122 }
123 let dirty = git_output(&path, &["status", "--porcelain"]);
124 if !dirty.trim().is_empty() {
125 eprintln!("{}: dirty — skipping (commit first)", repo);
126 continue;
127 }
128 if dry_run {
129 eprintln!("{}: clean (dry-run, would pull+push)", repo);
130 continue;
131 }
132 eprint!("{}: ", repo);
133 let pull = Command::new("git").args(["-C", &path, "pull", "--ff-only"]).status()?;
134 if pull.success() {
135 let push = Command::new("git").args(["-C", &path, "push"]).status()?;
136 eprintln!("{}", if push.success() { "synced" } else { "push failed" });
137 } else {
138 eprintln!("pull failed (diverged?)");
139 }
140 }
141 Ok(())
142}
143
144fn git_output(repo_path: &str, args: &[&str]) -> String {
145 Command::new("git")
146 .args([&["-C", repo_path], args].concat())
147 .output()
148 .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
149 .unwrap_or_default()
150}