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    match attempt_push(project_root, &remote, &branch).await {
197        Ok(()) => {}
198        Err(push_error) if is_non_fast_forward_push_error(&push_error) => {
199            println!(
200                "{} {}",
201                "Branch behind remote".bright_yellow().bold(),
202                format!("rebasing onto {}/{} then retrying push...", remote, branch)
203                    .bright_white()
204            );
205            pull_rebase(project_root, &remote, &branch)
206                .await
207                .map_err(|rebase_error| {
208                    format!(
209                        "`{branch}` is behind `{remote}/{branch}` and auto-rebase failed: {rebase_error}. Resolve conflicts, then `git push`."
210                    )
211                })?;
212            rebased = true;
213            attempt_push(project_root, &remote, &branch)
214                .await
215                .map_err(|retry_error| {
216                    summarize_git_push_error(&remote, &branch, &push_error, &retry_error)
217                })?;
218        }
219        Err(push_error) => {
220            return Err(summarize_git_push_error(
221                &remote,
222                &branch,
223                &push_error,
224                &push_error,
225            ));
226        }
227    }
228
229    Ok(Some(PushOutcome {
230        remote,
231        branch,
232        rebased,
233    }))
234}
235
236/// Rebase the current branch onto its remote counterpart, detecting remote/branch dynamically.
237pub async fn pull_rebase_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
238    if !command_exists("git") {
239        return Ok(None);
240    }
241
242    let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
243        .await
244        .ok()
245        .filter(|value| !value.is_empty() && value != "HEAD");
246    let Some(branch) = branch else {
247        return Ok(None);
248    };
249
250    let remote = resolve_push_remote(project_root, &branch).await?;
251    println!(
252        "{} {}",
253        "Rebasing".bright_cyan().bold(),
254        format!("onto {}/{}...", remote, branch).bright_white()
255    );
256    pull_rebase(project_root, &remote, &branch).await?;
257    Ok(Some(PushOutcome {
258        remote,
259        branch,
260        rebased: true,
261    }))
262}
263
264pub fn print_skip(action_label: &str, reason: &str) {
265    println!(
266        "{} {} {}",
267        "Auto-commit".bright_yellow().bold(),
268        format!("skipped for {}", action_label).bright_white(),
269        format!("({})", reason).dimmed()
270    );
271}
272
273pub fn print_push_summary(outcome: &PushOutcome) {
274    let target = format!("{}/{}", outcome.remote, outcome.branch);
275    if outcome.rebased {
276        println!(
277            "{} {} {}",
278            "Pushed".bright_green().bold(),
279            target.bright_white(),
280            "(after auto-rebase)".dimmed()
281        );
282    } else {
283        println!(
284            "{} {}",
285            "Pushed".bright_green().bold(),
286            target.bright_white()
287        );
288    }
289}
290
291async fn attempt_push(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
292    match git_output(project_root, &["push"]).await {
293        Ok(_) => Ok(()),
294        Err(push_error) => {
295            // No upstream yet, or default push.refspecs not set — set upstream and push.
296            git_output(project_root, &["push", "-u", remote, branch])
297                .await
298                .map(|_| ())
299                .map_err(|fallback_error| format!("{push_error}\n{fallback_error}"))
300        }
301    }
302}
303
304async fn pull_rebase(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
305    // Fetch first so rebase uses current remote tips even if the branch has no upstream yet.
306    let _ = git_output(project_root, &["fetch", remote, branch]).await;
307    git_output(project_root, &["pull", "--rebase", remote, branch]).await?;
308    Ok(())
309}
310
311/// Resolve the remote used for push/pull for `branch`.
312///
313/// Order: configured branch remote → upstream remote name → `origin` if present → first remote.
314pub async fn resolve_push_remote(project_root: &Path, branch: &str) -> Result<String, String> {
315    if let Ok(configured) = git_output(
316        project_root,
317        &["config", "--get", &format!("branch.{branch}.remote")],
318    )
319    .await
320    {
321        let trimmed = configured.trim();
322        if !trimmed.is_empty() {
323            return Ok(trimmed.to_string());
324        }
325    }
326
327    if let Ok(upstream) = git_output(
328        project_root,
329        &[
330            "rev-parse",
331            "--abbrev-ref",
332            "--symbolic-full-name",
333            "@{upstream}",
334        ],
335    )
336    .await
337    {
338        if let Some(remote) = split_remote_branch(upstream.trim()).map(|(remote, _)| remote) {
339            return Ok(remote);
340        }
341    }
342
343    let remotes = git_output(project_root, &["remote"]).await.unwrap_or_default();
344    let remote_names = remotes
345        .lines()
346        .map(str::trim)
347        .filter(|line| !line.is_empty())
348        .map(ToOwned::to_owned)
349        .collect::<Vec<_>>();
350
351    if remote_names.iter().any(|name| name == "origin") {
352        return Ok("origin".to_string());
353    }
354
355    remote_names.into_iter().next().ok_or_else(|| {
356        "No git remotes are configured for this repository. Add one with `git remote add <name> <url>`."
357            .to_string()
358    })
359}
360
361fn split_remote_branch(upstream: &str) -> Option<(String, String)> {
362    let (remote, branch) = upstream.split_once('/')?;
363    let remote = remote.trim();
364    let branch = branch.trim();
365    if remote.is_empty() || branch.is_empty() {
366        return None;
367    }
368    Some((remote.to_string(), branch.to_string()))
369}
370
371fn is_non_fast_forward_push_error(error: &str) -> bool {
372    let corpus = error.to_ascii_lowercase();
373    corpus.contains("non-fast-forward")
374        || corpus.contains("tip of your current branch is behind")
375        || (corpus.contains("failed to push some refs")
376            && (corpus.contains("behind")
377                || corpus.contains("rejected")
378                || corpus.contains("fetch first")))
379}
380
381fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
382    let branch = outcome
383        .branch
384        .as_deref()
385        .map(|value| format!(" on {}", value.bright_blue()))
386        .unwrap_or_default();
387    let files = if outcome.committed_files.is_empty() {
388        "(none)".dimmed().to_string()
389    } else {
390        outcome
391            .committed_files
392            .iter()
393            .map(|value| value.bright_white().to_string())
394            .collect::<Vec<_>>()
395            .join(", ")
396    };
397
398    println!(
399        "{} {}{}",
400        "Auto-commit".bright_green().bold(),
401        format!("created for {}", action_label).bright_white(),
402        branch
403    );
404    println!(
405        "  {} {} {}",
406        "Repo".bright_cyan().bold(),
407        outcome.repo_name.bright_white().bold(),
408        format!("({})", outcome.repo_root.display()).dimmed()
409    );
410    println!(
411        "  {} {} {}",
412        "Commit".bright_cyan().bold(),
413        outcome.short_sha.bright_green().bold(),
414        format!("({})", outcome.commit_sha).dimmed()
415    );
416    println!(
417        "  {} {}",
418        "Message".bright_cyan().bold(),
419        outcome.message.bright_magenta()
420    );
421    println!("  {} {}", "Files".bright_cyan().bold(), files);
422}
423
424async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
425    let owned_args = args
426        .iter()
427        .map(|value| value.to_string())
428        .collect::<Vec<_>>();
429    git_output_owned(project_root, owned_args).await
430}
431
432async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
433    let output = Command::new("git")
434        .current_dir(project_root)
435        .args(&args)
436        .output()
437        .await
438        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
439
440    if !output.status.success() {
441        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
442        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
443        // husky / pre-commit often print diagnostics on stdout.
444        let detail = [stderr, stdout]
445            .into_iter()
446            .filter(|s| !s.is_empty())
447            .collect::<Vec<_>>()
448            .join("\n");
449        if detail.is_empty() {
450            return Err(format!(
451                "`git {}` failed with status {}",
452                args.join(" "),
453                output.status
454            ));
455        }
456        return Err(format!("`git {}` failed: {}", args.join(" "), detail));
457    }
458
459    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
460}
461
462async fn commit_with_optional_hook_retry(
463    project_root: &Path,
464    message: &str,
465    committed_files: &[String],
466) -> Result<(), String> {
467    match git_output(project_root, &["commit", "-m", message]).await {
468        Ok(_) => Ok(()),
469        Err(error) => {
470            let hook_failure = is_commit_hook_failure(&error);
471            if !hook_failure {
472                return Err(error);
473            }
474
475            // XBP-generated commits (.xbp/** only) should never be blocked by
476            // repo lint/test hooks (husky + ultracite scanning the whole tree, etc.).
477            if is_xbp_managed_commit(committed_files) {
478                println!(
479                    "{} {}",
480                    "Hooks".bright_yellow().bold(),
481                    "failed on XBP-managed files only — retrying with hooks disabled (--no-verify)."
482                        .bright_white()
483                );
484                return run_commit_with_hooks_disabled(project_root, message).await;
485            }
486
487            if is_missing_lefthook_error(&error)
488                || is_husky_or_pre_commit_hook_error(&error)
489            {
490                if std::io::stdin().is_terminal() {
491                    let prompt = if is_missing_lefthook_error(&error) {
492                        "Commit hooks failed to resolve lefthook. Retry once with hooks disabled?"
493                    } else {
494                        "Pre-commit / husky hooks failed. Retry this XBP commit with hooks disabled?"
495                    };
496                    let retry = Confirm::with_theme(&ColorfulTheme::default())
497                        .with_prompt(prompt)
498                        .default(true)
499                        .interact()
500                        .map_err(|e| format!("Failed to read retry choice: {}", e))?;
501                    if retry {
502                        println!(
503                            "{} {}",
504                            "Hooks".bright_yellow().bold(),
505                            "disabled for this commit (--no-verify, HUSKY=0, LEFTHOOK=0)."
506                                .bright_white()
507                        );
508                        return run_commit_with_hooks_disabled(project_root, message).await;
509                    }
510                } else {
511                    // Non-interactive automation: soft-skip hooks for husky failures
512                    // so CI-like agent runs can still land XBP config commits.
513                    println!(
514                        "{} {}",
515                        "Hooks".bright_yellow().bold(),
516                        "failed in non-interactive mode — retrying with hooks disabled."
517                            .bright_white()
518                    );
519                    return run_commit_with_hooks_disabled(project_root, message).await;
520                }
521            }
522
523            Err(error)
524        }
525    }
526}
527
528async fn run_commit_with_hooks_disabled(project_root: &Path, message: &str) -> Result<(), String> {
529    let output = Command::new("git")
530        .current_dir(project_root)
531        // husky v8/v9 and lefthook both honor these env flags.
532        .env("HUSKY", "0")
533        .env("LEFTHOOK", "0")
534        .args(["commit", "--no-verify", "-m", message])
535        .output()
536        .await
537        .map_err(|e| format!("Failed to run `git commit --no-verify -m {}`: {}", message, e))?;
538
539    if !output.status.success() {
540        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
541        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
542        let detail = [stderr, stdout]
543            .into_iter()
544            .filter(|s| !s.is_empty())
545            .collect::<Vec<_>>()
546            .join("\n");
547        if detail.is_empty() {
548            return Err(format!(
549                "`git commit --no-verify -m {}` failed with status {}",
550                message, output.status
551            ));
552        }
553        return Err(format!(
554            "`git commit --no-verify -m {}` failed: {}",
555            message, detail
556        ));
557    }
558
559    Ok(())
560}
561
562/// Paths produced by XBP tooling that are safe to commit without project lint hooks.
563fn is_xbp_managed_commit(committed_files: &[String]) -> bool {
564    !committed_files.is_empty()
565        && committed_files.iter().all(|path| {
566            let normalized = path.replace('\\', "/");
567            normalized == ".xbp"
568                || normalized.starts_with(".xbp/")
569                || normalized == "xbp.yaml"
570                || normalized == "xbp.yml"
571                || normalized == "xbp.json"
572                || normalized == "pnpm-workspace.yaml"
573                || normalized == "package.json"
574                || normalized.ends_with("/package.json")
575                || normalized.ends_with("/pnpm-workspace.yaml")
576        })
577}
578
579fn is_commit_hook_failure(error: &str) -> bool {
580    let lower = error.to_ascii_lowercase();
581    lower.contains("pre-commit")
582        || lower.contains("husky")
583        || lower.contains("lefthook")
584        || lower.contains("hook")
585        || lower.contains("ultracite")
586        || lower.contains("lint-staged")
587        || lower.contains("hook exited")
588        || lower.contains("script failed")
589}
590
591fn is_husky_or_pre_commit_hook_error(error: &str) -> bool {
592    let lower = error.to_ascii_lowercase();
593    lower.contains("husky")
594        || lower.contains("pre-commit")
595        || lower.contains("ultracite")
596        || lower.contains("lint-staged")
597        || (lower.contains("hook") && lower.contains("failed"))
598}
599
600async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
601    let current_name = git_output(project_root, &["config", "--get", "user.name"])
602        .await
603        .ok()
604        .map(|value| value.trim().to_string())
605        .filter(|value| !value.is_empty());
606    let current_email = git_output(project_root, &["config", "--get", "user.email"])
607        .await
608        .ok()
609        .map(|value| value.trim().to_string())
610        .filter(|value| !value.is_empty());
611
612    if current_name.is_some() && current_email.is_some() {
613        return Ok(());
614    }
615
616    let suggested = SshConfig::load()
617        .ok()
618        .and_then(|config| config.cli_auth)
619        .and_then(|auth| match (auth.user_name, auth.user_email) {
620            (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
621                Some((name, email))
622            }
623            _ => None,
624        });
625
626    if !std::io::stdin().is_terminal() {
627        return Err(identity_setup_hint(
628            current_name.as_deref(),
629            current_email.as_deref(),
630            suggested
631                .as_ref()
632                .map(|(name, email)| (name.as_str(), email.as_str())),
633        ));
634    }
635
636    let prefill_name = suggested
637        .as_ref()
638        .map(|(name, _)| name.clone())
639        .or_else(|| current_name.clone())
640        .unwrap_or_default();
641    let prefill_email = suggested
642        .as_ref()
643        .map(|(_, email)| email.clone())
644        .or_else(|| current_email.clone())
645        .unwrap_or_default();
646
647    let mut configured_name = current_name;
648    let mut configured_email = current_email;
649
650    if configured_name.is_none() {
651        let name: String = Input::with_theme(&ColorfulTheme::default())
652            .with_prompt("Git commit author name")
653            .with_initial_text(prefill_name)
654            .interact_text()
655            .map_err(|e| format!("Failed to read git author name: {}", e))?;
656        let trimmed = name.trim().to_string();
657        if trimmed.is_empty() {
658            return Err("Git commit author name cannot be empty.".to_string());
659        }
660        git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
661        configured_name = Some(trimmed);
662    }
663
664    if configured_email.is_none() {
665        let email: String = Input::with_theme(&ColorfulTheme::default())
666            .with_prompt("Git commit author email")
667            .with_initial_text(prefill_email)
668            .interact_text()
669            .map_err(|e| format!("Failed to read git author email: {}", e))?;
670        let trimmed = email.trim().to_string();
671        if trimmed.is_empty() {
672            return Err("Git commit author email cannot be empty.".to_string());
673        }
674        git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
675        configured_email = Some(trimmed);
676    }
677
678    if configured_name.is_some() && configured_email.is_some() {
679        Ok(())
680    } else {
681        Err("Git commit identity is still incomplete after prompting.".to_string())
682    }
683}
684
685async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
686    if is_missing_git_identity_error(error) {
687        let suggested = SshConfig::load()
688            .ok()
689            .and_then(|config| config.cli_auth)
690            .and_then(|auth| match (auth.user_name, auth.user_email) {
691                (Some(name), Some(email))
692                    if !name.trim().is_empty() && !email.trim().is_empty() =>
693                {
694                    Some((name, email))
695                }
696                _ => None,
697            });
698
699        return identity_setup_hint(
700            git_output(project_root, &["config", "--get", "user.name"])
701                .await
702                .ok()
703                .as_deref(),
704            git_output(project_root, &["config", "--get", "user.email"])
705                .await
706                .ok()
707                .as_deref(),
708            suggested
709                .as_ref()
710                .map(|(name, email)| (name.as_str(), email.as_str())),
711        );
712    }
713
714    if is_missing_lefthook_error(error) {
715        return format!(
716            "The commit hook tried to load lefthook from the current repo but the module was missing.\n\
717Run `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\
718If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0` / `HUSKY=0` or `git commit --no-verify`.\n\
719Raw error: {error}"
720        );
721    }
722
723    if is_husky_or_pre_commit_hook_error(error) {
724        let husky_hint = if project_root.join(".husky").is_dir() {
725            "\nThis repo has a `.husky/` pre-commit hook. Common blockers: broken shebang order, \
726`pnpm test` on every commit, or formatters (ultracite/biome) scanning the whole tree."
727        } else {
728            ""
729        };
730        return format!(
731            "Git commit was blocked by a pre-commit hook (husky/lefthook/lint).\n\
732XBP-only commits under `.xbp/` are retried automatically with hooks disabled.\n\
733To 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\
734Workaround: `HUSKY=0 git commit --no-verify` (or `LEFTHOOK=0`).{husky_hint}\n\
735Raw error: {error}"
736        );
737    }
738
739    format!("Git commit failed: {}", error)
740}
741
742fn identity_setup_hint(
743    current_name: Option<&str>,
744    current_email: Option<&str>,
745    suggested: Option<(&str, &str)>,
746) -> String {
747    let mut lines = vec![
748        "Git blocked the commit because your author identity is not configured in this environment."
749            .to_string(),
750    ];
751    if current_name.is_none() {
752        lines.push("Missing `user.name`.".to_string());
753    }
754    if current_email.is_none() {
755        lines.push("Missing `user.email`.".to_string());
756    }
757    if let Some((name, email)) = suggested {
758        lines.push(format!(
759            "XBP found a likely identity to prefill: {} <{}>",
760            name, email
761        ));
762        lines.push(format!("Run `git config --local user.name \"{}\"`", name));
763        lines.push(format!("Run `git config --local user.email \"{}\"`", email));
764    } else {
765        lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
766            .to_string());
767        lines.push(
768            "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
769        );
770    }
771    lines.join("\n")
772}
773
774fn is_missing_git_identity_error(error: &str) -> bool {
775    error.contains("Author identity unknown")
776        || error.contains("empty ident name")
777        || error.contains("Please tell me who you are")
778}
779
780fn is_missing_lefthook_error(error: &str) -> bool {
781    let lower = error.to_ascii_lowercase();
782    (lower.contains("lefthook") && lower.contains("module not found"))
783        || (lower.contains("lefthook") && lower.contains("cannot find module"))
784}
785
786fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
787    let mut deduped = BTreeSet::new();
788
789    for path in paths {
790        if path.as_os_str().is_empty() {
791            continue;
792        }
793
794        let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
795            relative.to_path_buf()
796        } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
797            relative
798        } else {
799            path.to_path_buf()
800        };
801
802        let rendered = normalized.to_string_lossy().replace('\\', "/");
803        let trimmed = rendered.trim();
804        if !trimmed.is_empty() && trimmed != "." {
805            deduped.insert(trimmed.to_string());
806        }
807    }
808
809    deduped.into_iter().collect()
810}
811
812fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
813    let root = project_root
814        .to_string_lossy()
815        .replace('\\', "/")
816        .trim_end_matches('/')
817        .to_string();
818    let candidate = path.to_string_lossy().replace('\\', "/");
819
820    if candidate.len() <= root.len() {
821        return None;
822    }
823
824    let (prefix, suffix) = candidate.split_at(root.len());
825    if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
826        return Some(PathBuf::from(suffix.trim_start_matches('/')));
827    }
828
829    None
830}
831
832pub fn summarize_git_push_error(
833    remote: &str,
834    branch: &str,
835    primary_error: &str,
836    fallback_error: &str,
837) -> String {
838    let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
839
840    if is_non_fast_forward_push_error(&corpus) {
841        return format!(
842            "`{branch}` is behind `{remote}/{branch}`. Run `git pull --rebase {remote} {branch}`, then `git push`."
843        );
844    }
845
846    if corpus.contains("authentication failed")
847        || corpus.contains("could not read username")
848        || corpus.contains("403")
849        || corpus.contains("401")
850    {
851        return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
852            .to_string();
853    }
854
855    let lines = dedupe_git_error_lines(primary_error, fallback_error);
856    lines
857        .into_iter()
858        .last()
859        .unwrap_or_else(|| "git push failed".to_string())
860}
861
862fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
863    let mut seen = BTreeSet::new();
864    let mut lines = Vec::new();
865
866    for line in primary_error.lines().chain(fallback_error.lines()) {
867        let trimmed = line.trim();
868        if trimmed.is_empty() || trimmed.starts_with("hint:") {
869            continue;
870        }
871        let key = trimmed.to_ascii_lowercase();
872        if seen.insert(key) {
873            lines.push(trimmed.to_string());
874        }
875    }
876
877    lines
878}
879
880fn repo_name(path: &Path) -> String {
881    path.file_name()
882        .and_then(|value| value.to_str())
883        .filter(|value| !value.trim().is_empty())
884        .unwrap_or("repository")
885        .to_string()
886}
887
888#[cfg(test)]
889mod tests {
890    use super::{
891        explicit_push_requested, is_commit_hook_failure, is_husky_or_pre_commit_hook_error,
892        is_missing_git_identity_error, is_missing_lefthook_error, is_non_fast_forward_push_error,
893        is_xbp_managed_commit, normalize_commit_paths, reset_explicit_push, set_explicit_push,
894        split_remote_branch, summarize_git_push_error,
895    };
896    use std::path::{Path, PathBuf};
897
898    #[test]
899    fn xbp_managed_commit_detects_config_only_paths() {
900        assert!(is_xbp_managed_commit(&[".xbp/xbp.yaml".to_string()]));
901        assert!(is_xbp_managed_commit(&[
902            ".xbp/xbp.yaml".to_string(),
903            "package.json".to_string(),
904            "pnpm-workspace.yaml".to_string(),
905        ]));
906        assert!(!is_xbp_managed_commit(&[
907            ".xbp/xbp.yaml".to_string(),
908            "src/app.ts".to_string(),
909        ]));
910        assert!(!is_xbp_managed_commit(&[]));
911    }
912
913    #[test]
914    fn detects_husky_pre_commit_failures() {
915        assert!(is_commit_hook_failure(
916            "husky - pre-commit script failed (code 1)"
917        ));
918        assert!(is_husky_or_pre_commit_hook_error(
919            "`git commit -m chore` failed: husky - pre-commit script failed (code 1)"
920        ));
921        assert!(is_commit_hook_failure(
922            "Ultracite found issues that could not be auto-fixed."
923        ));
924    }
925
926    #[test]
927    fn normalizes_commit_paths_relative_to_project_root() {
928        let project_root = Path::new("C:/repo");
929        let paths = vec![
930            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
931            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
932            PathBuf::from("CHANGELOG.md"),
933        ];
934
935        let normalized = normalize_commit_paths(project_root, &paths);
936
937        assert_eq!(
938            normalized,
939            vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
940        );
941    }
942
943    #[test]
944    fn detects_missing_git_identity_errors() {
945        assert!(is_missing_git_identity_error(
946            "Author identity unknown\n*** Please tell me who you are."
947        ));
948    }
949
950    #[test]
951    fn detects_lefthook_module_errors() {
952        assert!(is_missing_lefthook_error(
953            "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
954        ));
955    }
956
957    #[test]
958    fn summarizes_non_fast_forward_push_errors_without_git_hints() {
959        let primary = "! [rejected] main -> main (non-fast-forward)";
960        let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
961hint: Updates were rejected because the tip of your current branch is behind
962hint: its remote counterpart. If you want to integrate the remote changes,
963hint: use 'git pull' before pushing again."#;
964
965        let summary = summarize_git_push_error("origin", "main", primary, fallback);
966
967        assert!(summary.contains("behind `origin/main`"));
968        assert!(summary.contains("git pull --rebase origin main"));
969        assert!(!summary.contains("hint:"));
970        assert!(!summary.contains("git push -u origin main"));
971    }
972
973    #[test]
974    fn summarizes_behind_errors_with_detected_remote_name() {
975        let primary = "! [rejected] cleanup-sql -> cleanup-sql (non-fast-forward)";
976        let fallback = "error: failed to push some refs to 'https://github.com/acme/speedrun-formations.git'";
977
978        let summary = summarize_git_push_error("origin", "cleanup-sql", primary, fallback);
979
980        assert!(summary.contains("cleanup-sql"));
981        assert!(summary.contains("git pull --rebase origin cleanup-sql"));
982    }
983
984    #[test]
985    fn detects_non_fast_forward_push_errors() {
986        assert!(is_non_fast_forward_push_error(
987            "! [rejected] main -> main (non-fast-forward)"
988        ));
989        assert!(is_non_fast_forward_push_error(
990            "failed to push some refs\ntip of your current branch is behind"
991        ));
992        assert!(!is_non_fast_forward_push_error(
993            "authentication failed for 'https://github.com/acme/repo.git'"
994        ));
995    }
996
997    #[test]
998    fn explicit_push_flag_tracks_cli_override() {
999        reset_explicit_push();
1000        assert!(!explicit_push_requested());
1001        set_explicit_push(true);
1002        assert!(explicit_push_requested());
1003        reset_explicit_push();
1004        assert!(!explicit_push_requested());
1005    }
1006
1007    #[test]
1008    fn splits_upstream_into_remote_and_branch() {
1009        assert_eq!(
1010            split_remote_branch("origin/cleanup-sql"),
1011            Some(("origin".to_string(), "cleanup-sql".to_string()))
1012        );
1013        assert_eq!(
1014            split_remote_branch("upstream/feature/foo"),
1015            Some(("upstream".to_string(), "feature/foo".to_string()))
1016        );
1017        assert_eq!(split_remote_branch("origin"), None);
1018    }
1019}