Skip to content

fix: refuse a publish that would append to another project's history - #127

Open
TheAmericanMaker wants to merge 2 commits into
mainfrom
fix/publish-source-repo-collision-guard
Open

fix: refuse a publish that would append to another project's history#127
TheAmericanMaker wants to merge 2 commits into
mainfrom
fix/publish-source-repo-collision-guard

Conversation

@TheAmericanMaker

@TheAmericanMaker TheAmericanMaker commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #123. deriveSlug uses only the trailing path segment of source_repo, so acme/whisper and openai/whisper both produce whisper, and publishing the second wrote it as v2 of the first. The entry's version history then spanned two unrelated codebases, and since buildIndexEntry reads only the newest version's metadata, index.yaml attributed the whole entry to whichever repo published last. Nothing reported it, at publish time or after.

publishEntry now reads the source_repo recorded on the newest version and fails before writing anything when it denotes a different repository.

Comparison is normalized so re-publishing one repository spelled another way is unaffected. Scheme or none, embedded credentials (git@, user:token@), git@host:path SCP syntax, a default port, a www. prefix, a trailing .git, trailing slashes, backslash separators and casing all collapse to one form. A non-default port is kept, since two services on one host can differ by port alone, and only spellings that are unambiguously the same target collapse, because the caller treats "different" as fatal.

Second commit: a false positive I introduced and then found

Worth reading on its own. The first pass ran the SCP branch before stripping the scheme and never stripped a userinfo prefix, so four ordinary spellings of one repository each reduced to something different:

ssh://git@github.com/acme/tool.git        -> git@github.com/acme/tool
ssh://git@github.com:22/acme/tool.git     -> git@github.com:22/acme/tool
https://git@github.com/acme/tool          -> git@github.com/acme/tool
https://user:token@github.com/acme/tool   -> user:token@github.com/acme/tool

Publishing once from an HTTPS clone and later from an SSH clone of the same repo was refused as a different project, which is the failure direction this guard exists to avoid. Reordered to strip scheme, then userinfo, then apply the SCP branch only when there was no scheme, since that is the only context where a colon means host:path rather than a port.

The first round of tests missed it because every URL form they used had either no scheme or no credentials. That gap is closed: nine more equivalent spellings, plus distinctness assertions pinning non-default ports, Windows drive letters, and a same-host different-path pair. Two tests fail against the first commit's normalization.

A repository that genuinely moved is the one legitimate reason to change the recorded value, so allowSourceRepoChange opts out, exposed on MCP as allow_source_repo_change.

Type of change

  • Bug fix
  • Feature
  • Refactor (no behavior change)
  • Documentation
  • CI / build
  • Framework / pipeline change (template, skills, validation, prompts)

Checklist

  • Tests added or updated (npm test passes locally)
  • Pipeline invariants still hold (Pi extension and MCP server byte-identical with template)
  • Documentation updated (README, MANUAL, GUIDE, CHANGELOG, or relevant .codecarto/ files)
  • No new dependencies added without discussion
  • Commit messages follow the repo style (feat:, fix:, docs:, refactor:, ci:, framework:)

Eight new tests, 350 total, suite green from a 342 baseline. Three fail with the guard disabled: the two-project collision, the metadata-only path, and forceNewVersion. The other five pin the normalization equivalences, keep genuinely distinct repos distinct, and hold the false-positive and malformed-metadata paths open. No .codecarto/ files touched, so the invariant tests were unaffected either way, and they pass.

Related issues

Fixes #123.

Found in the same audit, filed separately since each needs its own decision: #124 (the documented confidentiality vs visibility comparison does not exist), #125 (publish is documented as committing by default, but commitPublish has no callers), #126 (three more places library-format.md describes a shape the code does not produce).

Additional notes

Three decisions worth your attention:

The check sits ahead of the content-hash branch. Identical spec bytes take the metadata-only path, which overwrote the other project's source_repo and headline in place, so guarding only the new-version path would have left the quieter half of the bug intact.

forceNewVersion does not bypass it. That option means "another version of this entry", not "overwrite a different project".

Unreadable or malformed recorded metadata skips the check. There is nothing to compare against, and refusing on unknown would turn a corrupt v1 into an entry nobody can publish to.

On the doc change: library-format.md promised auto-suffixing (-2, -3) for this case. It was never implemented, and silent suffixing is its own kind of surprise for an agent-driven tool, so the doc now describes the refusal and the override rather than the behaviour we would have had to build to match it.

Deliberately not in this PR

The escape hatch is MCP only, which is the opposite of the surface priority in CLAUDE.md. Closing it on Pi is UX work on the primary surface and wanted its own discussion rather than a ride-along in a bug fix. Two pre-existing Pi gaps, neither caused by this change:

/codecarto-publish takes no arguments (handler: async (_args, ctx)), so a Pi user who trips the guard after genuinely moving a directory gets a clear error with no way to act on it. Pi already uses ctx.ui.confirm for the publish preview, which looks like the right place to surface the conflict and offer the override.

Pi passes ctx.cwd as source_repo, a local absolute path. That is also why the collision is easy to hit there: slug and source_repo both derive from ctx.cwd, so two same-named directories under different parents collide. The format doc calls local paths "permitted but discouraged for shared libraries", and using the git remote when there is one looks like the better default.

James Sesler and others added 2 commits August 21, 2026 01:27
Slugs derive from the trailing path segment of source_repo, so acme/whisper
and openai/whisper both produce "whisper". Publishing the second wrote it as
v2 of the first. The entry's version history then spanned two unrelated
codebases, and since buildIndexEntry reads only the newest version's
metadata, index.yaml attributed every prior version to whichever repo
published last. Nothing surfaced the collision at publish time or afterwards.

publishEntry now reads the source_repo recorded on the newest version and
fails before writing when it denotes a different repository.

Comparison is normalized so that re-publishing one repository spelled another
way is unaffected: scheme or none, git@host:path SCP syntax, a www. prefix, a
trailing .git, trailing slashes, backslash separators, and casing all collapse
to the same form. Only spellings that are unambiguously the same target
collapse, since the caller treats "different" as fatal.

Three details worth noting for review:

- The check sits ahead of the content-hash branch. Identical spec bytes take
  the metadata-only path, which overwrote the other project's source_repo and
  headline in place, so guarding only the new-version path would have left the
  quieter half of the bug.
- forceNewVersion does not bypass it. That option means "another version of
  this entry", not "overwrite a different project".
- Unreadable or malformed recorded metadata skips the check. There is nothing
  to compare, and refusing on unknown would turn a corrupt v1 into an
  unpublishable entry.

A genuine repository move is the one legitimate reason to change the recorded
value, so allowSourceRepoChange opts out, exposed on the MCP surface as
allow_source_repo_change.

docs/library-format.md promised auto-suffixing (-2, -3) for this case. That was
never implemented, and silent suffixing is its own surprise for an
agent-driven tool, so the doc now describes the refusal and the override.

Eight tests. Three fail without the guard: the two-project collision, the
metadata-only path, and forceNewVersion. The rest pin the normalization
equivalences, keep genuinely distinct repos distinct, and hold the
false-positive and malformed-metadata paths open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-review of #127 found the guard refusing a legitimate re-publish, which is
the failure direction the guard was supposed to avoid.

normalizeSourceRepo ran the SCP branch before stripping the scheme and never
stripped a userinfo prefix, so four common spellings of one repository each
reduced to something different:

  ssh://git@github.com/acme/tool.git        -> git@github.com/acme/tool
  ssh://git@github.com:22/acme/tool.git     -> git@github.com:22/acme/tool
  https://git@github.com/acme/tool          -> git@github.com/acme/tool
  https://user:token@github.com/acme/tool   -> user:token@github.com/acme/tool

Publishing once from an HTTPS clone and later from an SSH clone of the same repo
was therefore refused as a different project.

Reordered: scheme off first, then userinfo, then the SCP branch, which now runs
only when there was no scheme, since that is the only place a colon means
host:path rather than a port. Default ports 22, 80 and 443 are dropped; any
other port is kept, because two services on one host can differ by port alone.
The SCP branch also now requires a dotted host, so a Windows drive letter is not
read as host:path.

The original tests missed all of this: every URL form they used had either no
scheme or no credentials. Widened to nine more equivalent spellings, and the
distinctness assertions now pin non-default ports, drive letters, and a
same-host different-path pair. Two tests fail against the previous
normalization.

351 tests, suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

publish: two repos with the same trailing path segment merge into one entry's version history

1 participant