blob: 318a46b22d5152c94c274cbbd82b5752734f88ef [file]
#!/usr/bin/env python3
# ***************************************************************************************************************************
# * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file
# * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file
# * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
# * with the License. You may obtain a copy of the License at
# *
# * http://www.apache.org/licenses/LICENSE-2.0
# *
# * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
# * specific language governing permissions and limitations under the License.
# ***************************************************************************************************************************
"""
Post-harvest side-clone reset, automating the "After harvest (reset the side clone)" procedure
in ~/Project Work/skills/todo-and-waves/SKILL.md.
That skill is the authority for this procedure. Where this script and the skill disagree, the
skill wins and this script is the bug. The seven numbered steps below are the skill's, in the
skill's order:
1. Confirm the clone is not `in-flight` in repos.md and not a canonical tree.
2. git fetch the clone's own origin.
3. git checkout master (Juneau / IRS) or main (Console).
4. git reset --hard to the canonical remote-tracking branch.
5. Delete leftover local cu/* branches if skill-safe.
6. git clean -fd for untracked non-ignored files. Never git clean -x (preserve .work/).
7. Update repos.md: branch = master/main, WAVE / session empty, status idle.
WHY THIS EXISTS. It is not a keystroke saver. It runs at a wave boundary -- the exact moment a
hand-run drops a guard -- and it performs `reset --hard` plus `clean` across up to nine working
trees. Its whole value is that the guards below cannot be skipped, so every one of them is a
REFUSAL (nothing destructive is attempted on that clone) rather than a warning.
GUARDS
* Canonical trees are refused by RESOLVED REAL PATH, so a symlink, a relative invocation, or a
board row pointing at one cannot slip past. Belt and braces: the hardcoded CANONICAL_TREES
list, plus any repos.md row whose role says "canonical", plus a refusal inside the git
wrapper itself so no code path can run git in a canonical tree at all.
* Only clones the board knows about can be selected. An arbitrary path is refused.
* `in-flight` (and `do-not-use`) rows are refused.
* An unreadable or unparseable repos.md aborts everything before any clone is touched. Treating
a board we could not read as "nothing is in flight" is the fail-open version of that check,
so a missing file, a decode error, a missing table, an unexpected header, or a malformed row
all exit 2 having done nothing.
* A clone with uncommitted, staged, or untracked-non-ignored work is refused and reported, not
reset over.
* A remote whose URL is a local path resolving into ANOTHER project's canonical tree is refused.
That is the "never cross IRS with Apache remotes" rule made mechanical. Fetches never pass a
URL or a remote outside the clone's own configured remotes, so a crossing cannot originate
here; this guard catches one that was configured elsewhere.
* `.work/` is copied out before the clone is touched and restored after, and the copy is only
deleted once the post-reset `.work/` verifies byte-identical to it.
* `git clean` can only ever run as GIT_CLEAN_ARGV == ("clean", "-fd"). There is deliberately no
flag, parameter, or code path that can add -x: _git_clean() takes no flag argument, and _git()
refuses a `clean` argv that is not exactly GIT_CLEAN_ARGV as well as any -x/-X style token in
any argv. `git clean -x` is the one unrecoverable mistake in this procedure (it deletes the
gitignored .work/ brainstorm tree), so it is unreachable by construction rather than by
remembering not to pass it.
* `git config` is refused by the wrapper in every form, including a read-only --get.
DEFAULT IS A DRY RUN. Acting requires --apply. The safe mode is the one you get by typing the
command wrong, forgetting a flag, or hitting Enter early. A dry run performs no fetch either --
it stays entirely read-only -- so the target commit it prints is the CURRENT remote-tracking sha
and is labelled as such; the real target is resolved after the fetch in step 2.
VERIFICATION compares actual file CONTENT against the canonical blob rather than trusting the
commit hash: after the reset, `git hash-object` over the real working-tree file is compared with
the blob id that the fetched canonical commit records for that path (`git rev-parse <ref>:<path>`).
A matching HEAD sha only proves the ref moved; it says nothing about what is on disk, which is
precisely the confusion this check exists to remove. SENTINEL_PATHS are checked when the target
ref carries them, and a deterministic sample of the target tree is used when it does not, so
"nothing to verify" can never be reported as a pass.
POOLS come from the board, not from this file -- the only per-pool knowledge here is the default
branch, mirroring the skill's pool table (Juneau master, IRS master, Console main). --all covers
the juneau and console pools; the IRS pool must be named explicitly with --pool irs, because the
skill isolates IRS as a separate hat and one command that resets Apache and IRS trees together
mixes them. The exclusion is printed, never silent.
Usage:
./scripts/reset-side-clones.py --all # dry run (default)
./scripts/reset-side-clones.py --all --apply
./scripts/reset-side-clones.py --pool juneau --apply
./scripts/reset-side-clones.py --pool irs --apply
./scripts/reset-side-clones.py --clones juneau-2,juneau-7 --apply
Options:
--all Every side clone in the juneau and console pools (IRS excluded; see above).
--pool <name> juneau | console | irs. Repeatable.
--clones <list> Comma-separated board basenames or paths. Repeatable.
--apply Actually perform the reset. Without this, nothing is written or run.
--board <path> repos.md to read (default: ~/Project Work/repos.md).
--no-board-update Do the reset but only REPORT the repos.md row change (step 7 by hand).
--help, -h Show this help message.
Exit status:
0 Everything requested completed (or a dry run produced a plan with no refusals).
1 At least one clone was refused by a guard. Nothing destructive was attempted on it.
2 repos.md could not be read or parsed. NOTHING was done to any clone.
3 A reset or a post-reset verification failed. Read the report; that clone needs attention.
4 Usage error (no selection given, unknown pool, unknown or ambiguous clone name).
5 Resets completed but one or more repos.md rows could not be written safely. The needed
change is printed; apply it by hand. Reported rather than risking a mangled board.
When several apply, the most severe is returned in the order 3, 5, 1.
"""
from __future__ import annotations
import argparse
import filecmp
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
# ---------------------------------------------------------------------------------------
# Canonical trees. Refused by resolved real path -- never by string comparison against the
# spelling the caller happened to use. These are the three the skill names as canonical
# ("Never reset canonical juneau / irs-1 / sandbox-support-console"); board rows whose role
# says "canonical" are refused too, so a canonical tree added to the board later is covered
# without a code change here.
# ---------------------------------------------------------------------------------------
CANONICAL_TREES = (
Path.home() / "git" / "apache" / "juneau",
Path.home() / "git" / "central-routing" / "irs-1",
Path.home() / "git" / "sandbox" / "sandbox-support-console",
)
BOARD_DEFAULT = Path.home() / "Project Work" / "repos.md"
# The board's own vocabulary. A row is a candidate only if its project and role match EXACTLY --
# an allowlist, not a substring test, which is what keeps "IRS-only (workspace)" (irs-1) and
# "IRS-only (FIT)" (irs-falcon-6) out while letting plain "IRS-only" side clones in.
PROJECT_TO_POOL = {"Juneau": "juneau", "Console": "console", "IRS": "irs"}
SELECTABLE_ROLES = frozenset({"worker clone", "IRS-only"})
# The skill's pool table. The only per-pool knowledge in this file.
POOL_DEFAULT_BRANCH = {"juneau": "master", "console": "main", "irs": "master"}
# --all deliberately omits irs; see the module docstring.
POOLS_IN_ALL = ("juneau", "console")
BOARD_COLUMNS = ("path", "project", "role", "branch", "wave / session", "status")
# ---------------------------------------------------------------------------------------
# git clean, in the only shape it is ever allowed to take. -x deletes gitignored files, which
# in a side clone means the .work/ brainstorm tree -- unrecoverable, and the reason this is a
# constant rather than a parameter. _git_clean() below has no flag argument at all, and _git()
# refuses any clean argv that is not exactly this tuple.
# ---------------------------------------------------------------------------------------
GIT_CLEAN_ARGV = ("clean", "-fd")
# Never run, in any form, including a read-only `git config --get user.email`.
FORBIDDEN_GIT_SUBCOMMANDS = frozenset({"config"})
# Content-verified after the reset when the target ref carries them (see verify_content()).
SENTINEL_PATHS = ("scripts/todo-next-id.py", "scripts/todo-status-audit.py")
SENTINEL_SAMPLE_SIZE = 5
WAVE_TOKEN_RE = re.compile(r"\bWAVE-\d{4}\b")
CU_BRANCH_PREFIX = "cu/"
class GitGuardViolation(RuntimeError):
"""A git invocation was refused before it ran. Always a bug in this script, never input."""
class BoardError(RuntimeError):
"""repos.md could not be read or parsed. Fail closed: nothing is touched."""
class ResetFailed(RuntimeError):
"""A step of the reset, or its verification, failed for one clone."""
def _real(path: Path | str) -> Path:
"""Resolve to a real path (expanding ~, following symlinks, collapsing '..')."""
return Path(os.path.realpath(os.path.expanduser(str(path))))
CANONICAL_REAL = frozenset(_real(p) for p in CANONICAL_TREES)
# ---------------------------------------------------------------------------------------
# The single git choke point.
# ---------------------------------------------------------------------------------------
def _git(clone: Path, argv: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
"""
Run `git -C <clone> <argv>` after the guards that must hold for EVERY git invocation.
Every git call in this script goes through here, which is what makes the guards properties
of the script rather than habits of its callers:
* a canonical tree is never the working directory of any git command,
* `git config` never runs, in any form,
* `git clean` runs only as GIT_CLEAN_ARGV,
* no argv carries an -x/-X style token (redundant with the clean rule on purpose).
A violation raises rather than returning an error, because every one of them is a
programming mistake in this file, not a condition to recover from.
"""
if not argv:
raise GitGuardViolation("empty git argv")
subcommand = argv[0]
if subcommand in FORBIDDEN_GIT_SUBCOMMANDS:
raise GitGuardViolation(f"refusing to run `git {subcommand}`: forbidden by the wave skill")
if subcommand == "clean" and tuple(argv) != GIT_CLEAN_ARGV:
raise GitGuardViolation(
f"refusing `git {' '.join(argv)}`: clean may only run as `git {' '.join(GIT_CLEAN_ARGV)}`"
)
for token in argv:
if _is_untracked_ignored_flag(token):
raise GitGuardViolation(
f"refusing `git {' '.join(argv)}`: that flag reaches gitignored files (.work/)"
)
real = _real(clone)
if real in CANONICAL_REAL:
raise GitGuardViolation(f"refusing to run git in a canonical tree: {real}")
return subprocess.run(
["git", "-C", str(clone), *argv],
check=check,
capture_output=True,
text=True,
)
def _is_untracked_ignored_flag(token: str) -> bool:
"""
True for the flags that would widen a git command onto gitignored files.
Spelled as a character test rather than a literal list so that a bundled short form
(-fdx, -fdX) is caught as well as the standalone flag.
"""
ignored_letters = {"x", "X"}
if token in {f"-{letter}" for letter in ignored_letters}:
return True
if token.startswith("-") and not token.startswith("--"):
return bool(set(token[1:]) & ignored_letters)
return False
def _git_clean(clone: Path) -> subprocess.CompletedProcess:
"""
Step 6. Takes no flag argument BY DESIGN -- there is no parameter through which a caller
could pass -x, which is why this is a function rather than an inline _git() call.
"""
return _git(clone, list(GIT_CLEAN_ARGV))
def _git_out(clone: Path, argv: list[str]) -> str:
return _git(clone, argv).stdout.strip()
# ---------------------------------------------------------------------------------------
# The board.
# ---------------------------------------------------------------------------------------
@dataclass(frozen=True)
class BoardRow:
index: int # 0-based index into the board's lines, so a row can be rewritten in place
raw: str
path_cell: str
path: Path
real: Path
project: str
role: str
branch: str
session: str
status: str
@property
def name(self) -> str:
return self.path.name
@property
def pool(self) -> str | None:
return PROJECT_TO_POOL.get(self.project)
@property
def is_canonical_row(self) -> bool:
return "canonical" in self.role.lower()
def _cells(line: str) -> list[str] | None:
"""Split a markdown table row into its cells, or None if the line is not a table row."""
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return None
return [cell.strip() for cell in stripped[1:-1].split("|")]
def parse_board(text: str) -> list[BoardRow]:
"""
Parse the clone-assignment table out of repos.md.
Fails closed on anything unexpected. An unreadable or surprising board must never be
interpreted as an empty one: "no rows" would read as "no clone is in flight", which is the
fail-open version of the in-flight guard.
"""
lines = text.splitlines()
header_index = None
for i, line in enumerate(lines):
cells = _cells(line)
if cells and tuple(cell.lower() for cell in cells) == BOARD_COLUMNS:
header_index = i
break
if header_index is None:
raise BoardError(
"no clone-assignment table found: expected a header row "
f"| {' | '.join(BOARD_COLUMNS)} |"
)
separator = _cells(lines[header_index + 1]) if header_index + 1 < len(lines) else None
if not separator or not all(set(cell) <= set("-: ") and cell for cell in separator):
raise BoardError("clone-assignment table header is not followed by a separator row")
rows: list[BoardRow] = []
for i in range(header_index + 2, len(lines)):
cells = _cells(lines[i])
if cells is None:
break # the table ends at the first non-table line
if len(cells) != len(BOARD_COLUMNS):
raise BoardError(
f"repos.md line {i + 1} has {len(cells)} cells, expected {len(BOARD_COLUMNS)}: {lines[i]!r}"
)
path_cell = cells[0]
bare = path_cell.strip().strip("`").strip()
if not bare:
raise BoardError(f"repos.md line {i + 1} has an empty path cell")
path = Path(os.path.expanduser(bare))
rows.append(
BoardRow(
index=i,
raw=lines[i],
path_cell=path_cell,
path=path,
real=_real(path),
project=cells[1],
role=cells[2],
branch=cells[3].strip("`"),
session=cells[4],
status=cells[5],
)
)
if not rows:
raise BoardError("clone-assignment table has no rows")
return rows
def load_board(board_path: Path) -> tuple[str, list[BoardRow]]:
try:
text = board_path.read_text(encoding="utf-8")
except OSError as exc:
raise BoardError(f"cannot read {board_path}: {exc}") from exc
except UnicodeDecodeError as exc:
raise BoardError(f"cannot decode {board_path} as UTF-8: {exc}") from exc
return text, parse_board(text)
# ---------------------------------------------------------------------------------------
# Assessment: every guard, evaluated before anything is touched.
# ---------------------------------------------------------------------------------------
@dataclass
class Assessment:
row: BoardRow
pool: str | None = None
branch: str | None = None
refusals: list[tuple[str, str]] = field(default_factory=list) # (code, detail)
remotes: dict[str, str] = field(default_factory=dict)
canonical_remotes: list[str] = field(default_factory=list)
dirty: list[str] = field(default_factory=list)
cu_branches: list[str] = field(default_factory=list)
current_branch: str | None = None
current_target_sha: str | None = None # remote-tracking sha BEFORE any fetch
@property
def ok(self) -> bool:
return not self.refusals
def refuse(self, code: str, detail: str) -> None:
self.refusals.append((code, detail))
def assess_static(row: BoardRow, canonical_real: frozenset[Path]) -> Assessment:
"""Guards that need no git and no filesystem writes. Ordered cheapest-and-hardest first."""
a = Assessment(row=row)
if row.real in CANONICAL_REAL:
a.refuse("canonical_tree", f"{row.real} is a canonical tree (hardcoded); never reset")
if row.real in canonical_real:
a.refuse("canonical_row", f"repos.md marks {row.path_cell} role={row.role!r}")
pool = row.pool
if pool is None or row.role not in SELECTABLE_ROLES:
a.refuse(
"not_a_side_clone",
f"project={row.project!r} role={row.role!r} is not a selectable side clone",
)
else:
a.pool = pool
a.branch = POOL_DEFAULT_BRANCH[pool]
status = row.status.strip().lower()
if status == "in-flight":
a.refuse("in_flight", "repos.md marks this clone in-flight; a subagent is using it")
if status == "do-not-use":
a.refuse("do_not_use", "repos.md marks this clone do-not-use")
wave = WAVE_TOKEN_RE.search(row.session)
if wave:
a.refuse(
"board_inconsistent",
f"session cell still carries a wave assignment ({wave.group(0)}) while status is "
f"{row.status!r}; clear the assignment on the board first",
)
if not row.path.is_dir():
a.refuse("missing_directory", f"{row.path} does not exist")
return a
def assess_git(a: Assessment) -> Assessment:
"""Read-only git guards. Not reached if a static guard already refused."""
row = a.row
try:
toplevel = _git_out(row.path, ["rev-parse", "--show-toplevel"])
except (subprocess.CalledProcessError, GitGuardViolation) as exc:
a.refuse("not_a_git_worktree", f"{row.path}: {exc}")
return a
if _real(toplevel) != row.real:
a.refuse(
"not_worktree_root",
f"{row.path} is inside {toplevel}, not its own worktree root",
)
return a
a.current_branch = _git_out(row.path, ["rev-parse", "--abbrev-ref", "HEAD"])
for line in _git_out(row.path, ["remote"]).splitlines():
remote = line.strip()
if not remote:
continue
a.remotes[remote] = _git_out(row.path, ["remote", "get-url", remote])
_check_remote_crossing(a)
if not a.ok:
return a
a.canonical_remotes = _canonical_remote_candidates(a)
if not a.canonical_remotes:
a.refuse("no_remote", "clone has no remotes to fetch from")
return a
a.dirty = [line for line in _git_out(row.path, ["status", "--porcelain"]).splitlines() if line]
if a.dirty:
staged = sum(1 for line in a.dirty if line[:1] not in {" ", "?"})
a.refuse(
"dirty",
f"{len(a.dirty)} uncommitted path(s), {staged} staged -- reporting instead of "
"resetting over them",
)
return a
a.cu_branches = [
b.strip()
for b in _git_out(row.path, ["for-each-ref", "--format=%(refname:short)", f"refs/heads/{CU_BRANCH_PREFIX}*"]).splitlines()
if b.strip()
]
a.current_target_sha = _existing_remote_tracking_sha(a)
return a
def _check_remote_crossing(a: Assessment) -> None:
"""
Refuse a clone whose remote is a local path inside another project's canonical tree.
This is "IRS clones must never be crossed with Apache remotes" made mechanical, in both
directions. A crossing cannot originate in this script -- fetches only ever name a remote
the clone already has, and `git config` never runs, so no remote is ever created or
repointed here -- but one configured by hand elsewhere would be silently honoured by a
fetch, so it is refused instead.
"""
own_canonical = _canonical_for_pool(a.pool)
for remote, url in a.remotes.items():
local = _local_remote_path(url)
if local is None:
continue
for pool, canonical in _pool_canonicals().items():
if local == canonical or canonical in local.parents:
if own_canonical is None or canonical != own_canonical:
a.refuse(
"remote_crossing",
f"remote {remote!r} -> {url} resolves into the {pool} canonical tree "
f"while this clone is in the {a.pool} pool",
)
break
def _pool_canonicals() -> dict[str, Path]:
"""Pool -> canonical tree real path, derived from CANONICAL_TREES by pool default branch."""
by_name = {p.name: p for p in CANONICAL_TREES}
return {
"juneau": _real(by_name["juneau"]),
"irs": _real(by_name["irs-1"]),
"console": _real(by_name["sandbox-support-console"]),
}
def _canonical_for_pool(pool: str | None) -> Path | None:
return _pool_canonicals().get(pool) if pool else None
def _local_remote_path(url: str) -> Path | None:
"""Real path of a remote URL that names a local directory, else None (an ssh/https URL)."""
if "://" in url or re.match(r"^[^/]+@[^/]+:", url):
return None
candidate = Path(os.path.expanduser(url))
if not candidate.is_absolute():
return None
real = _real(candidate)
if real.name == ".git":
real = real.parent
return real
def _canonical_remote_candidates(a: Assessment) -> list[str]:
"""
The remotes that could carry the canonical branch, in preference order.
`origin` always counts (step 2 names it). Console clones also carry a second remote pointing
at the local canonical console tree, which the skill mentions as "the local-canonical remote
if present" -- included here so a disagreement between the two is detected rather than
silently resolved in favour of whichever this script happened to prefer.
"""
own_canonical = _canonical_for_pool(a.pool)
candidates = ["origin"] if "origin" in a.remotes else []
for remote, url in sorted(a.remotes.items()):
if remote == "origin":
continue
local = _local_remote_path(url)
if local is not None and own_canonical is not None and local == own_canonical:
candidates.append(remote)
return candidates
def _existing_remote_tracking_sha(a: Assessment) -> str | None:
"""Current (pre-fetch) sha of <origin>/<branch>, for the dry-run report only."""
for remote in a.canonical_remotes:
try:
return _git_out(a.row.path, ["rev-parse", "--verify", f"{remote}/{a.branch}"])
except subprocess.CalledProcessError:
continue
return None
# ---------------------------------------------------------------------------------------
# .work/ preservation.
# ---------------------------------------------------------------------------------------
@dataclass
class WorkBackup:
src: Path
dest: Path
root: Path
files: int
def backup_work(clone: Path) -> WorkBackup | None:
"""
Copy the clone's .work/ out of the tree before anything touches it (the skill's git-safety
rule: back up .work/ before checkout/reset, restore after).
The copy lands in the system temp directory -- outside the clone -- so it cannot itself be
reached by anything running inside the clone.
"""
src = clone / ".work"
if not src.is_dir():
return None
root = Path(tempfile.mkdtemp(prefix="reset-side-clones-work-"))
dest = root / ".work"
shutil.copytree(src, dest, symlinks=True)
return WorkBackup(src=src, dest=dest, root=root, files=_count_files(dest))
def _count_files(root: Path) -> int:
return sum(1 for p in root.rglob("*") if p.is_file())
def _relative_files(root: Path) -> list[Path]:
return sorted(p.relative_to(root) for p in root.rglob("*") if p.is_file())
def restore_work(backup: WorkBackup | None) -> tuple[list[Path], bool]:
"""
Put back anything under .work/ that did not survive, then verify.
Returns (restored paths, verified). `git clean -fd` leaves gitignored files alone, so in
practice nothing needs restoring and this is the check that proves it rather than assuming
it. The backup directory is deleted only when the post-reset .work/ is byte-identical to it;
otherwise it is kept and its location reported.
"""
if backup is None:
return [], True
restored: list[Path] = []
for rel in _relative_files(backup.dest):
target = backup.src / rel
if not target.exists():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup.dest / rel, target)
restored.append(rel)
verified = _trees_identical(backup.dest, backup.src)
if verified:
shutil.rmtree(backup.root, ignore_errors=True)
return restored, verified
def _trees_identical(a: Path, b: Path) -> bool:
files_a, files_b = _relative_files(a), _relative_files(b)
if files_a != files_b:
return False
return all(filecmp.cmp(a / rel, b / rel, shallow=False) for rel in files_a)
# ---------------------------------------------------------------------------------------
# The reset itself (steps 2-6) and its verification.
# ---------------------------------------------------------------------------------------
@dataclass
class ResetResult:
target_ref: str
target_sha: str
fetched: list[str]
deleted_cu: list[str]
kept_cu: list[str]
work_restored: list[Path]
verified_paths: list[str]
verify_note: str
def reset_clone(a: Assessment) -> ResetResult:
"""Steps 2-6 in the skill's order, then verification. Raises ResetFailed on any problem."""
clone, branch = a.row.path, a.branch
assert branch is not None # guaranteed by assess_static
backup = backup_work(clone)
try:
# Step 2 -- fetch. Only ever a remote name the clone already has; never a URL, so this
# cannot introduce a remote or cross two projects' remotes.
fetched = []
for remote in a.canonical_remotes:
try:
_git(clone, ["fetch", remote])
except subprocess.CalledProcessError as exc:
raise ResetFailed(f"git fetch {remote} failed: {_stderr(exc)}") from exc
fetched.append(remote)
target_ref, target_sha = _resolve_target(a)
# Step 3 -- checkout the pool's default branch.
if _branch_exists(clone, branch):
try:
_git(clone, ["checkout", branch])
except subprocess.CalledProcessError as exc:
raise ResetFailed(f"git checkout {branch} failed: {_stderr(exc)}") from exc
else:
try:
_git(clone, ["checkout", "-b", branch, target_ref])
except subprocess.CalledProcessError as exc:
raise ResetFailed(f"git checkout -b {branch} {target_ref} failed: {_stderr(exc)}") from exc
# Step 4 -- reset --hard to the canonical remote-tracking branch.
try:
_git(clone, ["reset", "--hard", target_ref])
except subprocess.CalledProcessError as exc:
raise ResetFailed(f"git reset --hard {target_ref} failed: {_stderr(exc)}") from exc
# Step 5 -- leftover cu/* branches, only when provably safe (see _delete_cu_branches).
deleted_cu, kept_cu = _delete_cu_branches(a, target_ref, branch)
# Step 6 -- clean, in its only permitted form.
try:
_git_clean(clone)
except subprocess.CalledProcessError as exc:
raise ResetFailed(f"git clean failed: {_stderr(exc)}") from exc
finally:
work_restored, work_verified = restore_work(backup)
if backup is not None and not work_verified:
raise ResetFailed(f".work/ did not survive byte-identical; backup kept at {backup.dest}")
head = _git_out(clone, ["rev-parse", "HEAD"])
if head != target_sha:
raise ResetFailed(f"HEAD is {head}, expected {target_sha}")
residue = [line for line in _git_out(clone, ["status", "--porcelain"]).splitlines() if line]
if residue:
raise ResetFailed(f"working tree still not clean after reset: {residue[:5]}")
verified_paths, note = verify_content(clone, target_ref)
return ResetResult(
target_ref=target_ref,
target_sha=target_sha,
fetched=fetched,
deleted_cu=deleted_cu,
kept_cu=kept_cu,
work_restored=work_restored,
verified_paths=verified_paths,
verify_note=note,
)
def _stderr(exc: subprocess.CalledProcessError) -> str:
"""Last meaningful line of a failed git command, for a one-line report."""
lines = ((exc.stderr or "") + "\n" + (exc.stdout or "")).strip().splitlines()
return lines[-1].strip() if lines else str(exc)
def _resolve_target(a: Assessment) -> tuple[str, str]:
"""
The ref to reset to, and its sha.
A Console clone can have both `origin` (the remote) and a local-canonical remote. The skill
says to reset to "origin/main or the local-canonical remote's main" without saying which
wins, so when the two disagree this refuses and reports both shas rather than picking one.
Choosing silently is exactly the kind of guess that makes a wave boundary untrustworthy.
"""
seen: dict[str, str] = {}
for remote in a.canonical_remotes:
ref = f"{remote}/{a.branch}"
try:
seen[ref] = _git_out(a.row.path, ["rev-parse", "--verify", ref])
except subprocess.CalledProcessError:
continue
if not seen:
raise ResetFailed(
f"no canonical remote-tracking branch found (looked for "
f"{', '.join(f'{r}/{a.branch}' for r in a.canonical_remotes)})"
)
if len(set(seen.values())) > 1:
detail = ", ".join(f"{ref}={sha[:10]}" for ref, sha in sorted(seen.items()))
raise ResetFailed(
f"canonical remotes disagree on {a.branch} ({detail}); which one is canonical is an "
"operator decision, so nothing was reset"
)
# They agree, so any of them names the same commit; report the one step 4 names first.
ref = next(f"{remote}/{a.branch}" for remote in a.canonical_remotes if f"{remote}/{a.branch}" in seen)
return ref, seen[ref]
def _branch_exists(clone: Path, branch: str) -> bool:
try:
_git(clone, ["rev-parse", "--verify", f"refs/heads/{branch}"])
return True
except subprocess.CalledProcessError:
return False
def _delete_cu_branches(a: Assessment, target_ref: str, branch: str) -> tuple[list[str], list[str]]:
"""
Step 5, restricted to the part a script can prove.
The skill permits deleting a leftover cu/* branch when it is "already harvested". No script
can determine that -- harvest lands as UNSTAGED files on canonical, so a harvested slice is
not reachable from any ref. What is mechanically provable is containment: a cu/* branch whose
tip is an ancestor of the target ref holds nothing unique, so deleting it cannot lose work.
Anything else is reported and left alone for a human, because `git branch -D` on a branch
with unique commits is unrecoverable in the way this script exists to prevent.
"""
deleted: list[str] = []
kept: list[str] = []
worktree_branches = _branches_in_other_worktrees(a.row.path)
for cu in a.cu_branches:
if cu == branch or cu in worktree_branches:
kept.append(f"{cu} (checked out)")
continue
try:
_git(a.row.path, ["merge-base", "--is-ancestor", cu, target_ref])
except subprocess.CalledProcessError:
kept.append(f"{cu} (has commits not in {target_ref}; cannot prove it was harvested)")
continue
try:
_git(a.row.path, ["branch", "-d", cu])
except subprocess.CalledProcessError as exc:
kept.append(f"{cu} (delete refused: {_stderr(exc)})")
continue
deleted.append(cu)
return deleted, kept
def _branches_in_other_worktrees(clone: Path) -> set[str]:
out = _git_out(clone, ["worktree", "list", "--porcelain"])
return {
line.split(" ", 1)[1].strip().removeprefix("refs/heads/")
for line in out.splitlines()
if line.startswith("branch ")
}
def verify_content(clone: Path, target_ref: str) -> tuple[list[str], str]:
"""
Compare real file CONTENT against the canonical blob, not the commit hash.
`git hash-object` re-hashes the bytes actually on disk; `git rev-parse <ref>:<path>` is the
blob id the fetched canonical commit records for that path. Equal ids mean the file on disk
IS the canonical file. A matching HEAD proves only that a ref moved, which is why the manual
version of this check was what made the nine-clone report trustworthy.
SENTINEL_PATHS are used when the target ref carries them. When it carries none, a
deterministic sample of the tree is used instead -- "there was nothing to verify" must never
be reportable as a pass.
"""
present = [p for p in SENTINEL_PATHS if _blob_id(clone, target_ref, p) is not None]
note = "declared sentinels"
if not present:
listing = _git_out(clone, ["ls-tree", "-r", "--name-only", target_ref]).splitlines()
present = sorted(listing)[:SENTINEL_SAMPLE_SIZE]
note = f"no declared sentinel in {target_ref}; deterministic sample of the tree"
if not present:
raise ResetFailed(f"{target_ref} is empty: nothing could be content-verified")
for path in present:
expected = _blob_id(clone, target_ref, path)
actual = _git_out(clone, ["hash-object", "--", path])
if expected != actual:
raise ResetFailed(
f"content mismatch for {path}: on disk {actual[:10]}, canonical {str(expected)[:10]}"
)
return present, note
def _blob_id(clone: Path, ref: str, path: str) -> str | None:
try:
return _git_out(clone, ["rev-parse", "--verify", f"{ref}:{path}"])
except subprocess.CalledProcessError:
return None
# ---------------------------------------------------------------------------------------
# Step 7 -- the board row.
# ---------------------------------------------------------------------------------------
def render_row(raw: str, branch: str, status: str) -> str:
"""
Rewrite one board row's branch / session / status cells, leaving every other byte alone.
Operates on the original line's own `|` split so the surrounding prose, the other columns,
and the file's spacing style are untouched.
"""
parts = raw.split("|")
if len(parts) != len(BOARD_COLUMNS) + 2:
raise BoardError(f"cannot rewrite malformed row: {raw!r}")
parts[4] = f" `{branch}` "
parts[5] = " "
parts[6] = f" {status} "
return "|".join(parts)
def write_board(board_path: Path, updates: dict[int, str]) -> None:
"""
Install row rewrites atomically, refusing anything that would mangle the board.
Concurrency: a sibling lock file created with O_CREAT|O_EXCL. A held lock is NOT stolen and
no stale-lock heuristic is applied -- the caller reports the needed change instead, because a
mangled shared board is worse than a row someone has to fix by hand.
Safety: the rewritten text is re-parsed and compared line by line against the original, and
the write is refused unless exactly the intended lines changed.
"""
lock = board_path.with_name(board_path.name + ".lock")
try:
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError as exc:
raise BoardError(f"{lock} is held by another writer; not touching the board") from exc
try:
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
original = board_path.read_text(encoding="utf-8")
lines = original.splitlines(keepends=True)
for index, new_line in updates.items():
if index >= len(lines):
raise BoardError("board changed under us: row index out of range")
ending = "\n" if lines[index].endswith("\n") else ""
lines[index] = new_line + ending
updated = "".join(lines)
_assert_only_intended_lines_changed(original, updated, set(updates))
parse_board(updated) # a rewrite that no longer parses is never installed
tmp = board_path.with_name(board_path.name + f".tmp.{os.getpid()}")
tmp.write_text(updated, encoding="utf-8")
os.replace(tmp, board_path)
finally:
lock.unlink(missing_ok=True)
def _assert_only_intended_lines_changed(original: str, updated: str, intended: set[int]) -> None:
before, after = original.splitlines(), updated.splitlines()
if len(before) != len(after):
raise BoardError("refusing to write: line count changed")
changed = {i for i, (b, a) in enumerate(zip(before, after)) if b != a}
if not changed <= intended:
raise BoardError(f"refusing to write: unintended lines would change: {sorted(changed - intended)}")
# ---------------------------------------------------------------------------------------
# Selection.
# ---------------------------------------------------------------------------------------
def select_rows(rows: list[BoardRow], *, all_pools: bool, pools: list[str], names: list[str]) -> list[BoardRow]:
"""Resolve the requested selection to board rows. Raises SystemExit(4) on a usage error."""
selected: dict[int, BoardRow] = {}
wanted_pools = list(POOLS_IN_ALL) if all_pools else []
for pool in pools:
if pool not in POOL_DEFAULT_BRANCH:
_usage_error(f"unknown pool {pool!r}; known pools: {', '.join(sorted(POOL_DEFAULT_BRANCH))}")
if pool not in wanted_pools:
wanted_pools.append(pool)
for row in rows:
if row.pool in wanted_pools and row.role in SELECTABLE_ROLES:
selected[row.index] = row
for name in names:
matches = [r for r in rows if r.name == name or r.real == _real(name) or r.path_cell.strip("`") == name]
if not matches:
_usage_error(
f"{name!r} is not a row in the board. Only clones the board knows about can be "
"selected; add it there first."
)
if len(matches) > 1:
_usage_error(f"{name!r} matches {len(matches)} board rows; use the full path")
selected[matches[0].index] = matches[0]
return [selected[i] for i in sorted(selected)]
def _usage_error(message: str) -> None:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(4)
# ---------------------------------------------------------------------------------------
# Reporting.
# ---------------------------------------------------------------------------------------
def print_plan(a: Assessment) -> None:
row = a.row
print(f"\n{row.name} ({row.path}) pool={a.pool}")
if not a.ok:
for code, detail in a.refusals:
print(f" REFUSED [{code}] {detail}")
if a.dirty:
for line in a.dirty[:10]:
print(f" {line}")
if len(a.dirty) > 10:
print(f" ... {len(a.dirty) - 10} more")
return
target = f"{a.canonical_remotes[0]}/{a.branch}"
sha = a.current_target_sha[:10] if a.current_target_sha else "(unknown until fetched)"
print(f" on branch: {a.current_branch} clean")
for remote in a.canonical_remotes:
print(f" would run: git -C {row.name} fetch {remote}")
print(f" would run: git -C {row.name} checkout {a.branch}")
print(f" would run: git -C {row.name} reset --hard {target} (currently {sha}, re-resolved after fetch)")
print(f" would run: git -C {row.name} {' '.join(GIT_CLEAN_ARGV)} (never -x; .work/ preserved)")
if a.cu_branches:
print(f" cu/* branches: {', '.join(a.cu_branches)} (deleted only if contained in the target ref)")
else:
print(" cu/* branches: none")
work = row.path / ".work"
print(f" .work/: {'backed up and verified after' if work.is_dir() else 'absent'}")
print(f" would verify content of: {', '.join(SENTINEL_PATHS)} (blob hash vs canonical)")
print(f" would set board row: branch=`{a.branch}` session=(empty) status=idle")
if row.session.strip():
print(f" NOTE: clearing a non-empty session cell: {row.session.strip()!r}")
def print_result(a: Assessment, result: ResetResult) -> None:
row = a.row
print(f"\n{row.name} ({row.path}) pool={a.pool}")
print(f" fetched: {', '.join(result.fetched)}")
print(f" reset to: {result.target_ref} {result.target_sha[:10]}")
print(f" cleaned: git {' '.join(GIT_CLEAN_ARGV)}")
print(f" .work/: preserved and verified"
f"{f' ({len(result.work_restored)} entries restored)' if result.work_restored else ''}")
if result.deleted_cu:
print(f" cu/* deleted (contained in target): {', '.join(result.deleted_cu)}")
for kept in result.kept_cu:
print(f" cu/* KEPT for a human: {kept}")
print(f" content verified ({result.verify_note}): {', '.join(result.verified_paths)}")
# ---------------------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="reset-side-clones.py",
description="Reset harvested WAVE side clones to a fresh default branch (dry run unless --apply).",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"Pools: {', '.join(sorted(POOL_DEFAULT_BRANCH))}. --all covers {', '.join(POOLS_IN_ALL)}.",
)
parser.add_argument("--all", action="store_true", help=f"every side clone in the {', '.join(POOLS_IN_ALL)} pools")
parser.add_argument("--pool", action="append", default=[], metavar="NAME", help="pool to reset (repeatable)")
parser.add_argument("--clones", action="append", default=[], metavar="LIST", help="comma-separated board names (repeatable)")
parser.add_argument("--apply", action="store_true", help="actually perform the reset (default: dry run)")
parser.add_argument("--board", type=Path, default=BOARD_DEFAULT, help=f"repos.md to read (default: {BOARD_DEFAULT})")
parser.add_argument("--no-board-update", action="store_true", help="report the repos.md row change instead of writing it")
args = parser.parse_args(argv)
names = [n.strip() for group in args.clones for n in group.split(",") if n.strip()]
if not (args.all or args.pool or names):
_usage_error("nothing selected; pass --all, --pool <name>, or --clones <list>")
# Fail closed on the board BEFORE anything else. An unreadable board must never be read as
# "nothing is in flight".
try:
_, rows = load_board(args.board)
except BoardError as exc:
print(f"error: {exc}", file=sys.stderr)
print("Nothing was touched. The board is the in-flight guard; it must be readable.", file=sys.stderr)
return 2
canonical_real = frozenset(r.real for r in rows if r.is_canonical_row)
selection = select_rows(rows, all_pools=args.all, pools=args.pool, names=names)
mode = "APPLY" if args.apply else "DRY RUN (no flag given; nothing will be changed)"
print(f"reset-side-clones: {mode}")
print(f"board: {args.board}")
if args.all and "irs" not in args.pool:
print("note: --all covers the juneau and console pools. IRS clones are excluded; pass "
"--pool irs to include them (the skill keeps IRS a separate hat).")
if not selection:
print("no clones selected.")
return 0
assessments = []
for row in selection:
a = assess_static(row, canonical_real)
if a.ok:
a = assess_git(a)
assessments.append(a)
refused = [a for a in assessments if not a.ok]
actionable = [a for a in assessments if a.ok]
if not args.apply:
for a in assessments:
print_plan(a)
print(f"\n{len(actionable)} clone(s) would be reset, {len(refused)} refused. "
f"Re-run with --apply to act.")
return 1 if refused else 0
for a in refused:
print_plan(a)
failures: list[tuple[Assessment, str]] = []
board_updates: dict[int, str] = {}
succeeded: list[Assessment] = []
for a in actionable:
try:
result = reset_clone(a)
except (ResetFailed, GitGuardViolation) as exc:
print(f"\n{a.row.name} ({a.row.path})")
print(f" FAILED: {exc}")
failures.append((a, str(exc)))
continue
print_result(a, result)
succeeded.append(a)
assert a.branch is not None
board_updates[a.row.index] = render_row(a.row.raw, a.branch, "idle")
board_deferred = False
if board_updates and not args.no_board_update:
try:
write_board(args.board, board_updates)
print(f"\nboard: updated {len(board_updates)} row(s) in {args.board}")
except BoardError as exc:
board_deferred = True
print(f"\nboard: NOT updated -- {exc}")
elif board_updates:
board_deferred = True
print("\nboard: --no-board-update given; not written")
if board_deferred:
print("Apply these rows by hand (step 7):")
for index in sorted(board_updates):
print(f" line {index + 1}: {board_updates[index]}")
print(f"\nsummary: {len(succeeded)} reset, {len(refused)} refused, {len(failures)} failed.")
if failures:
return 3
if board_deferred:
return 5
if refused:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())