# Game repos

> Where each Moonspell game's git repo lives, the one interface in front of the two hosts (GitHub and Cloudflare Artifacts), and how a deployment moves games between them.

Every game has one private git repo. The builder's sandbox clones it, the workflow pushes each run's commit, community drafts live on `community/<userId>`, a merge lands on the public branch with `git merge --no-ff`, and [local developer mode](https://docs.moonspell.app/moonspell/local-dev-mode.md) hands a developer's machine an hour of write access.

Two hosts can hold them, behind one interface (`src/lib/game-repo/`):

| Host | Where | Remote | Used for |
| --- | --- | --- | --- |
| `github` | A GitHub App on `GAMES_GITHUB_ORG` | `https://github.com/<org>/<name>.git`, username `x-access-token` | The default. |
| `artifacts` | Cloudflare Artifacts, namespace `GAMES_ARTIFACTS_NAMESPACE`, through the edge Worker's `ARTIFACTS` binding | `https://<account>.artifacts.cloudflare.net/git/<namespace>/<name>.git`, username `x` | Games made while `GAMES_REPO_HOST=artifacts`, and games moved by `scripts/move-repos-to-artifacts.mts`. |

The Artifacts host is built and tested against Cloudflare's local simulator. Artifacts is in open beta on the Workers Paid plan; check its [limits](https://developers.cloudflare.com/artifacts/platform/limits/) (per file, per push and per repo) and [pricing](https://developers.cloudflare.com/artifacts/platform/pricing/) before you switch a deployment to it.

## How it works

`src/lib/game-repo/index.ts` is the only way in:

- `ensureRepo(game)`: creates the repo (private, empty) on first need and saves `games.repo` and, for Artifacts, `games.repo_host`. `createRepo` and `saveRepo` split that for scripts that push before saving.
- `readAccess(game)`, `writeAccess(game)`: `{remoteUrl, username, password, expiresAt}`, valid for an hour, for this repo only. Local developer mode's repo-token route returns this (plus `branch: "main"`), so the CLI never knows the host.
- `moveBranch(game, sha, branch?)`, `readFile(game, path, ref)`, `commitExists(game, sha)`.
- `plannedRepo(game)`: the repo's name before it exists (a run's Cloudflare gateway token is scoped by it).

Which host: a game with a repo stays on `games.repo_host` (missing means `github`). A game without a repo gets `GAMES_REPO_HOST` (`github` when unset, or `artifacts`).

`games.repo` is `<owner>/<name>` on both hosts: the GitHub org or the Artifacts namespace, then the same `cm-<slug>-<id6>`. The Cloudflare gateway scopes a game's backend by the name only, so moving a repo does not move its Cloudflare resources.

The run script (`runScript`, `src/lib/daytona.ts`) clones with the password in `REPO_TOKEN`, through git's credential helper, and unsets it before Claude Code starts. `pushRunCommit` takes a `GitAccess`.

### GitHub

`src/lib/game-repo/github.ts`: GitHub App calls on `fetch`, with a JWT signed in Node, so scripts run under `tsx` use the same code. One-repo installation tokens, `contents: read` or `write`, for an hour.

### Artifacts

`src/lib/game-repo/artifacts.ts` calls the edge Worker, which holds the binding (`edge/src/repos.ts`):

```text
POST /internal/repos/ensure   {name, description?}   -> {name, remote, defaultBranch, created}
POST /internal/repos/info     {name}                 -> {name, remote, defaultBranch, lastPushAt}
POST /internal/repos/token    {name, scope, ttl?}    -> {remote, token, scope, expiresAt}
POST /internal/repos/file     {name, ref, path}      -> the file's bytes, or 404
POST /internal/repos/commit   {name, sha}            -> {exists}
Authorization: Bearer <INTERNAL_API_SECRET>
```

- The binding is used instead of Artifacts' REST API so that the server holds no Cloudflare API token that can reach every namespace, the binding is limited to one namespace, and the local simulator tests the whole path. Switching to REST would be another `RepoHost` in `artifacts.ts`.
- `ensure` revokes the day-long write token that creating a repo returns.
- Tokens last an hour by default.
- `moveBranch` mints a five-minute write token and sends git's own ref update over smart HTTP (`src/lib/game-repo/smart-http.ts`), the same request `git push --force <remote> <sha>:<branch>` makes, because the server has no `git` binary.
- No route deletes a repo.

## Forks

A fork is a player's own copy of someone else's game, used by [local developer mode](https://docs.moonspell.app/moonspell/local-dev-mode.md#someone-elses-game). The host interface covers it:

| Function | What it does |
| --- | --- |
| `forkRepoName(game, userId)` | The fork's name: `<repo name>-fork-<12 hex>` |
| `createFork(game, userId, seed)` | On Artifacts, the binding's `fork` with the default branch only. On GitHub, a new repo seeded by a copy, since GitHub cannot fork a repo into the organization that owns it |
| `copyCommit(from, to, sha, branch, {expected})` | Copies a commit from one repo onto another's branch over git smart HTTP, as a compare-and-swap, with no git binary |
| `branchHead(game, branch)` | The branch's current commit |
| `isAncestor(game, a, b)` | Whether one commit is in another's history. GitHub: the compare API. Artifacts: the edge walks every parent, at most 3,000 commits |

On Artifacts the server reaches these through two more internal edge routes:

```text
POST /internal/repos/ancestor {name, ancestor, sha}  -> {ancestor, complete}
POST /internal/repos/fork     {name, target, description?} -> {name, remote, defaultBranch, created}
```

## Tests

`npm test` runs `src/lib/game-repo/game-repo.test.ts`: the edge Worker on Miniflare with Cloudflare's local Artifacts simulator, and the server's Artifacts host calling it. It covers creating a repo twice, pushing with the run script's own push command (a 12 MB model included), cloning with a read token and failing to push with it, a draft branch, a forced push, a merge onto the public branch, reading a file, `commitExists`, `moveBranch`, the move script's mirror, and the edge refusing bad requests. The GitHub host is tested with GitHub's API stubbed. The Artifacts tests need `cd edge && npm install`; without it they are skipped.

## Moving existing games

`scripts/move-repos-to-artifacts.mts --game <id> | --all [--dry-run]`. For each game on GitHub it makes `<namespace>/<same name>`, mirrors every branch and tag, checks that the public head and every version's `commit_sha` are on the new repo and that nothing moved meanwhile, then sets `games.repo` and `games.repo_host = 'artifacts'`. A game with a run in progress is skipped. It is safe to run again, and the GitHub repo is left in place.

A developer working on a moved game in local developer mode gets the new remote from their next repo token.

## Switching a deployment to Artifacts

1. Pick a Cloudflare account on Workers Paid and a namespace name.
2. In `edge/wrangler.jsonc`, enable the `artifacts` binding with that namespace, run `npx wrangler types`, and deploy the edge with `INTERNAL_API_SECRET` set to the server's value.
3. Apply `db/migrations/0016_game_repo_host.sql` (`npm run db:migrate`). It is additive.
4. Set `GAMES_ARTIFACTS_NAMESPACE` on the server, with `CRESCENT_EDGE_URL` and `INTERNAL_API_SECRET`, and deploy. Nothing changes yet.
5. Move games: a dry run, then one game, then `--all`.
6. Set `GAMES_REPO_HOST=artifacts` so new games are made there.

To go back, unset `GAMES_REPO_HOST`: new games go to GitHub again. Moved games stay on Artifacts unless their `games.repo` and `repo_host` are set back by hand; their GitHub repos still have everything up to the move.
