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 if remote_branch_has_unmerged_commits(project_root, &remote, &branch).await? {
201 println!(
202 "{} {}",
203 "Branch behind remote".bright_yellow().bold(),
204 format!("rebasing onto {}/{} before push...", remote, branch).bright_white()
205 );
206 pull_rebase(project_root, &remote, &branch)
207 .await
208 .map_err(|rebase_error| {
209 format!(
210 "`{branch}` is behind `{remote}/{branch}` and auto-rebase failed: {rebase_error}. Resolve conflicts, then `git push`."
211 )
212 })?;
213 rebased = true;
214 }
215
216 match attempt_push(project_root, &remote, &branch).await {
217 Ok(()) => {}
218 Err(push_error) if is_non_fast_forward_push_error(&push_error) => {
219 println!(
220 "{} {}",
221 "Branch behind remote".bright_yellow().bold(),
222 format!("rebasing onto {}/{} then retrying push...", remote, branch)
223 .bright_white()
224 );
225 pull_rebase(project_root, &remote, &branch)
226 .await
227 .map_err(|rebase_error| {
228 format!(
229 "`{branch}` is behind `{remote}/{branch}` and auto-rebase failed: {rebase_error}. Resolve conflicts, then `git push`."
230 )
231 })?;
232 rebased = true;
233 attempt_push(project_root, &remote, &branch)
234 .await
235 .map_err(|retry_error| {
236 summarize_git_push_error(&remote, &branch, &push_error, &retry_error)
237 })?;
238 }
239 Err(push_error) => {
240 return Err(summarize_git_push_error(
241 &remote,
242 &branch,
243 &push_error,
244 &push_error,
245 ));
246 }
247 }
248
249 Ok(Some(PushOutcome {
250 remote,
251 branch,
252 rebased,
253 }))
254}
255
256async fn remote_branch_has_unmerged_commits(
258 project_root: &Path,
259 remote: &str,
260 branch: &str,
261) -> Result<bool, String> {
262 let _ = git_output(project_root, &["fetch", remote, branch]).await;
263 let remote_ref = format!("{remote}/{branch}");
264
265 if git_output(
267 project_root,
268 &["rev-parse", "--verify", "--quiet", &remote_ref],
269 )
270 .await
271 .is_err()
272 {
273 return Ok(false);
274 }
275
276 let count = git_output(
277 project_root,
278 &["rev-list", "--count", &format!("HEAD..{remote_ref}")],
279 )
280 .await
281 .unwrap_or_else(|_| "0".to_string());
282 Ok(count.trim().parse::<usize>().unwrap_or(0) > 0)
283}
284
285pub async fn sync_and_push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
289 push_current_branch(project_root).await
290}
291
292pub async fn pull_rebase_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
294 if !command_exists("git") {
295 return Ok(None);
296 }
297
298 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
299 .await
300 .ok()
301 .filter(|value| !value.is_empty() && value != "HEAD");
302 let Some(branch) = branch else {
303 return Ok(None);
304 };
305
306 let remote = resolve_push_remote(project_root, &branch).await?;
307 println!(
308 "{} {}",
309 "Rebasing".bright_cyan().bold(),
310 format!("onto {}/{}...", remote, branch).bright_white()
311 );
312 pull_rebase(project_root, &remote, &branch).await?;
313 Ok(Some(PushOutcome {
314 remote,
315 branch,
316 rebased: true,
317 }))
318}
319
320pub fn print_skip(action_label: &str, reason: &str) {
321 println!(
322 "{} {} {}",
323 "Auto-commit".bright_yellow().bold(),
324 format!("skipped for {}", action_label).bright_white(),
325 format!("({})", reason).dimmed()
326 );
327}
328
329pub fn print_push_summary(outcome: &PushOutcome) {
330 let target = format!("{}/{}", outcome.remote, outcome.branch);
331 if outcome.rebased {
332 println!(
333 "{} {} {}",
334 "Pushed".bright_green().bold(),
335 target.bright_white(),
336 "(after auto-rebase)".dimmed()
337 );
338 } else {
339 println!(
340 "{} {}",
341 "Pushed".bright_green().bold(),
342 target.bright_white()
343 );
344 }
345}
346
347async fn attempt_push(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
348 match git_output(project_root, &["push"]).await {
349 Ok(_) => Ok(()),
350 Err(push_error) => {
351 git_output(project_root, &["push", "-u", remote, branch])
353 .await
354 .map(|_| ())
355 .map_err(|fallback_error| format!("{push_error}\n{fallback_error}"))
356 }
357 }
358}
359
360const AUTO_REBASE_STASH_MESSAGE: &str = "xbp auto-rebase: temporary stash";
361
362async fn pull_rebase(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
363 let _ = git_output(project_root, &["fetch", remote, branch]).await;
365
366 let stashed = stash_if_worktree_dirty(project_root).await?;
369
370 let rebase_result = git_output(project_root, &["pull", "--rebase", remote, branch])
371 .await
372 .map(|_| ())
373 .map_err(|error| enrich_rebase_error(&error));
374
375 let restore_result = if stashed {
376 restore_auto_rebase_stash(project_root).await
377 } else {
378 Ok(())
379 };
380
381 match (rebase_result, restore_result) {
382 (Ok(()), Ok(())) => Ok(()),
383 (Ok(()), Err(restore_error)) => Err(format!(
384 "Rebase onto `{remote}/{branch}` succeeded, but restoring the temporary stash failed: {restore_error}. \
385Look for a stash named `{AUTO_REBASE_STASH_MESSAGE}` (`git stash list`) and run `git stash pop`."
386 )),
387 (Err(rebase_error), Ok(())) => Err(rebase_error),
388 (Err(rebase_error), Err(restore_error)) => Err(format!(
389 "{rebase_error}; also failed to restore temporary stash: {restore_error}. \
390Look for a stash named `{AUTO_REBASE_STASH_MESSAGE}` (`git stash list`)."
391 )),
392 }
393}
394
395async fn worktree_is_dirty(project_root: &Path) -> Result<bool, String> {
396 let status = git_output(project_root, &["status", "--porcelain"]).await?;
397 Ok(!status.trim().is_empty())
398}
399
400async fn stash_if_worktree_dirty(project_root: &Path) -> Result<bool, String> {
402 if !worktree_is_dirty(project_root).await? {
403 return Ok(false);
404 }
405
406 println!(
407 "{} {}",
408 "Dirty worktree".bright_yellow().bold(),
409 "stashing unstaged/untracked WIP for auto-rebase...".bright_white()
410 );
411
412 let stash_output = git_output(
413 project_root,
414 &[
415 "stash",
416 "push",
417 "--include-untracked",
418 "-m",
419 AUTO_REBASE_STASH_MESSAGE,
420 ],
421 )
422 .await;
423
424 match stash_output {
425 Ok(output) if is_nothing_to_stash_message(&output) => Ok(false),
426 Ok(_) => Ok(true),
427 Err(error) if is_nothing_to_stash_message(&error) => Ok(false),
428 Err(error) => Err(format!(
429 "Failed to stash dirty worktree before auto-rebase: {error}"
430 )),
431 }
432}
433
434async fn restore_auto_rebase_stash(project_root: &Path) -> Result<(), String> {
435 if rebase_in_progress(project_root).await {
437 return Err(format!(
438 "rebase still in progress; temporary stash `{AUTO_REBASE_STASH_MESSAGE}` was left applied in the stash list"
439 ));
440 }
441
442 match git_output(project_root, &["stash", "pop"]).await {
443 Ok(_) => {
444 println!(
445 "{} {}",
446 "Restored".bright_green().bold(),
447 "temporary auto-rebase stash".bright_white()
448 );
449 Ok(())
450 }
451 Err(error) => Err(error),
452 }
453}
454
455async fn rebase_in_progress(project_root: &Path) -> bool {
456 git_output(project_root, &["rev-parse", "--verify", "REBASE_HEAD"])
458 .await
459 .is_ok()
460 || git_output(
461 project_root,
462 &["rev-parse", "--git-path", "rebase-merge"],
463 )
464 .await
465 .ok()
466 .map(|path| {
467 let p = project_root.join(path.trim());
468 std::path::Path::new(path.trim()).exists() || p.exists()
470 })
471 .unwrap_or(false)
472 || git_output(project_root, &["rev-parse", "--git-path", "rebase-apply"])
473 .await
474 .ok()
475 .map(|path| {
476 let p = project_root.join(path.trim());
477 std::path::Path::new(path.trim()).exists() || p.exists()
478 })
479 .unwrap_or(false)
480}
481
482fn is_nothing_to_stash_message(message: &str) -> bool {
483 let lowered = message.to_ascii_lowercase();
484 lowered.contains("no local changes to save") || lowered.contains("no changes to save")
485}
486
487fn is_dirty_worktree_rebase_error(error: &str) -> bool {
488 let lowered = error.to_ascii_lowercase();
489 (lowered.contains("cannot pull with rebase") || lowered.contains("cannot rebase"))
490 && (lowered.contains("unstaged changes")
491 || lowered.contains("uncommitted changes")
492 || lowered.contains("your index contains uncommitted changes")
493 || lowered.contains("please commit or stash"))
494}
495
496fn enrich_rebase_error(error: &str) -> String {
497 if is_dirty_worktree_rebase_error(error) {
498 format!(
499 "{error}. Dirty worktree blocked rebase even after stash attempt; \
500commit or stash unrelated WIP, then `git pull --rebase` and `git push`."
501 )
502 } else {
503 error.to_string()
504 }
505}
506
507pub async fn resolve_push_remote(project_root: &Path, branch: &str) -> Result<String, String> {
511 if let Ok(configured) = git_output(
512 project_root,
513 &["config", "--get", &format!("branch.{branch}.remote")],
514 )
515 .await
516 {
517 let trimmed = configured.trim();
518 if !trimmed.is_empty() {
519 return Ok(trimmed.to_string());
520 }
521 }
522
523 if let Ok(upstream) = git_output(
524 project_root,
525 &[
526 "rev-parse",
527 "--abbrev-ref",
528 "--symbolic-full-name",
529 "@{upstream}",
530 ],
531 )
532 .await
533 {
534 if let Some(remote) = split_remote_branch(upstream.trim()).map(|(remote, _)| remote) {
535 return Ok(remote);
536 }
537 }
538
539 let remotes = git_output(project_root, &["remote"]).await.unwrap_or_default();
540 let remote_names = remotes
541 .lines()
542 .map(str::trim)
543 .filter(|line| !line.is_empty())
544 .map(ToOwned::to_owned)
545 .collect::<Vec<_>>();
546
547 if remote_names.iter().any(|name| name == "origin") {
548 return Ok("origin".to_string());
549 }
550
551 remote_names.into_iter().next().ok_or_else(|| {
552 "No git remotes are configured for this repository. Add one with `git remote add <name> <url>`."
553 .to_string()
554 })
555}
556
557fn split_remote_branch(upstream: &str) -> Option<(String, String)> {
558 let (remote, branch) = upstream.split_once('/')?;
559 let remote = remote.trim();
560 let branch = branch.trim();
561 if remote.is_empty() || branch.is_empty() {
562 return None;
563 }
564 Some((remote.to_string(), branch.to_string()))
565}
566
567fn is_non_fast_forward_push_error(error: &str) -> bool {
568 let corpus = error.to_ascii_lowercase();
569 corpus.contains("non-fast-forward")
570 || corpus.contains("tip of your current branch is behind")
571 || (corpus.contains("failed to push some refs")
572 && (corpus.contains("behind")
573 || corpus.contains("rejected")
574 || corpus.contains("fetch first")))
575}
576
577fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
578 let branch = outcome
579 .branch
580 .as_deref()
581 .map(|value| format!(" on {}", value.bright_blue()))
582 .unwrap_or_default();
583 let files = if outcome.committed_files.is_empty() {
584 "(none)".dimmed().to_string()
585 } else {
586 outcome
587 .committed_files
588 .iter()
589 .map(|value| value.bright_white().to_string())
590 .collect::<Vec<_>>()
591 .join(", ")
592 };
593
594 println!(
595 "{} {}{}",
596 "Auto-commit".bright_green().bold(),
597 format!("created for {}", action_label).bright_white(),
598 branch
599 );
600 println!(
601 " {} {} {}",
602 "Repo".bright_cyan().bold(),
603 outcome.repo_name.bright_white().bold(),
604 format!("({})", outcome.repo_root.display()).dimmed()
605 );
606 println!(
607 " {} {} {}",
608 "Commit".bright_cyan().bold(),
609 outcome.short_sha.bright_green().bold(),
610 format!("({})", outcome.commit_sha).dimmed()
611 );
612 println!(
613 " {} {}",
614 "Message".bright_cyan().bold(),
615 outcome.message.bright_magenta()
616 );
617 println!(" {} {}", "Files".bright_cyan().bold(), files);
618}
619
620async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
621 let owned_args = args
622 .iter()
623 .map(|value| value.to_string())
624 .collect::<Vec<_>>();
625 git_output_owned(project_root, owned_args).await
626}
627
628async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
629 let output = Command::new("git")
630 .current_dir(project_root)
631 .args(&args)
632 .output()
633 .await
634 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
635
636 if !output.status.success() {
637 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
638 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
639 let detail = [stderr, stdout]
641 .into_iter()
642 .filter(|s| !s.is_empty())
643 .collect::<Vec<_>>()
644 .join("\n");
645 if detail.is_empty() {
646 return Err(format!(
647 "`git {}` failed with status {}",
648 args.join(" "),
649 output.status
650 ));
651 }
652 return Err(format!("`git {}` failed: {}", args.join(" "), detail));
653 }
654
655 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
656}
657
658async fn commit_with_optional_hook_retry(
659 project_root: &Path,
660 message: &str,
661 committed_files: &[String],
662) -> Result<(), String> {
663 match git_output(project_root, &["commit", "-m", message]).await {
664 Ok(_) => Ok(()),
665 Err(error) => {
666 let hook_failure = is_commit_hook_failure(&error);
667 if !hook_failure {
668 return Err(error);
669 }
670
671 if is_xbp_managed_commit(committed_files) {
674 println!(
675 "{} {}",
676 "Hooks".bright_yellow().bold(),
677 "failed on XBP-managed files only — retrying with hooks disabled (--no-verify)."
678 .bright_white()
679 );
680 return run_commit_with_hooks_disabled(project_root, message).await;
681 }
682
683 if is_missing_lefthook_error(&error)
684 || is_husky_or_pre_commit_hook_error(&error)
685 {
686 if std::io::stdin().is_terminal() {
687 let prompt = if is_missing_lefthook_error(&error) {
688 "Commit hooks failed to resolve lefthook. Retry once with hooks disabled?"
689 } else {
690 "Pre-commit / husky hooks failed. Retry this XBP commit with hooks disabled?"
691 };
692 let retry = Confirm::with_theme(&ColorfulTheme::default())
693 .with_prompt(prompt)
694 .default(true)
695 .interact()
696 .map_err(|e| format!("Failed to read retry choice: {}", e))?;
697 if retry {
698 println!(
699 "{} {}",
700 "Hooks".bright_yellow().bold(),
701 "disabled for this commit (--no-verify, HUSKY=0, LEFTHOOK=0)."
702 .bright_white()
703 );
704 return run_commit_with_hooks_disabled(project_root, message).await;
705 }
706 } else {
707 println!(
710 "{} {}",
711 "Hooks".bright_yellow().bold(),
712 "failed in non-interactive mode — retrying with hooks disabled."
713 .bright_white()
714 );
715 return run_commit_with_hooks_disabled(project_root, message).await;
716 }
717 }
718
719 Err(error)
720 }
721 }
722}
723
724async fn run_commit_with_hooks_disabled(project_root: &Path, message: &str) -> Result<(), String> {
725 let output = Command::new("git")
726 .current_dir(project_root)
727 .env("HUSKY", "0")
729 .env("LEFTHOOK", "0")
730 .args(["commit", "--no-verify", "-m", message])
731 .output()
732 .await
733 .map_err(|e| format!("Failed to run `git commit --no-verify -m {}`: {}", message, e))?;
734
735 if !output.status.success() {
736 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
737 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
738 let detail = [stderr, stdout]
739 .into_iter()
740 .filter(|s| !s.is_empty())
741 .collect::<Vec<_>>()
742 .join("\n");
743 if detail.is_empty() {
744 return Err(format!(
745 "`git commit --no-verify -m {}` failed with status {}",
746 message, output.status
747 ));
748 }
749 return Err(format!(
750 "`git commit --no-verify -m {}` failed: {}",
751 message, detail
752 ));
753 }
754
755 Ok(())
756}
757
758fn is_xbp_managed_commit(committed_files: &[String]) -> bool {
760 !committed_files.is_empty()
761 && committed_files.iter().all(|path| {
762 let normalized = path.replace('\\', "/");
763 normalized == ".xbp"
764 || normalized.starts_with(".xbp/")
765 || normalized == "xbp.yaml"
766 || normalized == "xbp.yml"
767 || normalized == "xbp.json"
768 || normalized == "pnpm-workspace.yaml"
769 || normalized == "package.json"
770 || normalized.ends_with("/package.json")
771 || normalized.ends_with("/pnpm-workspace.yaml")
772 })
773}
774
775fn is_commit_hook_failure(error: &str) -> bool {
776 let lower = error.to_ascii_lowercase();
777 lower.contains("pre-commit")
778 || lower.contains("husky")
779 || lower.contains("lefthook")
780 || lower.contains("hook")
781 || lower.contains("ultracite")
782 || lower.contains("lint-staged")
783 || lower.contains("hook exited")
784 || lower.contains("script failed")
785}
786
787fn is_husky_or_pre_commit_hook_error(error: &str) -> bool {
788 let lower = error.to_ascii_lowercase();
789 lower.contains("husky")
790 || lower.contains("pre-commit")
791 || lower.contains("ultracite")
792 || lower.contains("lint-staged")
793 || (lower.contains("hook") && lower.contains("failed"))
794}
795
796async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
797 let current_name = git_output(project_root, &["config", "--get", "user.name"])
798 .await
799 .ok()
800 .map(|value| value.trim().to_string())
801 .filter(|value| !value.is_empty());
802 let current_email = git_output(project_root, &["config", "--get", "user.email"])
803 .await
804 .ok()
805 .map(|value| value.trim().to_string())
806 .filter(|value| !value.is_empty());
807
808 if current_name.is_some() && current_email.is_some() {
809 return Ok(());
810 }
811
812 let suggested = SshConfig::load()
813 .ok()
814 .and_then(|config| config.cli_auth)
815 .and_then(|auth| match (auth.user_name, auth.user_email) {
816 (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
817 Some((name, email))
818 }
819 _ => None,
820 });
821
822 if !std::io::stdin().is_terminal() {
823 return Err(identity_setup_hint(
824 current_name.as_deref(),
825 current_email.as_deref(),
826 suggested
827 .as_ref()
828 .map(|(name, email)| (name.as_str(), email.as_str())),
829 ));
830 }
831
832 let prefill_name = suggested
833 .as_ref()
834 .map(|(name, _)| name.clone())
835 .or_else(|| current_name.clone())
836 .unwrap_or_default();
837 let prefill_email = suggested
838 .as_ref()
839 .map(|(_, email)| email.clone())
840 .or_else(|| current_email.clone())
841 .unwrap_or_default();
842
843 let mut configured_name = current_name;
844 let mut configured_email = current_email;
845
846 if configured_name.is_none() {
847 let name: String = Input::with_theme(&ColorfulTheme::default())
848 .with_prompt("Git commit author name")
849 .with_initial_text(prefill_name)
850 .interact_text()
851 .map_err(|e| format!("Failed to read git author name: {}", e))?;
852 let trimmed = name.trim().to_string();
853 if trimmed.is_empty() {
854 return Err("Git commit author name cannot be empty.".to_string());
855 }
856 git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
857 configured_name = Some(trimmed);
858 }
859
860 if configured_email.is_none() {
861 let email: String = Input::with_theme(&ColorfulTheme::default())
862 .with_prompt("Git commit author email")
863 .with_initial_text(prefill_email)
864 .interact_text()
865 .map_err(|e| format!("Failed to read git author email: {}", e))?;
866 let trimmed = email.trim().to_string();
867 if trimmed.is_empty() {
868 return Err("Git commit author email cannot be empty.".to_string());
869 }
870 git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
871 configured_email = Some(trimmed);
872 }
873
874 if configured_name.is_some() && configured_email.is_some() {
875 Ok(())
876 } else {
877 Err("Git commit identity is still incomplete after prompting.".to_string())
878 }
879}
880
881async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
882 if is_missing_git_identity_error(error) {
883 let suggested = SshConfig::load()
884 .ok()
885 .and_then(|config| config.cli_auth)
886 .and_then(|auth| match (auth.user_name, auth.user_email) {
887 (Some(name), Some(email))
888 if !name.trim().is_empty() && !email.trim().is_empty() =>
889 {
890 Some((name, email))
891 }
892 _ => None,
893 });
894
895 return identity_setup_hint(
896 git_output(project_root, &["config", "--get", "user.name"])
897 .await
898 .ok()
899 .as_deref(),
900 git_output(project_root, &["config", "--get", "user.email"])
901 .await
902 .ok()
903 .as_deref(),
904 suggested
905 .as_ref()
906 .map(|(name, email)| (name.as_str(), email.as_str())),
907 );
908 }
909
910 if is_missing_lefthook_error(error) {
911 return format!(
912 "The commit hook tried to load lefthook from the current repo but the module was missing.\n\
913Run `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\
914If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0` / `HUSKY=0` or `git commit --no-verify`.\n\
915Raw error: {error}"
916 );
917 }
918
919 if is_husky_or_pre_commit_hook_error(error) {
920 let husky_hint = if project_root.join(".husky").is_dir() {
921 "\nThis repo has a `.husky/` pre-commit hook. Common blockers: broken shebang order, \
922`pnpm test` on every commit, or formatters (ultracite/biome) scanning the whole tree."
923 } else {
924 ""
925 };
926 return format!(
927 "Git commit was blocked by a pre-commit hook (husky/lefthook/lint).\n\
928XBP-only commits under `.xbp/` are retried automatically with hooks disabled.\n\
929To 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\
930Workaround: `HUSKY=0 git commit --no-verify` (or `LEFTHOOK=0`).{husky_hint}\n\
931Raw error: {error}"
932 );
933 }
934
935 format!("Git commit failed: {}", error)
936}
937
938fn identity_setup_hint(
939 current_name: Option<&str>,
940 current_email: Option<&str>,
941 suggested: Option<(&str, &str)>,
942) -> String {
943 let mut lines = vec![
944 "Git blocked the commit because your author identity is not configured in this environment."
945 .to_string(),
946 ];
947 if current_name.is_none() {
948 lines.push("Missing `user.name`.".to_string());
949 }
950 if current_email.is_none() {
951 lines.push("Missing `user.email`.".to_string());
952 }
953 if let Some((name, email)) = suggested {
954 lines.push(format!(
955 "XBP found a likely identity to prefill: {} <{}>",
956 name, email
957 ));
958 lines.push(format!("Run `git config --local user.name \"{}\"`", name));
959 lines.push(format!("Run `git config --local user.email \"{}\"`", email));
960 } else {
961 lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
962 .to_string());
963 lines.push(
964 "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
965 );
966 }
967 lines.join("\n")
968}
969
970fn is_missing_git_identity_error(error: &str) -> bool {
971 error.contains("Author identity unknown")
972 || error.contains("empty ident name")
973 || error.contains("Please tell me who you are")
974}
975
976fn is_missing_lefthook_error(error: &str) -> bool {
977 let lower = error.to_ascii_lowercase();
978 (lower.contains("lefthook") && lower.contains("module not found"))
979 || (lower.contains("lefthook") && lower.contains("cannot find module"))
980}
981
982fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
983 let mut deduped = BTreeSet::new();
984
985 for path in paths {
986 if path.as_os_str().is_empty() {
987 continue;
988 }
989
990 let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
991 relative.to_path_buf()
992 } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
993 relative
994 } else {
995 path.to_path_buf()
996 };
997
998 let rendered = normalized.to_string_lossy().replace('\\', "/");
999 let trimmed = rendered.trim();
1000 if !trimmed.is_empty() && trimmed != "." {
1001 deduped.insert(trimmed.to_string());
1002 }
1003 }
1004
1005 deduped.into_iter().collect()
1006}
1007
1008fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
1009 let root = project_root
1010 .to_string_lossy()
1011 .replace('\\', "/")
1012 .trim_end_matches('/')
1013 .to_string();
1014 let candidate = path.to_string_lossy().replace('\\', "/");
1015
1016 if candidate.len() <= root.len() {
1017 return None;
1018 }
1019
1020 let (prefix, suffix) = candidate.split_at(root.len());
1021 if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
1022 return Some(PathBuf::from(suffix.trim_start_matches('/')));
1023 }
1024
1025 None
1026}
1027
1028pub fn summarize_git_push_error(
1029 remote: &str,
1030 branch: &str,
1031 primary_error: &str,
1032 fallback_error: &str,
1033) -> String {
1034 let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
1035
1036 if is_non_fast_forward_push_error(&corpus) {
1037 return format!(
1038 "`{branch}` is behind `{remote}/{branch}`. Run `git pull --rebase {remote} {branch}`, then `git push`."
1039 );
1040 }
1041
1042 if corpus.contains("authentication failed")
1043 || corpus.contains("could not read username")
1044 || corpus.contains("403")
1045 || corpus.contains("401")
1046 {
1047 return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
1048 .to_string();
1049 }
1050
1051 let lines = dedupe_git_error_lines(primary_error, fallback_error);
1052 lines
1053 .into_iter()
1054 .last()
1055 .unwrap_or_else(|| "git push failed".to_string())
1056}
1057
1058fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
1059 let mut seen = BTreeSet::new();
1060 let mut lines = Vec::new();
1061
1062 for line in primary_error.lines().chain(fallback_error.lines()) {
1063 let trimmed = line.trim();
1064 if trimmed.is_empty() || trimmed.starts_with("hint:") {
1065 continue;
1066 }
1067 let key = trimmed.to_ascii_lowercase();
1068 if seen.insert(key) {
1069 lines.push(trimmed.to_string());
1070 }
1071 }
1072
1073 lines
1074}
1075
1076fn repo_name(path: &Path) -> String {
1077 path.file_name()
1078 .and_then(|value| value.to_str())
1079 .filter(|value| !value.trim().is_empty())
1080 .unwrap_or("repository")
1081 .to_string()
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::{
1087 enrich_rebase_error, explicit_push_requested, is_commit_hook_failure,
1088 is_dirty_worktree_rebase_error, is_husky_or_pre_commit_hook_error,
1089 is_missing_git_identity_error, is_missing_lefthook_error, is_nothing_to_stash_message,
1090 is_non_fast_forward_push_error, is_xbp_managed_commit, normalize_commit_paths,
1091 reset_explicit_push, set_explicit_push, split_remote_branch, summarize_git_push_error,
1092 };
1093 use std::path::{Path, PathBuf};
1094
1095 #[test]
1096 fn xbp_managed_commit_detects_config_only_paths() {
1097 assert!(is_xbp_managed_commit(&[".xbp/xbp.yaml".to_string()]));
1098 assert!(is_xbp_managed_commit(&[
1099 ".xbp/xbp.yaml".to_string(),
1100 "package.json".to_string(),
1101 "pnpm-workspace.yaml".to_string(),
1102 ]));
1103 assert!(!is_xbp_managed_commit(&[
1104 ".xbp/xbp.yaml".to_string(),
1105 "src/app.ts".to_string(),
1106 ]));
1107 assert!(!is_xbp_managed_commit(&[]));
1108 }
1109
1110 #[test]
1111 fn detects_husky_pre_commit_failures() {
1112 assert!(is_commit_hook_failure(
1113 "husky - pre-commit script failed (code 1)"
1114 ));
1115 assert!(is_husky_or_pre_commit_hook_error(
1116 "`git commit -m chore` failed: husky - pre-commit script failed (code 1)"
1117 ));
1118 assert!(is_commit_hook_failure(
1119 "Ultracite found issues that could not be auto-fixed."
1120 ));
1121 }
1122
1123 #[test]
1124 fn normalizes_commit_paths_relative_to_project_root() {
1125 let project_root = Path::new("C:/repo");
1126 let paths = vec![
1127 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
1128 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
1129 PathBuf::from("CHANGELOG.md"),
1130 ];
1131
1132 let normalized = normalize_commit_paths(project_root, &paths);
1133
1134 assert_eq!(
1135 normalized,
1136 vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
1137 );
1138 }
1139
1140 #[test]
1141 fn detects_missing_git_identity_errors() {
1142 assert!(is_missing_git_identity_error(
1143 "Author identity unknown\n*** Please tell me who you are."
1144 ));
1145 }
1146
1147 #[test]
1148 fn detects_lefthook_module_errors() {
1149 assert!(is_missing_lefthook_error(
1150 "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
1151 ));
1152 }
1153
1154 #[test]
1155 fn summarizes_non_fast_forward_push_errors_without_git_hints() {
1156 let primary = "! [rejected] main -> main (non-fast-forward)";
1157 let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
1158hint: Updates were rejected because the tip of your current branch is behind
1159hint: its remote counterpart. If you want to integrate the remote changes,
1160hint: use 'git pull' before pushing again."#;
1161
1162 let summary = summarize_git_push_error("origin", "main", primary, fallback);
1163
1164 assert!(summary.contains("behind `origin/main`"));
1165 assert!(summary.contains("git pull --rebase origin main"));
1166 assert!(!summary.contains("hint:"));
1167 assert!(!summary.contains("git push -u origin main"));
1168 }
1169
1170 #[test]
1171 fn summarizes_behind_errors_with_detected_remote_name() {
1172 let primary = "! [rejected] cleanup-sql -> cleanup-sql (non-fast-forward)";
1173 let fallback = "error: failed to push some refs to 'https://github.com/acme/speedrun-formations.git'";
1174
1175 let summary = summarize_git_push_error("origin", "cleanup-sql", primary, fallback);
1176
1177 assert!(summary.contains("cleanup-sql"));
1178 assert!(summary.contains("git pull --rebase origin cleanup-sql"));
1179 }
1180
1181 #[test]
1182 fn detects_dirty_worktree_rebase_errors() {
1183 assert!(is_dirty_worktree_rebase_error(
1184 "error: cannot pull with rebase: You have unstaged changes.\nerror: Please commit or stash them."
1185 ));
1186 assert!(is_dirty_worktree_rebase_error(
1187 "`git pull --rebase origin main` failed: error: cannot pull with rebase: You have unstaged changes."
1188 ));
1189 assert!(!is_dirty_worktree_rebase_error(
1190 "CONFLICT (content): Merge conflict in Cargo.toml"
1191 ));
1192 assert!(is_nothing_to_stash_message("No local changes to save"));
1193 let enriched = enrich_rebase_error(
1194 "error: cannot pull with rebase: You have unstaged changes.",
1195 );
1196 assert!(enriched.contains("Dirty worktree blocked rebase"));
1197 }
1198
1199 #[test]
1200 fn detects_non_fast_forward_push_errors() {
1201 assert!(is_non_fast_forward_push_error(
1202 "! [rejected] main -> main (non-fast-forward)"
1203 ));
1204 assert!(is_non_fast_forward_push_error(
1205 "failed to push some refs\ntip of your current branch is behind"
1206 ));
1207 assert!(!is_non_fast_forward_push_error(
1208 "authentication failed for 'https://github.com/acme/repo.git'"
1209 ));
1210 }
1211
1212 #[test]
1213 fn explicit_push_flag_tracks_cli_override() {
1214 reset_explicit_push();
1215 assert!(!explicit_push_requested());
1216 set_explicit_push(true);
1217 assert!(explicit_push_requested());
1218 reset_explicit_push();
1219 assert!(!explicit_push_requested());
1220 }
1221
1222 #[test]
1223 fn splits_upstream_into_remote_and_branch() {
1224 assert_eq!(
1225 split_remote_branch("origin/cleanup-sql"),
1226 Some(("origin".to_string(), "cleanup-sql".to_string()))
1227 );
1228 assert_eq!(
1229 split_remote_branch("upstream/feature/foo"),
1230 Some(("upstream".to_string(), "feature/foo".to_string()))
1231 );
1232 assert_eq!(split_remote_branch("origin"), None);
1233 }
1234}