Skip to main content

auto_commit_rs/
git.rs

1use anyhow::{bail, Context, Result};
2use glob::Pattern;
3use std::fs;
4use std::path::Path;
5use std::process::{Command, Stdio};
6
7/// Get the output of `git diff --staged`
8pub fn get_staged_diff() -> Result<String> {
9    let output = Command::new("git")
10        .args(["diff", "--staged"])
11        .output()
12        .context("Failed to run git diff --staged")?;
13
14    if !output.status.success() {
15        let stderr = String::from_utf8_lossy(&output.stderr);
16        bail!("git diff --staged failed: {stderr}");
17    }
18
19    let diff = String::from_utf8_lossy(&output.stdout).to_string();
20
21    if diff.trim().is_empty() {
22        bail!(
23            "No staged changes found. Stage files with {} first.",
24            colored::Colorize::yellow("git add <files>")
25        );
26    }
27
28    Ok(diff)
29}
30
31/// List staged file paths
32pub fn list_staged_files() -> Result<Vec<String>> {
33    let output = Command::new("git")
34        .args(["diff", "--staged", "--name-only"])
35        .output()
36        .context("Failed to run git diff --staged --name-only")?;
37
38    if !output.status.success() {
39        let stderr = String::from_utf8_lossy(&output.stderr);
40        bail!("git diff --staged --name-only failed: {stderr}");
41    }
42
43    let files = String::from_utf8_lossy(&output.stdout)
44        .lines()
45        .map(str::trim)
46        .filter(|line| !line.is_empty())
47        .map(ToString::to_string)
48        .collect::<Vec<_>>();
49
50    Ok(files)
51}
52
53/// Find the git repository root directory
54pub fn find_repo_root() -> Result<String> {
55    let output = Command::new("git")
56        .args(["rev-parse", "--show-toplevel"])
57        .output()
58        .context("Failed to run git rev-parse")?;
59
60    if !output.status.success() {
61        bail!("Not in a git repository");
62    }
63
64    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
65}
66
67/// Run `git commit -m "<message>" [extra_args...]`
68pub fn run_commit(message: &str, extra_args: &[String], suppress_output: bool) -> Result<()> {
69    let mut cmd = Command::new("git");
70    cmd.args(["commit", "-m", message]);
71    cmd.args(extra_args);
72    configure_stdio(&mut cmd, suppress_output);
73    let status = cmd.status().context("Failed to run git commit")?;
74
75    if !status.success() {
76        bail!("git commit exited with status {status}");
77    }
78
79    Ok(())
80}
81
82/// Run `git push`
83pub fn run_push(suppress_output: bool) -> Result<()> {
84    let mut cmd = Command::new("git");
85    cmd.arg("push");
86    configure_stdio(&mut cmd, suppress_output);
87
88    let status = cmd.status().context("Failed to run git push")?;
89    if !status.success() {
90        bail!("git push exited with status {status}");
91    }
92
93    Ok(())
94}
95
96/// Returns the latest tag according to git version sorting.
97pub fn get_latest_tag() -> Result<Option<String>> {
98    let output = Command::new("git")
99        .args(["tag", "--sort=-version:refname"])
100        .output()
101        .context("Failed to run git tag --sort=-version:refname")?;
102
103    if !output.status.success() {
104        let stderr = String::from_utf8_lossy(&output.stderr);
105        bail!("git tag --sort=-version:refname failed: {stderr}");
106    }
107
108    let latest = String::from_utf8_lossy(&output.stdout)
109        .lines()
110        .map(str::trim)
111        .find(|line| !line.is_empty())
112        .map(ToString::to_string);
113
114    Ok(latest)
115}
116
117/// Compute the next minor semver tag from latest tag.
118pub fn compute_next_minor_tag(latest: Option<&str>) -> Result<String> {
119    let Some(latest_tag) = latest else {
120        return Ok("0.1.0".to_string());
121    };
122
123    let (major, minor, _patch) = parse_semver_tag(latest_tag)?;
124    Ok(format!("{major}.{}.0", minor + 1))
125}
126
127/// Create a git lightweight tag.
128pub fn create_tag(tag_name: &str, suppress_output: bool) -> Result<()> {
129    let mut cmd = Command::new("git");
130    cmd.args(["tag", tag_name]);
131    configure_stdio(&mut cmd, suppress_output);
132    let status = cmd.status().context("Failed to run git tag")?;
133
134    if !status.success() {
135        bail!("git tag exited with status {status}");
136    }
137
138    Ok(())
139}
140
141/// Returns true when HEAD exists on upstream branch
142pub fn is_head_pushed() -> Result<bool> {
143    if !has_upstream_branch()? {
144        return Ok(false);
145    }
146
147    let output = Command::new("git")
148        .args(["branch", "-r", "--contains", "HEAD"])
149        .output()
150        .context("Failed to determine whether HEAD is pushed")?;
151
152    if !output.status.success() {
153        let stderr = String::from_utf8_lossy(&output.stderr);
154        bail!("git branch -r --contains HEAD failed: {stderr}");
155    }
156
157    let stdout = String::from_utf8_lossy(&output.stdout);
158    let pushed = stdout
159        .lines()
160        .map(str::trim)
161        .any(|line| !line.is_empty() && !line.contains("->"));
162    Ok(pushed)
163}
164
165/// Returns true if HEAD has multiple parents
166pub fn head_is_merge_commit() -> Result<bool> {
167    ensure_head_exists()?;
168
169    let output = Command::new("git")
170        .args(["rev-list", "--parents", "-n", "1", "HEAD"])
171        .output()
172        .context("Failed to inspect latest commit parents")?;
173
174    if !output.status.success() {
175        let stderr = String::from_utf8_lossy(&output.stderr);
176        bail!("git rev-list --parents -n 1 HEAD failed: {stderr}");
177    }
178
179    let parent_count = String::from_utf8_lossy(&output.stdout)
180        .split_whitespace()
181        .count()
182        .saturating_sub(1);
183    Ok(parent_count > 1)
184}
185
186/// Undo latest commit, keep all changes staged
187pub fn undo_last_commit_soft(suppress_output: bool) -> Result<()> {
188    ensure_head_exists()?;
189
190    let mut cmd = Command::new("git");
191    cmd.args(["reset", "--soft", "HEAD~1"]);
192    configure_stdio(&mut cmd, suppress_output);
193
194    let status = cmd
195        .status()
196        .context("Failed to run git reset --soft HEAD~1")?;
197    if !status.success() {
198        bail!("git reset --soft HEAD~1 exited with status {status}");
199    }
200    Ok(())
201}
202
203pub fn has_upstream_branch() -> Result<bool> {
204    let status = Command::new("git")
205        .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
206        .stdout(Stdio::null())
207        .stderr(Stdio::null())
208        .status()
209        .context("Failed to detect upstream branch")?;
210    Ok(status.success())
211}
212
213pub fn ensure_head_exists() -> Result<()> {
214    let status = Command::new("git")
215        .args(["rev-parse", "--verify", "HEAD"])
216        .stdout(Stdio::null())
217        .stderr(Stdio::null())
218        .status()
219        .context("Failed to run git rev-parse --verify HEAD")?;
220
221    if !status.success() {
222        bail!("No commits found in this repository.");
223    }
224    Ok(())
225}
226
227fn configure_stdio(cmd: &mut Command, suppress_output: bool) {
228    if suppress_output {
229        cmd.stdout(Stdio::null()).stderr(Stdio::null());
230    }
231}
232
233pub fn ensure_commit_exists(commit: &str) -> Result<()> {
234    let status = Command::new("git")
235        .args(["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
236        .stdout(Stdio::null())
237        .stderr(Stdio::null())
238        .status()
239        .with_context(|| format!("Failed to verify commit reference {commit}"))?;
240
241    if !status.success() {
242        bail!("Commit reference not found: {commit}");
243    }
244    Ok(())
245}
246
247pub fn get_commit_diff(commit: &str) -> Result<String> {
248    ensure_commit_exists(commit)?;
249    let output = Command::new("git")
250        .args(["show", "--format=", "--no-color", commit])
251        .output()
252        .with_context(|| format!("Failed to run git show for {commit}"))?;
253
254    if !output.status.success() {
255        let stderr = String::from_utf8_lossy(&output.stderr);
256        bail!("git show failed for {commit}: {stderr}");
257    }
258
259    let diff = String::from_utf8_lossy(&output.stdout).to_string();
260    if diff.trim().is_empty() {
261        bail!("Selected commit has no diff to analyze: {commit}");
262    }
263    Ok(diff)
264}
265
266pub fn get_range_diff(older: &str, newer: &str) -> Result<String> {
267    ensure_commit_exists(older)?;
268    ensure_commit_exists(newer)?;
269
270    let output = Command::new("git")
271        .args(["diff", "--no-color", older, newer])
272        .output()
273        .with_context(|| format!("Failed to run git diff {older} {newer}"))?;
274
275    if !output.status.success() {
276        let stderr = String::from_utf8_lossy(&output.stderr);
277        bail!("git diff failed for {older}..{newer}: {stderr}");
278    }
279
280    let diff = String::from_utf8_lossy(&output.stdout).to_string();
281    if diff.trim().is_empty() {
282        bail!("No diff found for range {older}..{newer}");
283    }
284    Ok(diff)
285}
286
287pub fn is_head_commit(commit: &str) -> Result<bool> {
288    ensure_commit_exists(commit)?;
289    Ok(resolve_commit("HEAD")? == resolve_commit(commit)?)
290}
291
292pub fn commit_is_merge(commit: &str) -> Result<bool> {
293    ensure_commit_exists(commit)?;
294    let output = Command::new("git")
295        .args(["rev-list", "--parents", "-n", "1", commit])
296        .output()
297        .with_context(|| format!("Failed to inspect parents for {commit}"))?;
298
299    if !output.status.success() {
300        let stderr = String::from_utf8_lossy(&output.stderr);
301        bail!("git rev-list failed for {commit}: {stderr}");
302    }
303
304    let parent_count = String::from_utf8_lossy(&output.stdout)
305        .split_whitespace()
306        .count()
307        .saturating_sub(1);
308    Ok(parent_count > 1)
309}
310
311pub fn commit_is_pushed(commit: &str) -> Result<bool> {
312    ensure_commit_exists(commit)?;
313    if !has_upstream_branch()? {
314        return Ok(false);
315    }
316
317    let output = Command::new("git")
318        .args(["branch", "-r", "--contains", commit])
319        .output()
320        .with_context(|| format!("Failed to determine whether {commit} is pushed"))?;
321
322    if !output.status.success() {
323        let stderr = String::from_utf8_lossy(&output.stderr);
324        bail!("git branch -r --contains {commit} failed: {stderr}");
325    }
326
327    let stdout = String::from_utf8_lossy(&output.stdout);
328    Ok(stdout
329        .lines()
330        .map(str::trim)
331        .any(|line| !line.is_empty() && !line.contains("->")))
332}
333
334pub fn rewrite_commit_message(target: &str, message: &str, suppress_output: bool) -> Result<()> {
335    ensure_commit_exists(target)?;
336
337    if is_head_commit(target)? {
338        let mut cmd = Command::new("git");
339        cmd.args(["commit", "--amend", "-m", message]);
340        configure_stdio(&mut cmd, suppress_output);
341        let status = cmd.status().context("Failed to run git commit --amend")?;
342        if !status.success() {
343            bail!("git commit --amend exited with status {status}");
344        }
345        return Ok(());
346    }
347
348    if commit_is_merge(target)? {
349        bail!("Altering non-HEAD merge commits is not supported.");
350    }
351
352    ensure_ancestor_of_head(target)?;
353    reword_non_head_commit(target, message, suppress_output)
354}
355
356fn resolve_commit(commit: &str) -> Result<String> {
357    let output = Command::new("git")
358        .args(["rev-parse", "--verify", &format!("{commit}^{{commit}}")])
359        .output()
360        .with_context(|| format!("Failed to resolve commit {commit}"))?;
361
362    if !output.status.success() {
363        let stderr = String::from_utf8_lossy(&output.stderr);
364        bail!("Failed to resolve commit {commit}: {stderr}");
365    }
366
367    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
368}
369
370pub fn ensure_ancestor_of_head(commit: &str) -> Result<()> {
371    let status = Command::new("git")
372        .args(["merge-base", "--is-ancestor", commit, "HEAD"])
373        .stdout(Stdio::null())
374        .stderr(Stdio::null())
375        .status()
376        .with_context(|| format!("Failed to check whether {commit} is an ancestor of HEAD"))?;
377
378    if !status.success() {
379        bail!("Target commit must be on the current branch and reachable from HEAD.");
380    }
381    Ok(())
382}
383
384fn reword_non_head_commit(target: &str, message: &str, suppress_output: bool) -> Result<()> {
385    let parent = format!("{target}^");
386    let temp = std::env::temp_dir();
387    let sequence_editor = temp.join(format!("cgen-seq-editor-{}.sh", std::process::id()));
388    let message_editor = temp.join(format!("cgen-msg-editor-{}.sh", std::process::id()));
389
390    write_sequence_editor_script(&sequence_editor)?;
391    write_message_editor_script(&message_editor)?;
392
393    let mut cmd = Command::new("git");
394    cmd.args(["rebase", "-i", &parent]);
395    cmd.env("GIT_SEQUENCE_EDITOR", script_command(&sequence_editor));
396    cmd.env("GIT_EDITOR", script_command(&message_editor));
397    cmd.env("CGEN_NEW_MESSAGE", message);
398    configure_stdio(&mut cmd, suppress_output);
399
400    let status = cmd.status().context("Failed to run git rebase -i")?;
401
402    let _ = fs::remove_file(&sequence_editor);
403    let _ = fs::remove_file(&message_editor);
404
405    if !status.success() {
406        bail!(
407            "Rewriting commit message failed during rebase. Resolve conflicts and run `git rebase --abort` if needed."
408        );
409    }
410    Ok(())
411}
412
413fn write_sequence_editor_script(path: &Path) -> Result<()> {
414    let script = r#"#!/bin/sh
415set -e
416todo="$1"
417tmp="${todo}.cgen"
418first=1
419
420while IFS= read -r line; do
421  if [ "$first" -eq 1 ] && printf '%s\n' "$line" | grep -q '^pick '; then
422    printf '%s\n' "$line" | sed 's/^pick /reword /' >> "$tmp"
423    first=0
424  else
425    printf '%s\n' "$line" >> "$tmp"
426  fi
427done < "$todo"
428
429mv "$tmp" "$todo"
430"#;
431    fs::write(path, script).with_context(|| format!("Failed to write {:?}", path))?;
432    make_executable(path)?;
433    Ok(())
434}
435
436fn write_message_editor_script(path: &Path) -> Result<()> {
437    let script = r#"#!/bin/sh
438set -e
439msg_file="$1"
440printf '%s\n' "$CGEN_NEW_MESSAGE" > "$msg_file"
441"#;
442    fs::write(path, script).with_context(|| format!("Failed to write {:?}", path))?;
443    make_executable(path)?;
444    Ok(())
445}
446
447fn make_executable(path: &Path) -> Result<()> {
448    #[cfg(not(unix))]
449    let _ = path;
450
451    #[cfg(unix)]
452    {
453        use std::os::unix::fs::PermissionsExt;
454        let mut perms = fs::metadata(path)?.permissions();
455        perms.set_mode(0o700);
456        fs::set_permissions(path, perms)?;
457    }
458    Ok(())
459}
460
461fn script_command(path: &Path) -> String {
462    path.to_string_lossy().replace('\\', "/")
463}
464
465fn parse_semver_tag(tag: &str) -> Result<(u64, u64, u64)> {
466    let parts: Vec<&str> = tag.trim().split('.').collect();
467    if parts.len() != 3 {
468        bail!("Latest tag '{tag}' is not valid semantic versioning (expected MAJOR.MINOR.PATCH).");
469    }
470
471    let major = parts[0]
472        .parse::<u64>()
473        .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
474    let minor = parts[1]
475        .parse::<u64>()
476        .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
477    let patch = parts[2]
478        .parse::<u64>()
479        .with_context(|| format!("Latest tag '{tag}' is not valid semantic versioning."))?;
480
481    Ok((major, minor, patch))
482}
483
484/// Filter unified diff to exclude files matching glob patterns.
485/// Files matching any pattern are removed from the diff output but will still be committed.
486pub fn filter_diff_by_globs(diff: &str, exclude_patterns: &[String]) -> String {
487    if exclude_patterns.is_empty() {
488        return diff.to_string();
489    }
490
491    let patterns: Vec<Pattern> = exclude_patterns
492        .iter()
493        .filter_map(|p| Pattern::new(p).ok())
494        .collect();
495
496    if patterns.is_empty() {
497        return diff.to_string();
498    }
499
500    let mut result = String::new();
501    let mut include_current = true;
502
503    for line in diff.lines() {
504        if line.starts_with("diff --git ") {
505            // Extract path: "diff --git a/path b/path" -> "path"
506            let file_path = line
507                .strip_prefix("diff --git a/")
508                .and_then(|s| s.split(" b/").next())
509                .unwrap_or("");
510
511            // Check only the filename, not the full path
512            let filename = std::path::Path::new(file_path)
513                .file_name()
514                .and_then(|n| n.to_str())
515                .unwrap_or(file_path);
516
517            include_current = !patterns.iter().any(|p| p.matches(filename));
518        }
519
520        if include_current {
521            result.push_str(line);
522            result.push('\n');
523        }
524    }
525
526    result
527}
528
529/// Get staged diff with files filtered by glob patterns.
530/// Excluded files are still committed, just not sent to the LLM for analysis.
531pub fn get_staged_diff_filtered(exclude_patterns: &[String]) -> Result<String> {
532    let diff = get_staged_diff()?;
533    Ok(filter_diff_by_globs(&diff, exclude_patterns))
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn test_parse_semver_tag_valid() {
542        let (major, minor, patch) = parse_semver_tag("1.2.3").unwrap();
543        assert_eq!((major, minor, patch), (1, 2, 3));
544    }
545
546    #[test]
547    fn test_parse_semver_tag_zeros() {
548        let (major, minor, patch) = parse_semver_tag("0.0.0").unwrap();
549        assert_eq!((major, minor, patch), (0, 0, 0));
550    }
551
552    #[test]
553    fn test_parse_semver_tag_large_numbers() {
554        let (major, minor, patch) = parse_semver_tag("100.200.300").unwrap();
555        assert_eq!((major, minor, patch), (100, 200, 300));
556    }
557
558    #[test]
559    fn test_parse_semver_tag_invalid_format_two_parts() {
560        let result = parse_semver_tag("1.2");
561        assert!(result.is_err());
562        assert!(result.unwrap_err().to_string().contains("not valid semantic"));
563    }
564
565    #[test]
566    fn test_parse_semver_tag_invalid_format_four_parts() {
567        let result = parse_semver_tag("1.2.3.4");
568        assert!(result.is_err());
569    }
570
571    #[test]
572    fn test_parse_semver_tag_non_numeric_major() {
573        let result = parse_semver_tag("a.2.3");
574        assert!(result.is_err());
575    }
576
577    #[test]
578    fn test_parse_semver_tag_non_numeric_minor() {
579        let result = parse_semver_tag("1.b.3");
580        assert!(result.is_err());
581    }
582
583    #[test]
584    fn test_parse_semver_tag_non_numeric_patch() {
585        let result = parse_semver_tag("1.2.c");
586        assert!(result.is_err());
587    }
588
589    #[test]
590    fn test_parse_semver_tag_with_v_prefix() {
591        // v prefix is not supported - this should fail
592        let result = parse_semver_tag("v1.2.3");
593        assert!(result.is_err());
594    }
595
596    #[test]
597    fn test_parse_semver_tag_with_whitespace() {
598        // Whitespace is trimmed
599        let (major, minor, patch) = parse_semver_tag("  1.2.3  ").unwrap();
600        assert_eq!((major, minor, patch), (1, 2, 3));
601    }
602
603    #[test]
604    fn test_script_command_unix_path() {
605        let path = std::path::Path::new("/tmp/script.sh");
606        let result = script_command(path);
607        assert_eq!(result, "/tmp/script.sh");
608    }
609
610    #[test]
611    fn test_script_command_windows_path() {
612        let path = std::path::Path::new("C:\\Users\\test\\script.sh");
613        let result = script_command(path);
614        // Backslashes should be converted to forward slashes
615        assert!(result.contains('/'));
616        assert!(!result.contains('\\'));
617    }
618
619    #[test]
620    fn test_configure_stdio_suppress() {
621        let mut cmd = Command::new("echo");
622        configure_stdio(&mut cmd, true);
623        // Can't easily verify the result, but ensure it doesn't panic
624    }
625
626    #[test]
627    fn test_configure_stdio_no_suppress() {
628        let mut cmd = Command::new("echo");
629        configure_stdio(&mut cmd, false);
630        // Can't easily verify the result, but ensure it doesn't panic
631    }
632
633    #[test]
634    fn test_filter_diff_all_invalid_patterns() {
635        let diff = "diff --git a/test.rs b/test.rs\n+code\n";
636        // All patterns are invalid - should return full diff
637        let patterns = vec!["[invalid".to_string(), "[also[bad".to_string()];
638        let filtered = filter_diff_by_globs(diff, &patterns);
639        assert_eq!(filtered, diff);
640    }
641
642    #[test]
643    fn test_filter_diff_empty_diff() {
644        let patterns = vec!["*.json".to_string()];
645        let filtered = filter_diff_by_globs("", &patterns);
646        assert_eq!(filtered, "");
647    }
648
649    #[test]
650    fn test_filter_diff_no_diff_header() {
651        // Content without proper diff header
652        let content = "just some random text\nwithout diff headers";
653        let patterns = vec!["*.json".to_string()];
654        let filtered = filter_diff_by_globs(content, &patterns);
655        // Should keep content since no diff header matched
656        assert!(filtered.contains("random text"));
657    }
658
659    #[test]
660    fn test_filter_diff_malformed_header() {
661        // Diff header without proper format
662        let diff = "diff --git \n+something\n";
663        let patterns = vec!["*.json".to_string()];
664        let filtered = filter_diff_by_globs(diff, &patterns);
665        // Should keep since filename extraction fails and defaults to include
666        assert!(filtered.contains("something"));
667    }
668
669    #[test]
670    fn test_compute_next_minor_tag_increment() {
671        let result = compute_next_minor_tag(Some("2.5.9")).unwrap();
672        assert_eq!(result, "2.6.0");
673    }
674
675    #[test]
676    fn test_compute_next_minor_tag_none() {
677        let result = compute_next_minor_tag(None).unwrap();
678        assert_eq!(result, "0.1.0");
679    }
680
681    #[test]
682    fn test_make_executable_windows() {
683        // On Windows, make_executable is a no-op
684        let temp_dir = std::env::temp_dir();
685        let test_file = temp_dir.join("test_make_executable.sh");
686        std::fs::write(&test_file, "#!/bin/sh\necho test").unwrap();
687        let result = make_executable(&test_file);
688        assert!(result.is_ok());
689        let _ = std::fs::remove_file(&test_file);
690    }
691
692    #[test]
693    fn test_write_sequence_editor_script() {
694        let temp_dir = std::env::temp_dir();
695        let script_path = temp_dir.join("test_seq_editor.sh");
696        let result = write_sequence_editor_script(&script_path);
697        assert!(result.is_ok());
698        let content = std::fs::read_to_string(&script_path).unwrap();
699        assert!(content.contains("#!/bin/sh"));
700        assert!(content.contains("reword"));
701        let _ = std::fs::remove_file(&script_path);
702    }
703
704    #[test]
705    fn test_write_message_editor_script() {
706        let temp_dir = std::env::temp_dir();
707        let script_path = temp_dir.join("test_msg_editor.sh");
708        let result = write_message_editor_script(&script_path);
709        assert!(result.is_ok());
710        let content = std::fs::read_to_string(&script_path).unwrap();
711        assert!(content.contains("#!/bin/sh"));
712        assert!(content.contains("CGEN_NEW_MESSAGE"));
713        let _ = std::fs::remove_file(&script_path);
714    }
715
716    #[test]
717    fn test_filter_diff_single_file_excluded() {
718        let diff = "diff --git a/config.json b/config.json\n+{}\n";
719        let patterns = vec!["*.json".to_string()];
720        let filtered = filter_diff_by_globs(diff, &patterns);
721        assert!(filtered.is_empty() || !filtered.contains("config.json"));
722    }
723
724    #[test]
725    fn test_filter_diff_preserves_context_lines() {
726        let diff = r#"diff --git a/main.rs b/main.rs
727--- a/main.rs
728+++ b/main.rs
729@@ -1,5 +1,6 @@
730 fn main() {
731+    println!("new");
732     old_code();
733 }
734"#;
735        let patterns = vec!["*.json".to_string()]; // Won't match .rs
736        let filtered = filter_diff_by_globs(diff, &patterns);
737        assert!(filtered.contains("fn main()"));
738        assert!(filtered.contains("println!"));
739        assert!(filtered.contains("old_code"));
740    }
741
742    #[test]
743    fn test_filter_diff_consecutive_files() {
744        let diff = r#"diff --git a/a.json b/a.json
745+first
746diff --git a/b.json b/b.json
747+second
748diff --git a/c.rs b/c.rs
749+third
750"#;
751        let patterns = vec!["*.json".to_string()];
752        let filtered = filter_diff_by_globs(diff, &patterns);
753        assert!(!filtered.contains("first"));
754        assert!(!filtered.contains("second"));
755        assert!(filtered.contains("third"));
756    }
757
758    #[test]
759    fn test_parse_semver_tag_empty() {
760        let result = parse_semver_tag("");
761        assert!(result.is_err());
762    }
763
764    #[test]
765    fn test_parse_semver_tag_single_number() {
766        let result = parse_semver_tag("1");
767        assert!(result.is_err());
768    }
769
770    #[test]
771    fn test_script_command_empty_path() {
772        let path = std::path::Path::new("");
773        let result = script_command(path);
774        assert_eq!(result, "");
775    }
776
777    #[test]
778    fn test_script_command_relative_path() {
779        let path = std::path::Path::new("scripts/test.sh");
780        let result = script_command(path);
781        assert_eq!(result, "scripts/test.sh");
782    }
783
784    #[test]
785    fn test_filter_diff_mixed_content_types() {
786        let diff = r#"diff --git a/readme.md b/readme.md
787--- a/readme.md
788+++ b/readme.md
789@@ -1 +1,2 @@
790+Documentation
791diff --git a/package-lock.json b/package-lock.json
792--- a/package-lock.json
793+++ b/package-lock.json
794@@ -1 +1,2 @@
795+{"deps": true}
796diff --git a/src/app.ts b/src/app.ts
797--- a/src/app.ts
798+++ b/src/app.ts
799@@ -1 +1,2 @@
800+// TypeScript code
801"#;
802        let patterns = vec!["*.json".to_string()];
803        let filtered = filter_diff_by_globs(diff, &patterns);
804
805        assert!(filtered.contains("readme.md"));
806        assert!(filtered.contains("Documentation"));
807        assert!(!filtered.contains("package-lock.json"));
808        assert!(filtered.contains("src/app.ts"));
809        assert!(filtered.contains("TypeScript code"));
810    }
811
812    #[test]
813    fn test_filter_diff_special_characters_in_path() {
814        let diff = "diff --git a/path with spaces/file.rs b/path with spaces/file.rs\n+code\n";
815        let patterns = vec!["*.json".to_string()];
816        let filtered = filter_diff_by_globs(diff, &patterns);
817        assert!(filtered.contains("code"));
818    }
819
820    #[test]
821    fn test_compute_next_minor_tag_large_version() {
822        let result = compute_next_minor_tag(Some("99.999.0")).unwrap();
823        assert_eq!(result, "99.1000.0");
824    }
825
826    #[test]
827    fn test_compute_next_minor_tag_error_propagation() {
828        // Invalid semver should return error
829        let result = compute_next_minor_tag(Some("not-semver"));
830        assert!(result.is_err());
831    }
832}