1use colored::Colorize;
2use std::collections::BTreeSet;
3use std::path::{Path, PathBuf};
4use tokio::process::Command;
5
6use crate::utils::command_exists;
7
8pub struct AutoCommitRequest<'a> {
9 pub project_root: &'a Path,
10 pub paths: Vec<PathBuf>,
11 pub message: String,
12 pub action_label: &'a str,
13}
14
15pub enum AutoCommitResult {
16 Committed(AutoCommitOutcome),
17 Skipped(String),
18}
19
20pub struct AutoCommitOutcome {
21 pub repo_name: String,
22 pub repo_root: PathBuf,
23 pub branch: Option<String>,
24 pub commit_sha: String,
25 pub short_sha: String,
26 pub message: String,
27 pub committed_files: Vec<String>,
28}
29
30pub struct PushOutcome {
31 pub branch: String,
32}
33
34pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
35 if !command_exists("git") {
36 return Ok(AutoCommitResult::Skipped(
37 "Git is not installed on this machine.".to_string(),
38 ));
39 }
40
41 let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
42 {
43 Ok(root) => PathBuf::from(root),
44 Err(_) => {
45 return Ok(AutoCommitResult::Skipped(
46 "Current project is not inside a git repository.".to_string(),
47 ));
48 }
49 };
50
51 let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
52 if normalized_paths.is_empty() {
53 return Ok(AutoCommitResult::Skipped(
54 "No generated or updated files were provided for auto-commit.".to_string(),
55 ));
56 }
57
58 let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
59 add_args.extend(normalized_paths.iter().cloned());
60 git_output_owned(request.project_root, add_args).await?;
61
62 let mut diff_args = vec![
63 "diff".to_string(),
64 "--cached".to_string(),
65 "--name-only".to_string(),
66 "--".to_string(),
67 ];
68 diff_args.extend(normalized_paths.iter().cloned());
69 let committed_files = git_output_owned(request.project_root, diff_args)
70 .await?
71 .lines()
72 .map(str::trim)
73 .filter(|line| !line.is_empty())
74 .map(|line| line.replace('\\', "/"))
75 .collect::<Vec<_>>();
76
77 if committed_files.is_empty() {
78 return Ok(AutoCommitResult::Skipped(
79 "Target files did not produce any staged git diff.".to_string(),
80 ));
81 }
82
83 git_output(
84 request.project_root,
85 &["commit", "-m", request.message.as_str()],
86 )
87 .await?;
88
89 let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
90 let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
91 let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
92 .await
93 .ok()
94 .filter(|value| !value.is_empty() && value != "HEAD");
95
96 let outcome = AutoCommitOutcome {
97 repo_name: repo_name(&repo_root),
98 repo_root,
99 branch,
100 commit_sha,
101 short_sha,
102 message: request.message,
103 committed_files,
104 };
105
106 print_commit_summary(request.action_label, &outcome);
107
108 Ok(AutoCommitResult::Committed(outcome))
109}
110
111pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
112 if !command_exists("git") {
113 return Ok(None);
114 }
115
116 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
117 .await
118 .ok()
119 .filter(|value| !value.is_empty() && value != "HEAD");
120
121 let Some(branch) = branch else {
122 return Ok(None);
123 };
124
125 git_output(project_root, &["push"]).await?;
126 Ok(Some(PushOutcome { branch }))
127}
128
129pub fn print_skip(action_label: &str, reason: &str) {
130 println!(
131 "{} {} {}",
132 "Auto-commit".bright_yellow().bold(),
133 format!("skipped for {}", action_label).bright_white(),
134 format!("({})", reason).dimmed()
135 );
136}
137
138pub fn print_push_summary(outcome: &PushOutcome) {
139 println!(
140 "{} {}",
141 "Pushed".bright_green().bold(),
142 format!("origin/{}", outcome.branch).bright_white()
143 );
144}
145
146fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
147 let branch = outcome
148 .branch
149 .as_deref()
150 .map(|value| format!(" on {}", value.bright_blue()))
151 .unwrap_or_default();
152 let files = if outcome.committed_files.is_empty() {
153 "(none)".dimmed().to_string()
154 } else {
155 outcome
156 .committed_files
157 .iter()
158 .map(|value| value.bright_white().to_string())
159 .collect::<Vec<_>>()
160 .join(", ")
161 };
162
163 println!(
164 "{} {}{}",
165 "Auto-commit".bright_green().bold(),
166 format!("created for {}", action_label).bright_white(),
167 branch
168 );
169 println!(
170 " {} {} {}",
171 "Repo".bright_cyan().bold(),
172 outcome.repo_name.bright_white().bold(),
173 format!("({})", outcome.repo_root.display()).dimmed()
174 );
175 println!(
176 " {} {} {}",
177 "Commit".bright_cyan().bold(),
178 outcome.short_sha.bright_green().bold(),
179 format!("({})", outcome.commit_sha).dimmed()
180 );
181 println!(
182 " {} {}",
183 "Message".bright_cyan().bold(),
184 outcome.message.bright_magenta()
185 );
186 println!(" {} {}", "Files".bright_cyan().bold(), files);
187}
188
189async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
190 let owned_args = args
191 .iter()
192 .map(|value| value.to_string())
193 .collect::<Vec<_>>();
194 git_output_owned(project_root, owned_args).await
195}
196
197async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
198 let output = Command::new("git")
199 .current_dir(project_root)
200 .args(&args)
201 .output()
202 .await
203 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
204
205 if !output.status.success() {
206 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
207 if stderr.is_empty() {
208 return Err(format!(
209 "`git {}` failed with status {}",
210 args.join(" "),
211 output.status
212 ));
213 }
214 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
215 }
216
217 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
218}
219
220fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
221 let mut deduped = BTreeSet::new();
222
223 for path in paths {
224 if path.as_os_str().is_empty() {
225 continue;
226 }
227
228 let normalized = if path.is_absolute() {
229 path.strip_prefix(project_root).unwrap_or(path)
230 } else {
231 path.as_path()
232 };
233
234 let rendered = normalized.to_string_lossy().replace('\\', "/");
235 let trimmed = rendered.trim();
236 if !trimmed.is_empty() && trimmed != "." {
237 deduped.insert(trimmed.to_string());
238 }
239 }
240
241 deduped.into_iter().collect()
242}
243
244fn repo_name(path: &Path) -> String {
245 path.file_name()
246 .and_then(|value| value.to_str())
247 .filter(|value| !value.trim().is_empty())
248 .unwrap_or("repository")
249 .to_string()
250}
251
252#[cfg(test)]
253mod tests {
254 use super::normalize_commit_paths;
255 use std::path::{Path, PathBuf};
256
257 #[test]
258 fn normalizes_commit_paths_relative_to_project_root() {
259 let project_root = Path::new("C:/repo");
260 let paths = vec![
261 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
262 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
263 PathBuf::from("CHANGELOG.md"),
264 ];
265
266 let normalized = normalize_commit_paths(project_root, &paths);
267
268 assert_eq!(
269 normalized,
270 vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
271 );
272 }
273}