Skip to main content

_bver/
git.rs

1use std::fs;
2use std::path::Path;
3use std::process::Command;
4
5use crate::finders::find_repo_root;
6use crate::schema::{Action, GitConfig, RunPreCommit};
7
8/// Detected pre-commit tool type
9enum PreCommitTool {
10    Prek,
11    PreCommit,
12}
13
14/// Check if a command is available
15fn command_available(cmd: &str) -> bool {
16    Command::new(cmd)
17        .arg("--version")
18        .output()
19        .map(|o| o.status.success())
20        .unwrap_or(false)
21}
22
23/// Detect which pre-commit tool is available and configured
24fn detect_pre_commit_tool() -> Option<PreCommitTool> {
25    let repo_root = find_repo_root()?;
26    let hook_path = repo_root.join(".git/hooks/pre-commit");
27
28    if !hook_path.exists() {
29        return None;
30    }
31
32    // Check if the hook is a prek hook
33    if let Ok(content) = fs::read_to_string(&hook_path)
34        && content.contains("File generated by prek")
35        && command_available("prek")
36    {
37        return Some(PreCommitTool::Prek);
38    }
39
40    // Fall back to pre-commit if available
41    if command_available("pre-commit") {
42        return Some(PreCommitTool::PreCommit);
43    }
44
45    None
46}
47
48/// Run pre-commit hooks based on config setting
49pub fn maybe_run_pre_commit(setting: RunPreCommit) -> Result<(), String> {
50    match setting {
51        RunPreCommit::Disabled => Ok(()),
52        RunPreCommit::Enabled => run_pre_commit(true),
53        RunPreCommit::WhenPresent => run_pre_commit(false),
54    }
55}
56
57fn run_pre_commit(required: bool) -> Result<(), String> {
58    let tool = match detect_pre_commit_tool() {
59        Some(t) => t,
60        None => {
61            if required {
62                return Err(
63                    "pre-commit/prek is not installed but run-pre-commit is enabled".to_string(),
64                );
65            }
66            return Ok(());
67        }
68    };
69
70    let (cmd, name) = match tool {
71        PreCommitTool::Prek => ("prek", "prek"),
72        PreCommitTool::PreCommit => ("pre-commit", "pre-commit"),
73    };
74
75    println!("Running {} hooks...", name);
76
77    let status = Command::new(cmd)
78        .args(["run", "--all-files"])
79        .status()
80        .map_err(|e| format!("Failed to run {}: {}", name, e))?;
81
82    if status.success() {
83        println!("{} hooks passed.", name);
84        return Ok(());
85    }
86
87    // Failed, run it a second time (it may have auto-fixed files)
88    println!("{} hooks failed, running again...", name);
89
90    let status = Command::new(cmd)
91        .args(["run", "--all-files"])
92        .status()
93        .map_err(|e| format!("Failed to run {}: {}", name, e))?;
94
95    if status.success() {
96        println!("{} hooks passed on second run.", name);
97        Ok(())
98    } else {
99        Err(format!("{} hooks failed twice, aborting bump", name))
100    }
101}
102
103/// Run a git command and return the result
104fn git(args: &[&str]) -> Result<(), String> {
105    println!("Running: git {}", args.join(" "));
106
107    let output = Command::new("git")
108        .args(args)
109        .output()
110        .map_err(|e| format!("Failed to run git: {e}"))?;
111
112    if !output.status.success() {
113        let stderr = String::from_utf8_lossy(&output.stderr);
114        return Err(format!("git {} failed: {}", args[0], stderr.trim()));
115    }
116
117    Ok(())
118}
119
120/// Apply template substitutions for version strings
121fn apply_template(template: &str, current_version: &str, new_version: &str) -> String {
122    template
123        .replace("{current-version}", current_version)
124        .replace("{new-version}", new_version)
125}
126
127/// Run git operations based on config setting
128pub fn run_git_actions(
129    git_config: &GitConfig,
130    current_version: &str,
131    new_version: &str,
132    force: bool,
133    changed_files: &[&Path],
134) -> Result<(), String> {
135    let tag_name = apply_template(&git_config.tag_template, current_version, new_version);
136    let commit_msg = apply_template(&git_config.commit_template, current_version, new_version);
137    let branch_name = apply_template(&git_config.branch_template, current_version, new_version);
138
139    if git_config.has(Action::Branch) {
140        git_checkout_new_branch(&branch_name)?;
141    }
142    if git_config.has(Action::AddAll) {
143        git_add_all()?;
144    } else if git_config.has(Action::Commit) {
145        git_add_files(changed_files)?;
146    }
147    if git_config.has(Action::Commit) {
148        git_commit(&commit_msg)?;
149    }
150    if git_config.has(Action::Tag) {
151        git_tag(&tag_name, new_version, force)?;
152    }
153    if git_config.has(Action::Push) {
154        let set_upstream = git_config.has(Action::Branch);
155        git_push(force, set_upstream, &branch_name)?;
156        if git_config.has(Action::Tag) {
157            git_push_tag(&tag_name, force)?;
158        }
159    }
160    if git_config.has(Action::Pr) {
161        gh_pr_create(&commit_msg)?;
162    }
163
164    Ok(())
165}
166
167fn git_add_all() -> Result<(), String> {
168    git(&["add", "--all"])
169}
170
171fn git_add_files(paths: &[&Path]) -> Result<(), String> {
172    for path in paths {
173        let path_str = path.to_str().ok_or_else(|| format!("Invalid path: {:?}", path))?;
174        git(&["add", path_str])?;
175    }
176    Ok(())
177}
178
179fn git_commit(msg: &str) -> Result<(), String> {
180    git(&["commit", "-m", msg])
181}
182
183fn git_tag(tag_name: &str, version: &str, force: bool) -> Result<(), String> {
184    let msg = format!("Release {}", version);
185    if force {
186        git(&["tag", "-a", tag_name, "-m", &msg, "-f"])
187    } else {
188        git(&["tag", "-a", tag_name, "-m", &msg])
189    }
190}
191
192fn git_push(force: bool, set_upstream: bool, branch: &str) -> Result<(), String> {
193    match (force, set_upstream) {
194        (true, true) => git(&["push", "-u", "origin", branch, "--force"]),
195        (true, false) => git(&["push", "--force"]),
196        (false, true) => git(&["push", "-u", "origin", branch]),
197        (false, false) => git(&["push"]),
198    }
199}
200
201fn git_push_tag(tag_name: &str, force: bool) -> Result<(), String> {
202    if force {
203        git(&["push", "origin", tag_name, "--force"])
204    } else {
205        git(&["push", "origin", tag_name])
206    }
207}
208
209fn git_checkout_new_branch(name: &str) -> Result<(), String> {
210    git(&["checkout", "-b", name])
211}
212
213fn gh_pr_create(title: &str) -> Result<(), String> {
214    println!("Running: gh pr create --title {:?} --body \"\"", title);
215
216    let output = Command::new("gh")
217        .args(["pr", "create", "--title", title, "--body", ""])
218        .output()
219        .map_err(|e| format!("Failed to run gh: {e}"))?;
220
221    if !output.status.success() {
222        let stderr = String::from_utf8_lossy(&output.stderr);
223        return Err(format!("gh pr create failed: {}", stderr.trim()));
224    }
225
226    Ok(())
227}