mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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.
This commit is contained in:
parent
93bd26b9a9
commit
cb12cb2f6b
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
91
pkg/commands/git_commands/status_test.go
Normal file
91
pkg/commands/git_commands/status_test.go
Normal file
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue