1use colored::Colorize;
2use dialoguer::{theme::ColorfulTheme, Confirm, Input};
3use std::cell::Cell;
4use std::collections::BTreeSet;
5use std::io::IsTerminal;
6use std::path::{Path, PathBuf};
7use tokio::process::Command;
8
9use crate::commands::service::load_xbp_config_with_root;
10use crate::config::SshConfig;
11use crate::utils::command_exists;
12
13thread_local! {
14 static CLI_EXPLICIT_PUSH: Cell<bool> = const { Cell::new(false) };
15}
16
17pub fn set_explicit_push(push: bool) {
19 if push {
20 CLI_EXPLICIT_PUSH.with(|flag| flag.set(true));
21 }
22}
23
24pub fn explicit_push_requested() -> bool {
25 CLI_EXPLICIT_PUSH.with(|flag| flag.get())
26}
27
28pub fn reset_explicit_push() {
29 CLI_EXPLICIT_PUSH.with(|flag| flag.set(false));
30}
31
32pub async fn should_push_after_auto_commit(request_push: bool) -> bool {
33 if request_push || explicit_push_requested() {
34 return true;
35 }
36
37 load_xbp_config_with_root()
38 .await
39 .map(|(_, config)| config.auto_push_on_commit_enabled())
40 .unwrap_or(true)
41}
42
43pub struct AutoCommitRequest<'a> {
44 pub project_root: &'a Path,
45 pub paths: Vec<PathBuf>,
46 pub message: String,
47 pub action_label: &'a str,
48 pub push: bool,
50}
51
52pub enum AutoCommitResult {
53 Committed(AutoCommitOutcome),
54 Skipped(String),
55}
56
57pub struct AutoCommitOutcome {
58 pub repo_name: String,
59 pub repo_root: PathBuf,
60 pub branch: Option<String>,
61 pub commit_sha: String,
62 pub short_sha: String,
63 pub message: String,
64 pub committed_files: Vec<String>,
65}
66
67pub struct PushOutcome {
68 pub remote: String,
69 pub branch: String,
70 pub rebased: bool,
71}
72
73pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
74 if !command_exists("git") {
75 return Ok(AutoCommitResult::Skipped(
76 "Git is not installed on this machine.".to_string(),
77 ));
78 }
79
80 let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
81 {
82 Ok(root) => PathBuf::from(root),
83 Err(_) => {
84 return Ok(AutoCommitResult::Skipped(
85 "Current project is not inside a git repository.".to_string(),
86 ));
87 }
88 };
89
90 let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
91 if normalized_paths.is_empty() {
92 return Ok(AutoCommitResult::Skipped(
93 "No generated or updated files were provided for auto-commit.".to_string(),
94 ));
95 }
96
97 let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
98 add_args.extend(normalized_paths.iter().cloned());
99 git_output_owned(request.project_root, add_args).await?;
100
101 let mut diff_args = vec![
102 "diff".to_string(),
103 "--cached".to_string(),
104 "--name-only".to_string(),
105 "--".to_string(),
106 ];
107 diff_args.extend(normalized_paths.iter().cloned());
108 let committed_files = git_output_owned(request.project_root, diff_args)
109 .await?
110 .lines()
111 .map(str::trim)
112 .filter(|line| !line.is_empty())
113 .map(|line| line.replace('\\', "/"))
114 .collect::<Vec<_>>();
115
116 if committed_files.is_empty() {
117 return Ok(AutoCommitResult::Skipped(
118 "Target files did not produce any staged git diff.".to_string(),
119 ));
120 }
121
122 ensure_git_commit_identity(request.project_root).await?;
123
124 if let Err(error) = commit_with_optional_hook_retry(
125 request.project_root,
126 &request.message,
127 &committed_files,
128 )
129 .await
130 {
131 return Err(format_git_commit_failure(request.project_root, &error).await);
132 }
133
134 let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
135 let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
136 let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
137 .await
138 .ok()
139 .filter(|value| !value.is_empty() && value != "HEAD");
140
141 let outcome = AutoCommitOutcome {
142 repo_name: repo_name(&repo_root),
143 repo_root,
144 branch,
145 commit_sha,
146 short_sha,
147 message: request.message,
148 committed_files,
149 };
150
151 print_commit_summary(request.action_label, &outcome);
152
153 if should_push_after_auto_commit(request.push).await {
154 match push_current_branch(request.project_root).await {
155 Ok(Some(push_outcome)) => print_push_summary(&push_outcome),
156 Ok(None) => {}
157 Err(error) => {
158 return Err(format!(
159 "Created local commit {} but push failed: {}",
160 outcome.short_sha, error
161 ));
162 }
163 }
164 }
165
166 Ok(AutoCommitResult::Committed(outcome))
167}
168
169pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
170 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
171 .await
172 .ok()
173 .filter(|value| !value.is_empty() && value != "HEAD");
174 push_current_branch_with_name(project_root, branch.as_deref()).await
175}
176
177pub async fn push_current_branch_with_name(
178 project_root: &Path,
179 branch: Option<&str>,
180) -> Result<Option<PushOutcome>, String> {
181 if !command_exists("git") {
182 return Ok(None);
183 }
184
185 let Some(branch) = branch
186 .map(str::trim)
187 .filter(|value| !value.is_empty() && *value != "HEAD")
188 .map(ToOwned::to_owned)
189 else {
190 return Ok(None);
191 };
192
193 let remote = resolve_push_remote(project_root, &branch).await?;
194 let mut rebased = false;
195
196 match attempt_push(project_root, &remote, &branch).await {
197 Ok(()) => {}
198 Err(push_error) if is_non_fast_forward_push_error(&push_error) => {
199 println!(
200 "{} {}",
201 "Branch behind remote".bright_yellow().bold(),
202 format!("rebasing onto {}/{} then retrying push...", remote, branch)
203 .bright_white()
204 );
205 pull_rebase(project_root, &remote, &branch)
206 .await
207 .map_err(|rebase_error| {
208 format!(
209 "`{branch}` is behind `{remote}/{branch}` and auto-rebase failed: {rebase_error}. Resolve conflicts, then `git push`."
210 )
211 })?;
212 rebased = true;
213 attempt_push(project_root, &remote, &branch)
214 .await
215 .map_err(|retry_error| {
216 summarize_git_push_error(&remote, &branch, &push_error, &retry_error)
217 })?;
218 }
219 Err(push_error) => {
220 return Err(summarize_git_push_error(
221 &remote,
222 &branch,
223 &push_error,
224 &push_error,
225 ));
226 }
227 }
228
229 Ok(Some(PushOutcome {
230 remote,
231 branch,
232 rebased,
233 }))
234}
235
236pub async fn pull_rebase_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
238 if !command_exists("git") {
239 return Ok(None);
240 }
241
242 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
243 .await
244 .ok()
245 .filter(|value| !value.is_empty() && value != "HEAD");
246 let Some(branch) = branch else {
247 return Ok(None);
248 };
249
250 let remote = resolve_push_remote(project_root, &branch).await?;
251 println!(
252 "{} {}",
253 "Rebasing".bright_cyan().bold(),
254 format!("onto {}/{}...", remote, branch).bright_white()
255 );
256 pull_rebase(project_root, &remote, &branch).await?;
257 Ok(Some(PushOutcome {
258 remote,
259 branch,
260 rebased: true,
261 }))
262}
263
264pub fn print_skip(action_label: &str, reason: &str) {
265 println!(
266 "{} {} {}",
267 "Auto-commit".bright_yellow().bold(),
268 format!("skipped for {}", action_label).bright_white(),
269 format!("({})", reason).dimmed()
270 );
271}
272
273pub fn print_push_summary(outcome: &PushOutcome) {
274 let target = format!("{}/{}", outcome.remote, outcome.branch);
275 if outcome.rebased {
276 println!(
277 "{} {} {}",
278 "Pushed".bright_green().bold(),
279 target.bright_white(),
280 "(after auto-rebase)".dimmed()
281 );
282 } else {
283 println!(
284 "{} {}",
285 "Pushed".bright_green().bold(),
286 target.bright_white()
287 );
288 }
289}
290
291async fn attempt_push(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
292 match git_output(project_root, &["push"]).await {
293 Ok(_) => Ok(()),
294 Err(push_error) => {
295 git_output(project_root, &["push", "-u", remote, branch])
297 .await
298 .map(|_| ())
299 .map_err(|fallback_error| format!("{push_error}\n{fallback_error}"))
300 }
301 }
302}
303
304async fn pull_rebase(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
305 let _ = git_output(project_root, &["fetch", remote, branch]).await;
307 git_output(project_root, &["pull", "--rebase", remote, branch]).await?;
308 Ok(())
309}
310
311pub async fn resolve_push_remote(project_root: &Path, branch: &str) -> Result<String, String> {
315 if let Ok(configured) = git_output(
316 project_root,
317 &["config", "--get", &format!("branch.{branch}.remote")],
318 )
319 .await
320 {
321 let trimmed = configured.trim();
322 if !trimmed.is_empty() {
323 return Ok(trimmed.to_string());
324 }
325 }
326
327 if let Ok(upstream) = git_output(
328 project_root,
329 &[
330 "rev-parse",
331 "--abbrev-ref",
332 "--symbolic-full-name",
333 "@{upstream}",
334 ],
335 )
336 .await
337 {
338 if let Some(remote) = split_remote_branch(upstream.trim()).map(|(remote, _)| remote) {
339 return Ok(remote);
340 }
341 }
342
343 let remotes = git_output(project_root, &["remote"]).await.unwrap_or_default();
344 let remote_names = remotes
345 .lines()
346 .map(str::trim)
347 .filter(|line| !line.is_empty())
348 .map(ToOwned::to_owned)
349 .collect::<Vec<_>>();
350
351 if remote_names.iter().any(|name| name == "origin") {
352 return Ok("origin".to_string());
353 }
354
355 remote_names.into_iter().next().ok_or_else(|| {
356 "No git remotes are configured for this repository. Add one with `git remote add <name> <url>`."
357 .to_string()
358 })
359}
360
361fn split_remote_branch(upstream: &str) -> Option<(String, String)> {
362 let (remote, branch) = upstream.split_once('/')?;
363 let remote = remote.trim();
364 let branch = branch.trim();
365 if remote.is_empty() || branch.is_empty() {
366 return None;
367 }
368 Some((remote.to_string(), branch.to_string()))
369}
370
371fn is_non_fast_forward_push_error(error: &str) -> bool {
372 let corpus = error.to_ascii_lowercase();
373 corpus.contains("non-fast-forward")
374 || corpus.contains("tip of your current branch is behind")
375 || (corpus.contains("failed to push some refs")
376 && (corpus.contains("behind")
377 || corpus.contains("rejected")
378 || corpus.contains("fetch first")))
379}
380
381fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
382 let branch = outcome
383 .branch
384 .as_deref()
385 .map(|value| format!(" on {}", value.bright_blue()))
386 .unwrap_or_default();
387 let files = if outcome.committed_files.is_empty() {
388 "(none)".dimmed().to_string()
389 } else {
390 outcome
391 .committed_files
392 .iter()
393 .map(|value| value.bright_white().to_string())
394 .collect::<Vec<_>>()
395 .join(", ")
396 };
397
398 println!(
399 "{} {}{}",
400 "Auto-commit".bright_green().bold(),
401 format!("created for {}", action_label).bright_white(),
402 branch
403 );
404 println!(
405 " {} {} {}",
406 "Repo".bright_cyan().bold(),
407 outcome.repo_name.bright_white().bold(),
408 format!("({})", outcome.repo_root.display()).dimmed()
409 );
410 println!(
411 " {} {} {}",
412 "Commit".bright_cyan().bold(),
413 outcome.short_sha.bright_green().bold(),
414 format!("({})", outcome.commit_sha).dimmed()
415 );
416 println!(
417 " {} {}",
418 "Message".bright_cyan().bold(),
419 outcome.message.bright_magenta()
420 );
421 println!(" {} {}", "Files".bright_cyan().bold(), files);
422}
423
424async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
425 let owned_args = args
426 .iter()
427 .map(|value| value.to_string())
428 .collect::<Vec<_>>();
429 git_output_owned(project_root, owned_args).await
430}
431
432async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
433 let output = Command::new("git")
434 .current_dir(project_root)
435 .args(&args)
436 .output()
437 .await
438 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
439
440 if !output.status.success() {
441 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
442 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
443 let detail = [stderr, stdout]
445 .into_iter()
446 .filter(|s| !s.is_empty())
447 .collect::<Vec<_>>()
448 .join("\n");
449 if detail.is_empty() {
450 return Err(format!(
451 "`git {}` failed with status {}",
452 args.join(" "),
453 output.status
454 ));
455 }
456 return Err(format!("`git {}` failed: {}", args.join(" "), detail));
457 }
458
459 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
460}
461
462async fn commit_with_optional_hook_retry(
463 project_root: &Path,
464 message: &str,
465 committed_files: &[String],
466) -> Result<(), String> {
467 match git_output(project_root, &["commit", "-m", message]).await {
468 Ok(_) => Ok(()),
469 Err(error) => {
470 let hook_failure = is_commit_hook_failure(&error);
471 if !hook_failure {
472 return Err(error);
473 }
474
475 if is_xbp_managed_commit(committed_files) {
478 println!(
479 "{} {}",
480 "Hooks".bright_yellow().bold(),
481 "failed on XBP-managed files only — retrying with hooks disabled (--no-verify)."
482 .bright_white()
483 );
484 return run_commit_with_hooks_disabled(project_root, message).await;
485 }
486
487 if is_missing_lefthook_error(&error)
488 || is_husky_or_pre_commit_hook_error(&error)
489 {
490 if std::io::stdin().is_terminal() {
491 let prompt = if is_missing_lefthook_error(&error) {
492 "Commit hooks failed to resolve lefthook. Retry once with hooks disabled?"
493 } else {
494 "Pre-commit / husky hooks failed. Retry this XBP commit with hooks disabled?"
495 };
496 let retry = Confirm::with_theme(&ColorfulTheme::default())
497 .with_prompt(prompt)
498 .default(true)
499 .interact()
500 .map_err(|e| format!("Failed to read retry choice: {}", e))?;
501 if retry {
502 println!(
503 "{} {}",
504 "Hooks".bright_yellow().bold(),
505 "disabled for this commit (--no-verify, HUSKY=0, LEFTHOOK=0)."
506 .bright_white()
507 );
508 return run_commit_with_hooks_disabled(project_root, message).await;
509 }
510 } else {
511 println!(
514 "{} {}",
515 "Hooks".bright_yellow().bold(),
516 "failed in non-interactive mode — retrying with hooks disabled."
517 .bright_white()
518 );
519 return run_commit_with_hooks_disabled(project_root, message).await;
520 }
521 }
522
523 Err(error)
524 }
525 }
526}
527
528async fn run_commit_with_hooks_disabled(project_root: &Path, message: &str) -> Result<(), String> {
529 let output = Command::new("git")
530 .current_dir(project_root)
531 .env("HUSKY", "0")
533 .env("LEFTHOOK", "0")
534 .args(["commit", "--no-verify", "-m", message])
535 .output()
536 .await
537 .map_err(|e| format!("Failed to run `git commit --no-verify -m {}`: {}", message, e))?;
538
539 if !output.status.success() {
540 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
541 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
542 let detail = [stderr, stdout]
543 .into_iter()
544 .filter(|s| !s.is_empty())
545 .collect::<Vec<_>>()
546 .join("\n");
547 if detail.is_empty() {
548 return Err(format!(
549 "`git commit --no-verify -m {}` failed with status {}",
550 message, output.status
551 ));
552 }
553 return Err(format!(
554 "`git commit --no-verify -m {}` failed: {}",
555 message, detail
556 ));
557 }
558
559 Ok(())
560}
561
562fn is_xbp_managed_commit(committed_files: &[String]) -> bool {
564 !committed_files.is_empty()
565 && committed_files.iter().all(|path| {
566 let normalized = path.replace('\\', "/");
567 normalized == ".xbp"
568 || normalized.starts_with(".xbp/")
569 || normalized == "xbp.yaml"
570 || normalized == "xbp.yml"
571 || normalized == "xbp.json"
572 || normalized == "pnpm-workspace.yaml"
573 || normalized == "package.json"
574 || normalized.ends_with("/package.json")
575 || normalized.ends_with("/pnpm-workspace.yaml")
576 })
577}
578
579fn is_commit_hook_failure(error: &str) -> bool {
580 let lower = error.to_ascii_lowercase();
581 lower.contains("pre-commit")
582 || lower.contains("husky")
583 || lower.contains("lefthook")
584 || lower.contains("hook")
585 || lower.contains("ultracite")
586 || lower.contains("lint-staged")
587 || lower.contains("hook exited")
588 || lower.contains("script failed")
589}
590
591fn is_husky_or_pre_commit_hook_error(error: &str) -> bool {
592 let lower = error.to_ascii_lowercase();
593 lower.contains("husky")
594 || lower.contains("pre-commit")
595 || lower.contains("ultracite")
596 || lower.contains("lint-staged")
597 || (lower.contains("hook") && lower.contains("failed"))
598}
599
600async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
601 let current_name = git_output(project_root, &["config", "--get", "user.name"])
602 .await
603 .ok()
604 .map(|value| value.trim().to_string())
605 .filter(|value| !value.is_empty());
606 let current_email = git_output(project_root, &["config", "--get", "user.email"])
607 .await
608 .ok()
609 .map(|value| value.trim().to_string())
610 .filter(|value| !value.is_empty());
611
612 if current_name.is_some() && current_email.is_some() {
613 return Ok(());
614 }
615
616 let suggested = SshConfig::load()
617 .ok()
618 .and_then(|config| config.cli_auth)
619 .and_then(|auth| match (auth.user_name, auth.user_email) {
620 (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
621 Some((name, email))
622 }
623 _ => None,
624 });
625
626 if !std::io::stdin().is_terminal() {
627 return Err(identity_setup_hint(
628 current_name.as_deref(),
629 current_email.as_deref(),
630 suggested
631 .as_ref()
632 .map(|(name, email)| (name.as_str(), email.as_str())),
633 ));
634 }
635
636 let prefill_name = suggested
637 .as_ref()
638 .map(|(name, _)| name.clone())
639 .or_else(|| current_name.clone())
640 .unwrap_or_default();
641 let prefill_email = suggested
642 .as_ref()
643 .map(|(_, email)| email.clone())
644 .or_else(|| current_email.clone())
645 .unwrap_or_default();
646
647 let mut configured_name = current_name;
648 let mut configured_email = current_email;
649
650 if configured_name.is_none() {
651 let name: String = Input::with_theme(&ColorfulTheme::default())
652 .with_prompt("Git commit author name")
653 .with_initial_text(prefill_name)
654 .interact_text()
655 .map_err(|e| format!("Failed to read git author name: {}", e))?;
656 let trimmed = name.trim().to_string();
657 if trimmed.is_empty() {
658 return Err("Git commit author name cannot be empty.".to_string());
659 }
660 git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
661 configured_name = Some(trimmed);
662 }
663
664 if configured_email.is_none() {
665 let email: String = Input::with_theme(&ColorfulTheme::default())
666 .with_prompt("Git commit author email")
667 .with_initial_text(prefill_email)
668 .interact_text()
669 .map_err(|e| format!("Failed to read git author email: {}", e))?;
670 let trimmed = email.trim().to_string();
671 if trimmed.is_empty() {
672 return Err("Git commit author email cannot be empty.".to_string());
673 }
674 git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
675 configured_email = Some(trimmed);
676 }
677
678 if configured_name.is_some() && configured_email.is_some() {
679 Ok(())
680 } else {
681 Err("Git commit identity is still incomplete after prompting.".to_string())
682 }
683}
684
685async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
686 if is_missing_git_identity_error(error) {
687 let suggested = SshConfig::load()
688 .ok()
689 .and_then(|config| config.cli_auth)
690 .and_then(|auth| match (auth.user_name, auth.user_email) {
691 (Some(name), Some(email))
692 if !name.trim().is_empty() && !email.trim().is_empty() =>
693 {
694 Some((name, email))
695 }
696 _ => None,
697 });
698
699 return identity_setup_hint(
700 git_output(project_root, &["config", "--get", "user.name"])
701 .await
702 .ok()
703 .as_deref(),
704 git_output(project_root, &["config", "--get", "user.email"])
705 .await
706 .ok()
707 .as_deref(),
708 suggested
709 .as_ref()
710 .map(|(name, email)| (name.as_str(), email.as_str())),
711 );
712 }
713
714 if is_missing_lefthook_error(error) {
715 return format!(
716 "The commit hook tried to load lefthook from the current repo but the module was missing.\n\
717Run `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\
718If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0` / `HUSKY=0` or `git commit --no-verify`.\n\
719Raw error: {error}"
720 );
721 }
722
723 if is_husky_or_pre_commit_hook_error(error) {
724 let husky_hint = if project_root.join(".husky").is_dir() {
725 "\nThis repo has a `.husky/` pre-commit hook. Common blockers: broken shebang order, \
726`pnpm test` on every commit, or formatters (ultracite/biome) scanning the whole tree."
727 } else {
728 ""
729 };
730 return format!(
731 "Git commit was blocked by a pre-commit hook (husky/lefthook/lint).\n\
732XBP-only commits under `.xbp/` are retried automatically with hooks disabled.\n\
733To fix hooks: inspect `.husky/pre-commit`, ensure the shebang is the first line, and avoid running full-repo `pnpm test` / format on unrelated paths.\n\
734Workaround: `HUSKY=0 git commit --no-verify` (or `LEFTHOOK=0`).{husky_hint}\n\
735Raw error: {error}"
736 );
737 }
738
739 format!("Git commit failed: {}", error)
740}
741
742fn identity_setup_hint(
743 current_name: Option<&str>,
744 current_email: Option<&str>,
745 suggested: Option<(&str, &str)>,
746) -> String {
747 let mut lines = vec![
748 "Git blocked the commit because your author identity is not configured in this environment."
749 .to_string(),
750 ];
751 if current_name.is_none() {
752 lines.push("Missing `user.name`.".to_string());
753 }
754 if current_email.is_none() {
755 lines.push("Missing `user.email`.".to_string());
756 }
757 if let Some((name, email)) = suggested {
758 lines.push(format!(
759 "XBP found a likely identity to prefill: {} <{}>",
760 name, email
761 ));
762 lines.push(format!("Run `git config --local user.name \"{}\"`", name));
763 lines.push(format!("Run `git config --local user.email \"{}\"`", email));
764 } else {
765 lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
766 .to_string());
767 lines.push(
768 "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
769 );
770 }
771 lines.join("\n")
772}
773
774fn is_missing_git_identity_error(error: &str) -> bool {
775 error.contains("Author identity unknown")
776 || error.contains("empty ident name")
777 || error.contains("Please tell me who you are")
778}
779
780fn is_missing_lefthook_error(error: &str) -> bool {
781 let lower = error.to_ascii_lowercase();
782 (lower.contains("lefthook") && lower.contains("module not found"))
783 || (lower.contains("lefthook") && lower.contains("cannot find module"))
784}
785
786fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
787 let mut deduped = BTreeSet::new();
788
789 for path in paths {
790 if path.as_os_str().is_empty() {
791 continue;
792 }
793
794 let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
795 relative.to_path_buf()
796 } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
797 relative
798 } else {
799 path.to_path_buf()
800 };
801
802 let rendered = normalized.to_string_lossy().replace('\\', "/");
803 let trimmed = rendered.trim();
804 if !trimmed.is_empty() && trimmed != "." {
805 deduped.insert(trimmed.to_string());
806 }
807 }
808
809 deduped.into_iter().collect()
810}
811
812fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
813 let root = project_root
814 .to_string_lossy()
815 .replace('\\', "/")
816 .trim_end_matches('/')
817 .to_string();
818 let candidate = path.to_string_lossy().replace('\\', "/");
819
820 if candidate.len() <= root.len() {
821 return None;
822 }
823
824 let (prefix, suffix) = candidate.split_at(root.len());
825 if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
826 return Some(PathBuf::from(suffix.trim_start_matches('/')));
827 }
828
829 None
830}
831
832pub fn summarize_git_push_error(
833 remote: &str,
834 branch: &str,
835 primary_error: &str,
836 fallback_error: &str,
837) -> String {
838 let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
839
840 if is_non_fast_forward_push_error(&corpus) {
841 return format!(
842 "`{branch}` is behind `{remote}/{branch}`. Run `git pull --rebase {remote} {branch}`, then `git push`."
843 );
844 }
845
846 if corpus.contains("authentication failed")
847 || corpus.contains("could not read username")
848 || corpus.contains("403")
849 || corpus.contains("401")
850 {
851 return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
852 .to_string();
853 }
854
855 let lines = dedupe_git_error_lines(primary_error, fallback_error);
856 lines
857 .into_iter()
858 .last()
859 .unwrap_or_else(|| "git push failed".to_string())
860}
861
862fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
863 let mut seen = BTreeSet::new();
864 let mut lines = Vec::new();
865
866 for line in primary_error.lines().chain(fallback_error.lines()) {
867 let trimmed = line.trim();
868 if trimmed.is_empty() || trimmed.starts_with("hint:") {
869 continue;
870 }
871 let key = trimmed.to_ascii_lowercase();
872 if seen.insert(key) {
873 lines.push(trimmed.to_string());
874 }
875 }
876
877 lines
878}
879
880fn repo_name(path: &Path) -> String {
881 path.file_name()
882 .and_then(|value| value.to_str())
883 .filter(|value| !value.trim().is_empty())
884 .unwrap_or("repository")
885 .to_string()
886}
887
888#[cfg(test)]
889mod tests {
890 use super::{
891 explicit_push_requested, is_commit_hook_failure, is_husky_or_pre_commit_hook_error,
892 is_missing_git_identity_error, is_missing_lefthook_error, is_non_fast_forward_push_error,
893 is_xbp_managed_commit, normalize_commit_paths, reset_explicit_push, set_explicit_push,
894 split_remote_branch, summarize_git_push_error,
895 };
896 use std::path::{Path, PathBuf};
897
898 #[test]
899 fn xbp_managed_commit_detects_config_only_paths() {
900 assert!(is_xbp_managed_commit(&[".xbp/xbp.yaml".to_string()]));
901 assert!(is_xbp_managed_commit(&[
902 ".xbp/xbp.yaml".to_string(),
903 "package.json".to_string(),
904 "pnpm-workspace.yaml".to_string(),
905 ]));
906 assert!(!is_xbp_managed_commit(&[
907 ".xbp/xbp.yaml".to_string(),
908 "src/app.ts".to_string(),
909 ]));
910 assert!(!is_xbp_managed_commit(&[]));
911 }
912
913 #[test]
914 fn detects_husky_pre_commit_failures() {
915 assert!(is_commit_hook_failure(
916 "husky - pre-commit script failed (code 1)"
917 ));
918 assert!(is_husky_or_pre_commit_hook_error(
919 "`git commit -m chore` failed: husky - pre-commit script failed (code 1)"
920 ));
921 assert!(is_commit_hook_failure(
922 "Ultracite found issues that could not be auto-fixed."
923 ));
924 }
925
926 #[test]
927 fn normalizes_commit_paths_relative_to_project_root() {
928 let project_root = Path::new("C:/repo");
929 let paths = vec![
930 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
931 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
932 PathBuf::from("CHANGELOG.md"),
933 ];
934
935 let normalized = normalize_commit_paths(project_root, &paths);
936
937 assert_eq!(
938 normalized,
939 vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
940 );
941 }
942
943 #[test]
944 fn detects_missing_git_identity_errors() {
945 assert!(is_missing_git_identity_error(
946 "Author identity unknown\n*** Please tell me who you are."
947 ));
948 }
949
950 #[test]
951 fn detects_lefthook_module_errors() {
952 assert!(is_missing_lefthook_error(
953 "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
954 ));
955 }
956
957 #[test]
958 fn summarizes_non_fast_forward_push_errors_without_git_hints() {
959 let primary = "! [rejected] main -> main (non-fast-forward)";
960 let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
961hint: Updates were rejected because the tip of your current branch is behind
962hint: its remote counterpart. If you want to integrate the remote changes,
963hint: use 'git pull' before pushing again."#;
964
965 let summary = summarize_git_push_error("origin", "main", primary, fallback);
966
967 assert!(summary.contains("behind `origin/main`"));
968 assert!(summary.contains("git pull --rebase origin main"));
969 assert!(!summary.contains("hint:"));
970 assert!(!summary.contains("git push -u origin main"));
971 }
972
973 #[test]
974 fn summarizes_behind_errors_with_detected_remote_name() {
975 let primary = "! [rejected] cleanup-sql -> cleanup-sql (non-fast-forward)";
976 let fallback = "error: failed to push some refs to 'https://github.com/acme/speedrun-formations.git'";
977
978 let summary = summarize_git_push_error("origin", "cleanup-sql", primary, fallback);
979
980 assert!(summary.contains("cleanup-sql"));
981 assert!(summary.contains("git pull --rebase origin cleanup-sql"));
982 }
983
984 #[test]
985 fn detects_non_fast_forward_push_errors() {
986 assert!(is_non_fast_forward_push_error(
987 "! [rejected] main -> main (non-fast-forward)"
988 ));
989 assert!(is_non_fast_forward_push_error(
990 "failed to push some refs\ntip of your current branch is behind"
991 ));
992 assert!(!is_non_fast_forward_push_error(
993 "authentication failed for 'https://github.com/acme/repo.git'"
994 ));
995 }
996
997 #[test]
998 fn explicit_push_flag_tracks_cli_override() {
999 reset_explicit_push();
1000 assert!(!explicit_push_requested());
1001 set_explicit_push(true);
1002 assert!(explicit_push_requested());
1003 reset_explicit_push();
1004 assert!(!explicit_push_requested());
1005 }
1006
1007 #[test]
1008 fn splits_upstream_into_remote_and_branch() {
1009 assert_eq!(
1010 split_remote_branch("origin/cleanup-sql"),
1011 Some(("origin".to_string(), "cleanup-sql".to_string()))
1012 );
1013 assert_eq!(
1014 split_remote_branch("upstream/feature/foo"),
1015 Some(("upstream".to_string(), "feature/foo".to_string()))
1016 );
1017 assert_eq!(split_remote_branch("origin"), None);
1018 }
1019}