Skip to content

fix(github): return 409 for ref lookups in an empty repository - #3

Merged
byapparov merged 1 commit into
github-onlyfrom
fix/empty-repo-git-ref-409
Nov 6, 2026
Merged

byapparov merged 1 commit into
github-onlyfrom
fix/empty-repo-git-ref-409

Conversation

@byapparov

Copy link
Copy Markdown
Member

Why

When a repository has no commits, GitHub answers GET /repos/{owner}/{repo}/git/ref/{ref} with 409 "Git Repository is empty." The emulator returned 404, which clients can't tell apart from a missing branch.

aictrl relies on that 409 to recognise an empty repository; production logs show it as github-content 409 "Git Repository is empty.". With the emulator returning 404, the aictrl fix for aictrl-dev/aictrl#6942 needed an emulator-only fallback in production code. Fixing the emulator removes that fallback.

Change

  • GET /repos/{owner}/{repo}/git/ref/{ref} returns 409 Git Repository is empty. when the repository has no commits. This matches the existing /commits behaviour.
  • A missing branch in a populated repository still returns 404.
  • After the first commit, the ref resolves normally.

Tests

  • 3 new tests in contents-commits.test.ts. The empty-repo case was shown failing first (expected 404 to be 409).
  • pnpm build, type-check, lint, format:check and test all pass on Node 24.

refs aictrl-dev/aictrl#6942

🤖 Generated with Claude Code

https://claude.ai/code/session_01Br5cc4QAYrB1gYBPmKLEz5

GitHub answers GET /repos/{owner}/{repo}/git/ref/{ref} on a repository
with no commits with 409 "Git Repository is empty.". The emulator
returned 404, which clients cannot tell apart from a missing branch.
That forced aictrl to add an emulator-only fallback (aictrl-dev/aictrl#6942).

A missing branch in a populated repository still returns 404, and the
ref resolves normally after the first commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Br5cc4QAYrB1gYBPmKLEz5
@byapparov
byapparov merged commit 8f2b8b4 into github-only Nov 6, 2026
4 checks passed
@byapparov byapparov self-assigned this Nov 6, 2026
@byapparov byapparov added the bug Something isn't working label Nov 6, 2026
assertRepoContentsRead(gh, c.get("authUser"), repo);
// GitHub answers a ref lookup in a repository with no commits with 409,
// not 404, so clients can tell "empty" from "missing branch".
if (gh.commits.findBy("repo_id", repo.id).length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 409 gate counts commits; GitHub empties have no refs.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/github-emulator PR #3, packages/@emulators/github/src/routes/branches.ts:692-694):

Problem: 409 gate counts commits; GitHub empties have no refs
Detail: The new gate treats a repo as empty when it has zero commits, but GitHub's "Git Repository is empty." 409 fires for a repository without branches (no refs). The emulator allows creating dangling Git objects without any ref: existing tests (e.g. "keeps the branch head before newer-dated parents") POST /git/commits and only afterwards PATCH /git/refs — so the store can hold commits with zero refs. In that state this endpoint now falls through to the ref lookup and returns 404, while real GitHub would still answer 409 "Git Repository is empty." — the exact case the PR was written to match. Gating on the refs table is also the self-consistent predicate for a ref endpoint. Repro: Given octocat/empty-repo has no refs; When the client seeds dangling objects via POST /git/blobs, POST /git/trees, POST /git/commits (all 201, no ref created); And calls GET /repos/octocat/empty-repo/git/ref/heads/main; Then the emulator responds 404 but real GitHub responds 409 "Git Repository is empty.".
Suggested fix: Gate on refs instead of commits: `if (gh.refs.findBy("repo_id", repo.id).length === 0) throw new ApiError(409, "Git Repository is empty.");` — matching GitHub's "no branches" definition of an empty repository. If adopted, apply the same predicate to the commits-list handler (commits.ts:99) via the shared helper so the two endpoints stay consistent.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new gate treats a repo as empty when it has zero commits, but GitHub's "Git Repository is empty." 409 fires for a repository without branches (no refs). The emulator allows creating dangling Git objects without any ref: existing tests (e.g. "keeps the branch head before newer-dated parents") POST /git/commits and only afterwards PATCH /git/refs — so the store can hold commits with zero refs. In that state this endpoint now falls through to the ref lookup and returns 404, while real GitHub would still answer 409 "Git Repository is empty." — the exact case the PR was written to match. Gating on the refs table is also the self-consistent predicate for a ref endpoint. Repro: Given octocat/empty-repo has no refs; When the client seeds dangling objects via POST /git/blobs, POST /git/trees, POST /git/commits (all 201, no ref created); And calls GET /repos/octocat/empty-repo/git/ref/heads/main; Then the emulator responds 404 but real GitHub responds 409 "Git Repository is empty.".

    const repo = lookupRepo(gh, owner, repoName);
    if (!repo) throw notFoundResponse();
    assertRepoContentsRead(gh, c.get("authUser"), repo);
    // GitHub answers a ref lookup in a repository with no commits with 409,
    // not 404, so clients can tell "empty" from "missing branch".
    if (gh.commits.findBy("repo_id", repo.id).length === 0) {
      throw new ApiError(409, "Git Repository is empty.");
    }
    const fullRef = fullRefFromParam(refParam);
    const r = gh.refs.findBy("repo_id", repo.id).find((x) => x.ref === fullRef);
    if (!r) throw notFoundResponse();
    return c.json(formatRefJson(gh, repo, r.ref, r.sha, baseUrl));

assertRepoContentsRead(gh, c.get("authUser"), repo);
// GitHub answers a ref lookup in a repository with no commits with 409,
// not 404, so clients can tell "empty" from "missing branch".
if (gh.commits.findBy("repo_id", repo.id).length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Empty-repo 409 guard duplicated from commits.ts.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/github-emulator PR #3, packages/@emulators/github/src/routes/branches.ts:692-694):

Problem: Empty-repo 409 guard duplicated from commits.ts
Detail: The new inline guard (branches.ts:692-694) is a verbatim copy of the existing block in the commits-list handler GET /repos/:owner/:repo/commits (packages/@emulators/github/src/routes/commits.ts:99-101): same predicate, same status code, same literal "Git Repository is empty." string. Two hand-maintained copies of the same rule across route files will drift (message wording or the emptiness definition), and the sibling matching-refs handler will need a third copy. The codebase already centralises route guards (notFoundResponse, assertRepoContentsRead) in route-helpers.ts, so this predicate belongs there too.
Suggested fix: Add e.g. `export function assertRepoNotEmpty(gh: GitHubStore, repo: GitHubRepo): void { if (gh.commits.findBy("repo_id", repo.id).length === 0) throw new ApiError(409, "Git Repository is empty."); }` to packages/@emulators/github/src/route-helpers.ts and call it from both the /commits handler (commits.ts:99) and the /git/ref handler (branches.ts:692), deleting the duplicated inline copies (and reuse it in matching-refs when that gap is fixed).

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new inline guard (branches.ts:692-694) is a verbatim copy of the existing block in the commits-list handler GET /repos/:owner/:repo/commits (packages/@emulators/github/src/routes/commits.ts:99-101): same predicate, same status code, same literal "Git Repository is empty." string. Two hand-maintained copies of the same rule across route files will drift (message wording or the emptiness definition), and the sibling matching-refs handler will need a third copy. The codebase already centralises route guards (notFoundResponse, assertRepoContentsRead) in route-helpers.ts, so this predicate belongs there too.

    const repo = lookupRepo(gh, owner, repoName);
    if (!repo) throw notFoundResponse();
    assertRepoContentsRead(gh, c.get("authUser"), repo);
    // GitHub answers a ref lookup in a repository with no commits with 409,
    // not 404, so clients can tell "empty" from "missing branch".
    if (gh.commits.findBy("repo_id", repo.id).length === 0) {
      throw new ApiError(409, "Git Repository is empty.");
    }
    const fullRef = fullRefFromParam(refParam);
    const r = gh.refs.findBy("repo_id", repo.id).find((x) => x.ref === fullRef);
    if (!r) throw notFoundResponse();
    return c.json(formatRefJson(gh, repo, r.ref, r.sha, baseUrl));

@aictrl-dev

aictrl-dev Bot commented Nov 6, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 2 · ⚪ 0 · 0/3 resolved

  • 🟡 packages/@emulators/github/src/routes/branches.ts:692-694 — 409 gate counts commits; GitHub empties have no refs
  • 🟡 packages/@emulators/github/src/routes/branches.ts:692-694 — Empty-repo 409 guard duplicated from commits.ts
  • 🟠 packages/@emulators/github/src/routes/branches.ts:707-708 — matching-refs skips empty-repo 409 added for git/ref
🤖 Fix all 3 open findings with your agent
Fix the following code review findings on aictrl-dev/github-emulator PR #3 (head branch).
Run the relevant tests/linters after each change.

1. packages/@emulators/github/src/routes/branches.ts:692-694 — 409 gate counts commits; GitHub empties have no refs
   Detail: The new gate treats a repo as empty when it has zero commits, but GitHub's "Git Repository is empty." 409 fires for a repository without branches (no refs). The emulator allows creating dangling Git objects without any ref: existing tests (e.g. "keeps the branch head before newer-dated parents") POST /git/commits and only afterwards PATCH /git/refs — so the store can hold commits with zero refs. In that state this endpoint now falls through to the ref lookup and returns 404, while real GitHub would still answer 409 "Git Repository is empty." — the exact case the PR was written to match. Gating on the refs table is also the self-consistent predicate for a ref endpoint. Repro: Given octocat/empty-repo has no refs; When the client seeds dangling objects via POST /git/blobs, POST /git/trees, POST /git/commits (all 201, no ref created); And calls GET /repos/octocat/empty-repo/git/ref/heads/main; Then the emulator responds 404 but real GitHub responds 409 "Git Repository is empty.".
   Suggested fix: Gate on refs instead of commits: `if (gh.refs.findBy("repo_id", repo.id).length === 0) throw new ApiError(409, "Git Repository is empty.");` — matching GitHub's "no branches" definition of an empty repository. If adopted, apply the same predicate to the commits-list handler (commits.ts:99) via the shared helper so the two endpoints stay consistent.
2. packages/@emulators/github/src/routes/branches.ts:692-694 — Empty-repo 409 guard duplicated from commits.ts
   Detail: The new inline guard (branches.ts:692-694) is a verbatim copy of the existing block in the commits-list handler GET /repos/:owner/:repo/commits (packages/@emulators/github/src/routes/commits.ts:99-101): same predicate, same status code, same literal "Git Repository is empty." string. Two hand-maintained copies of the same rule across route files will drift (message wording or the emptiness definition), and the sibling matching-refs handler will need a third copy. The codebase already centralises route guards (notFoundResponse, assertRepoContentsRead) in route-helpers.ts, so this predicate belongs there too.
   Suggested fix: Add e.g. `export function assertRepoNotEmpty(gh: GitHubStore, repo: GitHubRepo): void { if (gh.commits.findBy("repo_id", repo.id).length === 0) throw new ApiError(409, "Git Repository is empty."); }` to packages/@emulators/github/src/route-helpers.ts and call it from both the /commits handler (commits.ts:99) and the /git/ref handler (branches.ts:692), deleting the duplicated inline copies (and reuse it in matching-refs when that gap is fixed).
3. packages/@emulators/github/src/routes/branches.ts:707-708 — matching-refs skips empty-repo 409 added for git/ref
   Detail: The PR adds the empty-repo 409 ("Git Repository is empty.") to GET /repos/:owner/:repo/git/ref/:ref, but the sibling ref-read endpoint GET /repos/:owner/:repo/git/matching-refs/:ref (branches.ts:701-714) still returns 200 with [] for a zero-commit repository. GitHub's REST docs for "List matching references" list 409 Conflict for exactly this case, and api.github.com answers 409 {"message":"Git Repository is empty."} — the same behaviour this PR emulates for the singular ref lookup. The PR's stated goal ("return 409 for ref lookups in an empty repository") is therefore only half-implemented: clients probing matching-refs to distinguish "empty repo" from "no matching refs" still get a divergent answer. Repro: Given the store contains octocat/empty-repo with zero commits; When the client calls GET /repos/octocat/empty-repo/git/matching-refs/heads/main with a valid token; Then the emulator responds 200 [] while real GitHub responds 409 "Git Repository is empty.".
   Suggested fix: Extract the empty-repo check into a shared helper (e.g. assertRepoNotEmpty(gh, repo) throwing new ApiError(409, "Git Repository is empty.")) and call it in the matching-refs handler right after assertRepoContentsRead, mirroring the git/ref handler. Add a test asserting GET /repos/octocat/empty-repo/git/matching-refs/heads returns 409 with the same message.
📋 Out-of-diff findings (3)
Sev Location Finding
🟡 packages/@emulators/github/src/routes/branches.ts:692-694 409 gate counts commits; GitHub empties have no refs
🟡 packages/@emulators/github/src/routes/branches.ts:692-694 Empty-repo 409 guard duplicated from commits.ts
🟠 packages/@emulators/github/src/routes/branches.ts:707-708 matching-refs skips empty-repo 409 added for git/ref

Reviewed 2 files · 0 inline · view all 3 findings ↗ · AI-generated — verify findings before acting · AI guidance and feedback


aictrl · AI code review for fast-moving teams · aictrl.dev

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant