Tandem Git: hosted repositories

Tandem Git is first-class git hosting inside the platform: private repositories with a completely standard git experience — an HTTPS clone URL and an access token — plus a server-side repository mutation API so agents (and the future web editor) can read and commit without cloning anything.

You never need GitHub to launch on Tandem, and developers you bring in later find a normal git host with nothing unusual about it.

This guide covers every surface Tandem Git ships today: creating repos, clone/push auth (incl. credential-helper setup), the mutation API, sharing, deploying from a Tandem repo, migrating a repository between GitHub and Tandem Git in either direction, and continuous Mirror to GitHub (a live, ongoing copy — as opposed to the one-time migrations). Git is infrastructure here, not the product: non-technical founders never need to know it exists, and the developers they hire later find a completely standard git host with nothing to complain about.

Create a repository

  • Portal: the project’s Code tab, or the source picker when creating a project.
  • MCP: create_git_repo{ name, organizationId?, projectId?, init? }.
  • HTTP: POST /api/orgs/:orgId/git/repos.

init modes:

  • template (default) — seeds a first commit with a minimal platform.yml (single node service), a README containing the clone URL and token instructions, and a .gitignore covering .env, node_modules/, and dist/. First push can deploy with zero extra config.
  • empty — a bare repository with no commits; push whatever you like.
  • Bringing in an existing repo’s history (rather than starting from a template) is “Import” — see below, not an init mode.

Repos are private. Names are per-org slugs (my-app, api.v2, …); renaming is safe at any time because storage is keyed by id, but the clone URL follows the name.

Clone, push, pull (quickstart)

git clone https://git.launchtandem.com/<org-slug>/<repo-name>.git

When git prompts:

  • Username: git (any value works; the token is what counts)
  • Password: a Tandem Git access token — or, for agents, your existing deployment token

Humans mint access tokens in the portal (Settings → Git access tokens) or via POST /api/git/tokens; the full token (tdm_git_…) is shown exactly once. Tokens authenticate as you — what each one can do to a repo is your live role + grants, so revoking a grant instantly affects every token. Agents don’t need a separate token type: the deployment token they already hold works as the git password, provided it carries the source:read / source:write actions (minted on by default for new tokens).

Everything standard works: branches, tags, force-push (non-default branches), shallow clones, credential helpers. Force-pushing or deleting the default branch requires repo admin capability. Archived repos stay clonable but reject all writes.

Add Tandem Git as a remote (existing project)

If you already have a local project (or an existing GitHub clone) and just want to start pushing it to Tandem Git, add it as a remote — no import job needed:

# In your existing repo:
git remote add tandem https://git.launchtandem.com/<org-slug>/<repo-name>.git
git push -u tandem main            # push your default branch
git push tandem --all              # (optional) push every branch
git push tandem --tags             # (optional) push tags

Keeping a repo on both hosts at once is fine — add tandem alongside origin and git push tandem when you want to update it. (For a managed, always-in-sync copy rather than manual pushes, use Mirror to GitHub below.) To make Tandem the deploy source once the code is up, wire it to a project (portal “Make this the deploy source”, or set_project_repository with tandemGitRepoId) — then pushes to the deploy branch auto-deploy.

Everyday git is just git

Tandem Git is a standard git server, so the day-to-day is plain git — for humans and agents. Create and switch branches, tag, merge, rebase, force-push (non-default), delete branches (git push tandem --delete <branch>), fetch, and pull with the git CLI (or Cursor / Claude Code / VS Code / Codex — anything that speaks git over HTTPS). The platform deliberately does not wrap those in portal buttons or MCP tools; git already does them well.

The portal and MCP surfaces exist only for the things that are not a git operation — platform orchestration around the repo:

  • create / list / share repos and mint access tokens,
  • wire a repo up as a project’s deploy source,
  • migrate in/out of GitHub and configure Mirror to GitHub,
  • and the server-side mutation API (below) for editing without a clone.

So an agent building an app clones/commits/pushes with git for code changes, and reaches for an MCP tool only to (say) create the repo, share it, or make it a project’s deploy source.

Creating a PAT

  1. Portal: Settings → Git access tokens → New token. Give it a name (and, optionally, an expiry) — the full value is shown exactly once, so copy it immediately.
  2. Or over HTTP/MCP: POST /api/git/tokens with { name, expiresAt? }. There is no MCP tool for minting a PAT — deliberately: an agent already authenticates with its deployment token, which works as the git password once it carries source:read/source:write. A separate agent-mintable PAT would just be a second credential doing the same job, so minting stays a human-portal (and plain-HTTP) action only.
  3. Use the token as the git password on every clone/push. Revoke it any time from the same card, or DELETE /api/git/tokens/:tokenId — revocation is immediate.

Credential helpers

Tokens are long, so let your git client remember them instead of retyping on every push:

  • macOS: git config --global credential.helper osxkeychain — the Keychain stores the token after the first prompt.
  • Linux: git config --global credential.helper libsecret (or store if libsecret isn’t packaged) — first push prompts once.
  • Windows: install Git Credential Manager (bundled with recent Git for Windows) — no extra config needed; the first clone/push pops a credential prompt, and GCM stores the token afterward. Enter git as the username and the PAT as the password when prompted.
  • Everywhere: git credential-cache works too for a shorter-lived, in-memory cache instead of persistent storage.

Whatever you use, treat the token like a password — it authenticates as the human or agent that minted it, with that principal’s live capabilities.

The hired-developer story

There is nothing to migrate. A developer you hire later gets a standard git remote and a token — full stop:

  • Standard git. git clone https://git.launchtandem.com/<org>/<repo>.git, username git, password a PAT you share (or they mint their own once you grant them access via share_git_repo / the Share dialog). Every normal git operation works: branches, tags, shallow clones, force-push on non-default branches, credential helpers.
  • A PAT, not an account they have to fight with. Sharing to an email with no Tandem account creates one and emails a setup link — they set a password, mint their own token, and clone. No GitHub invite flow, no waiting on org membership approval elsewhere.
  • Or export/mirror if they’d rather work on GitHub. “Export to GitHub” (below) hands them a one-time copy on their own turf, or enable “Mirror to GitHub” for a continuously synced read-only copy they can clone and PR against on GitHub while Tandem stays (or stops being) the source of truth.

Whichever path — standard git, PAT, export, or mirror — nothing about the app, its deploys, or its env vars changes. Deploys keep working exactly the same because the deploy source is a database link (repositories.tandem_repo_id), not a developer’s personal setup.

Repository mutation API

The HTTP surface for reading and writing without a clone — the same contract the MCP tools (read_git_repository, commit_git_changes) and the future in-portal editor use. Auth: portal session cookie or Authorization: Bearer <token> (access token or deployment token).

  • GET /api/orgs/:orgId/git/repos · POST /api/orgs/:orgId/git/repos
  • GET | PATCH | DELETE /api/git/repos/:id, POST …/archive, POST …/unarchive
  • GET | POST /api/git/repos/:id/branches, DELETE …/branches/<name>
  • GET …/commits?ref&path&limit&cursor — history (cursor = last sha of the previous page)
  • GET …/tree?ref&path&recursive — directory listing
  • GET | PUT | DELETE …/contents/<path> — single-file read/write sugar (per-file sha optimistic concurrency)
  • POST …/commits — the batch primitive: { branch, baseSha?, createBranchFrom?, message, operations: [{ op: put|delete|move, path, contentBase64?, toPath? }] } applied as one atomic commit; a stale baseSha answers 409 { currentSha } so you can rebase and retry. Requests are capped at 25 MiB.
  • GET | POST | DELETE /api/git/tokens — your own access tokens
  • GET | POST | DELETE /api/git/repos/:id/grants — sharing

Status discipline: 401 with no credential; 404 for anything you can’t read (no existence oracle); 403 only for writes you can see but aren’t allowed.

Access model & sharing

Per-repo capabilities are canRead / canWrite / canAdmin:

  • Org role (implicit): owner/admin → all three; developer → read+write; viewer → read.
  • Share grants (additive): Drive-style grants to any principal or email — share_git_repo over MCP, the share dialog in the portal, or the grants API. Sharing with an email that has no Tandem account creates one and emails a setup link — the “hired developer” path: they claim the account, mint a token, and clone.
  • Agent narrowing: agent credentials additionally need source:read / source:write / source:admin in their action mask, and project-scoped tokens only reach repos linked to that project.

Deploying from a Tandem repo

A project can be born with a Tandem Git repo as its deploy source, or an existing project can attach one later. Either way, once a repo is a project’s source, a push to the deploy branch (or a commit_git_changes / mutation-API commit) auto-deploys every service in the project — the exact same pipeline a GitHub-linked project uses (wave I2’s push→deploy dispatch), just with provider: 'tandem' on the repositories row instead of 'github'.

Launching a new project (zero GitHub):

  • Portal: the “New project” form’s source picker — Host code on Tandem is the default option. Pick “Start from a template” (recommended — seeds platform.yml so the first push deploys) or “Empty repository”, give the project a name, and create. The Code tab shows the clone URL and a Deploy source badge with the deploy branch.
  • MCP: create_git_repo with asProjectSource: { newProject: "<name>" } creates the repo AND the project in one call.
  • HTTP: POST /api/orgs/:orgId/projects-from-tandem-repo{ projectName, repoName?, description?, init?, deployBranch? }{ project, repo, seedCommitSha }.

Attaching a Tandem repo to an existing project:

  • Portal: the project’s Code tab — “Make this the deploy source” on a repo already linked to the project (organizational link), or create-and-attach from the empty state.
  • MCP: to point a project at an existing repo, set_project_repository with { projectId, tandemGitRepoId, deployBranch? } (source:write); to create a new repo and attach it in one call, create_git_repo with asProjectSource: { projectId: "<id>" }. Either way commit_git_changes/read_git_repository work against it immediately.
  • HTTP: POST /api/orgs/:orgId/projects/:projectId/attach-tandem-repo — body { gitRepoId } to attach an existing org repo, or { create: { name?, init? } } to create-then-attach. deployBranch optional in both. A repo that’s already another project’s deploy source answers 409 tandem_repo_already_source.

Deploy-branch semantics are unchanged from the GitHub path: no deployBranch on the project means “the repo’s default branch”; pushes to other branches are recorded as events but don’t auto-deploy. A repo serving as a deploy source can be neither archived nor deleted (see Limits & lifecycle below) — detach it from the project first (point the project at a different source) if you need to reclaim it.

Migrating between GitHub and Tandem Git

Moving in either direction is a one-time copy run by the platform (not a continuous sync — see “Mirror to GitHub” below for that). Both directions go through the same async job (git_mirror_jobs): the route creates the target repository row and enqueues a worker job immediately, then you poll for completion.

Import (migrate in)

Two sources:

  • An existing GitHub-connected repository — “Copy to Tandem”. The worker mints a fresh installation token, clones the source with git clone --mirror, size-gates it, and pushes into the new Tandem repo.
    • Portal: the project’s Source tab → “Copy to Tandem Git” (shown when the project’s source is GitHub).
    • MCP: import_repository with source: { githubRepositoryId }. Pass repointProject: true to switch the project(s) currently deploying from the GitHub repo onto the new Tandem repo once the copy finishes.
    • HTTP: POST /api/orgs/:orgId/repositories/:repositoryId/copy-to-tandem{ tandemRepoName?, repointProject? }202 { job, repo }.
  • Any public https:// git URL — not limited to GitHub. The worker clones anonymously (no credentials sent anywhere).
    • Portal: the project’s Source tab → “Import from URL”.
    • MCP: import_repository with source: { url } and a required tandemRepoName; optionally project: { name } to create a new project from it, or project: { id } to attach an existing one.
    • HTTP: POST /api/orgs/:orgId/repositories/import-from-url{ url, tandemRepoName, projectName?, projectId? }202 { job, repo, projectId }.

Both create the Tandem repo empty first (so it exists immediately, before the clone finishes), then the worker’s job carries it through cloningpushing progress phases — poll with get_source_status (MCP) or GET /api/orgs/:orgId/git-mirror-jobs/:jobId (HTTP). On success the new repo’s default branch is set to match the source’s.

Two things every import guarantees:

  • Pull-request refs never come along. GitHub’s refs/pull/* are read-only synthetic refs that only make sense inside GitHub — the worker pushes explicit +refs/heads/*:refs/heads/* and +refs/tags/*:refs/tags/* refspecs, deliberately NOT git push --mirror (which would drag every ref, refs/pull/* included).
  • URL imports are SSRF-guarded: https:// only, no credentials embedded in the URL, and the host (and everything it resolves to, closing the DNS-rebinding gap a hostname-only check would leave open) must not be loopback, RFC 1918 private, link-local, or a cloud metadata address.
  • Oversized sources are rejected before anything is pushed: the cloned mirror is measured on disk and compared against TANDEM_GIT_IMPORT_MAX_BYTES (2 GiB default) — a clear too_large-style error, not a half-imported repo.

Export (migrate out)

The GitHub App cannot create repositories for you. Installation tokens can push to and read any repo the App covers, but they cannot create a repo under a personal account, and creating one under an organization account would need the Administration: write permission — a consent-screen cost the App deliberately does not ask for. So the export wizard is honest about the one manual step it can’t remove:

  1. Create a new, empty repository yourself at github.com/new (or pick an empty one you already made).
  2. Portal: the repo’s Source tab → “Export to GitHub” → pick the installation → the wizard polls the installation’s repo list (GET /api/orgs/:orgId/repositories/exportable-github-targets?installationId=) until your new repo shows up, then you pick it. MCP: export_repository_to_github with repo, installationId, and githubRepoNodeId (from the listing above). HTTP: POST /api/orgs/:orgId/git/repos/:gitRepoId/export-to-github{ installationId, githubRepoNodeId, then? }202 { job }.
  3. The worker clones the Tandem repo over loopback, verifies the GitHub target is actually empty (refuses with a clear error otherwise — App tokens can’t overwrite history, so a non-empty target is always a mistake, not something to force through), then pushes refs/heads/* + refs/tags/*.
  4. Pass then: "repoint" to switch the repo’s project(s) onto the new GitHub repository once the push lands — because github_repo_node_id now matches, the existing GitHub push webhook picks up auto-deploy automatically, with no separate wiring step.

Poll the same way as an import: get_source_status / GET /api/orgs/:orgId/git-mirror-jobs/:jobId, progress phases cloningverifying_emptypushing.

Secrets never transit a migration

Env vars live in env_vars, not in git, on both sides of every migration (import or export) — there is nothing for an import/export job to leak even if it wanted to. Installation tokens and Tandem internal tokens are minted fresh per job attempt and never stored in git_mirror_jobs.params.

Mirror to GitHub (continuous)

Import and export above are one-time copies. “Mirror” keeps a Tandem repo and a GitHub repo continuously in sync — but strictly single-primary, never multi-master: exactly one side is the source of truth at a time, and the other is a read-only copy that gets force-updated to match it. Git cannot auto-resolve two sides that both took independent commits, so Tandem doesn’t pretend to; a project’s deploy source always points at exactly one side, the current primary.

Enable / manage: POST /api/orgs/:orgId/git/repos/:gitRepoId/mirror{ mode: "tandem_primary" | "github_primary", githubRepositoryId } (or { installationId, githubRepoNodeId } for a repo not yet connected) → 201 { link }. Then POST .../repo-links/:linkId/pause|resume|switch-primary, DELETE .../repo-links/:linkId (stops mirroring; both repos stay intact), GET .../repo-links[/:linkId] for status. MCP: configure_git_mirror (action: enable|pause|resume|disable|switch_primary); get_source_status reports a mirrored repo’s link mode/status/lag/last error alongside its migrate-job history. Portal: the Source tab’s “Mirror to GitHub” card.

How it runs: each mirror link gets one persistent cached bare mirror (two remotes: the Tandem loopback, and the GitHub clone_url) so steady state is an incremental fetch, not a re-clone. A push to the primary side (a real git push, the mutation API, or MCP commit_git_changes on the Tandem side; any GitHub push webhook on the GitHub side) enqueues a debounced sync_push/sync_fetch job (rapid pushes coalesce into one job); a 15-minute sweeper (TANDEM_GIT_SYNC_MAX_LAG_MINUTES) re-enqueues anything that fell behind (a missed webhook, a worker restart) and retries a broken link with backoff.

Commits are never lost. Before force-updating the read-only side, the engine fetches its current refs; anything that diverged or would be pruned has its old sha backed up to refs/tandem/backup/<timestamp>/<ref> in the Tandem bare repo — regardless of which side is primary, Tandem is always the durable store for these. Backup refs are real refs, so git gc never prunes their objects (see bin/tandem-git-maint.sh); a 90-day-default retention sweep (TANDEM_GIT_BACKUP_REF_RETENTION_DAYS) deletes the stale REF (freeing the object for the next gc) — recover a lost commit with git fetch origin 'refs/tandem/backup/*:refs/tandem/backup/*' against the Tandem clone URL while it’s still within the window. The portal surfaces a diverged-ref warning whenever this has happened.

github_primary locks the Tandem side. When GitHub is primary, the Tandem repo’s receive_policy flips to mirror_locked: only the mirror engine’s own internal scope: 'sync' credential may push it — a normal human/agent push (even with write access) is rejected with a message pointing at the GitHub primary. switch_primary runs one final sync in the current direction, then atomically flips mode, repoints any project deploying from the outgoing primary onto the new one, and flips receive_policy back to read_write if Tandem becomes primary.

Protected-branch caveat: if sync_push’s push to GitHub hits a branch protection rule there, the link goes broken with broken_reason: 'push_rejected' — GitHub branch protection and Tandem mirroring are not compatible on the mirrored branch; relax the rule or keep that branch off the mirror.

App-uninstalled resilience. If the GitHub App installation backing a link is removed, every affected link flips to broken with broken_reason: 'installation_removed' (installation.deleted / meta.deleted webhooks). For a github_primary link this is a real selling point, not just an error state: the portal offers one-click “Switch primary to Tandem” — the Tandem mirror is current as of its last successful sync, so the founder keeps deploying with zero GitHub dependence. (switch_primary on an already-broken link skips the final-sync requirement — by definition it can’t run — and accepts the last known-good state.)

Secrets never transit the mirror, same as import/export: env vars live in env_vars, never in git, on either side.

Limits & lifecycle

  • Soft size quota 1 GiB per repo, raised only by a platform-admin override (POST /api/admin/git/repos/:repoId/size-limit — a “contact support to raise the limit” mechanism with no tenant portal/MCP surface by design); pushes are refused once a repo exceeds 2× its effective quota until it shrinks.
  • Archive = read-only (clone/fetch fine, all writes rejected). Delete = archive + 30-day grace, then disk purge. A repo serving as a project’s deploy source can be neither archived nor deleted.
  • Repos, collaborators, tokens, and commits are free.

Not in v1

Deliberately out of scope for this release — all standard git operations you’d expect still work, these are the platform-level features that don’t exist yet. Where each of these is headed is tracked in the Tandem Git roadmap (the in-portal web editor and native pull-request/code-review — including one-click agent reviewers — each have full plans there):

  • SSH. HTTPS + token auth only. The schema leaves room for SSH keys as a fast-follow; there’s no timeline yet.
  • Pull requests / code review. Revisit in v2. Today’s review workflow is: push a branch, review the diff on GitHub via export/mirror, or read it through the mutation API / portal file viewer.
  • Issues. Not a Tandem Git feature — use open_support_ticket / the portal’s support flow (see the MCP tool reference), or GitHub Issues on a mirrored/exported copy.
  • Git LFS. Not supported; large binary assets count against the repo’s size quota like anything else.
  • Public repositories. Every Tandem Git repo is private; is_public is a possible one-column follow-up, not built.
  • Archive / zip download. Clone the repo (or a specific ref) instead.

Operations (platform operators)

Repos are bare git directories under TANDEM_GIT_ROOT (prod: the dedicated /srv/git volume), served by platform-git.service (:8098; beta platform-git-beta.service, :8099) — the plan’s original 8096/8097 pair turned out to already be taken on vorson by the beta IdP and the second prod IdP instance, so the ship wave (G3) moved git to the next free pair; if you’re standing up a new host, re-check for the same clash before assuming 8096/8097 are free. Weekly maintenance (tandem-git-maint.timer: gc, fsck sampling, size accounting, purge of grace-expired deletes, git.repo_bytes usage snapshots) and nightly backups (tandem-git-backup.timer: per-repo git bundle --all + manifest, 7-day local / 30-day off-host retention, restore = git clone <uuid>.bundle) run on prod only. Bring-up: docs/guides/new-host-bringup.md § “Tandem Git”; first rollout sequencing: docs/runbooks/tandem-git-rollout.md.