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 match git_output(project_root, &["push"]).await {
126 Ok(_) => {}
127 Err(push_error) => {
128 git_output(project_root, &["push", "-u", "origin", branch.as_str()])
129 .await
130 .map_err(|fallback_error| {
131 format!(
132 "Git push failed (`git push`: {}; `git push -u origin {}`: {})",
133 push_error, branch, fallback_error
134 )
135 })?;
136 }
137 }
138 Ok(Some(PushOutcome { branch }))
139}
140
141pub fn print_skip(action_label: &str, reason: &str) {
142 println!(
143 "{} {} {}",
144 "Auto-commit".bright_yellow().bold(),
145 format!("skipped for {}", action_label).bright_white(),
146 format!("({})", reason).dimmed()
147 );
148}
149
150pub fn print_push_summary(outcome: &PushOutcome) {
151 println!(
152 "{} {}",
153 "Pushed".bright_green().bold(),
154 format!("origin/{}", outcome.branch).bright_white()
155 );
156}
157
158fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
159 let branch = outcome
160 .branch
161 .as_deref()
162 .map(|value| format!(" on {}", value.bright_blue()))
163 .unwrap_or_default();
164 let files = if outcome.committed_files.is_empty() {
165 "(none)".dimmed().to_string()
166 } else {
167 outcome
168 .committed_files
169 .iter()
170 .map(|value| value.bright_white().to_string())
171 .collect::<Vec<_>>()
172 .join(", ")
173 };
174
175 println!(
176 "{} {}{}",
177 "Auto-commit".bright_green().bold(),
178 format!("created for {}", action_label).bright_white(),
179 branch
180 );
181 println!(
182 " {} {} {}",
183 "Repo".bright_cyan().bold(),
184 outcome.repo_name.bright_white().bold(),
185 format!("({})", outcome.repo_root.display()).dimmed()
186 );
187 println!(
188 " {} {} {}",
189 "Commit".bright_cyan().bold(),
190 outcome.short_sha.bright_green().bold(),
191 format!("({})", outcome.commit_sha).dimmed()
192 );
193 println!(
194 " {} {}",
195 "Message".bright_cyan().bold(),
196 outcome.message.bright_magenta()
197 );
198 println!(" {} {}", "Files".bright_cyan().bold(), files);
199}
200
201async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
202 let owned_args = args
203 .iter()
204 .map(|value| value.to_string())
205 .collect::<Vec<_>>();
206 git_output_owned(project_root, owned_args).await
207}
208
209async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
210 let output = Command::new("git")
211 .current_dir(project_root)
212 .args(&args)
213 .output()
214 .await
215 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
216
217 if !output.status.success() {
218 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
219 if stderr.is_empty() {
220 return Err(format!(
221 "`git {}` failed with status {}",
222 args.join(" "),
223 output.status
224 ));
225 }
226 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
227 }
228
229 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
230}
231
232fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
233 let mut deduped = BTreeSet::new();
234
235 for path in paths {
236 if path.as_os_str().is_empty() {
237 continue;
238 }
239
240 let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
241 relative.to_path_buf()
242 } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
243 relative
244 } else {
245 path.to_path_buf()
246 };
247
248 let rendered = normalized.to_string_lossy().replace('\\', "/");
249 let trimmed = rendered.trim();
250 if !trimmed.is_empty() && trimmed != "." {
251 deduped.insert(trimmed.to_string());
252 }
253 }
254
255 deduped.into_iter().collect()
256}
257
258fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
259 let root = project_root
260 .to_string_lossy()
261 .replace('\\', "/")
262 .trim_end_matches('/')
263 .to_string();
264 let candidate = path.to_string_lossy().replace('\\', "/");
265
266 if candidate.len() <= root.len() {
267 return None;
268 }
269
270 let (prefix, suffix) = candidate.split_at(root.len());
271 if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
272 return Some(PathBuf::from(suffix.trim_start_matches('/')));
273 }
274
275 None
276}
277
278fn repo_name(path: &Path) -> String {
279 path.file_name()
280 .and_then(|value| value.to_str())
281 .filter(|value| !value.trim().is_empty())
282 .unwrap_or("repository")
283 .to_string()
284}
285
286#[cfg(test)]
287mod tests {
288 use super::normalize_commit_paths;
289 use std::path::{Path, PathBuf};
290
291 #[test]
292 fn normalizes_commit_paths_relative_to_project_root() {
293 let project_root = Path::new("C:/repo");
294 let paths = vec![
295 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
296 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
297 PathBuf::from("CHANGELOG.md"),
298 ];
299
300 let normalized = normalize_commit_paths(project_root, &paths);
301
302 assert_eq!(
303 normalized,
304 vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
305 );
306 }
307}