gitcabin

a small self-hosted github clone

gitcabin

a tiny self-hosted GitHub clone driven by the official gh CLI, with all metadata stored in git itself — no separate database.

§ 01

Concept

  • gh already speaks GitHub-flavored REST + GraphQL. Point it at gitcabin via the cab wrapper and it doesn't know it's not talking to github.com.
  • Issues, PRs, comments, and counters live in side refs of the bare git repo (refs/issues/*, refs/prs/*, refs/meta/*). Code lives in normal refs/heads/* and refs/tags/*. The two namespaces never collide.
  • The HTTP API server is the only writer of metadata refs. Plain git clone/git push only see code.
  • No database. No background workers. The bare repo on disk is the canonical store; if you can read it with git, you can recover everything.
§ 02

Quickstart

One unprivileged port. No /etc/hosts edits. No port-80 conflict.

docker compose up --watch

Compose builds the image, runs a single gitcabin service bound to 127.0.0.1:18080, streams its access log, and reloads on every source edit. The container fronts both the REST/GraphQL API and the HTML dashboard via Host-header dispatch — cab/gh traffic (Host: api.github.localhost) hits the API; browser traffic hits the dashboard.

The cab wrapper

cab points gh's HTTP traffic at gitcabin and registers the host on first use. Build the binary, or alias the docker image — both invocations are interchangeable:

# build the Go binary (host-side)
cd cab && go build -o /usr/local/bin/cab . && cd ..
# or alias the docker image (no Go toolchain needed)
docker buildx build --platform linux/amd64,linux/arm64 -t alltuner/cab:dev cab/
alias cab='docker run --rm --network gitcabin_default \
  -v "$HOME/.config/gh:/home/cab/.config/gh" \
  alltuner/cab:dev'

The very first cab command auto-registers github.localhost with gh (writes a placeholder token to ~/.config/gh/hosts.yml — gitcabin doesn't verify tokens, anyone who can reach the port is the owner) and then runs whatever you asked. Internally cab sets HTTP_PROXY to gitcabin's unprivileged port and GH_HOST to github.localhost, then execs gh. gh honors HTTP_PROXY for http://... URLs (its calls to real github.com over HTTPS are unaffected).

Stop the stack with docker compose down. See docs/cab.md for the design.

§ 03

Working with issues

A new repo needs a one-time bare-repo init on disk because gh validates the repo exists before sending mutations:

cab repo init me/cabin

After that, the rest is plain gh:

# create
cab issue create -R me/cabin --title "First issue" --body "Try things out"

# list — state filters work; ordering options are accepted but ignored
cab issue list  -R me/cabin
cab issue list  -R me/cabin --state closed

# view, optionally with comments
cab issue view 1 -R me/cabin
cab issue view 1 -R me/cabin --comments

# edit your own issue — title or body, separately or together
cab issue edit 1 -R me/cabin --title "Renamed"
cab issue edit 1 -R me/cabin --body  "Updated body"

# close. The reopen mutation isn't exposed over GraphQL yet (the
# dashboard reopens via its own POST endpoint — see /issues/<n>/reopen).
cab issue close 1 -R me/cabin

# comment
cab issue comment 1 -R me/cabin --body "A reply"

Comment edit + delete work via raw GraphQL today (updateIssueComment / deleteIssueComment); cab issue comment --edit-last and --delete are the friendlier wrappers as soon as gh's bundled version supports them.

§ 04

Who can change what

Same rules GitHub uses, enforced by the API:

Action viewer == author viewer ≠ author
Edit issue title / body yes no, never — even ADMIN. Editing someone else's words is impersonation.
Close / reopen issue yes only with TRIAGE / WRITE / MAINTAIN / ADMIN role
Edit comment body yes no, never — same rule
Delete comment yes only with ADMIN (moderation)

Checks fire in the API layer, so they hold whether you go through gh, raw GraphQL, or the dashboard. The GraphQL types also expose viewerCanUpdate, viewerCanCloseOrReopen, viewerCanDelete so a UI can hide affordances ahead of time.

For repos that have never been linked to a GitHub upstream, the viewer is implicitly ADMIN — you own the bare repo on your disk. For linked repos, the role is the one cached in the sync config.

§ 05

Browsing the data

The dashboard lives at the same port as the API, routed by Host header — browsers hit the dashboard, cab/gh hits the API:

open http://localhost:18080/

It reads the same bare repos and surfaces owners, repo trees with last-touched commit per file, blame, commits grouped by day, side-by-side diffs, branches, tags, issues, and the issue thread itself. Code refs and metadata refs are presented separately.

§ 06

Mirroring a GitHub repo

gitcabin can pull issues, PRs, and comments from a real GitHub repository, and push back local-only issues you drafted in gitcabin. The sync subsystem is opt-in per repo.

Identity check first — gitcabin's viewer_login must match the GitHub login gh is authenticated as. Mismatch surfaces a hint:

gitcabin sync identity
# gitcabin viewer_login: david
# github.com gh login:   davidpoblador
# these differ. set GITCABIN_VIEWER_LOGIN to the gh value,
# or pass --login on `gitcabin sync link` to override per repo.

Link a local repo to its GitHub counterpart. The role (READ / TRIAGE / WRITE / MAINTAIN / ADMIN) is fetched from GitHub automatically; pass --role to override:

gitcabin sync link me/cabin --gh alice/cabin
# linked me/cabin -> alice/cabin (role=ADMIN, login=davidpoblador)

Linking writes a sync config to refs/meta/sync inside the local bare repo.

Pull from GitHub. Pulls issues into refs/issues/<gh-number>, PRs into refs/prs/<gh-number>, and comments under each ref's comments/ subtree. Re-pulls overwrite — GitHub wins when there's a conflict:

gitcabin sync pull me/cabin
# pulled 12 issues, 3 PRs, 47 comments

Push local-only issues to GitHub. Walks refs/issues/local/*, posts each, gets back the upstream number, and renumbers the ref to match. The local ref is dropped only after the new synced ref is fully populated. Each upstream side effect lands in refs/meta/sync-pending before the next, so a crash mid-push resumes without double-publishing:

gitcabin sync push me/cabin
# pushed 1 issues

After push, the issue's provenance becomes SYNCED_BIDIR and its author is rewritten to the gh-side login.

Push then pull in one shot — useful because re-pull is GitHub-wins, so pulling on its own can clobber local-only drafts that were never pushed. --push-only / --pull-only short-circuit either side:

gitcabin sync sync me/cabin
# pushed 0 issues
# pulled 12 issues, 3 PRs, 47 comments

Run sync inside the container. gh ships in the runtime image, and the host's ~/.config/gh is bind-mounted read-only. On macOS the token lives in Keychain (not hosts.yml), so pass it through with -e GH_TOKEN:

docker compose exec -e GH_TOKEN=$(gh auth token --hostname github.com) \
  gitcabin gitcabin sync sync me/cabin

Caveats worth knowing: pull is still GitHub-wins for *edits* to already-synced items (closing a synced issue locally gets clobbered next pull); cross-fork PR push (head_ref="other:branch") still needs the manual git push workflow because gitcabin has no remote for someone else's fork; PR push isn't crash-safe yet (the issue path is, the PR path isn't wired up). Full design and outstanding gaps in docs/github-sync.md.

§ 07

Other deployment modes

One shipping mode today: local-only HTTP via cab (the quickstart above). The current implementation is solid enough that we're prioritizing iteration speed over deployment-mode breadth — multi-device access via Tailscale is documented as a deferred design, not yet built.

The discussion behind the single-mode decision — including options ruled out (per-machine local CA, public/team-with-own-domain, DuckDNS, shared-wildcard-cert) and the deferred Tailnet-shared mode — lives in docs/tls.md.

§ 08

Running natively (no Docker, no gh)

uv run gitcabin

Listens on 127.0.0.1:8000. Useful for direct probing with curl / httpie, but gh won't reach it — gh dials port 80 (github.localhost) or 443 (anything else), never 8000.

§ 09

Development

uv sync                                    # install deps + editable gitcabin
uv run pytest                              # tests
uv run ruff check . && uv run ruff format --check .