Skip to content

Commit 87ec001

Browse files
authored
Improve resolve conflicts script (open-telemetry#18606)
1 parent 0cafff8 commit 87ec001

1 file changed

Lines changed: 69 additions & 6 deletions

File tree

.github/scripts/pr-triage/resolve_conflicts.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import argparse
77
import sys
88
from pathlib import Path
9+
from typing import TypedDict
910

1011
from common import (
1112
Summary,
@@ -21,6 +22,15 @@
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+
2434
def 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+
3359
def 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

Comments
 (0)