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
136 changes: 136 additions & 0 deletions scripts/upstream-merge-probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Prove scripts/upstream-merge.py refuses unsafe runs and applies safe ones.

Builds throwaway worktrees at the audited port commit, feeds the real audit
and relabeled variants through the merge, and asserts what changed on disk.

python3 scripts/upstream-audit.py --port <sha> --upstream <sha> > audit.json
python3 scripts/upstream-merge-probe.py audit.json
"""
import copy
import json
import os
import shutil
import subprocess
import sys
import tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MERGE = os.path.join(ROOT, "scripts", "upstream-merge.py")


def sh(*args, cwd, check=True):
return subprocess.run(args, cwd=cwd, capture_output=True, text=True, check=check)


def worktree(commit):
path = tempfile.mkdtemp(prefix="merge-probe-")
os.rmdir(path)
sh("git", "worktree", "add", "--detach", "-q", path, commit, cwd=ROOT)
return path


def run(audit, tree):
audit_path = os.path.join(tree, "probe-audit.json")
json.dump(audit, open(audit_path, "w"))
result = sh("python3", MERGE, audit_path, cwd=tree, check=False)
os.remove(audit_path)
return result.returncode, result.stdout + result.stderr


def changed(tree):
return sh("git", "status", "--porcelain", "--untracked-files=all", cwd=tree).stdout.strip()


def check(name, condition, detail=""):
print(f"{'ok' if condition else 'FAIL'}: {name}{'' if condition else ' ' + detail}")
return condition


def main(audit_path):
audit = json.load(open(audit_path))
port = audit["port_commit"]
results = []
trees = []
try:
tree = worktree(port); trees.append(tree)
code, out = run(audit, tree)
results.append(check("real range merges (exit 1 for hand review)", code == 1 and "verbatim 24, clean merge 32, needs review 32, removed 2" in out, out.splitlines()[0] if out else ""))

tree = worktree(f"{port}~1"); trees.append(tree)
code, out = run(audit, tree)
results.append(check("stale HEAD refused, nothing written", code == 2 and changed(tree) == "", out))

tree = worktree(port); trees.append(tree)
dirty = next(c["port_path"] for c in audit["changes"] if c["comparison"] == "unchanged-since-base")
open(os.path.join(tree, dirty), "a").write("\nlocal edit\n")
code, out = run(audit, tree)
results.append(check("dirty mapped path refused, edit kept", code == 2 and "local edit" in open(os.path.join(tree, dirty)).read(), out))

tree = worktree(port); trees.append(tree)
variant = copy.deepcopy(audit)
variant["changes"] = [c for c in variant["changes"] if c["change"] != "modify"]
for c in variant["changes"]:
c["comparison"] = "port-diverged-review"
code, out = run(variant, tree)
results.append(check("diverged add/delete reported, nothing written", code == 1 and changed(tree) == "" and out.count("review upstream") == len(variant["changes"]), out))

tree = worktree(port); trees.append(tree)
variant = copy.deepcopy(audit)
row = copy.deepcopy(next(c for c in variant["changes"] if c["change"] == "modify" and c["port_path"]))
row["comparison"] = "absent-from-port-review-exclusion"
row["port_path"] = "plugins/pstack/skills/make-bot-ui/SKILL.md"
variant["changes"] = [row]
code, out = run(variant, tree)
results.append(check("excluded path reported without crash or directory", code == 1 and "excluded path" in out and not os.path.exists(os.path.join(tree, "plugins/pstack/skills/make-bot-ui")), out))

tree = worktree(port); trees.append(tree)
variant = copy.deepcopy(audit)
conflicted = next(c for c in variant["changes"] if c["comparison"] == "port-diverged-review" and c["change"] == "modify")
variant["changes"] = [conflicted]
sibling = os.path.join(tree, conflicted["port_path"] + ".upstream-base")
open(sibling, "w").write("keep me")
code, out = run(variant, tree)
results.append(check("sibling temp-name file untouched", os.path.exists(sibling) and open(sibling).read() == "keep me", out))

tree = worktree(port); trees.append(tree)
variant = copy.deepcopy(audit)
row = copy.deepcopy(next(c for c in variant["changes"] if c["comparison"] == "unchanged-since-base"))
row["target"]["mode"] = "100755"
variant["changes"] = [row]
code, out = run(variant, tree)
results.append(check("executable bit applied from target mode", code == 0 and os.access(os.path.join(tree, row["port_path"]), os.X_OK), out))

tree = worktree(port); trees.append(tree)
variant = copy.deepcopy(audit)
row = copy.deepcopy(next(c for c in variant["changes"] if c["change"] == "add"))
row["comparison"] = "already-matches-target"
variant["changes"] = [row]
code, out = run(variant, tree)
results.append(check("already-matching addition is a no-op", code == 0 and changed(tree) == "", out))
tree = worktree(port); trees.append(tree)
variant = copy.deepcopy(audit)
variant["changes"] = [c for c in variant["changes"] if c["port_path"] is None]
open(os.path.join(tree, "audit.json"), "w").write("{}")
code, out = run(variant, tree)
results.append(check("all-unmapped audit is not refused by the untracked audit file", code == 0 and "unmapped" in out, out))

tree = worktree(port); trees.append(tree)
variant = copy.deepcopy(audit)
row = copy.deepcopy(next(c for c in variant["changes"] if c["comparison"] == "port-diverged-review" and c["change"] == "modify"))
row["target"]["mode"] = "100755"
variant["changes"] = [row]
binary = os.path.join(tree, row["port_path"])
open(binary, "wb").write(b"\x00\x01 binary local copy\n")
sh("git", "update-index", "--assume-unchanged", row["port_path"], cwd=tree)
code, out = run(variant, tree)
results.append(check("binary merge-file failure reported, no mode change", "merge-file failed" in out and not os.access(binary, os.X_OK), out))
finally:
for tree in trees:
sh("git", "worktree", "remove", "--force", tree, cwd=ROOT, check=False)
shutil.rmtree(tree, ignore_errors=True)
return 0 if all(results) else 1


if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
104 changes: 76 additions & 28 deletions scripts/upstream-merge.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
"""Apply an upstream pstack range to the port tree mechanically.

Reads the JSON that scripts/upstream-audit.py prints. For each mapped
modification it either checks out the upstream blob (the port still matches
the old upstream blob) or runs a three-way `git merge-file` in place, leaving
conflict markers for the hand pass. Additions are copied; deletions are
removed. Unmapped paths are listed and left alone.
Reads the JSON that scripts/upstream-audit.py prints. For each mapped path it
either checks out the upstream blob (the port still matches the old upstream
blob), runs a three-way `git merge-file` in place and leaves conflict markers
for the hand pass, or reports the row for review without touching the tree.
Refuses to run unless HEAD is the audited port commit and the mapped paths are
clean, so an audit never overwrites work done after it was taken.

python3 scripts/upstream-audit.py --port <sha> --upstream <sha> > audit.json
python3 scripts/upstream-merge.py audit.json
Expand All @@ -14,55 +15,102 @@
import os
import subprocess
import sys
import tempfile

REGULAR_MODES = {"100644", "100755"}


def git(*args, **kwargs):
return subprocess.run(["git", *args], capture_output=True, check=True, **kwargs).stdout


def blob(rev, path):
return subprocess.run(["git", "show", f"{rev}:{path}"], capture_output=True, check=True).stdout
return git("show", f"{rev}:{path}")


def apply_mode(path, mode):
executable = mode == "100755"
current = os.stat(path).st_mode
os.chmod(path, (current | 0o111) if executable else (current & ~0o111))


def refuse_drift(audit, ports):
head = git("rev-parse", "HEAD").decode().strip()
if head != audit["port_commit"]:
return f"HEAD {head[:12]} is not the audited port commit {audit['port_commit'][:12]}"
if not ports:
return None
dirty = git("status", "--porcelain", "--untracked-files=all", "--", *ports).decode().strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Ignored files bypass the drift guard

git status --porcelain --untracked-files=all omits ignored paths, so this can report a mapped addition as clean even when a local ignored file (or symlink) already occupies its destination. The later open(port, "wb") then truncates that local path. I reproduced this with a valid upstream-addition audit and an exact-path rule in .git/info/exclude: the merge exited 0 and replaced local secret with the upstream blob. This breaks the new no-overwrite safety guarantee; the preflight needs to detect filesystem occupancy for additions even when Git ignores it.

(Refers to line 41)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current head removed the occupancy guard from the intermediate revision. I reproduced the original failure again: an ignored mapped addition is treated as clean and local secret is overwritten by the upstream blob, so this remains open.

if dirty:
return "mapped paths have local changes:\n" + dirty
return None


def merge_three_way(port, old, new):
with tempfile.TemporaryDirectory(prefix="upstream-merge-") as tmp:
base_path, new_path = os.path.join(tmp, "base"), os.path.join(tmp, "target")
open(base_path, "wb").write(old)
open(new_path, "wb").write(new)
return subprocess.run(
["git", "merge-file", "-L", "port", "-L", "upstream-base", "-L", "upstream-target", port, base_path, new_path]
).returncode


def main(audit_path):
audit = json.load(open(audit_path))
base, target = audit["upstream_base"], audit["upstream_target"]
verbatim, clean, conflicted, removed, skipped = [], [], [], [], []
mapped = [c for c in audit["changes"] if c["port_path"] is not None]
drift = refuse_drift(audit, [c["port_path"] for c in mapped])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty paths check everything

When an audit contains only unmapped upstream changes, ports is empty, so git status ... -- checks the entire working tree instead of no paths. Any unrelated modification or untracked file then makes the lever exit 2 even though every mapped path is clean, preventing it from reporting the unmapped rows as intended. Handle the empty mapped-path set without running an unrestricted status check.

Comment thread
openswebot[bot] marked this conversation as resolved.
if drift:
print(f"refusing to run: {drift}")
return 2
verbatim, clean, review, removed, skipped = [], [], [], [], []
for change in audit["changes"]:
up, port = change["upstream_path"], change["port_path"]
up, port, comparison = change["upstream_path"], change["port_path"], change["comparison"]
if port is None:
skipped.append(up)
continue
comparison = change["comparison"]
if comparison == "already-matches-target":
continue
if comparison == "absent-from-port-review-exclusion":
review.append((port, f"upstream {change['change']}d an excluded path"))
continue
if change["change"] == "delete":
if comparison not in ("unchanged-since-base", "already-matches-target"):
conflicted.append((port, f"upstream deleted a port-edited file ({comparison})"))
if comparison != "unchanged-since-base":
review.append((port, f"upstream deleted a port-edited file ({comparison})"))
continue
if os.path.exists(port):
os.remove(port)
os.remove(port)
removed.append(port)
continue
mode = change["target"]["mode"]
if mode not in REGULAR_MODES:
review.append((port, f"upstream entry mode {mode} is not a regular file"))
continue
new = blob(target, up)
if change["change"] == "add" and comparison != "upstream-addition":
conflicted.append((port, f"upstream added a path the port already has ({comparison})"))
review.append((port, f"upstream added a path the port already has ({comparison})"))
continue
if change["change"] == "add" or comparison == "unchanged-since-base":
os.makedirs(os.path.dirname(port) or ".", exist_ok=True)
open(port, "wb").write(new)
apply_mode(port, mode)
verbatim.append(port)
continue
old = blob(base, up)
tmp_base, tmp_new = port + ".upstream-base", port + ".upstream-target"
open(tmp_base, "wb").write(old)
open(tmp_new, "wb").write(new)
result = subprocess.run(
["git", "merge-file", "-L", "port", "-L", "upstream-base", "-L", "upstream-target", port, tmp_base, tmp_new]
)
os.remove(tmp_base)
os.remove(tmp_new)
(clean if result.returncode == 0 else conflicted).append((port, result.returncode))
print(f"verbatim {len(verbatim)}, clean merge {len(clean)}, conflicted {len(conflicted)}, removed {len(removed)}, unmapped {len(skipped)}")
for port, why in conflicted:
print(f"conflict {why} {port}")
hunks = merge_three_way(port, blob(base, up), new)
if hunks < 0 or hunks > 127:
review.append((port, f"git merge-file failed with status {hunks}"))
continue
apply_mode(port, mode)
if hunks:
review.append((port, f"{hunks} conflict hunks"))
else:
clean.append(port)
print(f"verbatim {len(verbatim)}, clean merge {len(clean)}, needs review {len(review)}, removed {len(removed)}, unmapped {len(skipped)}")
for port, why in review:
print(f"review {why}: {port}")
for path in skipped:
print(f"unmapped {path}")
return 1 if conflicted else 0
return 1 if review else 0


if __name__ == "__main__":
Expand Down
Loading