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 branch: String,
35}
36
37pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
38    if !command_exists("git") {
39        return Ok(AutoCommitResult::Skipped(
40            "Git is not installed on this machine.".to_string(),
41        ));
42    }
43
44    let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
45    {
46        Ok(root) => PathBuf::from(root),
47        Err(_) => {
48            return Ok(AutoCommitResult::Skipped(
49                "Current project is not inside a git repository.".to_string(),
50            ));
51        }
52    };
53
54    let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
55    if normalized_paths.is_empty() {
56        return Ok(AutoCommitResult::Skipped(
57            "No generated or updated files were provided for auto-commit.".to_string(),
58        ));
59    }
60
61    let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
62    add_args.extend(normalized_paths.iter().cloned());
63    git_output_owned(request.project_root, add_args).await?;
64
65    let mut diff_args = vec![
66        "diff".to_string(),
67        "--cached".to_string(),
68        "--name-only".to_string(),
69        "--".to_string(),
70    ];
71    diff_args.extend(normalized_paths.iter().cloned());
72    let committed_files = git_output_owned(request.project_root, diff_args)
73        .await?
74        .lines()
75        .map(str::trim)
76        .filter(|line| !line.is_empty())
77        .map(|line| line.replace('\\', "/"))
78        .collect::<Vec<_>>();
79
80    if committed_files.is_empty() {
81        return Ok(AutoCommitResult::Skipped(
82            "Target files did not produce any staged git diff.".to_string(),
83        ));
84    }
85
86    ensure_git_commit_identity(request.project_root).await?;
87
88    if let Err(error) =
89        commit_with_optional_hook_retry(request.project_root, &request.message).await
90    {
91        return Err(format_git_commit_failure(request.project_root, &error).await);
92    }
93
94    let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
95    let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
96    let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
97        .await
98        .ok()
99        .filter(|value| !value.is_empty() && value != "HEAD");
100
101    let outcome = AutoCommitOutcome {
102        repo_name: repo_name(&repo_root),
103        repo_root,
104        branch,
105        commit_sha,
106        short_sha,
107        message: request.message,
108        committed_files,
109    };
110
111    print_commit_summary(request.action_label, &outcome);
112
113    Ok(AutoCommitResult::Committed(outcome))
114}
115
116pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
117    if !command_exists("git") {
118        return Ok(None);
119    }
120
121    let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
122        .await
123        .ok()
124        .filter(|value| !value.is_empty() && value != "HEAD");
125
126    let Some(branch) = branch else {
127        return Ok(None);
128    };
129
130    match git_output(project_root, &["push"]).await {
131        Ok(_) => {}
132        Err(push_error) => {
133            git_output(project_root, &["push", "-u", "origin", branch.as_str()])
134                .await
135                .map_err(|fallback_error| {
136                    summarize_git_push_error(&branch, &push_error, &fallback_error)
137                })?;
138        }
139    }
140    Ok(Some(PushOutcome { branch }))
141}
142
143pub fn print_skip(action_label: &str, reason: &str) {
144    println!(
145        "{} {} {}",
146        "Auto-commit".bright_yellow().bold(),
147        format!("skipped for {}", action_label).bright_white(),
148        format!("({})", reason).dimmed()
149    );
150}
151
152pub fn print_push_summary(outcome: &PushOutcome) {
153    println!(
154        "{} {}",
155        "Pushed".bright_green().bold(),
156        format!("origin/{}", outcome.branch).bright_white()
157    );
158}
159
160fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
161    let branch = outcome
162        .branch
163        .as_deref()
164        .map(|value| format!(" on {}", value.bright_blue()))
165        .unwrap_or_default();
166    let files = if outcome.committed_files.is_empty() {
167        "(none)".dimmed().to_string()
168    } else {
169        outcome
170            .committed_files
171            .iter()
172            .map(|value| value.bright_white().to_string())
173            .collect::<Vec<_>>()
174            .join(", ")
175    };
176
177    println!(
178        "{} {}{}",
179        "Auto-commit".bright_green().bold(),
180        format!("created for {}", action_label).bright_white(),
181        branch
182    );
183    println!(
184        "  {} {} {}",
185        "Repo".bright_cyan().bold(),
186        outcome.repo_name.bright_white().bold(),
187        format!("({})", outcome.repo_root.display()).dimmed()
188    );
189    println!(
190        "  {} {} {}",
191        "Commit".bright_cyan().bold(),
192        outcome.short_sha.bright_green().bold(),
193        format!("({})", outcome.commit_sha).dimmed()
194    );
195    println!(
196        "  {} {}",
197        "Message".bright_cyan().bold(),
198        outcome.message.bright_magenta()
199    );
200    println!("  {} {}", "Files".bright_cyan().bold(), files);
201}
202
203async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
204    let owned_args = args
205        .iter()
206        .map(|value| value.to_string())
207        .collect::<Vec<_>>();
208    git_output_owned(project_root, owned_args).await
209}
210
211async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
212    let output = Command::new("git")
213        .current_dir(project_root)
214        .args(&args)
215        .output()
216        .await
217        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
218
219    if !output.status.success() {
220        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
221        if stderr.is_empty() {
222            return Err(format!(
223                "`git {}` failed with status {}",
224                args.join(" "),
225                output.status
226            ));
227        }
228        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
229    }
230
231    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
232}
233
234async fn commit_with_optional_hook_retry(project_root: &Path, message: &str) -> Result<(), String> {
235    match git_output(project_root, &["commit", "-m", message]).await {
236        Ok(_) => Ok(()),
237        Err(error) if is_missing_lefthook_error(&error) => {
238            if std::io::stdin().is_terminal() {
239                let retry = Confirm::with_theme(&ColorfulTheme::default())
240                    .with_prompt(
241                        "Commit hooks failed to resolve lefthook. Retry once with hooks disabled?",
242                    )
243                    .default(true)
244                    .interact()
245                    .map_err(|e| format!("Failed to read retry choice: {}", e))?;
246                if retry {
247                    run_commit_with_hooks_disabled(project_root, message).await
248                } else {
249                    Err(error)
250                }
251            } else {
252                Err(error)
253            }
254        }
255        Err(error) => Err(error),
256    }
257}
258
259async fn run_commit_with_hooks_disabled(project_root: &Path, message: &str) -> Result<(), String> {
260    let output = Command::new("git")
261        .current_dir(project_root)
262        .env("LEFTHOOK", "0")
263        .args(["commit", "-m", message])
264        .output()
265        .await
266        .map_err(|e| format!("Failed to run `git commit -m {}`: {}", message, e))?;
267
268    if !output.status.success() {
269        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
270        if stderr.is_empty() {
271            return Err(format!(
272                "`git commit -m {}` failed with status {}",
273                message, output.status
274            ));
275        }
276        return Err(format!("`git commit -m {}` failed: {}", message, stderr));
277    }
278
279    Ok(())
280}
281
282async fn ensure_git_commit_identity(project_root: &Path) -> Result<(), String> {
283    let current_name = git_output(project_root, &["config", "--get", "user.name"])
284        .await
285        .ok()
286        .map(|value| value.trim().to_string())
287        .filter(|value| !value.is_empty());
288    let current_email = git_output(project_root, &["config", "--get", "user.email"])
289        .await
290        .ok()
291        .map(|value| value.trim().to_string())
292        .filter(|value| !value.is_empty());
293
294    if current_name.is_some() && current_email.is_some() {
295        return Ok(());
296    }
297
298    let suggested = SshConfig::load()
299        .ok()
300        .and_then(|config| config.cli_auth)
301        .and_then(|auth| match (auth.user_name, auth.user_email) {
302            (Some(name), Some(email)) if !name.trim().is_empty() && !email.trim().is_empty() => {
303                Some((name, email))
304            }
305            _ => None,
306        });
307
308    if !std::io::stdin().is_terminal() {
309        return Err(identity_setup_hint(
310            current_name.as_deref(),
311            current_email.as_deref(),
312            suggested
313                .as_ref()
314                .map(|(name, email)| (name.as_str(), email.as_str())),
315        ));
316    }
317
318    let prefill_name = suggested
319        .as_ref()
320        .map(|(name, _)| name.clone())
321        .or_else(|| current_name.clone())
322        .unwrap_or_default();
323    let prefill_email = suggested
324        .as_ref()
325        .map(|(_, email)| email.clone())
326        .or_else(|| current_email.clone())
327        .unwrap_or_default();
328
329    let mut configured_name = current_name;
330    let mut configured_email = current_email;
331
332    if configured_name.is_none() {
333        let name: String = Input::with_theme(&ColorfulTheme::default())
334            .with_prompt("Git commit author name")
335            .with_initial_text(prefill_name)
336            .interact_text()
337            .map_err(|e| format!("Failed to read git author name: {}", e))?;
338        let trimmed = name.trim().to_string();
339        if trimmed.is_empty() {
340            return Err("Git commit author name cannot be empty.".to_string());
341        }
342        git_output(project_root, &["config", "user.name", trimmed.as_str()]).await?;
343        configured_name = Some(trimmed);
344    }
345
346    if configured_email.is_none() {
347        let email: String = Input::with_theme(&ColorfulTheme::default())
348            .with_prompt("Git commit author email")
349            .with_initial_text(prefill_email)
350            .interact_text()
351            .map_err(|e| format!("Failed to read git author email: {}", e))?;
352        let trimmed = email.trim().to_string();
353        if trimmed.is_empty() {
354            return Err("Git commit author email cannot be empty.".to_string());
355        }
356        git_output(project_root, &["config", "user.email", trimmed.as_str()]).await?;
357        configured_email = Some(trimmed);
358    }
359
360    if configured_name.is_some() && configured_email.is_some() {
361        Ok(())
362    } else {
363        Err("Git commit identity is still incomplete after prompting.".to_string())
364    }
365}
366
367async fn format_git_commit_failure(project_root: &Path, error: &str) -> String {
368    if is_missing_git_identity_error(error) {
369        let suggested = SshConfig::load()
370            .ok()
371            .and_then(|config| config.cli_auth)
372            .and_then(|auth| match (auth.user_name, auth.user_email) {
373                (Some(name), Some(email))
374                    if !name.trim().is_empty() && !email.trim().is_empty() =>
375                {
376                    Some((name, email))
377                }
378                _ => None,
379            });
380
381        return identity_setup_hint(
382            git_output(project_root, &["config", "--get", "user.name"])
383                .await
384                .ok()
385                .as_deref(),
386            git_output(project_root, &["config", "--get", "user.email"])
387                .await
388                .ok()
389                .as_deref(),
390            suggested
391                .as_ref()
392                .map(|(name, email)| (name.as_str(), email.as_str())),
393        );
394    }
395
396    if is_missing_lefthook_error(error) {
397        return format!(
398            "{}\n{}\n{}\n{}",
399            "The commit hook tried to load lefthook from the current repo but the module was missing."
400                .to_string(),
401            "Run `pnpm install` or `npm install` in the repo that owns the hook, or fix the hook path so it resolves from the current checkout."
402                .to_string(),
403            "If you want to keep moving, rerun the commit with hooks disabled by setting `LEFTHOOK=0`."
404                .to_string(),
405            format!("Raw error: {}", error)
406        );
407    }
408
409    format!("Git commit failed: {}", error)
410}
411
412fn identity_setup_hint(
413    current_name: Option<&str>,
414    current_email: Option<&str>,
415    suggested: Option<(&str, &str)>,
416) -> String {
417    let mut lines = vec![
418        "Git blocked the commit because your author identity is not configured in this environment."
419            .to_string(),
420    ];
421    if current_name.is_none() {
422        lines.push("Missing `user.name`.".to_string());
423    }
424    if current_email.is_none() {
425        lines.push("Missing `user.email`.".to_string());
426    }
427    if let Some((name, email)) = suggested {
428        lines.push(format!(
429            "XBP found a likely identity to prefill: {} <{}>",
430            name, email
431        ));
432        lines.push(format!("Run `git config --local user.name \"{}\"`", name));
433        lines.push(format!("Run `git config --local user.email \"{}\"`", email));
434    } else {
435        lines.push("Set them with `git config --global user.name \"Floris\"` and `git config --global user.email \"you@example.com\"`."
436            .to_string());
437        lines.push(
438            "Use `--global` for all repos, or omit it for the current repo only.".to_string(),
439        );
440    }
441    lines.join("\n")
442}
443
444fn is_missing_git_identity_error(error: &str) -> bool {
445    error.contains("Author identity unknown")
446        || error.contains("empty ident name")
447        || error.contains("Please tell me who you are")
448}
449
450fn is_missing_lefthook_error(error: &str) -> bool {
451    let lower = error.to_ascii_lowercase();
452    (lower.contains("lefthook") && lower.contains("module not found"))
453        || (lower.contains("lefthook") && lower.contains("cannot find module"))
454}
455
456fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
457    let mut deduped = BTreeSet::new();
458
459    for path in paths {
460        if path.as_os_str().is_empty() {
461            continue;
462        }
463
464        let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
465            relative.to_path_buf()
466        } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
467            relative
468        } else {
469            path.to_path_buf()
470        };
471
472        let rendered = normalized.to_string_lossy().replace('\\', "/");
473        let trimmed = rendered.trim();
474        if !trimmed.is_empty() && trimmed != "." {
475            deduped.insert(trimmed.to_string());
476        }
477    }
478
479    deduped.into_iter().collect()
480}
481
482fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
483    let root = project_root
484        .to_string_lossy()
485        .replace('\\', "/")
486        .trim_end_matches('/')
487        .to_string();
488    let candidate = path.to_string_lossy().replace('\\', "/");
489
490    if candidate.len() <= root.len() {
491        return None;
492    }
493
494    let (prefix, suffix) = candidate.split_at(root.len());
495    if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
496        return Some(PathBuf::from(suffix.trim_start_matches('/')));
497    }
498
499    None
500}
501
502pub fn summarize_git_push_error(
503    branch: &str,
504    primary_error: &str,
505    fallback_error: &str,
506) -> String {
507    let corpus = format!("{primary_error}\n{fallback_error}").to_ascii_lowercase();
508
509    if corpus.contains("non-fast-forward")
510        || corpus.contains("tip of your current branch is behind")
511        || corpus.contains("failed to push some refs")
512    {
513        return format!(
514            "`{branch}` is behind its remote counterpart. Run `git pull --rebase origin {branch}`, then `git push`."
515        );
516    }
517
518    if corpus.contains("authentication failed")
519        || corpus.contains("could not read username")
520        || corpus.contains("403")
521        || corpus.contains("401")
522    {
523        return "Git authentication failed while pushing. Refresh your GitHub credentials and try again."
524            .to_string();
525    }
526
527    let lines = dedupe_git_error_lines(primary_error, fallback_error);
528    lines
529        .into_iter()
530        .last()
531        .unwrap_or_else(|| "git push failed".to_string())
532}
533
534fn dedupe_git_error_lines(primary_error: &str, fallback_error: &str) -> Vec<String> {
535    let mut seen = BTreeSet::new();
536    let mut lines = Vec::new();
537
538    for line in primary_error.lines().chain(fallback_error.lines()) {
539        let trimmed = line.trim();
540        if trimmed.is_empty() || trimmed.starts_with("hint:") {
541            continue;
542        }
543        let key = trimmed.to_ascii_lowercase();
544        if seen.insert(key) {
545            lines.push(trimmed.to_string());
546        }
547    }
548
549    lines
550}
551
552fn repo_name(path: &Path) -> String {
553    path.file_name()
554        .and_then(|value| value.to_str())
555        .filter(|value| !value.trim().is_empty())
556        .unwrap_or("repository")
557        .to_string()
558}
559
560#[cfg(test)]
561mod tests {
562    use super::{
563        is_missing_git_identity_error, is_missing_lefthook_error, normalize_commit_paths,
564        summarize_git_push_error,
565    };
566    use std::path::{Path, PathBuf};
567
568    #[test]
569    fn normalizes_commit_paths_relative_to_project_root() {
570        let project_root = Path::new("C:/repo");
571        let paths = vec![
572            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
573            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
574            PathBuf::from("CHANGELOG.md"),
575        ];
576
577        let normalized = normalize_commit_paths(project_root, &paths);
578
579        assert_eq!(
580            normalized,
581            vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
582        );
583    }
584
585    #[test]
586    fn detects_missing_git_identity_errors() {
587        assert!(is_missing_git_identity_error(
588            "Author identity unknown\n*** Please tell me who you are."
589        ));
590    }
591
592    #[test]
593    fn detects_lefthook_module_errors() {
594        assert!(is_missing_lefthook_error(
595            "Error: Cannot find module 'C:\\\\repo\\\\node_modules\\\\lefthook\\\\bin\\\\index.js'"
596        ));
597    }
598
599    #[test]
600    fn summarizes_non_fast_forward_push_errors_without_git_hints() {
601        let primary = "! [rejected] main -> main (non-fast-forward)";
602        let fallback = r#"error: failed to push some refs to 'https://github.com/xylex-group/xbp.git'
603hint: Updates were rejected because the tip of your current branch is behind
604hint: its remote counterpart. If you want to integrate the remote changes,
605hint: use 'git pull' before pushing again."#;
606
607        let summary = summarize_git_push_error("main", primary, fallback);
608
609        assert!(summary.contains("behind its remote counterpart"));
610        assert!(summary.contains("git pull --rebase origin main"));
611        assert!(!summary.contains("hint:"));
612        assert!(!summary.contains("git push -u origin main"));
613    }
614}