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