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
59 changes: 48 additions & 11 deletions src/wraith_tools/sync_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from dataclasses import dataclass, field
from pathlib import Path

COMPANIONS = ("DBT", "GCP", "TF", "SP")
COMPANIONS = ("DBT", "GCP", "TF", "SP", "MP")
# The companion skills wraith names, from the Decision sections of
# docs/adr/0002 to 0004 and 0006. Drift anywhere else in a vendor repo is
# out of scope: wraith never vetted those skills and never points to them
Expand Down Expand Up @@ -85,6 +85,19 @@
"writing-skills",
}
),
"MP": frozenset(
{
"tdd",
"diagnosing-bugs",
"grilling",
"handoff",
"domain-modeling",
"code-review",
"setup-matt-pocock-skills",
"pr",
"writing-for-agents",
}
),
}
SKIP_DIRS = {".git", ".upstream", ".venv", "node_modules"}

Expand Down Expand Up @@ -250,21 +263,31 @@ class Change:

def _sync_file(
root: Path, rel: str, entries: list[Entry], old: Path, new: Path
) -> tuple[Row | None, Change | None]:
) -> tuple[Row | None, Change | None, list[tuple[str, str, str]]]:
changed = []
for key, _sha7, upath in entries:
# Source lines of files upstream left alone; main() moves them with
# the pin, or they drift from upstream-pins.txt.
moved = []
for key, sha7, upath in entries:
before = _read(old / key / upath)
after = _read(new / key / upath)
if before is None or before == after:
if before is None:
continue
if before == after:
new7 = _sha(new / key)[:7]
if new7 and new7 != sha7:
moved.append(
(key, f"{key}@{sha7}:{upath}", f"{key}@{new7}:{upath}")
)
continue
changed.append((key, upath, before, after))
if not changed:
return None, None
return None, None, moved
key, upath, before, after = changed[0]
if len(entries) > 1:
return Row(rel, "composite"), None
return Row(rel, "composite"), None, []
if after is None:
return Row(rel, "removed"), None
return Row(rel, "removed"), None, []
diff = _diff(before, after, upath)
hits = risk_scan(diff)
change = Change(key, upath, diff)
Expand All @@ -276,12 +299,12 @@ def _sync_file(
theirs_file.write_text(after, encoding="utf-8")
merged, clean = merge_file(ours, base_file, theirs_file)
if not clean:
return Row(rel, "conflict", hits), change
return Row(rel, "conflict", hits), change, []
old7 = entries[0][1]
new7 = _sha(new / key)[:7]
merged = merged.replace(f"{key}@{old7}:{upath}", f"{key}@{new7}:{upath}")
ours.write_text(merged, encoding="utf-8")
return Row(rel, "clean", hits), change
return Row(rel, "clean", hits), change, []


def _named_dirs(root: Path, key: str) -> set[str]:
Expand Down Expand Up @@ -423,15 +446,29 @@ def main(argv: list[str] | None = None) -> int:
rows: list[Row] = []
changes: dict[str, Change] = {}
blocked: set[str] = set()
derived: set[str] = set()
moves: list[tuple[str, str, str, str]] = []
for rel, entries in provenance(args.root).items():
row, change = _sync_file(args.root, rel, entries, args.old, args.new)
row, change, moved = _sync_file(
args.root, rel, entries, args.old, args.new
)
derived.update(key for key, _, _ in entries)
moves += [(rel, key, was, now) for key, was, now in moved]
if row:
rows.append(row)
if row.status != "clean":
blocked.update(key for key, _, _ in entries)
if change:
changes[rel] = change
drift = _companion_drift(args.old, args.new)
# A companion whose named skills drifted waits for its re-vet, even
# when its derived files merged cleanly.
blocked.update(drift)
for rel, key, was, now in moves:
if key not in blocked:
path = args.root / rel
text = path.read_text(encoding="utf-8")
path.write_text(text.replace(was, now), encoding="utf-8")

pins = []
for path in sorted(p for p in args.new.iterdir() if p.is_dir()):
Expand All @@ -447,7 +484,7 @@ def main(argv: list[str] | None = None) -> int:
" ".join(
key
for key, _, _, sha in pins
if key not in COMPANIONS
if (key not in COMPANIONS or key in derived)
and key not in blocked
and _sha(args.old / key) != sha
)
Expand Down
1 change: 1 addition & 0 deletions src/wraith_tools/vet_companion.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"GCP": "0003-companion-google.md",
"TF": "0004-companion-hashicorp.md",
"SP": "0006-process-skills-to-companions.md",
"MP": "0006-process-skills-to-companions.md",
}
DIFF_LIMIT = 150_000
BODY_DIFF_LIMIT = 45_000
Expand Down
48 changes: 45 additions & 3 deletions tests/test_sync_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,11 @@ def test_clean_merge_applied_and_sha_rewritten(tmp_path):
def test_prints_only_fully_clean_moved_keys(tmp_path, capsys):
ka = upstream(tmp_path / "old", "KA", {"f.md": BASE})
gs = upstream(tmp_path / "old", "GS", {"g.md": BASE})
upstream(tmp_path / "old", "MP", {"m.md": BASE})
upstream(tmp_path / "old", "AA", {"m.md": BASE})
upstream(tmp_path / "old", "TF", {"t.md": BASE})
upstream(tmp_path / "new", "KA", {"f.md": THEIRS})
upstream(tmp_path / "new", "GS", {"g.md": THEIRS})
upstream(tmp_path / "new", "MP", {"m.md": THEIRS})
upstream(tmp_path / "new", "AA", {"m.md": THEIRS})
upstream(tmp_path / "new", "TF", {"t.md": THEIRS})
local(tmp_path / "root", "f.md", OURS, f"KA@{ka[:7]}:f.md")
local(
Expand All @@ -164,7 +164,7 @@ def test_prints_only_fully_clean_moved_keys(tmp_path, capsys):
f"GS@{gs[:7]}:g.md",
)
run(tmp_path)
assert capsys.readouterr().out.split() == ["KA", "MP"]
assert capsys.readouterr().out.split() == ["AA", "KA"]


def test_conflict_left_untouched(tmp_path):
Expand Down Expand Up @@ -367,3 +367,45 @@ def test_provenance_reads_source_in_first_fenced_block_of_md(tmp_path):
"t.md": [("MP", "d1478fc", "a/t.sh")],
"u.md": [("MP", "d1478fc", "a/u.sh")],
}


def test_unchanged_file_follows_a_moved_pin(tmp_path):
old_sha = upstream(tmp_path / "old", "AA", {"f.md": BASE})
new_sha = upstream(
tmp_path / "new", "AA", {"f.md": BASE, "other.md": "x\n"}
)
path = local(tmp_path / "root", "f.md", OURS, f"AA@{old_sha[:7]}:f.md")
run(tmp_path)
assert f"AA@{new_sha[:7]}:f.md" in path.read_text(encoding="utf-8")


def test_blocked_key_keeps_its_unchanged_source_lines(tmp_path):
old_sha = upstream(tmp_path / "old", "AA", {"f.md": BASE, "g.md": BASE})
upstream(tmp_path / "new", "AA", {"f.md": BASE, "g.md": THEIRS})
keep = local(tmp_path / "root", "f.md", OURS, f"AA@{old_sha[:7]}:f.md")
local(
tmp_path / "root",
"g.md",
BASE.replace("line6", "line6 ours"),
f"AA@{old_sha[:7]}:g.md",
)
run(tmp_path)
assert f"AA@{old_sha[:7]}:f.md" in keep.read_text(encoding="utf-8")


def test_companion_that_is_also_derived_still_bumps(tmp_path, capsys):
old_sha = upstream(tmp_path / "old", "MP", {"m.md": BASE})
upstream(tmp_path / "new", "MP", {"m.md": THEIRS})
local(tmp_path / "root", "m.md", OURS, f"MP@{old_sha[:7]}:m.md")
run(tmp_path)
assert "MP" in capsys.readouterr().out.split()


def test_drifted_companion_is_not_bumped(tmp_path, capsys):
old_sha = upstream(
tmp_path / "old", "MP", {"m.md": BASE, "tdd/SKILL.md": "a\n"}
)
upstream(tmp_path / "new", "MP", {"m.md": THEIRS, "tdd/SKILL.md": "b\n"})
local(tmp_path / "root", "m.md", OURS, f"MP@{old_sha[:7]}:m.md")
run(tmp_path)
assert "MP" not in capsys.readouterr().out.split()