#2735

feat(lock): pin lock entries to the installed commit

Openantfubotwants to merge intofrom

Summary

skills install --frozen-lockfile could not restore a GitHub skill after its upstream repository changed. The project lock stored only ref (a branch or tag name) and computedHash. A restore fetched the current tip of ref, its hash no longer matched, and the install failed. There was nothing in the lock to fetch the old version with.

Lock entries for Git sources now record the commit the skill was installed from:

  • add records it. Clone path: getHeadCommit on the clone. Blob fast path: new fetchTreeCommit in src/blob.ts reads the branch tip from the GitHub commits API. It returns the commit only if it is still the commit the tree was read from, so a branch that moved between the two requests records no commit instead of a wrong one.
  • install (frozen or not) fetches <source>#<commit>. This uses the existing commit-SHA clone fallback in cloneRepo. The lock still records the original ref, so update keeps tracking the branch, and a non-frozen restore leaves the lock byte-identical.
  • update moves commit forward when it reinstalls a skill. It also adds commit to an older entry that has none, when that entry's content still matches. check writes nothing.

The global lock (~/.agents/.skill-lock.json) records commit too. add -g writes it, and update -g moves it forward. A failed commit lookup clears it instead of leaving an old one. The global lock has no restore command, so for now the field is a record of what is installed.

The field is optional in both locks, so older locks keep working. A project entry without commit follows ref as before until the next update. Neither lock version changes.

src/blob.ts refactor: the anonymous -> token -> gh api fallback that fetchRepoTree used is now a generic fetchFirstGitHubApi. fetchTreeCommit uses it too. The order of requests and the rate-limit memo are unchanged (the existing blob-fetch-tree-auth tests pass).

Note: when called with a branch name, the GitHub Trees API returns the commit SHA as sha, not the root tree SHA. fetchTreeCommit accepts a match on either.

  • refs #500 (item 4, "Commit SHA Tracking". The issue's other items, such as ci and verify, are out of scope.)
  • refs #11 (the versioning RFC proposes a commit field in the lock)

Related, already closed: #2279 (restoring entries whose ref is a commit SHA; this PR reuses that fallback) and #2560 (lockfile reconciliation tracking issue).

User prompts

Model: Claude Opus 5.5 in Claude Code (Paseo).

  • Asked how a frozen lockfile from GitHub recovers from a hash mismatch when upstream changes, and whether the git SHA is tracked.
  • After hearing that it does not, asked to store the resolved commit SHA and restore from it, and to open a PR.
  • Asked to record the commit in the global lock too, in this PR.

Verification

  • pnpm vitest run tests/lock-commit-pin.test.ts: 4 passed. These tests use a real local Git repo, reached through an https://...git URL that url.<base>.insteadOf rewrites. They cover: add records the commit; after upstream moves on, frozen and non-frozen restores install the old content and leave the lock unchanged; update moves the commit forward; update adds a commit to an old entry that has none; add -g records the commit and update -g moves it forward. With src/ reset to main, all 4 fail. The global case also fails with only the project-lock commit applied.
  • pnpm vitest run tests/blob-fetch-tree-auth.test.ts: 15 passed, including 4 new fetchTreeCommit cases (tree match, commit-SHA match, branch moved, HEAD).
  • pnpm test: 1376 passed, 4 failed. The 4 failures (src/detect-agent.test.ts x3, src/use.test.ts x1) also fail on main in this environment. They come from the agent-detection environment variables and a Node UNDICI warning on stderr.
  • pnpm type-check, pnpm format:check, pnpm build: pass. The tsnapi snapshot is unchanged.
  • Run by hand against live GitHub:
    • add vercel-labs/agent-skills --skill web-design-guidelines (blob path) recorded commit: 1a2c79d..., which matches git ls-remote ... HEAD. After deleting .agents, install --frozen-lockfile printed Restored web-design-guidelines.
    • add https://github.com/anthropics/skills --skill pdf (clone path) recorded a commit, and a frozen restore succeeded.
    • add -g vercel-labs/agent-skills --skill web-design-guidelines recorded the same commit in the global lock.
    • With a made-up commit in the lock, the restore fails with a clone error, as expected.
  • Not verified: GitLab and self-hosted servers. Fetching by SHA needs uploadpack.allowReachableSHA1InWant (or protocol v2). GitHub and GitLab.com support this.

Two existing mocks of src/git.ts and one child_process mock needed a getHeadCommit stub. Those test files have a one-line change each.


Written by an AI agent (Claude Code). The user asked the question and requested this change. They have not reviewed the diff yet.