hunkpick 0.7.0

Non-interactive git add -p alternative — pick and split unified-diff hunks by index, range, or content id
Documentation
use crate::model::*;
use std::path::Path;

#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
    CountMismatch {
        file: String,
        hunk_index: usize,
        field: &'static str,
        header: u32,
        body: u32,
    },
    OverlappingHunks {
        file: String,
        hunk_index: usize,
    },
    EmptyHunk {
        file: String,
        hunk_index: usize,
    },
    /// A hunk whose body is all context (no `+`/`-` lines). The count checks pass it
    /// (`old_lines == new_lines == ctx`), yet `git apply` rejects a hunk with no changes.
    /// Unreachable from a real git diff (git never emits a change-free hunk) but possible
    /// from a synthetic patch, so reject it explicitly.
    NoChangeHunk {
        file: String,
        hunk_index: usize,
    },
    /// A hunk whose new-side start does not follow from the old-side start plus the net size
    /// of the hunks before it in the same file. Carries the header's value and the one the
    /// diff implies. This is what an anchor carried over from a larger input diff looks like:
    /// the counts and the body are consistent, yet `git apply` — which searches from the
    /// new-side position — starts at the wrong line (see [`crate::renumber`]).
    StaleNewStart {
        file: String,
        hunk_index: usize,
        header: u32,
        expected: u32,
    },
}

/// Internal consistency check of a result diff. Git-agnostic, O(total lines).
pub fn validate_internal(patch: &Patch) -> Result<(), ValidationError> {
    for f in &patch.files {
        let path = f.display_path();
        let FileContent::Text(hunks) = &f.content else {
            continue; // binary files have no hunk bodies to check
        };
        let mut prev_old_end: Option<u32> = None;
        let mut prev_new_end: Option<u32> = None;
        // Net `added - deleted` of the hunks already seen in this file: what the new-side
        // anchor of every later hunk is shifted by.
        let mut delta: i64 = 0;
        for (i, h) in hunks.iter().enumerate() {
            // A text hunk with no body lines emits a `@@ -X,0 +Y,0 @@` stanza git rejects
            // as a corrupt patch. The count checks below pass it (0 == 0), so reject it here.
            if h.lines.is_empty() {
                return Err(ValidationError::EmptyHunk {
                    file: path.clone(),
                    hunk_index: i,
                });
            }
            let (ctx, add, del) = count_kinds(&h.lines);
            // A change-free (all-context) hunk passes the count checks but git apply rejects
            // it. `EmptyHunk` above only catches a zero-line body, so guard the non-empty
            // all-context case here.
            if add == 0 && del == 0 {
                return Err(ValidationError::NoChangeHunk {
                    file: path.clone(),
                    hunk_index: i,
                });
            }
            if h.old_lines != ctx + del {
                return Err(ValidationError::CountMismatch {
                    file: path.clone(),
                    hunk_index: i,
                    field: "old_lines",
                    header: h.old_lines,
                    body: ctx + del,
                });
            }
            if h.new_lines != ctx + add {
                return Err(ValidationError::CountMismatch {
                    file: path.clone(),
                    hunk_index: i,
                    field: "new_lines",
                    header: h.new_lines,
                    body: ctx + add,
                });
            }
            // The new-side start is not an independent value: it follows from the old-side
            // start and everything this diff already changed above. A hunk carried over from a
            // larger diff keeps the anchor of that diff and passes every check above, so check
            // it explicitly rather than leaving it for `git apply` to mis-locate.
            let expected = crate::renumber::expected_new_start(h, delta);
            if h.new_start != expected {
                return Err(ValidationError::StaleNewStart {
                    file: path.clone(),
                    hunk_index: i,
                    header: h.new_start,
                    expected,
                });
            }
            delta += i64::from(add) - i64::from(del);
            if let Some(pe) = prev_old_end {
                if h.old_start < pe {
                    return Err(ValidationError::OverlappingHunks {
                        file: path.clone(),
                        hunk_index: i,
                    });
                }
            }
            if let Some(pe) = prev_new_end {
                if h.new_start < pe {
                    return Err(ValidationError::OverlappingHunks {
                        file: path.clone(),
                        hunk_index: i,
                    });
                }
            }
            prev_old_end = Some(h.old_start + h.old_lines);
            prev_new_end = Some(h.new_start + h.new_lines);
        }
    }
    Ok(())
}

/// Run `git apply --check` against the working tree in `dir`, feeding `diff_bytes` on stdin.
/// Returns Err with git's stderr on failure (or if git could not be run).
pub fn validate_with_git(diff_bytes: &[u8], dir: &Path) -> Result<(), String> {
    use std::io::Write;
    use std::process::{Command, Stdio};

    let mut child = Command::new("git")
        .arg("apply")
        .arg("--check")
        .current_dir(dir)
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| format!("failed to run git: {e}"))?;
    child
        .stdin
        .take()
        .unwrap()
        .write_all(diff_bytes)
        .map_err(|e| format!("failed to write to git: {e}"))?;
    let output = child
        .wait_with_output()
        .map_err(|e| format!("git wait failed: {e}"))?;
    if output.status.success() {
        Ok(())
    } else {
        let stderr = String::from_utf8_lossy(&output.stderr);
        Err(format!(
            "git apply --check rejected the result diff: {}",
            stderr.trim()
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::parser::parse;

    #[test]
    fn well_formed_diff_passes() {
        let p = parse(
            "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
 a
-b
+B
 c
"
            .as_bytes(),
        )
        .unwrap();
        assert!(validate_internal(&p).is_ok());
    }

    #[test]
    fn stale_new_start_is_caught() {
        // The header of a hunk taken out of a larger diff: counts and body agree, but the
        // new-side start still describes the file the full diff produced.
        let p = parse(
            "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -17,3 +18,3 @@
 q
-r
+R
 s
"
            .as_bytes(),
        )
        .unwrap();
        assert_eq!(
            validate_internal(&p),
            Err(ValidationError::StaleNewStart {
                file: "f".to_string(),
                hunk_index: 0,
                header: 18,
                expected: 17,
            })
        );
    }

    #[test]
    fn accumulated_offset_across_hunks_passes() {
        // Two hunks of one diff: the first removes a line net, so the second starts one line
        // earlier on the new side. The check must accept exactly that.
        let p = parse(
            "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -2,3 +2,2 @@
 b
-c
 d
@@ -17,3 +16,3 @@
 q
-r
+R
 s
"
            .as_bytes(),
        )
        .unwrap();
        assert_eq!(validate_internal(&p), Ok(()));
    }

    #[test]
    fn count_mismatch_is_caught() {
        let mut p = parse(
            "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
 a
-b
+B
 c
"
            .as_bytes(),
        )
        .unwrap();
        // Corrupt the header count.
        if let FileContent::Text(h) = &mut p.files[0].content {
            h[0].old_lines = 99;
        }
        assert!(matches!(
            validate_internal(&p),
            Err(ValidationError::CountMismatch { .. })
        ));
    }

    #[test]
    fn empty_hunk_body_is_caught() {
        let mut p = parse(
            "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
 a
-b
+B
 c
"
            .as_bytes(),
        )
        .unwrap();
        // A text hunk with no body lines and zero counts passes the count checks
        // (0 == 0) yet emits a `@@ -X,0 +Y,0 @@` stanza git rejects. Catch it explicitly.
        if let FileContent::Text(h) = &mut p.files[0].content {
            h[0].lines.clear();
            h[0].old_lines = 0;
            h[0].new_lines = 0;
        }
        assert!(matches!(
            validate_internal(&p),
            Err(ValidationError::EmptyHunk { .. })
        ));
    }

    #[test]
    fn all_context_hunk_is_caught() {
        let mut p = parse(
            "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
 a
-b
+B
 c
"
            .as_bytes(),
        )
        .unwrap();
        // Turn the change lines into context: a change-free hunk whose counts still balance
        // (old_lines == new_lines == ctx) but which git apply rejects.
        if let FileContent::Text(h) = &mut p.files[0].content {
            for l in &mut h[0].lines {
                l.kind = LineKind::Context;
            }
            h[0].old_lines = 3;
            h[0].new_lines = 3;
        }
        assert!(matches!(
            validate_internal(&p),
            Err(ValidationError::NoChangeHunk { .. })
        ));
    }

    #[test]
    fn overlapping_hunks_are_caught() {
        let mut p = parse(
            "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,2 +1,2 @@
 a
-b
+B
@@ -10,2 +10,2 @@
 p
-q
+Q
"
            .as_bytes(),
        )
        .unwrap();
        // Force the second hunk to overlap the first on the old side.
        if let FileContent::Text(h) = &mut p.files[0].content {
            h[1].old_start = 1;
            h[1].new_start = 1;
        }
        assert!(matches!(
            validate_internal(&p),
            Err(ValidationError::OverlappingHunks { .. })
        ));
    }

    #[test]
    fn git_check_accepts_valid_result() {
        use std::process::Command;
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("f"), "a\nb\nc\n").unwrap();
        Command::new("git")
            .arg("init")
            .arg("-q")
            .current_dir(&dir)
            .status()
            .unwrap();
        let diff = "\
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
 a
-b
+B
 c
";
        assert!(validate_with_git(diff.as_bytes(), dir.path()).is_ok());
    }

    #[test]
    fn git_check_rejects_bad_result() {
        use std::process::Command;
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("f"), "totally\ndifferent\ncontent\n").unwrap();
        Command::new("git")
            .arg("init")
            .arg("-q")
            .current_dir(&dir)
            .status()
            .unwrap();
        let diff = "\
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
 a
-b
+B
 c
";
        assert!(validate_with_git(diff.as_bytes(), dir.path()).is_err());
    }
}