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.
Always the git CLI
Section titled “Always the git CLI”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:
| Setting | Why |
|---|---|
-c color.ui=never | Output is always plain text to parse |
-c core.quotepath=false | Non-ASCII file names come through as they are |
-c log.showSignature=false | Signature checks don’t add lines to log and show |
-c core.pager=cat | Nothing waits for a pager |
GIT_TERMINAL_PROMPT=0 | A credential prompt fails instead of hanging the UI |
GIT_OPTIONAL_LOCKS=0 | Reads 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 log
Section titled “The log”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_lanescolumns (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.
Badges
Section titled “Badges”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.
Loading
Section titled “Loading”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 loginstead of finishing it. - When the selection gets within 200 commits of the end, the next
max_commitsload 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.
Filters
Section titled “Filters”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. Sincels-filesonly 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-mailmapmakes git match the same mapped identity the list showed, and--fixed-stringsmeans 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.
Branches and tags
Section titled “Branches and tags”One process for every ref
Section titled “One process for every ref”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.
Grouping and sorting
Section titled “Grouping and sorting”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 status column
Section titled “The status column”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.
Checking out
Section titled “Checking out”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).
Other branch commands
Section titled “Other branch commands”| Action | Runs |
|---|---|
| Fetch | git fetch --all --prune |
| New branch | git switch -c <name> <start> |
| New branch in a worktree | git worktree add -b <name> .worktrees/<name> <start>, and .worktrees/ is added to .git/info/exclude |
| Rename | git branch -m <old> <new> |
| Delete local | git branch -d, then git branch -D if you confirm a force delete |
| Delete remote | git push <remote> --delete <branch> |
| Merge | git merge --no-edit <target> |
| Pull | git pull --no-edit |
| Push | git push, or git push -u <remote> HEAD the first time, to set the upstream |
| Cherry-pick | git cherry-pick <commit> |
| Rebase | git -c core.editor=: rebase <onto>, non-interactive |
| Copy | OSC 52 escape sequence to the terminal, which also works over SSH |
Merged and unmerged
Section titled “Merged and unmerged”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.
The branch history in the graph
Section titled “The branch history in the graph”On the Unmerged and Merged tabs, the graph shows only the selected branch:
git merge-base --is-ancestor <tip> <base>says whether it’s merged.- 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. - 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. - The parent of its oldest commit is where it branched off.
- 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.
| Action | Runs |
|---|---|
| New lightweight tag | git tag <name> <commit> |
| New annotated tag | git tag -a <name> -m <message> <commit> |
| Push a tag | git push <remote> refs/tags/<name> |
| Delete a tag here | git tag -d <name> |
| Delete a tag here and on the remote | git push <remote> --delete refs/tags/<name>, then git tag -d <name> |
<remote> is origin if there is one, otherwise the first remote.
Stashes
Section titled “Stashes”The stash list is only loaded when a stash reflog exists, so a repository without stashes pays nothing for it.
| Action | Runs |
|---|---|
| Stash everything | git stash push --include-untracked [-m <message>] |
| Stash staged | git stash push --staged |
| Stash unstaged | git 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 staged | git stash push --include-untracked --keep-index |
| Stash one file | git stash push --include-untracked -- :(literal)<path>…, after git restore --staged for a staged deletion (the old path of a rename) |
| Pop / apply | git stash pop stash@{n} / git stash apply stash@{n} |
| Drop | git stash drop stash@{n} |
| Branch | git stash branch <name> stash@{n} |
| Show | git 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.
Worktrees
Section titled “Worktrees”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.
| Action | Runs |
|---|---|
| Open | Nothing in git: tt-git shuts down its panes, watcher and plugins, changes its working directory to the worktree, and starts over there |
| Remove | git worktree remove <path>, then git worktree remove --force <path> if you confirm removing it with its changes |
| New worktree | git 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:
| Step | Runs |
|---|---|
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 branch | git reset --keep <commit> |
| Back to a branch | git switch <branch> |
| Back to a detached commit | git switch --detach <commit> |
| Back over a rebase of another branch | git 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.
Status and staging
Section titled “Status and staging”The file list
Section titled “The file list”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.
File actions
Section titled “File actions”| Action | Runs |
|---|---|
| Stage | git add -- <path> |
| Unstage | git restore --staged -- <path>, or git rm --cached before the first commit, when there’s no HEAD |
| Stage all / unstage all | git add -A / git reset -q (git rm --cached -r . before the first commit) |
| Discard an untracked file | Deletes it |
| Discard a new file | git rm -f |
| Discard a rename | Undone on both paths |
| Discard anything else | git restore --source=HEAD --staged --worktree |
| Commit | git commit -F -, with the message on stdin, so multi-line messages and ones starting with - are safe |
| Amend | git commit --amend --no-edit |
| Continue a merge | git commit --no-edit |
| Continue a rebase, cherry-pick or revert | git <op> --continue |
| Abort | git <op> --abort |
Continue runs with core.editor=:, so git keeps the message it prepared
rather than opening an editor inside the TUI.
Staging hunks and lines
Section titled “Staging hunks and lines”Each side of the staging view is
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 fileare 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 -Nso 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.
Opening a file in your editor
Section titled “Opening a file in your editor”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.
Agent actions
Section titled “Agent actions”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.
Details
Section titled “Details”A commit
Section titled “A commit”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.
Changed files
Section titled “Changed files”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.
Loading
Section titled “Loading”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:
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):
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.
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:
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):
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:
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
Section titled “Code colors”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.
Refreshing
Section titled “Refreshing”What reloads when, and how file watching skips ignored paths, is covered in Refreshing.