wtg
wtg manages feature branches that span multiple repos. When a feature touches several repos at once, wtg checks out a shared branch across all of them as git worktrees and wires them together with a go.work file — giving you an isolated, ready-to-build workspace per feature without cloning anything new.
New to worktrees? The GitKraken worktree guide is a good primer.
Installation
For using this fork:
go install github.com/suhlig/wtg@latest
Shell completions
bash — add to ~/.bashrc:
source <(wtg completion bash)
zsh — add to ~/.zshrc:
source <(wtg completion zsh)
fish — add to ~/.config/fish/conf.d/wtg.fish:
if status is-interactive
wtg completion fish | source
end
Configuration
Run wtg config init to scaffold a commented config file at ~/.config/wtg/config.toml:
wtg config init
It writes every setting commented out with its default; uncomment and edit the lines you want to override. wtg config prints the resolved file, wtg config edit opens it in $EDITOR, and wtg config path prints its path. A minimal config looks like:
[discovery]
root_dir = "~/repos" # where wtg scans for git repos (or use root_dirs = ["~/repos", "~/work/repos"])
max_depth = 2
[spaces]
root_dir = "~/workspaces" # where workspaces are created
[git]
branch_prefix = "" # prepended to workspace names, e.g. "yourname/"
[archive]
root_dir = "~/repos-archived" # where `wtg repo archive` moves retired clones
YAML is still accepted: a file ending in .yaml/.yml loads via its extension, so an existing config.yaml keeps working.
discovery.root_dir (or discovery.root_dirs for multiple search directories) should contain your regular repo clones, each sitting on their default branch (main, master, etc.) and otherwise left untouched. wtg creates worktrees alongside them — it never modifies the main clones.
Repos are addressed by their slash-separated path relative to their discovery root directory, e.g. github.com/suhlig/rustomato. A repo nested under org or group directories can also be addressed by its basename (rustomato) as long as no other discovered repo shares that basename; otherwise wtg errors and lists the matching repos, and you use the full path to disambiguate. A unique partial match on any path segment works too — infra selects github.com/suhlig/infrastructure, and suhlig selects a repo under that org — but when more than one repo matches, wtg lists all candidates and you disambiguate with a longer name or the full path.
Override with --config <path> or the WTG_CONFIG environment variable.
Always-included repos, files, and hooks
The optional always section applies the same setup to every new space:
[always]
repos = ["shared-tooling"] # symlinked into every new space
files = ["~/.config/wtg/CLAUDE.md"] # copied into every new space root
secrets = [".env"] # copied into each worktree when present in the source repo
run = "~/.config/wtg/on-event" # executable run after create/add/remove/delete
always.repos symlinks shared repos in (no feature-branch worktree),
always.files seeds template files into the space root, always.secrets
copies listed local files from each source repo into its worktree when present,
and always.run invokes a hook script on space lifecycle events with the space
context in WTG_* environment variables. See docs/always.md
for the full behaviour, the event/variable reference, and examples.
Quick start
# Create a workspace for a new feature across three repos
wtg new my-feature api payments frontend
# Jump in
wcd my-feature
# ... do your work, then clean up
wtg delete my-feature --delete-branch
Workspace commands
wtg new <workspace> <repo>...
Create a workspace. At least one repo must be specified. For each repo, wtg
creates or checks out a branch named <branch_prefix><workspace> as a linked
worktree. A go.work file is written automatically for repos that have a
go.mod. Paths listed in always.secrets are copied from each source repo into
its worktree when present (see docs/always.md).
wtg new my-feature api payments frontend
wtg new my-feature api --branch yourname/main # check out an existing branch
Branch behaviour per repo:
| Branch state |
Action |
| Does not exist |
Created from the repo's default branch |
| Exists, not checked out |
Checked out in the new worktree |
| Exists, already checked out |
Error |
wtg delete [<workspace>]
Delete a workspace and remove its worktrees. Prompts for confirmation if any
repo has uncommitted changes or commits that exist on no remote (so a branch
that was merge-landed with wtg land does not trigger a false alarm).
wtg delete my-feature # remove worktrees, keep branches
wtg delete my-feature -d # also delete branches when no commit exists only on them
wtg delete my-feature -D # force-delete branches
wtg delete my-feature --yes # answer the prompt with yes (scripts)
wtg delete # workspace inferred from CWD
delete exits non-zero whenever it does not delete, so wtg land && wtg delete
stops: a declined prompt prints aborted: nothing deleted, and without a
terminal and without --yes it refuses instead of reading an answer from a pipe.
A repo whose always.secrets copy differs from the main clone, or whose source
no longer exists, is kept (with its branch), the other repos are deleted as
usual, and delete exits non-zero. Copy the change back (or discard it) and run
wtg delete again; it resumes with exactly the kept repos. Other ignored files
you created yourself are not detected.
wtg add [<workspace>] <repo>...
Add repos to an existing workspace. Creates worktrees on the workspace's branch
and updates go.work. When run from inside a workspace directory, the workspace
argument can be omitted.
wtg add my-feature infra logging
wtg add infra logging # workspace inferred from CWD
wtg remove [<workspace>] <repo>...
Remove repos from a workspace. Prompts if there are uncommitted changes or
unpushed commits. Use wtg delete to remove the whole workspace. When run from
inside a workspace directory, the workspace argument can be omitted.
wtg remove my-feature logging
wtg remove my-feature logging -d # also delete the branch
wtg remove logging # workspace inferred from CWD
wtg push [<workspace>]
Push the workspace's branch from each repo's worktree to origin in parallel.
When run from inside a workspace directory, the workspace argument can be
omitted.
wtg push my-feature
wtg push # workspace inferred from CWD
If a push is rejected because the branch has diverged from its upstream
(typically after wtg rebase), the output says so and shows the
git push --force-with-lease command. wtg never force-pushes.
wtg land [<workspace>] [--strategy merge|pr] [--draft] [--no-link]
Publish every repo's branch that has commits beyond origin/<default>, in
parallel. With the pr strategy (the default) the branch is pushed and a pull
request is opened (an existing one is reused); with merge the remote default
branch is fast-forwarded to the branch. land never forces and never deletes
anything, so wtg land && wtg delete is the end of the workflow: land exits
non-zero if any repo failed or needs attention (wrong branch, not a
fast-forward, gh missing, siblings not linked).
The strategy is a per-repo choice kept in git config, not in a tracked file:
git -C ~/git/api config --local wtg.landStrategy merge # one clone
# ~/.gitconfig: every repo whose remote matches
[includeIf "hasconfig:remote.*.url:git@github.com:myorg/**"]
path = ~/.gitconfig-myorg-merge
--strategy overrides the setting for one run. With the pr strategy, each
pull request description gets a marked block that links the other PRs of the
workspace; --no-link suppresses it. --dry-run shows the plan, including
would link N sibling PRs.
wtg rebase [<workspace>] [--onto <branch>] [--progress]
Fetch origin and rebase the workspace's branch onto origin/<default branch>
in every repo's worktree, in parallel, with --autostash so uncommitted changes
come along. The default branch is resolved per repo; --onto names one branch
for all repos. When run from inside a workspace directory, the workspace
argument can be omitted.
wtg rebase my-feature
wtg rebase # workspace inferred from CWD
wtg rebase --onto develop # rebase onto origin/develop in every repo
wtg rebase --progress # live per-repo progress bar
- Repos that are already up to date are reported as
✓ up to date; rebased
repos as ↑ rebased (was a1b2c3d). Undo with git reset --keep a1b2c3d.
- A conflict aborts that repo's rebase and restores the worktree, including
uncommitted changes; the command to retry by hand is printed.
- If re-applying the stash conflicts after a successful rebase, the branch is
rebased, conflict markers are in the worktree, and the changes stay in the
stash.
- Repos on another branch, with unresolved conflicts, or with a rebase, merge,
cherry-pick or revert in progress are skipped.
- The exit code is non-zero if any repo failed or needs attention. Nothing is
pushed.
See ADR 0018 for the design.
wtg status [<workspace>...]
Show workspace status. Without arguments, shows all workspaces — the one
containing the current directory is listed first. Pass --long / -l to
expand file-level changes per repo.
wtg status
wtg status my-feature
wtg status my-feature --long
my-feature ~/workspaces/my-feature
api [geoff/my-feature] ✓ clean ↑2
payments [geoff/my-feature] ! 2 modified
frontend [geoff/my-feature] ✓ clean
wtg exec [<workspace>] [--parallel] -- <cmd> [<args>...]
Run a command in each repo's worktree sequentially, streaming output as it goes. With --parallel, commands run in all repos concurrently with a live progress indicator, buffering per-repo output until complete.
Execution continues even if a command fails — all repos are attempted and failures are reported at the end. When run from inside a workspace directory, the workspace argument can be omitted.
wtg exec my-feature -- git status
wtg exec my-feature --parallel -- go test ./...
wtg exec -- git status # workspace inferred from CWD
Previewing and reporting
Two global flags work with any command, before or after the subcommand.
wtg --dry-run new my-feature api payments # print the plan; change nothing
wtg push --summary # run, then list what was executed
--dry-run runs the real code with every mutation turned off and prints what
it would do: result rows say → would …, followed by the git, gh and
filesystem commands under "Would run". Reads still run, so the plan is based on
current state and on the last fetch; a dry run never fetches, never prompts
(would ask: …), and never runs the always.run hook or an exec command.
Its exit code is 0 when a plan could be made.
--summary runs the command normally and, afterwards, prints the mutating git
and gh commands and filesystem effects ((wtg) write …) to stderr, grouped
by repo. Failed commands are marked with their exit code.
Repo commands
These operate on your main repo clones, not workspace worktrees. Useful for keeping clones up to date before starting a new feature.
wtg repo sync [<repo>...]
Fetch and fast-forward each repo's default branch. Repos with uncommitted changes are skipped with a warning.
wtg repo sync # sync all repos
wtg repo sync api payments # sync specific repos
wtg repo sync --progress # watch per-repo progress live
See docs/repo-sync.md for the progress-bar symbol legend and state diagram.
wtg repo status [<repo>...]
Show branch, dirty status, and ahead/behind counts for each main repo clone.
wtg repo status
wtg repo status --long # also show remote URL and local path
wtg repo archive <repo>...
Retire one or more main repo clones: each is moved out of the discovery area into archive.root_dir, kept intact, and recorded (with its original path) under ~/.local/share/wtg/archived.yaml. Nothing is deleted and, without --remote, no remote is touched. See docs/archive.md for the full behaviour, the provenance file, and examples.
wtg repo archive old-service spike-repo
wtg repo archive old-service --dry-run
wtg repo archive old-service --remote
Archiving refuses if a clone has uncommitted changes, commits not on any remote, or stashes; pass --force to proceed anyway. It also refuses, with no override, when a clone has live or stale worktrees, is still referenced by a space, or is listed in always.repos — the messages name the exact command that clears the block.
archive.root_dir must be on the same filesystem as your repos and outside every discovery root, or the moved clones would be rediscovered.
Pass --remote to also archive each repo on GitHub, as part of the same all-or-nothing operation. It requires the gh CLI (2.32 or newer) with an authenticated github.com account, and every named repo must have a github.com origin — if any does not, the whole command aborts rather than silently skipping it. It is idempotent: a repo that is already archived upstream is left as is. If an upstream archival fails, the local moves are rolled back and nothing is left archived.
Without --remote, when a repo's origin is on github.com, the exact gh command to archive it upstream is printed at the end instead:
Upstream repos were not modified. To archive them on GitHub:
gh repo archive something/foo --yes
wtg repo unarchive <repo>...
Restore one or more archived repo clones: each is moved out of archive.root_dir back to the exact path recorded when it was archived, and its provenance record is removed. Nothing is deleted.
wtg repo unarchive old-service spike-repo
wtg repo unarchive old-service --dry-run
wtg repo unarchive old-service --remote
unarchive refuses if the recorded origin path already exists, or if the archived clone is missing from archive.root_dir; pass --archive-dir if you moved your archive root.
Pass --remote to also unarchive each repo on GitHub, under the same rules as --remote on archive: it requires the gh CLI (2.32 or newer) with an authenticated github.com account, every named repo must have a github.com origin, it is idempotent for a repo that is already unarchived upstream, and if an upstream unarchive fails the local restores are rolled back. Without --remote, the exact gh command is printed instead:
Upstream repos were not modified. To unarchive them on GitHub:
gh repo unarchive something/foo --yes
Development
This fork renames the module path to github.com/suhlig/wtg so it installs via go install github.com/suhlig/wtg@latest. Upstream declares github.com/geoffamey/wtg, so merging upstream changes conflicts on the internal import lines. Enable git rerere once and Git replays the same resolution automatically on every future merge:
git config rerere.enabled true
Conflicts are limited to hunks that touch import blocks; changes elsewhere in a file merge cleanly.