Skip to content

Commit 240dc66

Browse files
committed
feat(patch): add guided 3-way import recovery
1 parent 14b4519 commit 240dc66

7 files changed

Lines changed: 467 additions & 28 deletions

File tree

‎src-tauri/src/git/cli.rs‎

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ impl CliGitHandler {
6767
const PATCH_EXPORT_NO_COMMITS_SELECTED: &'static str =
6868
"GITMUN_ERROR_PATCH_EXPORT_NO_COMMITS_SELECTED";
6969
const PATCH_EXPORT_WRITTEN: &'static str = "GITMUN_PATCH_EXPORT_WRITTEN";
70+
const PATCH_IMPORT_APPLIED: &'static str = "GITMUN_PATCH_IMPORT_APPLIED";
71+
const PATCH_IMPORT_CONFLICTS: &'static str = "GITMUN_PATCH_IMPORT_CONFLICTS";
72+
const PATCH_IMPORT_THREE_WAY_BLOCKED: &'static str =
73+
"GITMUN_ERROR_PATCH_IMPORT_THREE_WAY_BLOCKED";
7074

7175
fn preferred_mime_from_path(file_path: &str) -> Option<String> {
7276
let mut first: Option<String> = None;
@@ -806,6 +810,7 @@ impl CliGitHandler {
806810
if staged {
807811
args.push("--cached");
808812
}
813+
args.push("--full-index");
809814
args.push("--binary");
810815
args.push("--");
811816
args.extend(paths.iter().map(String::as_str));
@@ -817,6 +822,7 @@ impl CliGitHandler {
817822
if staged {
818823
args.push("--cached");
819824
}
825+
args.push("--full-index");
820826
args.push("--binary");
821827
args.push("--");
822828
Self::run_git_allow_exit_codes(&args, Some(repo_path), &[1])
@@ -825,7 +831,15 @@ impl CliGitHandler {
825831
fn git_untracked_patch(repo_path: &Path, path: &str) -> GitResult<String> {
826832
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
827833
let output = Self::run_git_allow_exit_codes(
828-
&["diff", "--no-index", "--binary", "--", null_device, path],
834+
&[
835+
"diff",
836+
"--no-index",
837+
"--full-index",
838+
"--binary",
839+
"--",
840+
null_device,
841+
path,
842+
],
829843
Some(repo_path),
830844
&[1],
831845
)?;
@@ -893,7 +907,14 @@ impl CliGitHandler {
893907
let base_hash = parts.next().unwrap_or(Self::EMPTY_TREE_HASH);
894908

895909
Self::run_git_allow_exit_codes(
896-
&["diff", "--binary", base_hash, resolved_hash, "--"],
910+
&[
911+
"diff",
912+
"--full-index",
913+
"--binary",
914+
base_hash,
915+
resolved_hash,
916+
"--",
917+
],
897918
Some(repo_path),
898919
&[1],
899920
)
@@ -989,6 +1010,81 @@ impl CliGitHandler {
9891010
Ok(output)
9901011
}
9911012

1013+
fn patch_three_way_blocked_message() -> String {
1014+
Self::PATCH_IMPORT_THREE_WAY_BLOCKED.to_string()
1015+
}
1016+
1017+
fn status_has_tracked_changes(output: &str) -> bool {
1018+
output
1019+
.lines()
1020+
.map(str::trim)
1021+
.any(|line| !line.is_empty() && !line.starts_with("?? "))
1022+
}
1023+
1024+
fn status_has_unmerged_files(output: &str) -> bool {
1025+
output.lines().any(|line| {
1026+
let Some(status) = line.get(0..2) else {
1027+
return false;
1028+
};
1029+
matches!(status, "DD" | "AU" | "UD" | "UA" | "DU" | "AA" | "UU")
1030+
})
1031+
}
1032+
1033+
fn ensure_clean_for_three_way_patch(repo_path: &Path) -> GitResult<()> {
1034+
let output = Self::run_git(
1035+
&["status", "--porcelain=v1", "--untracked-files=no"],
1036+
Some(repo_path),
1037+
)?;
1038+
if Self::status_has_tracked_changes(&output) {
1039+
return Err(GitError::InvalidInput(
1040+
Self::patch_three_way_blocked_message(),
1041+
));
1042+
}
1043+
Ok(())
1044+
}
1045+
1046+
fn apply_patch_three_way(repo_path: &Path, patch_path: &str) -> GitResult<OperationResult> {
1047+
Self::ensure_clean_for_three_way_patch(repo_path)?;
1048+
1049+
match Self::run_git(
1050+
&["apply", "--3way", "--binary", patch_path],
1051+
Some(repo_path),
1052+
) {
1053+
Ok(output) => Ok(Self::operation_result(
1054+
Self::PATCH_IMPORT_APPLIED.to_string(),
1055+
output,
1056+
repo_path,
1057+
)),
1058+
Err(GitError::CommandFailed {
1059+
command,
1060+
stderr,
1061+
exit_code,
1062+
}) => {
1063+
let status_output = Self::run_git(&["status", "--porcelain=v1"], Some(repo_path))?;
1064+
if Self::status_has_unmerged_files(&status_output) {
1065+
return Ok(Self::operation_result(
1066+
Self::PATCH_IMPORT_CONFLICTS.to_string(),
1067+
stderr,
1068+
repo_path,
1069+
));
1070+
}
1071+
1072+
if stderr.contains("does not match index") {
1073+
return Err(GitError::InvalidInput(
1074+
Self::patch_three_way_blocked_message(),
1075+
));
1076+
}
1077+
1078+
Err(GitError::CommandFailed {
1079+
command,
1080+
stderr,
1081+
exit_code,
1082+
})
1083+
}
1084+
Err(error) => Err(error),
1085+
}
1086+
}
1087+
9921088
fn submodule_index_commit(repo_path: &Path, path: &str) -> Option<String> {
9931089
let output = Self::run_git_allow_exit_codes(
9941090
&["ls-files", "--stage", "--", path],
@@ -2639,11 +2735,15 @@ impl GitOperationHandler for CliGitHandler {
26392735
}
26402736

26412737
fn import_patch_file(&self, request: &ImportPatchRequest) -> GitResult<OperationResult> {
2642-
self.check_patch_file(request)?;
2643-
26442738
let repo_path = Self::normalise_repo_path(&request.repo_path)?;
26452739
let patch_path = Self::validate_patch_path(&request.patch_path)?;
26462740
let patch_path_string = patch_path.to_string_lossy().to_string();
2741+
2742+
if request.three_way {
2743+
return Self::apply_patch_three_way(&repo_path, &patch_path_string);
2744+
}
2745+
2746+
self.check_patch_file(request)?;
26472747
let output = Self::run_git(&["apply", "--binary", &patch_path_string], Some(&repo_path))?;
26482748

26492749
Ok(Self::operation_result(

‎src-tauri/src/git/types.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ pub struct Settings {
296296
pub struct ImportPatchRequest {
297297
pub repo_path: String,
298298
pub patch_path: String,
299+
#[serde(default)]
300+
pub three_way: bool,
299301
}
300302

301303
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]

‎src-tauri/tests/git.rs‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,19 @@ fn import_patch_request(repo: &TempDir, patch_path: &Path) -> ImportPatchRequest
238238
ImportPatchRequest {
239239
repo_path: repo.path().to_str().unwrap().to_string(),
240240
patch_path: patch_path.to_str().unwrap().to_string(),
241+
three_way: false,
242+
}
243+
}
244+
245+
fn import_patch_request_with_three_way(
246+
repo: &TempDir,
247+
patch_path: &Path,
248+
three_way: bool,
249+
) -> ImportPatchRequest {
250+
ImportPatchRequest {
251+
repo_path: repo.path().to_str().unwrap().to_string(),
252+
patch_path: patch_path.to_str().unwrap().to_string(),
253+
three_way,
241254
}
242255
}
243256

@@ -255,6 +268,23 @@ fn export_patch_request(
255268
}
256269
}
257270

271+
fn assert_patch_contains_full_index(output: &str) {
272+
let index_line = output
273+
.lines()
274+
.find(|line| line.starts_with("index "))
275+
.expect("patch has index line");
276+
let ids = index_line
277+
.trim_start_matches("index ")
278+
.split_whitespace()
279+
.next()
280+
.expect("index line has object ids");
281+
let (old, new) = ids
282+
.split_once("..")
283+
.expect("index line separates object ids");
284+
assert_eq!(old.len(), 40);
285+
assert_eq!(new.len(), 40);
286+
}
287+
258288
fn export_commit_patch_request(
259289
repo: &TempDir,
260290
patch_path: &Path,
@@ -420,6 +450,104 @@ fn import_patch_rejects_non_applicable_patch_without_modifying_files() {
420450
assert_eq!(read_file(dir.path(), "file.txt"), "different\n");
421451
}
422452

453+
#[test]
454+
fn import_patch_three_way_returns_conflict_result_for_drifted_full_index_patch() {
455+
let dir = init_repo();
456+
write_file(dir.path(), "file.txt", "base\n");
457+
git(dir.path(), &["add", "file.txt"]);
458+
git(dir.path(), &["commit", "-m", "add base file"]);
459+
let base_hash = head_hash(dir.path());
460+
461+
write_file(dir.path(), "file.txt", "patch\n");
462+
git(dir.path(), &["add", "file.txt"]);
463+
git(dir.path(), &["commit", "-m", "patch change"]);
464+
let patch = dir.path().join("change.patch");
465+
let patch_content = git_stdout(
466+
dir.path(),
467+
&["diff", "--full-index", "--binary", &base_hash, "HEAD", "--"],
468+
);
469+
fs::write(&patch, format!("{patch_content}\n")).expect("write patch");
470+
471+
git(dir.path(), &["reset", "--hard", &base_hash]);
472+
write_file(dir.path(), "file.txt", "target\n");
473+
git(dir.path(), &["add", "file.txt"]);
474+
git(dir.path(), &["commit", "-m", "target drift"]);
475+
476+
let result = handler()
477+
.import_patch_file(&import_patch_request_with_three_way(&dir, &patch, true))
478+
.expect("three-way import result");
479+
480+
assert_eq!(result.message, "GITMUN_PATCH_IMPORT_CONFLICTS");
481+
assert!(
482+
git_stdout(dir.path(), &["status", "--porcelain"])
483+
.lines()
484+
.any(|line| line.starts_with("UU file.txt"))
485+
);
486+
assert!(read_file(dir.path(), "file.txt").contains("<<<<<<<"));
487+
}
488+
489+
#[test]
490+
fn import_patch_three_way_failure_without_blob_data_leaves_files_unchanged() {
491+
let dir = init_repo();
492+
write_file(dir.path(), "file.txt", "different\n");
493+
git(dir.path(), &["add", "file.txt"]);
494+
git(dir.path(), &["commit", "-m", "add file"]);
495+
496+
let patch = dir.path().join("change.patch");
497+
fs::write(
498+
&patch,
499+
"diff --git a/file.txt b/file.txt\n\
500+
index 624785c..172491a 100644\n\
501+
--- a/file.txt\n\
502+
+++ b/file.txt\n\
503+
@@ -1 +1 @@\n\
504+
-before\n\
505+
+after\n",
506+
)
507+
.expect("write patch");
508+
509+
let result =
510+
handler().import_patch_file(&import_patch_request_with_three_way(&dir, &patch, true));
511+
512+
assert!(result.is_err());
513+
assert_eq!(read_file(dir.path(), "file.txt"), "different\n");
514+
assert!(
515+
git_stdout(dir.path(), &["status", "--porcelain"])
516+
.lines()
517+
.all(|line| !line.starts_with("UU "))
518+
);
519+
}
520+
521+
#[test]
522+
fn import_patch_three_way_blocks_dirty_tracked_files() {
523+
let dir = init_repo();
524+
write_file(dir.path(), "file.txt", "base\n");
525+
git(dir.path(), &["add", "file.txt"]);
526+
git(dir.path(), &["commit", "-m", "add base file"]);
527+
let base_hash = head_hash(dir.path());
528+
529+
write_file(dir.path(), "file.txt", "patch\n");
530+
git(dir.path(), &["add", "file.txt"]);
531+
git(dir.path(), &["commit", "-m", "patch change"]);
532+
let patch = dir.path().join("change.patch");
533+
let patch_content = git_stdout(
534+
dir.path(),
535+
&["diff", "--full-index", "--binary", &base_hash, "HEAD", "--"],
536+
);
537+
fs::write(&patch, format!("{patch_content}\n")).expect("write patch");
538+
539+
git(dir.path(), &["reset", "--hard", &base_hash]);
540+
write_file(dir.path(), "file.txt", "local\n");
541+
542+
let error = handler()
543+
.import_patch_file(&import_patch_request_with_three_way(&dir, &patch, true))
544+
.expect_err("dirty files block three-way apply")
545+
.to_string();
546+
547+
assert!(error.contains("GITMUN_ERROR_PATCH_IMPORT_THREE_WAY_BLOCKED"));
548+
assert_eq!(read_file(dir.path(), "file.txt"), "local\n");
549+
}
550+
423551
#[test]
424552
fn export_staged_patch_contains_only_staged_changes() {
425553
let dir = init_repo();
@@ -444,6 +572,7 @@ fn export_staged_patch_contains_only_staged_changes() {
444572

445573
assert!(output.contains("diff --git a/staged.txt b/staged.txt"));
446574
assert!(!output.contains("unstaged.txt"));
575+
assert_patch_contains_full_index(&output);
447576
}
448577

449578
#[test]
@@ -469,6 +598,7 @@ fn export_unstaged_patch_contains_tracked_unstaged_and_untracked_files() {
469598
assert!(output.contains("diff --git a/tracked.txt b/tracked.txt"));
470599
assert!(output.contains("diff --git a/new.txt b/new.txt"));
471600
assert!(output.contains("new file mode"));
601+
assert_patch_contains_full_index(&output);
472602
}
473603

474604
#[test]
@@ -497,6 +627,7 @@ fn export_all_concatenates_staged_unstaged_and_untracked_changes() {
497627
assert!(output.contains("diff --git a/staged.txt b/staged.txt"));
498628
assert!(output.contains("diff --git a/unstaged.txt b/unstaged.txt"));
499629
assert!(output.contains("diff --git a/new.txt b/new.txt"));
630+
assert_patch_contains_full_index(&output);
500631
}
501632

502633
#[test]
@@ -544,6 +675,7 @@ fn export_selected_honours_staged_and_unstaged_file_selections() {
544675
assert!(output.contains("diff --git a/unstaged.txt b/unstaged.txt"));
545676
assert!(output.contains("diff --git a/new.txt b/new.txt"));
546677
assert!(!output.contains("ignored.txt"));
678+
assert_patch_contains_full_index(&output);
547679
}
548680

549681
#[test]
@@ -562,6 +694,7 @@ fn export_commit_patch_contains_selected_commit_changes() {
562694

563695
assert!(output.contains("diff --git a/commit.txt b/commit.txt"));
564696
assert!(output.contains("+first"));
697+
assert_patch_contains_full_index(&output);
565698
}
566699

567700
#[test]

0 commit comments

Comments
 (0)