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