66import argparse
77import sys
88from pathlib import Path
9+ from typing import TypedDict
910
1011from common import (
1112 Summary ,
2122)
2223
2324
25+ CONFLICT_STAGES = ((1 , "base" ), (2 , "ours-head" ), (3 , "theirs-merge-head" ))
26+
27+
28+ class ConflictEntry (TypedDict ):
29+ path : str
30+ conflict_diff : str
31+ stage_snapshots : list [str ]
32+
33+
2434def parse_args () -> argparse .Namespace :
2535 parser = argparse .ArgumentParser (description = __doc__ )
2636 parser .add_argument ("pr" , type = int , help = "pull request number" )
@@ -30,21 +40,71 @@ def parse_args() -> argparse.Namespace:
3040 return parser .parse_args ()
3141
3242
43+ def safe_conflict_name (path : str ) -> str :
44+ return path .replace ("/" , "__" ).replace ("\\ " , "__" )
45+
46+
47+ def write_stage_snapshot (directory : Path , path : str , summary : Summary ) -> list [str ]:
48+ stage_paths : list [str ] = []
49+ for stage , label in CONFLICT_STAGES :
50+ result = git (["show" , f":{ stage } :{ path } " ], summary , check = False )
51+ if result .returncode != 0 :
52+ continue
53+ snapshot_path = directory / f"{ safe_conflict_name (path )} .{ label } "
54+ snapshot_path .write_text (result .stdout , encoding = "utf-8" )
55+ stage_paths .append (str (snapshot_path ))
56+ return stage_paths
57+
58+
3359def write_conflict_bundle (directory : Path , summary : Summary ) -> Path :
3460 progress (f"Preparing merge conflict bundle in { directory } " )
3561 directory .mkdir (parents = True , exist_ok = True )
3662 files = unmerged_paths (summary )
37- write_json (directory / "conflicts.json" , {"files" : files })
63+ staged_versions_dir = directory / "staged-versions"
64+ staged_versions_dir .mkdir (parents = True , exist_ok = True )
65+
66+ conflicts : list [ConflictEntry ] = []
3867 (directory / "status.txt" ).write_text (git (["status" , "--porcelain" ], summary ).stdout , encoding = "utf-8" )
3968 (directory / "head-log.txt" ).write_text (git (["log" , "--oneline" , "-n" , "20" , "HEAD" ], summary ).stdout , encoding = "utf-8" )
4069 (directory / "merge-head-log.txt" ).write_text (git (["log" , "--oneline" , "-n" , "20" , "MERGE_HEAD" ], summary ).stdout , encoding = "utf-8" )
4170 for path in files :
42- safe_name = path .replace ("/" , "__" ).replace ("\\ " , "__" )
43- (directory / f"{ safe_name } .diff" ).write_text (git (["diff" , "--" , path ], summary ).stdout , encoding = "utf-8" )
71+ safe_name = safe_conflict_name (path )
72+ diff_path = directory / f"{ safe_name } .diff"
73+ diff_path .write_text (git (["diff" , "--" , path ], summary ).stdout , encoding = "utf-8" )
74+ conflicts .append (
75+ {
76+ "path" : path ,
77+ "conflict_diff" : str (diff_path ),
78+ "stage_snapshots" : write_stage_snapshot (staged_versions_dir , path , summary ),
79+ }
80+ )
81+ write_json (directory / "conflicts.json" , {"files" : conflicts })
4482 plan = directory / "conflict-plan.md"
4583 lines = ["# Merge Conflict Resolution Plan" , "" , "## Conflicted Files" , "" ]
46- lines .extend (f"- { path } " for path in files )
47- lines .extend (["" , "## Context" , "" , f"- Status: { directory / 'status.txt' } " , f"- HEAD log: { directory / 'head-log.txt' } " , f"- MERGE_HEAD log: { directory / 'merge-head-log.txt' } " , "" ])
84+ for conflict in conflicts :
85+ lines .append (f"- { conflict ['path' ]} " )
86+ lines .append (f" - Conflict diff: { conflict ['conflict_diff' ]} " )
87+ for stage_snapshot in conflict ["stage_snapshots" ]:
88+ lines .append (f" - Stage snapshot: { stage_snapshot } " )
89+ lines .extend (
90+ [
91+ "" ,
92+ "## Context" ,
93+ "" ,
94+ f"- Status: { directory / 'status.txt' } " ,
95+ f"- HEAD log: { directory / 'head-log.txt' } " ,
96+ f"- MERGE_HEAD log: { directory / 'merge-head-log.txt' } " ,
97+ "" ,
98+ "## Resolution Checklist" ,
99+ "" ,
100+ "- Inspect each conflict diff and all available stage snapshots before editing." ,
101+ "- Identify what behavior HEAD changed and what behavior MERGE_HEAD changed." ,
102+ "- Preserve both behaviors when they can coexist." ,
103+ "- If one side is intentionally dropped, record why it is obsolete or incompatible." ,
104+ "- After editing, compare the resolved file against both sides before staging it." ,
105+ "" ,
106+ ]
107+ )
48108 plan .write_text ("\n " .join (lines ), encoding = "utf-8" )
49109 summary .temp_dir = str (directory )
50110 return plan
@@ -64,7 +124,10 @@ def copilot_prompt(plan_path: Path) -> str:
64124- Do not push.
65125- Do not rebase, abort, restart, or use force operations.
66126- Resolve only the conflicted files listed in the bundle.
67- - Preserve the intent of both HEAD and MERGE_HEAD when they can coexist.
127+ - For each conflicted file, inspect the conflict diff plus the available stage snapshots before editing: base, ours-HEAD, and theirs-MERGE_HEAD.
128+ - Preserve the intent of both HEAD and MERGE_HEAD when they can coexist; do not remove helpers, validation, generated bundle content, tests, or workflow steps from either side unless they are clearly obsolete after the merge.
129+ - If you choose one side over the other, explain why the dropped side is incompatible or superseded.
130+ - After editing each file, compare the resolved result against both side snapshots before staging it.
68131- If the conflict requires product judgment or involves binary/non-text files, stop and explain.
69132- Stage only files that you resolved.
70133- When done, print a concise summary of each resolved file.
0 commit comments