Private git

Your host stores your code.
It never gets to read it.

secretree keeps a repository in plaintext only on machines that hold a key. Everything that leaves them is ciphertext: history, branches, commit messages, pull requests, review comments, CI logs. GitHub, GitLab or a folder on a NAS becomes a blind, durable blob store. And you keep git push, pull requests, reviews and CI, because none of those needs a server that can read the code.

What the host seesgit ls-tree on the vault, real output
1945README.md
vault.json                     public keys only
1038repos/02ed7a372737891b/000001.bundle.age
951repos/02ed7a372737891b/000001.manifest.age
306repos/02ed7a372737891b/000001.manifest.age.sig
445repos/02ed7a372737891b/000002.bundle.age
1098repos/02ed7a372737891b/000002.manifest.age
306repos/02ed7a372737891b/000002.manifest.age.sig
2649repos/02ed7a372737891b/000004.bundle.age
1161repos/02ed7a372737891b/000004.manifest.age

commits: "backup", "backup", "backup"
branches: none. messages: none. names: none.
What you seesecretree ui, same vault, with a key
// src/retry.go  ·  feature/retry-logic  ·  PR #1 ✓ ci ✓ 1 approval
package main

import "time"

// retry calls fn up to n times, sleeping between attempts.
func retry(n int, delay time.Duration, fn func() error) error {
	var err error
	for i := 0; i < n; i++ {
		if err = fn(); err == nil {
			return nil
		}
		time.Sleep(delay)
	}
	return err
}
go install github.com/andraspalinkas/secretree/cmd/secretree@latest Or download a binary
How it works

The same workflow, one layer down.

Every step below is real output from a demo repository backed by a vault in a plain directory. The screens are the actual pages secretree serves, not mockups.

1

Push like always

A vault is an ordinary git remote with a secretree:: prefix. On push, the helper packs your new objects into a bundle, encrypts it to every member's key with age, signs a manifest with your device key, and appends it to the vault. On fetch it replays what it has not seen. Several people push; conflicts are the git conflicts you know.

$ git remote add origin secretree::git@gitlab.com:team/app-vault.git
$ git push -u origin main
Enumerating objects: 9, done.
To secretree::git@gitlab.com:team/app-vault.git
 * [new branch]      main -> main

$ secretree status
repository:      ~/code/app (demo, id 02ed7a372737891b)
vault:           git@gitlab.com:team/app-vault.git (branch main)
keys:            present in macOS Keychain (service secretree)
generations:     7 (9.9 KiB on the remote)
last backup:     generation 000007, 2026-09-15 15:10 (1m ago)
last proven:     generation 000007, 2026-09-15 15:10 (1m ago)
2

Open a pull request that lives in git

A pull request is data, not a service. Title, base, head, every comment, approval, check result and state change is a signed JSON object under refs/secretree/collab, encrypted and synced like any branch. Two people commenting at once merge as a plain union. No server ever holds any of it in plaintext.

$ secretree pr open --title "Add retry helper with backoff" --head feature/retry-logic
pull request #1 opened: Add retry helper with backoff (feature/retry-logic → main, 900c2a69)
$ secretree pr comment 1 -m "Should the delay be capped?" --path src/retry.go --line 17
comment added to #1
$ secretree pr approve 1 -m "Looks good, cap can be a follow-up."
#1: approve recorded for 900c2a69
$ secretree pr list
#1    open    Add retry helper with backoff   feature/retry-logic → main  by alice  ✓1 [✓ci]
3

Review in a UI that runs next to the key

secretree ui serves the pages you would expect on 127.0.0.1:7391: diff, conversation, approvals, checks, merge. It reads the vault mirror and writes signed events. Merge enforces the repository's own policy (approvals of the current head, green checks), and because the inputs are signed, every other client can verify that the policy held.

http://127.0.0.1:7391/pull/1
4

CI runs where the key is

A runner agent on a machine you control pulls new commits through the encrypted remote, executes the repository's own .secretree/ci (or make ci) in a throw-away worktree, and records a signed check with the log. The host never sees the workflow, the log or the source. A deploy agent on the target host ships a branch only when its check is green.

$ secretree runner --once
runner: #1 feature/retry-logic @ 900c2a69
runner: #1 feature/retry-logic @ 900c2a69 → success (./.secretree/ci passed in 0s)
runner: main @ fd72f765
runner: main @ fd72f765 → success (./.secretree/ci passed in 0s)
runner: 2 job(s) done

$ secretree pr merge 1
#1 merged into main as 3b1e0c92

# on the production host
$ secretree deploy-agent --to /srv/app --cmd "systemctl restart app"
deploy: 3b1e0c92 is live at /srv/app
5

Browse and link, blame and search

Links look like the ones you paste today: /blob/<ref>/<path>#L10. They open in the local UI for anyone with a key and reveal a path hit at most. Search runs over every branch of the mirror, instantly.

http://127.0.0.1:7391/blob/main/src/main.go#L6
6

Show something to someone without a key

secretree share writes one HTML file: a static viewer plus an age-encrypted snapshot under a fresh one-off key. Host the file anywhere; the key travels in the link fragment, which browsers never send to servers. Every share is a signed entry in the disclosure ledger, so the team always knows what left, when, and why.

The frame on the right is live: it decrypts in your browser right now.

https://secretree.dev/demo/share#AGE-SECRET-KEY-1E8XTKS…
7

Prove the restore, every time

A backup that was never restored is a hope. After every backup, secretree fetches the vault back from the remote into a private directory, verifies every signature and the hash chain, rebuilds the repository, compares every ref and runs git fsck. Until that is green it does not say "done". And a vault stays restorable with nothing but git, age and ssh-keygen.

$ secretree backup
generation 000008 pushed: incremental, 4 refs, 1.2 KiB
restore proof OK: generation 000008 rebuilt from the remote (8 generations verified, 3 applied, 4 refs match)

$ secretree ledger
2 disclosure(s), all signed and chained:
  000001  2026-09-15 10:40  share  file src/main.go@8a4947e8  by alice  expires 2026-09-22
  000002  2026-09-15 10:42  share  file src/main.go@8a4947e8  by alice  expires 2026-09-15  (viewer test)
Threat model

Blunt about what it does and does not do.

Protected against

  • A breached host: stolen tokens, provider CVEs, a rogue employee. They get ciphertext, the public keys of members, sizes and timestamps.
  • A curious host. It learns that a vault exists and how often it changes. Not branch names, not commit messages, not file names, not code.
  • Forged history. Every manifest is signed by a device key; every generation links to the previous by hash. A removed member's key stays valid for what they signed while they were in, and for nothing after.
  • Silent loss. Verified restore after every backup; a rollback or a dropped generation breaks the chain and is reported.

Not protected against

  • A compromised machine that holds a key. Your laptop, your runner. That is the trust root, by design.
  • Traffic analysis. Sizes of incremental bundles reveal how busy you are.
  • Availability. A host can refuse to serve; keep two remotes.
  • Losing every key and the printed recovery kit. Zero knowledge means nobody can help you then.

Cryptography is age (X25519, ChaCha20-Poly1305) and OpenSSH signatures (Ed25519, ssh-keygen -Y). secretree contains no cryptographic primitives of its own. What you give up: the host's web UI for code, hosted runners at scale, integrations that read source as a side effect, and "anyone with the link can view". Each has a deliberate, logged replacement; that is what the ledger is for.

Install

One binary. Five minutes to a first proven backup.

# macOS / Linux: download from the releases page, or
$ go install github.com/andraspalinkas/secretree/cmd/secretree@latest
$ secretree install-helper            # makes git understand secretree:: remotes

# a new project
$ cd ~/code/app
$ secretree init --vault git@gitlab.com:you/app-vault.git --kit-out ~/Desktop/kit.txt
Recovery kit written. PRINT IT, then delete the file.
$ git remote add origin secretree::git@gitlab.com:you/app-vault.git
$ git push -u origin main

# a teammate or a second laptop
$ secretree join --vault git@gitlab.com:you/app-vault.git --name laptop --out join.txt
# ...you approve it with: secretree member add --request join.txt
$ secretree clone git@gitlab.com:you/app-vault.git app

# reviews, CI and deploys
$ secretree policy --approvals 1 --checks ci   # commit .secretree/policy.json
$ secretree ui --open
$ secretree runner                              # on a machine you own
$ secretree deploy-agent --to /srv/app          # on the target host

Full documentation with examples: secretree.dev/docs. Keys live in the macOS Keychain (Linux: the Secret Service via secret-tool, else a 0600 file under ~/.config/secretree). A vault is any git remote or directory; S3 and rclone targets are planned.

Status

Five layers, one primitive.

Everything sits on the vault chain: an append-only sequence of signed manifests and encrypted bundles. It was built as a backup format; it is also, unchanged, a sync format and the carrier for everything above.

LayerWhat it gives youState
1 · VaultFrozen v1 format, backup, verify, restore proof, recovery kit, scheduling, hand-restore with stock toolsbuilt
2 · Syncgit remote helper, several writers, per-device keys, join and revoke, clonebuilt
3 · CollaborationPull requests, reviews, policy, local UI, share pages, disclosure ledger, permalinksbuilt
4 · Runner and deployCI agent with signed checks and encrypted logs, pull-based deploy agentbuilt
NextIDE extension, S3 and rclone targets, Linux and Windows key stores, re-encrypting history for late members, ephemeral and confidential-VM runners, notifications relayplanned