Skip to content

How tt-git uses git

This page is for when you want to know exactly what tt-git does to your repository: which git commands each pane runs, with which options, and why. You don’t need any of it to use tt-git. The pane pages (Graph, Status, Branches and so on) cover what you see and what the keys do.

Every pane reads from the git command line, never from a library that parses the repository itself. So tt-git always agrees with the git you run by hand: hooks, config, .mailmap, worktrees and partial clones all behave the same way.

Every git call runs with:

SettingWhy
-c color.ui=neverOutput is always plain text to parse
-c core.quotepath=falseNon-ASCII file names come through as they are
-c log.showSignature=falseSignature checks don’t add lines to log and show
-c core.pager=catNothing waits for a pager
GIT_TERMINAL_PROMPT=0A credential prompt fails instead of hanging the UI
GIT_OPTIONAL_LOCKS=0Reads never take the index lock away from a git command you’re running

Each kind of load keeps at most one git process in flight, with only the newest request waiting behind it. See Memory and speed.

The graph is git log --topo-order of every ref, newest first. In all mode that’s --exclude=refs/stash --all. --all is used rather than HEAD because it still works on a detached HEAD and doesn’t fail on an unborn branch. In current mode it’s HEAD plus its upstream, found with git rev-parse --symbolic-full-name @{upstream}, so you can see what you’d push or pull.

The lane layout is GitLens/GitKraken style: once a line has a column it keeps it, so branches run straight down instead of shifting sideways as other lines start and end.

  • A commit takes the leftmost lane that’s waiting for it. Other lanes waiting for it end there and join it with ╯. A commit nobody is waiting for is a branch tip and takes the first free column.
  • The first parent continues in the commit’s column. Each further parent joins a lane already waiting for it, or opens a new one.
  • A column freed in a row isn’t reused in that same row, so a line never looks like it passes straight through a merge.
  • Each new tip and merge lane gets the next of 8 colors. Merges are ◉, other commits ●.
  • Only max_lanes columns (default 16) are drawn and stored; anything wider collapses into …. Rows are packed at 2 bytes a column.

The layout only looks at the current lane state, never ahead, so rows are built while git log is still streaming.

The checked-out branch is name on green (HEAD when detached), local branches [name], remote branches [origin/name] and tags <v1.0>. Symbolic refs like origin/HEAD are skipped, since they only repeat another badge. Badges that don’t fit are dropped before the subject is.

Commits stream in batches of 1,000, up to max_commits (default 20,000).

  • The old rows stay on screen until the first new batch arrives, so a refresh never flashes empty.
  • A load that’s been replaced kills its git log instead of finishing it.
  • When the selection gets within 200 commits of the end, the next max_commits load with --skip, and the existing lanes carry on.

Loading more only continues the old lanes if the refs the graph started from are unchanged. tt-git checks that by fingerprinting the ref files (HEAD, refs/, packed-refs, shallow, worktree HEADs) without running git. If they changed, it reloads from the top. The same fingerprint means a fetch that brings nothing new doesn’t reload the graph at all.

The author and file filter gets its lists from:

  • Authors: git log --use-mailmap --format='%aN <%aE>' --all, counted per person, most commits first. Because of .mailmap, one person who committed under several emails is one entry.
  • Files: git ls-files, with each folder listed (ending in /) just before the first file in it. Since ls-files only knows files that still exist, if nothing matches, what you typed is used as the path, so you can still find a deleted file’s history.

The filtered log adds:

  • for an author, --use-mailmap --fixed-strings --author=<Name <email>>. --use-mailmap makes git match the same mapped identity the list showed, and --fixed-strings means names with regex characters match literally. The match is git’s own: a case-sensitive substring of the author line.
  • for a path, -- <path> and --parents. That turns on git’s parent rewriting: each commit’s parent becomes the nearest ancestor that also touched the path, so the lanes still join up into a readable graph instead of hundreds of disconnected dots.

An author filter can’t rewrite parents that way, so a line ends where the next commit is by someone else, and the next match starts a new lane. Renames aren’t followed (no --follow), and git’s default history simplification applies.

A filtered load comes back in one batch rather than streaming, because which parents drop out is only known once the whole log is in.

Everything on every tab of the branches pane comes from one git for-each-ref over refs/heads refs/remotes refs/tags. It returns each ref’s target, date, upstream, ahead/behind counts, author and subject in a single process, so the pane never runs a rev-list per branch. A full refresh is 6 to 8 git processes however many branches and tags you have.

Local and remote branches are grouped into one row: a remote branch joins the local branch that tracks it, else the one with the same name, else becomes a remote-only row. When several local branches track the same remote branch, the one with the same name gets it; the others still show their ahead and behind counts. Remote names that contain / are handled: the longest configured remote name that prefixes the ref wins.

A row’s activity, which the list is sorted by, is the newest of each ref’s tip commit date, the last entry in each ref’s reflog, and for local branches the last checkout: moving from A to B entry naming it in HEAD’s reflog. For a remote branch only update by push entries count; fetch, pull and clone entries are skipped, and so is a remote branch whose last 64 KiB of reflog holds no push. Reflogs are read straight from .git/logs/, and only from the end of the file, so this costs no git processes.

The ahead and behind counts come from git’s own %(upstream:track). gone is checked first (git reports the upstream gone, usually after fetch --prune), then tracking, then the rest: local, the remotes that have it, or =origin / ≠origin for a same-named branch you don’t track.

A local branch runs git switch <name>. A remote-only branch runs git switch -c <name> --track <remote>/<name>, preferring origin when several remotes have it.

From the graph, a commit with a local branch on it runs git switch <name>; any other commit runs git switch --detach <commit>. When you agree to stash your changes first, tt-git runs git stash push --include-untracked, then the switch. If the switch fails, it runs git stash pop to put the changes back (only if the push made a stash, so an older one is never popped by mistake).

ActionRuns
Fetchgit fetch --all --prune
New branchgit switch -c <name> <start>
New branch in a worktreegit worktree add -b <name> .worktrees/<name> <start>, and .worktrees/ is added to .git/info/exclude
Renamegit branch -m <old> <new>
Delete localgit branch -d, then git branch -D if you confirm a force delete
Delete remotegit push <remote> --delete <branch>
Mergegit merge --no-edit <target>
Pullgit pull --no-edit
Pushgit push, or git push -u <remote> HEAD the first time, to set the upstream
Cherry-pickgit cherry-pick <commit>
Rebasegit -c core.editor=: rebase <onto>, non-interactive
CopyOSC 52 escape sequence to the terminal, which also works over SSH

Merged means reachable: git for-each-ref --merged=<base>. A branch is merged when its tip is the base or one of the base’s ancestors, which is why squash-merged and rebase-merged branches stay on Unmerged.

The base branch is whatever a remote’s HEAD points at (origin first), else the first of main, master, development, develop or trunk on a remote, then locally. A base you pick is stored as tt-git.baseBranch in the repository’s own git config.

On the Unmerged and Merged tabs, the graph shows only the selected branch:

  1. git merge-base --is-ancestor <tip> <base> says whether it’s merged.
  2. If it is, git rev-list --first-parent --ancestry-path <tip>..<base> finds the base commit that merged it in. If that commit isn’t a merge, the branch was fast-forwarded and only its tip is shown.
  3. The branch’s own commits are git log --first-parent <tip> ^<merge's first parent>, or ^<base> if it’s unmerged. First-parent leaves out commits it pulled in from other branches.
  4. The parent of its oldest commit is where it branched off.
  5. The graph draws the branch as a loop off the base: the fork commit at the bottom, the branch’s commits, then the merge (or, for an unmerged branch, the base’s current tip). Base commits in between are left out.

Tags are sorted by creation date: the tagger date for annotated tags, the commit date for lightweight ones.

The five newest tags are compared with the base branch. On git 2.41 and later, all five come from one git for-each-ref --format=%(ahead-behind:<base>). On older git, each is git rev-list --left-right --count <base>...<tag>, and tt-git remembers the old version so it doesn’t try the new form again. Only five are compared because each one walks history, which gets slower the older the tag is.

ActionRuns
New lightweight taggit tag <name> <commit>
New annotated taggit tag -a <name> -m <message> <commit>
Push a taggit push <remote> refs/tags/<name>
Delete a tag heregit tag -d <name>
Delete a tag here and on the remotegit push <remote> --delete refs/tags/<name>, then git tag -d <name>

<remote> is origin if there is one, otherwise the first remote.

The stash list is only loaded when a stash reflog exists, so a repository without stashes pays nothing for it.

ActionRuns
Stash everythinggit stash push --include-untracked [-m <message>]
Stash stagedgit stash push --staged
Stash unstagedgit stash push --staged -m "tt-git: staged changes set aside", git stash push --include-untracked [-m <message>], then git stash pop --index stash@{1} to put the staged changes back. With nothing staged, just the middle command
Stash, keep stagedgit stash push --include-untracked --keep-index
Stash one filegit stash push --include-untracked -- :(literal)<path>…, after git restore --staged for a staged deletion (the old path of a rename)
Pop / applygit stash pop stash@{n} / git stash apply stash@{n}
Dropgit stash drop stash@{n}
Branchgit stash branch <name> stash@{n}
Showgit show --stat --patch of the stash commit, then of its untracked-files commit if it has one

git stash push --staged saves its stash before it takes the staged changes out of the working tree. When that step fails because a file’s staged and unstaged changes overlap (“Cannot remove worktree changes”), git leaves the files alone but keeps the stash; tt-git runs git stash drop stash@{0} to remove that duplicate. git older than 2.35 doesn’t know --staged, and tt-git says it needs 2.35 instead of showing git’s usage text.

The worktree list comes from git worktree list --porcelain, read with the other refs. Only while the Worktrees tab is showing, a background job runs git status --porcelain=v2 -z --untracked-files=all in each worktree to count its changed files, and git rev-list --left-right --count <base>...<head> to count the commits its HEAD has that the base branch doesn’t. One more job per worktree adds up the size of the files in its folder (without git, and without following links), so small worktrees show their size while a big one is still being walked. At most four run at once, so they don’t slow down the git commands behind the rest of the app. They run when the tab opens and when the list of worktrees changes.

ActionRuns
OpenNothing in git: tt-git shuts down its panes, watcher and plugins, changes its working directory to the worktree, and starts over there
Removegit worktree remove <path>, then git worktree remove --force <path> if you confirm removing it with its changes
New worktreegit worktree add -b <name> .worktrees/<name> <start>, as in Other branch commands

Undo reads the reflog only when you press z, never on a refresh: git log -g --format=%H%x1f%gs --max-count=500 HEAD, plus git for-each-ref refs/heads/ to know which branches still exist. Walking it newest first, each entry marked [tt-git undo] counts one of your own actions as already undone; the first action not cancelled out is the one to undo. A rebase’s (start) to (finish) entries make one action.

Before acting, tt-git checks with git rev-parse that HEAD, and for a rebase of another branch that branch, hasn’t moved since. Each command that moves HEAD runs with GIT_REFLOG_ACTION set to the marker:

StepRuns
Back over your own commit or amend (commit: or commit (amend): in the reflog)git reset --soft <commit>: the index and files stay as they are
Back over anything else on the current branchgit reset --keep <commit>
Back to a branchgit switch <branch>
Back to a detached commitgit switch --detach <commit>
Back over a rebase of another branchgit switch <branch you were on>, then git update-ref refs/heads/<rebased> <before> <rebased commit>

A rebase of another branch (git rebase <upstream> <branch>) is spotted from the rebased branch’s own reflog, git log -g refs/heads/<branch>: the entry before its rebase (finish) is where the branch was before. If that’s where HEAD was when the rebase started, it was an ordinary rebase of the current branch. If it isn’t, or the branch’s reflog doesn’t say, tt-git goes by the newest checkout, finished rebase or rename of the current branch in HEAD’s reflog before the rebase started. Only the switch is marked, so each step leaves one marked entry in HEAD’s reflog.

A soft reset never touches your files, so it runs straight away. For the other steps, with uncommitted changes and your go-ahead, it runs git stash push --include-untracked first. After a reset, git stash pop puts the changes back; after a switch they stay stashed. If the reset or switch fails, the stash is popped back, as for a checkout.

The status pane is git status --porcelain=v2 -z --branch --untracked-files=all. Porcelain v2 is used because its format is stable across git versions, and -z keeps odd file names intact. The upstream and ahead/behind counts in the title come from the same --branch output, so they cost no extra git call.

A merge, rebase, cherry-pick or revert in progress is detected from the files git leaves behind (rebase-merge/, rebase-apply/, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD), with no git call.

ActionRuns
Stagegit add -- <path>
Unstagegit restore --staged -- <path>, or git rm --cached before the first commit, when there’s no HEAD
Stage all / unstage allgit add -A / git reset -q (git rm --cached -r . before the first commit)
Discard an untracked fileDeletes it
Discard a new filegit rm -f
Discard a renameUndone on both paths
Discard anything elsegit restore --source=HEAD --staged --worktree
Commitgit commit -F -, with the message on stdin, so multi-line messages and ones starting with - are safe
Amendgit commit --amend --no-edit
Continue a mergegit commit --no-edit
Continue a rebase, cherry-pick or revertgit <op> --continue
Abortgit <op> --abort

Continue runs with core.editor=:, so git keeps the message it prepared rather than opening an editor inside the TUI.

Each side of the staging view is

Terminal window
git diff [--cached] --no-ext-diff --no-textconv --no-renames --no-relative \
--src-prefix=a/ --dst-prefix=b/

pinning every option that would change the output’s shape, so the patch tt-git builds from it always applies. tt-git then writes a patch with only the lines you picked:

  • An unpicked + line is dropped when staging; an unpicked - line becomes context. Unstaging and discarding apply the patch in reverse, so there it’s the other way round. Lines you didn’t pick stay as they are.
  • Hunk headers are recomputed from the lines written, and each later hunk’s start moves by what the earlier ones added or removed. That’s why tt-git doesn’t need git apply --recount, and full context is kept so the patch applies only where it should.
  • CRLF line endings and \ No newline at end of file are kept exactly.
  • Picking part of a new or deleted file turns it into a change to the file rather than creating or deleting it. An untracked file is first marked with git add -N so a part of it can be staged; if the patch then fails, that’s undone.

The patch goes to git apply --cached (stage), --cached -R (unstage) or -R (discard) on stdin. Binary files, conflicted files and untracked files over 256 KiB can’t be split.

e runs your editor on the file’s full path, from the repository’s root. tt-git runs one git command to pick the editor, and only when neither [editor] command, $VISUAL nor $EDITOR is set: git config --get core.editor.

From the status pane, the line comes from the same two git diff calls as the staging view, made in the background: the first unstaged change, else the first staged one, moved by any unstaged lines added or removed above it. A conflicted file is read directly for its first <<<<<<< line. In the staging view and details the line comes from the diff already on screen, with no git call.

A file from a commit that isn’t in the working tree is read with git rev-parse --short <commit>^{commit} and git cat-file blob <commit>:<path> (<commit>^ for a file the commit deleted), then written read-only to <temp>/tt-git/<repo>/<short id>/<path>, where the editor opens it. A copy that’s already there is reused.

A terminal editor gets the real terminal: tt-git stops reading keys, leaves its alternate screen and raw mode, waits for the editor to exit, then restores all three, redraws and refreshes. A GUI editor is started with no input or output attached and left running.

A fills the prompt from what tt-git already has loaded, plus two git calls on a background job: git log --oneline --no-decorate -50 <base>..<branch> for {log}, and, while a merge, rebase, cherry-pick or revert is stopped, git name-rev --name-only --always MERGE_HEAD (or REBASE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD) for {merge_head}. The prompt is also written to <temp>/tt-git/<repo>/agent-prompt-<pid>-<n>.md for {prompt_file}.

Inside herdr, tt-git runs herdr tab create --cwd <repo> --label <name> --focus (or herdr pane split --direction right for herdr-split), then herdr agent start <name> --kind <kind> --pane <pane> and herdr agent prompt <name> <prompt> for an agent herdr knows, or herdr pane run <pane> <command> for any other. Outside herdr the agent runs in a pseudo-terminal drawn in the details pane, like a plugin pane.

One git show --stat --patch --diff-merges=first-parent -M, with the full message, author, committer and parents. --diff-merges=first-parent shows a merge as what it brought into the branch, which is what you usually want to review, and -M shows renames as renames. Long diffs stop at 5,000 lines with a count of what’s left, so a huge generated file can’t stall the UI.

git show --name-status -M lists the files, with renames as old → new. The selected file’s diff is loaded one file at a time and cached while the view is open.

Nothing loads while details is hidden, and at most one load of each kind is in flight. Hold j in the graph and tt-git loads only the commit you stop on, not every commit you passed.

Blame is one git blame per version of the file:

Terminal window
git blame --porcelain -M -C [--ignore-revs-file <repo>/.git-blame-ignore-revs] [<rev>] -- <path>

There’s no <rev> for the working tree, so lines you haven’t committed show as Not committed yet. -M and -C follow lines that were moved or copied, within the file and from other files in the same commit, so a line keeps the commit that wrote it rather than the one that moved it. When the repository has a .git-blame-ignore-revs file at its root and blame.ignoreRevsFile isn’t set, tt-git passes --ignore-revs-file so the commits listed there are skipped. When blame.ignoreRevsFile is set, git uses it by itself.

The porcelain output gives each commit’s author, date and summary, and the previous commit and path that [ steps back to: the file as it was in that commit’s parent, under its old name if it was renamed. A commit with no previous added the file, so there’s nothing to step back to.

The date at the right of the title, when the version shown last changed, comes from one more command alongside each blame (HEAD for the working tree):

Terminal window
git log -1 --format=%at <rev> -- <path>

Whether that’s the latest version is found by comparing the file’s blob in <rev> with the one in HEAD. The working tree is always the latest, so blaming it runs none of these.

Terminal window
git rev-parse --verify -q <rev>:<path>
git rev-parse --verify -q HEAD:<path>

When they’re the same, the title says latest. When HEAD has no file at <path>, tt-git looks for where it was renamed to with git diff --name-status -M --diff-filter=R -z <rev> HEAD and compares with that. When they differ, git merge-base --is-ancestor <rev> HEAD says whether the version is in HEAD’s history. If it is, the count of newer commits is:

Terminal window
git rev-list --count <rev>..HEAD -- <path> [<new path>]

If it isn’t, the title says not on HEAD.

{ and } step through the commits that changed the file, found with git log. Back, from the version at <rev> (HEAD for the working tree):

Terminal window
git log --follow --name-only --format="commit %H" -n2 <rev> -- <path>

The first commit listed made the version shown; the second is the one to blame next, and --name-only gives the file’s name there. From a working tree where git status --porcelain -- <path> shows changes, the first step is HEAD. Forward, it finds the commit that made the version shown (git log --follow -n1 <rev> -- <path>), then lists the newer ones up to the version it’s heading for:

Terminal window
git log --follow --name-only --format="commit %H" <commit>..<newer rev> -- <newer path>

and blames the oldest of them. When that’s the newer version’s own commit, it goes back to that version instead.

Stepping back or forward doesn’t run git to find the line in the other version. tt-git diffs the two blames’ text in memory (a line diff, like git diff’s) and follows the selected line through it. Past a thousand added and removed lines it gives up on diffing and keeps the line’s place in the file instead.

The commit message that space shows is git show -s --format=%b <commit> --, loaded for the selected line’s commit and kept while blame is open. enter opens the commit with the same commands as the commit view.

Blame runs in the background. The working-tree blame is a snapshot taken when it opens, and doesn’t reload as files change.

Code colors run no git command. tt-git colors the text git already gave it with syntect and the syntax definitions bat ships, built into the binary. The colors themselves are tt-git’s, not a bat theme.

Coloring runs in the background job that loaded the text, right after the git command, so the pane draws the result without redoing it. Blame colors the whole file. A diff is colored per file, picking the language from the +++ path (the --- path for a deleted file), and per hunk, with the old and new sides kept apart. Files over 20,000 lines and lines over 2,000 bytes are left plain.

What reloads when, and how file watching skips ignored paths, is covered in Refreshing.