1use colored::Colorize;
2use dialoguer::{theme::ColorfulTheme, Confirm, Input};
3use std::collections::BTreeSet;
4use std::io::IsTerminal;
5use std::path::{Path, PathBuf};
6use tokio::process::Command;
7
8use crate::config::SshConfig;
9use crate::utils::command_exists;
10
11pub struct AutoCommitRequest<'a> {
12 pub project_root: &'a Path,
13 pub paths: Vec<PathBuf>,
14 pub message: String,
15 pub action_label: &'a str,
16}
17
18pub enum AutoCommitResult {
19 Committed(AutoCommitOutcome),
20 Skipped(String),
21}
22
23pub struct AutoCommitOutcome {
24 pub repo_name: String,
25 pub repo_root: PathBuf,
26 pub branch: Option<String>,
27 pub commit_sha: String,
28 pub short_sha: String,
29 pub message: String,
30 pub committed_files: Vec<String>,
31}
32
33pub struct PushOutcome {
34 pub branch: String,
35}
36
37pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
38 if !command_exists("git") {
39 return Ok(AutoCommitResult::Skipped(
40 "Git is not installed on this machine.".to_string(),
41 ));
42 }
43
44 let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
45 {
46 Ok(root) => PathBuf::from(root),
47 Err(_) => {
48 return Ok(AutoCommitResult::Skipped(
49 "Current project is not inside a git repository.".to_string(),
50 ));
51 }
52 };
53
54 let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
55 if normalized_paths.is_empty() {
56 return Ok(AutoCommitResult::Skipped(
57 "No generated or updated files were provided for auto-commit.".to_string(),
58 ));
59 }
60
61 let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
62 add_args.extend(normalized_paths.iter().cloned());
63 git_output_owned(request.project_root, add_args).await?;
64
65 let mut diff_args = vec![
66 "diff".to_string(),
67 "--cached".to_string(),
68 "--name-only".to_string(),
69 "--".to_string(),
70 ];
71 diff_args.extend(normalized_paths.iter().cloned());
72 let committed_files = git_output_owned(request.project_root, diff_args)
73 .await?
74 .lines()
75 .map(str::trim)
76 .filter(|line| !line.is_empty())
77 .map(|line| line.replace('\\', "/"))
78 .collect::<Vec<_>>();
79
80 if committed_files.is_empty() {
81 return Ok(AutoCommitResult::Skipped(
82 "Target files did not produce any staged git diff.".to_string(),
83 ));
84 }
85
86 ensure_git_commit_identity(request.project_root).await?;
87
88 if let Err(error) =
89 commit_with_optional_hook_retry(request.project_root, &request.message).await
90 {
91 return Err(format_git_commit_failure(request.project_root, &error).await);
92 }
93
94 let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
95 let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
96 let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
97 .await
98 .ok()
99 .filter(|value| !value.is_empty() && value != "HEAD");
100
101 let outcome = AutoCommitOutcome {
102 repo_name: repo_name(&repo_root),
103 repo_root,
104 branch,
105 commit_sha,
106 short_sha,
107 message: request.message,
108 committed_files,
109 };
110
111 print_commit_summary(request.action_label, &outcome);
112
113 Ok(AutoCommitResult::Committed(outcome))
114}
115
116pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
117 if !command_exists("git") {
118 return Ok(None);
119 }
120
121 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
122 .await
123 .ok()
124 .filter(|value| !value.is_empty() && value != "HEAD");
125
126 let Some(branch) = branch else {
127 return Ok(None);
128 };
129
130 match git_output(project_root, &["push"]).await {
131 Ok(_) => {}
132 Err(push_error) => {
133 git_output(project_root, &["push", "-u", "origin", branch.as_str()])
134 .await
135 .map_err(|fallback_error| {
136 summarize_git_push_error(&branch, &push_error, &fallback_error)
137 })?;
138 }
139 }
140 Ok(Some(PushOutcome { branch }))
141}
142
143pub fn print_skip(action_label: &str, reason: &str) {
144 println!(
145 "{} {} {}",
146 "Auto-commit".bright_yellow().bold(),
147 format!("skipped for {}", action_label).bright_white(),
148 format!("({})", reason).dimmed()
149 );
150}
151
152pub fn print_push_summary(outcome: &PushOutcome) {
153 println!(
154 "{} {}",
155 "Pushed".bright_green().bold(),
156 format!("origin/{}", outcome.branch).bright_white()
157 );
158}
159
160fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
161 let branch = outcome
162 .branch
163 .as_deref()
164 .map(|value| format!(" on {}", value.bright_blue()))
165 .unwrap_or_default();
166 let files = if outcome.committed_files.is_empty() {
167 "(none)".dimmed().to_string()
168 } else {
169 outcome
170 .committed_files
171 .iter()
172 .map(|value| value.bright_white().to_string())
173 .collect::<Vec<_>>()
174 .join(", ")
175 };
176
177 println!(
178 "{} {}{}",
179 "Auto-commit".bright_green().bold(),
180 format!("created for {}", action_label).bright_white(),
181 branch
182 );
183 println!(
184 " {} {} {}",
185 "Repo".bright_cyan().bold(),
186 outcome.repo_name.bright_white().bold(),
187 format!("({})", outcome.repo_root.display()).dimmed()
188 );
189 println!(
190 " {} {} {}",
191 "Commit".bright_cyan().bold(),
192 outcome.short_sha.bright_green().bold(),
193 format!("({})", outcome.commit_sha).dimmed()
194 );
195 println!(
196 " {} {}",
197 "Message".bright_cyan().bold(),
198 outcome.message.bright_magenta()
199 );
200 println!(" {} {}", "Files".bright_cyan().bold(), files);
201}
202
203async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
204 let owned_args = args
205 .iter()
206 .map(|value| value.to_string())
207 .collect::<Vec<_>>();
208 git_output_owned(project_root, owned_args).await
209}
210
211async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
212 let output = Command::new("git")
213 .current_dir(project_root)
214 .args(&args)
215 .output()
216 .await
217 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
218
219 if !output.status.success() {
220 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
221 if stderr.is_empty() {
222 return Err(format!(
223 "`git {}` failed with status {}",
224 args.join(" "),
225 output.status
226 ));
227 }
228 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
229 }
230
231 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
232}
233
234async fn commit_with_optional_hook_retry(project_root: &Path, message: &str) -> Result<(), String> {
235 match git_output(project_root, &["commit", "-m", message]).await {
236 Ok(_) => Ok(()),
237 Err(error) if is_missing_lefthook_error(&error) => {
238 if std::io::stdin().is_terminal() {
239 let retry = Confirm::with_theme(&ColorfulTheme::default())
240 .with_prompt(
241 "Commit hooks failed to resolve lefthook. Retry once with hooks disabled?",
242 )
243 .default(true)
244 .interact()
245 .map_err(|e| format!("Failed to read retry choice: {}", e))?;
246 if retry {
247 run_commit_with_hooks_disabled(project_root, message).await
248 } else {
249 Err(error)
250 }
251 } else {
252 Err(error)
253 }
254 }
255 Err(error) => Err(error),
256 }
257}
258
259async fn run_commit_with_hooks_disabled(project_root: &Path, message: &str) -> Result<(), String> {
260 let output = Command::new("git")
261 .current_dir(project_root)
262 .env("LEFTHOOK", "0")
263 .args(["commit", "-m", message])
264 .output()
265 .await
266 .map_err(|e| format!("Failed to run `git commit -m {}`: {}", message, e))?;
267
268 if !output.status.success() {
269 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
270 if stderr.is_empty() {
271 return Err(format!(
272 "`git commit -m {}` failed with status {}",
273 message, output.status
274 ));
275 }
276 return Err(format!("`git commit -m {}` failed: {}", message, stderr));
277 }
278
279 Ok(())
280}
281
282async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
283 let current_name = git_output(project_root, &["config", "--get", "user.name"])
284 .await
285 .ok()
286 .map(|value| value.trim().to_string())
287 .filter(|value| !value.is_empty());
288 let current_email = git_output(project_root, &["config", "--get", "user.email"])
289 .await
290 .ok()
291 .map(|value| value.trim().to_string())
292 .filter(|value| !value.is_empty());
293
294 if current_name.is_some() && current_email.is_some() {
295 return Ok(());
296 }
297
298 let suggested = SshConfig::load()
299 .ok()
300 .and_then(|config| config.cli_auth)
301 .and_then(|auth| match (auth.user_name, auth.user_email) {
302 (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
303 Some((name, email))
304 }
305 _ => None,
306 });
307
308 if !std::io::stdin().is_terminal() {
309 return Err(identity_setup_hint(
310 current_name.as_deref(),
311 current_email.as_deref(),
312 suggested
313 .as_ref()
314 .map(|(name, email)| (name.as_str(), email.as_str())),
315 ));
316 }
317
318 let prefill_name = suggested
319 .as_ref()
320 .map(|(name, _)| name.clone())
321 .or_else(|| current_name.clone())
322 .unwrap_or_default();
323 let prefill_email = suggested
324 .as_ref()
325 .map(|(_, email)| email.clone())
326 .or_else(|| current_email.clone())
327 .unwrap_or_default();
328
329 let mut configured_name = current_name;
330 let mut configured_email = current_email;
331
332 if configured_name.is_none() {
333 let name: String = Input::with_theme(&ColorfulTheme::default())
334 .with_prompt("Git commit author name")
335 .with_initial_text(prefill_name)
336 .interact_text()
337 .map_err(|e| format!("Failed to read git author name: {}", e))?;
338 let trimmed = name.trim().to_string();
339 if trimmed.is_empty() {
340 return Err("Git commit author name cannot be empty.".to_string());
341 }
342 git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
343 configured_name = Some(trimmed);
344 }
345
346 if configured_email.is_none() {
347 let email: String = Input::with_theme(&ColorfulTheme::default())
348 .with_prompt("Git commit author email")
349 .with_initial_text(prefill_email)
350 .interact_text()
351 .map_err(|e| format!("Failed to read git author email: {}", e))?;
352 let trimmed = email.trim().to_string();
353 if trimmed.is_empty() {
354 return Err("Git commit author email cannot be empty.".to_string());
355 }
356 git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
357 configured_email = Some(trimmed);
358 }
359
360 if configured_name.is_some() && configured_email.is_some() {
361 Ok(())
362 } else {
363 Err("Git commit identity is still incomplete after prompting.".to_string())
364 }
365}
366
367async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
368 if is_missing_git_identity_error(error) {
369 let suggested = SshConfig::load()
370 .ok()
371 .and_then(|config| config.cli_auth)
372 .and_then(|auth| match (auth.user_name, auth.user_email) {
373 (Some(name), Some(email))
374 if !name.trim().is_empty() && !email.trim().is_empty() =>
375 {
376 Some((name, email))
377 }
378 _ => None,
379 });
380
381 return identity_setup_hint(
382 git_output(project_root, &["config", "--get", "user.name"])
383 .await
384 .ok()
385 .as_deref(),
386 git_output(project_root, &["config", "--get", "user.email"])
387 .await
388 .ok()
389 .as_deref(),
390 suggested
391 .as_ref()
392 .map(|(name, email)| (name.as_str(), email.as_str())),
393 );
394 }
395
396 if is_missing_lefthook_error(error) {
397 return format!(
398 "{}\n{}\n{}\n{}",
399 "The commit hook tried to load lefthook from the current repo but the module was missing."
400 .to_string(),
401 "Run `pnpm install` or `npm install` in the repo that owns the hook, or fix the hook path so it resolves from the current checkout."
402 .to_string(),
403 "If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0`."
404 .to_string(),
405 format!("Raw error: {}", error)
406 );
407 }
408
409 format!("Git commit failed: {}", error)
410}
411
412fn identity_setup_hint(
413 current_name: Option<&str>,
414 current_email: Option<&str>,
415 suggested: Option<(&str, &str)>,
416) -> String {
417 let mut lines = vec![
418 "Git blocked the commit because your author identity is not configured in this environment."
419 .to_string(),
420 ];
421 if current_name.is_none() {
422 lines.push("Missing `user.name`.".to_string());
423 }
424 if current_email.is_none() {
425 lines.push("Missing `user.email`.".to_string());
426 }
427 if let Some((name, email)) = suggested {
428 lines.push(format!(
429 "XBP found a likely identity to prefill: {} <{}>",
430 name, email
431 ));
432 lines.push(format!("Run `git config --local user.name \"{}\"`", name));
433 lines.push(format!("Run `git config --local user.email \"{}\"`", email));
434 } else {
435 lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
436 .to_string());
437 lines.push(
438 "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
439 );
440 }
441 lines.join("\n")
442}
443
444fn is_missing_git_identity_error(error: &str) -> bool {
445 error.contains("Author identity unknown")
446 || error.contains("empty ident name")
447 || error.contains("Please tell me who you are")
448}
449
450fn is_missing_lefthook_error(error: &str) -> bool {
451 let lower = error.to_ascii_lowercase();
452 (lower.contains("lefthook") && lower.contains("module not found"))
453 || (lower.contains("lefthook") && lower.contains("cannot find module"))
454}
455
456fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
457 let mut deduped = BTreeSet::new();
458
459 for path in paths {
460 if path.as_os_str().is_empty() {
461 continue;
462 }
463
464 let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
465 relative.to_path_buf()
466 } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
467 relative
468 } else {
469 path.to_path_buf()
470 };
471
472 let rendered = normalized.to_string_lossy().replace('\\', "/");
473 let trimmed = rendered.trim();
474 if !trimmed.is_empty() && trimmed != "." {
475 deduped.insert(trimmed.to_string());
476 }
477 }
478
479 deduped.into_iter().collect()
480}
481
482fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
483 let root = project_root
484 .to_string_lossy()
485 .replace('\\', "/")
486 .trim_end_matches('/')
487 .to_string();
488 let candidate = path.to_string_lossy().replace('\\', "/");
489
490 if candidate.len() <= root.len() {
491 return None;
492 }
493
494 let (prefix, suffix) = candidate.split_at(root.len());
495 if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
496 return Some(PathBuf::from(suffix.trim_start_matches('/')));
497 }
498
499 None
500}
501
502pub fn summarize_git_push_error(
503 branch: &str,
504 primary_error: &str,
505 fallback_error: &str,
506) -> String {
507 let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
508
509 if corpus.contains("non-fast-forward")
510 || corpus.contains("tip of your current branch is behind")
511 || corpus.contains("failed to push some refs")
512 {
513 return format!(
514 "`{branch}` is behind its remote counterpart. Run `git pull --rebase origin {branch}`, then `git push`."
515 );
516 }
517
518 if corpus.contains("authentication failed")
519 || corpus.contains("could not read username")
520 || corpus.contains("403")
521 || corpus.contains("401")
522 {
523 return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
524 .to_string();
525 }
526
527 let lines = dedupe_git_error_lines(primary_error, fallback_error);
528 lines
529 .into_iter()
530 .last()
531 .unwrap_or_else(|| "git push failed".to_string())
532}
533
534fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
535 let mut seen = BTreeSet::new();
536 let mut lines = Vec::new();
537
538 for line in primary_error.lines().chain(fallback_error.lines()) {
539 let trimmed = line.trim();
540 if trimmed.is_empty() || trimmed.starts_with("hint:") {
541 continue;
542 }
543 let key = trimmed.to_ascii_lowercase();
544 if seen.insert(key) {
545 lines.push(trimmed.to_string());
546 }
547 }
548
549 lines
550}
551
552fn repo_name(path: &Path) -> String {
553 path.file_name()
554 .and_then(|value| value.to_str())
555 .filter(|value| !value.trim().is_empty())
556 .unwrap_or("repository")
557 .to_string()
558}
559
560#[cfg(test)]
561mod tests {
562 use super::{
563 is_missing_git_identity_error, is_missing_lefthook_error, normalize_commit_paths,
564 summarize_git_push_error,
565 };
566 use std::path::{Path, PathBuf};
567
568 #[test]
569 fn normalizes_commit_paths_relative_to_project_root() {
570 let project_root = Path::new("C:/repo");
571 let paths = vec![
572 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
573 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
574 PathBuf::from("CHANGELOG.md"),
575 ];
576
577 let normalized = normalize_commit_paths(project_root, &paths);
578
579 assert_eq!(
580 normalized,
581 vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
582 );
583 }
584
585 #[test]
586 fn detects_missing_git_identity_errors() {
587 assert!(is_missing_git_identity_error(
588 "Author identity unknown\n*** Please tell me who you are."
589 ));
590 }
591
592 #[test]
593 fn detects_lefthook_module_errors() {
594 assert!(is_missing_lefthook_error(
595 "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
596 ));
597 }
598
599 #[test]
600 fn summarizes_non_fast_forward_push_errors_without_git_hints() {
601 let primary = "! [rejected] main -> main (non-fast-forward)";
602 let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
603hint: Updates were rejected because the tip of your current branch is behind
604hint: its remote counterpart. If you want to integrate the remote changes,
605hint: use 'git pull' before pushing again."#;
606
607 let summary = summarize_git_push_error("main", primary, fallback);
608
609 assert!(summary.contains("behind its remote counterpart"));
610 assert!(summary.contains("git pull --rebase origin main"));
611 assert!(!summary.contains("hint:"));
612 assert!(!summary.contains("git push -u origin main"));
613 }
614}