Simulators
Git repositories
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:
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.
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 has the rules and the refusal text. A merge queue’s group branches take no push at all: see Merge queues.
What a push may carry
The simulator refuses a push whole when its pack carries:
- a tree with a
.gitentry in any spelling, a symlinked.gitmodules, one name twice, entries out of git’s order or an entry naming the all-zero id; - a
.gitmodulesor.gitattributesentry, at any mode but a symlink, naming anything but a blob; - a
.gitattributesentry, at any mode but a symlink, naming nothing; - a
.gitmoduleswhose submodule url, path or update setting would run as an option or a command, or whose submodule name climbs out of the repository; - a
.gitattributeswith 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.
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:
- Refs.
refs/heads/*holds the 21 branches ofgarrytan/gbrainitself; the other 266 branch rows are fork branches (179 forks share names), so a fork head is reachable by itsrefs/pull/N/headonly, exactly as on GitHub. - Patches.
files[].patchis 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 matchesgit diff -U3 --inter-hunk-context=1of the pack. - Commits.
state_commitholds 1,395 commits the API named; 136 of them are earlier heads no advertised ref reaches and are not in the pack, sogit fetchcannot reach them and the API still lists them. - Writes. A push or a merge lands loose objects beside the pack; the pack is never rewritten.
POST /_rystic/seedof the same JSON at run time serves the API without the pack, so there is no git endpoint that way —--scenariois the way in. - Actors.
collaboratorsholdsgarrytan(the owner andmember) andactor_b’s login. It also holds each login GitHub labelsOWNER,MEMBERorCOLLABORATOR. Every other user has read access, andGET /repos/garrytan/gbrainreturns theirpermissionsto match. - 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
commentscount 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.