1use anyhow::{bail, Context, Result};
2use std::fs;
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6pub fn get_staged_diff() -> Result<String> {
8 let output = Command::new("git")
9 .args(["diff", "--staged"])
10 .output()
11 .context("Failed to run git diff --staged")?;
12
13 if !output.status.success() {
14 let stderr = String::from_utf8_lossy(&output.stderr);
15 bail!("git diff --staged failed: {stderr}");
16 }
17
18 let diff = String::from_utf8_lossy(&output.stdout).to_string();
19
20 if diff.trim().is_empty() {
21 bail!(
22 "No staged changes found. Stage files with {} first.",
23 colored::Colorize::yellow("git add <files>")
24 );
25 }
26
27 Ok(diff)
28}
29
30pub fn list_staged_files() -> Result<Vec<String>> {
32 let output = Command::new("git")
33 .args(["diff", "--staged", "--name-only"])
34 .output()
35 .context("Failed to run git diff --staged --name-only")?;
36
37 if !output.status.success() {
38 let stderr = String::from_utf8_lossy(&output.stderr);
39 bail!("git diff --staged --name-only failed: {stderr}");
40 }
41
42 let files = String::from_utf8_lossy(&output.stdout)
43 .lines()
44 .map(str::trim)
45 .filter(|line| !line.is_empty())
46 .map(ToString::to_string)
47 .collect::<Vec<_>>();
48
49 Ok(files)
50}
51
52pub fn find_repo_root() -> Result<String> {
54 let output = Command::new("git")
55 .args(["rev-parse", "--show-toplevel"])
56 .output()
57 .context("Failed to run git rev-parse")?;
58
59 if !output.status.success() {
60 bail!("Not in a git repository");
61 }
62
63 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
64}
65
66pub fn run_commit(message: &str, extra_args: &[String], suppress_output: bool) -> Result<()> {
68 let mut cmd = Command::new("git");
69 cmd.args(["commit", "-m", message]);
70 cmd.args(extra_args);
71 configure_stdio(&mut cmd, suppress_output);
72 let status = cmd.status().context("Failed to run git commit")?;
73
74 if !status.success() {
75 bail!("git commit exited with status {status}");
76 }
77
78 Ok(())
79}
80
81pub fn run_push(suppress_output: bool) -> Result<()> {
83 let mut cmd = Command::new("git");
84 cmd.arg("push");
85 configure_stdio(&mut cmd, suppress_output);
86
87 let status = cmd.status().context("Failed to run git push")?;
88 if !status.success() {
89 bail!("git push exited with status {status}");
90 }
91
92 Ok(())
93}
94
95pub fn get_latest_tag() -> Result<Option<String>> {
97 let output = Command::new("git")
98 .args(["tag", "--sort=-version:refname"])
99 .output()
100 .context("Failed to run git tag --sort=-version:refname")?;
101
102 if !output.status.success() {
103 let stderr = String::from_utf8_lossy(&output.stderr);
104 bail!("git tag --sort=-version:refname failed: {stderr}");
105 }
106
107 let latest = String::from_utf8_lossy(&output.stdout)
108 .lines()
109 .map(str::trim)
110 .find(|line| !line.is_empty())
111 .map(ToString::to_string);
112
113 Ok(latest)
114}
115
116pub fn compute_next_minor_tag(latest: Option<&str>) -> Result<String> {
118 let Some(latest_tag) = latest else {
119 return Ok("0.1.0".to_string());
120 };
121
122 let (major, minor, _patch) = parse_semver_tag(latest_tag)?;
123 Ok(format!("{major}.{}.0", minor + 1))
124}
125
126pub fn create_tag(tag_name: &str, suppress_output: bool) -> Result<()> {
128 let mut cmd = Command::new("git");
129 cmd.args(["tag", tag_name]);
130 configure_stdio(&mut cmd, suppress_output);
131 let status = cmd.status().context("Failed to run git tag")?;
132
133 if !status.success() {
134 bail!("git tag exited with status {status}");
135 }
136
137 Ok(())
138}
139
140pub fn is_head_pushed() -> Result<bool> {
142 if !has_upstream_branch()? {
143 return Ok(false);
144 }
145
146 let output = Command::new("git")
147 .args(["branch", "-r", "--contains", "HEAD"])
148 .output()
149 .context("Failed to determine whether HEAD is pushed")?;
150
151 if !output.status.success() {
152 let stderr = String::from_utf8_lossy(&output.stderr);
153 bail!("git branch -r --contains HEAD failed: {stderr}");
154 }
155
156 let stdout = String::from_utf8_lossy(&output.stdout);
157 let pushed = stdout
158 .lines()
159 .map(str::trim)
160 .any(|line| !line.is_empty() && !line.contains("->"));
161 Ok(pushed)
162}
163
164pub fn head_is_merge_commit() -> Result<bool> {
166 ensure_head_exists()?;
167
168 let output = Command::new("git")
169 .args(["rev-list", "--parents", "-n", "1", "HEAD"])
170 .output()
171 .context("Failed to inspect latest commit parents")?;
172
173 if !output.status.success() {
174 let stderr = String::from_utf8_lossy(&output.stderr);
175 bail!("git rev-list --parents -n 1 HEAD failed: {stderr}");
176 }
177
178 let parent_count = String::from_utf8_lossy(&output.stdout)
179 .split_whitespace()
180 .count()
181 .saturating_sub(1);
182 Ok(parent_count > 1)
183}
184
185pub fn undo_last_commit_soft(suppress_output: bool) -> Result<()> {
187 ensure_head_exists()?;
188
189 let mut cmd = Command::new("git");
190 cmd.args(["reset", "--soft", "HEAD~1"]);
191 configure_stdio(&mut cmd, suppress_output);
192
193 let status = cmd
194 .status()
195 .context("Failed to run git reset --soft HEAD~1")?;
196 if !status.success() {
197 bail!("git reset --soft HEAD~1 exited with status {status}");
198 }
199 Ok(())
200}
201
202pub fn has_upstream_branch() -> Result<bool> {
203 let status = Command::new("git")
204 .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
205 .stdout(Stdio::null())
206 .stderr(Stdio::null())
207 .status()
208 .context("Failed to detect upstream branch")?;
209 Ok(status.success())
210}
211
212pub fn ensure_head_exists() -> Result<()> {
213 let status = Command::new("git")
214 .args(["rev-parse", "--verify", "HEAD"])
215 .stdout(Stdio::null())
216 .stderr(Stdio::null())
217 .status()
218 .context("Failed to run git rev-parse --verify HEAD")?;
219
220 if !status.success() {
221 bail!("No commits found in this repository.");
222 }
223 Ok(())
224}
225
226fn configure_stdio(cmd: &mut Command, suppress_output: bool) {
227 if suppress_output {
228 cmd.stdout(Stdio::null()).stderr(Stdio::null());
229 }
230}
231
232pub fn ensure_commit_exists(commit: &str) -> Result<()> {
233 let status = Command::new("git")
234 .args(["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
235 .stdout(Stdio::null())
236 .stderr(Stdio::null())
237 .status()
238 .with_context(|| format!("Failed to verify commit reference {commit}"))?;
239
240 if !status.success() {
241 bail!("Commit reference not found: {commit}");
242 }
243 Ok(())
244}
245
246pub fn get_commit_diff(commit: &str) -> Result<String> {
247 ensure_commit_exists(commit)?;
248 let output = Command::new("git")
249 .args(["show", "--format=", "--no-color", commit])
250 .output()
251 .with_context(|| format!("Failed to run git show for {commit}"))?;
252
253 if !output.status.success() {
254 let stderr = String::from_utf8_lossy(&output.stderr);
255 bail!("git show failed for {commit}: {stderr}");
256 }
257
258 let diff = String::from_utf8_lossy(&output.stdout).to_string();
259 if diff.trim().is_empty() {
260 bail!("Selected commit has no diff to analyze: {commit}");
261 }
262 Ok(diff)
263}
264
265pub fn get_range_diff(older: &str, newer: &str) -> Result<String> {
266 ensure_commit_exists(older)?;
267 ensure_commit_exists(newer)?;
268
269 let output = Command::new("git")
270 .args(["diff", "--no-color", older, newer])
271 .output()
272 .with_context(|| format!("Failed to run git diff {older} {newer}"))?;
273
274 if !output.status.success() {
275 let stderr = String::from_utf8_lossy(&output.stderr);
276 bail!("git diff failed for {older}..{newer}: {stderr}");
277 }
278
279 let diff = String::from_utf8_lossy(&output.stdout).to_string();
280 if diff.trim().is_empty() {
281 bail!("No diff found for range {older}..{newer}");
282 }
283 Ok(diff)
284}
285
286pub fn is_head_commit(commit: &str) -> Result<bool> {
287 ensure_commit_exists(commit)?;
288 Ok(resolve_commit("HEAD")? == resolve_commit(commit)?)
289}
290
291pub fn commit_is_merge(commit: &str) -> Result<bool> {
292 ensure_commit_exists(commit)?;
293 let output = Command::new("git")
294 .args(["rev-list", "--parents", "-n", "1", commit])
295 .output()
296 .with_context(|| format!("Failed to inspect parents for {commit}"))?;
297
298 if !output.status.success() {
299 let stderr = String::from_utf8_lossy(&output.stderr);
300 bail!("git rev-list failed for {commit}: {stderr}");
301 }
302
303 let parent_count = String::from_utf8_lossy(&output.stdout)
304 .split_whitespace()
305 .count()
306 .saturating_sub(1);
307 Ok(parent_count > 1)
308}
309
310pub fn commit_is_pushed(commit: &str) -> Result<bool> {
311 ensure_commit_exists(commit)?;
312 if !has_upstream_branch()? {
313 return Ok(false);
314 }
315
316 let output = Command::new("git")
317 .args(["branch", "-r", "--contains", commit])
318 .output()
319 .with_context(|| format!("Failed to determine whether {commit} is pushed"))?;
320
321 if !output.status.success() {
322 let stderr = String::from_utf8_lossy(&output.stderr);
323 bail!("git branch -r --contains {commit} failed: {stderr}");
324 }
325
326 let stdout = String::from_utf8_lossy(&output.stdout);
327 Ok(stdout
328 .lines()
329 .map(str::trim)
330 .any(|line| !line.is_empty() && !line.contains("->")))
331}
332
333pub fn rewrite_commit_message(target: &str, message: &str, suppress_output: bool) -> Result<()> {
334 ensure_commit_exists(target)?;
335
336 if is_head_commit(target)? {
337 let mut cmd = Command::new("git");
338 cmd.args(["commit", "--amend", "-m", message]);
339 configure_stdio(&mut cmd, suppress_output);
340 let status = cmd.status().context("Failed to run git commit --amend")?;
341 if !status.success() {
342 bail!("git commit --amend exited with status {status}");
343 }
344 return Ok(());
345 }
346
347 if commit_is_merge(target)? {
348 bail!("Altering non-HEAD merge commits is not supported.");
349 }
350
351 ensure_ancestor_of_head(target)?;
352 reword_non_head_commit(target, message, suppress_output)
353}
354
355fn resolve_commit(commit: &str) -> Result<String> {
356 let output = Command::new("git")
357 .args(["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
358 .output()
359 .with_context(|| format!("Failed to resolve commit {commit}"))?;
360
361 if !output.status.success() {
362 let stderr = String::from_utf8_lossy(&output.stderr);
363 bail!("Failed to resolve commit {commit}: {stderr}");
364 }
365
366 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
367}
368
369fn ensure_ancestor_of_head(commit: &str) -> Result<()> {
370 let status = Command::new("git")
371 .args(["merge-base", "--is-ancestor", commit, "HEAD"])
372 .stdout(Stdio::null())
373 .stderr(Stdio::null())
374 .status()
375 .with_context(|| format!("Failed to check whether {commit} is an ancestor of HEAD"))?;
376
377 if !status.success() {
378 bail!("Target commit must be on the current branch and reachable from HEAD.");
379 }
380 Ok(())
381}
382
383fn reword_non_head_commit(target: &str, message: &str, suppress_output: bool) -> Result<()> {
384 let parent = format!("{target}^");
385 let temp = std::env::temp_dir();
386 let sequence_editor = temp.join(format!("cgen-seq-editor-{}.sh", std::process::id()));
387 let message_editor = temp.join(format!("cgen-msg-editor-{}.sh", std::process::id()));
388
389 write_sequence_editor_script(&sequence_editor)?;
390 write_message_editor_script(&message_editor)?;
391
392 let mut cmd = Command::new("git");
393 cmd.args(["rebase", "-i", &parent]);
394 cmd.env("GIT_SEQUENCE_EDITOR", script_command(&sequence_editor));
395 cmd.env("GIT_EDITOR", script_command(&message_editor));
396 cmd.env("CGEN_NEW_MESSAGE", message);
397 configure_stdio(&mut cmd, suppress_output);
398
399 let status = cmd.status().context("Failed to run git rebase -i")?;
400
401 let _ = fs::remove_file(&sequence_editor);
402 let _ = fs::remove_file(&message_editor);
403
404 if !status.success() {
405 bail!(
406 "Rewriting commit message failed during rebase. Resolve conflicts and run `git rebase --abort` if needed."
407 );
408 }
409 Ok(())
410}
411
412fn write_sequence_editor_script(path: &Path) -> Result<()> {
413 let script = r#"#!/bin/sh
414set -e
415todo="$1"
416tmp="${todo}.cgen"
417first=1
418
419while IFS= read -r line; do
420 if [ "$first" -eq 1 ] && printf '%s\n' "$line" | grep -q '^pick '; then
421 printf '%s\n' "$line" | sed 's/^pick /reword /' >> "$tmp"
422 first=0
423 else
424 printf '%s\n' "$line" >> "$tmp"
425 fi
426done < "$todo"
427
428mv "$tmp" "$todo"
429"#;
430 fs::write(path, script).with_context(|| format!("Failed to write {:?}", path))?;
431 make_executable(path)?;
432 Ok(())
433}
434
435fn write_message_editor_script(path: &Path) -> Result<()> {
436 let script = r#"#!/bin/sh
437set -e
438msg_file="$1"
439printf '%s\n' "$CGEN_NEW_MESSAGE" > "$msg_file"
440"#;
441 fs::write(path, script).with_context(|| format!("Failed to write {:?}", path))?;
442 make_executable(path)?;
443 Ok(())
444}
445
446fn make_executable(path: &Path) -> Result<()> {
447 #[cfg(not(unix))]
448 let _ = path;
449
450 #[cfg(unix)]
451 {
452 use std::os::unix::fs::PermissionsExt;
453 let mut perms = fs::metadata(path)?.permissions();
454 perms.set_mode(0o700);
455 fs::set_permissions(path, perms)?;
456 }
457 Ok(())
458}
459
460fn script_command(path: &Path) -> String {
461 path.to_string_lossy().replace('\\', "/")
462}
463
464fn parse_semver_tag(tag: &str) -> Result<(u64, u64, u64)> {
465 let parts: Vec<&str> = tag.trim().split('.').collect();
466 if parts.len() != 3 {
467 bail!("Latest tag '{tag}' is not valid semantic versioning (expected MAJOR.MINOR.PATCH).");
468 }
469
470 let major = parts[0]
471 .parse::<u64>()
472 .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
473 let minor = parts[1]
474 .parse::<u64>()
475 .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
476 let patch = parts[2]
477 .parse::<u64>()
478 .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
479
480 Ok((major, minor, patch))
481}