Command line utility to manage git workflow, connect to issue trackers, and standardize commit messages through a TUI.
- Go 1.25+
- Git
git clone https://github.com/piprim/git-zf.git
cd git-zf
make
sudo make install # copies binary to $(git --exec-path)On macOS with Homebrew Git the exec-path is user-writable; omit
sudo.
go install github.com/piprim/git-zf@latest
sudo git-zf install # copies binary to $(git --exec-path)If
git --exec-pathis user-writable, omitsudo.
git zf versiongit zf uninstall
$ git zf commit
Usage:
git-zf commit [flags]
Flags:
-a, --all stage all tracked modified/deleted files before committing
--allow-empty allow a commit with no changes
--amend replace the tip of the current branch
--author string override commit author as "Name <email>"
-h, --help help for commit
-n, --no-verify bypass pre-commit and commit-msg hooks
-s, --signoff add Signed-off-by trailer to the commit message
If any commit flag is passed, the options page of the TUI form is skipped and the flags are used directly.
$ git zf issue
$ git zf issue start
$ git zf issue list
$ git zf issue close
issue start — start work on an issue: optionally fetch open issues from a tracker (Redmine), or enter an issue ID, title, and type manually. A properly named branch is created and checked out automatically. Branch state is tracked in .git/git-zf.db.
If a tracker is configured, issue start pre-selects fetching from the tracker; after picking an issue you can update its status to "In Progress" in one step.
issue list — list issues enriched with local branch data. When a tracker is configured it is the primary source; the local store is used as fallback.
Columns: Issue ID · [Project] · Title · Branch · Local Status · Tracker Status · Created. The Project column appears automatically when issues span more than one project. "∅" means the issue has no local branch yet; "N.A." means no tracker is configured.
issue list flags:
--status string filter by status: open, closed, all (default: open)
--stdout print table to stdout without TUI
--json print JSON array to stdout
In the interactive TUI:
/— filter rows in real time (matches any column, case-insensitive);Enterto confirm,Escto cleartab— cycle status filter (Open → Closed → All)p— open the project picker (↑/↓ or j/k to navigate,Enterto confirm,Escto cancel)q— quit
issue close — close an in-progress issue: pick from the list of in-progress branches (the currently checked-out branch is pre-selected), merge into the base branch, update the local store, and optionally update the tracker status and delete the local branch.
The close flow:
- A conflict dry-run is performed via
git merge-tree— if conflicts are detected the command aborts without touching anything. - Choose merge strategy: Rebase (default, recommended — single clean commit, submodule-safe), Squash (
git merge --squash, fast but not submodule-safe), or Classic (--no-ff, preserves full history). For Rebase and Squash, the final commit is composed through the commitizen TUI form, pre-filled from the branch's issue ID and type. - Confirm the merge. After a successful merge the branch is marked as
mergedin the local store and the issue is marked asclosed. - If a tracker is configured, a status picker lets you update the remote issue status (or skip).
- Optionally delete the local branch. Safe delete (
-d) is used for classic merges; force delete (-D) for Squash and Rebase (neither preserves ancestry, so git requires-D).
| Strategy | Mechanism | History on base | Submodule-safe |
|---|---|---|---|
| Rebase (default) | Real git merge origin/<base> + git reset --soft origin/<base> |
one clean commit | ✅ yes |
| Squash | git merge --squash |
one commit, no merge parent | --squash is known to mishandle submodule gitlinks |
| Classic | git merge --no-ff |
merge commit + full feature history | ✅ yes |
Rebase produces the same end state as Squash (one clean commit on the local base branch) but uses a different mechanic under the hood that correctly handles submodule pointers. The work happens on your feature branch, then fast-forwards onto local base. Concretely:
1. Pre-flight
- `git status --porcelain --untracked-files=no` → abort if dirty.
(Untracked files are intentionally ignored — they survive rollback
and don't put your work at risk.)
2. Setup
- Checkout the feature branch (idempotent — no `post-checkout` hook
fires if you're already there).
- Capture the feature tip SHA for safe rollback.
- `git fetch origin` to pick up the latest remote base.
- `git merge-base --is-ancestor feature origin/<base>` → if feature
has no commits ahead of `origin/<base>`, abort cleanly
("already integrated?"). Avoids producing an empty commit.
- `git merge-tree` dry-run against `origin/<base>` → abort with the
conflict file list if the endpoint can't merge cleanly.
3. Execute
- `git merge --no-edit origin/<base>` — a *real* merge, which handles
submodule gitlinks correctly. `--no-edit` suppresses $EDITOR for
the transient merge-commit message.
- `git reset --soft origin/<base>` — collapse the merge into one
staged diff against `origin/<base>`. HEAD moves back, working tree
stays at the merged state, the index is fully staged.
4. TUI commit form
- The commitizen form opens pre-filled with type, scope, and the
subject `Squashed close of <feature-tip> into <origin-base-tip>.`
- Submit → `git commit` lands one clean commit on the feature branch.
- Esc / Ctrl+C / hook rejection → atomic rollback: feature is reset
to its captured original tip via `git reset --hard`. You see
`Rolled back: feature branch "<name>" restored to <sha>` on stderr.
5. Deploy
- Checkout local `<base>` (idempotent).
- `git merge --ff-only feature` to land the new commit.
6. Bookkeeping
- Update local store and (if configured) tracker; prompt to delete
the feature branch.
A git rebase mechanism would have replayed the feature's commits one at a time. If commit #2 conflicts but commit #4 fixes it, the merge-tree dry-run reports the endpoint as clean — but the rebase still halts mid-way on commit #2, dumping you into a detached HEAD with conflict markers. A real merge looks at the endpoint of both branches, which is exactly what merge-tree predicts, so the dry-run's "clean" verdict is a guarantee.
Submodules are handled correctly because the gitlink quirk only affects merge --squash. Plain git merge resolves submodule pointers three-way like any other file.
The rebase orchestrator captures the feature ref's SHA before mutating anything. From that point until the final commit lands, any failure — TUI abort, pre-commit hook rejection, commit-msg hook rejection, signing failure — triggers git reset --hard <featureOrigSHA> on the feature branch. The feature is restored atomically and you see a Rolled back: … message on stderr. If the rollback itself fails, both the original error and the rollback error are surfaced so you know the repo is in a half-state and why.
The post-commit failure mode is treated differently. If the commit landed on the feature branch but git merge --ff-only refuses (because local <base> has diverged from origin/<base>), the commit is not rolled back — it already exists as a clean, valid commit on the feature branch. Instead you see:
Commit created on "<feature>" but local <base> has diverged from origin/<base>.
Run `git pull --ff-only` on <base>, then `git merge --ff-only <feature>` to land it.
The store and tracker are not updated, the delete-branch prompt is skipped, and the close exits cleanly. You reconcile local base manually and FF the feature commit yourself.
- Rebase — your repo has submodules, or you want a clean linear history with one commit per issue. Recommended default.
- Squash — no submodules involved, you want the existing
git merge --squashsemantics (fast, fewer git operations). - Classic — you want to preserve the feature's full commit history on the base branch via a merge commit. Use when intermediate commits have value (large features, bisect surface, audit trail).
$ git zf branch new # create a branch with manual input
$ git zf branch list # list tracked branches
$ git zf branch merge # merge a branch via TUI
$ git zf branch prune # clean up stale DB records
branch new is the same flow as issue start but pre-selects manual input.
branch list flags:
--status string filter by status: in_progress, merged, all (default: in_progress)
--stdout print table to stdout without TUI
--json print JSON array to stdout
branch prune flags:
--base string base branch for merge detection (default: auto-detected)
--dry-run show what would be pruned without executing
$ git zf config show
$ git zf config init
config show — print the active config file path followed by the effective configuration as formatted JSON. The issue-tracker.token field is masked as *** so the output is safe to share or paste into issues.
Example output:
Config file: /home/user/.git-zf.json
{
"commit-types": [...],
...
"issue-tracker": {
"type": "redmine",
"url": "https://redmine.example.com",
"token": "***"
}
}
If no config file is found the header reads no config file found (built-in defaults apply).
config init — interactively write the default config file. The destination is chosen based on context:
- Outside a git repo, no home config: the home path is selected automatically without a prompt.
- Inside a git repo or a home config already exists: a TUI picker lets you choose between
$HOME/.git-zf.jsonand<repo>/.git/.git-zf.json. The repo-level file lives inside.git/so it is never committed and cannot leak secrets. It takes precedence over the home file when present.
If the target file already exists a confirmation prompt is shown before overwriting.
For Bash users, set up completion with these steps:
- Generate Completion Script
git zf completion bash > ~/git-zf-completion.bash - Install System-Wide (recommended)
sudo mv ~/git-zf-completion.bash /etc/bash_completion.d/ - Or Install User-Only
mkdir -p ~/.local/share/bash-completion/completions mv ~/git-zf-completion.bash ~/.local/share/bash-completion/completions/git-zf
- Reload Your Shell
source ~/.bashrc
Take a look at the Cobra Shell-Specific Configuration for the other supported shells.
Usage:
git-zf [command]
Available Commands:
branch Manage local branches
commit Record changes to the repository
completion Generate completion script
config Manage git-zf configuration
help Help about any command
install Install this tool to git-core as git-zf
uninstall uninstall this tool from git-core
issue Manage issues
version Print version information and quit
Flags:
-d, --debug debug mode, output debug info to debug.log
-h, --help help for git-zfConfig file: .git-zf.json (JSON). Two locations are supported; the repo-level file takes precedence over the home file:
| Location | Path | Notes |
|---|---|---|
| Home | $HOME/.git-zf.json |
Applied everywhere |
| Repo | <repo>/.git/.git-zf.json |
Inside .git/ — never committed, can contain secrets |
Use git zf config init to create the file interactively, or git zf config show to inspect the currently active configuration.
The default configuration is embedded in config/default.json.
Override the list of commit types shown in the type selector:
{
"commit-types": [
{ "name": "feat", "desc": "A new feature" },
{ "name": "fix", "desc": "A bug fix" },
{ "name": "chore", "desc": "Build process or tooling changes" }
]
}Override the form fields and/or the message template:
{
"commit-message": {
"items": [
{ "name": "scope", "desc": "Scope (users, db, poll…):", "form": "input" },
{ "name": "subject", "desc": "Concise description. Imperative, lower case, no final dot:", "form": "input", "required": true },
{ "name": "body", "desc": "Motivation for the change:", "form": "multiline" },
{ "name": "footer", "desc": "Breaking changes and referenced issues:", "form": "multiline" }
],
"template": "{{.type}}{{with .scope}}({{.}}){{end}}: {{.subject}}{{with .body}}\n\n{{.}}{{end}}{{with .footer}}\n\n{{.}}{{end}}"
}
}Branch names follow the format {issue-id}@{type}@{slugified-title}@{short-uuid}, e.g.:
ABC-42@feat@add-oauth-login@550e8400
To override the base branch (default: auto-detected from origin/HEAD, then main, then master):
{
"branch": {
"base": "develop"
}
}git zf issue start and issue list can fetch open issues assigned to you from a project tracker. Supported trackers: Redmine and GitHub.
Add an issue-tracker section to .git-zf.json:
Redmine
{
"issue-tracker": {
"type": "redmine",
"url": "https://redmine.example.com",
"token": "YOUR_API_KEY"
}
}GitHub (public or GitHub Enterprise)
{
"issue-tracker": {
"type": "github",
"url": "https://api.github.com",
"token": "ghp_yourPersonalAccessToken"
}
}For GitHub Enterprise, set url to your instance API root, e.g. https://github.example.com/api/v3/.
| Key | Description |
|---|---|
type |
Tracker type: "redmine" or "github". |
url |
Base URL of the tracker API. For GitHub use https://api.github.com. |
token |
API key (Redmine) or personal access token with repo scope (GitHub). |
projects |
Optional list of projects to show. Redmine: project slugs or numeric IDs. GitHub: "owner/repo" strings. When omitted all assigned issues are shown. |
Filtering by project
Use projects to limit which repositories or Redmine projects appear in issue list:
{
"issue-tracker": {
"type": "github",
"url": "https://api.github.com",
"token": "ghp_...",
"projects": ["myorg/backend", "myorg/frontend"]
}
}Note for
UpdateIssueStatusvia GitHub: because GitHub's update endpoint requires theowner/repo, exactly one entry must be present inprojectswhen usingissue closewith a GitHub tracker.
When a tracker is configured:
issue startasks whether to fetch issues from the tracker.- If yes, open issues assigned to you are listed; type any key to filter the list, pick one and select a branch type.
- After the branch is created, a status picker shows the live list of statuses from the tracker; pick one or skip.
- If the tracker is unavailable or returns no issues, the flow falls back to manual input.
issue closeshows the same live status picker after merging, so you can move the issue to "Done", "Closed", or any other status in a single step.
When you run git zf commit on an issue branch (e.g. ABC-42@feat@add-oauth@550e8400), the issue ID is automatically pre-filled into the commit form — into scope if that field exists, otherwise footer, otherwise subject as a fallback. The pre-fill is a hint only; you can edit or clear it before confirming.
