A cross-platform CLI tool that implements a customized gitflow workflow. It detects your current git branch, presents context-appropriate options, and handles all the branching, merging, tagging, and PR creation for you.
brew tap middelkampdev/tap
brew install gflowchoco install gflowDownload the latest release from the GitHub Releases page.
cargo install --path .gflow works with GitHub (including GitHub Enterprise) through gh. It passes the origin repository to every gh call (--repo HOST/OWNER/REPO). In a fork with an upstream remote, PRs therefore stay in your fork's repository instead of the parent.
gflow works with either name. On its first run in a repository it detects which
one you use — main if that branch exists locally or on the remote, otherwise
master — saves the answer to your repository's config, and says so:
Detected mainline branch 'master' (saved to gflow.branch.main).
Every later run just reads the setting. To set or change it yourself:
git config gflow.branch.main master # or: mainThe key is repo-local on purpose: the mainline is a property of the repository,
not of the developer (the opposite of the worktree settings, which are personal
preference and therefore global by default). Only main and master are
supported; any other value is rejected with a message naming the fix.
gflow manages two permanent branches and several short-lived branch types:
gitGraph
commit id: "init"
branch develop
commit id: "dev work"
branch feature/login
commit id: "feat: login"
checkout develop
merge feature/login
branch release/2.6.0
commit id: "tag v2.6.0-rc.1" tag: "v2.6.0-rc.1"
branch release-fix/2.6.0/payment
commit id: "fix: payment"
checkout release/2.6.0
merge release-fix/2.6.0/payment
commit id: "tag v2.6.0-rc.2" tag: "v2.6.0-rc.2"
checkout main
merge release/2.6.0
checkout develop
merge release/2.6.0
checkout main
branch hotfix/2.6.2
commit id: "hotfix branch"
branch hotfix-fix/2.6.2/crash
commit id: "fix: crash"
checkout hotfix/2.6.2
merge hotfix-fix/2.6.2/crash tag: "v2.6.2"
checkout main
merge hotfix/2.6.2
checkout develop
merge hotfix/2.6.2
Tags always sit on the release or hotfix branch, on the exact commit that was
tested; the merge into main carries that commit as its second parent.
| Branch | Created from | Merges into | Purpose |
|---|---|---|---|
main / master |
— | — | Production code |
develop |
— | — | Integration branch |
feature/{name} |
develop |
develop (PR) |
New functionality |
fix/{name} |
develop |
develop (PR) |
Bug fixes |
chore/{name} |
develop |
develop (PR) |
Maintenance & tooling |
docs/{name} |
develop |
develop (PR) |
Documentation |
refactor/{name} |
develop |
develop (PR) |
Code restructuring |
release/{major}.{minor}.{patch} |
develop |
main + develop |
Release preparation |
release-fix/{v}/{name} |
release/{v} |
release/{v} (PR) |
Fixes during release |
version/{v}-into-{target} |
release/{v} or hotfix/{v} |
the same branch (PR) | gflow-created in protected mode: every version change (release cut, gflow bump under patch, hotfix start), squash-merged and deleted in the same call (version/* branches) |
hotfix/{major}.{minor}.{patch} |
main |
main + open release/* + develop |
Urgent production fix |
hotfix-fix/{v}/{name} |
hotfix/{v} |
hotfix/{v} (PR) |
Fixes during hotfix |
Run gflow in any git repository. The tool detects your current branch and shows the appropriate menu.
gflow keeps its repo-wide policy in a committed .gflow/config. A repository
without that file is not initialised: interactive gflow offers a
three-question setup (landing mode, whether to keep release branches, bump
strategy) and writes the file; subcommands refuse with run 'gflow init'.
Run gflow init yourself at any time, then commit .gflow/config so every
clone and CI job shares the same policy.
gflow init # one-time per repository; then commit .gflow/configWhen you run gflow start with uncommitted changes (staged, unstaged, or untracked files), gflow automatically stashes your changes, creates the new branch, and restores them on the new branch. No flags or prompts needed — your work-in-progress follows you.
If restoring changes causes conflicts with the target branch, gflow leaves the conflicts for you to resolve manually (the stash is preserved as a safety net).
Finish commands (gflow finish, gflow bump, gflow sync) still require a clean working tree.
? What would you like to do?
> start feature
start fix
start chore
start docs
start refactor
start release
? What would you like to do?
> start hotfix fix
? What would you like to do?
> finish {type}
start feature
start fix
start chore
start docs
start refactor
Selecting finish creates a PR back to the base branch. You can also start a new branch from the current branch or from develop.
? What would you like to do?
> finish release
start release fix
bump version
sync with develop
? What would you like to do?
> finish release fix
? What would you like to do?
> finish hotfix
start hotfix fix
? What would you like to do?
> finish hotfix fix
gflow ships a Claude Code skill that teaches Claude how to drive this CLI (branch start/finish, releases, worktrees, PR automation). Install it with:
/plugin marketplace add middelkampdev/gflow
/plugin install gflow@gflow
The skill documents the current CLI surface, so keep the gflow binary up to
date. It checks gflow --version on first use and tells you if your binary is
older than the syntax it documents.
All commands can be invoked directly via subcommands, bypassing the interactive menu.
When gflow needs an answer and there is no terminal, it stops with an error that names the question (No terminal to answer 'Release type' ...); pass the flag that answers it (here --major or --minor).
gflow start feature --name <name> [--base <branch>] [--no-checkout] [--no-worktree]
gflow start fix --name <name> [--base <branch>] [--no-checkout] [--no-worktree]
gflow start chore --name <name> [--base <branch>] [--no-checkout] [--no-worktree]
gflow start docs --name <name> [--base <branch>] [--no-checkout] [--no-worktree]
gflow start refactor --name <name> [--base <branch>] [--no-checkout] [--no-worktree]
gflow start release [--major | --minor] [--no-worktree]
gflow start release-fix --name <name> [--no-checkout] [--no-worktree] # must be on a release branch
gflow start hotfix-fix --name <name> [--no-checkout] [--no-worktree] # must be on the mainline or a hotfix branch--base defaults to develop when omitted.
Every start branches from the latest origin/<base>, which the fetch has just updated, not from your local copy, which can be behind. The local base is used only when it has commits that are not on origin yet.
--major / --minor on start release skips the interactive prompt and forces the bump level. Useful for scripts and AI agents.
--no-checkout creates and pushes the branch without switching to it. You stay on your current branch. Designed for git worktree workflows. Not available for start release.
--no-worktree skips the optional worktree flow for a single command when worktree=true is set. No effect otherwise.
gflow finish [--breaking | --breaking=false] [--base <branch>] [--accept-merge-type]
gflow finish --abort # discard an in-progress release/hotfix finishInfers the action from the current branch type (e.g., creates PR on work branches, merges + tags on release/hotfix branches).
Every PR gflow creates or re-surfaces — work and fix PRs, protected-mode
landing PRs, version PRs — also opens in your default browser right after its
URL is printed. When no browser can open (headless CI, no xdg-open), gflow
prints a warning and carries on; the PR itself is unaffected.
Protected-mode landing PRs (the merges gflow opens when finishing — hotfix or
release into main, develop, or an open release/*) get an empty
description: their title says everything, so the repository's own PR
template never decorates them. A gflow-release.md / gflow-hotfix.md (or
gflow-default.md) in .github/pr-templates/ still applies when you created
one on purpose.
On feature, fix, and refactor branches, gflow finish asks whether the work contains breaking changes. Pass --breaking (true) or --breaking=false to skip the prompt in non-interactive contexts. The flag is honored on any work branch type.
On work branches, the PR target is normally detected from the branch topology: when exactly one candidate parent is found it is used directly, and only when several candidates exist does a selection menu appear. Candidates are develop plus the work branches your branch could have come from, ranked nearest-first; work branches that were started from your branch are excluded, as are branches your history left before it left develop (a PR into them would carry develop's commits too), while develop is always offered no matter how far ahead of you it has moved. Pass --base <branch> to set the target explicitly and skip detection and the menu entirely — combined with --breaking, this makes gflow finish fully scriptable without a TTY (CI, AI agents). The branch must exist on origin — PRs are created on the hosting platform, so a local-only branch is rejected (push or fetch it first) — and must differ from the branch being finished. --base is only valid on work branches; release, hotfix, release-fix, and hotfix-fix finishes have a fixed target and reject it.
Every PR gflow opens must be completed with a specific button, and gflow prints a hard-to-miss banner next to the PR URL saying which one:
- Work and fix PRs (feature/fix/chore/docs/refactor,
release-fix,hotfix-fix,version/*PRs) → Squash. One commit per change lands on the target. - Protected-mode landing PRs (
landing/*intomain/develop/release/*) → Merge commit. The merge keeps history connected, which the finish flows rely on.
gflow cannot pre-select the button (GitHub has no API for it), so it verifies after the fact: on the next run it reads the merge commit's parent count (2 parents = merge commit, 1 = squash). A wrong completion hard-stops the flow — nothing is cleaned up or tagged — and prints the undo steps:
- Wrongly merged work PR: revert (or reset) the merge on the target, run
git commit --amend --no-editon your branch (the new commit id lets a fresh PR open), then re-rungflow finish. - Wrongly squashed landing PR: revert the PR on the hosting platform — a protected branch cannot be force-pushed back.
To accept the mistake and continue instead, re-run the same command with
--accept-merge-type (available on finish and sync). A rebase merge of a
single-commit PR is indistinguishable from a squash and passes the squash
check — the result is identical.
Re-running gflow finish on a work branch (or release-fix/hotfix-fix branch) whose PR has been merged completes the finish instead of opening a new PR: it deletes the remote branch (if the platform didn't already), deletes the local branch, and — when the branch lives in its own worktree — removes the worktree and tells you the editor window can be closed. Completion is detected from the hosting platform's PR state, so there is nothing to remember between runs.
Cleanup only happens when the local branch tip is exactly the commit the PR merged; if you committed more work after the merge, gflow finish says so and opens a fresh PR for the new commits instead. A PR that was closed without merging also leads to a new PR, never to cleanup. Outside a worktree, cleanup switches you back to the PR's target branch and fast-forwards it first.
gflow finish on release and hotfix branches is idempotent: if a merge into main, develop, or an open release/* branch conflicts, resolve the conflict in your editor and git commit the merge. A conflict usually leaves HEAD on the target branch (e.g. develop), so to continue you switch back to the source branch and re-run gflow finish:
git add . && git commit --no-edit # complete the merge you just resolved
git switch hotfix/2.5.2 # back to the branch that started the finish
gflow finish # resumes; already-done steps are skippedSteps that already completed (merges, tags, pushes, branch deletion) are detected from git state and skipped — the flow continues from the first incomplete step. The conflict message names the exact branch to switch back to.
Resume is branch-scoped. Each in-progress finish is tracked in its own file under .git/gflow-finish/ (e.g. hotfix-2.5.2.state), keyed by the source branch. gflow only resumes when you are standing on that source branch — from develop, main, or any feature/* branch it behaves normally, so a stalled finish never blocks other work. Two finishes (e.g. a release and a hotfix) can be in progress at once without colliding. Use gflow finish --abort from the source branch to discard its in-progress state and start fresh.
When gflow finish opens a PR, it picks the body template by branch type. Place templates in .github/pr-templates/ named gflow-<key>.md. Resolution is most-specific first:
- Branch-specific —
gflow-<type>.md(e.g.gflow-release-fix.md) - Group — the fix family (
fix,release-fix,hotfix-fix) sharesgflow-fix.md; every other type's group equals its own name - Default —
gflow-default.md - Git default — the repo's own default template, else an empty body.
.github/PULL_REQUEST_TEMPLATE.mdand the other pathsghrecognizes.
| File | Applies to |
|---|---|
gflow-feature.md |
feature/* |
gflow-fix.md |
the fix family — fix/*, release-fix/*, hotfix-fix/* (unless overridden below) |
gflow-release-fix.md |
only release-fix/* (overrides gflow-fix.md) |
gflow-hotfix-fix.md |
only hotfix-fix/* (overrides gflow-fix.md) |
gflow-chore.md / gflow-docs.md / gflow-refactor.md |
chore/* / docs/* / refactor/* |
gflow-default.md |
any PR with no more specific match |
The feature is opt-in: with no .github/pr-templates/ directory, gflow falls back to the existing git default behavior unchanged.
gflow bump # bump patch version
gflow sync # sync release into develop [--accept-merge-type]Both require being on a release branch.
gflow deploy-version # 0.4.0+3: version and build number
gflow deploy-version --name # 0.4.0
gflow deploy-version --build # 3Prints the version of a deploy build from the current branch, for build scripts. It only reads: no config, prompts, hosting calls, or fetch. It runs on develop, release and hotfix branches, and on the work branches that land on them; it refuses on the mainline, version/*, and landing/*.
| Branch | Version | Build number |
|---|---|---|
develop |
the next minor after the highest version tag (0.1.0 without tags) |
1 plus the first-parent commits on develop since the cut of that tag's release line (all commits without tags) |
release/X.Y.0 |
its latest tag of the X.Y line (-rc.N included), or X.Y.0 before its first tag |
the first-parent commits on the branch since it opened from develop |
hotfix/X.Y.Z |
X.Y.Z: its tag comes only at finish |
the first-parent commits on the branch since it opened from the mainline |
| a work branch | as the branch it lands on | as the branch it lands on, counted on the work branch's own commits |
Only a branch's own first-parent line counts, so a merged PR counts as one commit, and on develop, release, and hotfix branches one version and build number always mean one commit. A work branch can repeat a number its target reaches later: use those for debug runs only.
Optionally, every new branch can be created in its own git worktree and opened in your editor, so each piece of work lives in a separate directory instead of switching the current checkout. It's off by default and uses native git worktree — no extra tools required.
The quickest way is the built-in setup command — no need to remember any keys:
gflow worktree # interactive setup: enable, pick an editor, choose a locationOr set options directly (handy for scripts and dotfiles):
gflow worktree enable # turn the flow on
gflow worktree editor cursor # code (default) | cursor | windsurf | zed | pycharm | none | any command
gflow worktree path ~/worktrees # where worktree folders go (default: the repo's parent)
gflow worktree status # show the current settings (all layers merged)
gflow worktree setup # re-run the setup commands in this worktree (see below)
gflow worktree disable # turn it off
gflow worktree enable --repo # ... in this repository's committed config
gflow worktree enable --local # ... in this repository, for you only (not committed)These write to ~/.gflow/config by default (per-developer, all repositories). Two flags aim
them elsewhere: --repo writes the repository's committed <repo>/.gflow/config, and --local
writes <repo>/.gflow/config.local, which is yours alone and never committed. All three are
plain key=value files you can also edit by hand — see
Configuration layers:
| Key | Default | Meaning |
|---|---|---|
worktree |
false |
Turn the worktree flow on |
editor |
code |
Command to open the worktree (<editor> <path>), in the interactive menu only. Use none to skip opening |
path |
(unset) | Directory to place worktree folders in (~ is expanded). Defaults to the repo's parent directory |
Upgrading from 4.0.x: the old
gflow.worktree.*git config keys move into these files automatically on the first run — global keys to~/.gflow/config,--localkeys to that repository's.gflow/config.local. Local git config was never committed, so it stays uncommitted. Nothing to do by hand, and nothing appears ingit status. Migration saves the new file before removing old keys. A failed save keeps your old settings.
One other key lives outside this family and is repo-local, because the mainline is a property of the repository rather than a per-developer preference:
| Key | Default | Meaning |
|---|---|---|
gflow.branch.main |
detected on first use | The repo's mainline branch — main or master. See Mainline branch |
The editor accepts any command whose CLI opens a folder as <command> <path> — VS Code (code),
Cursor (cursor), Windsurf (windsurf), Zed (zed), and JetBrains launchers
(idea / pycharm / webstorm / …) all work once their shell command is on your PATH.
When enabled, gflow start feature/fix/chore/docs/refactor (and release-fix / hotfix-fix) will:
- Create and push the branch without switching your current checkout.
- Add a git worktree for it.
- Open that folder in your editor (unless
editor = none). Only the interactive menu (gflowwith no subcommand) opens the editor; subcommands such asgflow start feature --name xonly create the worktree. An editor that isn't installed is a warning, not a failure — the worktree is still ready.
Pass --no-worktree to skip the flow for a single command.
start release is included, with one difference: the release branch is created
and tagged in your current checkout (the version script must run on a
checked-out branch), then your checkout returns to develop and the release
opens in its own worktree. Re-running start release while a worktree already
holds the release just prints its path.
start hotfix-fix also covers the hotfix/{v} container branch it resolves or
creates: the container gets its own worktree too — that is where gflow finish
runs later — opened before the fix branch's worktree, so the fix keeps editor
focus. A hotfix already held by a worktree just prints its path.
With the worktree flow enabled, gflow looks for the repo's setup commands
after creating a worktree and runs them before opening the editor — the
same files Cursor and worktree-cli
use: .cursor/worktrees.json (a JSON array of commands) or worktrees.json
at the repo root ({"setup-worktree": [...]}); the first non-empty one wins,
no file means nothing runs. Each entry is a shell command run inside the new
worktree with $ROOT_WORKTREE_PATH pointing at your main checkout; a failing
command is reported and the rest still run, and the start never fails because
of a setup command. Nothing is asked — the file is repo content you committed,
so enabling worktrees is the opt-in, and with the flow disabled the file is not
read at all.
The file must be strict JSON — the same dialect JSON.parse accepts, so what
works here works for the other tools reading it. A file gflow cannot parse is
reported as a warning naming the file and the byte offset (a trailing comma is
called out as such), the setup commands are skipped, and the command you ran
carries on: a typo in worktrees.json never blocks gflow.
To run the setup commands again — after editing the file, or in a worktree you
created yourself — run gflow worktree setup inside that linked worktree. It
reads the file from the worktree you stand in, works whether or not the worktree
flow is enabled, and refuses in the main checkout (where $ROOT_WORKTREE_PATH
would point at the same directory). Unlike during a start, a missing or
unparsable file is an error, and so is any failing command: every command still
runs, then gflow exits non-zero.
{
"setup-worktree": [
"fvm use",
"dart pub get",
"cd tools/shuttel_lint && dart pub get"
]
}As with --no-checkout, an active worktree flow relaxes the branch-type check for release-fix and hotfix-fix — the target release/hotfix branch is discovered automatically, so you can run them from any branch. Discovery is a fallback, not an override: standing on a release/{v} or hotfix/{v} branch means release-fix/hotfix-fix targets that branch, even inside a worktree. When gflow does have to discover, it takes the newest open version, never the first name in sort order.
Worktree folders are flat siblings of the repo (or live under gflow.worktree.path), each named <repo-name>-<branch-with-slashes-as-dashes> so the folder's own name tells you which repo and branch it is:
Projects/
├── gflow/ # main checkout (stays on develop/master)
├── gflow-feature-login/ # worktree for feature/login
├── gflow-fix-auth-bug/ # worktree for fix/auth-bug
└── gflow-release-fix-1.2.0-null-crash/ # worktree for release-fix/1.2.0/null-crash
Wherever gflow runs — the main checkout or a linked worktree — it reads .gflow/config, the version script, and PR templates from that working tree, not the main one. A linked worktree can have a different branch checked out than the main tree, so this is what lets each worktree apply its own branch's mode, script, and templates instead of silently picking up whatever the main checkout happens to have.
gflow finish on a release or hotfix branch can run from any worktree. For each
merge target (main, develop, open release/*) gflow asks git where that
branch is checked out. If another worktree holds it, the merge runs in that
worktree (git -C <path> merge …), and that tree must be clean — gflow refuses
and names the path otherwise, so it never merges over uncommitted work. If no
worktree holds the target, it is checked out in the current tree as before.
Cleanup follows the same rule: if the finish ends while still standing on the
release/hotfix branch and main is held by another worktree, gflow detaches
HEAD instead of checking main out, then deletes the branch.
When the finish runs inside the release/hotfix branch's own worktree and deletes
the branch, it removes that worktree too and tells you the editor window can be
closed. With keep-release-branches=true (or when gflow keeps the branch as a
safety guard) the worktree stays with it.
Simple workflow for day-to-day work:
sequenceDiagram
participant D as develop
participant W as feature/login
D->>W: gflow → start feature → "login"
Note over W: Do your work, commit, push
W->>D: gflow → finish feature (creates PR)
Releases are tagged for deployment. Build agents auto-deploy based on tags.
sequenceDiagram
participant D as develop
participant R as release/2.6.0
participant RF as release-fix/2.6.0/payment
participant M as main
D->>R: gflow → start release (auto-creates release/2.6.0)
Note over R: Tagged v2.6.0-rc.1
R->>RF: Creates release-fix/2.6.0/payment
Note over RF: Fix the issue
RF->>R: gflow → finish release fix (PR)
Note over R: gflow → bump version → tags v2.6.0-rc.2
Note over R: gflow → sync with develop (if needed)
R->>M: gflow → finish release
R->>D: merges into develop
Note over R: Branch deleted
When starting a release, gflow scans commits since the last release for breaking changes and preselects major or minor accordingly. A commit is considered breaking if its title has ! before the colon (e.g. feat!: drop legacy API, refactor(auth)!: rewrite), or if the body contains a line starting with BREAKING CHANGE: or BREAKING-CHANGE: (case-insensitive, per Conventional Commits).
When finishing a feature, fix, or refactor branch, gflow asks whether the work contains breaking changes. If yes, the PR title gets a ! suffix (e.g. feat!: name) so the signal carries into the commit history and gets picked up at the next release. For non-interactive use (scripts, CI, AI agents), pass --breaking (true) or --breaking=false to skip the prompt.
| Command | What it does |
|---|---|
| gflow start release | Cuts release/X.Y.0 from develop and sets its version (the release's first version moment), then tags it vX.Y.0-rc.1 — or vX.Y.0 under bump-strategy=patch. develop gets no version bump |
| gflow bump | Creates next RC tag (v2.6.0-rc.1 → v2.6.0-rc.2) at the tip, with no version change — or sets and tags the next patch version (v2.6.0 → v2.6.1) under bump-strategy=patch |
| gflow sync | Merges release changes into develop for fixes needed immediately |
| gflow finish | Tags the release tip with the clean production tag (v2.6.0) before any merge, then merges into main + develop and cleans up the branch. Under bump-strategy=patch it only merges — the last bump tag is already final |
Staging-tag guard:
gflow finishon a release branch is rejected if HEAD has commits past the latest RC tag (or latest patch tag underbump-strategy=patch). Every commit merged tomainmust have been validated on staging via a tagged deploy. If the guard fires, rungflow bumpto cut the next tag, wait for staging to pass, thengflow finish.
For urgent production fixes:
sequenceDiagram
participant M as main
participant H as hotfix/2.6.1
participant HF as hotfix-fix/2.6.1/crash
participant D as develop
M->>H: gflow → start hotfix fix (auto-creates hotfix/2.6.1)
H->>HF: Creates hotfix-fix/2.6.1/crash
Note over HF: Fix the issue
HF->>H: gflow → finish hotfix fix (PR)
Note over H: gflow → finish hotfix: tags the tip v2.6.1
H->>M: merges into main
H->>D: merges into develop
Note over H: Branch deleted
When a hotfix is finished and a release/* branch is open, gflow also propagates the fix into every open release branch. The landing order is main, then each open release, then develop. Without this, the upcoming release would ship a tree that was never validated on staging in its final form — staging deployed the release without the hotfix, and the release-into-main merge would produce a combined tree at production-tag time that no RC ever covered.
gitGraph
commit id: "v2.6.1" tag: "v2.6.1"
branch develop
commit id: "dev work"
branch release/2.7.0
commit id: "release prep" tag: "v2.7.0-rc.5"
checkout main
branch hotfix/2.6.2
commit id: "fix: prod crash" tag: "v2.6.2"
checkout main
merge hotfix/2.6.2
checkout release/2.7.0
merge hotfix/2.6.2
commit id: "gflow bump" tag: "v2.7.0-rc.6"
checkout develop
merge hotfix/2.6.2
The hotfix (tagged v2.6.2 on its tip) lands on main, release/2.7.0, and develop. Because the release branch advances past its previous RC tag, gflow finish on the release will refuse to run until the operator runs gflow bump to cut v2.7.0-rc.6 — at which point staging redeploys the combined code, so the eventual v2.7.0 production tag is validated end-to-end. This is the same RC-head guard that protects every release path: production never deploys a tree that hasn't been validated on staging.
If the merge into a release branch conflicts, gflow surfaces the error and keeps the hotfix branch alive so you can resolve the conflict and retry. The production tag and the merge into main have already completed at that point — the hotfix is shipped to production; the release branch and then develop are left to finish. Resolve the conflict, git commit the merge, and re-run gflow finish — already-done steps are skipped and the propagation resumes.
gflow already prevents the related "two open hotfixes" or "two open releases" cases at start-time: start.rs reuses an existing branch instead of creating a second one. The only concurrent state allowed is exactly this one — one release + one hotfix.
Two related, opt-in features for teams whose main/develop reject direct pushes, or who keep the version number inside their own repo files (Cargo.toml, package.json, ...). The config file itself is required — see Initialising a repository; with the defaults it selects, a repo behaves exactly as before.
gflow reads three files and merges them. Later layers override earlier ones, key by key — a layer that stays silent about a key never undoes a lower one:
| Layer | File | Written by | Scope | Committed |
|---|---|---|---|---|
| 1 | ~/.gflow/config |
gflow worktree … |
you, in every repository | no |
| 2 | <repo>/.gflow/config |
gflow worktree … --repo |
this repository, everyone who clones it | yes |
| 3 | <repo>/.gflow/config.local |
gflow worktree … --local |
you, in this repository | no (gitignored) |
So: your habits go in layer 1, the team's rules in layer 2, and your exceptions to either in layer 3.
Layers 1 and 2 take every key. Layer 3 takes only the personal ones — worktree, editor,
path. mode, keep-release-branches, and bump-strategy are team decisions: a private
opt-out of mode=protected in a file nobody reviews would defeat the guarantee the committed
file exists to make. A team key found there is reported and ignored.
Layer 1 also makes gflow init optional for a throwaway repository: if ~/.gflow/config
states a policy, a repo with no committed file of its own uses it rather than refusing.
gflow writes and maintains <repo>/.gflow/.gitignore itself, so layer 3 is never committed.
That file ignores itself too, so gflow's bookkeeping never shows up as untracked noise. Your
repository's own .gitignore is never touched.
A committed file, one key=value pair per line, # comments allowed, unknown keys ignored (except a misspelled hook-* key, which is an error):
mode=protected
keep-release-branches=true
| Key | Values | Default | Meaning |
|---|---|---|---|
mode |
free | protected |
free |
free is today's behavior: finish/bump/sync merge and push directly. protected lands every merge into main, develop, or an already-pushed release/* branch via a PR instead. |
keep-release-branches |
true | false |
false |
When true, finish and bump stop deleting the release/*/hotfix/* branch when they're done with it. Work branches (feature/*, fix/*, ...) are never affected. |
bump-strategy |
rc | patch |
rc |
How bump versions staged builds. rc is today's behavior: pre-release tags (v2.6.0-rc.1, -rc.2, …) with one clean tag at finish. patch increments the real patch version at every bump (v2.6.0 → v2.6.1 → …) — see Bump strategy. |
hook-release-start, hook-release-bump, hook-release-finish, hook-hotfix-finish |
a shell command | none | A command gflow runs after that event is done. A key may repeat. See Hooks. |
It also accepts worktree, editor, and path — the same keys gflow worktree --repo writes.
This file is committed, so a setting made here applies to everyone who clones the repository.
Keep machine-specific values (your editor, your worktree directory) in ~/.gflow/config, or
in <repo>/.gflow/config.local when they apply to one repository only.
Any other value is a hard error naming the file, the key, and the accepted values. This is a committed file, not git config: these are team decisions, and a fresh clone must see the same policy everyone else does — git config is per-clone and would silently drift. (Same reasoning as the version script below.) What stays in git config is only what gflow detects and caches for itself: gflow.branch.main.
Some projects can't consume pre-release tags — every staged build needs a real, incrementing version written into the project's own version files. bump-strategy=patch serves those projects:
| Step | rc (default) |
patch |
|---|---|---|
gflow start release |
tags v2.6.0-rc.1 |
tags v2.6.0 (clean) |
gflow bump |
tags v2.6.0-rc.2 at the tip; the version stays 2.6.0, so nothing is committed |
increments the patch: tags v2.6.1; version script gets the new 2.6.1 |
gflow finish |
cuts the clean production tag v2.6.0 on the release tip — the same commit as the last RC — before any merge |
merges only — the last bump tag (e.g. v2.6.2) is already the final version (finish re-pushes it if origin is missing it) |
Everything else is identical: the staging guard still refuses finish when HEAD has commits past the latest tag (gflow bump is the remedy), protected mode still routes version-script commits through a version PR and tags its squash commit, and hotfixes work the same way. One patch-specific subtlety is handled for you: an open release branch's staging tags (v2.6.0, v2.6.1, …) are not production history, so a hotfix started while that release is in flight derives its version from the last shipped tag (e.g. v2.5.4), never from the release's in-flight numbers.
gflow writes vX.Y.Z tags. A tag without the v (X.Y.Z, e.g. cut by a pipeline) is read as the same version — for the latest-version lookup and for deciding that a release or hotfix has shipped.
One consequence to plan for: under patch every tag is a clean vX.Y.Z — there is no tag-shape distinction between staging and production, so CI must gate production on something other than the tag pattern (a branch, the merge to main, or a manual promotion step).
Use mode=protected when main/develop require pull requests (branch protection, required reviews). In this mode:
- gflow merges each landing PR itself. Every landing that would otherwise be a direct push instead opens (or reuses) a PR, and gflow merges it at once with a merge commit, then goes on to the next landing in the same run. When the platform will not merge yet (required checks or reviews pending), gflow turns on auto-complete with a merge commit, prints the PR, and stops.
- Every landing PR's head is a throwaway
landing/*branch cut from the source —landing/release-1.2.0-into-main,landing/hotfix-1.1.1-into-release-1.2.0, and so on. The release/hotfix branch itself is never a PR head, so nothing (a conflict resolution, the platform's auto-delete-head-branches setting) can ever touch it. gflow creates, refreshes, and deletes these branches itself; a landing that predates newer source commits is re-opened with a refreshed landing branch, so no commit can silently miss a target. finish,bump, andsyncexit 0 with the PR pending — nothing is left half-done. Re-run the same command after the PR is merged; it continues from there.- Landings happen one PR at a time, in order:
main, then — for hotfixes — each openrelease/*branch, thendevelop. Only the last landing deletes the source branch (and everylanding/*branch, orphans included) — unlesskeep-release-branches=true, or its tip provably landed nowhere, in which case gflow keeps the branch and tells you how to remove it yourself. - Progress is never stored on disk for a protected finish — there's nothing to resume, because it never merged locally. Each run re-derives what's landed from the hosting platform's PR state and from tags.
- Landing PRs are born mergeable. Before opening (or refreshing) a landing PR, gflow merges the target into the landing branch itself. A new landing branch always gets that merge, also when the target did not move since the cut, so every landing looks the same. A conflict — e.g. on the version file, see Version-file merge-conflict papercut — therefore surfaces immediately in your terminal, mid-run, and gflow announces that your current worktree is now on the landing branch, mid-merge. Resolve the conflicts right there,
git add . && git commit --no-edit, and re-run — the re-run switches the worktree back to the source branch itself, pushes the resolution, and opens the PR conflict-free (git merge --abort && git switch <source>backs out instead). A PR that turns conflicted later because the target moved is healed the same way — just re-run. The release/hotfix branch is never touched by a resolution, and the landing branch is never rebased. - Upgrading with a landing in flight? An open landing PR whose head is the release/hotfix branch itself (opened by an older gflow) is a hard error naming it: merge it and re-run, or close/abandon it and re-run — gflow then reopens it from a landing branch. Already-merged old-style legs are recognized as landed. Landing branches used to be named
finish/*; gflow no longer recognizes those, so land a release or hotfix that is mid-landing with the gflow version that started it, then delete any leftoverfinish/*branches (git push origin --delete <branch>). - That's different from deliberately adding new, unrelated work to a release branch after its
mainPR has merged — don't do that; the clean tag already sits on the tested tip. Ship further fixes as a hotfix instead. If you do, gflow says so rather than letting it pass unnoticed:
⚠ release/2.6.0 has 1 commit after the main landing: not in v2.6.0, and not reaching main.
Release them as a hotfix if they must ship to production.
Those commits still reach develop through the remaining landing, so nothing is lost — they just are not in this release.
A gflow finish on a release branch whose platform merges at once:
$ gflow finish
Tagging the tested tip of release/2.6.0: v2.6.0
✔ Merged https://github.com/org/repo/pull/42 with a merge commit.
✔ Merged https://github.com/org/repo/pull/43 with a merge commit.
Cleaning up release branch...
✔ Release 2.6.0 complete.
When required checks are still running, the run stops at that PR instead:
$ gflow finish
Copied PR title + URL to clipboard.
chore: merge release 2.6.0 into develop
https://github.com/org/repo/pull/43
Waiting for this PR to merge.
Re-run 'gflow finish' to continue after the merge.
Conflicts later (develop moved)? Just re-run — gflow merges `develop` into `landing/release-2.6.0-into-develop` in this worktree and stops there for you to resolve locally if needed.
Auto-complete is on: the platform merges this PR once its checks and reviews pass.
The title + URL pair is printed bare (bold title in a terminal, no prefixes) and copied to your clipboard, so pasting the PR into Slack is select-copy-paste — or just paste. On a machine without a clipboard tool the copy is silently skipped; the block is always printed. The same block is printed for work-branch and version PRs. Styling degrades automatically: bold is dropped when output is piped, when NO_COLOR is set, or when TERM=dumb.
If cleanup finds commits on the branch that never reached any of the landed PRs — e.g. something was pushed to it directly after everything else landed — it keeps the branch instead of deleting it:
⚠ Keeping release/2.6.0: its tip is not part of any landed pull request, so deleting it could lose commits.
Review it, then delete it yourself: git push origin --delete release/2.6.0
Hotfix branches print the same warning, naming the hotfix branch instead.
gflow sync behaves the same way on a release branch ("Re-run 'gflow sync' after the merge."). gflow bump may also defer its RC tag — see below.
An opt-in repo file that lets gflow write the tag-derived version into your own source files at the moments the version changes, instead of you doing it by hand.
- Path:
.gflow/set-version.sh(macOS/Linux) or.gflow/set-version.cmd(Windows) — picked by platform. Both files present is fine; only the other platform's file present (yours missing) is an error naming both paths. Neither present → the feature is off. - Contract: gflow runs the script with
argv[1]set to the cleanX.Y.Zversion (never a-rc.Nform) and the current working directory set to the repo root. - Clean tree required: gflow refuses to run the script on a dirty working tree, so a version commit never sweeps up unrelated local changes.
- No-op is a no-op: if the script leaves the tree unchanged, gflow makes no commit. If it changes files, gflow stages everything (
git add -A) and commitschore: set version {X.Y.Z}. - The three moments it runs: cutting a new release branch (version
X.Y.0);gflow bumpon a release branch (X.Y.0underrc, so nothing changes; the next patch underpatch); creating a new hotfix branch (X.Y.Z).developnever gets a version bump: its version files only change when a release or hotfix lands on it. Reusing an existing release/hotfix branch never re-runs the script.
A script gflow finds but can't run — most often because it's missing its executable bit — names the fix instead of a bare OS error:
Version script .gflow/set-version.sh could not be run: Permission denied (os error 13)
Make it executable: chmod +x .gflow/set-version.sh && git update-index --chmod=+x .gflow/set-version.sh, then re-run the command.
git update-index --chmod=+x is there because git tracks the executable bit itself — a local chmod +x alone doesn't survive a fresh clone. This differs from the platform-mismatch error above in when it can fire: platform mismatch is resolved eagerly, once, at the start of every command, so it fires no matter what you run; a not-executable script is only ever spawned by the commands that actually run it (cutting a release/hotfix branch, gflow bump) — that asymmetry can be surprising the first time you hit it.
In protected mode, every version change goes out as its own PR, from a branch named after the version and its target. gflow pushes the release or hotfix branch first, then the version branch:
version/X.Y.0-into-release-X.Y.0— the release cut, and underbump-strategy=patcheverygflow bump(version/X.Y.Z-into-release-X.Y.0).version/X.Y.Z-into-hotfix-X.Y.Z— the hotfix start.
Free mode never needs one: gflow commits the version on the branch itself.
gflow completes these PRs itself, in this order:
- Merge now with squash (
gh pr merge --squash --delete-branch/az repos pr update --status completed --squash true --delete-source-branch true). The release tag then sits on that squash commit, cut in the same run — each tag marks the exact tested build. - Auto-complete with squash, when the platform will not merge yet (pending checks or reviews). The platform merges once they pass, and the tag waits for a re-run.
- You merge it, when both fail. gflow prints a warning with both reasons; the command still succeeds.
These branches are machine-owned: don't commit to them, and gflow finish does not recognize them. Older release-chore/* branches are no longer recognized either — merge or close their PRs before you upgrade.
gflow never tags a commit that isn't yet on the branch being tagged. When a version PR cannot merge at once, the tag waits for its merge:
Copied PR title + URL to clipboard.
chore: set version 2.6.0
https://github.com/org/repo/pull/44
The RC tag is deferred until this PR merges. After it merges, re-run 'gflow bump' to cut the tag.
Re-running gflow bump after that PR merges tags the PR's merge commit directly — it does not re-run the script. For a deferred release cut, that first tag still fires release-start. A script whose output depends on repo history (e.g. a build number) would otherwise produce a different diff on every re-run; tagging the merge commit is what makes the RC converge to one tag instead of drifting forever.
Because develop gets no version bump, it carries the version of the last release or hotfix that landed on it, so landing on develop rarely conflicts on the version line. A hotfix that lands on an open release still can: e.g. a hotfix's 1.2.1 against the release's 1.3.0 — keep the release's version. Free mode surfaces this as an ordinary resumable merge conflict (resolve it, git commit, re-run gflow finish); protected mode surfaces it while merging the target into the landing/* branch — resolve it there mid-run, commit, and re-run. gflow does not auto-resolve version-file conflicts — it has no way to know which lines the script owns.
gflow start hotfix-fix --no-checkout (or an active worktree flow) creates the hotfix branch without switching to it, so gflow cannot safely commit version files there — HEAD stays on your current branch. gflow skips the script and warns instead of guessing:
⚠ Version script not run: hotfix/1.2.1 was created without checkout, so gflow cannot commit version files there.
Recover manually: git switch hotfix/1.2.1, run set-version.sh 1.2.1, commit, and push.
A repo can run its own commands after a release or hotfix event, for example a deploy. Declare them in the committed .gflow/config, one key per event:
hook-release-start=<command>
hook-release-bump=<command>
hook-release-finish=<command>
hook-hotfix-finish=<command>
- Only the events you need. An event without a key runs nothing.
- A key may repeat. Its commands run in file order, each once. When one fails, the next still runs. This is how a backend and an app in one repo both react to the same event.
- Typos are errors. An unknown
hook-*key (hook-relase-bump) stops gflow and lists the valid keys. The same command twice for one event is an error too. - Committed file only. Hook keys in
~/.gflow/configor.gflow/config.localare ignored, with a warning.
An event fires after it is completely done: merged, tagged, pushed, and cleaned up.
| Event | Fires when | GFLOW_TAG |
GFLOW_TAG_KIND |
|---|---|---|---|
release-start |
gflow start release created the branch and pushed its first tag. A cut whose version PR waited fires it on the gflow bump that cuts that first tag. Reusing an open release fires nothing. |
v1.2.0-rc.1 (v1.2.0 under patch) |
rc (final) |
release-bump |
gflow bump pushed the new tag. A protected-mode bump fires on the run that cuts the deferred tag. |
v1.2.0-rc.2 (v1.2.1 under patch) |
rc (final) |
release-finish |
gflow finish completed the release, under both bump strategies. Under patch it pushes no new tag: GFLOW_TAG is the existing final tag. |
v1.2.0 (v1.2.1 under patch) |
final |
hotfix-finish |
gflow finish completed the hotfix. |
v1.2.1 |
final |
A work-branch finish, a sync, and a protected-mode finish that still waits for a landing PR fire nothing. A failed tag push fires nothing.
Each command runs through the shell (sh -c, or cmd /C on Windows) in the repository's main working tree, with stdin, stdout, and stderr connected to your terminal, and with:
| Variable | Value |
|---|---|
GFLOW_EVENT |
release-start, release-bump, release-finish, or hotfix-finish |
GFLOW_TAG |
the tag of the event |
GFLOW_TAG_KIND |
rc or final |
GFLOW_BRANCH |
the event's branch, for example release/1.2.0 |
GFLOW_REMOTE |
the remote the tag is on (origin) |
A pull request can change .gflow/config, so gflow does not run a hook command it has not seen before. Before the first run of a command, and again when its text changes, gflow shows it and asks Run this hook? [y/N]. A yes is stored as a SHA-256 of the command, for this repository, in ~/.gflow/hook-approvals, never in the repository. Without a terminal (CI), an unapproved command does not run: gflow prints one line and goes on.
Each command runs at most once per event and tag on a clone. gflow records each event in .git/gflow-hooks/ before it runs anything, and each command just before that command starts. If gflow stops (a crash, or a merge conflict in a finish), the next gflow run in the repository runs the commands that did not run yet, exactly once. An interrupted finish fires nothing until you resume it and it completes.
--no-hooks on gflow start release, gflow bump, and gflow finish skips the event's commands for good and prints each one as a rerun command.
The release or hotfix stays done: nothing is rolled back, and a resumed finish does not see the hook as an unfinished step. gflow prints one line per failed command, with the exit code and the exact command to rerun it by hand, and exits non-zero at the end:
✖ Hook failed (exit 1). Rerun: cd '/repos/facedown' && GFLOW_EVENT='release-bump' GFLOW_TAG='v1.2.0-rc.2' GFLOW_TAG_KIND='rc' GFLOW_BRANCH='release/1.2.0' GFLOW_REMOTE='origin' sh -c './deploy/web.sh staging'
A web backend deploys every staged tag to staging and the final tag to production:
hook-release-start=./deploy/web.sh staging "$GFLOW_TAG"
hook-release-bump=./deploy/web.sh staging "$GFLOW_TAG"
hook-release-finish=./deploy/web.sh production "$GFLOW_TAG"
hook-hotfix-finish=./deploy/web.sh production "$GFLOW_TAG"
A mobile app ships one build: it builds and uploads to the stores' test track on start and bump, and promotes that same tested build at release finish. A hotfix has no tested build to promote, so it builds again:
hook-release-start=./app/build-and-upload.sh test-track "$GFLOW_TAG"
hook-release-bump=./app/build-and-upload.sh test-track "$GFLOW_TAG"
hook-release-finish=./app/promote.sh production "$GFLOW_TAG"
hook-hotfix-finish=./app/build-and-upload.sh production "$GFLOW_TAG"
Both in one repository (a backend and an app): repeat the keys. Each event runs the web line first, then the app line:
hook-release-start=./deploy/web.sh staging "$GFLOW_TAG"
hook-release-start=./app/build-and-upload.sh test-track "$GFLOW_TAG"
hook-release-bump=./deploy/web.sh staging "$GFLOW_TAG"
hook-release-bump=./app/build-and-upload.sh test-track "$GFLOW_TAG"
hook-release-finish=./deploy/web.sh production "$GFLOW_TAG"
hook-release-finish=./app/promote.sh production "$GFLOW_TAG"
hook-hotfix-finish=./deploy/web.sh production "$GFLOW_TAG"
hook-hotfix-finish=./app/build-and-upload.sh production "$GFLOW_TAG"
When starting a release-fix or hotfix-fix, gflow automatically resolves the integration branch:
flowchart TD
A[Start release fix] --> B{release/* branch exists?}
B -- Yes --> C[Use existing branch]
B -- No --> D[Find latest semver tag]
D --> E[Bump minor version]
E --> F[Create release/X.Y.0 from develop]
F --> G[Set version X.Y.0, tag X.Y.0-rc.1]
G --> H[Create release-fix branch]
C --> H
I[Start hotfix fix] --> J{hotfix/* branch exists?}
J -- Yes --> K[Use existing branch]
J -- No --> L[Find latest semver tag]
L --> M[Bump patch version]
M --> N[Create hotfix/X.Y.Z from main, set version X.Y.Z]
N --> O[Create hotfix-fix branch]
K --> O
All commits and PR titles generated by gflow follow Conventional Commits:
| Branch type | PR title format |
|---|---|
feature/{name} |
feat: {name} |
fix/{name} |
fix: {name} |
chore/{name} |
chore: {name} |
docs/{name} |
docs: {name} |
refactor/{name} |
refactor: {name} |
release-fix/{v}/{name} |
fix: {name} |
hotfix-fix/{v}/{name} |
fix: {name} |
Dashes in the branch name are converted to spaces in the PR title (e.g. feature/foo-bar → feat: foo bar, hotfix-fix/2.1.0/null-crash → fix: null crash), keeping the squash-merged history readable.
Merge commits and tags also follow the convention:
chore: bump version to v2.6.0-rc.2(tag message at the release cut and every bump)chore: release 2.6.0(tag message for finish release)chore: merge release 2.6.0 into mainchore: merge release 2.6.0 into developchore: sync release 2.6.0 with developchore: hotfix 2.5.4(tag message)chore: merge hotfix 2.5.4 into mainchore: merge hotfix 2.5.4 into release/2.6.0(when a release branch is open)chore: merge hotfix 2.5.4 into developchore: merge main into landing/hotfix-2.5.4-into-main(protected mode: the target merged into its landing branch)chore: set version 2.6.0(version-script commit — see Landing Modes & Version Script)
gflow uses SemVer pre-release tags to let CI systems distinguish staging from production deployments:
- RC tags (
v1.2.0-rc.1,v1.2.0-rc.2) → staging/test deployments - Clean tags (
v1.2.0) → production deployments
This follows the convention used by Kubernetes, React, Node.js, Docker, and .NET.
sequenceDiagram
participant D as develop
participant R as release/2.6.0
participant M as main
participant CI as GitHub Actions
D->>R: gflow start release
Note over R: Tagged v2.6.0-rc.1
R-->>CI: v*-rc.* → deploy staging
Note over R: Fix found, bump
Note over R: Tagged v2.6.0-rc.2
R-->>CI: v*-rc.* → deploy staging
Note over R: gflow finish release: tags the tested tip v2.6.0
R-->>CI: v* (no dash) → deploy production
R->>M: merges into main
name: Deploy Staging
on:
push:
tags: ['v*-rc.*']
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Deploying ${{ github.ref_name }} to staging"name: Deploy Production
on:
push:
tags: ['v*']
jobs:
deploy:
if: "!contains(github.ref_name, '-')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Deploying ${{ github.ref_name }} to production"Why the
ifcondition? GitHub Actions tag patterns cannot exclude —v*matches bothv1.2.0andv1.2.0-rc.1. Theif: "!contains(github.ref_name, '-')"condition skips the job when the tag contains a hyphen (all RC tags do). The staging workflow doesn't need this becausev*-rc.*only matches RC tags.
Apple's App Store rejects version strings with hyphens — CFBundleShortVersionString only accepts X.Y.Z format. The RC tag is a CI signal, not the app version.
Your CI pipeline extracts the clean version from the tag and sets the build number separately:
name: Deploy Staging
on:
push:
tags: ['v*-rc.*']
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
run: |
# Tag: "v1.2.0-rc.2" → version: "1.2.0"
VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//' | sed 's/-.*//')
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Build & deploy to TestFlight
run: |
flutter build ios \
--build-name=$VERSION \
--build-number=${{ github.run_number }}
# TestFlight shows: "1.2.0 (47)"Traceability: When a tester reports "bug on 1.2.0 (47)", check CI run #47 → triggered by tag v1.2.0-rc.2 → git show v1.2.0-rc.2 → exact commit.
src/
├── main.rs — Composition root: builds adapters, preflight, hands off to lifecycle
├── lib.rs — Library root, re-exports all modules
├── action.rs — Action enum: the single currency both interfaces resolve into
├── cli.rs — CLI subcommands (clap); resolves to Action
├── lifecycle.rs — Resume lookup, stash/state ordering contract, dispatch
├── mainline.rs — Resolves the repo's mainline branch (main vs master)
├── git/
│ ├── mod.rs — Git trait, GitCli adapter, CommandRunner port
│ └── branch.rs — Branch type detection and parsing
├── hosting/
│ ├── mod.rs — HostingPlatform trait + CliRunner port
│ ├── detect.rs — The GitHub repository from the origin remote URL
│ └── github.rs — GitHub implementation via gh CLI
├── flows/
│ ├── start.rs — Start work/release-fix/hotfix-fix
│ ├── finish_work.rs — PR creation for work branches
│ ├── finish_release.rs — Bump, sync, finish release (idempotent)
│ └── finish_hotfix.rs — Finish hotfix with auto-tag, propagate to open releases (idempotent)
├── state.rs — Persisted finish state for conflict recovery
├── repo_config.rs — The config layer stack (~/.gflow/config, <repo>/.gflow/config,
│ <repo>/.gflow/config.local), its key=value format, and the
│ 4.0.x git-config migration
├── version_script.rs — Discovery + execution port for .gflow/set-version.{sh,cmd}
├── version.rs — SemVer parsing and bumping
├── menu.rs — Interactive menus via crossterm; implements Prompter
├── prompt.rs — Prompter trait: selection and text input as a port
├── editor.rs — Editor trait for opening worktrees
├── worktree.rs — Worktree config, path resolution, and setup wizard
└── test_support.rs — Shared helpers for inline unit tests (test builds only)
The Git, HostingPlatform, Editor, and Prompter traits keep every flow fully mockable and keep the hosting provider swappable (GitHub today).
Two narrower ports sit one level down, under the adapters themselves:
CommandRunner (git/mod.rs) and CliRunner (hosting/mod.rs) own nothing but
the process spawn. That keeps every subprocess call in a single implementation
each (SystemRunner, SystemCli) while leaving the adapters' own logic — git's
exit-code semantics, output parsing, and the gh/az reuse-vs-create policy —
testable without those CLIs installed.
gflow is developed test-first (TDD) with a coverage ratchet:
cargo test— runs the full suite. Flow tests use mocks. Process tests use temporary fake tools and isolated terminals.cargo llvm-cov --summary-only --fail-under-lines 100 --fail-under-functions 100 --fail-under-regions 100— strict coverage check (brew install cargo-llvm-cov).- Coverage may never decrease:
.claude/hooks/coverage-baseline.txtrecords the high-water mark and.claude/hooks/tdd-gate.shenforces it (wired as a Claude Code Stop hook).
Line, function, and region coverage reach 100% on macOS. The coverage gate checks exact counts for all three. Its cache tracks untracked Rust contents, the lockfile, gate policy, and baseline changes. Coverage includes startup, process adapters, terminal rendering, input, and error handling. Process fixtures use private directories and environment variables. Terminal tests allocate their own terminal. Tests do not use your repositories, hosting accounts, browser, clipboard, or terminal. Unix process and terminal tests run on macOS and Linux. Portable mock tests also run on Windows.