fix(install): dedupe copy-file operations sharing a destination (#2429)

OpenCode ships override command files under .opencode/commands/ that
shadow the generic commands/*.md sources. The manifest install plan
recorded both writes against the same destination, so `ecc doctor`
reported perpetual drift for the 29 shadowed command files and `ecc
repair` "fixed" that phantom drift by copying the generic source over
the correct override, corrupting the installed command while never
clearing the warning.

Dedupe copy-file operations by destination in createManifestInstallPlan,
keeping the last writer to match the sequential apply order. install,
repair, and doctor all consume this one builder, so a fresh install is
clean and a single repair rewrites drifted state green.

Fixes #2414
This commit is contained in:
Gaurav Dubey 2026-07-04 09:07:32 +05:30 committed by GitHub
parent 52f7e82a61
commit c8c83ef428
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 84 additions and 1 deletions

View file

@ -688,6 +688,32 @@ function materializeScaffoldOperation(sourceRoot, operation) {
});
}
function dedupeCopyFileOperations(operations) {
// A `copy-file` operation fully overwrites its destination, so when several
// of them target the same path (e.g. a generic `commands/<name>.md` shadowed
// by an OpenCode `.opencode/commands/<name>.md` override) only the last one
// actually determines the installed content. Recording the shadowed earlier
// writes in install-state makes `doctor` report perpetual drift and drives
// `repair` to clobber the override with the generic source (issue #2414).
// Keep only the last `copy-file` per destination — matching the sequential
// apply order in applyInstallPlan — and leave every other operation kind
// (e.g. accumulating `merge-json` writes into a shared config) untouched and
// in order.
const lastCopyIndexByDestination = new Map();
operations.forEach((operation, index) => {
if (operation.kind === 'copy-file' && operation.destinationPath) {
lastCopyIndexByDestination.set(operation.destinationPath, index);
}
});
return operations.filter((operation, index) => {
if (operation.kind !== 'copy-file' || !operation.destinationPath) {
return true;
}
return lastCopyIndexByDestination.get(operation.destinationPath) === index;
});
}
function createManifestInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
@ -716,7 +742,9 @@ function createManifestInstallPlan(options = {}) {
target
});
const adapter = getInstallTargetAdapter(target);
const operations = plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation));
const operations = dedupeCopyFileOperations(
plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation))
);
const source = {
repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot),
@ -776,6 +804,7 @@ module.exports = {
createLegacyCompatInstallPlan,
createManifestInstallPlan,
createLegacyInstallPlan,
dedupeCopyFileOperations,
getSourceRoot,
listAvailableLanguages,
parseInstallArgs