From cb12cb2f6b7a9740fd6b993386cf56e6c8822e41 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:05:37 +0200 Subject: [PATCH] Add Status.RefsSnapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/commands/git_commands/deps_test.go | 6 ++ pkg/commands/git_commands/status.go | 62 ++++++++++++++++ pkg/commands/git_commands/status_test.go | 91 ++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 pkg/commands/git_commands/status_test.go diff --git a/pkg/commands/git_commands/deps_test.go b/pkg/commands/git_commands/deps_test.go index 85de496dc..235f21716 100644 --- a/pkg/commands/git_commands/deps_test.go +++ b/pkg/commands/git_commands/deps_test.go @@ -168,6 +168,12 @@ func buildBranchCommands(deps commonDeps) *BranchCommands { return NewBranchCommands(gitCommon) } +func buildStatusCommands(deps commonDeps) *StatusCommands { + gitCommon := buildGitCommon(deps) + + return NewStatusCommands(gitCommon) +} + func buildFlowCommands(deps commonDeps) *FlowCommands { gitCommon := buildGitCommon(deps) diff --git a/pkg/commands/git_commands/status.go b/pkg/commands/git_commands/status.go index ff09e22bc..d9120d87d 100644 --- a/pkg/commands/git_commands/status.go +++ b/pkg/commands/git_commands/status.go @@ -4,8 +4,10 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/spf13/afero" ) type StatusCommands struct { @@ -82,6 +84,66 @@ func (self *StatusCommands) IsInRevert() (bool, error) { return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "REVERT_HEAD")) } +// RefsSnapshot returns a string fingerprint of the current state of local +// branches and HEAD. Comparing two snapshots byte-for-byte tells us whether +// any local ref or HEAD has moved since the last snapshot. +func (self *StatusCommands) RefsSnapshot() (string, error) { + t := time.Now() + defer func() { self.Log.Infof("RefsSnapshot took %s", time.Since(t)) }() + + refsArgs := NewGitCmd("for-each-ref"). + Arg("--format=%(objectname) %(refname)"). + Arg("refs/heads"). + ToArgv() + refs, err := self.cmd.New(refsArgs).DontLog().RunWithOutput() + if err != nil { + return "", err + } + + head, err := self.headSnapshot() + if err != nil { + return "", err + } + + return refs + head, nil +} + +// headSnapshot returns a fingerprint of HEAD that distinguishes "detached at +// commit X" from "on a branch that points at X". The commit hash alone can't +// tell those apart, which matters at the end of a rebase: HEAD reattaches to +// the branch without the hash changing, and we'd otherwise miss that refresh. +// +// We read .git/HEAD directly rather than shelling out: it's faster (no child +// process) and its content is exactly the symref-or-hash distinction we want +// ("ref: refs/heads/foo" when attached, the raw hash when detached). The +// reftable backend, however, doesn't keep a real .git/HEAD — it writes a fixed +// stub ("ref: refs/heads/.invalid") that never reflects the actual HEAD. When +// we see that stub (or the file is missing/unreadable) we fall back to +// porcelain commands, which are backend-agnostic. +func (self *StatusCommands) headSnapshot() (string, error) { + headPath := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "HEAD") + if content, err := afero.ReadFile(self.Fs, headPath); err == nil { + head := strings.TrimSpace(string(content)) + if head != "" && head != "ref: refs/heads/.invalid" { + return head, nil + } + } + + // symbolic-ref gives the branch when HEAD is attached and fails when it's + // detached, in which case rev-parse gives the commit HEAD points at. + symbolicRefArgs := NewGitCmd("symbolic-ref").Arg("HEAD").ToArgv() + if symref, err := self.cmd.New(symbolicRefArgs).DontLog().RunWithOutput(); err == nil { + return strings.TrimSpace(symref), nil + } + + revParseArgs := NewGitCmd("rev-parse").Arg("HEAD").ToArgv() + head, err := self.cmd.New(revParseArgs).DontLog().RunWithOutput() + if err != nil { + return "", err + } + return strings.TrimSpace(head), nil +} + // Full ref (e.g. "refs/heads/mybranch") of the branch that is currently // being rebased, or empty string when we're not in a rebase func (self *StatusCommands) BranchBeingRebased() string { diff --git a/pkg/commands/git_commands/status_test.go b/pkg/commands/git_commands/status_test.go new file mode 100644 index 000000000..dc6b2d558 --- /dev/null +++ b/pkg/commands/git_commands/status_test.go @@ -0,0 +1,91 @@ +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() + }) + } +}