upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-30 08:54:31 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-30 16:29:19 +0000
commit3a03cca6eb6597c19f5146c5f0d18f9230eb0fae (patch)
tree94597b3298e34d5d31083255bee605617accaab5 /src
parentf3a6ae82ccee44dc3b66a66caafe1bb39e7a46a6 (diff)
fix(patch): handle diff.noprefix and normalize non-standard diff paths
libgit2 respects the user's `diff.noprefix` git config when generating patches via `git_email_create_from_commit`, producing diffs without the standard `a/`/`b/` path prefixes. `git2::Diff::from_buffer` always expects the standard prefix format and fails with "header filename does not contain 1 path components" when it is absent. Two fixes: - In `make_patch_from_commit`: explicitly set `old_prefix("a/")` and `new_prefix("b/")` on the diff options so ngit always generates interoperable patches regardless of the submitter's git config. - In `create_commit_from_patch`: normalize no-prefix diffs before passing to `git2::Diff::from_buffer`, as a defensive measure for patches already published by affected ngit versions or other tools. Adds unit tests for the normalization function covering: no-op on already-prefixed diffs, single and multi-file patches, new/deleted files (/dev/null preservation), and end-to-end libgit2 parse verification.
Diffstat (limited to 'src')
-rw-r--r--src/lib/git/mod.rs237
1 files changed, 236 insertions, 1 deletions
diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs
index ca7aa3f..c5b5f2e 100644
--- a/src/lib/git/mod.rs
+++ b/src/lib/git/mod.rs
@@ -417,6 +417,11 @@ impl RepoActions for Repo {
417 ))?) 417 ))?)
418 .context(format!("failed to find commit {}", &commit))?; 418 .context(format!("failed to find commit {}", &commit))?;
419 let mut options = git2::EmailCreateOptions::default(); 419 let mut options = git2::EmailCreateOptions::default();
420 // Explicitly set a/b prefixes so that the user's `diff.noprefix` git
421 // config (or any other prefix override) does not affect the generated
422 // patch. Patches must use standard a/b prefixes to be parseable by
423 // git2::Diff::from_buffer when applying them.
424 options.diff_options().old_prefix("a/").new_prefix("b/");
420 if let Some((n, total)) = series_count { 425 if let Some((n, total)) = series_count {
421 options.subject_prefix(format!("PATCH {n}/{total}")); 426 options.subject_prefix(format!("PATCH {n}/{total}"));
422 } 427 }
@@ -695,9 +700,10 @@ impl RepoActions for Repo {
695 // let mut apply_opts = git2::ApplyOptions::new(); 700 // let mut apply_opts = git2::ApplyOptions::new();
696 // apply_opts.check(false); 701 // apply_opts.check(false);
697 let mut existing_index = self.git_repo.index()?; 702 let mut existing_index = self.git_repo.index()?;
703 let normalized_content = normalize_diff_prefix(&patch.content);
698 let mut index = self.git_repo.apply_to_tree( 704 let mut index = self.git_repo.apply_to_tree(
699 &parent_tree, 705 &parent_tree,
700 &git2::Diff::from_buffer(patch.content.as_bytes())?, 706 &git2::Diff::from_buffer(normalized_content.as_bytes())?,
701 // Some(&mut apply_opts), 707 // Some(&mut apply_opts),
702 None, 708 None,
703 )?; 709 )?;
@@ -1067,6 +1073,98 @@ impl SignatureData {
1067 } 1073 }
1068} 1074}
1069 1075
1076/// Normalize a patch that uses no-prefix diff format (produced by libgit2 with
1077/// `--no-prefix`, e.g. `diff --git src/foo.rs src/foo.rs`) to the standard
1078/// `a/`/`b/` prefix format that `git2::Diff::from_buffer` requires.
1079///
1080/// If the patch already uses standard prefixes this function is a no-op.
1081fn normalize_diff_prefix(content: &str) -> String {
1082 // Detect whether any diff header is missing the a/b prefix.
1083 // A standard header looks like: diff --git a/<path> b/<path>
1084 // A no-prefix header looks like: diff --git <path> <path>
1085 let needs_normalization = content
1086 .lines()
1087 .filter(|l| l.starts_with("diff --git "))
1088 .any(|l| {
1089 let rest = &l["diff --git ".len()..];
1090 !rest.starts_with("a/")
1091 });
1092
1093 if !needs_normalization {
1094 return content.to_string();
1095 }
1096
1097 let mut out = String::with_capacity(content.len() + 64);
1098 for line in content.lines() {
1099 if let Some(rest) = line.strip_prefix("diff --git ") {
1100 // rest is "<old-path> <new-path>". For paths without spaces the
1101 // split is unambiguous. For paths with spaces we rely on the fact
1102 // that old == new for renames-in-place; we find the midpoint by
1103 // trying each space as a split and checking old == new.
1104 let (old, new) = split_diff_git_paths(rest);
1105 out.push_str("diff --git a/");
1106 out.push_str(old);
1107 out.push_str(" b/");
1108 out.push_str(new);
1109 } else if let Some(rest) = line.strip_prefix("--- ") {
1110 if rest == "/dev/null" {
1111 out.push_str(line);
1112 } else {
1113 out.push_str("--- a/");
1114 out.push_str(rest);
1115 }
1116 } else if let Some(rest) = line.strip_prefix("+++ ") {
1117 if rest == "/dev/null" {
1118 out.push_str(line);
1119 } else {
1120 out.push_str("+++ b/");
1121 out.push_str(rest);
1122 }
1123 } else {
1124 out.push_str(line);
1125 }
1126 out.push('\n');
1127 }
1128 // Preserve trailing newline behaviour of the original.
1129 if !content.ends_with('\n') && out.ends_with('\n') {
1130 out.pop();
1131 }
1132 out
1133}
1134
1135/// Split the path portion of a `diff --git <old> <new>` line into (old, new).
1136///
1137/// For paths without spaces this is trivial. For paths with spaces we try each
1138/// space as a candidate split point and return the first one where the two
1139/// halves are equal (which is always the case for non-rename patches). If no
1140/// equal split is found we fall back to splitting at the first space.
1141fn split_diff_git_paths(rest: &str) -> (&str, &str) {
1142 // Fast path: no spaces → split at the single space
1143 if let Some(pos) = rest.find(' ') {
1144 let (old, new_with_space) = rest.split_at(pos);
1145 let new = &new_with_space[1..];
1146 if !old.contains(' ') {
1147 return (old, new);
1148 }
1149 // Paths contain spaces: try each space as a split point.
1150 let bytes = rest.as_bytes();
1151 for (i, &b) in bytes.iter().enumerate() {
1152 if b == b' ' {
1153 let candidate_old = &rest[..i];
1154 let candidate_new = &rest[i + 1..];
1155 if candidate_old == candidate_new {
1156 return (candidate_old, candidate_new);
1157 }
1158 }
1159 }
1160 // Fallback: split at first space
1161 (old, new)
1162 } else {
1163 // No space at all — malformed, return as-is
1164 (rest, rest)
1165 }
1166}
1167
1070fn extract_description_from_patch(patch: &nostr::Event) -> Result<String> { 1168fn extract_description_from_patch(patch: &nostr::Event) -> Result<String> {
1071 if let Ok(desc) = tag_value(patch, "description") { 1169 if let Ok(desc) = tag_value(patch, "description") {
1072 return Ok(desc); 1170 return Ok(desc);
@@ -1138,6 +1236,142 @@ mod tests {
1138 1236
1139 use super::*; 1237 use super::*;
1140 1238
1239 mod normalize_diff_prefix {
1240 use super::*;
1241
1242 #[test]
1243 fn no_op_when_already_prefixed() {
1244 let patch = "\
1245diff --git a/src/foo.rs b/src/foo.rs\n\
1246index ce01362..a21e91c 100644\n\
1247--- a/src/foo.rs\n\
1248+++ b/src/foo.rs\n\
1249@@ -1 +1 @@\n\
1250-hello\n\
1251+world\n";
1252 assert_eq!(normalize_diff_prefix(patch), patch);
1253 }
1254
1255 #[test]
1256 fn adds_prefix_to_no_prefix_diff() {
1257 let patch_no_prefix = "\
1258diff --git src/foo.rs src/foo.rs\n\
1259index ce01362..a21e91c 100644\n\
1260--- src/foo.rs\n\
1261+++ src/foo.rs\n\
1262@@ -1 +1 @@\n\
1263-hello\n\
1264+world\n";
1265 let expected = "\
1266diff --git a/src/foo.rs b/src/foo.rs\n\
1267index ce01362..a21e91c 100644\n\
1268--- a/src/foo.rs\n\
1269+++ b/src/foo.rs\n\
1270@@ -1 +1 @@\n\
1271-hello\n\
1272+world\n";
1273 assert_eq!(normalize_diff_prefix(patch_no_prefix), expected);
1274 }
1275
1276 #[test]
1277 fn preserves_dev_null_for_new_file() {
1278 let patch_no_prefix = "\
1279diff --git src/new.rs src/new.rs\n\
1280new file mode 100644\n\
1281index 0000000..a21e91c\n\
1282--- /dev/null\n\
1283+++ src/new.rs\n\
1284@@ -0,0 +1 @@\n\
1285+hello\n";
1286 let expected = "\
1287diff --git a/src/new.rs b/src/new.rs\n\
1288new file mode 100644\n\
1289index 0000000..a21e91c\n\
1290--- /dev/null\n\
1291+++ b/src/new.rs\n\
1292@@ -0,0 +1 @@\n\
1293+hello\n";
1294 assert_eq!(normalize_diff_prefix(patch_no_prefix), expected);
1295 }
1296
1297 #[test]
1298 fn preserves_dev_null_for_deleted_file() {
1299 let patch_no_prefix = "\
1300diff --git src/old.rs src/old.rs\n\
1301deleted file mode 100644\n\
1302index a21e91c..0000000\n\
1303--- src/old.rs\n\
1304+++ /dev/null\n\
1305@@ -1 +0,0 @@\n\
1306-hello\n";
1307 let expected = "\
1308diff --git a/src/old.rs b/src/old.rs\n\
1309deleted file mode 100644\n\
1310index a21e91c..0000000\n\
1311--- a/src/old.rs\n\
1312+++ /dev/null\n\
1313@@ -1 +0,0 @@\n\
1314-hello\n";
1315 assert_eq!(normalize_diff_prefix(patch_no_prefix), expected);
1316 }
1317
1318 #[test]
1319 fn handles_multiple_files() {
1320 let patch_no_prefix = "\
1321diff --git src/foo.rs src/foo.rs\n\
1322index ce01362..a21e91c 100644\n\
1323--- src/foo.rs\n\
1324+++ src/foo.rs\n\
1325@@ -1 +1 @@\n\
1326-hello\n\
1327+world\n\
1328diff --git src/bar.rs src/bar.rs\n\
1329index 1234567..abcdef0 100644\n\
1330--- src/bar.rs\n\
1331+++ src/bar.rs\n\
1332@@ -1 +1 @@\n\
1333-foo\n\
1334+baz\n";
1335 let expected = "\
1336diff --git a/src/foo.rs b/src/foo.rs\n\
1337index ce01362..a21e91c 100644\n\
1338--- a/src/foo.rs\n\
1339+++ b/src/foo.rs\n\
1340@@ -1 +1 @@\n\
1341-hello\n\
1342+world\n\
1343diff --git a/src/bar.rs b/src/bar.rs\n\
1344index 1234567..abcdef0 100644\n\
1345--- a/src/bar.rs\n\
1346+++ b/src/bar.rs\n\
1347@@ -1 +1 @@\n\
1348-foo\n\
1349+baz\n";
1350 assert_eq!(normalize_diff_prefix(patch_no_prefix), expected);
1351 }
1352
1353 #[test]
1354 fn libgit2_can_parse_normalized_no_prefix_diff() {
1355 // Verify that after normalization, git2::Diff::from_buffer succeeds
1356 let patch_no_prefix = "\
1357diff --git src/foo.rs src/foo.rs\n\
1358index ce01362..a21e91c 100644\n\
1359--- src/foo.rs\n\
1360+++ src/foo.rs\n\
1361@@ -1 +1 @@\n\
1362-hello\n\
1363+world\n";
1364 let normalized = normalize_diff_prefix(patch_no_prefix);
1365 let result = git2::Diff::from_buffer(normalized.as_bytes());
1366 assert!(
1367 result.is_ok(),
1368 "libgit2 failed to parse normalized diff: {:?}",
1369 result.err()
1370 );
1371 assert_eq!(result.unwrap().deltas().count(), 1);
1372 }
1373 }
1374
1141 mod git_config_item_local { 1375 mod git_config_item_local {
1142 use super::*; 1376 use super::*;
1143 1377
@@ -2609,4 +2843,5 @@ mod tests {
2609 Ok(()) 2843 Ok(())
2610 } 2844 }
2611 } 2845 }
2846
2612} 2847}