Skip to main content

xbp_cli/cli/
auto_commit.rs

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
17/// Record `--push` from the root CLI or a leaf command such as `xbp commit --push`.
18pub 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    /// Force push after commit regardless of config (used by release workflows).
49    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    /// Unpushed history was rewritten to drop GitHub-oversized blobs before push.
72    pub rewritten_for_large_files: bool,
73}
74
75/// GitHub pre-receive hard limit for a single blob (100 MiB).
76pub 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    // Always operate from the git toplevel. Nested xbp project roots (e.g.
92    // packages/athena-js with its own .xbp/) resolve show-toplevel to the monorepo,
93    // and `git diff --cached --name-only` returns paths relative to that toplevel —
94    // not relative to the nested package cwd. Using those monorepo pathspecs with
95    // cwd=package then fails: pathspec 'packages/foo/bar' did not match.
96    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    // Never ship blobs GitHub will reject; leave them local/unstaged.
119    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    // Pathspec commit: only these files land in the commit, even if other paths
159    // (e.g. leftover staged `.xbp/deployments/*`) were already in the index.
160    // Pathspecs and cwd both use repo_root so monorepo-relative names match.
161    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    // Match `xbp commit`: fire Discord commit webhook for auto-commits too.
204    {
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    // Proactive integrate when behind/diverged. Avoids:
260    // - non-fast-forward push rejection
261    // - plain `git pull` abort under `pull.ff=only`
262    // Dirty worktrees are stashed inside `pull_rebase`.
263    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    // Drop oversize blobs from *unpushed* commits before GitHub rejects the push.
280    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            // Last resort: rewrite then retry once (covers races / missed scans).
291            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
355/// Fetch and report whether `{remote}/{branch}` has commits not in HEAD.
356async 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    // Missing remote-tracking ref → nothing to integrate yet.
365    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
384/// Sync current branch with its remote (stash-safe rebase when needed) and push.
385///
386/// Intended for the standalone `xbp push` command and other "just ship commits" flows.
387pub async fn sync_and_push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
388    push_current_branch(project_root).await
389}
390
391/// Rebase the current branch onto its remote counterpart, detecting remote/branch dynamically.
392pub 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
458/// Soft-reset unpushed commits onto `{remote}/{branch}` and recommit without blobs
459/// at or above [`GITHUB_MAX_FILE_BYTES`]. Local oversized files stay in the worktree.
460async 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    // Also restore index entries for paths known oversized from history even if the
544    // worktree copy was deleted / not present for size checks.
545    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    // Pathspec-less commit of everything still staged (only safe content remains).
580    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
620/// Unstage any index entries whose blob is ≥ GitHub's limit. Worktree copies stay put.
621pub 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    // Prefer worktree size when present (matches what would be pushed for normal adds).
664    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            // No upstream yet, or default push.refspecs not set — set upstream and push.
765            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    // Fetch first so rebase uses current remote tips even if the branch has no upstream yet.
777    let _ = git_output(project_root, &["fetch", remote, branch]).await;
778
779    // Version bumps and other auto-commits often run with unrelated WIP still dirty.
780    // `git pull --rebase` refuses dirty trees; stash around the rebase, then restore.
781    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
813/// Stash dirty tracked/untracked files so rebase can run. Returns whether a stash was created.
814async 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    // Avoid popping into the middle of a conflicted rebase.
849    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 rev-parse --verify` exits non-zero when the path is missing.
870    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            // When given an absolute path from --git-path, join may still work as absolute on Windows.
882            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
920/// Resolve the remote used for push/pull for `branch`.
921///
922/// Order: configured branch remote → upstream remote name → `origin` if present → first remote.
923pub 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    // GH001 / pre-receive large-file rejections also say "rejected" + "failed to push";
993    // never treat those as "behind remote".
994    if is_github_large_file_push_error(&corpus) {
995        return false;
996    }
997    if corpus.contains("remote rejected") && !corpus.contains("non-fast-forward") {
998        // Other pre-receive hooks (secrets, LFS, rulesets) — not a rebase problem.
999        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        // husky / pre-commit often print diagnostics on stdout.
1079        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            // XBP-generated commits (.xbp/** only) should never be blocked by
1111            // repo lint/test hooks (husky + ultracite scanning the whole tree, etc.).
1112            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                    // Non-interactive automation: soft-skip hooks for husky failures
1153                    // so CI-like agent runs can still land XBP config commits.
1154                    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
1170/// Commit only `committed_files` (pathspec), never the full index.
1171async 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        // husky v8/v9 and lefthook both honor these env flags.
1197        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
1233/// Paths produced by XBP tooling that are safe to commit without project lint hooks.
1234fn 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
1455/// Normalize paths for `git add`/`git commit` pathspecs.
1456///
1457/// Pathspecs must be relative to the **git repository root** (toplevel), because
1458/// git reports cached names that way and pathspecs are resolved from the command
1459/// cwd — which we always set to the toplevel in [`commit_paths`].
1460///
1461/// `project_root` may be a nested xbp package (e.g. `packages/athena-js`). Relative
1462/// paths from version bump/release are interpreted relative to that project root
1463/// first, then rewritten under the monorepo root.
1464fn 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    // 1) Absolute path under the git toplevel.
1509    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        // Absolute under nested project root → prefix with monorepo-relative package path.
1517        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        // Last resort: keep as-is (may still work if already monorepo-relative absolute).
1530        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    // 2) Nested xbp package: treat bare relative paths as package-local
1540    // (package.json → packages/athena-js/package.json). Prefer this over a
1541    // same-named monorepo-root file when project_root is nested.
1542    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    // 3) Plain relative path (repo-root project).
1550    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    // Prefer the *retry* (fallback) error when it is a large-file rejection even if the
1592    // first attempt was non-fast-forward — otherwise GH001 is masked as "behind remote".
1593    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        // Reproduces: xbp version bump from packages/athena-js with nearest .xbp config
1726        // producing pathspecs packages/athena-js/* while git cwd was the package dir.
1727        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        // GitHub large-file pre-receive must NOT look like "behind remote".
1835        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}