A small Go CLI for bare-repo + worktree git workflows. Clone, manage worktrees, prune orphans, wire up direnv per-repo.
v0.1.0 is in progress. Release archives are published from tags via GoReleaser.
Primary installer:
curl -fsSL https://raw.githubusercontent.com/pedromvgomes/gt/main/install.sh | shFallback with Go:
go install github.com/pedromvgomes/gt/cmd/gt@latestIf macOS blocks the downloaded binary on first run, remove the quarantine attribute:
xattr -d com.apple.quarantine "$(which gt)"gt clone git@github.com:pedromvgomes/foo.git
cd foo
gt wt add feature/new-thing
cd feature/new-thing
# ... do work ...
gt wt rm new-thing --branchgt clone https://... prompts to use SSH by default, then runs the direnv authentication setup unless --no-setup-auth is passed.
Clone a repository into a bare repo plus worktree layout:
- bare repository at
<folder>/.bare .gitpointer at<folder>/.git- default branch worktree at
<folder>/<default-branch>/ - worktree type directories from config (
feature,fix,choreby default)
Flags:
--sshconverts HTTPS URLs to SSH without prompting.--no-sshkeeps HTTPS URLs without prompting.--no-setup-authskips post-clone direnv setup.--user <name>chooses the GitHub user for post-clone direnv setup.--setup <a,b,c>runs only the named setup templates (overrides match-based selection).--no-setupskips post-clone setup templates entirely.--yesskips the setup confirmation prompt.--show-setupprints full template bodies before prompting.--dry-run-setupprints the setup plan without executing it.
Create a typed worktree and branch, for example gt wt add feature/new-thing --from main.
Remove a worktree by its short name. With --branch/-b, also delete the matching local branch.
Before removing, gt checks whether the worktree has uncommitted changes (any git status --porcelain output — staged, unstaged, or untracked):
- A clean worktree is removed without prompting.
- A dirty worktree prompts for confirmation in an interactive terminal (
Force delete and lose them? [y/N]); answering no aborts with a non-zero exit and leaves the worktree untouched. - In a non-interactive session (no TTY) a dirty worktree aborts with an error instead of prompting — pass
--force/-fto remove it anyway.
--force/-f skips the dirty check entirely and removes without prompting (use it in scripts and CI). A confirmed or forced removal also covers --branch deletion of an unmerged branch, so you are asked at most once.
List typed worktrees and the scratch worktree, including the latest commit summary for each.
Remove all typed worktrees. Worktrees with uncommitted changes are skipped with a warning unless --force/-f is given, which removes them and discards those changes. With --branches/-b, force-delete the corresponding local branches.
Delete local branches that do not have an active worktree, while preserving main, master, the default branch, and scratch.
Create or manage the top-level scratch worktree. gt scratch creates it when missing or prints its current commit when it already exists, --reset checks it out from a new source branch, and --delete removes it plus the local scratch branch.
Write an idempotent .envrc at the gt-managed root that exports GH_TOKEN from gh auth token --user <name>, runs direnv allow, and prints shell-hook instructions if direnv is not active in new shells.
Run the configured setup templates against the current repository. Works inside any git repository (gt-managed or plain). Reads the origin remote URL and matches it against each template's match patterns; every matching template runs in config-file order. With --setup, runs exactly those templates in the given order. --from <name> resumes a previously-failed run from that template. --show jumps straight to the detailed plan; --dry-run prints the plan without running anything.
Keep repositories structurally consistent: the CI/CD pipeline, Dependabot config, conventional commits, and Dependabot auto-merge, all declared in one committed .gt-repo.yaml.
gt repo init— detect Dependabot ecosystems from the files on disk and write.gt-repo.yamlwith gt's defaults, for you to review.--dry-runprints without writing.gt repo check— render the spec and diff it against the working tree. Non-zero exit on drift, so it works as a PR check.--jsonfor machine-readable output.gt repo sync— write the files that have drifted.--dry-run,--yes, and--skip-workflows. A repository with no.gt-repo.yamlis not governed; sync says so and exits 0, which makes it safe to run from a post-clone setup template.gt repo config [--json]— print the resolved spec with defaults applied. gt's own workflows consume this rather than re-parsing the YAML.gt repo settings diff|apply— branch protection, merge methods, and the single required status check, through your existingghcredentials.gt repo fleet check|sync --owner <name>— sweep every repository in an owner;syncopens a PR per repo. This is the escalation path for filesGITHUB_TOKENcannot write.gt repo fleet merge-pending --owner <name>— list (or--merge) the Dependabot PRs the in-repo auto-merge cannot touch. Applies the same eligibility gates as the in-repo job.
See Repository governance for the full model.
gt config path— print the absolute path of the global config file.gt config show— print the current contents.gt config validate— parse and validate, surfacing any errors.gt config edit— open the config in$VISUAL/$EDITOR. On save, the file is parsed and validated; if invalid, you are prompted to re-open the editor and the original file is left untouched until a valid edit is saved.
gt bootstraps YAML config on first command run at $XDG_CONFIG_HOME/gt/config.yaml, or ~/.config/gt/config.yaml when XDG_CONFIG_HOME is unset.
worktree_types:
- feature
- fix
- chore
ssh:
host_aliases:
github.com: github-personal
setup:
templates:
- name: agentic-toolkit
match:
- "github.com:pedromvgomes/*"
- "github.com/pedromvgomes/*"
run: |
git clone git@github.com:pedromvgomes/agentic-toolkit.git "${GT_WORKDIR}/.agentic"
ln -sfn "${GT_WORKDIR}/.agentic/CLAUDE.md" "${GT_WORKDIR}/CLAUDE.md"
- name: golang-extras
match: ["*"]
script: ${HOME}/.config/gt/setup-scripts/golang-extras.shPer-repo overrides live at <gt-managed-root>/.gt.yaml and override global config per key. A per-repo config may declare its own setup.templates; they are merged on top of the global templates by name — a per-repo template that reuses a global template's name replaces it in place, and new names are appended after the global ones. This file lives at your gt-managed root (not committed inside the repo), so its templates are as trusted as the ones in your global config.
Templates run after gt clone and on demand via gt setup. They are simple shell scripts; gt does not introspect them.
- Each template needs
nameand exactly one ofrun(inline shell) orscript(path to an executable file).${VAR}references inscriptpaths are expanded. matchis a list of glob patterns checked as substrings against the clone URL.*matches any sequence;?matches a single character.match: ["*"]runs the template for every repo. An empty/missingmatchmakes the template only runnable via--setup <name>.- Templates execute in the order they appear in the file. Every template whose
matchmatches the URL runs. - Available env vars (also substituted into
script:paths):GT_ROOT— gt-managed root, or git repo root when not gt-managed.GT_WORKDIR—${GT_ROOT}/${GT_DEFAULT_BRANCH}in bare layout,${GT_ROOT}in plain.GT_LAYOUT—bareorplain.GT_DEFAULT_BRANCH,GT_REPO_OWNER,GT_REPO_NAME,GT_REPO_URL.
- Before running, gt prints the list of templates that will execute and prompts
[Y/n/d](dshows the full bodies). The prompt is skipped when--yesis passed or stdin is not a TTY. - On the first non-zero exit gt stops and tells you to resume with
gt setup --from <template>.
Templates in your global config and in a per-repo <gt-managed-root>/.gt.yaml run as you with your environment. Both files live on your machine where you put them, so treat editing them the same way you treat editing your shell profile. Templates a repository ships in its own committed .gt.yaml are different: they are untrusted, gated behind an explicit confirmation, and never auto-run without a TTY unless you pass --yes. gt setup will not silently run anything you have not added to a config you control.
Keeping many repositories structurally consistent is the problem gt repo
solves. A GitHub template repo cannot: templates copy once and never
propagate, so a change to shared policy means editing every repository by hand.
A repository opts in by committing a .gt-repo.yaml. That one file drives
three propagation mechanisms:
| Layer | How it reaches the repo |
|---|---|
| Stage logic (attestation, conventional commits, bulwark, governance) | gt's reusable workflows, pinned to a moving major tag — changes need no file edit at all |
Files that must exist in-repo (orchestrators, dependabot.yml, CODEOWNERS) |
gt repo sync, locally or from the weekly in-repo job |
| GitHub API state (branch protection, merge methods) | gt repo settings apply, using your gh credentials |
# .gt-repo.yaml
dependabot:
- ecosystem: gomod
directory: /
pipeline:
ci:
stages: [preflight, build, test, end2end]
cd:
enabled: falsegt is opinionated, so the file records what you have decided differently, not the resolved configuration. Everything absent follows gt's default and keeps following it as that default changes.
That is the point, not a formatting preference. A repository that spells out a
default has silently stopped tracking it: changing an opinion in gt would reach
none of the repositories whose files already state the old value, and the
propagation mechanism this whole subsystem exists for would quietly do nothing.
So sync writes only the overrides, and check reports a file that restates
defaults as drift — the weekly in-repo sync then slims it without anyone
cloning the repository.
Overriding is unaffected: any value you set that differs from the default is
kept, including a false against a default of true. gt repo config prints
the resolved spec with every default applied.
Shared policy — Dependabot cooldown, commit-message prefixes, the sync schedule — deliberately lives in gt's templates rather than this file, so changing it everywhere is one gt release.
gt owns ci-orchestration.yml and cd-orchestration.yml. They wire the stages
together and nothing else:
attest
└─ ci-preflight
└─ ci-build
├─ ci-test ─── bulwark
└─ ci-end2end
conventional-commits, governance
└─ ci-gate ← the one required check
The ci-* and cd-* stage files are yours. gt writes each one once as an
empty stub and then never touches it again — not to update it, and not to
delete it when you drop the stage. That is where your build, tests and
deployment go.
ci-preflight decides what runs: emit run-build, run-test or run-end2end
as "false" to skip a stage. The stub emits nothing, so everything runs until
you say otherwise. A skipped stage passes the gate.
Branch protection requires exactly one check: ci-gate. It is a plain job
in a workflow your repository owns, so its check name is just the job name.
Because every stage is a job in that same workflow, it aggregates them with
needs: — no polling, no timeout, and no way to confuse "absent" with "not
started yet".
Rename a CI job and you edit .gt-repo.yaml, never the protection rule.
For a pull_request event GitHub tests refs/pull/N/merge — the merged result
of the PR into its base — and a squash merge produces a commit with that same
tree. So ci-gate records the tree it validated as a gt/validated-tree
commit status, and later runs compare against it:
- push to the default branch — if the tree already carries a passing attestation, every stage skips. The identical tree passed; re-running proves nothing.
- tag push —
cd-orchestrationrefuses to publish a tree that carries no passing attestation. That is a stronger guarantee than re-running CI, which only tells you the code passes now, again.
This replaces "require branches to be up to date": nobody has to rebase, so a merge never turns other open PRs red. It fails safe in every direction — a missing, unreadable or mismatched attestation means run the pipeline.
GitHub blocks GITHUB_TOKEN from creating or updating anything under
.github/workflows/, so a compromised action cannot rewrite a repository's own
automation. Three consequences, all handled the same way:
- The weekly sync repairs everything except workflow files, and reports the ones it had to leave.
- Dependabot PRs touching
.github/workflows/**can never self-merge — every repo with thegithub-actionsecosystem produces these routinely. - Updating an orchestrator itself needs the same escalation.
In each case the in-repo job does what its token allows, and the rest escalates
to a local gt repo fleet … run using your own credentials. No long-lived
token is stored anywhere.
gt repo init # detect ecosystems, write .gt-repo.yaml
$EDITOR .gt-repo.yaml # prune what detection over-found, add notes
gt repo sync # render the orchestrators and stage stubs
# move your build and test jobs into ci-build.yml / ci-test.yml
gt repo settings diff # preview branch protection changes
gt repo settings applyHave ci-test.yml upload its coverage as an artifact named gt-coverage, and
the bulwark stage consumes it instead of running your suite a second time.
Then add a setup template so new clones are governed automatically:
setup:
templates:
- name: repo-governance
match: ["github.com:pedromvgomes/*", "github.com/pedromvgomes/*"]
run: gt repo sync --yesSee docs/pipeline-design.md for why it is shaped this way.
This repo ships an agentic/ directory that lets coding agents (Claude Code, Cursor, etc.) discover and follow the bare-repo + worktree workflow without you having to re-explain it every session.
agentic/
skills/use-gt/
SKILL.md # how to drive gt from an agent
scripts/ensure-gt.sh # idempotent installer the skill calls
rules/
worktree-per-session.md # short "rules of the road" for agents
agentic/skills/use-gt/is a Claude Code skill (with the standardname+descriptionfrontmatter). Point your agent at it once and it will reach forgt clone/gt wt add/gt wt rminstead of rawgit, installgton demand via the helper script, and ask the right pre-clone questions (whichghuser to authenticate as, SSH vs HTTPS, etc.).agentic/rules/worktree-per-session.mdcaptures the non-negotiables: always use the bare-repo layout, sessions start at the gt-managed root, and every session mustgt wt addits own worktree before doing any work.
Why bother:
- Consistency across repos. Same auth wiring, same typed worktrees, same branch naming, regardless of which repo the agent is dropped into.
- Parallel-safe sessions. One worktree per session means multiple agent runs can work side-by-side without stepping on each other or on your default-branch checkout.
- No bypassed validation. Raw
git worktree addskips the configured worktree-types check; routing throughgtkeeps everything inside the layout you've set up. - Self-bootstrapping. The skill's
ensure-gt.shinstallsgtfrom the official release script if it's missing, so a fresh agent environment becomes productive on the first command.
The installer prompts to install completions for zsh, bash, or fish when run interactively. You can regenerate them manually:
gt completion zsh > "$(brew --prefix)/share/zsh/site-functions/_gt" # Homebrew zsh, already on $fpath
gt completion bash > ~/.local/share/bash-completion/completions/gt
gt completion fish > ~/.config/fish/completions/gt.fishIf you don't use Homebrew zsh, pick a directory on your $fpath (check with echo $fpath | tr ' ' '\n') or add your own:
mkdir -p ~/.zsh/completions
gt completion zsh > ~/.zsh/completions/_gt
# then in ~/.zshrc, before `compinit`:
# fpath=(~/.zsh/completions $fpath)Restart your shell (or exec zsh) to pick up new completions. If oh-my-zsh has cached an older compinit, also run rm -f ~/.zcompdump.
go test ./tests/... -coverpkg=./internal/... -coverprofile=coverage.out
go vet ./...
golangci-lint runReleases are cut by pushing a semver tag:
git tag v0.1.0
git push origin v0.1.0The release workflow runs GoReleaser and publishes four tar.gz archives plus checksums.txt.