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
563            .unwrap_err()
564            .to_string()
565            .contains("not valid semantic"));
566    }
567
568    #[test]
569    fn test_parse_semver_tag_invalid_format_four_parts() {
570        let result = parse_semver_tag("1.2.3.4");
571        assert!(result.is_err());
572    }
573
574    #[test]
575    fn test_parse_semver_tag_non_numeric_major() {
576        let result = parse_semver_tag("a.2.3");
577        assert!(result.is_err());
578    }
579
580    #[test]
581    fn test_parse_semver_tag_non_numeric_minor() {
582        let result = parse_semver_tag("1.b.3");
583        assert!(result.is_err());
584    }
585
586    #[test]
587    fn test_parse_semver_tag_non_numeric_patch() {
588        let result = parse_semver_tag("1.2.c");
589        assert!(result.is_err());
590    }
591
592    #[test]
593    fn test_parse_semver_tag_with_v_prefix() {
594        // v prefix is not supported - this should fail
595        let result = parse_semver_tag("v1.2.3");
596        assert!(result.is_err());
597    }
598
599    #[test]
600    fn test_parse_semver_tag_with_whitespace() {
601        // Whitespace is trimmed
602        let (major, minor, patch) = parse_semver_tag("  1.2.3  ").unwrap();
603        assert_eq!((major, minor, patch), (1, 2, 3));
604    }
605
606    #[test]
607    fn test_script_command_unix_path() {
608        let path = std::path::Path::new("/tmp/script.sh");
609        let result = script_command(path);
610        assert_eq!(result, "/tmp/script.sh");
611    }
612
613    #[test]
614    fn test_script_command_windows_path() {
615        let path = std::path::Path::new("C:\\Users\\test\\script.sh");
616        let result = script_command(path);
617        // Backslashes should be converted to forward slashes
618        assert!(result.contains('/'));
619        assert!(!result.contains('\\'));
620    }
621
622    #[test]
623    fn test_configure_stdio_suppress() {
624        let mut cmd = Command::new("echo");
625        configure_stdio(&mut cmd, true);
626        // Can't easily verify the result, but ensure it doesn't panic
627    }
628
629    #[test]
630    fn test_configure_stdio_no_suppress() {
631        let mut cmd = Command::new("echo");
632        configure_stdio(&mut cmd, false);
633        // Can't easily verify the result, but ensure it doesn't panic
634    }
635
636    #[test]
637    fn test_filter_diff_all_invalid_patterns() {
638        let diff = "diff --git a/test.rs b/test.rs\n+code\n";
639        // All patterns are invalid - should return full diff
640        let patterns = vec!["[invalid".to_string(), "[also[bad".to_string()];
641        let filtered = filter_diff_by_globs(diff, &patterns);
642        assert_eq!(filtered, diff);
643    }
644
645    #[test]
646    fn test_filter_diff_empty_diff() {
647        let patterns = vec!["*.json".to_string()];
648        let filtered = filter_diff_by_globs("", &patterns);
649        assert_eq!(filtered, "");
650    }
651
652    #[test]
653    fn test_filter_diff_no_diff_header() {
654        // Content without proper diff header
655        let content = "just some random text\nwithout diff headers";
656        let patterns = vec!["*.json".to_string()];
657        let filtered = filter_diff_by_globs(content, &patterns);
658        // Should keep content since no diff header matched
659        assert!(filtered.contains("random text"));
660    }
661
662    #[test]
663    fn test_filter_diff_malformed_header() {
664        // Diff header without proper format
665        let diff = "diff --git \n+something\n";
666        let patterns = vec!["*.json".to_string()];
667        let filtered = filter_diff_by_globs(diff, &patterns);
668        // Should keep since filename extraction fails and defaults to include
669        assert!(filtered.contains("something"));
670    }
671
672    #[test]
673    fn test_compute_next_minor_tag_increment() {
674        let result = compute_next_minor_tag(Some("2.5.9")).unwrap();
675        assert_eq!(result, "2.6.0");
676    }
677
678    #[test]
679    fn test_compute_next_minor_tag_none() {
680        let result = compute_next_minor_tag(None).unwrap();
681        assert_eq!(result, "0.1.0");
682    }
683
684    #[test]
685    fn test_make_executable_windows() {
686        // On Windows, make_executable is a no-op
687        let temp_dir = std::env::temp_dir();
688        let test_file = temp_dir.join("test_make_executable.sh");
689        std::fs::write(&test_file, "#!/bin/sh\necho test").unwrap();
690        let result = make_executable(&test_file);
691        assert!(result.is_ok());
692        let _ = std::fs::remove_file(&test_file);
693    }
694
695    #[test]
696    fn test_write_sequence_editor_script() {
697        let temp_dir = std::env::temp_dir();
698        let script_path = temp_dir.join("test_seq_editor.sh");
699        let result = write_sequence_editor_script(&script_path);
700        assert!(result.is_ok());
701        let content = std::fs::read_to_string(&script_path).unwrap();
702        assert!(content.contains("#!/bin/sh"));
703        assert!(content.contains("reword"));
704        let _ = std::fs::remove_file(&script_path);
705    }
706
707    #[test]
708    fn test_write_message_editor_script() {
709        let temp_dir = std::env::temp_dir();
710        let script_path = temp_dir.join("test_msg_editor.sh");
711        let result = write_message_editor_script(&script_path);
712        assert!(result.is_ok());
713        let content = std::fs::read_to_string(&script_path).unwrap();
714        assert!(content.contains("#!/bin/sh"));
715        assert!(content.contains("CGEN_NEW_MESSAGE"));
716        let _ = std::fs::remove_file(&script_path);
717    }
718
719    #[test]
720    fn test_filter_diff_single_file_excluded() {
721        let diff = "diff --git a/config.json b/config.json\n+{}\n";
722        let patterns = vec!["*.json".to_string()];
723        let filtered = filter_diff_by_globs(diff, &patterns);
724        assert!(filtered.is_empty() || !filtered.contains("config.json"));
725    }
726
727    #[test]
728    fn test_filter_diff_preserves_context_lines() {
729        let diff = r#"diff --git a/main.rs b/main.rs
730--- a/main.rs
731+++ b/main.rs
732@@ -1,5 +1,6 @@
733 fn main() {
734+    println!("new");
735     old_code();
736 }
737"#;
738        let patterns = vec!["*.json".to_string()]; // Won't match .rs
739        let filtered = filter_diff_by_globs(diff, &patterns);
740        assert!(filtered.contains("fn main()"));
741        assert!(filtered.contains("println!"));
742        assert!(filtered.contains("old_code"));
743    }
744
745    #[test]
746    fn test_filter_diff_consecutive_files() {
747        let diff = r#"diff --git a/a.json b/a.json
748+first
749diff --git a/b.json b/b.json
750+second
751diff --git a/c.rs b/c.rs
752+third
753"#;
754        let patterns = vec!["*.json".to_string()];
755        let filtered = filter_diff_by_globs(diff, &patterns);
756        assert!(!filtered.contains("first"));
757        assert!(!filtered.contains("second"));
758        assert!(filtered.contains("third"));
759    }
760
761    #[test]
762    fn test_parse_semver_tag_empty() {
763        let result = parse_semver_tag("");
764        assert!(result.is_err());
765    }
766
767    #[test]
768    fn test_parse_semver_tag_single_number() {
769        let result = parse_semver_tag("1");
770        assert!(result.is_err());
771    }
772
773    #[test]
774    fn test_script_command_empty_path() {
775        let path = std::path::Path::new("");
776        let result = script_command(path);
777        assert_eq!(result, "");
778    }
779
780    #[test]
781    fn test_script_command_relative_path() {
782        let path = std::path::Path::new("scripts/test.sh");
783        let result = script_command(path);
784        assert_eq!(result, "scripts/test.sh");
785    }
786
787    #[test]
788    fn test_filter_diff_mixed_content_types() {
789        let diff = r#"diff --git a/readme.md b/readme.md
790--- a/readme.md
791+++ b/readme.md
792@@ -1 +1,2 @@
793+Documentation
794diff --git a/package-lock.json b/package-lock.json
795--- a/package-lock.json
796+++ b/package-lock.json
797@@ -1 +1,2 @@
798+{"deps": true}
799diff --git a/src/app.ts b/src/app.ts
800--- a/src/app.ts
801+++ b/src/app.ts
802@@ -1 +1,2 @@
803+// TypeScript code
804"#;
805        let patterns = vec!["*.json".to_string()];
806        let filtered = filter_diff_by_globs(diff, &patterns);
807
808        assert!(filtered.contains("readme.md"));
809        assert!(filtered.contains("Documentation"));
810        assert!(!filtered.contains("package-lock.json"));
811        assert!(filtered.contains("src/app.ts"));
812        assert!(filtered.contains("TypeScript code"));
813    }
814
815    #[test]
816    fn test_filter_diff_special_characters_in_path() {
817        let diff = "diff --git a/path with spaces/file.rs b/path with spaces/file.rs\n+code\n";
818        let patterns = vec!["*.json".to_string()];
819        let filtered = filter_diff_by_globs(diff, &patterns);
820        assert!(filtered.contains("code"));
821    }
822
823    #[test]
824    fn test_compute_next_minor_tag_large_version() {
825        let result = compute_next_minor_tag(Some("99.999.0")).unwrap();
826        assert_eq!(result, "99.1000.0");
827    }
828
829    #[test]
830    fn test_compute_next_minor_tag_error_propagation() {
831        // Invalid semver should return error
832        let result = compute_next_minor_tag(Some("not-semver"));
833        assert!(result.is_err());
834    }
835}