# Git repositories

A real git repository per scenario on the simulator's own port: clone, fetch, push and test merges, all with git's own shas.

Every scenario that holds a repository serves it as a real git repository on the simulator's own port, so `git clone`, `git fetch` and `git push` work and the shas match the API's. Clone the default scenario's repository, fetch a pull request's head and its test merge, push a branch and read it back through the API:

```bash
H='Authorization: Bearer ghp_anything'
R="$RYSTIC_URL/repos/tb-owner/tb-repo"
git clone -q "$RYSTIC_URL/tb-owner/tb-repo.git" repo
git -C repo fetch -q origin refs/pull/41/head
git -C repo rev-parse FETCH_HEAD
# -> 517a4bbdfb525691b11db3e28f466eb3d7598d19
curl -s "$R/pulls/41" -H "$H" | jq -r .head.sha
# -> 517a4bbdfb525691b11db3e28f466eb3d7598d19
git -C repo fetch -q origin refs/pull/41/merge
git -C repo rev-parse FETCH_HEAD
# -> f5194252d073b36611b628cb9196d0b169cc5acd
curl -s "$R/pulls/41" -H "$H" | jq -r .merge_commit_sha
# -> f5194252d073b36611b628cb9196d0b169cc5acd
git -C repo checkout -q -b tb-new-work FETCH_HEAD
echo hi > repo/hello.txt
git -C repo add hello.txt
git -C repo -c user.name=Dev -c user.email=dev@example.com commit -q -m "add hello"
git -C repo push -q origin tb-new-work
curl -s -X POST "$R/pulls" -H "$H" -d '{"title":"Add hello","head":"tb-new-work","base":"tb-base-branch"}' | jq -c '{number, state}'
# -> {"number":45,"state":"open"}
curl -s "$R/branches/tb-new-work" -H "$H" | jq -c '{name, protected}'
# -> {"name":"tb-new-work","protected":false}
curl -s "$R/contents/hello.txt?ref=tb-new-work" -H "$H" | jq -c '{name, size}'
# -> {"name":"hello.txt","size":3}
```

Committing with no clone and reading a commit's verification are on [Commits through the API](https://www.rystic.ai/docs/simulators/github-pulls/features/commits.md).

### Pushing

`git push` to an open pull request's head branch moves the pull request: its head sha, `commits` and `files` follow, and a `synchronize` notification goes out. Pushing a new branch creates it.

A push to a branch of a fork the scenario holds moves the upstream pull request whose head it is. Its files read there, and the upstream serves its `refs/pull/{n}/head` to a fetch. Its test merge is against the upstream's base branch. The `synchronize` names the upstream, so a hook there receives it. Deleting the fork's branch closes the pull request in the upstream's timeline.

Deleting a branch changes the open pull requests that use it:

| Branch deleted | Result |
|---|---|
| An open pull request's head | The pull request closes, logging `head_ref_deleted` and `closed`; reopening it returns `422` |
| The base of open pull requests, and a merged pull request's head | They retarget onto that merged pull request's base |
| Any other base of open pull requests | They close |

A protected branch refuses a push that breaks its rules: [Protected pushes](https://www.rystic.ai/docs/simulators/github-pulls/features/protected-pushes.md) has the rules and the refusal text. A merge queue's group branches take no push at all: see [Merge queues](https://www.rystic.ai/docs/simulators/github-pulls/features/merge-queue.md).

### What a push may carry

The simulator refuses a push whole when its pack carries:

- a tree with a `.git` entry in any spelling, a symlinked `.gitmodules`, one name twice, entries out of git's order or an entry naming the all-zero id;
- a `.gitmodules` or `.gitattributes` entry, at any mode but a symlink, naming anything but a blob;
- a `.gitattributes` entry, at any mode but a symlink, naming nothing;
- a `.gitmodules` whose submodule url, path or update setting would run as an option or a command, or whose submodule name climbs out of the repository;
- a `.gitattributes` with a line of 2048 bytes or more;
- a commit or annotated tag whose author, committer or tagger line git refuses;
- a commit whose header lines are not laid out as git lays them: a tree line first, then parent, author and committer lines, each ended by a newline.

The `.gitmodules` and `.gitattributes` checks read the object an entry names, whether the push brought it or the repository already held it. The report reads `unpack index-pack failed` and every ref command `failed`, and nothing is stored. A corrupt pack gets the same report.

**Differs from GitHub:** a push lands even when an object in its pack names one neither the push nor the repository holds, or names one of the wrong type. That covers a commit's tree or parent, a tag's object and every tree entry but a submodule and those the list above refuses. An annotated tag too short for git to read lands too. GitHub refuses each of these whole.

These land, as GitHub lets them through: a malformed timezone, a zero-padded mode, a header line after a commit's committer and a commit with no blank line after its headers. Every pushed object is served back byte for byte, so a fresh clone finds it under its pushed name.

### Merging

A merge through the API moves the base branch: `git fetch` afterwards finds the commit the merge response named.

`refs/pull/{n}/merge` is the pull request's test merge: its head merged into the base branch's current tip. It is rebuilt as either moves, and an open pull request's `merge_commit_sha` names it. A head that conflicts with its base has no test merge and reads `mergeable: false` and `mergeable_state: "dirty"`. Merging it returns `405`. A merge or squash lands the test merge's tree.

Merging is git's, path by path and line by line. `rebase` replays each commit's tree onto the base as git cherry-picks it, by the same rules.

**Differs from GitHub:** merging detects no renames, so a file one side renames and the other edits conflicts, where git may merge it cleanly.

**Differs from GitHub:** any change inside the span of a rewrite past 4,096 changed lines conflicts, where git may merge it cleanly.

**Differs from GitHub:** a rebase whose replay of a commit conflicts lands that commit's changes as an overwrite of the base, where GitHub refuses the rebase.

### Diffs

A pull request's `files`, `changed_files`, `additions` and `deletions` are GitHub's three-dot diff: its head against its merge base with the base branch. `files[].patch` is the diff of the pull request's own commits.

### The imported repository

`github-pulls_garrytan_gbrain` is not authored: it is imported from api.github.com, the repository's pull-request surface, with a clone of the repository, and the scenario carries both. Only the branches and pull refs the scenario advertises are reachable. The API side is the records GitHub served — every pull request's head and base sha, its `files[].patch`, its check runs, its reviewers — with only the hosts in URL fields rewritten. The git side is a real packfile beside the scenario file, served over the dumb HTTP protocol: a clone downloads it whole, and every sha the API names is an object in it.

```sh
rystic stop default 2>/dev/null
export RYSTIC_URL=$(rystic run github-pulls-twin -d --port 0 --scenario github-pulls_garrytan_gbrain --json | jq -r .data.url)
H='Authorization: Bearer ghp_anything'
git clone $RYSTIC_URL/garrytan/gbrain.git                       # ~2 s, one 85 MB pack
curl -s "$RYSTIC_URL/repos/garrytan/gbrain/pulls?state=open&per_page=3" -H "$H" | jq -r '.[] | "\(.number) \(.head.sha[:8]) \(.title)"'
git -C gbrain fetch origin refs/pull/5149/head && git -C gbrain rev-parse FETCH_HEAD   # the API's head.sha
curl -s $RYSTIC_URL/repos/garrytan/gbrain/pulls/5149/files -H "$H" | jq -r '.[].filename'  # GitHub's diffs, git's content
```

What differs from an authored scenario:

1. **Refs.** `refs/heads/*` holds the 21 branches of `garrytan/gbrain` itself; the other 266 branch rows are fork branches (179 forks share names), so a fork head is reachable by its `refs/pull/N/head` only, exactly as on GitHub.
2. **Patches.** `files[].patch` is what GitHub sent: its three-line-context diff against the merge base, absent for the 49 files GitHub omitted it for (large or binary). Every sent patch matches `git diff -U3 --inter-hunk-context=1` of the pack.
3. **Commits.** `state_commit` holds 1,395 commits the API named; 136 of them are earlier heads no advertised ref reaches and are not in the pack, so `git fetch` cannot reach them and the API still lists them.
4. **Writes.** A push or a merge lands loose objects beside the pack; the pack is never rewritten. `POST /_rystic/seed` of the same JSON at run time serves the API without the pack, so there is no git endpoint that way — `--scenario` is the way in.
5. **Actors.** `collaborators` holds `garrytan` (the owner and `member`) and `actor_b`'s login. It also holds each login GitHub labels `OWNER`, `MEMBER` or `COLLABORATOR`. Every other user has read access, and `GET /repos/garrytan/gbrain` returns their `permissions` to match.
6. **Conversation.** A pull request's conversation comments and issue events are the ones GitHub served before the scenario's clock and no later than the pull request's own record. So its `comments` count is its comment list's length, and no open pull request logs a merge. Its timeline is derived from those comments and events, its reviews and its commits.

The scenario file is compressed in the binary and inflated at load, which is why booting it takes about two seconds and 600 MB.

---

Source: https://www.rystic.ai/docs/simulators/github-pulls/features/git · Markdown: https://www.rystic.ai/docs/simulators/github-pulls/features/git.md
