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 pub rewritten_for_large_files: bool,
73}
74
75pub const GITHUB_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct OversizedPath {
80 pub path: String,
81 pub size_bytes: u64,
82}
83
84pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
85 if !command_exists("git") {
86 return Ok(AutoCommitResult::Skipped(
87 "Git is not installed on this machine.".to_string(),
88 ));
89 }
90
91 let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
97 {
98 Ok(root) => PathBuf::from(root.trim()),
99 Err(_) => {
100 return Ok(AutoCommitResult::Skipped(
101 "Current project is not inside a git repository.".to_string(),
102 ));
103 }
104 };
105
106 let normalized_paths =
107 normalize_commit_paths(&repo_root, request.project_root, &request.paths);
108 if normalized_paths.is_empty() {
109 return Ok(AutoCommitResult::Skipped(
110 "No generated or updated files were provided for auto-commit.".to_string(),
111 ));
112 }
113
114 let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
115 add_args.extend(normalized_paths.iter().cloned());
116 git_output_owned(&repo_root, add_args).await?;
117
118 let excluded = unstage_github_oversized_paths(&repo_root).await?;
120 if !excluded.is_empty() {
121 print_oversized_excluded(&excluded, "auto-commit staging");
122 }
123
124 let mut diff_args = vec![
125 "diff".to_string(),
126 "--cached".to_string(),
127 "--name-only".to_string(),
128 "--".to_string(),
129 ];
130 diff_args.extend(normalized_paths.iter().cloned());
131 let committed_files = git_output_owned(&repo_root, diff_args)
132 .await?
133 .lines()
134 .map(str::trim)
135 .filter(|line| !line.is_empty())
136 .map(|line| line.replace('\\', "/"))
137 .collect::<Vec<_>>();
138
139 if committed_files.is_empty() {
140 if !excluded.is_empty() {
141 return Ok(AutoCommitResult::Skipped(format!(
142 "Only GitHub-oversized files were staged ({}). Left unstaged: {}.",
143 format_oversized_list(&excluded),
144 excluded
145 .iter()
146 .map(|item| item.path.as_str())
147 .collect::<Vec<_>>()
148 .join(", ")
149 )));
150 }
151 return Ok(AutoCommitResult::Skipped(
152 "Target files did not produce any staged git diff.".to_string(),
153 ));
154 }
155
156 ensure_git_commit_identity(&repo_root).await?;
157
158 if let Err(error) =
162 commit_with_optional_hook_retry(&repo_root, &request.message, &committed_files).await
163 {
164 return Err(format_git_commit_failure(&repo_root, &error).await);
165 }
166
167 let commit_sha = git_output(&repo_root, &["rev-parse", "HEAD"]).await?;
168 let short_sha = git_output(&repo_root, &["rev-parse", "--short", "HEAD"]).await?;
169 let branch = git_output(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
170 .await
171 .ok()
172 .filter(|value| !value.is_empty() && value != "HEAD");
173
174 let outcome = AutoCommitOutcome {
175 repo_name: repo_name(&repo_root),
176 repo_root: repo_root.clone(),
177 branch,
178 commit_sha,
179 short_sha,
180 message: request.message,
181 committed_files,
182 };
183
184 print_commit_summary(request.action_label, &outcome);
185
186 let mut pushed = false;
187 if should_push_after_auto_commit(request.push).await {
188 match push_current_branch(&repo_root).await {
189 Ok(Some(push_outcome)) => {
190 print_push_summary(&push_outcome);
191 pushed = true;
192 }
193 Ok(None) => {}
194 Err(error) => {
195 return Err(format!(
196 "Created local commit {} but push failed: {}",
197 outcome.short_sha, error
198 ));
199 }
200 }
201 }
202
203 {
205 let subject = outcome
206 .message
207 .lines()
208 .next()
209 .unwrap_or(outcome.message.as_str())
210 .trim();
211 let xbp_config = crate::strategies::DeploymentConfig::load_xbp_config(None)
212 .await
213 .ok();
214 crate::commands::discord_notify::notify_discord_for_project(
215 &outcome.repo_root,
216 xbp_config.as_ref(),
217 None,
218 crate::commands::discord_notify::commit_notification(
219 &outcome.short_sha,
220 subject,
221 outcome.branch.as_deref(),
222 pushed,
223 ),
224 )
225 .await;
226 }
227
228 Ok(AutoCommitResult::Committed(outcome))
229}
230
231pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
232 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
233 .await
234 .ok()
235 .filter(|value| !value.is_empty() && value != "HEAD");
236 push_current_branch_with_name(project_root, branch.as_deref()).await
237}
238
239pub async fn push_current_branch_with_name(
240 project_root: &Path,
241 branch: Option<&str>,
242) -> Result<Option<PushOutcome>, String> {
243 if !command_exists("git") {
244 return Ok(None);
245 }
246
247 let Some(branch) = branch
248 .map(str::trim)
249 .filter(|value| !value.is_empty() && *value != "HEAD")
250 .map(ToOwned::to_owned)
251 else {
252 return Ok(None);
253 };
254
255 let remote = resolve_push_remote(project_root, &branch).await?;
256 let mut rebased = false;
257 let mut rewritten_for_large_files = false;
258
259 if remote_branch_has_unmerged_commits(project_root, &remote, &branch).await? {
264 println!(
265 "{} {}",
266 "Branch behind remote".bright_yellow().bold(),
267 format!("rebasing onto {}/{} before push...", remote, branch).bright_white()
268 );
269 pull_rebase(project_root, &remote, &branch)
270 .await
271 .map_err(|rebase_error| {
272 format!(
273 "`{branch}` is behind `{remote}/{branch}` and auto-rebase failed: {rebase_error}. Resolve conflicts, then `git push`."
274 )
275 })?;
276 rebased = true;
277 }
278
279 if rewrite_unpushed_history_without_oversized(project_root, &remote, &branch)
281 .await?
282 .rewritten
283 {
284 rewritten_for_large_files = true;
285 }
286
287 match attempt_push(project_root, &remote, &branch).await {
288 Ok(()) => {}
289 Err(push_error) if is_github_large_file_push_error(&push_error) => {
290 if rewrite_unpushed_history_without_oversized(project_root, &remote, &branch)
292 .await?
293 .rewritten
294 {
295 rewritten_for_large_files = true;
296 attempt_push(project_root, &remote, &branch)
297 .await
298 .map_err(|retry_error| {
299 summarize_git_push_error(&remote, &branch, &push_error, &retry_error)
300 })?;
301 } else {
302 return Err(summarize_git_push_error(
303 &remote,
304 &branch,
305 &push_error,
306 &push_error,
307 ));
308 }
309 }
310 Err(push_error) if is_non_fast_forward_push_error(&push_error) => {
311 println!(
312 "{} {}",
313 "Branch behind remote".bright_yellow().bold(),
314 format!("rebasing onto {}/{} then retrying push...", remote, branch)
315 .bright_white()
316 );
317 pull_rebase(project_root, &remote, &branch)
318 .await
319 .map_err(|rebase_error| {
320 format!(
321 "`{branch}` is behind `{remote}/{branch}` and auto-rebase failed: {rebase_error}. Resolve conflicts, then `git push`."
322 )
323 })?;
324 rebased = true;
325 if rewrite_unpushed_history_without_oversized(project_root, &remote, &branch)
326 .await?
327 .rewritten
328 {
329 rewritten_for_large_files = true;
330 }
331 attempt_push(project_root, &remote, &branch)
332 .await
333 .map_err(|retry_error| {
334 summarize_git_push_error(&remote, &branch, &push_error, &retry_error)
335 })?;
336 }
337 Err(push_error) => {
338 return Err(summarize_git_push_error(
339 &remote,
340 &branch,
341 &push_error,
342 &push_error,
343 ));
344 }
345 }
346
347 Ok(Some(PushOutcome {
348 remote,
349 branch,
350 rebased,
351 rewritten_for_large_files,
352 }))
353}
354
355async fn remote_branch_has_unmerged_commits(
357 project_root: &Path,
358 remote: &str,
359 branch: &str,
360) -> Result<bool, String> {
361 let _ = git_output(project_root, &["fetch", remote, branch]).await;
362 let remote_ref = format!("{remote}/{branch}");
363
364 if git_output(
366 project_root,
367 &["rev-parse", "--verify", "--quiet", &remote_ref],
368 )
369 .await
370 .is_err()
371 {
372 return Ok(false);
373 }
374
375 let count = git_output(
376 project_root,
377 &["rev-list", "--count", &format!("HEAD..{remote_ref}")],
378 )
379 .await
380 .unwrap_or_else(|_| "0".to_string());
381 Ok(count.trim().parse::<usize>().unwrap_or(0) > 0)
382}
383
384pub async fn sync_and_push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
388 push_current_branch(project_root).await
389}
390
391pub async fn pull_rebase_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
393 if !command_exists("git") {
394 return Ok(None);
395 }
396
397 let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
398 .await
399 .ok()
400 .filter(|value| !value.is_empty() && value != "HEAD");
401 let Some(branch) = branch else {
402 return Ok(None);
403 };
404
405 let remote = resolve_push_remote(project_root, &branch).await?;
406 println!(
407 "{} {}",
408 "Rebasing".bright_cyan().bold(),
409 format!("onto {}/{}...", remote, branch).bright_white()
410 );
411 pull_rebase(project_root, &remote, &branch).await?;
412 Ok(Some(PushOutcome {
413 remote,
414 branch,
415 rebased: true,
416 rewritten_for_large_files: false,
417 }))
418}
419
420pub fn print_skip(action_label: &str, reason: &str) {
421 println!(
422 "{} {} {}",
423 "Auto-commit".bright_yellow().bold(),
424 format!("skipped for {}", action_label).bright_white(),
425 format!("({})", reason).dimmed()
426 );
427}
428
429pub fn print_push_summary(outcome: &PushOutcome) {
430 let target = format!("{}/{}", outcome.remote, outcome.branch);
431 let mut notes = Vec::new();
432 if outcome.rebased {
433 notes.push("after auto-rebase");
434 }
435 if outcome.rewritten_for_large_files {
436 notes.push("rewrote unpushed history without oversized blobs");
437 }
438 if notes.is_empty() {
439 println!(
440 "{} {}",
441 "Pushed".bright_green().bold(),
442 target.bright_white()
443 );
444 } else {
445 println!(
446 "{} {} {}",
447 "Pushed".bright_green().bold(),
448 target.bright_white(),
449 format!("({})", notes.join("; ")).dimmed()
450 );
451 }
452}
453
454struct RewriteOutcome {
455 rewritten: bool,
456}
457
458async fn rewrite_unpushed_history_without_oversized(
461 project_root: &Path,
462 remote: &str,
463 branch: &str,
464) -> Result<RewriteOutcome, String> {
465 let remote_ref = format!("{remote}/{branch}");
466 if git_output(
467 project_root,
468 &["rev-parse", "--verify", "--quiet", &remote_ref],
469 )
470 .await
471 .is_err()
472 {
473 return Ok(RewriteOutcome { rewritten: false });
474 }
475
476 let ahead = git_output(
477 project_root,
478 &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
479 )
480 .await
481 .unwrap_or_else(|_| "0".to_string())
482 .trim()
483 .parse::<usize>()
484 .unwrap_or(0);
485 if ahead == 0 {
486 return Ok(RewriteOutcome { rewritten: false });
487 }
488
489 let oversized = find_oversized_blobs_in_range(project_root, &format!("{remote_ref}..HEAD")).await?;
490 if oversized.is_empty() {
491 return Ok(RewriteOutcome { rewritten: false });
492 }
493
494 let merge_count = git_output(
495 project_root,
496 &[
497 "rev-list",
498 "--count",
499 "--merges",
500 &format!("{remote_ref}..HEAD"),
501 ],
502 )
503 .await
504 .unwrap_or_else(|_| "0".to_string())
505 .trim()
506 .parse::<usize>()
507 .unwrap_or(0);
508 if merge_count > 0 {
509 return Err(format!(
510 "Unpushed history contains oversized files ({}) and {} merge commit(s). \
511XBP will not auto-rewrite merges. Soft-reset onto `{remote_ref}`, drop oversized blobs, then recommit/push.",
512 format_oversized_list(&oversized),
513 merge_count
514 ));
515 }
516
517 let subjects = git_output(
518 project_root,
519 &[
520 "log",
521 "--reverse",
522 "--pretty=format:%s",
523 &format!("{remote_ref}..HEAD"),
524 ],
525 )
526 .await
527 .unwrap_or_default();
528
529 println!(
530 "{} {}",
531 "Large files".bright_yellow().bold(),
532 format!(
533 "unpushed history has {} over GitHub's 100 MiB limit — rewriting {} commit(s) without those blobs...",
534 format_oversized_list(&oversized),
535 ahead
536 )
537 .bright_white()
538 );
539
540 git_output(project_root, &["reset", "--soft", &remote_ref]).await?;
541
542 let excluded = unstage_github_oversized_paths(project_root).await?;
543 for item in &oversized {
546 let _ = git_output(project_root, &["restore", "--staged", "--", &item.path]).await;
547 }
548
549 let staged = git_output(project_root, &["diff", "--cached", "--name-only"])
550 .await
551 .unwrap_or_default();
552 if staged.trim().is_empty() {
553 println!(
554 "{} {}",
555 "Large files".bright_yellow().bold(),
556 "after excluding oversized blobs there is nothing left to recommit; \
557local oversize files remain unstaged."
558 .bright_white()
559 );
560 return Ok(RewriteOutcome { rewritten: true });
561 }
562
563 let mut message = String::from("chore: rewrite unpushed commits without oversized blobs\n\n");
564 message.push_str("Dropped files at or above GitHub's 100 MiB limit:\n");
565 for item in excluded.iter().chain(oversized.iter()) {
566 message.push_str(&format!(
567 " - {} ({})\n",
568 item.path,
569 format_byte_size(item.size_bytes)
570 ));
571 }
572 if !subjects.trim().is_empty() {
573 message.push_str("\nOriginal unpushed subjects:\n");
574 for subject in subjects.lines().map(str::trim).filter(|s| !s.is_empty()) {
575 message.push_str(&format!(" - {subject}\n"));
576 }
577 }
578
579 ensure_git_commit_identity(project_root).await?;
581 let message_path =
582 std::env::temp_dir().join(format!("xbp-large-file-rewrite-{}.txt", std::process::id()));
583 std::fs::write(&message_path, &message).map_err(|e| {
584 format!(
585 "Failed to write temporary rewrite commit message {}: {e}",
586 message_path.display()
587 )
588 })?;
589 let commit_result = git_output(
590 project_root,
591 &[
592 "commit",
593 "--no-verify",
594 "--file",
595 &message_path.to_string_lossy(),
596 ],
597 )
598 .await;
599 let _ = std::fs::remove_file(&message_path);
600 commit_result?;
601
602 println!(
603 "{} {}",
604 "Rewrote".bright_green().bold(),
605 format!(
606 "unpushed history without oversized blobs ({})",
607 format_oversized_list(
608 &excluded
609 .into_iter()
610 .chain(oversized.into_iter())
611 .collect::<Vec<_>>()
612 )
613 )
614 .bright_white()
615 );
616
617 Ok(RewriteOutcome { rewritten: true })
618}
619
620pub async fn unstage_github_oversized_paths(
622 project_root: &Path,
623) -> Result<Vec<OversizedPath>, String> {
624 let oversized = find_oversized_staged_paths(project_root).await?;
625 for item in &oversized {
626 git_output(project_root, &["restore", "--staged", "--", &item.path]).await?;
627 println!(
628 "{} {} {}",
629 "Keeping oversized path unstaged:".bright_black(),
630 item.path.bright_white(),
631 format!("({})", format_byte_size(item.size_bytes)).dimmed()
632 );
633 }
634 Ok(oversized)
635}
636
637async fn find_oversized_staged_paths(project_root: &Path) -> Result<Vec<OversizedPath>, String> {
638 let names = git_output(
639 project_root,
640 &["diff", "--cached", "--name-only", "--diff-filter=dAMCR"],
641 )
642 .await
643 .unwrap_or_default();
644
645 let mut found = Vec::new();
646 for name in names.lines().map(str::trim).filter(|n| !n.is_empty()) {
647 let path = name.replace('\\', "/");
648 if let Some(size) = blob_size_in_index(project_root, &path).await? {
649 if size >= GITHUB_MAX_FILE_BYTES {
650 found.push(OversizedPath {
651 path,
652 size_bytes: size,
653 });
654 }
655 }
656 }
657 found.sort_by(|a, b| a.path.cmp(&b.path));
658 found.dedup_by(|a, b| a.path == b.path);
659 Ok(found)
660}
661
662async fn blob_size_in_index(project_root: &Path, path: &str) -> Result<Option<u64>, String> {
663 let worktree = project_root.join(path);
665 if let Ok(meta) = std::fs::metadata(&worktree) {
666 if meta.is_file() {
667 return Ok(Some(meta.len()));
668 }
669 }
670
671 let oid = match git_output(project_root, &["rev-parse", &format!(":{path}")]).await {
672 Ok(value) => value,
673 Err(_) => return Ok(None),
674 };
675 let size = git_output(project_root, &["cat-file", "-s", oid.trim()])
676 .await?
677 .trim()
678 .parse::<u64>()
679 .map_err(|e| format!("Invalid blob size for `{path}`: {e}"))?;
680 Ok(Some(size))
681}
682
683async fn find_oversized_blobs_in_range(
684 project_root: &Path,
685 range: &str,
686) -> Result<Vec<OversizedPath>, String> {
687 let objects = git_output(project_root, &["rev-list", "--objects", range])
688 .await
689 .unwrap_or_default();
690
691 let mut by_path: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
692 for line in objects.lines() {
693 let mut parts = line.split_whitespace();
694 let Some(oid) = parts.next() else {
695 continue;
696 };
697 let Some(path) = parts.next() else {
698 continue;
699 };
700 let path = path.replace('\\', "/");
701 let obj_type = match git_output(project_root, &["cat-file", "-t", oid]).await {
702 Ok(value) => value,
703 Err(_) => continue,
704 };
705 if obj_type.trim() != "blob" {
706 continue;
707 }
708 let size = match git_output(project_root, &["cat-file", "-s", oid]).await {
709 Ok(value) => value.trim().parse::<u64>().unwrap_or(0),
710 Err(_) => continue,
711 };
712 if size >= GITHUB_MAX_FILE_BYTES {
713 by_path
714 .entry(path)
715 .and_modify(|existing| *existing = (*existing).max(size))
716 .or_insert(size);
717 }
718 }
719
720 Ok(by_path
721 .into_iter()
722 .map(|(path, size_bytes)| OversizedPath { path, size_bytes })
723 .collect())
724}
725
726fn format_byte_size(bytes: u64) -> String {
727 const MIB: f64 = 1024.0 * 1024.0;
728 if bytes >= 1024 * 1024 {
729 format!("{:.2} MiB", bytes as f64 / MIB)
730 } else if bytes >= 1024 {
731 format!("{:.1} KiB", bytes as f64 / 1024.0)
732 } else {
733 format!("{bytes} B")
734 }
735}
736
737fn format_oversized_list(items: &[OversizedPath]) -> String {
738 if items.is_empty() {
739 return "(none)".to_string();
740 }
741 items
742 .iter()
743 .map(|item| format!("{} {}", item.path, format_byte_size(item.size_bytes)))
744 .collect::<Vec<_>>()
745 .join(", ")
746}
747
748fn print_oversized_excluded(items: &[OversizedPath], context: &str) {
749 println!(
750 "{} {}",
751 "Large files".bright_yellow().bold(),
752 format!(
753 "excluded from {context} (GitHub max 100 MiB): {}",
754 format_oversized_list(items)
755 )
756 .bright_white()
757 );
758}
759
760async fn attempt_push(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
761 match git_output(project_root, &["push"]).await {
762 Ok(_) => Ok(()),
763 Err(push_error) => {
764 git_output(project_root, &["push", "-u", remote, branch])
766 .await
767 .map(|_| ())
768 .map_err(|fallback_error| format!("{push_error}\n{fallback_error}"))
769 }
770 }
771}
772
773const AUTO_REBASE_STASH_MESSAGE: &str = "xbp auto-rebase: temporary stash";
774
775async fn pull_rebase(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
776 let _ = git_output(project_root, &["fetch", remote, branch]).await;
778
779 let stashed = stash_if_worktree_dirty(project_root).await?;
782
783 let rebase_result = git_output(project_root, &["pull", "--rebase", remote, branch])
784 .await
785 .map(|_| ())
786 .map_err(|error| enrich_rebase_error(&error));
787
788 let restore_result = if stashed {
789 restore_auto_rebase_stash(project_root).await
790 } else {
791 Ok(())
792 };
793
794 match (rebase_result, restore_result) {
795 (Ok(()), Ok(())) => Ok(()),
796 (Ok(()), Err(restore_error)) => Err(format!(
797 "Rebase onto `{remote}/{branch}` succeeded, but restoring the temporary stash failed: {restore_error}. \
798Look for a stash named `{AUTO_REBASE_STASH_MESSAGE}` (`git stash list`) and run `git stash pop`."
799 )),
800 (Err(rebase_error), Ok(())) => Err(rebase_error),
801 (Err(rebase_error), Err(restore_error)) => Err(format!(
802 "{rebase_error}; also failed to restore temporary stash: {restore_error}. \
803Look for a stash named `{AUTO_REBASE_STASH_MESSAGE}` (`git stash list`)."
804 )),
805 }
806}
807
808async fn worktree_is_dirty(project_root: &Path) -> Result<bool, String> {
809 let status = git_output(project_root, &["status", "--porcelain"]).await?;
810 Ok(!status.trim().is_empty())
811}
812
813async fn stash_if_worktree_dirty(project_root: &Path) -> Result<bool, String> {
815 if !worktree_is_dirty(project_root).await? {
816 return Ok(false);
817 }
818
819 println!(
820 "{} {}",
821 "Dirty worktree".bright_yellow().bold(),
822 "stashing unstaged/untracked WIP for auto-rebase...".bright_white()
823 );
824
825 let stash_output = git_output(
826 project_root,
827 &[
828 "stash",
829 "push",
830 "--include-untracked",
831 "-m",
832 AUTO_REBASE_STASH_MESSAGE,
833 ],
834 )
835 .await;
836
837 match stash_output {
838 Ok(output) if is_nothing_to_stash_message(&output) => Ok(false),
839 Ok(_) => Ok(true),
840 Err(error) if is_nothing_to_stash_message(&error) => Ok(false),
841 Err(error) => Err(format!(
842 "Failed to stash dirty worktree before auto-rebase: {error}"
843 )),
844 }
845}
846
847async fn restore_auto_rebase_stash(project_root: &Path) -> Result<(), String> {
848 if rebase_in_progress(project_root).await {
850 return Err(format!(
851 "rebase still in progress; temporary stash `{AUTO_REBASE_STASH_MESSAGE}` was left applied in the stash list"
852 ));
853 }
854
855 match git_output(project_root, &["stash", "pop"]).await {
856 Ok(_) => {
857 println!(
858 "{} {}",
859 "Restored".bright_green().bold(),
860 "temporary auto-rebase stash".bright_white()
861 );
862 Ok(())
863 }
864 Err(error) => Err(error),
865 }
866}
867
868async fn rebase_in_progress(project_root: &Path) -> bool {
869 git_output(project_root, &["rev-parse", "--verify", "REBASE_HEAD"])
871 .await
872 .is_ok()
873 || git_output(
874 project_root,
875 &["rev-parse", "--git-path", "rebase-merge"],
876 )
877 .await
878 .ok()
879 .map(|path| {
880 let p = project_root.join(path.trim());
881 std::path::Path::new(path.trim()).exists() || p.exists()
883 })
884 .unwrap_or(false)
885 || git_output(project_root, &["rev-parse", "--git-path", "rebase-apply"])
886 .await
887 .ok()
888 .map(|path| {
889 let p = project_root.join(path.trim());
890 std::path::Path::new(path.trim()).exists() || p.exists()
891 })
892 .unwrap_or(false)
893}
894
895fn is_nothing_to_stash_message(message: &str) -> bool {
896 let lowered = message.to_ascii_lowercase();
897 lowered.contains("no local changes to save") || lowered.contains("no changes to save")
898}
899
900fn is_dirty_worktree_rebase_error(error: &str) -> bool {
901 let lowered = error.to_ascii_lowercase();
902 (lowered.contains("cannot pull with rebase") || lowered.contains("cannot rebase"))
903 && (lowered.contains("unstaged changes")
904 || lowered.contains("uncommitted changes")
905 || lowered.contains("your index contains uncommitted changes")
906 || lowered.contains("please commit or stash"))
907}
908
909fn enrich_rebase_error(error: &str) -> String {
910 if is_dirty_worktree_rebase_error(error) {
911 format!(
912 "{error}. Dirty worktree blocked rebase even after stash attempt; \
913commit or stash unrelated WIP, then `git pull --rebase` and `git push`."
914 )
915 } else {
916 error.to_string()
917 }
918}
919
920pub async fn resolve_push_remote(project_root: &Path, branch: &str) -> Result<String, String> {
924 if let Ok(configured) = git_output(
925 project_root,
926 &["config", "--get", &format!("branch.{branch}.remote")],
927 )
928 .await
929 {
930 let trimmed = configured.trim();
931 if !trimmed.is_empty() {
932 return Ok(trimmed.to_string());
933 }
934 }
935
936 if let Ok(upstream) = git_output(
937 project_root,
938 &[
939 "rev-parse",
940 "--abbrev-ref",
941 "--symbolic-full-name",
942 "@{upstream}",
943 ],
944 )
945 .await
946 {
947 if let Some(remote) = split_remote_branch(upstream.trim()).map(|(remote, _)| remote) {
948 return Ok(remote);
949 }
950 }
951
952 let remotes = git_output(project_root, &["remote"]).await.unwrap_or_default();
953 let remote_names = remotes
954 .lines()
955 .map(str::trim)
956 .filter(|line| !line.is_empty())
957 .map(ToOwned::to_owned)
958 .collect::<Vec<_>>();
959
960 if remote_names.iter().any(|name| name == "origin") {
961 return Ok("origin".to_string());
962 }
963
964 remote_names.into_iter().next().ok_or_else(|| {
965 "No git remotes are configured for this repository. Add one with `git remote add <name> <url>`."
966 .to_string()
967 })
968}
969
970fn split_remote_branch(upstream: &str) -> Option<(String, String)> {
971 let (remote, branch) = upstream.split_once('/')?;
972 let remote = remote.trim();
973 let branch = branch.trim();
974 if remote.is_empty() || branch.is_empty() {
975 return None;
976 }
977 Some((remote.to_string(), branch.to_string()))
978}
979
980fn is_github_large_file_push_error(error: &str) -> bool {
981 let corpus = error.to_ascii_lowercase();
982 corpus.contains("gh001")
983 || corpus.contains("large files detected")
984 || corpus.contains("exceeds github's file size limit")
985 || corpus.contains("this exceeds github's file size limit")
986 || (corpus.contains("file ") && corpus.contains("mb") && corpus.contains("file size limit"))
987 || corpus.contains("git-lfs.github.com")
988}
989
990fn is_non_fast_forward_push_error(error: &str) -> bool {
991 let corpus = error.to_ascii_lowercase();
992 if is_github_large_file_push_error(&corpus) {
995 return false;
996 }
997 if corpus.contains("remote rejected") && !corpus.contains("non-fast-forward") {
998 if !corpus.contains("tip of your current branch is behind")
1000 && !corpus.contains("fetch first")
1001 {
1002 return false;
1003 }
1004 }
1005 corpus.contains("non-fast-forward")
1006 || corpus.contains("tip of your current branch is behind")
1007 || (corpus.contains("failed to push some refs")
1008 && (corpus.contains("behind")
1009 || corpus.contains("non-fast-forward")
1010 || corpus.contains("fetch first")
1011 || (corpus.contains("rejected")
1012 && !corpus.contains("remote rejected")
1013 && !corpus.contains("pre-receive hook declined"))))
1014}
1015
1016fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
1017 let branch = outcome
1018 .branch
1019 .as_deref()
1020 .map(|value| format!(" on {}", value.bright_blue()))
1021 .unwrap_or_default();
1022 let files = if outcome.committed_files.is_empty() {
1023 "(none)".dimmed().to_string()
1024 } else {
1025 outcome
1026 .committed_files
1027 .iter()
1028 .map(|value| value.bright_white().to_string())
1029 .collect::<Vec<_>>()
1030 .join(", ")
1031 };
1032
1033 println!(
1034 "{} {}{}",
1035 "Auto-commit".bright_green().bold(),
1036 format!("created for {}", action_label).bright_white(),
1037 branch
1038 );
1039 println!(
1040 " {} {} {}",
1041 "Repo".bright_cyan().bold(),
1042 outcome.repo_name.bright_white().bold(),
1043 format!("({})", outcome.repo_root.display()).dimmed()
1044 );
1045 println!(
1046 " {} {} {}",
1047 "Commit".bright_cyan().bold(),
1048 outcome.short_sha.bright_green().bold(),
1049 format!("({})", outcome.commit_sha).dimmed()
1050 );
1051 println!(
1052 " {} {}",
1053 "Message".bright_cyan().bold(),
1054 outcome.message.bright_magenta()
1055 );
1056 println!(" {} {}", "Files".bright_cyan().bold(), files);
1057}
1058
1059async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
1060 let owned_args = args
1061 .iter()
1062 .map(|value| value.to_string())
1063 .collect::<Vec<_>>();
1064 git_output_owned(project_root, owned_args).await
1065}
1066
1067async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
1068 let output = Command::new("git")
1069 .current_dir(project_root)
1070 .args(&args)
1071 .output()
1072 .await
1073 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
1074
1075 if !output.status.success() {
1076 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1077 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1078 let detail = [stderr, stdout]
1080 .into_iter()
1081 .filter(|s| !s.is_empty())
1082 .collect::<Vec<_>>()
1083 .join("\n");
1084 if detail.is_empty() {
1085 return Err(format!(
1086 "`git {}` failed with status {}",
1087 args.join(" "),
1088 output.status
1089 ));
1090 }
1091 return Err(format!("`git {}` failed: {}", args.join(" "), detail));
1092 }
1093
1094 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1095}
1096
1097async fn commit_with_optional_hook_retry(
1098 project_root: &Path,
1099 message: &str,
1100 committed_files: &[String],
1101) -> Result<(), String> {
1102 match run_commit_with_pathspec(project_root, message, committed_files, false).await {
1103 Ok(_) => Ok(()),
1104 Err(error) => {
1105 let hook_failure = is_commit_hook_failure(&error);
1106 if !hook_failure {
1107 return Err(error);
1108 }
1109
1110 if is_xbp_managed_commit(committed_files) {
1113 println!(
1114 "{} {}",
1115 "Hooks".bright_yellow().bold(),
1116 "failed on XBP-managed files only — retrying with hooks disabled (--no-verify)."
1117 .bright_white()
1118 );
1119 return run_commit_with_pathspec(project_root, message, committed_files, true).await;
1120 }
1121
1122 if is_missing_lefthook_error(&error)
1123 || is_husky_or_pre_commit_hook_error(&error)
1124 {
1125 if std::io::stdin().is_terminal() {
1126 let prompt = if is_missing_lefthook_error(&error) {
1127 "Commit hooks failed to resolve lefthook. Retry once with hooks disabled?"
1128 } else {
1129 "Pre-commit / husky hooks failed. Retry this XBP commit with hooks disabled?"
1130 };
1131 let retry = Confirm::with_theme(&ColorfulTheme::default())
1132 .with_prompt(prompt)
1133 .default(true)
1134 .interact()
1135 .map_err(|e| format!("Failed to read retry choice: {}", e))?;
1136 if retry {
1137 println!(
1138 "{} {}",
1139 "Hooks".bright_yellow().bold(),
1140 "disabled for this commit (--no-verify, HUSKY=0, LEFTHOOK=0)."
1141 .bright_white()
1142 );
1143 return run_commit_with_pathspec(
1144 project_root,
1145 message,
1146 committed_files,
1147 true,
1148 )
1149 .await;
1150 }
1151 } else {
1152 println!(
1155 "{} {}",
1156 "Hooks".bright_yellow().bold(),
1157 "failed in non-interactive mode — retrying with hooks disabled."
1158 .bright_white()
1159 );
1160 return run_commit_with_pathspec(project_root, message, committed_files, true)
1161 .await;
1162 }
1163 }
1164
1165 Err(error)
1166 }
1167 }
1168}
1169
1170async fn run_commit_with_pathspec(
1172 project_root: &Path,
1173 message: &str,
1174 committed_files: &[String],
1175 no_verify: bool,
1176) -> Result<(), String> {
1177 if committed_files.is_empty() {
1178 return Err("No paths provided for pathspec commit.".to_string());
1179 }
1180
1181 let mut args = Vec::new();
1182 if no_verify {
1183 args.push("commit".to_string());
1184 args.push("--no-verify".to_string());
1185 } else {
1186 args.push("commit".to_string());
1187 }
1188 args.push("-m".to_string());
1189 args.push(message.to_string());
1190 args.push("--".to_string());
1191 args.extend(committed_files.iter().cloned());
1192
1193 let mut command = Command::new("git");
1194 command.current_dir(project_root).args(&args);
1195 if no_verify {
1196 command.env("HUSKY", "0").env("LEFTHOOK", "0");
1198 }
1199
1200 let output = command.output().await.map_err(|e| {
1201 format!(
1202 "Failed to run `git commit{} -m … -- <paths>`: {}",
1203 if no_verify { " --no-verify" } else { "" },
1204 e
1205 )
1206 })?;
1207
1208 if !output.status.success() {
1209 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1210 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1211 let detail = [stderr, stdout]
1212 .into_iter()
1213 .filter(|s| !s.is_empty())
1214 .collect::<Vec<_>>()
1215 .join("\n");
1216 if detail.is_empty() {
1217 return Err(format!(
1218 "`git commit{} -m …` failed with status {}",
1219 if no_verify { " --no-verify" } else { "" },
1220 output.status
1221 ));
1222 }
1223 return Err(format!(
1224 "`git commit{} -m …` failed: {}",
1225 if no_verify { " --no-verify" } else { "" },
1226 detail
1227 ));
1228 }
1229
1230 Ok(())
1231}
1232
1233fn is_xbp_managed_commit(committed_files: &[String]) -> bool {
1235 !committed_files.is_empty()
1236 && committed_files.iter().all(|path| {
1237 let normalized = path.replace('\\', "/");
1238 normalized == ".xbp"
1239 || normalized.starts_with(".xbp/")
1240 || crate::utils::is_xbp_project_config_filename(&normalized)
1241 || normalized == "pnpm-workspace.yaml"
1242 || normalized == "package.json"
1243 || normalized.ends_with("/package.json")
1244 || normalized.ends_with("/pnpm-workspace.yaml")
1245 })
1246}
1247
1248fn is_commit_hook_failure(error: &str) -> bool {
1249 let lower = error.to_ascii_lowercase();
1250 lower.contains("pre-commit")
1251 || lower.contains("husky")
1252 || lower.contains("lefthook")
1253 || lower.contains("hook")
1254 || lower.contains("ultracite")
1255 || lower.contains("lint-staged")
1256 || lower.contains("hook exited")
1257 || lower.contains("script failed")
1258}
1259
1260fn is_husky_or_pre_commit_hook_error(error: &str) -> bool {
1261 let lower = error.to_ascii_lowercase();
1262 lower.contains("husky")
1263 || lower.contains("pre-commit")
1264 || lower.contains("ultracite")
1265 || lower.contains("lint-staged")
1266 || (lower.contains("hook") && lower.contains("failed"))
1267}
1268
1269async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
1270 let current_name = git_output(project_root, &["config", "--get", "user.name"])
1271 .await
1272 .ok()
1273 .map(|value| value.trim().to_string())
1274 .filter(|value| !value.is_empty());
1275 let current_email = git_output(project_root, &["config", "--get", "user.email"])
1276 .await
1277 .ok()
1278 .map(|value| value.trim().to_string())
1279 .filter(|value| !value.is_empty());
1280
1281 if current_name.is_some() && current_email.is_some() {
1282 return Ok(());
1283 }
1284
1285 let suggested = SshConfig::load()
1286 .ok()
1287 .and_then(|config| config.cli_auth)
1288 .and_then(|auth| match (auth.user_name, auth.user_email) {
1289 (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
1290 Some((name, email))
1291 }
1292 _ => None,
1293 });
1294
1295 if !std::io::stdin().is_terminal() {
1296 return Err(identity_setup_hint(
1297 current_name.as_deref(),
1298 current_email.as_deref(),
1299 suggested
1300 .as_ref()
1301 .map(|(name, email)| (name.as_str(), email.as_str())),
1302 ));
1303 }
1304
1305 let prefill_name = suggested
1306 .as_ref()
1307 .map(|(name, _)| name.clone())
1308 .or_else(|| current_name.clone())
1309 .unwrap_or_default();
1310 let prefill_email = suggested
1311 .as_ref()
1312 .map(|(_, email)| email.clone())
1313 .or_else(|| current_email.clone())
1314 .unwrap_or_default();
1315
1316 let mut configured_name = current_name;
1317 let mut configured_email = current_email;
1318
1319 if configured_name.is_none() {
1320 let name: String = Input::with_theme(&ColorfulTheme::default())
1321 .with_prompt("Git commit author name")
1322 .with_initial_text(prefill_name)
1323 .interact_text()
1324 .map_err(|e| format!("Failed to read git author name: {}", e))?;
1325 let trimmed = name.trim().to_string();
1326 if trimmed.is_empty() {
1327 return Err("Git commit author name cannot be empty.".to_string());
1328 }
1329 git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
1330 configured_name = Some(trimmed);
1331 }
1332
1333 if configured_email.is_none() {
1334 let email: String = Input::with_theme(&ColorfulTheme::default())
1335 .with_prompt("Git commit author email")
1336 .with_initial_text(prefill_email)
1337 .interact_text()
1338 .map_err(|e| format!("Failed to read git author email: {}", e))?;
1339 let trimmed = email.trim().to_string();
1340 if trimmed.is_empty() {
1341 return Err("Git commit author email cannot be empty.".to_string());
1342 }
1343 git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
1344 configured_email = Some(trimmed);
1345 }
1346
1347 if configured_name.is_some() && configured_email.is_some() {
1348 Ok(())
1349 } else {
1350 Err("Git commit identity is still incomplete after prompting.".to_string())
1351 }
1352}
1353
1354async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
1355 if is_missing_git_identity_error(error) {
1356 let suggested = SshConfig::load()
1357 .ok()
1358 .and_then(|config| config.cli_auth)
1359 .and_then(|auth| match (auth.user_name, auth.user_email) {
1360 (Some(name), Some(email))
1361 if !name.trim().is_empty() && !email.trim().is_empty() =>
1362 {
1363 Some((name, email))
1364 }
1365 _ => None,
1366 });
1367
1368 return identity_setup_hint(
1369 git_output(project_root, &["config", "--get", "user.name"])
1370 .await
1371 .ok()
1372 .as_deref(),
1373 git_output(project_root, &["config", "--get", "user.email"])
1374 .await
1375 .ok()
1376 .as_deref(),
1377 suggested
1378 .as_ref()
1379 .map(|(name, email)| (name.as_str(), email.as_str())),
1380 );
1381 }
1382
1383 if is_missing_lefthook_error(error) {
1384 return format!(
1385 "The commit hook tried to load lefthook from the current repo but the module was missing.\n\
1386Run `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\
1387If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0` / `HUSKY=0` or `git commit --no-verify`.\n\
1388Raw error: {error}"
1389 );
1390 }
1391
1392 if is_husky_or_pre_commit_hook_error(error) {
1393 let husky_hint = if project_root.join(".husky").is_dir() {
1394 "\nThis repo has a `.husky/` pre-commit hook. Common blockers: broken shebang order, \
1395`pnpm test` on every commit, or formatters (ultracite/biome) scanning the whole tree."
1396 } else {
1397 ""
1398 };
1399 return format!(
1400 "Git commit was blocked by a pre-commit hook (husky/lefthook/lint).\n\
1401XBP-only commits under `.xbp/` are retried automatically with hooks disabled.\n\
1402To 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\
1403Workaround: `HUSKY=0 git commit --no-verify` (or `LEFTHOOK=0`).{husky_hint}\n\
1404Raw error: {error}"
1405 );
1406 }
1407
1408 format!("Git commit failed: {}", error)
1409}
1410
1411fn identity_setup_hint(
1412 current_name: Option<&str>,
1413 current_email: Option<&str>,
1414 suggested: Option<(&str, &str)>,
1415) -> String {
1416 let mut lines = vec![
1417 "Git blocked the commit because your author identity is not configured in this environment."
1418 .to_string(),
1419 ];
1420 if current_name.is_none() {
1421 lines.push("Missing `user.name`.".to_string());
1422 }
1423 if current_email.is_none() {
1424 lines.push("Missing `user.email`.".to_string());
1425 }
1426 if let Some((name, email)) = suggested {
1427 lines.push(format!(
1428 "XBP found a likely identity to prefill: {} <{}>",
1429 name, email
1430 ));
1431 lines.push(format!("Run `git config --local user.name \"{}\"`", name));
1432 lines.push(format!("Run `git config --local user.email \"{}\"`", email));
1433 } else {
1434 lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
1435 .to_string());
1436 lines.push(
1437 "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
1438 );
1439 }
1440 lines.join("\n")
1441}
1442
1443fn is_missing_git_identity_error(error: &str) -> bool {
1444 error.contains("Author identity unknown")
1445 || error.contains("empty ident name")
1446 || error.contains("Please tell me who you are")
1447}
1448
1449fn is_missing_lefthook_error(error: &str) -> bool {
1450 let lower = error.to_ascii_lowercase();
1451 (lower.contains("lefthook") && lower.contains("module not found"))
1452 || (lower.contains("lefthook") && lower.contains("cannot find module"))
1453}
1454
1455fn normalize_commit_paths(
1465 repo_root: &Path,
1466 project_root: &Path,
1467 paths: &[PathBuf],
1468) -> Vec<String> {
1469 let mut deduped = BTreeSet::new();
1470
1471 let nested_prefix = strip_project_root_prefix(repo_root, project_root)
1472 .or_else(|| {
1473 project_root
1474 .strip_prefix(repo_root)
1475 .ok()
1476 .map(|p| p.to_path_buf())
1477 })
1478 .map(|p| p.to_string_lossy().replace('\\', "/"))
1479 .map(|s| s.trim_matches('/').to_string())
1480 .filter(|s| !s.is_empty() && s != ".");
1481
1482 for path in paths {
1483 if path.as_os_str().is_empty() {
1484 continue;
1485 }
1486
1487 let Some(rendered) =
1488 path_to_repo_relative_pathspec(repo_root, project_root, nested_prefix.as_deref(), path)
1489 else {
1490 continue;
1491 };
1492
1493 let trimmed = rendered.trim();
1494 if !trimmed.is_empty() && trimmed != "." {
1495 deduped.insert(trimmed.to_string());
1496 }
1497 }
1498
1499 deduped.into_iter().collect()
1500}
1501
1502fn path_to_repo_relative_pathspec(
1503 repo_root: &Path,
1504 project_root: &Path,
1505 nested_prefix: Option<&str>,
1506 path: &Path,
1507) -> Option<String> {
1508 if path.is_absolute() {
1510 if let Ok(relative) = path.strip_prefix(repo_root) {
1511 return Some(relative.to_string_lossy().replace('\\', "/"));
1512 }
1513 if let Some(relative) = strip_project_root_prefix(repo_root, path) {
1514 return Some(relative.to_string_lossy().replace('\\', "/"));
1515 }
1516 if let Ok(relative) = path.strip_prefix(project_root) {
1518 return Some(join_nested_prefix(
1519 nested_prefix,
1520 &relative.to_string_lossy().replace('\\', "/"),
1521 ));
1522 }
1523 if let Some(relative) = strip_project_root_prefix(project_root, path) {
1524 return Some(join_nested_prefix(
1525 nested_prefix,
1526 &relative.to_string_lossy().replace('\\', "/"),
1527 ));
1528 }
1529 return Some(path.to_string_lossy().replace('\\', "/"));
1531 }
1532
1533 let relative = path.to_string_lossy().replace('\\', "/");
1534 let relative = relative.trim_start_matches("./");
1535 if relative.is_empty() || relative == "." {
1536 return None;
1537 }
1538
1539 if let Some(prefix) = nested_prefix {
1543 if relative == prefix || relative.starts_with(&format!("{prefix}/")) {
1544 return Some(relative.to_string());
1545 }
1546 return Some(join_nested_prefix(Some(prefix), relative));
1547 }
1548
1549 Some(relative.to_string())
1551}
1552
1553fn join_nested_prefix(nested_prefix: Option<&str>, relative: &str) -> String {
1554 let relative = relative.trim_start_matches('/');
1555 match nested_prefix {
1556 Some(prefix) if !relative.is_empty() => format!("{prefix}/{relative}"),
1557 Some(prefix) => prefix.to_string(),
1558 None => relative.to_string(),
1559 }
1560}
1561
1562fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
1563 let root = project_root
1564 .to_string_lossy()
1565 .replace('\\', "/")
1566 .trim_end_matches('/')
1567 .to_string();
1568 let candidate = path.to_string_lossy().replace('\\', "/");
1569
1570 if candidate.len() <= root.len() {
1571 return None;
1572 }
1573
1574 let (prefix, suffix) = candidate.split_at(root.len());
1575 if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
1576 return Some(PathBuf::from(suffix.trim_start_matches('/')));
1577 }
1578
1579 None
1580}
1581
1582pub fn summarize_git_push_error(
1583 remote: &str,
1584 branch: &str,
1585 primary_error: &str,
1586 fallback_error: &str,
1587) -> String {
1588 let corpus = format!("{primary_error}\n{fallback_error}");
1589 let corpus_lower = corpus.to_ascii_lowercase();
1590
1591 if is_github_large_file_push_error(&corpus_lower) {
1594 return summarize_github_large_file_error(primary_error, fallback_error);
1595 }
1596
1597 if is_non_fast_forward_push_error(&corpus_lower) {
1598 return format!(
1599 "`{branch}` is behind `{remote}/{branch}`. Run `git pull --rebase {remote} {branch}`, then `git push`."
1600 );
1601 }
1602
1603 if corpus_lower.contains("authentication failed")
1604 || corpus_lower.contains("could not read username")
1605 || corpus_lower.contains("403")
1606 || corpus_lower.contains("401")
1607 {
1608 return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
1609 .to_string();
1610 }
1611
1612 let lines = dedupe_git_error_lines(primary_error, fallback_error);
1613 lines
1614 .into_iter()
1615 .last()
1616 .unwrap_or_else(|| "git push failed".to_string())
1617}
1618
1619fn summarize_github_large_file_error(primary_error: &str, fallback_error: &str) -> String {
1620 let lines = dedupe_git_error_lines(primary_error, fallback_error);
1621 let detail = lines
1622 .iter()
1623 .find(|line| {
1624 let lower = line.to_ascii_lowercase();
1625 lower.contains("file ")
1626 && (lower.contains("mb") || lower.contains("exceeds") || lower.contains("gh001"))
1627 })
1628 .cloned()
1629 .or_else(|| lines.last().cloned())
1630 .unwrap_or_else(|| "GitHub rejected a blob over 100 MiB".to_string());
1631
1632 format!(
1633 "GitHub rejected the push because a file exceeds the 100 MiB limit ({detail}). \
1634XBP auto-excludes oversized files from new commits and rewrites *unpushed* history when possible. \
1635If this persists, remove the blob from unpushed commits (`git reset --soft origin/<branch>`, unstage the file, recommit) or use Git LFS."
1636 )
1637}
1638
1639fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
1640 let mut seen = BTreeSet::new();
1641 let mut lines = Vec::new();
1642
1643 for line in primary_error.lines().chain(fallback_error.lines()) {
1644 let trimmed = line.trim();
1645 if trimmed.is_empty() || trimmed.starts_with("hint:") {
1646 continue;
1647 }
1648 let key = trimmed.to_ascii_lowercase();
1649 if seen.insert(key) {
1650 lines.push(trimmed.to_string());
1651 }
1652 }
1653
1654 lines
1655}
1656
1657fn repo_name(path: &Path) -> String {
1658 path.file_name()
1659 .and_then(|value| value.to_str())
1660 .filter(|value| !value.trim().is_empty())
1661 .unwrap_or("repository")
1662 .to_string()
1663}
1664
1665#[cfg(test)]
1666mod tests {
1667 use super::{
1668 enrich_rebase_error, explicit_push_requested, is_commit_hook_failure,
1669 is_dirty_worktree_rebase_error, is_github_large_file_push_error,
1670 is_husky_or_pre_commit_hook_error, is_missing_git_identity_error,
1671 is_missing_lefthook_error, is_nothing_to_stash_message, is_non_fast_forward_push_error,
1672 is_xbp_managed_commit, normalize_commit_paths, reset_explicit_push, set_explicit_push,
1673 split_remote_branch, summarize_git_push_error,
1674 };
1675 use std::path::{Path, PathBuf};
1676
1677 #[test]
1678 fn xbp_managed_commit_detects_config_only_paths() {
1679 assert!(is_xbp_managed_commit(&[".xbp/xbp.yaml".to_string()]));
1680 assert!(is_xbp_managed_commit(&[
1681 ".xbp/xbp.yaml".to_string(),
1682 "package.json".to_string(),
1683 "pnpm-workspace.yaml".to_string(),
1684 ]));
1685 assert!(!is_xbp_managed_commit(&[
1686 ".xbp/xbp.yaml".to_string(),
1687 "src/app.ts".to_string(),
1688 ]));
1689 assert!(!is_xbp_managed_commit(&[]));
1690 }
1691
1692 #[test]
1693 fn detects_husky_pre_commit_failures() {
1694 assert!(is_commit_hook_failure(
1695 "husky - pre-commit script failed (code 1)"
1696 ));
1697 assert!(is_husky_or_pre_commit_hook_error(
1698 "`git commit -m chore` failed: husky - pre-commit script failed (code 1)"
1699 ));
1700 assert!(is_commit_hook_failure(
1701 "Ultracite found issues that could not be auto-fixed."
1702 ));
1703 }
1704
1705 #[test]
1706 fn normalizes_commit_paths_relative_to_project_root() {
1707 let repo_root = Path::new("C:/repo");
1708 let project_root = Path::new("C:/repo");
1709 let paths = vec![
1710 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
1711 PathBuf::from("C:/repo/.xbp/xbp.yaml"),
1712 PathBuf::from("CHANGELOG.md"),
1713 ];
1714
1715 let normalized = normalize_commit_paths(repo_root, project_root, &paths);
1716
1717 assert_eq!(
1718 normalized,
1719 vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
1720 );
1721 }
1722
1723 #[test]
1724 fn normalizes_nested_package_paths_to_monorepo_relative_pathspecs() {
1725 let repo_root = Path::new("C:/repo");
1728 let project_root = Path::new("C:/repo/packages/athena-js");
1729 let paths = vec![
1730 PathBuf::from("C:/repo/packages/athena-js/package.json"),
1731 PathBuf::from("package.json"),
1732 PathBuf::from(".xbp/xbp.yaml"),
1733 PathBuf::from("README.md"),
1734 PathBuf::from("package-lock.json"),
1735 ];
1736
1737 let normalized = normalize_commit_paths(repo_root, project_root, &paths);
1738
1739 assert_eq!(
1740 normalized,
1741 vec![
1742 "packages/athena-js/.xbp/xbp.yaml".to_string(),
1743 "packages/athena-js/README.md".to_string(),
1744 "packages/athena-js/package-lock.json".to_string(),
1745 "packages/athena-js/package.json".to_string(),
1746 ]
1747 );
1748 }
1749
1750 #[test]
1751 fn keeps_already_monorepo_relative_nested_paths() {
1752 let repo_root = Path::new("C:/repo");
1753 let project_root = Path::new("C:/repo/packages/athena-js");
1754 let paths = vec![PathBuf::from("packages/athena-js/package.json")];
1755
1756 let normalized = normalize_commit_paths(repo_root, project_root, &paths);
1757
1758 assert_eq!(
1759 normalized,
1760 vec!["packages/athena-js/package.json".to_string()]
1761 );
1762 }
1763
1764 #[test]
1765 fn detects_missing_git_identity_errors() {
1766 assert!(is_missing_git_identity_error(
1767 "Author identity unknown\n*** Please tell me who you are."
1768 ));
1769 }
1770
1771 #[test]
1772 fn detects_lefthook_module_errors() {
1773 assert!(is_missing_lefthook_error(
1774 "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
1775 ));
1776 }
1777
1778 #[test]
1779 fn summarizes_non_fast_forward_push_errors_without_git_hints() {
1780 let primary = "! [rejected] main -> main (non-fast-forward)";
1781 let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
1782hint: Updates were rejected because the tip of your current branch is behind
1783hint: its remote counterpart. If you want to integrate the remote changes,
1784hint: use 'git pull' before pushing again."#;
1785
1786 let summary = summarize_git_push_error("origin", "main", primary, fallback);
1787
1788 assert!(summary.contains("behind `origin/main`"));
1789 assert!(summary.contains("git pull --rebase origin main"));
1790 assert!(!summary.contains("hint:"));
1791 assert!(!summary.contains("git push -u origin main"));
1792 }
1793
1794 #[test]
1795 fn summarizes_behind_errors_with_detected_remote_name() {
1796 let primary = "! [rejected] cleanup-sql -> cleanup-sql (non-fast-forward)";
1797 let fallback = "error: failed to push some refs to 'https://github.com/acme/speedrun-formations.git'";
1798
1799 let summary = summarize_git_push_error("origin", "cleanup-sql", primary, fallback);
1800
1801 assert!(summary.contains("cleanup-sql"));
1802 assert!(summary.contains("git pull --rebase origin cleanup-sql"));
1803 }
1804
1805 #[test]
1806 fn detects_dirty_worktree_rebase_errors() {
1807 assert!(is_dirty_worktree_rebase_error(
1808 "error: cannot pull with rebase: You have unstaged changes.\nerror: Please commit or stash them."
1809 ));
1810 assert!(is_dirty_worktree_rebase_error(
1811 "`git pull --rebase origin main` failed: error: cannot pull with rebase: You have unstaged changes."
1812 ));
1813 assert!(!is_dirty_worktree_rebase_error(
1814 "CONFLICT (content): Merge conflict in Cargo.toml"
1815 ));
1816 assert!(is_nothing_to_stash_message("No local changes to save"));
1817 let enriched = enrich_rebase_error(
1818 "error: cannot pull with rebase: You have unstaged changes.",
1819 );
1820 assert!(enriched.contains("Dirty worktree blocked rebase"));
1821 }
1822
1823 #[test]
1824 fn detects_non_fast_forward_push_errors() {
1825 assert!(is_non_fast_forward_push_error(
1826 "! [rejected] main -> main (non-fast-forward)"
1827 ));
1828 assert!(is_non_fast_forward_push_error(
1829 "failed to push some refs\ntip of your current branch is behind"
1830 ));
1831 assert!(!is_non_fast_forward_push_error(
1832 "authentication failed for 'https://github.com/acme/repo.git'"
1833 ));
1834 let gh001 = r#"remote: error: File foundry/instagram.db is 132.42 MB; this exceeds GitHub's file size limit of 100.00 MB
1836remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com.
1837! [remote rejected] main -> main (pre-receive hook declined)
1838error: failed to push some refs to 'https://github.com/acme/repo.git'"#;
1839 assert!(is_github_large_file_push_error(gh001));
1840 assert!(!is_non_fast_forward_push_error(gh001));
1841 let summary = summarize_git_push_error("origin", "main", gh001, gh001);
1842 assert!(summary.to_ascii_lowercase().contains("100 mib"));
1843 assert!(!summary.contains("behind `origin/main`"));
1844 }
1845
1846 #[test]
1847 fn summarize_prefers_large_file_error_after_non_fast_forward() {
1848 let primary = "! [rejected] main -> main (non-fast-forward)";
1849 let fallback = r#"remote: error: File foundry/instagram.db is 132.42 MB; this exceeds GitHub's file size limit of 100.00 MB
1850remote: error: GH001: Large files detected.
1851! [remote rejected] main -> main (pre-receive hook declined)
1852error: failed to push some refs"#;
1853 let summary = summarize_git_push_error("origin", "main", primary, fallback);
1854 assert!(summary.to_ascii_lowercase().contains("100 mib") || summary.contains("GH001") || summary.contains("instagram.db"));
1855 assert!(!summary.contains("behind `origin/main`"));
1856 }
1857
1858 #[test]
1859 fn explicit_push_flag_tracks_cli_override() {
1860 reset_explicit_push();
1861 assert!(!explicit_push_requested());
1862 set_explicit_push(true);
1863 assert!(explicit_push_requested());
1864 reset_explicit_push();
1865 assert!(!explicit_push_requested());
1866 }
1867
1868 #[test]
1869 fn splits_upstream_into_remote_and_branch() {
1870 assert_eq!(
1871 split_remote_branch("origin/cleanup-sql"),
1872 Some(("origin".to_string(), "cleanup-sql".to_string()))
1873 );
1874 assert_eq!(
1875 split_remote_branch("upstream/feature/foo"),
1876 Some(("upstream".to_string(), "feature/foo".to_string()))
1877 );
1878 assert_eq!(split_remote_branch("origin"), None);
1879 }
1880}