Skip to main content

xbp_cli/cli/
auto_commit.rs

1use colored::Colorize;
2use std::collections::BTreeSet;
3use std::path::{Path, PathBuf};
4use tokio::process::Command;
5
6use crate::utils::command_exists;
7
8pub struct AutoCommitRequest<'a> {
9    pub project_root: &'a Path,
10    pub paths: Vec<PathBuf>,
11    pub message: String,
12    pub action_label: &'a str,
13}
14
15pub enum AutoCommitResult {
16    Committed(AutoCommitOutcome),
17    Skipped(String),
18}
19
20pub struct AutoCommitOutcome {
21    pub repo_name: String,
22    pub repo_root: PathBuf,
23    pub branch: Option<String>,
24    pub commit_sha: String,
25    pub short_sha: String,
26    pub message: String,
27    pub committed_files: Vec<String>,
28}
29
30pub struct PushOutcome {
31    pub branch: String,
32}
33
34pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
35    if !command_exists("git") {
36        return Ok(AutoCommitResult::Skipped(
37            "Git is not installed on this machine.".to_string(),
38        ));
39    }
40
41    let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
42    {
43        Ok(root) => PathBuf::from(root),
44        Err(_) => {
45            return Ok(AutoCommitResult::Skipped(
46                "Current project is not inside a git repository.".to_string(),
47            ));
48        }
49    };
50
51    let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
52    if normalized_paths.is_empty() {
53        return Ok(AutoCommitResult::Skipped(
54            "No generated or updated files were provided for auto-commit.".to_string(),
55        ));
56    }
57
58    let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
59    add_args.extend(normalized_paths.iter().cloned());
60    git_output_owned(request.project_root, add_args).await?;
61
62    let mut diff_args = vec![
63        "diff".to_string(),
64        "--cached".to_string(),
65        "--name-only".to_string(),
66        "--".to_string(),
67    ];
68    diff_args.extend(normalized_paths.iter().cloned());
69    let committed_files = git_output_owned(request.project_root, diff_args)
70        .await?
71        .lines()
72        .map(str::trim)
73        .filter(|line| !line.is_empty())
74        .map(|line| line.replace('\\', "/"))
75        .collect::<Vec<_>>();
76
77    if committed_files.is_empty() {
78        return Ok(AutoCommitResult::Skipped(
79            "Target files did not produce any staged git diff.".to_string(),
80        ));
81    }
82
83    git_output(
84        request.project_root,
85        &["commit", "-m", request.message.as_str()],
86    )
87    .await?;
88
89    let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
90    let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
91    let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
92        .await
93        .ok()
94        .filter(|value| !value.is_empty() && value != "HEAD");
95
96    let outcome = AutoCommitOutcome {
97        repo_name: repo_name(&repo_root),
98        repo_root,
99        branch,
100        commit_sha,
101        short_sha,
102        message: request.message,
103        committed_files,
104    };
105
106    print_commit_summary(request.action_label, &outcome);
107
108    Ok(AutoCommitResult::Committed(outcome))
109}
110
111pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
112    if !command_exists("git") {
113        return Ok(None);
114    }
115
116    let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
117        .await
118        .ok()
119        .filter(|value| !value.is_empty() && value != "HEAD");
120
121    let Some(branch) = branch else {
122        return Ok(None);
123    };
124
125    git_output(project_root, &["push"]).await?;
126    Ok(Some(PushOutcome { branch }))
127}
128
129pub fn print_skip(action_label: &str, reason: &str) {
130    println!(
131        "{} {} {}",
132        "Auto-commit".bright_yellow().bold(),
133        format!("skipped for {}", action_label).bright_white(),
134        format!("({})", reason).dimmed()
135    );
136}
137
138pub fn print_push_summary(outcome: &PushOutcome) {
139    println!(
140        "{} {}",
141        "Pushed".bright_green().bold(),
142        format!("origin/{}", outcome.branch).bright_white()
143    );
144}
145
146fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
147    let branch = outcome
148        .branch
149        .as_deref()
150        .map(|value| format!(" on {}", value.bright_blue()))
151        .unwrap_or_default();
152    let files = if outcome.committed_files.is_empty() {
153        "(none)".dimmed().to_string()
154    } else {
155        outcome
156            .committed_files
157            .iter()
158            .map(|value| value.bright_white().to_string())
159            .collect::<Vec<_>>()
160            .join(", ")
161    };
162
163    println!(
164        "{} {}{}",
165        "Auto-commit".bright_green().bold(),
166        format!("created for {}", action_label).bright_white(),
167        branch
168    );
169    println!(
170        "  {} {} {}",
171        "Repo".bright_cyan().bold(),
172        outcome.repo_name.bright_white().bold(),
173        format!("({})", outcome.repo_root.display()).dimmed()
174    );
175    println!(
176        "  {} {} {}",
177        "Commit".bright_cyan().bold(),
178        outcome.short_sha.bright_green().bold(),
179        format!("({})", outcome.commit_sha).dimmed()
180    );
181    println!(
182        "  {} {}",
183        "Message".bright_cyan().bold(),
184        outcome.message.bright_magenta()
185    );
186    println!("  {} {}", "Files".bright_cyan().bold(), files);
187}
188
189async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
190    let owned_args = args
191        .iter()
192        .map(|value| value.to_string())
193        .collect::<Vec<_>>();
194    git_output_owned(project_root, owned_args).await
195}
196
197async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
198    let output = Command::new("git")
199        .current_dir(project_root)
200        .args(&args)
201        .output()
202        .await
203        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
204
205    if !output.status.success() {
206        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
207        if stderr.is_empty() {
208            return Err(format!(
209                "`git {}` failed with status {}",
210                args.join(" "),
211                output.status
212            ));
213        }
214        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
215    }
216
217    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
218}
219
220fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
221    let mut deduped = BTreeSet::new();
222
223    for path in paths {
224        if path.as_os_str().is_empty() {
225            continue;
226        }
227
228        let normalized = if path.is_absolute() {
229            path.strip_prefix(project_root).unwrap_or(path)
230        } else {
231            path.as_path()
232        };
233
234        let rendered = normalized.to_string_lossy().replace('\\', "/");
235        let trimmed = rendered.trim();
236        if !trimmed.is_empty() && trimmed != "." {
237            deduped.insert(trimmed.to_string());
238        }
239    }
240
241    deduped.into_iter().collect()
242}
243
244fn repo_name(path: &Path) -> String {
245    path.file_name()
246        .and_then(|value| value.to_str())
247        .filter(|value| !value.trim().is_empty())
248        .unwrap_or("repository")
249        .to_string()
250}
251
252#[cfg(test)]
253mod tests {
254    use super::normalize_commit_paths;
255    use std::path::{Path, PathBuf};
256
257    #[test]
258    fn normalizes_commit_paths_relative_to_project_root() {
259        let project_root = Path::new("C:/repo");
260        let paths = vec![
261            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
262            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
263            PathBuf::from("CHANGELOG.md"),
264        ];
265
266        let normalized = normalize_commit_paths(project_root, &paths);
267
268        assert_eq!(
269            normalized,
270            vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
271        );
272    }
273}