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