Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .config/jp/tools/src/git/stage_patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,15 @@ fn build_file_patch<R: ProcessRunner>(
return Err(format!("Patch IDs {patch_ids:?} not found (available: {available:?})").into());
}

Ok(format!("{header}\n{}", hunks.join("\n")))
// Ensure the patch ends with a newline. Non-last hunks lose their
// trailing newline during the `\n@@ ` split (the newline becomes part
// of the delimiter). `git apply` requires every diff line to be
// newline-terminated; without it the patch is rejected as corrupt.
let mut patch = format!("{header}\n{}", hunks.join("\n"));
if !patch.ends_with('\n') {
patch.push('\n');
}
Ok(patch)
}

#[cfg(test)]
Expand Down
37 changes: 37 additions & 0 deletions .config/jp/tools/src/git/stage_patch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,43 @@ fn stage_single_file() {
assert_eq!(result.into_content().unwrap(), "Patch applied.");
}

#[test]
fn stage_non_last_hunk_of_multi_hunk_diff() {
let dir = tempdir().unwrap();
let ctx = Context {
root: dir.path().to_owned(),
action: Action::Run,
};

let mut answers = serde_json::Map::new();
answers.insert("stage_changes".to_string(), json!(true));

// Two hunks: hunk 0 changes line 1, hunk 1 changes line 5.
// Selecting only hunk 0 (non-last) previously produced a patch without
// a trailing newline, causing `git apply` to reject it as corrupt.
let diff = "diff --git a/f.rs b/f.rs\nindex abc..def 100644\n--- a/f.rs\n+++ b/f.rs\n@@ -1 +1 \
@@\n-aaa\n+AAA\n@@ -5 +5 @@\n-eee\n+EEE\n";

let runner = MockProcessRunner::builder()
.expect("git")
.args(&["ls-files", "f.rs"])
.returns_success("f.rs\n")
.expect("git")
.args(&["diff-files", "-p", "--minimal", "--unified=0", "--", "f.rs"])
.returns_success(diff)
.expect("git")
.args(&["apply", "--cached", "--unidiff-zero", "-"])
.returns_success("");

let patches = vec![PatchTarget {
path: "f.rs".to_string(),
ids: vec![0].into(),
}];

let result = git_stage_patch_impl(&ctx, &answers, &patches, &runner, &[]).unwrap();
assert_eq!(result.into_content().unwrap(), "Patch applied.");
}

#[test]
fn partial_failure_stages_what_it_can() {
let dir = tempdir().unwrap();
Expand Down
23 changes: 23 additions & 0 deletions .config/jp/tools/tests/git_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,29 @@ async fn stage_patch_single_hunk() {
assert_eq!(staged_content(&root, "s.rs"), "new\n");
}

#[tokio::test]
async fn stage_patch_non_last_hunk() {
if !has_git() {
return;
}

let (_dir, root) = init_repo();
commit_then_modify(&root, "nl.rs", "a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n");

// Stage only the FIRST hunk (a→A), which is not the last.
run_ok(
ctx(&root),
tool_with_answers(
"git_stage_patch",
&json!({"patches": [{"path": "nl.rs", "ids": [0]}]}),
&json!({"stage_changes": true}),
),
)
.await;

assert_eq!(staged_content(&root, "nl.rs"), "A\nb\nc\nd\ne\n");
}

#[tokio::test]
async fn stage_patch_selective_hunk() {
if !has_git() {
Expand Down
Loading