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