827 lines
33 KiB
Python
Executable File
827 lines
33 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""sync_upstream.py — bring upstream Zed GPUI changes into the standalone gpui-ce fork.
|
|
|
|
Strategy: a vendor-branch 3-way (subtree-style) merge that preserves upstream history.
|
|
A local branch (vendor/zed-gpui) holds a *filtered replay* of upstream's actual
|
|
gpui-touching commits — each preserving its author/date/message (plus a `zed-upstream:`
|
|
trailer) but carrying only the tracked crates/gpui* trees. Merging its tip replays exactly
|
|
the upstream delta since the last sync (already-cherry-picked changes no-op) while every
|
|
upstream commit stays in the merge's second-parent history. A conflicted merge is committed
|
|
as TWO commits for reviewability: the raw merge with conflict markers committed in, then
|
|
claude's resolution as a separate, auditable diff. Build fixes land in a third commit.
|
|
|
|
Local-only: this never pushes, and never lets claude push.
|
|
|
|
Usage:
|
|
sync_upstream.py bootstrap [BASELINE_SHA] # one-time setup (see README)
|
|
sync_upstream.py sync [REF] [options] # default subcommand
|
|
sync_upstream.py status
|
|
|
|
Options (sync):
|
|
--ref REF upstream ref to sync to (default: main / SYNC_ZED_REF)
|
|
--model NAME claude model (default: opus / SYNC_MODEL)
|
|
--retries N max claude passes per phase (default: 3 / SYNC_RETRIES)
|
|
--no-bump don't bump pinned zed git-dep revs
|
|
--dry-run show what would be synced, then stop
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import NoReturn, cast
|
|
|
|
# locate repo
|
|
SCRIPT_DIR: Path = Path(__file__).resolve().parent
|
|
REPO_ROOT: Path = Path(
|
|
subprocess.run(
|
|
["git", "-C", str(SCRIPT_DIR), "rev-parse", "--show-toplevel"],
|
|
capture_output=True, text=True, check=True,
|
|
).stdout.strip()
|
|
)
|
|
|
|
|
|
# static config (env-overridable)
|
|
def _env(name: str, default: str) -> str:
|
|
return os.environ.get(name, default)
|
|
|
|
|
|
# NOTE: `zed` is a SEPARATE remote from `upstream` (which points at gpui-ce/gpui-ce).
|
|
ZED_REMOTE_NAME: str = _env("SYNC_ZED_REMOTE_NAME", "zed")
|
|
ZED_REMOTE_URL: str = _env("SYNC_ZED_REMOTE_URL", "https://github.com/zed-industries/zed.git")
|
|
|
|
# Fork crate dir (under crates/) -> upstream crate path (under crates/). The in-tree gpui*
|
|
# crates map to themselves; the rest were vendored + renamed from the Zed monorepo by PR #91
|
|
# ("removed all of the git sources") and are tracked via path remapping. Their gpui-ce
|
|
# adaptations (package rename, path deps, ztracing->tracing, zlog removal, etc.) are
|
|
# preserved through the 3-way merge's conflict resolution — never re-applied by hand.
|
|
# Left untouched: gpui_elements (fork-only stub), tooling/perf (fork-only). util_macros is
|
|
# no longer used by the fork.
|
|
TRACKED_CRATES: dict[str, str] = {
|
|
# in-tree gpui crates (identity mapping)
|
|
"gpui": "gpui",
|
|
"gpui_linux": "gpui_linux",
|
|
"gpui_macos": "gpui_macos",
|
|
"gpui_macros": "gpui_macros",
|
|
"gpui_platform": "gpui_platform",
|
|
"gpui_shared_string": "gpui_shared_string",
|
|
"gpui_tokio": "gpui_tokio",
|
|
"gpui_web": "gpui_web",
|
|
"gpui_wgpu": "gpui_wgpu",
|
|
"gpui_windows": "gpui_windows",
|
|
# vendored + renamed from upstream (fork dir -> upstream path)
|
|
"gpui_collections": "collections",
|
|
"gpui_sum_tree": "sum_tree",
|
|
"gpui_refineable": "refineable",
|
|
"gpui_derive_refineable": "refineable/derive_refineable", # nested upstream
|
|
"gpui_scheduler": "scheduler",
|
|
"gpui_media": "media",
|
|
"gpui_zed_util": "util",
|
|
"gpui_ce_util": "gpui_util",
|
|
# Created mid-range by upstream's "Split out `RelPath` into a separate crate" (#61029),
|
|
# which MOVED crates/util/src/rel_path.rs into it. Tracked so the split arrives through the
|
|
# merge (see MOVE detection below) instead of leaving a stale duplicate in gpui_zed_util.
|
|
"gpui_path": "path",
|
|
}
|
|
|
|
VENDOR_BRANCH: str = _env("SYNC_VENDOR_BRANCH", "vendor/zed-gpui")
|
|
|
|
CLAUDE_BIN: str = _env("SYNC_CLAUDE_BIN", "claude")
|
|
# Per-invocation wall-clock cap (seconds); 0 disables. The real safety bound — hitting it
|
|
# is non-fatal (the loop re-checks progress and retries).
|
|
CLAUDE_TIMEOUT: int = int(_env("SYNC_CLAUDE_TIMEOUT", "1800"))
|
|
# Optional cap on claude's agentic turns per invocation (0 = no cap, the default).
|
|
CLAUDE_MAX_TURNS: int = int(_env("SYNC_CLAUDE_MAX_TURNS", "0"))
|
|
# Edits/writes are auto-accepted via --permission-mode acceptEdits; the rest are
|
|
# read-only/build helpers. Git staging/commits are done here, never by claude; push is
|
|
# never allowed.
|
|
_DEFAULT_ALLOWED_TOOLS: str = (
|
|
"Read Edit Write Grep Glob Bash(cargo check:*) Bash(cargo build:*) "
|
|
"Bash(cargo metadata:*) Bash(git status:*) Bash(git diff:*) Bash(git log:*) "
|
|
"Bash(rg:*) Bash(grep:*) Bash(ls:*) Bash(cat:*) Bash(sed:*) Bash(find:*)"
|
|
)
|
|
CLAUDE_ALLOWED_TOOLS: str = _env("SYNC_CLAUDE_ALLOWED_TOOLS", _DEFAULT_ALLOWED_TOOLS)
|
|
|
|
# Post-merge gates (host-buildable crates only; macOS/Windows changes need their own
|
|
# platform or CI). The verify-fix loop runs the build gate, then the test gate.
|
|
VERIFY_CMD: str = _env("SYNC_VERIFY_CMD", "just check")
|
|
TEST_CMD: str = _env("SYNC_TEST_CMD", "just test")
|
|
RUN_TESTS: bool = _env("SYNC_RUN_TESTS", "1") == "1"
|
|
# Treat compile warnings as a fixable condition (the synced branch must be warning-clean to
|
|
# pass CI, which denies warnings). Set 0 if pre-existing warnings cause churn.
|
|
FAIL_ON_WARNINGS: bool = _env("SYNC_FAIL_ON_WARNINGS", "1") == "1"
|
|
|
|
STATE_FILE: Path = REPO_ROOT / "scripts" / "sync-upstream" / "state.json"
|
|
|
|
|
|
# runtime config (env defaults; overridable by CLI flags / during a run)
|
|
@dataclass
|
|
class Runtime:
|
|
zed_ref: str
|
|
model: str
|
|
retries: int
|
|
bump_zed_deps: bool
|
|
dry_run: bool
|
|
build_log: str = ""
|
|
|
|
|
|
RT = Runtime(
|
|
zed_ref=_env("SYNC_ZED_REF", "main"),
|
|
model=_env("SYNC_MODEL", "opus"),
|
|
retries=int(_env("SYNC_RETRIES", "3")),
|
|
bump_zed_deps=_env("SYNC_BUMP_ZED_DEPS", "1") == "1",
|
|
dry_run=False,
|
|
)
|
|
|
|
|
|
# output
|
|
_COLOR: bool = sys.stdout.isatty()
|
|
|
|
|
|
def _c(code: str) -> str:
|
|
return code if _COLOR else ""
|
|
|
|
|
|
_B, _G, _Y, _R, _D, _Z = (
|
|
_c("\033[34m"), _c("\033[32m"), _c("\033[33m"), _c("\033[31m"), _c("\033[2m"), _c("\033[0m"),
|
|
)
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(f"{_B}▶{_Z} {msg}", flush=True)
|
|
|
|
|
|
def ok(msg: str) -> None:
|
|
print(f"{_G}✓{_Z} {msg}", flush=True)
|
|
|
|
|
|
def warn(msg: str) -> None:
|
|
print(f"{_Y}⚠{_Z} {msg}", file=sys.stderr, flush=True)
|
|
|
|
|
|
class SyncError(Exception):
|
|
"""Fatal, user-facing error; caught in main() and printed without a traceback."""
|
|
|
|
|
|
def die(msg: str) -> NoReturn:
|
|
raise SyncError(msg)
|
|
|
|
|
|
# git helpers
|
|
def git(
|
|
*args: str,
|
|
check: bool = True,
|
|
capture: bool = True,
|
|
env: dict[str, str] | None = None,
|
|
input_text: str | None = None,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
full_env: dict[str, str] = dict(os.environ)
|
|
if env:
|
|
full_env.update(env)
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=REPO_ROOT,
|
|
env=full_env,
|
|
input=input_text,
|
|
text=True,
|
|
stdout=subprocess.PIPE if capture else None,
|
|
stderr=subprocess.PIPE if capture else None,
|
|
)
|
|
if check and result.returncode != 0:
|
|
detail = (result.stderr or "").strip()
|
|
die(f"`git {' '.join(args)}` failed (exit {result.returncode})"
|
|
+ (f":\n{detail}" if detail else ""))
|
|
return result
|
|
|
|
|
|
def run_git(
|
|
*args: str,
|
|
check: bool = True,
|
|
capture: bool = True,
|
|
env: dict[str, str] | None = None,
|
|
input_text: str | None = None,
|
|
) -> None:
|
|
"""git() for side-effect-only calls (result intentionally discarded)."""
|
|
_ = git(*args, check=check, capture=capture, env=env, input_text=input_text)
|
|
|
|
|
|
def gout(*args: str, env: dict[str, str] | None = None) -> str:
|
|
return git(*args, env=env).stdout.strip()
|
|
|
|
|
|
def gok(*args: str) -> bool:
|
|
return git(*args, check=False).returncode == 0
|
|
|
|
|
|
# vendor history (filtered replay of upstream commits)
|
|
def tracked_pathspec() -> list[str]:
|
|
"""Upstream pathspecs for the tracked crates (used to filter upstream rev-lists)."""
|
|
return [f"crates/{up}" for up in TRACKED_CRATES.values()]
|
|
|
|
|
|
def filtered_tree(sha: str) -> str | None:
|
|
"""Tree object holding ONLY the tracked crates from <sha>, each placed at its FORK path
|
|
(remapping upstream dirs -> gpui-ce's renamed dirs). None if none are present.
|
|
|
|
Uses a throwaway index, so there's no working-tree churn.
|
|
"""
|
|
tmp = tempfile.mkdtemp()
|
|
env = {"GIT_INDEX_FILE": os.path.join(tmp, "index")}
|
|
try:
|
|
run_git("read-tree", "--empty", env=env)
|
|
added = 0
|
|
for fork_path, up_path in TRACKED_CRATES.items():
|
|
if gok("cat-file", "-e", f"{sha}:crates/{up_path}"):
|
|
run_git("read-tree", f"--prefix=crates/{fork_path}/", f"{sha}:crates/{up_path}", env=env)
|
|
added += 1
|
|
if added == 0:
|
|
return None
|
|
# A tracked upstream path can nest inside another (refineable/derive_refineable lives
|
|
# under refineable). The parent read pulled the nested subtree into the parent's fork
|
|
# prefix; drop it so each crate holds only its own files.
|
|
for fork_path, up_path in TRACKED_CRATES.items():
|
|
for other_up in TRACKED_CRATES.values():
|
|
if other_up != up_path and other_up.startswith(up_path + "/"):
|
|
rel = other_up[len(up_path) + 1:]
|
|
run_git("rm", "-r", "-q", "--cached", "--ignore-unmatch", "--",
|
|
f"crates/{fork_path}/{rel}", env=env, check=False)
|
|
return gout("write-tree", env=env)
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def build_vendor_snapshot(sha: str, parent: str | None = None) -> str:
|
|
"""Bootstrap baseline: a single filtered snapshot commit. Echoes the commit sha."""
|
|
tree = filtered_tree(sha)
|
|
if tree is None:
|
|
die(f"no tracked crates found at {sha[:12]}")
|
|
parent_args = ["-p", parent] if parent else []
|
|
msg = f"vendor: zed gpui baseline @ {sha[:12]}\n"
|
|
return git("commit-tree", tree, *parent_args, input_text=msg).stdout.strip()
|
|
|
|
|
|
def replay_commit(tree: str, parent: str, original: str) -> str:
|
|
"""Filtered commit preserving <original>'s author/committer/dates/message + trailer."""
|
|
message = gout("show", "-s", "--format=%B", original)
|
|
env = {
|
|
"GIT_AUTHOR_NAME": gout("show", "-s", "--format=%an", original),
|
|
"GIT_AUTHOR_EMAIL": gout("show", "-s", "--format=%ae", original),
|
|
"GIT_AUTHOR_DATE": gout("show", "-s", "--format=%aI", original),
|
|
"GIT_COMMITTER_NAME": gout("show", "-s", "--format=%cn", original),
|
|
"GIT_COMMITTER_EMAIL": gout("show", "-s", "--format=%ce", original),
|
|
"GIT_COMMITTER_DATE": gout("show", "-s", "--format=%cI", original),
|
|
}
|
|
body = f"{message}\n\nzed-upstream: {original}\n"
|
|
return git("commit-tree", tree, "-p", parent, env=env, input_text=body).stdout.strip()
|
|
|
|
|
|
def build_vendor_history(parent: str, frm: str, to: str) -> str:
|
|
"""Replay upstream gpui-touching commits frm..to as a filtered chain onto <parent>.
|
|
|
|
Merge commits and commits with no net change inside the tracked crates are dropped.
|
|
Echoes the new tip sha.
|
|
"""
|
|
prev = parent
|
|
replayed = 0
|
|
listing = gout("rev-list", "--reverse", "--topo-order", "--no-merges",
|
|
f"{frm}..{to}", "--", *tracked_pathspec())
|
|
for commit in listing.split():
|
|
tree = filtered_tree(commit)
|
|
if tree is None:
|
|
continue
|
|
if tree == gout("rev-parse", f"{prev}^{{tree}}"):
|
|
continue # no net change inside the tracked crates
|
|
prev = replay_commit(tree, prev, commit)
|
|
replayed += 1
|
|
log(f"replayed {replayed} upstream gpui commit(s) into vendor history")
|
|
return prev
|
|
|
|
|
|
# cross-crate file moves
|
|
# Upstream regularly relocates code between crates (e.g. #61029 split crates/util/src/rel_path.rs
|
|
# out into the new crates/path). Because BOTH sides of a vendor-history diff are already remapped
|
|
# to this fork's paths, git's rename detection reports such a move in fork terms
|
|
# (crates/gpui_zed_util/src/rel_path.rs -> crates/gpui_path/src/rel_path.rs) — which is exactly
|
|
# what's needed to settle the resulting delete/modify conflict correctly.
|
|
MOVE_SIMILARITY: str = _env("SYNC_MOVE_SIMILARITY", "40%")
|
|
|
|
|
|
def detect_moves(base: str, tip: str) -> dict[str, str]:
|
|
"""Map old fork path -> new fork path for files upstream relocated across tracked crates.
|
|
|
|
Only cross-directory moves are reported; a pure in-place rewrite isn't a move.
|
|
"""
|
|
out = gout("diff", f"-M{MOVE_SIMILARITY}", "--name-status", "--diff-filter=R", base, tip)
|
|
moves: dict[str, str] = {}
|
|
for line in out.splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) != 3:
|
|
continue
|
|
_, old, new = parts
|
|
if os.path.dirname(old) != os.path.dirname(new):
|
|
moves[old] = new
|
|
for old, new in moves.items():
|
|
log(f" upstream moved {old} → {new}")
|
|
if moves:
|
|
ok(f"detected {len(moves)} cross-crate file move(s) in this range")
|
|
return moves
|
|
|
|
|
|
# claude invocation
|
|
def render_prompt(kind: str, files: list[str], issue: str = "",
|
|
moves: dict[str, str] | None = None) -> str:
|
|
if kind == "resolve":
|
|
head = (
|
|
"You are resolving git merge conflicts from syncing upstream Zed's GPUI crates "
|
|
"into the standalone `gpui-ce` fork. A merge commit with the conflict markers "
|
|
"committed in already exists; edit the working-tree files to remove every marker. "
|
|
"Your edits become a SEPARATE resolution commit that will be reviewed in "
|
|
"isolation, so resolve faithfully."
|
|
)
|
|
listing = "\n".join(f" - {f}" for f in files)
|
|
moves_block = ""
|
|
if moves:
|
|
relocated = "\n".join(f" - {old} → {new}" for old, new in moves.items())
|
|
moves_block = (
|
|
"\n\nUpstream RELOCATED these files across crates in this range (the old path has\n"
|
|
"already been removed for you; the new one arrived via the merge). Carry any gpui-ce\n"
|
|
"adaptation that lived in the old file over to the new location, and make sure nothing\n"
|
|
"still refers to the old path:\n" + relocated
|
|
)
|
|
body = (SCRIPT_DIR / "resolve-conflicts.prompt.md").read_text()
|
|
return f"{head}\n\nConflicted / unresolved files:\n{listing}{moves_block}\n\n{body}"
|
|
head = (
|
|
f"You are fixing {issue or 'build issues'} that the upstream Zed GPUI merge introduced "
|
|
"in `gpui-ce`. The merge is already committed; fix only what the merge/sync caused."
|
|
)
|
|
cmd_used = TEST_CMD if issue == "test failures" else VERIFY_CMD
|
|
tail = "\n".join(RT.build_log.splitlines()[-300:])
|
|
body = (SCRIPT_DIR / "fix-build.prompt.md").read_text()
|
|
block = f"```\n{tail}\n```"
|
|
return f"{head}\n\nCommand: `{cmd_used}`\nRecent output (tail):\n{block}\n\n{body}"
|
|
|
|
|
|
def run_claude(prompt: str) -> None:
|
|
"""Invoke claude -p. Never fatal: max-turns/timeout exit non-zero AFTER useful work, so
|
|
the surrounding loop re-checks real progress and retries up to RT.retries."""
|
|
cmd = [
|
|
CLAUDE_BIN, "-p", prompt,
|
|
"--model", RT.model,
|
|
"--permission-mode", "acceptEdits",
|
|
"--allowedTools", CLAUDE_ALLOWED_TOOLS,
|
|
]
|
|
if CLAUDE_MAX_TURNS > 0:
|
|
cmd += ["--max-turns", str(CLAUDE_MAX_TURNS)]
|
|
try:
|
|
result = subprocess.run(cmd, cwd=REPO_ROOT, timeout=CLAUDE_TIMEOUT or None)
|
|
if result.returncode != 0:
|
|
warn(f"claude exited non-zero ({result.returncode}; likely --max-turns) — re-checking progress, may retry")
|
|
except subprocess.TimeoutExpired:
|
|
warn(f"claude hit the {CLAUDE_TIMEOUT}s timeout — re-checking progress, may retry")
|
|
|
|
|
|
# conflict detection / resolution
|
|
_MARKER_RE = r"^(<{7}|>{7}|\|{7})"
|
|
|
|
|
|
def _unmerged() -> list[str]:
|
|
out = gout("diff", "--name-only", "--diff-filter=U")
|
|
return out.split() if out else []
|
|
|
|
|
|
def _marker_files() -> list[str]:
|
|
result = git("grep", "-lE", _MARKER_RE, "--", "crates/", check=False)
|
|
out = result.stdout.strip()
|
|
return out.split() if (result.returncode == 0 and out) else []
|
|
|
|
|
|
def conflicted_files() -> list[str]:
|
|
return sorted(set(_unmerged()) | set(_marker_files()))
|
|
|
|
|
|
def has_unresolved() -> bool:
|
|
return bool(_unmerged()) or bool(_marker_files())
|
|
|
|
|
|
def auto_resolve_add_delete(moves: dict[str, str] | None = None) -> None:
|
|
"""Settle add/delete conflicts claude can't express via edits:
|
|
DU = deleted by us (gpui-ce), modified by them -> keep gpui-ce's deletion;
|
|
UD = modified by us, deleted by them -> keep gpui-ce's version (flag),
|
|
UNLESS upstream merely RELOCATED the file to another tracked crate, in which case
|
|
the deletion is accepted (the content arrived at the new path via this same merge)
|
|
and claude is told to carry the fork's adaptations across.
|
|
"""
|
|
moves = moves or {}
|
|
handled = False
|
|
for line in gout("status", "--porcelain").splitlines():
|
|
if len(line) < 4:
|
|
continue
|
|
code, path = line[:2], line[3:]
|
|
if code == "DU":
|
|
log(f" modify/delete — keeping gpui-ce's deletion of {path}")
|
|
run_git("rm", "-q", "--force", "--", path, check=False)
|
|
handled = True
|
|
elif code == "UD":
|
|
dest = moves.get(path)
|
|
if dest and (REPO_ROOT / dest).exists():
|
|
log(f" delete/modify — upstream MOVED {path} → {dest}; accepting the deletion")
|
|
run_git("rm", "-q", "--force", "--", path, check=False)
|
|
else:
|
|
warn(f" delete/modify — upstream deleted {path}; keeping gpui-ce's version (review)")
|
|
run_git("add", "--", path, check=False)
|
|
handled = True
|
|
if handled:
|
|
ok("auto-resolved add/delete conflicts")
|
|
|
|
|
|
def resolve_conflicts_loop(branch: str, moves: dict[str, str] | None = None) -> None:
|
|
attempt = 0
|
|
while has_unresolved():
|
|
attempt += 1
|
|
if attempt > RT.retries:
|
|
warn(f"still unresolved after {RT.retries} attempt(s):")
|
|
for path in conflicted_files():
|
|
print(f" {path}", file=sys.stderr)
|
|
die(f"conflict resolution failed; branch '{branch}' left for manual finishing")
|
|
files = conflicted_files()
|
|
before = len(files)
|
|
log(f"claude conflict-resolution pass {attempt}/{RT.retries} ({before} file(s) remaining)")
|
|
for path in files:
|
|
print(f" {_D}conflict:{_Z} {path}")
|
|
run_claude(render_prompt("resolve", files, moves=moves))
|
|
# Stage only tracked updates (resolves the conflicted files). NOT `-A`: conflict
|
|
# resolution edits existing files, so any new untracked file is claude's scratch
|
|
# (e.g. saved base/upstream copies for diffing) and must not be swept into the commit.
|
|
run_git("add", "-u")
|
|
after = len(conflicted_files())
|
|
if after > 0 and after >= before:
|
|
warn(f"no progress this pass ({before} → {after} files) — claude may be stuck")
|
|
|
|
|
|
# dependency bump
|
|
def bump_zed_deps(target: str) -> None:
|
|
log(f"bumping zed-industries/zed git-dep revs → {target[:12]}")
|
|
path = REPO_ROOT / "Cargo.toml"
|
|
lines = path.read_text().splitlines(keepends=True)
|
|
bumped = 0
|
|
for i, line in enumerate(lines):
|
|
if 'github.com/zed-industries/zed"' in line:
|
|
new_line, count = re.subn(r'rev = "[0-9a-fA-F]+"', f'rev = "{target}"', line)
|
|
if count:
|
|
lines[i] = new_line
|
|
bumped += 1
|
|
_ = path.write_text("".join(lines))
|
|
ok(f"rewrote {bumped} zed dep rev(s) in Cargo.toml")
|
|
|
|
|
|
# build / test verification + fixing
|
|
# A compile warning line: `warning: ...` or `warning[...]` at the start of a line.
|
|
_WARNING_RE = r"(?m)^warning(:|\[)"
|
|
|
|
|
|
def _run_gate(cmd: str) -> tuple[int, str]:
|
|
result = subprocess.run(
|
|
cmd, cwd=REPO_ROOT, shell=True, text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
)
|
|
out = result.stdout or ""
|
|
print(out, end="")
|
|
return result.returncode, out
|
|
|
|
|
|
def _build_issue() -> str:
|
|
"""Run the compile gate; '' if clean, else a short issue label (errors or warnings)."""
|
|
log(f"verifying build: {VERIFY_CMD}")
|
|
rc, out = _run_gate(VERIFY_CMD)
|
|
RT.build_log = out
|
|
if rc != 0:
|
|
return "compile errors"
|
|
if FAIL_ON_WARNINGS and re.search(_WARNING_RE, out) is not None:
|
|
return "compile warnings"
|
|
return ""
|
|
|
|
|
|
def _test_issue() -> str:
|
|
"""Run the test gate; '' if it passes, else 'test failures'."""
|
|
log(f"running tests: {TEST_CMD}")
|
|
rc, out = _run_gate(TEST_CMD)
|
|
RT.build_log = out
|
|
return "test failures" if rc != 0 else ""
|
|
|
|
|
|
def verify_fix_loop() -> bool:
|
|
"""Loop the build (compile errors + warnings) and test gates, fixing failures with claude.
|
|
|
|
The build gate runs first; only once it's clean do we run tests. Any failing gate's
|
|
output is handed to claude, bounded by RT.retries total fix passes.
|
|
"""
|
|
attempt = 0
|
|
while True:
|
|
issue = _build_issue()
|
|
if not issue and RUN_TESTS:
|
|
issue = _test_issue()
|
|
if not issue:
|
|
ok("verification passed (build + tests)" if RUN_TESTS else "verification passed (build)")
|
|
return True
|
|
attempt += 1
|
|
if attempt > RT.retries:
|
|
warn(f"{issue} remain after {RT.retries} fix attempt(s); leaving branch for manual finishing")
|
|
return False
|
|
log(f"claude fix pass {attempt}/{RT.retries} ({issue})")
|
|
run_claude(render_prompt("verify", [], issue))
|
|
|
|
|
|
# state
|
|
def state_get(key: str) -> str:
|
|
if not STATE_FILE.exists():
|
|
return ""
|
|
try:
|
|
data = cast("dict[str, object]", json.loads(STATE_FILE.read_text()))
|
|
except (json.JSONDecodeError, OSError):
|
|
return ""
|
|
value = data.get(key)
|
|
return value if isinstance(value, str) else ""
|
|
|
|
|
|
def write_state(last_synced: str, vendor_tip: str) -> None:
|
|
payload: dict[str, str] = {
|
|
"last_synced_sha": last_synced,
|
|
"vendor_tip": vendor_tip,
|
|
"last_synced_date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"upstream_url": ZED_REMOTE_URL,
|
|
"upstream_ref": RT.zed_ref,
|
|
}
|
|
_ = STATE_FILE.write_text(json.dumps(payload, indent=2) + "\n")
|
|
|
|
|
|
# upstream remote
|
|
def require_clean_tree() -> None:
|
|
if gout("status", "--porcelain"):
|
|
die("working tree is not clean; commit or stash changes first")
|
|
|
|
|
|
def ensure_remote() -> None:
|
|
if not gok("remote", "get-url", ZED_REMOTE_NAME):
|
|
log(f"adding remote '{ZED_REMOTE_NAME}' → {ZED_REMOTE_URL}")
|
|
run_git("remote", "add", ZED_REMOTE_NAME, ZED_REMOTE_URL)
|
|
|
|
|
|
def fetch_ref(ref: str) -> None:
|
|
log(f"fetching {ZED_REMOTE_NAME}/{ref} (blobless partial fetch)…")
|
|
run_git("fetch", "--filter=blob:none", ZED_REMOTE_NAME, ref, capture=False)
|
|
|
|
|
|
def fetch_object(sha: str) -> None:
|
|
if gok("cat-file", "-e", f"{sha}^{{commit}}"):
|
|
return
|
|
log(f"fetching object {sha[:12]} from {ZED_REMOTE_NAME}…")
|
|
run_git("fetch", "--filter=blob:none", ZED_REMOTE_NAME, sha, check=False, capture=False)
|
|
if not gok("cat-file", "-e", f"{sha}^{{commit}}"):
|
|
die(f"could not fetch {sha} from upstream")
|
|
|
|
|
|
def default_baseline() -> str:
|
|
"""The zed rev currently pinned in the root Cargo.toml (best automatic guess)."""
|
|
for line in (REPO_ROOT / "Cargo.toml").read_text().splitlines():
|
|
if 'github.com/zed-industries/zed"' in line:
|
|
match = re.search(r'rev = "([0-9a-fA-F]{7,40})"', line)
|
|
if match:
|
|
return match.group(1)
|
|
return ""
|
|
|
|
|
|
# subcommands
|
|
def cmd_bootstrap(baseline: str | None) -> None:
|
|
require_clean_tree()
|
|
ensure_remote()
|
|
base = baseline or default_baseline()
|
|
if not base:
|
|
die("no baseline sha given and none pinned in Cargo.toml; pass one explicitly")
|
|
log(f"bootstrapping sync baseline at {base[:12]}")
|
|
fetch_object(base)
|
|
base = gout("rev-parse", f"{base}^{{commit}}")
|
|
|
|
v0 = build_vendor_snapshot(base)
|
|
run_git("update-ref", f"refs/heads/{VENDOR_BRANCH}", v0)
|
|
ok(f"vendor branch '{VENDOR_BRANCH}' → {v0[:12]}")
|
|
|
|
if gok("merge-base", "--is-ancestor", v0, "HEAD"):
|
|
warn("baseline already recorded in this branch's history; skipping ours-merge")
|
|
else:
|
|
msg = (f"chore(sync): record zed gpui baseline {base[:12]}\n\n"
|
|
"Establishes the merge base for automated upstream syncs. No file changes.")
|
|
run_git("merge", "-s", "ours", "--allow-unrelated-histories", "--no-edit", "-m", msg, v0,
|
|
capture=False)
|
|
ok("recorded baseline in history (no file changes)")
|
|
|
|
write_state(base, v0)
|
|
run_git("add", str(STATE_FILE))
|
|
run_git("commit", "-m", f"chore(sync): initialize upstream sync state at {base[:12]}")
|
|
ok("bootstrap complete — run 'just sync-upstream' to pull upstream changes")
|
|
|
|
|
|
def cmd_sync(ref: str | None) -> None:
|
|
require_clean_tree()
|
|
ensure_remote()
|
|
if ref:
|
|
RT.zed_ref = ref
|
|
|
|
last = state_get("last_synced_sha")
|
|
vendor_tip = state_get("vendor_tip")
|
|
if not last:
|
|
die("no sync baseline recorded — run: just sync-upstream-bootstrap [SHA]")
|
|
if not vendor_tip:
|
|
die("state is missing vendor_tip — re-run bootstrap")
|
|
|
|
fetch_ref(RT.zed_ref)
|
|
target = gout("rev-parse", "FETCH_HEAD^{commit}")
|
|
log(f"last synced {last[:12]} → target {target[:12]} ({RT.zed_ref})")
|
|
if target == last:
|
|
ok(f"already up to date with {RT.zed_ref}")
|
|
return
|
|
|
|
log("upstream commits touching tracked crates:")
|
|
listing = git("log", "--oneline", f"{last}..{target}", "--", *tracked_pathspec(), check=False)
|
|
for line in listing.stdout.splitlines():
|
|
print(f" {line}")
|
|
|
|
if RT.dry_run:
|
|
ok("dry run — no changes made")
|
|
return
|
|
|
|
if not gok("cat-file", "-e", f"{vendor_tip}^{{commit}}"):
|
|
die(f"vendor_tip {vendor_tip[:12]} missing locally — re-run bootstrap")
|
|
if not gok("merge-base", "--is-ancestor", vendor_tip, "HEAD"):
|
|
die("vendor_tip not in current branch history — run sync from the branch holding the last sync (usually main)")
|
|
|
|
vnew = build_vendor_history(vendor_tip, last, target)
|
|
run_git("update-ref", f"refs/heads/{VENDOR_BRANCH}", vnew)
|
|
if vnew == vendor_tip:
|
|
warn("no gpui-touching upstream commits in range — only deps will be updated")
|
|
|
|
# Both sides are already remapped to fork paths, so this reports upstream's cross-crate
|
|
# relocations in gpui-ce terms — used to settle delete/modify conflicts as moves.
|
|
moves = detect_moves(vendor_tip, vnew)
|
|
|
|
branch = f"sync/zed-{datetime.now(timezone.utc):%Y%m%d}-{target[:7]}"
|
|
run_git("switch", "-C", branch, capture=False)
|
|
ok(f"working on branch '{branch}'")
|
|
|
|
upstream_note = (
|
|
"The individual upstream commits are preserved in this merge's second-parent\n"
|
|
"history (filtered to the tracked crates)."
|
|
)
|
|
merge_clean_msg = (
|
|
f"merge: sync zed gpui {last[:12]}..{target[:12]}\n\n"
|
|
f"Synced tracked GPUI crates from zed-industries/zed ({RT.zed_ref}); no conflicts.\n"
|
|
f"{upstream_note}\nUpstream range: {last}..{target}"
|
|
)
|
|
merge_raw_msg = (
|
|
f"merge: sync zed gpui {last[:12]}..{target[:12]} (raw, conflict markers)\n\n"
|
|
"git's automatic 3-way merge of the filtered upstream history. Files git could\n"
|
|
"NOT auto-merge are committed here WITH their conflict markers intact; the very\n"
|
|
"next commit contains the resolution, so it can be reviewed as an isolated diff\n"
|
|
"against this raw state. Deterministic add/delete conflicts were settled by\n"
|
|
f"policy (gpui-ce's deletions kept).\n{upstream_note}\nUpstream range: {last}..{target}"
|
|
)
|
|
resolution_msg = (
|
|
f"resolve conflicts from zed gpui sync {last[:12]}..{target[:12]}\n\n"
|
|
"Resolution of the conflict markers left by the preceding merge commit, performed\n"
|
|
"by claude -p. Review THIS diff in isolation to audit the resolution — it shows\n"
|
|
"exactly which side/lines were chosen, distinct from what git auto-merged."
|
|
)
|
|
|
|
log("merging upstream delta…")
|
|
# 3-way merge but DON'T auto-commit, so we can split raw conflicts from the resolution.
|
|
merged = git("merge", "--no-ff", "--no-commit", vnew, check=False, capture=False)
|
|
if merged.returncode == 0:
|
|
run_git("commit", "--no-edit", "-m", merge_clean_msg)
|
|
ok("merged cleanly (no conflicts)")
|
|
else:
|
|
if not gok("rev-parse", "-q", "--verify", "MERGE_HEAD"):
|
|
die("git merge failed without conflicts to resolve; aborting")
|
|
# Settle deterministic add/delete conflicts (kept out of the LLM step).
|
|
auto_resolve_add_delete(moves)
|
|
nconf = len(conflicted_files())
|
|
# Commit 1: the RAW merge — auto-merges applied, conflict markers committed in.
|
|
run_git("add", "-A")
|
|
run_git("commit", "--no-edit", "-m", merge_raw_msg)
|
|
ok(f"committed raw merge with conflict markers ({nconf} file(s) to resolve)")
|
|
if nconf > 0:
|
|
# Commit 2: claude's resolution of the markers — reviewable as an isolated diff.
|
|
warn("resolving conflict markers with claude…")
|
|
resolve_conflicts_loop(branch, moves)
|
|
run_git("commit", "-m", resolution_msg)
|
|
ok("committed conflict resolution (separate, reviewable commit)")
|
|
else:
|
|
ok("no content conflicts (only add/delete, settled in the merge commit)")
|
|
|
|
if RT.bump_zed_deps:
|
|
bump_zed_deps(target)
|
|
|
|
verify_ok = verify_fix_loop()
|
|
|
|
if gout("status", "--porcelain"):
|
|
run_git("add", "-A")
|
|
run_git("commit", "-m",
|
|
"chore(sync): post-merge fixes (zed deps bump + build/test/warning fixes)",
|
|
check=False)
|
|
ok("committed post-merge fixes")
|
|
|
|
write_state(target, vnew)
|
|
run_git("add", str(STATE_FILE))
|
|
run_git("commit", "-m", f"chore(sync): advance sync state to {target[:12]}")
|
|
summary(branch, last, target, verify_ok)
|
|
|
|
|
|
def cmd_status() -> None:
|
|
ensure_remote()
|
|
last = state_get("last_synced_sha")
|
|
print(f"last synced : {last or '<none — run bootstrap>'}")
|
|
print(f"vendor tip : {state_get('vendor_tip')}")
|
|
print(f"synced date : {state_get('last_synced_date')}")
|
|
run_git("fetch", "--filter=blob:none", ZED_REMOTE_NAME, RT.zed_ref, check=False)
|
|
target = gout("rev-parse", "FETCH_HEAD^{commit}") if gok("rev-parse", "FETCH_HEAD^{commit}") else ""
|
|
if target:
|
|
print(f"upstream {RT.zed_ref} : {target[:12]}")
|
|
if last and target and last != target:
|
|
count = gout("rev-list", "--count", f"{last}..{target}") if gok(
|
|
"rev-list", "--count", f"{last}..{target}") else "?"
|
|
print(f"\nbehind by {count} upstream commit(s) on {RT.zed_ref}")
|
|
elif last and target and last == target:
|
|
ok(f"up to date with {RT.zed_ref}")
|
|
|
|
|
|
def summary(branch: str, last: str, target: str, verify_ok: bool) -> None:
|
|
gates = f"{VERIFY_CMD} + {TEST_CMD}" if RUN_TESTS else VERIFY_CMD
|
|
print()
|
|
if verify_ok:
|
|
ok("sync complete — build + tests pass" if RUN_TESTS else "sync complete — build passes")
|
|
else:
|
|
warn("sync complete — VERIFICATION FAILED, finish manually")
|
|
print(f" branch : {branch}")
|
|
print(f" upstream range : {last[:12]}..{target[:12]}")
|
|
print(f" verify ({gates}) : {'OK' if verify_ok else 'FAILED'}")
|
|
print(f"\nReview : git log --oneline --stat main..{branch}")
|
|
print(f"Merge : git switch main && git merge --ff-only {branch} (or open a PR)")
|
|
print(f"{_D}Nothing was pushed.{_Z}")
|
|
|
|
|
|
# entrypoint
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="sync_upstream.py",
|
|
description="Sync upstream Zed GPUI changes into gpui-ce (local-only, never pushes).",
|
|
)
|
|
_ = parser.add_argument("command", nargs="?", default="sync",
|
|
choices=["sync", "bootstrap", "status"])
|
|
_ = parser.add_argument("arg", nargs="?", default=None,
|
|
help="bootstrap: baseline sha; sync: upstream ref")
|
|
_ = parser.add_argument("--ref", default=None, help="upstream ref to sync to")
|
|
_ = parser.add_argument("--model", default=None, help="claude model")
|
|
_ = parser.add_argument("--retries", type=int, default=None, help="max claude passes per phase")
|
|
_ = parser.add_argument("--no-bump", action="store_true", help="don't bump pinned zed git-dep revs")
|
|
_ = parser.add_argument("--dry-run", action="store_true", help="show what would be synced, then stop")
|
|
ns = parser.parse_args()
|
|
|
|
command = cast(str, ns.command)
|
|
positional = cast("str | None", ns.arg)
|
|
model = cast("str | None", ns.model)
|
|
retries = cast("int | None", ns.retries)
|
|
ref = cast("str | None", ns.ref)
|
|
|
|
if model is not None:
|
|
RT.model = model
|
|
if retries is not None:
|
|
RT.retries = retries
|
|
if cast(bool, ns.no_bump):
|
|
RT.bump_zed_deps = False
|
|
RT.dry_run = cast(bool, ns.dry_run)
|
|
|
|
try:
|
|
if command == "bootstrap":
|
|
cmd_bootstrap(positional)
|
|
elif command == "status":
|
|
cmd_status()
|
|
else:
|
|
cmd_sync(ref or positional)
|
|
except SyncError as exc:
|
|
print(f"{_R}✗ {exc}{_Z}", file=sys.stderr)
|
|
return 1
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|