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 minimalplatform.yml(single node service), a README containing the clone URL and token instructions, and a.gitignorecovering.env,node_modules/, anddist/. 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
initmode.
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
- 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.
- Or over HTTP/MCP:
POST /api/git/tokenswith{ 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 carriessource: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. - 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(orstoreif 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
gitas the username and the PAT as the password when prompted. - Everywhere:
git credential-cacheworks 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, usernamegit, password a PAT you share (or they mint their own once you grant them access viashare_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/reposGET | PATCH | DELETE /api/git/repos/:id,POST …/archive,POST …/unarchiveGET | 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 listingGET | PUT | DELETE …/contents/<path>— single-file read/write sugar (per-fileshaoptimistic concurrency)POST …/commits— the batch primitive:{ branch, baseSha?, createBranchFrom?, message, operations: [{ op: put|delete|move, path, contentBase64?, toPath? }] }applied as one atomic commit; a stalebaseShaanswers409 { currentSha }so you can rebase and retry. Requests are capped at 25 MiB.GET | POST | DELETE /api/git/tokens— your own access tokensGET | 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_repoover 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:adminin 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.ymlso 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_repowithasProjectSource: { 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_repositorywith{ projectId, tandemGitRepoId, deployBranch? }(source:write); to create a new repo and attach it in one call,create_git_repowithasProjectSource: { projectId: "<id>" }. Either waycommit_git_changes/read_git_repositorywork 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.deployBranchoptional in both. A repo that’s already another project’s deploy source answers409 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_repositorywithsource: { githubRepositoryId }. PassrepointProject: trueto 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_repositorywithsource: { url }and a requiredtandemRepoName; optionallyproject: { name }to create a new project from it, orproject: { 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
cloning → pushing 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 NOTgit 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 cleartoo_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:
- Create a new, empty repository yourself at
github.com/new(or pick an empty one you already made). - 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_githubwithrepo,installationId, andgithubRepoNodeId(from the listing above). HTTP:POST /api/orgs/:orgId/git/repos/:gitRepoId/export-to-github—{ installationId, githubRepoNodeId, then? }→202 { job }. - 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/*. - Pass
then: "repoint"to switch the repo’s project(s) onto the new GitHub repository once the push lands — becausegithub_repo_node_idnow 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 cloning →
verifying_empty → pushing.
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_publicis 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.