If a submodule's url contains "path=", for example
"https://example.com/repo?path=other", lazygit takes "other" for the
submodule's path and loses the url. Also, a commented-out section in
.gitmodules shows up as a submodule.
The regexes that parse .gitmodules aren't anchored, so they find a key
or a section header anywhere in a line. Anchor them at the start of the
line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If .gitmodules contains a submodule path like "../other_repo", nuking
the working tree in lazygit stashes all changes and untracked files of
that other repository. The same happens if the path is a symlink that
points to it. The changes can be recovered with "git stash pop" in that
repository, but the user has no reason to look there.
This happens because lazygit runs some commands in the submodule's
directory, and takes that directory from .gitmodules as it is. Git
itself only uses a path from .gitmodules if its index has a submodule
at that path, and such a path can't lead out of the repository.
Ignore a submodule unless its path is a directory below the directory
that contains the .gitmodules file, without any symlinks in between.
This also keeps GetConfigs from reading the same .gitmodules file again
and again for a path of ".".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Git accepts "." as a submodule name, and so does GetConfigs. Removing
such a submodule in lazygit deletes .git/modules, together with the git
dirs of all other submodules. Any unpushed commits in those submodules
are lost.
Check the path of the git dir before running any git command, and
refuse to remove the submodule if the path isn't below .git/modules.
This also protects against names with ".." components, in case one ever
gets past GetConfigs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If .gitmodules contains a submodule name like "../../../victim",
removing that submodule in lazygit deletes the directory "victim"
outside the repository, along with everything in it. With enough "../"
components the path becomes "/", and lazygit then deletes all files
that the user has permission to delete. An attacker only needs to
commit such a .gitmodules file to a repository. If a user of that
repository removes the submodule in lazygit, their files are gone.
This happens because lazygit deletes the submodule's git dir with
os.RemoveAll, and builds its path by joining .git/modules with the name
from .gitmodules. filepath.Join resolves the ".." components.
Git refuses to use such names. It prints "ignoring suspicious submodule
name" and treats the entry as if it didn't exist. Do the same in
GetConfigs, using the same rule as git's check_submodule_name().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
So far, GetConfigs loaded the nested submodules of an entry as soon as
it saw the entry's path line. Parse the whole file first, and load the
nested submodules in a second loop. This prepares for checking the name
and path of each entry before using it.
This only makes a difference for malformed entries. If the path value
is empty, we no longer read the same .gitmodules file over and over.
If an entry has several path lines, we no longer load its nested
submodules several times.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing a submodule in a linked worktree deletes the git dir of that
submodule in the main worktree. This breaks the submodule there, and
any unpushed commits, local branches and stashes in it are lost. The
git dir of the submodule in the linked worktree stays behind.
Git keeps the git dirs of submodules separately for each worktree. For
a linked worktree, they are in .git/worktrees/<worktree>/modules.
Lazygit used the worktree's git dir until 3b723282cb switched it to the
repo's git dir, so this has been broken since v0.41.0. Use the
worktree's git dir again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discarding a nested repository deletes its directory like that of any
untracked file. If the repository keeps its git dir in its directory,
this deletes all of its history, and nothing said so. The directory of
a removed submodule keeps its git dir elsewhere, but it may have
changes that aren't committed.
Check for both before discarding. If anything would be lost, list it
and ask for confirmation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If you staged the directory of a removed submodule, a nested repository
or a worktree inside the repo, git added it as an embedded repository: a
gitlink without an entry in .gitmodules. For a removed submodule, this
brings it back in a broken state. git warns about it, but lazygit didn't
show the warning. Staging all files did the same to every such
directory.
Disable staging a selection that contains one of these, and disable
staging all files if that would stage one. The disabled reason tells you
how to get rid of it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Selecting one of these in the files panel showed the error "Could not
access 'modules/sub/null'". The main view showed the entry's diff
against /dev/null with --no-index, as for an untracked file. When git
compares a file with a directory that way, it looks for a file of the
same name inside the directory.
Show what the directory is and what to do about it instead. Below that,
show the status of the repository that it is, or the files in it if it
is a plain directory. The status tells you whether discarding the
directory would lose anything.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The files panel shows such a directory like an untracked file. Nothing
tells you that it is a directory, or that a submodule left it behind
when a commit that you checked out removed the submodule. This is the
case for the directory of a removed submodule, a nested repository, a
linked worktree inside the repo, and any untracked directory if
status.showUntrackedFiles is "normal".
Tell these apart when loading the files, by looking at the directory's
.git. Label removed submodules, nested repositories and worktrees, as we
already do for submodules, and put a slash after the name of a plain
directory, as git status does. With file icons on, a removed submodule
gets the submodule icon, and a nested repository or a plain directory
gets the directory icon.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
git status doesn't descend into a nested repository, such as the
directory that a removed submodule leaves behind. It reports the
directory as a whole, with a trailing slash. The file tree splits paths
at slashes, so it showed a directory with a file without a name in it.
Discarding the directory removed it, but then the cleanup of emptied
directories took the parent of "modules/sub/" to be "modules/sub"
itself, and failed to read it.
Take the slash off when loading the files. This also covers any
untracked directory if status.showUntrackedFiles is "normal", and linked
worktrees inside the repo. Their slash was taken off when they were
marked as worktrees; this is no longer needed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If the repo or linked worktree that lazygit has open is deleted while
lazygit is running, every git command fails. lazygit no longer panics
since the previous commit, but it shows empty lists, and there's
nothing useful left to do in it.
Check at the start of every refresh whether the worktree and its git
dir still exist. If one of them is gone, quit and print its path in
the terminal. The background files refresh does this check too, so
lazygit quits within ten seconds even if nobody is using it. To quit
from a refresh, return an error that wraps gocui.ErrQuit; this keeps
it out of the error popup.
Switching to another worktree or to a recent repo would be an
alternative, but it would be more surprising than quitting. Also,
there isn't always a main worktree to switch to.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If the repo that lazygit has open is deleted while lazygit is running,
the next full refresh panics. The same happens for a linked worktree
that is deleted. In both cases `git for-each-ref` fails because its
working directory is gone, and BranchLoader panics when that happens.
Return the error instead. The branches refresh logs it, like it does
with the other errors it gets from loading the branches.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If a linked worktree's directory is deleted and another worktree is
moved to the same path, `git worktree list` reports both worktrees with
that path. lazygit then crashes at startup with a stack overflow, and
the terminal is left in a broken state.
To tell the paths in a group apart, getUniqueNamesFromPathsAux keeps
looking at one more parent directory, but identical paths never come
apart, so it recurses forever. A stack overflow is a fatal error rather
than a panic. No deferred function runs, so the terminal is not
restored either.
Name identical paths like a single path instead of recursing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It returns true if the path is missing. It started out as
IsWorktreePathMissing, and was renamed without inverting its result
when it moved into the loader in de57cfd6ff.
Invert the result instead of renaming it back, so that the call site
reads `IsPathMissing: !self.pathExists(path)`. If we can't check the
path, it still counts as existing, as it always did.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
time.AfterFunc and errgroup.Group.Go run the function they are given
on a goroutine of their own, so a panic in it leaves the terminal in
raw mode, like a panic in any other goroutine that doesn't use
utils.Safe.
Add utils.SafeFunc and utils.SafeFuncWithError, which return a copy
of a function that runs through utils.Safe or utils.SafeWithError, and
use them for the functions given to time.AfterFunc and errgroup.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Most goroutines run their function through utils.Safe, which closes
the screen if the function panics. Without it, the panic is printed to
a terminal that is still in raw mode and on the alternate screen, and
the terminal needs a reset afterwards. Go has no process-wide hook for
panics, so every goroutine has to do this itself.
Use utils.Safe for the goroutines that don't use it yet.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Several actions on the selected lines of a diff look up those lines in
the diff of their file by their line numbers: staging, unstaging and
discarding them, editing the hunk they are in, and adding them to a
custom patch. Showing which lines of a diff are in the custom patch
does the same lookup the other way round. For this, the change lines of
the file's diff are indexed by their line numbers.
The index asked the patch for the number of each change line separately,
and each of these calls counts the lines of the hunk before the line. So
for a new file of 100,000 lines, building the index took 2 seconds.
Number all lines of the patch in one pass instead. Building the index
for that file now takes 4 milliseconds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With no diff renderer configured, opening the jump-to-file menu for the
diff of a commit that adds or deletes a file of 100,000 lines takes
more than 20 seconds, and so does picking a file from it. In such a
diff, moving the selection by change block takes as long for each key
press. All this time the UI is frozen.
To find out which line of which file each row of the main view shows,
lazygit parses the view's content as a diff. For every line of a file's
diff it asks the parsed patch for the line's number in the new and in
the old file, and each of these calls counts the lines of the hunk
before it. So parsing takes time quadratic in the length of a hunk, and
the diff of a new or deleted file is a single hunk. For a diff of
200,000 lines, one parse took about 10 seconds, and opening the menu
parses it twice. A diff renderer that states which line each row shows,
such as delta, doesn't go through this parse.
Number all lines of the patch in one pass instead. Parsing that diff
now takes about 40 milliseconds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If a diff renderer is configured, its command shows up in the command
log once after startup, although the user didn't do anything. This is
the probe that asks the renderer whether it emits OSC 1717 metadata. It
changes nothing, so like other read-only commands it shouldn't be
logged.
The next commit carries a line of a diff forward to the file as another
worktree has it, and needs to diff against that worktree's working copy
for this.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If diff.mnemonicPrefix is set, pressing space on a line in the focused
main view does nothing. The command log shows "Apply patch", but the
line is not staged. Other commands that read a path out of the working
tree's diff go wrong as well, such as jumping to a file. Building a
custom patch from a commit's diff still works, because git uses
mnemonic prefixes only when one side of the diff is the index or the
working tree.
With this config, the paths in the working tree's diff start with i/
and w/ instead of a/ and b/. We only strip a/ and b/, so the path we
read out of the diff header is not a file of the files panel, and we
skip the line without a message.
Users who set the config want to see these prefixes, so keep them.
Strip the first component of the path instead, whatever it is. `git
apply` does the same by default, and ParseableDiffPrefixes makes sure
there is such a component.
A section without +++ and --- lines needs more than that, because we
find the new path on its "diff --git" line by searching for " b/".
Take the path from the "rename to" or "copy to" line instead where
there is one. Otherwise the two paths on the "diff --git" line are the
same. Split the line where the two halves match after stripping their
prefixes, like `git apply` does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If diff.srcPrefix and diff.dstPrefix are set (git 2.45 and later),
pressing space on a line in the focused main view does nothing. These
configs change the diff of a commit too, so the other commands that
read a path out of a diff go wrong as well.
The configs replace the a/ and b/ in front of the paths of a diff with
any string. We only know how to strip a/ and b/, so the paths we read
out of the diff header don't name a file.
We can't tell from the diff where such a prefix ends, so override the
two configs along with diff.noprefix.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If diff.noprefix is set, pressing space on a line of a file under a
top-level directory named a or b does nothing. In a stash entry's diff,
the menu for jumping to a file shows such a file without its directory.
We read the path of a file out of the diff header and strip the a/ or
b/ in front of it. Most diff commands override diff.noprefix so that
this prefix is there, but the diff of the working tree and the diff of
a stash entry don't. With the config set, we strip the file's own
directory instead.
Use ParseableDiffPrefixes for these two commands too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Seven diff commands pass -c diff.noprefix=false so that the paths in
their output carry a prefix. The next commits use the override for
more commands and add more config to it, so give it a single home
first.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing routes to the explorer contexts after line actions and file entry
moved into the normal main pair. Delete their contexts, views, main-pair
wiring, discovery API, test drivers, and selection-state package so the
surviving diff view is the only implementation.
Their names go from the contexts a custom command may bind to as well, so
that a config still naming one is reported as the config error it now is
rather than taken as a context we simply failed to find.
Co-Authored-By: GitHub Copilot <copilot@github.com>
A focused diff must replace the patch builder's whole-file toggle
without losing file creation or deletion metadata. Ordinary modifications
and renames must remain line-level selections even when every changed
content line is selected.
Ask the patch builder's canonical raw diff whether it consists of one
hunk containing only additions or only deletions and no context. Use the
whole-file operation only when the selection also covers every change.
Co-Authored-By: GitHub Copilot <copilot@github.com>
Space in the pane previewing the patch had nothing to act on: everything
there is in the patch already, so it can only mean taking those lines back
out, in the way that space in the staged side of the working tree's diff
only unstages.
A line of the patch can't be found by its number: the patch renumbers
whatever follows a change it leaves out, so a line of it names a line of
the commit's diff that isn't the one shown. A line is identified instead
by its position among its file's changes, counted in the diff the patch is
shown as. That is the same position it has among the changes the patch
holds for the file, since both are in the order the file has them. This
holds however the rendering came out, so a renderer that groups a hunk's
deletions before its additions, or leaves a change out altogether, no
longer matters.
The pane's rows are stated in terms of the trees the patch was
materialized into, so the paths come back to the repo's own before
anything is looked up in them. This also lets the lines there be copied
and edited, as the lines of a commit's diff can be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pane beside a commit's diff showed the patch being built from it by
assembling the text itself. That text could not be handed to a diff
renderer the way a diff can: a stdin filter might have coped, but a tool
that diffs two files could not. Its idea of how much context to show
around a hunk was also its own rather than git's.
Materialize the patch instead: write each of its files as it is before the
patch into one tree and as it is after into another, and let git diff the
two trees. The patch becomes a diff of real files, rendered by whatever
renders the rest of them, with git's own context around it. Its lines can
then be pointed at; taking them back out of the patch will need that.
The trees are named a and b, so that with git's own prefixes suppressed
the paths read like an ordinary diff's over the repo's own paths. They are
written when the patch changes rather than when it is shown, the patch
builder counting its own versions for that, and they go away with the
patch.
A renamed file is materialized under the name the patch expects to find it
under. Where the patch carries the rename, that is the name the file had
before, so the rename comes out as a rename. git names the trees
themselves in the two rename lines, having only the two paths to go by.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A patch is built in terms of where a line sits in the file's diff, which is
what the patch explorer has to hand. The main view doesn't: a row of it
resolves to a line of a file, and what index that line has depends on how
much of the diff has been read and how a diff renderer chose to lay it out.
So let the two meet at the patch builder's edge, in the identity of a change
line — its number on the side it belongs to — leaving the main view to speak
only of lines it can see.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A diff renderer may lay a diff out however it likes: line numbers in a
gutter, the +/- column replaced by colour, the two sides in columns. Once
it has, we can only tell which line of which file a row shows if the
renderer says so. Under a renderer that doesn't say, the main view holds
a diff that can be read but not staged, edited or copied from. That is no
good now that the main view is where you stage.
So focusing it brings git's own diff instead, and every re-render while it
stays focused keeps to that, so staging a hunk doesn't flip back. Browsing
is untouched: you see what the renderer produced until you focus the view
to act on it.
Whether the renderer says anything is settled by asking it rather than by
watching it work: run it on empty input and see whether it announces the
protocol. Announcing is a property of the renderer, so the answer is known
before we render anything, and a diff with no lines to describe can't fool
it. Watching would have to see a diff go by first, and a binary file's diff
holds nothing that would tell the two cases apart. The answer is remembered
until the renderer changes. git itself is asked the same question, with the
renderer's own arguments, since it announces itself for exactly the formats
whose output can't be read back as a diff.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things about a diff command follow from what its output is for: whether
the diff renderer produces it, and whether it is coloured. That was a
`plain` flag, which covers two of the three cases — the diff as configured,
and git's own uncoloured diff for building patches out of — and leaves no
room for the third, which is about to be needed: git's own diff, coloured,
for showing where the renderer's version of it can't be acted on.
So the flag becomes a mode. It also takes over deciding the colour, which
each command spelled out for itself, and it settles a question the flag
couldn't put: whether ignoring whitespace applies. It is about what the user
wants to see, so it holds for anything shown, and not for a diff a patch is
built from, which has to describe every change.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parse is lenient: it takes any text and reads a diff out of it, which is
what we want when we hand it a diff, but it has no way to say "this
isn't one". We're about to parse the *rendered* contents of a diff view,
which a diff renderer is free to restructure — putting the line numbers
in a gutter, say, shifts the +/- marker off the start of each body line,
so every line reads as context and the parse silently lies about which
lines are changes.
Comparing each hunk's body against the lengths its header declares
catches exactly that, without teaching us anything about any particular
renderer's layout: a faithful unified diff agrees with its headers, a
restructured one doesn't.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Identifying a change line of a diff by its file line number needs both
sides: two consecutive deletions sit at the same new-file position, so
only their old-file line numbers tell them apart. LineNumberOfLine only
answers for the new file, which leaves deletions ambiguous.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This lets pushing a range selection of tags push them all with a single
git command.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This lets deleting a range selection of tags delete them all with a
single git command, the same way it works for branches.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The worktree loader associates a worktree that is mid-rebase or
mid-bisect with the branch involved, even though its HEAD is detached.
Fast-forwarding such a branch therefore ran the fast-forward in that
worktree. This moved the detached HEAD and put the upstream commits
into the rebase in progress, while the branch stayed where it was.
Remember in the worktree model when its branch comes from a rebase or
bisect, and refuse to fast-forward the branch in that case. Updating
only the ref is no option for a rebase; the rebase writes the branch
when it finishes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Branches that lazygit forwards to their upstream move by a direct ref
write, so no git command turns up in their reflog to explain it. Until
now the entry had no message either, leaving `git reflog <branch>` with
nothing but a blank line about it. Name lazygit and what it did, so that
a branch which moved on its own can be traced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After somebody else rebased and force-pushed a stack of branches, every
branch of the stack has to be brought back to its upstream, and pressing
`f` on them one by one is as tedious as the checking out and pulling it
replaces.
Let `f` work on a range selection. The upstream branches are fetched with
one `git fetch` per remote, and the branches that aren't checked out
anywhere move in a single `git update-ref` call. All the selected
branches are looked at before any of them is moved, so a branch that has
to be refused leaves the others alone rather than updating the stack
halfway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When somebody else rebases a branch and force-pushes it, the local
branch is left diverged from it, so `f` refuses to touch it. To get back
in sync, the branch has to be checked out and pulled, and for a stack of
branches that means doing it once per branch.
Such a branch has none of its own work in it, though: the commits it is
ahead by are the old versions of the ones that are now on the remote
branch. So when the branch has diverged and none of the commits it is
ahead by is ours, reset it to its upstream instead of refusing. This is
the same state that checking the branch out and pulling it would produce,
as rebase drops commits that the upstream already has.
A branch that isn't checked out anywhere is reset with the same
`git update-ref` that a fast-forward uses. One that is checked out
somewhere is reset with `git reset --keep`, and only when that worktree
has no changes to tracked files, so that the files under the user's feet
change no more than they have to. `--keep` also refuses to overwrite an
untracked file that the reset would bring in, which `--hard` would do
silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fast-forwarding a branch that isn't checked out ran a single
`git fetch <remote> refs/heads/<branch>:<branch>`, so git's own refusal
to move a local branch backwards served as the safety check. A later
commit widens the set of branches the command accepts, and for that
lazygit has to make that decision itself.
Fetch the remote branch on its own, updating only its remote-tracking
branch. Then ask git whether the branch is an ancestor of it, and only
then move the branch: with `git update-ref` when it isn't checked out
anywhere, guarded by the hash we knew it to have, and with
`git merge --ff-only` in its worktree when it is checked out there.
Keeping `git pull --ff-only` for the latter case would fetch a second
time.
Fast-forwarding a branch that isn't checked out in a worktree had no
integration test at all, so add one. The second new test covers a repo
that keeps no reflogs, because a later commit starts reading those and
this case has to keep working without them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The function asks git whether HEAD is an ancestor of a ref, and its name
says what the one caller wants to know. A later commit asks the same
question about a branch that isn't checked out, so let the caller name
both refs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When somebody else rebases a branch and force-pushes it, our branch
shows up as diverged, say ↓5↑3, and that looks exactly like a branch
that carries three commits of our own. The two want different treatment.
The first one only has to be moved to its upstream, while the second one
needs a decision about what to do with those commits. The panel doesn't
tell them apart, so users have to look at each branch in turn to find
out which one they are dealing with.
Determine for every diverged branch whether it has commits of its own,
and dim its divergence when it doesn't. This runs on a worker, as it
takes a few git commands per diverged branch, and a branch keeps the
answer from the previous load while a new one is in flight, so that the
display doesn't flicker. A later commit teaches the fast-forward command
to act on such a branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flag says whether this load is the one that kicks off the values
determined in the background afterwards. A later commit adds a second of
those next to the behind-counts, so name the flag after what it controls
rather than after its only user so far.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A branch that has diverged from its upstream doesn't necessarily hold any
work of its own. If somebody else rewrote the remote branch and
force-pushed it, our branch is still at the commits it had before, and
every one of them was on the remote branch at some point. A later commit
offers to reset such a branch to its upstream, and for that it has to
tell this case apart from a branch that holds commits created here.
The reflog of the remote-tracking branch records the values it had before
it was rewritten, so a commit that was ever on the remote branch is
contained in its current value or in one of those. HasLocalOnlyCommits
asks git for a commit of the branch that none of them contains.
Two details of the reflog are worth knowing. The value the ref had before
its oldest entry is that entry's old value, and <ref>@{<number of
entries>} is the only way to name it. It doesn't exist when the oldest
entry is the one that created the ref, and asking for it then is an
error, not an empty result. Reflogs can also be missing altogether,
because core.logAllRefUpdates defaults to false in a bare repository.
Neither case must stop us from recognizing a branch that is strictly
behind its upstream, so the current value of the remote-tracking branch
is always used; missing previous values only make the answer more
conservative.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>