🌐 Sprache / Language: English · Deutsch
“It's like a jungle sometimes”
— Grandmaster Flash and the Furious Five, “The Message”
A fast terminal overview (TUI) of every Git repository below the current directory, so you can see what still needs attention and tidy it up on the spot.
Green means clean and in sync with your remote; red and yellow mean something is
left over. One Python file, standard library only — no pip install, no daemon,
no registration of repositories. It scans whatever is below the directory you
start it in.
Every screen in this README is generated from the real program on the --demo sandbox — python3 docs/make-screens.py (and --check in CI). No screenshots to retake when the UI changes. The commands in the log below the list were really run on the sandbox: a stash pop and a pull.
Try it without touching your own repositories:
python3 gitmaster_flash.py --demo--demo builds a throwaway sandbox of fake repositories covering every state and
opens the UI on it. The folder lives in your temp directory; delete it when done.
git clone https://github.com/DanielMuellerIR/gitmaster_flash.git
cd gitmaster_flash
./install.sh # self-test, then registers the `gmf` shell wrapper
gmf ~/projects # or just `gmf` for the current directoryThree keys carry you through the first session:
↑/↓picks a repository (←/→jump a whole column in the compact view); the ones needing attention are already at the top.Ashows what changed in a file,Ccommits it with a guided helper.Hlists every Git command gmf ran for you — that is how you pick up the syntax without memorizing it.
Nothing is ever pushed, dropped or deleted without a confirmation that names the exact command first. Details are further down; you do not need them to start.
With up to 20 repositories gmf opens in the detail view: one full line each,
with counts, branch and remotes. Above that the same list turns into a long
scroll, so it starts in the compact view instead — one mark plus the name per
repository, filled column by column like ls, using the width of the window
rather than its height. That is the screen at the top of this page; the threshold
is compact_from in config.json.
The detail view deliberately remains the default for 20 repositories or fewer:
at that size, having every count, branch and remote directly on screen is still
the clearest overview. The compact view is its natural evolution as the
collection grows, not a replacement. It gets you to a repository in a few
keystrokes; press M there to see all of that repository's details.
The mark is the whole status, condensed into one field:
| Mark | Meaning |
|---|---|
| ✔ | clean and in sync |
| ● | modified, deleted or untracked files |
| ⚠ | merge conflict |
| ⚑ | stash present |
| ↑n / ↓n | ahead of / behind the sync remote |
| ⇅ | diverged (both ahead and behind) |
| ✘ | repository scan error or last fetch failed for a remote |
| ? | no sync remote, no remote branch, or detached HEAD |
When several states apply, the mark shows the most urgent one: ✘, then ⚠,
●, ⚑, the sync distance, and finally ?. Red means an error, conflict,
local change, missing incoming commits or divergence; yellow marks a stash,
outgoing commits or missing sync relationship; green ✔ means clean and
synchronized. M or I reveals the details behind the condensed mark.
M switches between compact and detail view and keeps the selected repository,
so you can find a repository in the wide overview and then work on it in detail.
←/→ move one column at a time, which crosses 60 repositories in a few
keystrokes. Expanding files and stashes stays in the detail view.
- remote names are always visible — every line ends with all configured remotes, even when everything is synchronized. Their order is the private sync remote first, other remotes next, and GitHub at the far right.
- ↑n / ↓n next to a remote — commits ahead of / behind that exact remote for
the current branch, based on the last fetch.
Rrefreshes every remote in every repository withgit fetch --allwithout changing a working tree. - M / D / U — number of modified, deleted and untracked files.
- ⚑Stash:n — stashes that exist in the repo (easy to forget, so it is shown).
- ⚠conflict:n — unmerged files, e.g. after a
git stash popthat did not apply cleanly. Kept separate from "modified", because it needs different work. - Warnings such as "no sync remote" or "branch not on remote".
Repositories that need attention sort to the top, clean ones to the bottom. Both views use that same order, so in the compact view the leftmost column is the one worth reading.
The last commands stay below the list: three lines in the detail view, and in the
compact view everything the columns leave over. Tab moves the focus there — in
the detail view the pane grows to a third of the window for it. The selection bar
moves with the focus: while you are in the log, the repository list has none, so
it is always obvious which pane the arrow keys belong to. ↑/↓ walk through the
commands, Tab returns to the list — both sides remember where you were.
Cancelled dialogs appear as ⊘ … (not run — cancelled), so the log never
suggests something ran that did not. Two identical fetch lines are no glitch
either: gmf fetches once before it asks and once after you confirm, and only acts
if nothing moved in between (see "Safe push and pull").
H shows the same log in full, above the safety rules. Read-only scan commands
are deliberately left out — they would bury the interesting lines. Arguments are
quoted the way a shell needs them, so a line can be typed or pasted as-is, and
destructive dialogs show the command before you confirm it: you see
git remote remove github while deciding, not afterwards.
Every shortcut is permanently visible in the footer, so there is nothing to
memorize. Case does not matter — f works like F.
| Key | Action |
|---|---|
| ↑ / ↓ | select a repository |
| → / ← | expand / collapse (files with M/D/U/C, stashes) |
| M | switch between compact and detail view |
| Tab | move the focus to the command log and back |
| ⏎ | quit and cd into the repository (needs the gmf wrapper, see below) |
| E | open the repository in a configured app (add your own in config.json) |
| A | look at the changes: pick a file, see its diff; there Z discards it |
| C | commit helper (see below) |
| P | safely push the current branch to the private sync remote |
| L | safely fast-forward the current branch from the private sync remote |
| G | guarded GitHub push with outgoing-commit/file preview and typed confirmation |
| H | show the command log of this session plus the Git safety rules |
| I | repository details and remotes; there: T test a remote, X remove one |
| U | apply the latest stash (git stash pop, with confirmation) |
| S | view the latest stash as a diff (read-only, scrollable) |
| D | drop the latest stash (git stash drop, with confirmation) |
| R | reload everything including git fetch --all |
| Q | quit |
A stash is never popped onto a tree that already has conflicts — resolve those first. Its preview includes untracked and binary files; a failed or unexpectedly empty Git preview is labelled explicitly before the destructive drop action remains available.
→ shows that a file changed; A shows what changed in it. Pick a file with
↑/↓ (or Tab), press ⏎, and its diff opens in the scrollable viewer —
including new files, which git diff normally ignores, and deleted ones.
Changes · api-gateway
M README.md
M server.py
U notes.txt
D old-config.yml
↑/↓ or Tab select file · ⏎ show diff · Z discard · Q/Esc back
Looking is purely read-only: neither the index nor the working tree is touched.
Z discards the selected file, and it is the one place in gmf that really
throws away an uncommitted change. It sits here on purpose — where the file list
is in front of you and ⏎ shows the diff first.
What Z does depends on the state of the file:
- Modified, deleted or staged: back to the state of the last commit
(
git restore --source=HEAD --staged --worktree). History stays untouched; only the uncommitted change is lost. There is no undo command for that, and the dialog says so — it also names the extent in changed lines, so a file with real work in it looks different from one that a program merely touched. - Newly added: the file is in no commit, so there is no earlier state to go back to. It only loses its staging and stays on disk as an untracked file — gmf does not delete it.
- Untracked, merge conflict or rename: refused, with the reason shown above the footer. Untracked files were never in Git, so removing one would be deleting, not discarding. A rename consists of two entries — taking back one half would leave the other behind.
From two changed tracked files on, the same dialog offers a second route with
A: all changes in the repository go into a stash instead of into nothing,
after a confirmation of their own. That is the widening for the case where you
did not judge every file individually. Afterwards the content stays visible and
within reach: S shows it, U brings it back, D drops it. Untracked files
are left alone here as well.
I opens a scrollable overview for the selected repository. It shows the path,
branch and full HEAD, latest commit, history size, upstream ahead/behind state,
working-tree counts, stashes, tags at HEAD, and every remote.
Fetch and push addresses are listed separately because Git can configure them to
different targets. GitHub remotes additionally get a credential-free
https://github.com/… web URL that can be opened directly from supporting
terminals. Embedded URL credentials, query parameters, and fragments are never
displayed. Opening the view changes nothing.
The remotes are selectable: ↑/↓ or Tab moves the highlight from one remote
block to the next, PgUp/PgDn scrolls the text.
T tests the selected remote. It runs git ls-remote, which only asks for
the remote's ref list — no objects are transferred and nothing changes locally.
The answer distinguishes the cases that otherwise look identical:
| Result | Meaning |
|---|---|
| exists and answers (n branches) | address correct, access works |
| answers but has no branches | reachable, repository still empty |
| address reachable, but no repository there | deleted, renamed, or no access |
| server wants a login | credential helper or SSH key missing |
| SSH host key unknown or changed | connect once in a terminal and verify it |
| host name does not resolve | no network or DNS problem |
| no connection to the host | offline, firewall, or the server is down |
| server replied with an error | problem on their side, not your repository |
| no answer within n seconds | network or server too slow |
The info view also keeps Git's own error message as evidence, underneath the plain-language classification.
X removes the selected remote after a confirmation that spells out exactly
what happens. This is a local Git configuration change only: the
[remote "<name>"] section disappears from .git/config, its remote-tracking
branches refs/remotes/<name>/* are deleted, and a local branch that tracked it
loses its upstream setting. Commits, files, branches and stashes stay untouched,
and nothing is sent to or changed on the server. The dialog shows both the exact
command it will run and the commands that restore the local configuration —
including extra fetch/push URLs and the upstream link of every branch that
tracked the remote, because git remote remove deletes those too. (The
remote-tracking branches themselves come back with the next fetch.)
Useful together: repositories deleted on GitHub keep their now-dead remote
locally. R marks such a remote red (✘) in the repository line, T confirms
that the address is reachable but the repository is gone, and X cleans it up.
Local branches are listed too — the other state Git never transfers. Nobody
sees them because you only ever look at the current branch, so finished features
and old experiments pile up. Each branch shows its last commit, its upstream with
ahead/behind, and whether it is already merged. X on a branch deletes it, but
only when it is fully merged into HEAD (git branch -d): its commits are then
reachable from HEAD anyway, so nothing can be lost. Unmerged branches are refused
with the reason and the terminal command that would force it. The confirmation
shows both the exact delete command and the commands that restore the branch —
git branch <name> <oid> plus, if an upstream was set, the
git branch --set-upstream-to … line, because git branch -d deletes that
link as well.
Values line up in one column, and identical fetch/push addresses share a single
fetch+push line — they are only listed separately when they really differ
(git remote set-url --push allows that, and gmf then blocks transfers).
Repository info · api-gateway
Path: ~/projects/api-gateway
Branch: main
HEAD: a1b2c3d (a1b2c3d4e5f6789012345678901234567890abcd)
Last commit: 2026-07-25T10:30:00+02:00 · Example Author
feat: add health endpoint
History: 42 commit(s) · full clone
Upstream: origin/main (0 ahead / 0 behind)
Working tree: clean
Stashes: 0
Tags at HEAD: v1.4.0
Remotes:
origin [sync]
fetch+push: [email protected]:team/api-gateway.git
branch main: 0 ahead / 0 behind
github [GitHub, last fetch failed]
fetch+push: https://github.com/example/api-gateway.git
web: https://github.com/example/api-gateway
branch main: 2 ahead / 0 behind
Local branches:
main [current]
commit: a1b2c3d · 2026-07-25 · feat: add health endpoint
upstream: origin/main (0 ahead / 0 behind)
spike-caching [merged]
commit: 9f8e7d6 · 2026-07-11 · try a simpler cache key
upstream: (none)
Commit helper · api-gateway — review, then ⏎
M README.md ✔ commit
U notes.txt ✔ commit
U server.py ✔ commit
U build/out.o ✎ .gitignore: build/
␣ commit on/off · i gitignore on/off · ⏎ next · Esc cancel
- Every changed and new file is listed with a suggestion: typical junk
(
node_modules/,.DS_Store,__pycache__/,*.log,.env, …) is proposed for .gitignore, everything else for committing. Both are togglable per file (␣commit on/off,igitignore on/off). - Before you type the commit message, the repository's recent messages are shown as a style reference — as many as fit above the input line, which always stays visible.
- Merge conflicts block the helper completely.
.gitignoreis extended atomically without following symlinks. The commit is built in a temporary index containing only the approved paths; an existing user index, including deliberately staged but excluded work, stays intact. For the committed paths the real index adopts the new commit, exactly asgit commit -- <path>does — otherwisegit statuswould keep reporting them as modified. Optionally the commit is pushed through the same guarded private sync path asPafterwards. After a local commit, the result line includesgit reset --soft HEAD~1as the undo command.
Requires Python 3 and a terminal. Nothing else.
git clone https://github.com/DanielMuellerIR/gitmaster_flash.git
python3 gitmaster_flash/gitmaster_flash.pyFor the ⏎ = cd into the repository feature, source the shell wrapper — a child
process cannot change the working directory of the shell that started it, so a
small function has to do it. install.sh does that for you: it runs the
self-test, then registers the safely quoted absolute path to gmf.zsh in your
~/.zshrc (idempotent — a second run changes nothing, including from clone paths
with spaces or shell metacharacters):
gitmaster_flash/install.shA line that already sources the same wrapper is accepted in any spelling
(~/…, $HOME/…, quoted or not); only a genuinely different path stops the
installer and asks you to sort it out by hand.
Or add the line manually:
echo 'source /path/to/gitmaster_flash/gmf.zsh' >> ~/.zshrcIn a new shell, gmf then starts the tool (and cds where you asked it to):
cd ~/projects && gmfWithout the wrapper everything works the same, except that ⏎ prints the path instead of changing into it.
If you keep the same repos on more than one machine (laptop + desktop, Mac + Linux),
they drift apart in ways git never warns you about. Remotes live in .git/config
and git never transfers them — add a github remote on one machine and the other
simply doesn't have it, so a pending push is invisible there. Same for branches you
don't currently have checked out.
gitmaster_flash.py --diff mymac # compare ~/git here with ~/git on mymac
gitmaster_flash.py --diff mymac --json # machine-readable
gitmaster_flash.py --diff mymac:~/code # different directory on the other side
gitmaster_flash.py --diff mymac --fetch # refresh ahead/behind counts firstIt prints only the differences, split into classes — that split is the point, a report that lists everything gets ignored:
DRIFT favenio: remote 'github' only here (git never transfers remotes)
DRIFT notes: origin is 4 ahead/2 behind here, 0/0 on mymac
SYNC music: origin 0 ahead/3 behind on both machines
local webapp: [main] here, [feature/x] on mymac
local blog: 3 changed/new file(s) here
only on mymac: experiment
DRIFT also covers errors, conflicts, stashes, branch availability, remote safety
classification and credential-free endpoint fingerprints; raw remote URLs and
credentials never enter JSON. DRIFT = should be identical but isn't (worth
acting on). SYNC = both machines agree, but together they sit ahead/behind the
sync remote — invisible in a pure two-machine comparison, yet usually the number
you actually care about. local = explainable (different branch checked out,
dirty working tree). Exit code 0 =
nothing to report, 1 = findings (note: a SYNC line means the machines agree
with each other, so "identical machines" alone no longer guarantees exit 0),
2 = the other machine could not be reached.
Requirements: ssh HOST has to work — that's it. gitmaster_flash does not need
to be installed on the other machine: the script is piped over stdin, so the remote
only needs python3 and git, and both sides always run the exact same version (no
version drift to reason about). Works against Linux too.
It never changes your repositories — no remotes added, nothing committed or pushed,
branch, index and working tree untouched. It tells you what differs; fixing is yours.
Two things it does write, both outside your repository contents: --fetch runs a real
git fetch --all --prune on both machines — that is what the flag is for, and it only
updates remote-tracking refs — and the very first run creates
~/.config/gitmaster_flash/config.json, on the queried machine as well, because the
same script runs there.
Tip: put your machines in ~/.ssh/config and add
ControlMaster auto / ControlPath ~/.ssh/cm-%C / ControlPersist 60s. Scanning many
repos opens many ssh connections at once, and the sshd default (MaxStartups 10:30:100)
drops some of them at random — which looks like a broken repo but isn't.
gitmaster_flash.py --list # colored text list
gitmaster_flash.py --json # machine-readable
gitmaster_flash.py --json --fetch # fetch each repo first
# Every output carries the version — so a diff of two machines' output shows
# whether the same build produced them:
# --list header: gitmaster_flash 0.6.0 · /Users/you/git · 61 repos
# --json (0.6.0+): {"version": "0.6.0", "root": "…", "repos": [ … ]}
# (before 0.6.0 --json printed a bare array)Exit code 0 means everything is clean and in sync, 1 means at least one repository needs attention. Without a TTY the tool prints the list instead of starting the UI, so a pipe does the sensible thing.
~/.config/gitmaster_flash/config.json, created on first run:
apps— key → application used to open a repository (macOSopen -a). The key shows up in the footer automatically, so{"Z": {"name": "Zed", "path": "/Applications/Zed.app"}}gives youZ Zed. Pick a key that is not already taken by the table above. Inside an SSH session gmf refuses this action with a clear explanation: an app can only open on the Mac where you are working directly.sync_remote_names/sync_remote_hosts— how the private sync remote is recognized: by remote name, or by an exact normalized host in the remote URL (substring matches are never accepted). Defaults tooriginfor a generic installation. All remotes are displayed regardless; GitHub is recognized from its URL and sorted last.skip_dirs— directories the scan does not descend into.lang—"en","de", ornullto follow$LANG.compact_from— when the repository count exceeds this number, gmf starts in the compact view (default 20: up to 20 use the detail view;Mswitches at any time).git_timeout/fetch_timeout— seconds per git call.commit_timeout— seconds forgit commitalone (default 120). It runs the repository's pre-commit hook, which often starts linters or tests and needs far longer thangit_timeout. When the limit is hit, git and everything the hook started are terminated, and gmf reports it instead of aborting.
P and L are intentionally limited to a non-public sync remote. Both fetch
first, require a clean working tree and reject divergent history. Fetch and push
URLs must identify one identical credential-free host/repository target; multiple
or differing push URLs are blocked. Immediately before a confirmed mutation the
branch, HEAD, index, worktree, remote identity and target OID are checked again.
Pull merges only the approved immutable OID by fast-forward; push sends the approved
commit OID through an explicit refspec. An exact target-OID lease prevents a
remote deletion or concurrent move from turning it into an unreviewed update;
tags are never sent.
GitHub uses the separate G path. It works only when the same branch already
exists on one GitHub remote and the histories are related. Before publishing it
shows every outgoing commit and changed file name. The exact phrase
PUSH <remote> must then be typed. The final command still sends only the current
branch: approved source OID, exact target lease, no tags, no new branch. A remote
with multiple or differing fetch/push targets is blocked entirely, even if both
targets are on GitHub. Complex cases stay terminal-only.
Git never asks for credentials here. Every Git call runs with terminal prompts and
askpass disabled and in its own session, because Git writes such a question
(Username for 'https://github.com':) straight to the terminal rather than to the
captured output — inside the curses screen that destroys the display and then waits
for input that never arrives. A remote that needs a login therefore fails right away
with <remote> needs a login (no credential helper or SSH key). instead of asking.
Store HTTPS credentials in a credential helper (macOS:
git config --global credential.helper osxkeychain) or use SSH with a key in the
agent; both work without any prompt.
python3 -m unittest discover -s testsThe logic (status parsing, heuristics, repo scan) is separated from the curses UI and tested headlessly against real temporary repositories.
python3 docs/make-screens.py --check # do the pictures still match the UI?--check fails when the screens in docs/ would come out different — after a UI
change, run docs/make-screens.py and commit the result.
A nod to Grandmaster Flash — the tool is mostly about quick cuts between many records.
WTFPL — see LICENSE.
