Skip to content

Manage bisect repair patches through the CLI - #170

Open
rameziophobia wants to merge 23 commits into
mainfrom
ramez/repair-patch-management
Open

Manage bisect repair patches through the CLI#170
rameziophobia wants to merge 23 commits into
mainfrom
ramez/repair-patch-management

Conversation

@rameziophobia

Copy link
Copy Markdown
Contributor

Summary

  • define the repair manifest and registry used by managed patches
  • add CLI commands to capture, create, verify, register, and mutate repair patches
  • verify patches in disposable worktrees and guard mutations during active sessions
  • honor configured copy ignores and reject the legacy repair configuration with migration guidance

Stack

Depends on the universal-repairs PR. This is the fourth PR in the bisect repair stack.

Validation

  • workspace build and typecheck passed
  • shaka-perf test suite passed

function parseNumstat(output: Buffer): PatchFileSummary[] {
return output.toString('utf8').split('\0').filter(Boolean).map((record) => {
const match = /^(\d+|-)\t(\d+|-)\t([\s\S]+)$/.exec(record);
if (!match) throw new Error('Patch contains an unreadable file summary');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Correctness: renamed-file patches break parseNumstat/inspectPatch.

git diff --numstat -z (and git apply --numstat -z, which shares the same formatter) emits a different record shape for a detected rename: the numeric fields are followed by an empty path field, then two separate NUL-terminated tokens (old path, new path) — instead of added\tdeleted\tpath\0.

parseNumstat does output.toString('utf8').split('\0').filter(Boolean), so for a rename record the empty-path token "N\tM\t" survives filter(Boolean) (it's non-empty as a string) but fails the regex /^(\d+|-)\t(\d+|-)\t([\s\S]+)$/ because the third group requires ≥1 character. That throws Patch contains an unreadable file summary for every capture that includes a git-detected rename.

Since inspectPatch backs captureWorkingTreePatch, captureSourceCommitPatch, importPatchFile, and BisectPatchRegistry.describe() (via patch list/show), this means any patch involving a renamed file can't be captured, registered, or even displayed — a plain "unreadable file summary" error with no hint that the real cause is a rename. Worth handling the two-token rename record explicitly (or passing --no-renames/-M0 to the underlying diff so renames are never emitted as such).

Comment on lines +524 to +545
async function assertMutable(
context: PatchCliContext,
deps: BisectPatchCliDependencies,
): Promise<void> {
if (deps.assertMutable) return deps.assertMutable(context);
if (!context.projectSlug) return;
const outcome = await tryProxy({
slug: context.projectSlug,
request: { v: PROTOCOL_VERSION, cmd: 'bisect-status' },
});
if (outcome.proxied && outcome.code !== 0) {
throw new Error(outcome.error ?? 'Cannot query bisect lease status');
}
const activeSessionId = outcome.proxied
? (outcome.data as { activeSessionId?: string | null } | undefined)?.activeSessionId
: null;
if (activeSessionId) {
throw new Error(
`Cannot modify bisect patches while session "${activeSessionId}" owns the project`,
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Altitude/race: the "no mutation during an active bisect session" invariant is bolted onto the CLI layer, not enforced by BisectPatchRegistry itself.

assertMutable() is a plain check-then-act: it queries bisect-status over IPC, and only if that comes back with no activeSessionId do the five call sites (create, edit, remove, apply, updateMetadata — via runCreate/runEdit/runRemove/the apply action/runUpdate) proceed to call into BisectPatchRegistry, which writes the manifest/artifact directly via fs.renameSync with no lock held across the gap. A bisect session can begin between the status check and the actual write, so the registry mutation can race a running session reading bisect-repairs/manifest.json.

It's also purely opt-in: BisectPatchRegistry.create/edit/remove/apply/updateMetadata have zero internal awareness of session state — any future caller (a test helper, a new CLI command, a script driving the registry directly) silently loses this protection unless it remembers to call assertMutable first, unlike every other menu lifecycle action which is funneled through runProxiedAction's single serialization point in servers-menu.ts.

Consider moving the guard inside BisectPatchRegistry (or wrapping its mutating methods) so it's structurally impossible to bypass, and/or holding the check across the actual write rather than releasing between them.

Comment on lines +382 to +396
if ('all' in selector) {
selected = graph ?? [resolveCommit(repoDir, 'HEAD')];
} else if ('commits' in selector) {
selected = selector.commits.map((ref) => resolveCommit(repoDir, ref));
} else {
const from = selector.from
? resolveCommit(repoDir, selector.from)
: goodSha;
if (!from) {
selected = [resolveCommit(repoDir, selector.through)];
} else {
const through = resolveCommit(repoDir, selector.through);
selected = commitsBetween(repoDir, from, through, false);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

patch verify silently narrows to a single commit when no explicit good/bad refs are given, contradicting its own description.

The verify command is documented as "Verify a registered patch against its configured commit scope," and the README says { all: true } "applies to every SHA measured by the session... including endpoints, primary and queued candidates, merge second parents, and merge child candidates."

But when goodRef/badRef aren't supplied (so graph is null):

  • an { all: true } selector (line 382-383) verifies only HEAD — not "every SHA."
  • an interval selector with no explicit from (line 387-391) verifies only the through endpoint — not the interval.

So shaka-perf bisect patch verify <id> run bare on an { all: true } patch (a common case — e.g. demo-ecommerce/bisect-repairs/manifest.json's backport-deals-abtest patch uses { all: true }) gives false confidence: it reports success after checking exactly one commit (HEAD) while silently skipping every other commit the patch is actually meant to cover, with no warning that the check was narrowed.

Comment on lines +320 to +337
function writeManifestAtomic(manifestPath: string, manifest: BisectPatchManifest): void {
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
const temporary = temporaryPath(manifestPath);
try {
writeManifestFile(temporary, manifest);
fs.renameSync(temporary, manifestPath);
} finally {
fs.rmSync(temporary, { force: true });
}
}

function writeManifestFile(file: string, manifest: BisectPatchManifest): void {
fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
}

function temporaryPath(file: string): string {
return `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reuse: reimplements an atomic-write helper that already exists (three times) in this same directory.

writeManifestAtomic/writeManifestFile/temporaryPath reinvent the "write to a temp file, then rename" idiom already present in this package as writeFileAtomic (report.ts), writeJsonAtomic (persistence.ts), and the inline pattern in writeBadRefTestsAtomic (state.ts). A fourth independent implementation means any future fix to the atomic-write contract (e.g. fsync-before-rename, permission bits, error handling) has to be applied by hand in up to four places instead of one shared helper the two-file transaction functions (writeCreateTransaction/replaceArtifactTransaction/removeTransaction) could build on.

Comment on lines +166 to +178
function finishCapture(
repoDir: string,
bytes: Buffer,
source: BisectPatchSource,
copyIgnore?: CopyIgnoreConfig,
): CapturedPatch {
const files = inspectPatch(repoDir, bytes, copyIgnore);
return {
bytes,
files,
sha256: createHash('sha256').update(bytes).digest('hex'),
source,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Efficiency: inspectPatch runs twice on identical bytes for every capture.

Every capture path (captureWorkingTreePatch, captureSourceCommitPatch, importPatchFile) computes paths: patchPaths(repoDir, bytes, options.copyIgnore) — which itself calls inspectPatch(repoDir, bytes, copyIgnore) — as an argument to finishCapture, and finishCapture then calls inspectPatch(repoDir, bytes, copyIgnore) again (line 172) to compute .files. Both calls shell out to git apply --numstat -z --binary - and re-run the same copy-ignore/forbidden-path validation over identical input, doubling subprocess spawn cost on every patch create/patch edit. patchPaths could just derive paths from the files array finishCapture already computes (or finishCapture could take the already-inspected files instead of re-deriving them).

): PatchVerificationResult[] {
const root = gitRoot(repoDir);
const shas = verificationShas(root, selector, options);
return shas.map((sha) => verifyAtCommit(root, id, sha, bytes));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Efficiency: patch verification serializes disposable-worktree checks instead of running them concurrently.

verifyPatchBytes does a plain synchronous shas.map(sha => verifyAtCommit(...)), and each verifyAtCommit call does mkdtempSync + git worktree add + up to two git apply calls + git status + git worktree remove/prune — each a blocking execFileSync. For a wide { all: true } or long interval selector (tens of commits), this serializes tens of worktree round-trips instead of overlapping the I/O, inconsistent with the async + Promise.all style used elsewhere in this package (e.g. git.ts, repair-artifacts.ts's resolveApplicableShas). If sequential is intentional (e.g. to avoid git worktree add contention on the same repo), it'd be worth a comment; otherwise this is a straightforward candidate for bounded concurrency.

return withoutPurpose;
}

function validatePatchId(id: string): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reuse: validatePatchId duplicates the id regex already enforced by the manifest schema.

/^[A-Za-z0-9][A-Za-z0-9._-]*$/ is repeated here and in BisectPatchManifestEntrySchema.id (patch-manifest.ts). Two independent copies of the "valid patch id" rule in files added by the same PR can drift silently if one is ever tightened/loosened without the other. Also worth noting patch-capture.ts, patch-registry.ts, and patch-cli.ts each define their own local git/gitBuffer/gitRoot/resolveRef wrappers around execFileSync('git', …) rather than sharing one; ./git.ts already exports resolveCommit (used by repair-artifacts.ts in this same diff) that a shared sync equivalent could parallel.

Comment on lines +239 to +318
function writeCreateTransaction(
manifestPath: string,
directory: string,
entry: BisectPatchManifestEntry,
bytes: Buffer,
manifest: BisectPatchManifest,
): void {
fs.mkdirSync(directory, { recursive: true });
const artifactPath = path.join(directory, entry.filename);
if (fs.existsSync(artifactPath)) throw new Error(`Patch artifact already exists: ${artifactPath}`);
const artifactTemporary = temporaryPath(artifactPath);
const manifestTemporary = temporaryPath(manifestPath);
try {
fs.writeFileSync(artifactTemporary, bytes, { flag: 'wx' });
writeManifestFile(manifestTemporary, manifest);
fs.renameSync(artifactTemporary, artifactPath);
try {
fs.renameSync(manifestTemporary, manifestPath);
} catch (error) {
fs.rmSync(artifactPath, { force: true });
throw error;
}
} finally {
fs.rmSync(artifactTemporary, { force: true });
fs.rmSync(manifestTemporary, { force: true });
}
}

function replaceArtifactTransaction(
manifestPath: string,
directory: string,
entry: BisectPatchManifestEntry,
bytes: Buffer,
manifest: BisectPatchManifest,
): void {
const artifactPath = path.join(directory, entry.filename);
const artifactTemporary = temporaryPath(artifactPath);
const manifestTemporary = temporaryPath(manifestPath);
const original = fs.readFileSync(artifactPath);
try {
fs.writeFileSync(artifactTemporary, bytes, { flag: 'wx' });
writeManifestFile(manifestTemporary, manifest);
fs.renameSync(artifactTemporary, artifactPath);
try {
fs.renameSync(manifestTemporary, manifestPath);
} catch (error) {
fs.writeFileSync(artifactTemporary, original, { flag: 'wx' });
fs.renameSync(artifactTemporary, artifactPath);
throw error;
}
} finally {
fs.rmSync(artifactTemporary, { force: true });
fs.rmSync(manifestTemporary, { force: true });
}
}

function removeTransaction(
manifestPath: string,
directory: string,
entry: BisectPatchManifestEntry,
manifest: BisectPatchManifest,
keepFile: boolean,
): void {
const artifactPath = path.join(directory, entry.filename);
const heldArtifact = temporaryPath(artifactPath);
const manifestTemporary = temporaryPath(manifestPath);
try {
if (!keepFile) fs.renameSync(artifactPath, heldArtifact);
writeManifestFile(manifestTemporary, manifest);
try {
fs.renameSync(manifestTemporary, manifestPath);
} catch (error) {
if (!keepFile) fs.renameSync(heldArtifact, artifactPath);
throw error;
}
if (!keepFile) fs.rmSync(heldArtifact, { force: true });
} finally {
fs.rmSync(manifestTemporary, { force: true });
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Simplification: three near-identical ~25-line transaction functions.

writeCreateTransaction, replaceArtifactTransaction, and removeTransaction all do the same "write/rename artifact + write/rename manifest, roll back the artifact if the manifest rename fails" dance with slight variations. Any bugfix to the rollback ordering has to be hand-applied up to three times. A shared helper parameterized by the artifact-side mutation (write-new / replace / remove-or-keep) would collapse this to one implementation with three thin call sites.

Comment thread packages/shaka-perf/src/config.ts Outdated
Comment on lines +522 to +534
const rawBisect = raw && typeof raw === 'object'
? (raw as { bisect?: unknown }).bisect
: undefined;
if (
rawBisect && typeof rawBisect === 'object'
&& Object.prototype.hasOwnProperty.call(rawBisect, 'repairs')
) {
throw new Error(
at + 'bisect.repairs is not supported. Manage patches with:\n\n' +
' shaka-perf bisect patch create <id>\n\n' +
'Patch registrations are stored in bisect-repairs/manifest.json.',
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Altitude: reintroduces the per-key migration guard the codebase deliberately avoided.

The comment this block replaced explicitly documented the opposite design choice: "No per-key migration guards: every section is .strict(), so a removed or misspelled key is rejected by name on its own. Renames are documented in BREAKING_CHANGES.md rather than restated here — one list to maintain, and no risk of a future rename shipping without its guard." BisectConfigSchema is still .strict(), so Zod would already reject a stray bisect.repairs key with a generic "Unrecognized key(s)" error without this bespoke hasOwnProperty block. This is exactly the kind of special-cased guard the deleted comment says to avoid, and sets a precedent for future renames to get their own hand-written block instead of relying on .strict() + BREAKING_CHANGES.md alone.

cleanupCommands: z.array(repairCommandSchema),
registeredAt: z.string(),
source: z.literal('config'),
source: z.literal('manifest'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

source narrowed from z.literal('config') to z.literal('manifest') with no compat shim. readBisectSession/parseBisectSession (lines 352-360) call sessionSchema.parse(value) directly, so a session.json persisted by pre-PR shaka-perf (with repairs[].source: "config") will throw a raw ZodError ("Invalid literal value, expected 'manifest'") instead of a clear message, on both bisect --resume and bisect --report-only. Worth at least a friendlier error, since every other "can't resume" case in this file raises a domain error rather than letting Zod's raw message through.

if (outcome.proxied && outcome.code !== 0) {
throw new Error(outcome.error ?? 'Cannot query bisect lease status');
}
const activeSessionId = outcome.proxied

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

assertMutable's lease check silently no-ops when tryProxy can't reach a compatible server: outcome.proxied is false both when no server is running (intended — nothing to protect against) and when a running server speaks a different PROTOCOL_VERSION (client.ts tryProxy treats a version mismatch the same as "not proxied"). Since this PR bumps PROTOCOL_VERSION 3→4, during a rolling upgrade an old shaka-perf servers process with a live bisect session would make this check pass through as activeSessionId = null, letting patch create/edit/remove/apply mutate the manifest/artifact a live session is relying on. Might be worth treating a version mismatch as "can't verify, assume mutable is unsafe" rather than "assume safe," or at least surfacing a warning.

AllSelectorSchema,
]),
prepareCommands: z.array(PatchCommandSchema),
cleanupCommands: z.array(PatchCommandSchema),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

prepareCommands/cleanupCommands lost the .default([]) the old BisectRepairConfigSchema had for these fields (now bare z.array(...) inside a .strict() object, so they're required keys with no fallback). Every construction path in this repo (the registry's normalizeEntry, the CLI, demo-ecommerce/bisect-repairs/manifest.json) supplies both, so it's not exercised by tests — but a hand-edited manifest.json entry that omits either key (legal under the old per-repair config schema) now fails BisectPatchManifestSchema.safeParse with patches.N.prepareCommands: Required, and loadBisectPatchManifest throws for the whole manifest, not just that entry — every registered patch becomes unusable until it's fixed.

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review summary

Ran a multi-angle review (correctness, removed-behavior audit, reuse/simplification/efficiency, altitude/conventions) over the new bisect patch-management code. 12 inline comments posted covering:

  • Correctness bugs: parseNumstat can't parse git's rename-record numstat format (any patch touching a renamed file fails to capture), patch verify with no explicit refs silently checks only one commit instead of the configured scope, assertMutable's lease check silently no-ops on an IPC protocol-version mismatch (a real race during a rolling upgrade with an old shaka-perf servers process), and resuming a pre-PR session.json throws a raw ZodError instead of a friendly message (the persisted repair.source literal was renamed 'config''manifest' with no compat shim).
  • Schema regression: prepareCommands/cleanupCommands lost their .default([]), so a hand-edited manifest.json entry omitting either key now fails validation for the entire manifest, not just that entry.
  • Reuse/duplication: three independent local git-subprocess wrapper sets, a duplicated patch-id regex, a duplicated atomic-write helper (a fourth implementation in this same directory), and near-identical write/rename-with-rollback transaction functions.
  • Efficiency: every patch capture runs inspectPatch twice on identical bytes; patch verify walks selector commits serially through disposable worktrees instead of overlapping the I/O; gitRoot/inspectPatch are re-resolved per patch in list() instead of once per registry instance.
  • Design consistency: the new bisect.repairs rejection block in config.ts reintroduces the per-key migration guard the deleted comment explicitly says this codebase avoids (.strict() would already reject the key generically).

One item couldn't be posted inline (GitHub rejects anchors on files with no diff in this PR): bisect.repairs is a real removed/renamed config field (throws at parse time, define-config.ts deleted its input types) with no corresponding BREAKING_CHANGES.md Unreleased entry, which is required by this repo's own CLAUDE.md policy. Low-confidence caveat: bisect.repairs was added post-0.2.5-release, mid-stack, and was never tagged/published (no shaka-perf@0.2.5 tag), so it's plausible nothing external ever depended on it — worth confirming before deciding whether an entry is actually needed.

🤖 Generated with Claude Code

@rameziophobia
rameziophobia force-pushed the ramez/repair-patch-management branch from 43f8217 to 593bee2 Compare August 19, 2026 13:19
@rameziophobia
rameziophobia force-pushed the ramez/repair-patch-management branch from 593bee2 to 4445f60 Compare August 19, 2026 13:20
Comment thread packages/shaka-perf/src/config.ts Outdated
Comment on lines +525 to +534
if (
rawBisect && typeof rawBisect === 'object'
&& Object.prototype.hasOwnProperty.call(rawBisect, 'repairs')
) {
throw new Error(
at + 'bisect.repairs is not supported. Manage patches with:\n\n' +
' shaka-perf bisect patch create <id>\n\n' +
'Patch registrations are stored in bisect-repairs/manifest.json.',
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This makes bisect.repairs a hard-rejected, removed config field — a breaking change for any consumer whose abtests.config.ts still has a bisect.repairs array (confirmed by the new "rejects the removed bisect.repairs configuration" test in config.test.ts).

Per this repo's CLAUDE.md ("Breaking changes" section): "Any change that can break an existing consumer's ... abtests.config.ts — a removed/renamed abTest() option, a moved or renamed config field, a changed default — MUST be logged in BREAKING_CHANGES.md under its Unreleased section, with the exact fix for affected tests."

BREAKING_CHANGES.md isn't touched anywhere in this PR's diff. Please add an Unreleased entry documenting the removal and the migration path (shaka-perf bisect patch create <id> + moving existing patches into bisect-repairs/manifest.json), matching the format of the existing entries in that file.

Base automatically changed from ramez/universal-bisect-repairs to main August 19, 2026 13:24
@rameziophobia
rameziophobia force-pushed the ramez/repair-patch-management branch from 4445f60 to 8b27da6 Compare August 19, 2026 13:24
Comment on lines +451 to +463
let appliesTo: BisectPatchSelector;
if (selected === 'all') appliesTo = { all: true };
else if (selected === 'commits') {
const initial = 'commits' in current.appliesTo ? current.appliesTo.commits.join(', ') : '';
const commits = splitValues(await prompt.input('Exact commit SHAs (comma-separated)', initial));
if (commits.length === 0) throw new Error('At least one exact commit is required');
appliesTo = { commits: commits as [string, ...string[]] };
} else {
const interval = 'through' in current.appliesTo ? current.appliesTo : { through: '' };
const from = await prompt.input('Inclusive lower SHA (blank uses session good SHA)', interval.from ?? '');
const through = await prompt.input('Inclusive upper SHA', interval.through);
appliesTo = { ...(from ? { from } : {}), through };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

promptMetadata (used by patch update) builds the commits / from / through selector directly from raw prompt input, without resolving them through resolveRef the way selectorFromOptions does for patch create/patch edit (line ~364-368, which wraps every --at/--from/--through value in resolveRef(repoDir, ref)).

That means a selector typed during create/edit (via CLI flags or the interactive completeCreateAnswers flow, which also funnels through selectorFromOptions) always gets pinned to an immutable commit SHA at write time. A selector typed during patch update, though, is stored verbatim — e.g. entering HEAD~2 or a branch name for "Inclusive upper SHA" persists that literal string into the manifest. It isn't caught by the manifest schema either (IntervalSelectorSchema/CommitSelectorSchema just require non-empty trimmed strings, not a resolvable/immutable ref).

Since resolveApplicableShas / verifyPatchBytes re-resolve these refs at each future bisect run (via resolveCommit), a mutable ref persisted this way will silently re-resolve to whatever it points to at that later run, rather than staying pinned to what the operator meant when they ran patch update — a reproducibility gap relative to the create/edit paths. Consider routing promptMetadata's selector inputs through the same resolveRef call before returning appliesTo.

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review summary

Reviewed the patch-registry/patch-CLI machinery this PR adds for managing bisect repair patches (patch-capture.ts, patch-manifest.ts, patch-registry.ts, patch-cli.ts, repair-artifacts.ts, config.ts, session.ts).

Overall: the core design is solid — atomic write/rollback transactions for manifest+artifact updates, hash-verified patch bytes, disposable-worktree verification that never touches the live experiment checkout, execFileSync with array args everywhere (no shell injection), and a mutation guard tied into the existing twin-servers bisect lease. Test coverage for the new modules is thorough.

Two issues left as inline comments:

  1. Missing BREAKING_CHANGES.md entry (packages/shaka-perf/src/config.ts) — bisect.repairs is now a hard-rejected/removed config field (confirmed by the new "rejects the removed bisect.repairs configuration" test), which is exactly the kind of change this repo's CLAUDE.md requires logging under BREAKING_CHANGES.md's Unreleased section, with the migration path for affected configs. That file isn't touched in this diff.

  2. patch update doesn't pin selector refs to immutable SHAs (packages/shaka-perf/src/compare/bisect/patch-cli.ts, promptMetadata) — unlike patch create/patch edit, which resolve every --at/--from/--through value through resolveRef before writing the manifest, the interactive patch update flow stores whatever string the operator types for the selector verbatim. A mutable ref (branch name, HEAD~N, etc.) entered this way will re-resolve differently at each future bisect run instead of staying pinned to what was meant at update time.

No security concerns beyond the above (patch bytes/paths are validated against traversal and configured copy-ignore paths in patch-capture.ts's validateTargetPath/inspectPatch).

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review summary

Solid, well-tested implementation of the patch-manifest/registry/CLI stack (atomic manifest+artifact writes with rollback, sha256 verification, worktree-isolated verification, careful copy-ignore/path-safety checks on captured/imported patches). Two things worth addressing before merge:

1. Missing BREAKING_CHANGES.md entry for the bisect.repairs removal

packages/shaka-perf/src/config.ts now hard-rejects any abtests.config.ts that still has bisect.repairs (see the bisect.repairs is not supported check), and packages/shaka-shared/src/define-config.ts drops BisectRepairConfigInput/BisectRepairSelectorInput entirely. Per this repo's CLAUDE.md:

Any change that can break an existing consumer's .abtest.ts files or abtests.config.ts — a removed/renamed abTest() option, a moved or renamed config field, a changed default — MUST be logged in BREAKING_CHANGES.md under its Unreleased section, with the exact fix for affected tests.

BREAKING_CHANGES.md currently has no Unreleased section at all — this PR doesn't add one. Since this removes a previously-valid config field and throws for anyone still using it, it needs an Unreleased entry describing the migration (bisect.repairsshaka-perf bisect patch create <id> / bisect-repairs/manifest.json), matching the pattern of the existing entries in that file.

2. patch verify can silently skip explicitly-selected commits

In packages/shaka-perf/src/compare/bisect/patch-registry.ts, verificationShas() (~L371-404) intersects the selector's resolved SHAs with the goodRef..badRef graph whenever both refs are supplied:

} else if ('commits' in selector) {
    selected = selector.commits.map((ref) => resolveCommit(repoDir, ref));
} else { ... }

if (graph) {
    const allowed = new Set(graph);
    selected = selected.filter((sha) => allowed.has(sha));
}
...
if (selected.length === 0) throw new Error('Patch selector does not match any commits in the verification range');

For an appliesTo: { commits: [...] } patch, if you run shaka-perf bisect patch verify <id> <good-ref> <bad-ref> and one of the registered exact commits happens to fall outside that particular good/bad range, it's silently dropped rather than reported — the command only fails if every selected commit is out of range. Since verify's own description is "Verify a registered patch against its configured commit scope," a caller has no way to tell from a success result whether all of the patch's declared commits were actually exercised, or only some. This seems worth either (a) erroring when a commits selector has SHAs outside the supplied range, or (b) verifying the full declared selector regardless of the good/bad range and using the range only to scope all/interval selectors. No existing test covers commits + goodRef/badRef with a partially-out-of-range selector.

Everything else I looked at (transactional manifest/artifact writes in patch-registry.ts, patch-capture.ts's temp-index isolation and forbidden-path checks, the assertMutable lease guard, schema validation in patch-manifest.ts) looked correct and well covered by tests.

Comment on lines +397 to +403
if (graph) {
const allowed = new Set(graph);
selected = selected.filter((sha) => allowed.has(sha));
}
selected = [...new Set(selected)];
if (selected.length === 0) throw new Error('Patch selector does not match any commits in the verification range');
return selected;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For an appliesTo: { commits: [...] } patch, this silently drops any selected SHA that falls outside the goodRef..badRef graph instead of erroring — it only throws if every selected commit ends up out of range. Since patch verify is documented as verifying "against its configured commit scope," a caller running shaka-perf bisect patch verify <id> <good-ref> <bad-ref> gets a success result with no indication that some of the patch's declared exact commits were never actually exercised. Worth either erroring when a commits selector has a SHA outside the supplied range, or always verifying the full declared selector and using the range only to scope all/interval selectors. No test currently covers a commits selector combined with a good/bad range that excludes one of the listed commits.

@rameziophobia

Copy link
Copy Markdown
Contributor Author

@claude help

@rameziophobia

Copy link
Copy Markdown
Contributor Author

@claude code-review

Comment on lines +531 to +561
async function assertMutable(
context: PatchCliContext,
deps: BisectPatchCliDependencies,
): Promise<void> {
if (deps.assertMutable) return deps.assertMutable(context);
if (!context.projectSlug) return;
const outcome = await (deps.tryProxy ?? tryProxy)({
slug: context.projectSlug,
request: { v: PROTOCOL_VERSION, cmd: 'bisect-status' },
});
if (!outcome.proxied) {
if (/^manifest v\d+, this CLI speaks v\d+$/.test(outcome.reason)) {
throw new Error(
`Cannot verify the bisect lease because ${outcome.reason}. ` +
'Restart the running `shaka-perf servers` process and retry.',
);
}
return;
}
if (outcome.proxied && outcome.code !== 0) {
throw new Error(outcome.error ?? 'Cannot query bisect lease status');
}
const activeSessionId = (outcome.data as {
activeSessionId?: string | null;
} | undefined)?.activeSessionId;
if (activeSessionId) {
throw new Error(
`Cannot modify bisect patches while session "${activeSessionId}" owns the project`,
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Check-then-act race on the bisect lease. assertMutable queries bisect-status once (line 537-539) and returns if no session is active, but the actual mutation (registry.create/edit/apply/remove, all plain local filesystem writes) happens afterward with no lock held for the gap in between — for create, that gap includes an unbounded interactive prompt sequence (completeCreateAnswers).

A concurrent beginBisectSession can acquire the lease in that window (it's a locked/queued runProxiedAction on the server side), so bisect-status can observe activeSessionId: null a moment before the session actually starts. The patch mutation then proceeds and rewrites bisect-repairs/manifest.json/the artifact while a bisect run is starting or already using the very state session.ts explicitly snapshots at bisect start to avoid mid-session edits leaking in — exactly the hazard this check exists to prevent.

Also worth a look while touching this function: the protocol-mismatch branch on line 542 detects an IPC version skew by regex-matching outcome.reason, a free-text string only ever produced for console.error debugging in twin-servers/ipc/client.ts:76. Nothing ties the two together at compile time — if that log wording ever changes, the regex silently stops matching and assertMutable falls through to if (!outcome.proxied) return, treating an undetected protocol mismatch as "no server running, safe to proceed."

entry,
artifactPath,
files: inspectPatchAtRoot(this.root(), bytes),
hashValid: createHash('sha256').update(bytes).digest('hex') === entry.sha256,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

describe() calls inspectPatchAtRoot(this.root(), bytes) with no copyIgnore argument, so it always falls back to defaultCopyIgnoreConfig() (see patch-capture.ts:150-171, which throws Patch targets configured copy-ignore path: ... for any file matching that config).

PatchRegistryOptions (line 34-38) has no copyIgnore field at all, even though PatchCliContext carries one and every CLI call site does new BisectPatchRegistry({ ...context }) — the field is silently dropped. capture()/patch-capture.ts correctly thread the project's configured copyIgnore through at capture time, but every later read path (list, get, apply, verify — all funnel through describe()) re-validates against the hardcoded default instead.

If a project's twinServers.copyIgnore differs from the default (e.g. drops audit-results), a patch that was captured and registered successfully will throw on every subsequent list/get/apply/verify. Worse, list() (line 59-61) maps describe() over every manifest entry with no per-entry try/catch, so one such patch throws and breaks patch list for the entire registry, not just that patch.

Comment on lines +309 to +333

function removeTransaction(
manifestPath: string,
directory: string,
entry: BisectPatchManifestEntry,
manifest: BisectPatchManifest,
keepFile: boolean,
): void {
const artifactPath = path.join(directory, entry.filename);
const heldArtifact = temporaryPath(artifactPath);
const manifestTemporary = temporaryPath(manifestPath);
try {
if (!keepFile) fs.renameSync(artifactPath, heldArtifact);
writeManifestFile(manifestTemporary, manifest);
try {
fs.renameSync(manifestTemporary, manifestPath);
} catch (error) {
if (!keepFile) fs.renameSync(heldArtifact, artifactPath);
throw error;
}
if (!keepFile) fs.rmSync(heldArtifact, { force: true });
} finally {
fs.rmSync(manifestTemporary, { force: true });
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

removeTransaction renames the live artifact aside at line 321 (fs.renameSync(artifactPath, heldArtifact)) before attempting writeManifestFile(manifestTemporary, manifest) on the next line — but only the later fs.renameSync(manifestTemporary, manifestPath) has a catch that restores the artifact. If writeManifestFile itself throws (e.g. ENOSPC/EACCES writing to bisect-repairs/), the outer finally only removes manifestTemporary; heldArtifact is never renamed back to artifactPath.

Result: the manifest still lists the entry pointing at <id>.patch, but the file now sits under a random .patch.<pid>.<ts>.<rand>.tmp name, so get()/list()/apply() all throw Cannot read bisect patch "<id>" until someone manually renames it back.

Contrast with writeCreateTransaction/replaceArtifactTransaction above, which always write the manifest temp file before touching the live artifact — removeTransaction reverses that ordering, which is what opens this gap.

const bytes = fs.readFileSync(patch.artifactPath);
const reverse = options.reverse === true;
if (!reverse && !canApply(repoDir, bytes, false)) {
const clean = isClean(repoDir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

isClean() (defined at line 361-363) is a bare git status --porcelain emptiness check — it reimplements requireClean() from git.ts but drops its allowedPaths exception, which the rest of the bisect module relies on to tolerate known-OK dirty paths (e.g. generated artifact dirs) when judging a repo clean.

Used here in apply(), this means: if the experiment repo has an allowed-dirty artifact present (exactly the case requireClean(..., { allowedPaths }) exists to handle elsewhere in bisect), patch apply will treat the tree as dirty and throw Patch "<id>" appears in uncommitted working-tree changes; clean the repository first, even though the rest of the bisect flow would consider that same tree clean. Worth reusing requireClean's allowedPaths-aware check here instead of a second, stricter implementation.

Comment on lines +97 to +108
updateMetadata(id: string, metadata: PatchMetadata): RegisteredPatch {
const loaded = this.load();
const index = loaded.manifest.patches.findIndex((patch) => patch.id === id);
if (index < 0) throw new Error(`Unknown bisect patch "${id}"`);
const entry = normalizeEntry({ ...loaded.manifest.patches[index]!, ...metadata });
const patches = [...loaded.manifest.patches];
patches[index] = entry;
verifyPatchBytes(
this.options.repoDir,
entry.id,
entry.appliesTo,
fs.readFileSync(path.join(loaded.directory, entry.filename)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: updateMetadata reads the artifact bytes straight off disk (line 108) and hands them to verifyPatchBytes without first checking they match entry.sha256 — unlike apply(), which calls this.get(id) and explicitly throws when !patch.hashValid. If an artifact under bisect-repairs/ gets corrupted or hand-edited outside the CLI, patch update will "successfully" verify and persist metadata changes against the wrong bytes and report success, silently rewriting the manifest around a corrupt artifact. The corruption only surfaces later via apply()/verify()/list().

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review summary

This PR migrates bisect repair config into a managed patch manifest/registry with a large new CLI surface (shaka-perf bisect patch ...). The migration itself is thorough — duplicate-id/filename checks, path-traversal guards, mutually-exclusive selectors, and the legacy-config rejection are all well covered by tests.

Left 5 inline comments on patch-cli.ts/patch-registry.ts, ranked by severity:

  1. Check-then-act race on the bisect lease (patch-cli.ts assertMutable) — the lease check and the actual manifest/artifact mutation aren't atomic, so a bisect session can start in the gap and a patch mutation can land mid-session. Also flagged a fragile regex there that detects protocol mismatch by matching a free-text log string owned by a different module.
  2. copyIgnore never reaches BisectPatchRegistrydescribe() (used by list/get/apply/verify) always validates against the default copy-ignore config instead of the project's configured one, even though capture() correctly uses the real config. A patch valid at create time can make every later list/get/apply throw, and list() has no per-entry isolation so one bad patch breaks the whole listing.
  3. removeTransaction has an unguarded rollback gap — if the manifest temp-file write itself fails during patch remove, the already-renamed-aside artifact is never restored, orphaning it under a .tmp name while the manifest still references the original path.
  4. isClean() drops the allowedPaths exception from requireClean() — used in apply(), this can misjudge a repo with allowed-dirty artifacts as unclean, diverging from how the rest of the bisect module judges cleanliness.
  5. Minor: updateMetadata doesn't check hashValid before persisting metadata against on-disk bytes, unlike apply().

Also noted for awareness (not filed inline, lower priority): several git-wrapper helpers (gitRoot, resolveCommit, sha256 hashing) are reimplemented independently across patch-registry.ts/patch-capture.ts instead of reusing the existing git.ts versions, and patch-registry.ts re-reads manifest/artifact bytes on nearly every call (no in-memory cache), including a git apply --numstat spawn per patch on plain patch list whose result is discarded in non-verbose mode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant