Skip to main content

xbp_cli/cli/
auto_commit.rs

1use colored::Colorize;
2use dialoguer::{theme::ColorfulTheme, Confirm, Input};
3use std::collections::BTreeSet;
4use std::io::IsTerminal;
5use std::path::{Path, PathBuf};
6use tokio::process::Command;
7
8use crate::config::SshConfig;
9use crate::utils::command_exists;
10
11pub struct AutoCommitRequest<'a> {
12    pub project_root: &'a Path,
13    pub paths: Vec<PathBuf>,
14    pub message: String,
15    pub action_label: &'a str,
16}
17
18pub enum AutoCommitResult {
19    Committed(AutoCommitOutcome),
20    Skipped(String),
21}
22
23pub struct AutoCommitOutcome {
24    pub repo_name: String,
25    pub repo_root: PathBuf,
26    pub branch: Option<String>,
27    pub commit_sha: String,
28    pub short_sha: String,
29    pub message: String,
30    pub committed_files: Vec<String>,
31}
32
33pub struct PushOutcome {
34    pub remote: String,
35    pub branch: String,
36    pub rebased: bool,
37}
38
39pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
40    if !command_exists("git") {
41        return Ok(AutoCommitResult::Skipped(
42            "Git is not installed on this machine.".to_string(),
43        ));
44    }
45
46    let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
47    {
48        Ok(root) => PathBuf::from(root),
49        Err(_) => {
50            return Ok(AutoCommitResult::Skipped(
51                "Current project is not inside a git repository.".to_string(),
52            ));
53        }
54    };
55
56    let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
57    if normalized_paths.is_empty() {
58        return Ok(AutoCommitResult::Skipped(
59            "No generated or updated files were provided for auto-commit.".to_string(),
60        ));
61    }
62
63    let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
64    add_args.extend(normalized_paths.iter().cloned());
65    git_output_owned(request.project_root, add_args).await?;
66
67    let mut diff_args = vec![
68        "diff".to_string(),
69        "--cached".to_string(),
70        "--name-only".to_string(),
71        "--".to_string(),
72    ];
73    diff_args.extend(normalized_paths.iter().cloned());
74    let committed_files = git_output_owned(request.project_root, diff_args)
75        .await?
76        .lines()
77        .map(str::trim)
78        .filter(|line| !line.is_empty())
79        .map(|line| line.replace('\\', "/"))
80        .collect::<Vec<_>>();
81
82    if committed_files.is_empty() {
83        return Ok(AutoCommitResult::Skipped(
84            "Target files did not produce any staged git diff.".to_string(),
85        ));
86    }
87
88    ensure_git_commit_identity(request.project_root).await?;
89
90    if let Err(error) =
91        commit_with_optional_hook_retry(request.project_root, &request.message).await
92    {
93        return Err(format_git_commit_failure(request.project_root, &error).await);
94    }
95
96    let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
97    let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
98    let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
99        .await
100        .ok()
101        .filter(|value| !value.is_empty() && value != "HEAD");
102
103    let outcome = AutoCommitOutcome {
104        repo_name: repo_name(&repo_root),
105        repo_root,
106        branch,
107        commit_sha,
108        short_sha,
109        message: request.message,
110        committed_files,
111    };
112
113    print_commit_summary(request.action_label, &outcome);
114
115    Ok(AutoCommitResult::Committed(outcome))
116}
117
118pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
119    let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
120        .await
121        .ok()
122        .filter(|value| !value.is_empty() && value != "HEAD");
123    push_current_branch_with_name(project_root, branch.as_deref()).await
124}
125
126pub async fn push_current_branch_with_name(
127    project_root: &Path,
128    branch: Option<&str>,
129) -> Result<Option<PushOutcome>, String> {
130    if !command_exists("git") {
131        return Ok(None);
132    }
133
134    let Some(branch) = branch
135        .map(str::trim)
136        .filter(|value| !value.is_empty() && *value != "HEAD")
137        .map(ToOwned::to_owned)
138    else {
139        return Ok(None);
140    };
141
142    let remote = resolve_push_remote(project_root, &branch).await?;
143    let mut rebased = false;
144
145    match attempt_push(project_root, &remote, &branch).await {
146        Ok(()) => {}
147        Err(push_error) if is_non_fast_forward_push_error(&push_error) => {
148            println!(
149                "{} {}",
150                "Branch behind remote".bright_yellow().bold(),
151                format!("rebasing onto {}/{} then retrying push...", remote, branch)
152                    .bright_white()
153            );
154            pull_rebase(project_root, &remote, &branch)
155                .await
156                .map_err(|rebase_error| {
157                    format!(
158                        "`{branch}` is behind `{remote}/{branch}` and auto-rebase failed: {rebase_error}. Resolve conflicts, then `git push`."
159                    )
160                })?;
161            rebased = true;
162            attempt_push(project_root, &remote, &branch)
163                .await
164                .map_err(|retry_error| {
165                    summarize_git_push_error(&remote, &branch, &push_error, &retry_error)
166                })?;
167        }
168        Err(push_error) => {
169            return Err(summarize_git_push_error(
170                &remote,
171                &branch,
172                &push_error,
173                &push_error,
174            ));
175        }
176    }
177
178    Ok(Some(PushOutcome {
179        remote,
180        branch,
181        rebased,
182    }))
183}
184
185/// Rebase the current branch onto its remote counterpart, detecting remote/branch dynamically.
186pub async fn pull_rebase_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
187    if !command_exists("git") {
188        return Ok(None);
189    }
190
191    let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
192        .await
193        .ok()
194        .filter(|value| !value.is_empty() && value != "HEAD");
195    let Some(branch) = branch else {
196        return Ok(None);
197    };
198
199    let remote = resolve_push_remote(project_root, &branch).await?;
200    println!(
201        "{} {}",
202        "Rebasing".bright_cyan().bold(),
203        format!("onto {}/{}...", remote, branch).bright_white()
204    );
205    pull_rebase(project_root, &remote, &branch).await?;
206    Ok(Some(PushOutcome {
207        remote,
208        branch,
209        rebased: true,
210    }))
211}
212
213pub fn print_skip(action_label: &str, reason: &str) {
214    println!(
215        "{} {} {}",
216        "Auto-commit".bright_yellow().bold(),
217        format!("skipped for {}", action_label).bright_white(),
218        format!("({})", reason).dimmed()
219    );
220}
221
222pub fn print_push_summary(outcome: &PushOutcome) {
223    let target = format!("{}/{}", outcome.remote, outcome.branch);
224    if outcome.rebased {
225        println!(
226            "{} {} {}",
227            "Pushed".bright_green().bold(),
228            target.bright_white(),
229            "(after auto-rebase)".dimmed()
230        );
231    } else {
232        println!(
233            "{} {}",
234            "Pushed".bright_green().bold(),
235            target.bright_white()
236        );
237    }
238}
239
240async fn attempt_push(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
241    match git_output(project_root, &["push"]).await {
242        Ok(_) => Ok(()),
243        Err(push_error) => {
244            // No upstream yet, or default push.refspecs not set — set upstream and push.
245            git_output(project_root, &["push", "-u", remote, branch])
246                .await
247                .map(|_| ())
248                .map_err(|fallback_error| format!("{push_error}\n{fallback_error}"))
249        }
250    }
251}
252
253async fn pull_rebase(project_root: &Path, remote: &str, branch: &str) -> Result<(), String> {
254    // Fetch first so rebase uses current remote tips even if the branch has no upstream yet.
255    let _ = git_output(project_root, &["fetch", remote, branch]).await;
256    git_output(project_root, &["pull", "--rebase", remote, branch]).await?;
257    Ok(())
258}
259
260/// Resolve the remote used for push/pull for `branch`.
261///
262/// Order: configured branch remote → upstream remote name → `origin` if present → first remote.
263pub async fn resolve_push_remote(project_root: &Path, branch: &str) -> Result<String, String> {
264    if let Ok(configured) = git_output(
265        project_root,
266        &["config", "--get", &format!("branch.{branch}.remote")],
267    )
268    .await
269    {
270        let trimmed = configured.trim();
271        if !trimmed.is_empty() {
272            return Ok(trimmed.to_string());
273        }
274    }
275
276    if let Ok(upstream) = git_output(
277        project_root,
278        &[
279            "rev-parse",
280            "--abbrev-ref",
281            "--symbolic-full-name",
282            "@{upstream}",
283        ],
284    )
285    .await
286    {
287        if let Some(remote) = split_remote_branch(upstream.trim()).map(|(remote, _)| remote) {
288            return Ok(remote);
289        }
290    }
291
292    let remotes = git_output(project_root, &["remote"]).await.unwrap_or_default();
293    let remote_names = remotes
294        .lines()
295        .map(str::trim)
296        .filter(|line| !line.is_empty())
297        .map(ToOwned::to_owned)
298        .collect::<Vec<_>>();
299
300    if remote_names.iter().any(|name| name == "origin") {
301        return Ok("origin".to_string());
302    }
303
304    remote_names.into_iter().next().ok_or_else(|| {
305        "No git remotes are configured for this repository. Add one with `git remote add <name> <url>`."
306            .to_string()
307    })
308}
309
310fn split_remote_branch(upstream: &str) -> Option<(String, String)> {
311    let (remote, branch) = upstream.split_once('/')?;
312    let remote = remote.trim();
313    let branch = branch.trim();
314    if remote.is_empty() || branch.is_empty() {
315        return None;
316    }
317    Some((remote.to_string(), branch.to_string()))
318}
319
320fn is_non_fast_forward_push_error(error: &str) -> bool {
321    let corpus = error.to_ascii_lowercase();
322    corpus.contains("non-fast-forward")
323        || corpus.contains("tip of your current branch is behind")
324        || (corpus.contains("failed to push some refs")
325            && (corpus.contains("behind")
326                || corpus.contains("rejected")
327                || corpus.contains("fetch first")))
328}
329
330fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
331    let branch = outcome
332        .branch
333        .as_deref()
334        .map(|value| format!(" on {}", value.bright_blue()))
335        .unwrap_or_default();
336    let files = if outcome.committed_files.is_empty() {
337        "(none)".dimmed().to_string()
338    } else {
339        outcome
340            .committed_files
341            .iter()
342            .map(|value| value.bright_white().to_string())
343            .collect::<Vec<_>>()
344            .join(", ")
345    };
346
347    println!(
348        "{} {}{}",
349        "Auto-commit".bright_green().bold(),
350        format!("created for {}", action_label).bright_white(),
351        branch
352    );
353    println!(
354        "  {} {} {}",
355        "Repo".bright_cyan().bold(),
356        outcome.repo_name.bright_white().bold(),
357        format!("({})", outcome.repo_root.display()).dimmed()
358    );
359    println!(
360        "  {} {} {}",
361        "Commit".bright_cyan().bold(),
362        outcome.short_sha.bright_green().bold(),
363        format!("({})", outcome.commit_sha).dimmed()
364    );
365    println!(
366        "  {} {}",
367        "Message".bright_cyan().bold(),
368        outcome.message.bright_magenta()
369    );
370    println!("  {} {}", "Files".bright_cyan().bold(), files);
371}
372
373async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
374    let owned_args = args
375        .iter()
376        .map(|value| value.to_string())
377        .collect::<Vec<_>>();
378    git_output_owned(project_root, owned_args).await
379}
380
381async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
382    let output = Command::new("git")
383        .current_dir(project_root)
384        .args(&args)
385        .output()
386        .await
387        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
388
389    if !output.status.success() {
390        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
391        if stderr.is_empty() {
392            return Err(format!(
393                "`git {}` failed with status {}",
394                args.join(" "),
395                output.status
396            ));
397        }
398        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
399    }
400
401    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
402}
403
404async fn commit_with_optional_hook_retry(project_root: &Path, message: &str) -> Result<(), String> {
405    match git_output(project_root, &["commit", "-m", message]).await {
406        Ok(_) => Ok(()),
407        Err(error) if is_missing_lefthook_error(&error) => {
408            if std::io::stdin().is_terminal() {
409                let retry = Confirm::with_theme(&ColorfulTheme::default())
410                    .with_prompt(
411                        "Commit hooks failed to resolve lefthook. Retry once with hooks disabled?",
412                    )
413                    .default(true)
414                    .interact()
415                    .map_err(|e| format!("Failed to read retry choice: {}", e))?;
416                if retry {
417                    run_commit_with_hooks_disabled(project_root, message).await
418                } else {
419                    Err(error)
420                }
421            } else {
422                Err(error)
423            }
424        }
425        Err(error) => Err(error),
426    }
427}
428
429async fn run_commit_with_hooks_disabled(project_root: &Path, message: &str) -> Result<(), String> {
430    let output = Command::new("git")
431        .current_dir(project_root)
432        .env("LEFTHOOK", "0")
433        .args(["commit", "-m", message])
434        .output()
435        .await
436        .map_err(|e| format!("Failed to run `git commit -m {}`: {}", message, e))?;
437
438    if !output.status.success() {
439        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
440        if stderr.is_empty() {
441            return Err(format!(
442                "`git commit -m {}` failed with status {}",
443                message, output.status
444            ));
445        }
446        return Err(format!("`git commit -m {}` failed: {}", message, stderr));
447    }
448
449    Ok(())
450}
451
452async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
453    let current_name = git_output(project_root, &["config", "--get", "user.name"])
454        .await
455        .ok()
456        .map(|value| value.trim().to_string())
457        .filter(|value| !value.is_empty());
458    let current_email = git_output(project_root, &["config", "--get", "user.email"])
459        .await
460        .ok()
461        .map(|value| value.trim().to_string())
462        .filter(|value| !value.is_empty());
463
464    if current_name.is_some() && current_email.is_some() {
465        return Ok(());
466    }
467
468    let suggested = SshConfig::load()
469        .ok()
470        .and_then(|config| config.cli_auth)
471        .and_then(|auth| match (auth.user_name, auth.user_email) {
472            (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
473                Some((name, email))
474            }
475            _ => None,
476        });
477
478    if !std::io::stdin().is_terminal() {
479        return Err(identity_setup_hint(
480            current_name.as_deref(),
481            current_email.as_deref(),
482            suggested
483                .as_ref()
484                .map(|(name, email)| (name.as_str(), email.as_str())),
485        ));
486    }
487
488    let prefill_name = suggested
489        .as_ref()
490        .map(|(name, _)| name.clone())
491        .or_else(|| current_name.clone())
492        .unwrap_or_default();
493    let prefill_email = suggested
494        .as_ref()
495        .map(|(_, email)| email.clone())
496        .or_else(|| current_email.clone())
497        .unwrap_or_default();
498
499    let mut configured_name = current_name;
500    let mut configured_email = current_email;
501
502    if configured_name.is_none() {
503        let name: String = Input::with_theme(&ColorfulTheme::default())
504            .with_prompt("Git commit author name")
505            .with_initial_text(prefill_name)
506            .interact_text()
507            .map_err(|e| format!("Failed to read git author name: {}", e))?;
508        let trimmed = name.trim().to_string();
509        if trimmed.is_empty() {
510            return Err("Git commit author name cannot be empty.".to_string());
511        }
512        git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
513        configured_name = Some(trimmed);
514    }
515
516    if configured_email.is_none() {
517        let email: String = Input::with_theme(&ColorfulTheme::default())
518            .with_prompt("Git commit author email")
519            .with_initial_text(prefill_email)
520            .interact_text()
521            .map_err(|e| format!("Failed to read git author email: {}", e))?;
522        let trimmed = email.trim().to_string();
523        if trimmed.is_empty() {
524            return Err("Git commit author email cannot be empty.".to_string());
525        }
526        git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
527        configured_email = Some(trimmed);
528    }
529
530    if configured_name.is_some() && configured_email.is_some() {
531        Ok(())
532    } else {
533        Err("Git commit identity is still incomplete after prompting.".to_string())
534    }
535}
536
537async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
538    if is_missing_git_identity_error(error) {
539        let suggested = SshConfig::load()
540            .ok()
541            .and_then(|config| config.cli_auth)
542            .and_then(|auth| match (auth.user_name, auth.user_email) {
543                (Some(name), Some(email))
544                    if !name.trim().is_empty() && !email.trim().is_empty() =>
545                {
546                    Some((name, email))
547                }
548                _ => None,
549            });
550
551        return identity_setup_hint(
552            git_output(project_root, &["config", "--get", "user.name"])
553                .await
554                .ok()
555                .as_deref(),
556            git_output(project_root, &["config", "--get", "user.email"])
557                .await
558                .ok()
559                .as_deref(),
560            suggested
561                .as_ref()
562                .map(|(name, email)| (name.as_str(), email.as_str())),
563        );
564    }
565
566    if is_missing_lefthook_error(error) {
567        return format!(
568            "The commit hook tried to load lefthook from the current repo but the module was missing.\n\
569Run `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\
570If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0`.\n\
571Raw error: {error}"
572        );
573    }
574
575    format!("Git commit failed: {}", error)
576}
577
578fn identity_setup_hint(
579    current_name: Option<&str>,
580    current_email: Option<&str>,
581    suggested: Option<(&str, &str)>,
582) -> String {
583    let mut lines = vec![
584        "Git blocked the commit because your author identity is not configured in this environment."
585            .to_string(),
586    ];
587    if current_name.is_none() {
588        lines.push("Missing `user.name`.".to_string());
589    }
590    if current_email.is_none() {
591        lines.push("Missing `user.email`.".to_string());
592    }
593    if let Some((name, email)) = suggested {
594        lines.push(format!(
595            "XBP found a likely identity to prefill: {} <{}>",
596            name, email
597        ));
598        lines.push(format!("Run `git config --local user.name \"{}\"`", name));
599        lines.push(format!("Run `git config --local user.email \"{}\"`", email));
600    } else {
601        lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
602            .to_string());
603        lines.push(
604            "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
605        );
606    }
607    lines.join("\n")
608}
609
610fn is_missing_git_identity_error(error: &str) -> bool {
611    error.contains("Author identity unknown")
612        || error.contains("empty ident name")
613        || error.contains("Please tell me who you are")
614}
615
616fn is_missing_lefthook_error(error: &str) -> bool {
617    let lower = error.to_ascii_lowercase();
618    (lower.contains("lefthook") && lower.contains("module not found"))
619        || (lower.contains("lefthook") && lower.contains("cannot find module"))
620}
621
622fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
623    let mut deduped = BTreeSet::new();
624
625    for path in paths {
626        if path.as_os_str().is_empty() {
627            continue;
628        }
629
630        let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
631            relative.to_path_buf()
632        } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
633            relative
634        } else {
635            path.to_path_buf()
636        };
637
638        let rendered = normalized.to_string_lossy().replace('\\', "/");
639        let trimmed = rendered.trim();
640        if !trimmed.is_empty() && trimmed != "." {
641            deduped.insert(trimmed.to_string());
642        }
643    }
644
645    deduped.into_iter().collect()
646}
647
648fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
649    let root = project_root
650        .to_string_lossy()
651        .replace('\\', "/")
652        .trim_end_matches('/')
653        .to_string();
654    let candidate = path.to_string_lossy().replace('\\', "/");
655
656    if candidate.len() <= root.len() {
657        return None;
658    }
659
660    let (prefix, suffix) = candidate.split_at(root.len());
661    if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
662        return Some(PathBuf::from(suffix.trim_start_matches('/')));
663    }
664
665    None
666}
667
668pub fn summarize_git_push_error(
669    remote: &str,
670    branch: &str,
671    primary_error: &str,
672    fallback_error: &str,
673) -> String {
674    let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
675
676    if is_non_fast_forward_push_error(&corpus) {
677        return format!(
678            "`{branch}` is behind `{remote}/{branch}`. Run `git pull --rebase {remote} {branch}`, then `git push`."
679        );
680    }
681
682    if corpus.contains("authentication failed")
683        || corpus.contains("could not read username")
684        || corpus.contains("403")
685        || corpus.contains("401")
686    {
687        return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
688            .to_string();
689    }
690
691    let lines = dedupe_git_error_lines(primary_error, fallback_error);
692    lines
693        .into_iter()
694        .last()
695        .unwrap_or_else(|| "git push failed".to_string())
696}
697
698fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
699    let mut seen = BTreeSet::new();
700    let mut lines = Vec::new();
701
702    for line in primary_error.lines().chain(fallback_error.lines()) {
703        let trimmed = line.trim();
704        if trimmed.is_empty() || trimmed.starts_with("hint:") {
705            continue;
706        }
707        let key = trimmed.to_ascii_lowercase();
708        if seen.insert(key) {
709            lines.push(trimmed.to_string());
710        }
711    }
712
713    lines
714}
715
716fn repo_name(path: &Path) -> String {
717    path.file_name()
718        .and_then(|value| value.to_str())
719        .filter(|value| !value.trim().is_empty())
720        .unwrap_or("repository")
721        .to_string()
722}
723
724#[cfg(test)]
725mod tests {
726    use super::{
727        is_missing_git_identity_error, is_missing_lefthook_error, is_non_fast_forward_push_error,
728        normalize_commit_paths, split_remote_branch, summarize_git_push_error,
729    };
730    use std::path::{Path, PathBuf};
731
732    #[test]
733    fn normalizes_commit_paths_relative_to_project_root() {
734        let project_root = Path::new("C:/repo");
735        let paths = vec![
736            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
737            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
738            PathBuf::from("CHANGELOG.md"),
739        ];
740
741        let normalized = normalize_commit_paths(project_root, &paths);
742
743        assert_eq!(
744            normalized,
745            vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
746        );
747    }
748
749    #[test]
750    fn detects_missing_git_identity_errors() {
751        assert!(is_missing_git_identity_error(
752            "Author identity unknown\n*** Please tell me who you are."
753        ));
754    }
755
756    #[test]
757    fn detects_lefthook_module_errors() {
758        assert!(is_missing_lefthook_error(
759            "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
760        ));
761    }
762
763    #[test]
764    fn summarizes_non_fast_forward_push_errors_without_git_hints() {
765        let primary = "! [rejected] main -> main (non-fast-forward)";
766        let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
767hint: Updates were rejected because the tip of your current branch is behind
768hint: its remote counterpart. If you want to integrate the remote changes,
769hint: use 'git pull' before pushing again."#;
770
771        let summary = summarize_git_push_error("origin", "main", primary, fallback);
772
773        assert!(summary.contains("behind `origin/main`"));
774        assert!(summary.contains("git pull --rebase origin main"));
775        assert!(!summary.contains("hint:"));
776        assert!(!summary.contains("git push -u origin main"));
777    }
778
779    #[test]
780    fn summarizes_behind_errors_with_detected_remote_name() {
781        let primary = "! [rejected] cleanup-sql -> cleanup-sql (non-fast-forward)";
782        let fallback = "error: failed to push some refs to 'https://github.com/acme/speedrun-formations.git'";
783
784        let summary = summarize_git_push_error("origin", "cleanup-sql", primary, fallback);
785
786        assert!(summary.contains("cleanup-sql"));
787        assert!(summary.contains("git pull --rebase origin cleanup-sql"));
788    }
789
790    #[test]
791    fn detects_non_fast_forward_push_errors() {
792        assert!(is_non_fast_forward_push_error(
793            "! [rejected] main -> main (non-fast-forward)"
794        ));
795        assert!(is_non_fast_forward_push_error(
796            "failed to push some refs\ntip of your current branch is behind"
797        ));
798        assert!(!is_non_fast_forward_push_error(
799            "authentication failed for 'https://github.com/acme/repo.git'"
800        ));
801    }
802
803    #[test]
804    fn splits_upstream_into_remote_and_branch() {
805        assert_eq!(
806            split_remote_branch("origin/cleanup-sql"),
807            Some(("origin".to_string(), "cleanup-sql".to_string()))
808        );
809        assert_eq!(
810            split_remote_branch("upstream/feature/foo"),
811            Some(("upstream".to_string(), "feature/foo".to_string()))
812        );
813        assert_eq!(split_remote_branch("origin"), None);
814    }
815}