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    match git_output(project_root, &["push"]).await {
126        Ok(_) => {}
127        Err(push_error) => {
128            git_output(project_root, &["push", "-u", "origin", branch.as_str()])
129                .await
130                .map_err(|fallback_error| {
131                    format!(
132                        "Git push failed (`git push`: {}; `git push -u origin {}`: {})",
133                        push_error, branch, fallback_error
134                    )
135                })?;
136        }
137    }
138    Ok(Some(PushOutcome { branch }))
139}
140
141pub fn print_skip(action_label: &str, reason: &str) {
142    println!(
143        "{} {} {}",
144        "Auto-commit".bright_yellow().bold(),
145        format!("skipped for {}", action_label).bright_white(),
146        format!("({})", reason).dimmed()
147    );
148}
149
150pub fn print_push_summary(outcome: &PushOutcome) {
151    println!(
152        "{} {}",
153        "Pushed".bright_green().bold(),
154        format!("origin/{}", outcome.branch).bright_white()
155    );
156}
157
158fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
159    let branch = outcome
160        .branch
161        .as_deref()
162        .map(|value| format!(" on {}", value.bright_blue()))
163        .unwrap_or_default();
164    let files = if outcome.committed_files.is_empty() {
165        "(none)".dimmed().to_string()
166    } else {
167        outcome
168            .committed_files
169            .iter()
170            .map(|value| value.bright_white().to_string())
171            .collect::<Vec<_>>()
172            .join(", ")
173    };
174
175    println!(
176        "{} {}{}",
177        "Auto-commit".bright_green().bold(),
178        format!("created for {}", action_label).bright_white(),
179        branch
180    );
181    println!(
182        "  {} {} {}",
183        "Repo".bright_cyan().bold(),
184        outcome.repo_name.bright_white().bold(),
185        format!("({})", outcome.repo_root.display()).dimmed()
186    );
187    println!(
188        "  {} {} {}",
189        "Commit".bright_cyan().bold(),
190        outcome.short_sha.bright_green().bold(),
191        format!("({})", outcome.commit_sha).dimmed()
192    );
193    println!(
194        "  {} {}",
195        "Message".bright_cyan().bold(),
196        outcome.message.bright_magenta()
197    );
198    println!("  {} {}", "Files".bright_cyan().bold(), files);
199}
200
201async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
202    let owned_args = args
203        .iter()
204        .map(|value| value.to_string())
205        .collect::<Vec<_>>();
206    git_output_owned(project_root, owned_args).await
207}
208
209async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
210    let output = Command::new("git")
211        .current_dir(project_root)
212        .args(&args)
213        .output()
214        .await
215        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
216
217    if !output.status.success() {
218        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
219        if stderr.is_empty() {
220            return Err(format!(
221                "`git {}` failed with status {}",
222                args.join(" "),
223                output.status
224            ));
225        }
226        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
227    }
228
229    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
230}
231
232fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
233    let mut deduped = BTreeSet::new();
234
235    for path in paths {
236        if path.as_os_str().is_empty() {
237            continue;
238        }
239
240        let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
241            relative.to_path_buf()
242        } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
243            relative
244        } else {
245            path.to_path_buf()
246        };
247
248        let rendered = normalized.to_string_lossy().replace('\\', "/");
249        let trimmed = rendered.trim();
250        if !trimmed.is_empty() && trimmed != "." {
251            deduped.insert(trimmed.to_string());
252        }
253    }
254
255    deduped.into_iter().collect()
256}
257
258fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
259    let root = project_root
260        .to_string_lossy()
261        .replace('\\', "/")
262        .trim_end_matches('/')
263        .to_string();
264    let candidate = path.to_string_lossy().replace('\\', "/");
265
266    if candidate.len() <= root.len() {
267        return None;
268    }
269
270    let (prefix, suffix) = candidate.split_at(root.len());
271    if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
272        return Some(PathBuf::from(suffix.trim_start_matches('/')));
273    }
274
275    None
276}
277
278fn repo_name(path: &Path) -> String {
279    path.file_name()
280        .and_then(|value| value.to_str())
281        .filter(|value| !value.trim().is_empty())
282        .unwrap_or("repository")
283        .to_string()
284}
285
286#[cfg(test)]
287mod tests {
288    use super::normalize_commit_paths;
289    use std::path::{Path, PathBuf};
290
291    #[test]
292    fn normalizes_commit_paths_relative_to_project_root() {
293        let project_root = Path::new("C:/repo");
294        let paths = vec![
295            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
296            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
297            PathBuf::from("CHANGELOG.md"),
298        ];
299
300        let normalized = normalize_commit_paths(project_root, &paths);
301
302        assert_eq!(
303            normalized,
304            vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
305        );
306    }
307}