jesseduffield.lazygit/pkg/commands/git_commands/status_test.go
Stefan Haller cb12cb2f6b Add Status.RefsSnapshot
A cheap fingerprint of local branches and HEAD that future code can poll to
detect when refs have moved externally.

Branches come from a porcelain for-each-ref. HEAD is read directly from
.git/HEAD: that avoids spawning a child process and captures the symref-or-hash
distinction we need to tell "detached at X" apart from "on a branch pointing at
X" — they share a commit hash, which is exactly the situation at the end of a
rebase when HEAD reattaches to the branch. The reftable backend doesn't keep a
real .git/HEAD (it writes a fixed stub), so when we see that stub or the file is
unreadable we fall back to porcelain commands, which are backend-agnostic.

Uses DontLog so a future polling caller won't spam the command log. Not yet
wired up to any caller.
2026-06-19 18:07:48 +02:00

92 lines
3.1 KiB
Go

package git_commands
import (
"testing"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
)
func TestStatusRefsSnapshot(t *testing.T) {
const forEachRefOutput = "aaaa refs/heads/main\nbbbb refs/heads/topic\n"
forEachRefArgs := []string{"for-each-ref", "--format=%(objectname) %(refname)", "refs/heads"}
scenarios := []struct {
testName string
headFile *string // nil means: don't create a .git/HEAD file (simulates it being unreadable).
runner *oscommands.FakeCmdObjRunner
expectedHead string
}{
{
// files backend, on a branch: read straight from .git/HEAD, no
// child process for HEAD.
testName: "attached, read from HEAD file",
headFile: lo.ToPtr("ref: refs/heads/main\n"),
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil),
expectedHead: "ref: refs/heads/main",
},
{
// files backend, detached: .git/HEAD holds the raw hash.
testName: "detached, read from HEAD file",
headFile: lo.ToPtr("aaaa\n"),
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil),
expectedHead: "aaaa",
},
{
// reftable backend (HEAD is a fixed stub), attached: fall back to
// symbolic-ref, which succeeds.
testName: "reftable stub, attached, fall back to symbolic-ref",
headFile: lo.ToPtr("ref: refs/heads/.invalid\n"),
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil),
expectedHead: "refs/heads/main",
},
{
// reftable backend, detached: symbolic-ref fails, fall back to
// rev-parse.
testName: "reftable stub, detached, fall back to rev-parse",
headFile: lo.ToPtr("ref: refs/heads/.invalid\n"),
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "", errors.New("fatal: ref HEAD is not a symbolic ref")).
ExpectGitArgs([]string{"rev-parse", "HEAD"}, "aaaa\n", nil),
expectedHead: "aaaa",
},
{
// HEAD file missing/unreadable: same fallback as reftable.
testName: "no HEAD file, fall back to symbolic-ref",
headFile: nil,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil).
ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil),
expectedHead: "refs/heads/main",
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
fs := afero.NewMemMapFs()
if s.headFile != nil {
assert.NoError(t, afero.WriteFile(fs, "/repo/.git/HEAD", []byte(*s.headFile), 0o600))
}
instance := buildStatusCommands(commonDeps{
runner: s.runner,
fs: fs,
repoPaths: MockRepoPaths("/repo"),
})
snapshot, err := instance.RefsSnapshot()
assert.NoError(t, err)
assert.Equal(t, forEachRefOutput+s.expectedHead, snapshot)
s.runner.CheckForMissingCalls()
})
}
}