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 || crate::utils::is_xbp_project_config_filename(&normalized)
766 || normalized == "pnpm-workspace.yaml"
767 || normalized == "package.json"
768 || normalized.ends_with("/package.json")
769 || normalized.ends_with("/pnpm-workspace.yaml")
770 })
771}
772
773fn is_commit_hook_failure(error: &str) -> bool {
774 let lower = error.to_ascii_lowercase();
775 lower.contains("pre-commit")
776 || lower.contains("husky")
777 || lower.contains("lefthook")
778 || lower.contains("hook")
779 || lower.contains("ultracite")
780 || lower.contains("lint-staged")
781 || lower.contains("hook exited")
782 || lower.contains("script failed")
783}
784
785fn is_husky_or_pre_commit_hook_error(error: &str) -> bool {
786 let lower = error.to_ascii_lowercase();
787 lower.contains("husky")
788 || lower.contains("pre-commit")
789 || lower.contains("ultracite")
790 || lower.contains("lint-staged")
791 || (lower.contains("hook") && lower.contains("failed"))
792}
793
794async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
795 let current_name = git_output(project_root, &["config", "--get", "user.name"])
796 .await
797 .ok()
798 .map(|value| value.trim().to_string())
799 .filter(|value| !value.is_empty());
800 let current_email = git_output(project_root, &["config", "--get", "user.email"])
801 .await
802 .ok()
803 .map(|value| value.trim().to_string())
804 .filter(|value| !value.is_empty());
805
806 if current_name.is_some() && current_email.is_some() {
807 return Ok(());
808 }
809
810 let suggested = SshConfig::load()
811 .ok()
812 .and_then(|config| config.cli_auth)
813 .and_then(|auth| match (auth.user_name, auth.user_email) {
814 (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
815 Some((name, email))
816 }
817 _ => None,
818 });
819
820 if !std::io::stdin().is_terminal() {
821 return Err(identity_setup_hint(
822 current_name.as_deref(),
823 current_email.as_deref(),
824 suggested
825 .as_ref()
826 .map(|(name, email)| (name.as_str(), email.as_str())),
827 ));
828 }
829
830 let prefill_name = suggested
831 .as_ref()
832 .map(|(name, _)| name.clone())
833 .or_else(|| current_name.clone())
834 .unwrap_or_default();
835 let prefill_email = suggested
836 .as_ref()
837 .map(|(_, email)| email.clone())
838 .or_else(|| current_email.clone())
839 .unwrap_or_default();
840
841 let mut configured_name = current_name;
842 let mut configured_email = current_email;
843
844 if configured_name.is_none() {
845 let name: String = Input::with_theme(&ColorfulTheme::default())
846 .with_prompt("Git commit author name")
847 .with_initial_text(prefill_name)
848 .interact_text()
849 .map_err(|e| format!("Failed to read git author name: {}", e))?;
850 let trimmed = name.trim().to_string();
851 if trimmed.is_empty() {
852 return Err("Git commit author name cannot be empty.".to_string());
853 }
854 git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
855 configured_name = Some(trimmed);
856 }
857
858 if configured_email.is_none() {
859 let email: String = Input::with_theme(&ColorfulTheme::default())
860 .with_prompt("Git commit author email")
861 .with_initial_text(prefill_email)
862 .interact_text()
863 .map_err(|e| format!("Failed to read git author email: {}", e))?;
864 let trimmed = email.trim().to_string();
865 if trimmed.is_empty() {
866 return Err("Git commit author email cannot be empty.".to_string());
867 }
868 git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
869 configured_email = Some(trimmed);
870 }
871
872 if configured_name.is_some() && configured_email.is_some() {
873 Ok(())
874 } else {
875 Err("Git commit identity is still incomplete after prompting.".to_string())
876 }
877}
878
879async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
880 if is_missing_git_identity_error(error) {
881 let suggested = SshConfig::load()
882 .ok()
883 .and_then(|config| config.cli_auth)
884 .and_then(|auth| match (auth.user_name, auth.user_email) {
885 (Some(name), Some(email))
886 if !name.trim().is_empty() && !email.trim().is_empty() =>
887 {
888 Some((name, email))
889 }
890 _ => None,
891 });
892
893 return identity_setup_hint(
894 git_output(project_root, &["config", "--get", "user.name"])
895 .await
896 .ok()
897 .as_deref(),
898 git_output(project_root, &["config", "--get", "user.email"])
899 .await
900 .ok()
901 .as_deref(),
902 suggested
903 .as_ref()
904 .map(|(name, email)| (name.as_str(), email.as_str())),
905 );
906 }
907
908 if is_missing_lefthook_error(error) {
909 return format!(
910 "The commit hook tried to load lefthook from the current repo but the module was missing.\n\
911Run `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\
912If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0` / `HUSKY=0` or `git commit --no-verify`.\n\
913Raw error: {error}"
914 );
915 }
916
917 if is_husky_or_pre_commit_hook_error(error) {
918 let husky_hint = if project_root.join(".husky").is_dir() {
919 "\nThis repo has a `.husky/` pre-commit hook. Common blockers: broken shebang order, \
920`pnpm test` on every commit, or formatters (ultracite/biome) scanning the whole tree."
921 } else {
922 ""
923 };
924 return format!(
925 "Git commit was blocked by a pre-commit hook (husky/lefthook/lint).\n\
926XBP-only commits under `.xbp/` are retried automatically with hooks disabled.\n\
927To 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\
928Workaround: `HUSKY=0 git commit --no-verify` (or `LEFTHOOK=0`).{husky_hint}\n\
929Raw error: {error}"
930 );
931 }
932
933 format!("Git commit failed: {}", error)
934}
935
936fn identity_setup_hint(
937 current_name: Option<&str>,
938 current_email: Option<&str>,
939 suggested: Option<(&str, &str)>,
940) -> String {
941 let mut lines = vec![
942 "Git blocked the commit because your author identity is not configured in this environment."
943 .to_string(),
944 ];
945 if current_name.is_none() {
946 lines.push("Missing `user.name`.".to_string());
947 }
948 if current_email.is_none() {
949 lines.push("Missing `user.email`.".to_string());
950 }
951 if let Some((name, email)) = suggested {
952 lines.push(format!(
953 "XBP found a likely identity to prefill: {} <{}>",
954 name, email
955 ));
956 lines.push(format!("Run `git config --local user.name \"{}\"`", name));
957 lines.push(format!("Run `git config --local user.email \"{}\"`", email));
958 } else {
959 lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
960 .to_string());
961 lines.push(
962 "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
963 );
964 }
965 lines.join("\n")
966}
967
968fn is_missing_git_identity_error(error: &str) -> bool {
969 error.contains("Author identity unknown")
970 || error.contains("empty ident name")
971 || error.contains("Please tell me who you are")
972}
973
974fn is_missing_lefthook_error(error: &str) -> bool {
975 let lower = error.to_ascii_lowercase();
976 (lower.contains("lefthook") && lower.contains("module not found"))
977 || (lower.contains("lefthook") && lower.contains("cannot find module"))
978}
979
980fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
981 let mut deduped = BTreeSet::new();
982
983 for path in paths {
984 if path.as_os_str().is_empty() {
985 continue;
986 }
987
988 let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
989 relative.to_path_buf()
990 } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
991 relative
992 } else {
993 path.to_path_buf()
994 };
995
996 let rendered = normalized.to_string_lossy().replace('\\', "/");
997 let trimmed = rendered.trim();
998 if !trimmed.is_empty() && trimmed != "." {
999 deduped.insert(trimmed.to_string());
1000 }
1001 }
1002
1003 deduped.into_iter().collect()
1004}
1005
1006fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
1007 let root = project_root
1008 .to_string_lossy()
1009 .replace('\\', "/")
1010 .trim_end_matches('/')
1011 .to_string();
1012 let candidate = path.to_string_lossy().replace('\\', "/");
1013
1014 if candidate.len() <= root.len() {
1015 return None;
1016 }
1017
1018 let (prefix, suffix) = candidate.split_at(root.len());
1019 if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
1020 return Some(PathBuf::from(suffix.trim_start_matches('/')));
1021 }
1022
1023 None
1024}
1025
1026pub fn summarize_git_push_error(
1027 remote: &str,
1028 branch: &str,
1029 primary_error: &str,
1030 fallback_error: &str,
1031) -> String {
1032 let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
1033
1034 if is_non_fast_forward_push_error(&corpus) {
1035 return format!(
1036 "`{branch}` is behind `{remote}/{branch}`. Run `git pull --rebase {remote} {branch}`, then `git push`."
1037 );
1038 }
1039
1040 if corpus.contains("authentication failed")
1041 || corpus.contains("could not read username")
1042 || corpus.contains("403")
1043 || corpus.contains("401")
1044 {
1045 return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
1046 .to_string();
1047 }
1048
1049 let lines = dedupe_git_error_lines(primary_error, fallback_error);
1050 lines
1051 .into_iter()
1052 .last()
1053 .unwrap_or_else(|| "git push failed".to_string())
1054}
1055
1056fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
1057 let mut seen = BTreeSet::new();
1058 let mut lines = Vec::new();
1059
1060 for line in primary_error.lines().chain(fallback_error.lines()) {
1061 let trimmed = line.trim();
1062 if trimmed.is_empty() || trimmed.starts_with("hint:") {
1063 continue;
1064 }
1065 let key = trimmed.to_ascii_lowercase();
1066 if seen.insert(key) {
1067 lines.push(trimmed.to_string());
1068 }
1069 }
1070
1071 lines
1072}
1073
1074fn repo_name(path: &Path) -> String {
1075 path.file_name()
1076 .and_then(|value| value.to_str())
1077 .filter(|value| !value.trim().is_empty())
1078 .unwrap_or("repository")
1079 .to_string()
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::{
1085 enrich_rebase_error, explicit_push_requested, is_commit_hook_failure,
1086 is_dirty_worktree_rebase_error, is_husky_or_pre_commit_hook_error,
1087 is_missing_git_identity_error, is_missing_lefthook_error, is_nothing_to_stash_message,
1088 is_non_fast_forward_push_error, is_xbp_managed_commit, normalize_commit_paths,
1089 reset_explicit_push, set_explicit_push, split_remote_branch, summarize_git_push_error,
1090 };
1091 use std::path::{Path, PathBuf};
1092
1093 #[test]
1094 fn xbp_managed_commit_detects_config_only_paths() {
1095 assert!(is_xbp_managed_commit(&[".xbp/xbp.yaml".to_string()]));
1096 assert!(is_xbp_managed_commit(&[
1097 ".xbp/xbp.yaml".to_string(),
1098 "package.json".to_string(),
1099 "pnpm-workspace.yaml".to_string(),
1100 ]));
1101 assert!(!is_xbp_managed_commit(&[
1102 ".xbp/xbp.yaml".to_string(),
1103 "src/app.ts".to_string(),
1104 ]));
1105 assert!(!is_xbp_managed_commit(&[]));
1106 }
1107
1108 #[test]
1109 fn detects_husky_pre_commit_failures() {
1110 assert!(is_commit_hook_failure(
1111 "husky - pre-commit script failed (code 1)"
1112 ));
1113 assert!(is_husky_or_pre_commit_hook_error(
1114 "`git commit -m chore` failed: husky - pre-commit script failed (code 1)"
1115 ));
1116 assert!(is_commit_hook_failure(
1117 "Ultracite found issues that could not be auto-fixed."
1118 ));
1119 }
1120
1121 #[test]
1122 fn normalizes_commit_paths_relative_to_project_root() {
1123 let project_root = Path::new("C:/repo");
1124 let paths = vec![
1125 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
1126 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
1127 PathBuf::from("CHANGELOG.md"),
1128 ];
1129
1130 let normalized = normalize_commit_paths(project_root, &paths);
1131
1132 assert_eq!(
1133 normalized,
1134 vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
1135 );
1136 }
1137
1138 #[test]
1139 fn detects_missing_git_identity_errors() {
1140 assert!(is_missing_git_identity_error(
1141 "Author identity unknown\n*** Please tell me who you are."
1142 ));
1143 }
1144
1145 #[test]
1146 fn detects_lefthook_module_errors() {
1147 assert!(is_missing_lefthook_error(
1148 "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
1149 ));
1150 }
1151
1152 #[test]
1153 fn summarizes_non_fast_forward_push_errors_without_git_hints() {
1154 let primary = "! [rejected] main -> main (non-fast-forward)";
1155 let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
1156hint: Updates were rejected because the tip of your current branch is behind
1157hint: its remote counterpart. If you want to integrate the remote changes,
1158hint: use 'git pull' before pushing again."#;
1159
1160 let summary = summarize_git_push_error("origin", "main", primary, fallback);
1161
1162 assert!(summary.contains("behind `origin/main`"));
1163 assert!(summary.contains("git pull --rebase origin main"));
1164 assert!(!summary.contains("hint:"));
1165 assert!(!summary.contains("git push -u origin main"));
1166 }
1167
1168 #[test]
1169 fn summarizes_behind_errors_with_detected_remote_name() {
1170 let primary = "! [rejected] cleanup-sql -> cleanup-sql (non-fast-forward)";
1171 let fallback = "error: failed to push some refs to 'https://github.com/acme/speedrun-formations.git'";
1172
1173 let summary = summarize_git_push_error("origin", "cleanup-sql", primary, fallback);
1174
1175 assert!(summary.contains("cleanup-sql"));
1176 assert!(summary.contains("git pull --rebase origin cleanup-sql"));
1177 }
1178
1179 #[test]
1180 fn detects_dirty_worktree_rebase_errors() {
1181 assert!(is_dirty_worktree_rebase_error(
1182 "error: cannot pull with rebase: You have unstaged changes.\nerror: Please commit or stash them."
1183 ));
1184 assert!(is_dirty_worktree_rebase_error(
1185 "`git pull --rebase origin main` failed: error: cannot pull with rebase: You have unstaged changes."
1186 ));
1187 assert!(!is_dirty_worktree_rebase_error(
1188 "CONFLICT (content): Merge conflict in Cargo.toml"
1189 ));
1190 assert!(is_nothing_to_stash_message("No local changes to save"));
1191 let enriched = enrich_rebase_error(
1192 "error: cannot pull with rebase: You have unstaged changes.",
1193 );
1194 assert!(enriched.contains("Dirty worktree blocked rebase"));
1195 }
1196
1197 #[test]
1198 fn detects_non_fast_forward_push_errors() {
1199 assert!(is_non_fast_forward_push_error(
1200 "! [rejected] main -> main (non-fast-forward)"
1201 ));
1202 assert!(is_non_fast_forward_push_error(
1203 "failed to push some refs\ntip of your current branch is behind"
1204 ));
1205 assert!(!is_non_fast_forward_push_error(
1206 "authentication failed for 'https://github.com/acme/repo.git'"
1207 ));
1208 }
1209
1210 #[test]
1211 fn explicit_push_flag_tracks_cli_override() {
1212 reset_explicit_push();
1213 assert!(!explicit_push_requested());
1214 set_explicit_push(true);
1215 assert!(explicit_push_requested());
1216 reset_explicit_push();
1217 assert!(!explicit_push_requested());
1218 }
1219
1220 #[test]
1221 fn splits_upstream_into_remote_and_branch() {
1222 assert_eq!(
1223 split_remote_branch("origin/cleanup-sql"),
1224 Some(("origin".to_string(), "cleanup-sql".to_string()))
1225 );
1226 assert_eq!(
1227 split_remote_branch("upstream/feature/foo"),
1228 Some(("upstream".to_string(), "feature/foo".to_string()))
1229 );
1230 assert_eq!(split_remote_branch("origin"), None);
1231 }
1232}