diff --git a/cmd/entire/cli/gitrepo/reftable.go b/cmd/entire/cli/gitrepo/reftable.go new file mode 100644 index 0000000000..fa7b6e1a78 --- /dev/null +++ b/cmd/entire/cli/gitrepo/reftable.go @@ -0,0 +1,393 @@ +package gitrepo + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/storer" + gogitstorage "github.com/go-git/go-git/v6/storage" + gitfilesystem "github.com/go-git/go-git/v6/storage/filesystem" +) + +// reftableGitTimeout bounds each git plumbing invocation the reftable storer +// makes. Ref reads/writes against the local reftable stack are fast; the +// timeout only guards against a wedged git process. +const reftableGitTimeout = 30 * time.Second + +// repoUsesReftable reports whether the repository at the given git directories +// stores its references using the reftable backend rather than the classic +// loose-files + packed-refs layout. +// +// go-git (through the vendored v6 alpha) has no reftable reader: its filesystem +// storer reads refs from .git/refs, .git/packed-refs and .git/HEAD, none of +// which are authoritative in a reftable repository. Detection lets us route ref +// operations through the git CLI instead (see reftableStorer). +// +// A reftable repository is identified by the presence of a "reftable/" +// directory under either the worktree git dir or the common git dir. Git 2.45+ +// creates this directory for `git init --ref-format=reftable` and for +// `git refs migrate --ref-format=reftable`. Checking the directory avoids +// parsing config and works for linked worktrees, where the shared stack lives +// under the common git dir. +func repoUsesReftable(dotGitPath, commonGitPath string) bool { + candidates := []string{filepath.Join(dotGitPath, "reftable")} + if commonGitPath != "" && commonGitPath != dotGitPath { + candidates = append(candidates, filepath.Join(commonGitPath, "reftable")) + } + for _, dir := range candidates { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return true + } + } + return false +} + +// reftableStorer adapts a filesystem-backed go-git storage so it can open and +// operate on repositories that use the reftable ref backend. Object, config, +// index, shallow and module storage keep flowing through the embedded +// filesystem storage (reftable only changes ref storage, not object storage); +// the reference-storer methods are overridden to shell out to the git CLI, +// which is the only reftable reader/writer available to us. +// +// It also advertises reftable support via the ExtensionChecker interface so +// go-git's extension verification does not reject the repository on open. +type reftableStorer struct { + *gitfilesystem.Storage + + gitDir string + + // runGitFn runs a git plumbing command and returns trimmed stdout, raw + // stderr, and the exec error. It is overridable in tests to simulate + // spawn/timeout/exit failures deterministically; in production it is nil and + // runGit dispatches to execGit. + runGitFn func(args ...string) (string, []byte, error) +} + +var ( + _ gogitstorage.Storer = (*reftableStorer)(nil) + // The concrete methods below satisfy storer.ReferenceStorer, overriding the + // embedded filesystem implementation. + _ storer.ReferenceStorer = (*reftableStorer)(nil) +) + +// newReftableStorer wraps a filesystem storage with reftable-aware reference +// handling. gitDir must be the repository's git directory (for a linked +// worktree, the worktree's git dir); git resolves the shared reftable stack +// from its commondir automatically. +func newReftableStorer(fs *gitfilesystem.Storage, gitDir string) *reftableStorer { + return &reftableStorer{Storage: fs, gitDir: gitDir} +} + +// SupportsExtension lets go-git open a repository that declares the +// extensions.refstorage=reftable extension. Object-format and other extensions +// remain the responsibility of the embedded filesystem storage / go-git core. +func (s *reftableStorer) SupportsExtension(name, _ string) bool { + return strings.EqualFold(name, "refstorage") +} + +// runGit runs a git plumbing command scoped to this repository's git dir and +// returns trimmed stdout, raw stderr, and the exec error. Only ref plumbing +// (for-each-ref, symbolic-ref, update-ref, rev-parse) is used, none of which +// trigger git hooks, so this cannot recurse back into entire. Tests may inject +// runGitFn to simulate failures; production dispatches to execGit. +func (s *reftableStorer) runGit(args ...string) (string, []byte, error) { + if s.runGitFn != nil { + return s.runGitFn(args...) + } + return s.execGit(args...) +} + +func (s *reftableStorer) execGit(args ...string) (string, []byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), reftableGitTimeout) + defer cancel() + + full := append([]string{"--git-dir", s.gitDir}, args...) + cmd := exec.CommandContext(ctx, "git", full...) + cmd.Env = gitPlumbingEnv() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + // On our timeout the process is killed and Run reports the kill as an + // *exec.ExitError, which would be indistinguishable from a genuine + // non-zero exit. Re-wrap with the context error so callers (refLookupAbsent) + // never mistake a wedged git for a definitive "ref not found". + if err != nil && ctx.Err() != nil { + err = fmt.Errorf("git %s timed out after %s: %w", strings.Join(args, " "), reftableGitTimeout, ctx.Err()) + } + return strings.TrimRight(stdout.String(), "\n"), stderr.Bytes(), err +} + +// gitPlumbingEnv builds the environment for a reftable git plumbing command. +// LC_ALL=C / LANG=C force untranslated (English) diagnostics so the stderr +// classification (isRefCASConflict, and RemoveReference's idempotency check) +// stays correct on a localized machine — git's error messages are i18n'd, and +// matching translated text would silently misclassify CAS conflicts and delete +// failures. GIT_TERMINAL_PROMPT=0 keeps git non-interactive. The forced values +// are appended last so they override anything the caller's environment set +// (os/exec keeps the last value for a duplicate key). Mirrors the sibling +// shell-out in checkpoint/shadow_ref.go. +func gitPlumbingEnv() []string { + return append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "LC_ALL=C", + "LANG=C", + ) +} + +// refLookupAbsent reports whether a failed ref lookup (rev-parse --verify +// --quiet, update-ref) means the reference is genuinely absent rather than a +// failure to consult git at all. Only genuine absence may map to the +// plumbing.ErrReferenceNotFound / idempotent-delete sentinels; a spawn failure, +// timeout, or I/O error must be surfaced so a transient git failure is never +// mistaken for a missing ref (which would let the strategy orphan a checkpoint +// ref or drop a link). +// +// git ran and reported absence iff it exited non-zero AND stayed silent: under +// --quiet a missing ref produces no error output, whereas a fatal/I/O failure +// exits non-zero with a "fatal: ..." message, and a spawn failure or our +// timeout is not an *exec.ExitError at all. +func refLookupAbsent(err error, stderr []byte) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return false + } + return len(bytes.TrimSpace(stderr)) == 0 +} + +// SetReference stores a reference, dispatching to symbolic-ref for symbolic +// references and update-ref for hash references. +func (s *reftableStorer) SetReference(ref *plumbing.Reference) error { + if ref == nil { + return nil + } + if ref.Type() == plumbing.SymbolicReference { + if _, stderr, err := s.runGit("symbolic-ref", "--end-of-options", ref.Name().String(), ref.Target().String()); err != nil { + return fmt.Errorf("reftable set symbolic ref %s: %s: %w", ref.Name(), strings.TrimSpace(string(stderr)), err) + } + return nil + } + if _, stderr, err := s.runGit("update-ref", "--end-of-options", ref.Name().String(), ref.Hash().String()); err != nil { + return fmt.Errorf("reftable set ref %s: %s: %w", ref.Name(), strings.TrimSpace(string(stderr)), err) + } + return nil +} + +// CheckAndSetReference performs a compare-and-swap update. When old is non-nil +// the update is conditioned on the current value, mirroring go-git's atomic +// semantics; a genuine mismatch is reported as storage.ErrReferenceHasChanged. +// +// Only a real compare-and-swap conflict maps to that sentinel. Callers such as +// strategy.atomicSetV1Ref treat ErrReferenceHasChanged as "another worktree +// advanced the ref" and abort the push as a concurrency event, while wrapping +// every other error as a genuine failure. Mapping an unrelated failure (bad +// object, invalid ref name, lock contention, timeout, git spawn failure) to the +// conflict sentinel would misreport a storage error as a benign race, so those +// are surfaced as themselves. +func (s *reftableStorer) CheckAndSetReference(newRef, old *plumbing.Reference) error { + if newRef == nil { + return nil + } + if newRef.Type() == plumbing.SymbolicReference { + // Symbolic refs (e.g. HEAD) have no CAS form in update-ref; set directly. + return s.SetReference(newRef) + } + if old == nil { + return s.SetReference(newRef) + } + _, stderr, err := s.runGit("update-ref", "--end-of-options", newRef.Name().String(), newRef.Hash().String(), old.Hash().String()) + if err == nil { + return nil + } + if isRefCASConflict(stderr) { + return gogitstorage.ErrReferenceHasChanged + } + return fmt.Errorf("reftable CAS ref %s: %s: %w", newRef.Name(), strings.TrimSpace(string(stderr)), err) +} + +// isRefCASConflict reports whether git update-ref stderr indicates a +// compare-and-swap conflict: the stored value was not the expected old value +// ("... is at X but expected Y"), or a create-if-absent update found the ref +// already present ("reference already exists"). These are the only failures +// that mean the reference changed concurrently. Object/name/lock/spawn errors +// carry different messages and must not be misclassified as conflicts. +func isRefCASConflict(stderr []byte) bool { + msg := strings.ToLower(string(stderr)) + return strings.Contains(msg, "but expected") || + strings.Contains(msg, "reference already exists") +} + +// Reference returns the reference with the given name, preserving symbolic refs +// (such as HEAD) so go-git can resolve them itself. +func (s *reftableStorer) Reference(name plumbing.ReferenceName) (*plumbing.Reference, error) { + // Symbolic refs: symbolic-ref exits 0 and prints the target only for a + // genuine symbolic ref; -q makes it exit non-zero silently for a non-symbolic + // name. Classify the probe failure the same way as elsewhere: a genuine "not + // a symbolic ref" (exit non-zero, empty stderr) falls through to the hash + // lookup, but a spawn/timeout/I-O failure is surfaced rather than silently + // downgrading a symbolic ref (e.g. HEAD on a branch) to a Hash reference + // named "HEAD", which would make callers read the repo as detached. + target, symStderr, symErr := s.runGit("symbolic-ref", "-q", "--end-of-options", name.String()) + switch { + case symErr == nil && target != "": + return plumbing.NewSymbolicReference(name, plumbing.ReferenceName(target)), nil + case symErr != nil && !refLookupAbsent(symErr, symStderr): + return nil, fmt.Errorf("reftable probe symbolic ref %s: %s: %w", name, strings.TrimSpace(string(symStderr)), symErr) + } + + // Hash refs: rev-parse --verify resolves the ref to the object it points at. + // "^0" would peel tags; we want the ref's direct target, so verify the name + // as-is. A non-existent ref exits non-zero silently. + out, stderr, err := s.runGit("rev-parse", "--verify", "--quiet", "--end-of-options", name.String()) + if err != nil { + if refLookupAbsent(err, stderr) { + return nil, plumbing.ErrReferenceNotFound + } + return nil, fmt.Errorf("reftable resolve ref %s: %s: %w", name, strings.TrimSpace(string(stderr)), err) + } + if out == "" { + return nil, plumbing.ErrReferenceNotFound + } + h := plumbing.NewHash(out) + if h.IsZero() { + return nil, plumbing.ErrReferenceNotFound + } + return plumbing.NewHashReference(name, h), nil +} + +// IterReferences returns an iterator over every reference in the repository, +// including HEAD, matching the behaviour of the filesystem storer. +func (s *reftableStorer) IterReferences() (storer.ReferenceIter, error) { + refs := make([]*plumbing.Reference, 0, 16) + + // HEAD (symbolic on a branch, detached hash otherwise) is not emitted by + // for-each-ref, so resolve it explicitly first, classifying each probe: + // a genuine "not symbolic" (exit non-zero, empty stderr) means HEAD is + // detached, so resolve it as a hash; a spawn/timeout/I-O failure is surfaced + // rather than silently dropping HEAD or downgrading a symbolic HEAD to a + // hash; and a genuinely absent HEAD (unborn/empty repo) is omitted, matching + // the filesystem storer. + headTarget, headSymStderr, headSymErr := s.runGit("symbolic-ref", "-q", "--end-of-options", "HEAD") + switch { + case headSymErr == nil && headTarget != "": + refs = append(refs, plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.ReferenceName(headTarget))) + case headSymErr != nil && !refLookupAbsent(headSymErr, headSymStderr): + return nil, fmt.Errorf("reftable probe HEAD symbolic ref: %s: %w", strings.TrimSpace(string(headSymStderr)), headSymErr) + default: + // HEAD is detached or genuinely not symbolic: resolve it as a hash. + out, stderr, headErr := s.runGit("rev-parse", "--verify", "--quiet", "--end-of-options", "HEAD") + switch { + case headErr == nil && out != "": + if h := plumbing.NewHash(out); !h.IsZero() { + refs = append(refs, plumbing.NewHashReference(plumbing.HEAD, h)) + } + case headErr != nil && !refLookupAbsent(headErr, stderr): + return nil, fmt.Errorf("reftable resolve HEAD: %s: %w", strings.TrimSpace(string(stderr)), headErr) + } + } + + // All refs under refs/. %(symref) is non-empty only for symbolic refs so we + // preserve their symbolic nature (e.g. refs/remotes/origin/HEAD). + out, stderr, err := s.runGit("for-each-ref", "--format=%(objectname) %(refname) %(symref)") + if err != nil { + return nil, fmt.Errorf("reftable iterate refs: %s: %w", strings.TrimSpace(string(stderr)), err) + } + scanner := bufio.NewScanner(strings.NewReader(out)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + fields := strings.SplitN(line, " ", 3) + if len(fields) < 2 { + continue + } + objectName, refName := fields[0], fields[1] + symref := "" + if len(fields) == 3 { + symref = strings.TrimSpace(fields[2]) + } + if symref != "" { + refs = append(refs, plumbing.NewSymbolicReference(plumbing.ReferenceName(refName), plumbing.ReferenceName(symref))) + continue + } + h := plumbing.NewHash(objectName) + if h.IsZero() { + continue + } + refs = append(refs, plumbing.NewHashReference(plumbing.ReferenceName(refName), h)) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reftable scan refs: %w", err) + } + + return storer.NewReferenceSliceIter(refs), nil +} + +// RemoveReference deletes a reference. Deleting a missing ref is treated as a +// success so callers can remove idempotently, matching go-git's semantics. +func (s *reftableStorer) RemoveReference(name plumbing.ReferenceName) error { + // A symbolic ref must be deleted with symbolic-ref -d; update-ref -d on a + // symbolic ref deletes the ref it points at instead (e.g. update-ref -d HEAD + // deletes the current branch). The symbolic-ref -q probe reports absence the + // same way as a lookup — exit non-zero with empty stderr — so classify its + // failure: only a genuine "not a symbolic ref / not found" may fall through + // to update-ref -d. A spawn/timeout/I-O failure must be surfaced rather than + // assumed non-symbolic, or a transient error would be routed into a + // destructive delete of the wrong ref. + target, probeStderr, probeErr := s.runGit("symbolic-ref", "-q", "--end-of-options", name.String()) + switch { + case probeErr == nil && target != "": + if _, stderr, delErr := s.runGit("symbolic-ref", "-d", "--end-of-options", name.String()); delErr != nil { + return fmt.Errorf("reftable remove symbolic ref %s: %s: %w", name, strings.TrimSpace(string(stderr)), delErr) + } + return nil + case probeErr != nil && !refLookupAbsent(probeErr, probeStderr): + return fmt.Errorf("reftable probe symbolic ref %s: %s: %w", name, strings.TrimSpace(string(probeStderr)), probeErr) + } + + // name is not a symbolic ref (or does not exist): delete it as a hash ref. + _, stderr, err := s.runGit("update-ref", "-d", "--end-of-options", name.String()) + if err == nil { + return nil + } + // git update-ref exits 0 when deleting an already-absent ref, so reaching + // here means the delete actually failed. Only an explicit "does not exist" + // is idempotent success; an empty stderr must NOT be swallowed, because a + // killed/timed-out git also produces no stderr and would otherwise be + // silently reported as a successful deletion. + msg := strings.ToLower(strings.TrimSpace(string(stderr))) + if strings.Contains(msg, "does not exist") || strings.Contains(msg, "not exist") { + return nil + } + return fmt.Errorf("reftable remove ref %s: %s: %w", name, strings.TrimSpace(string(stderr)), err) +} + +// CountLooseRefs returns 0: reftable has no loose refs, and go-git only uses +// this count to decide whether to pack loose refs, which is a no-op here. +func (s *reftableStorer) CountLooseRefs() (int, error) { + return 0, nil +} + +// PackRefs is a no-op: the reftable backend maintains its own compaction, so +// there is nothing for go-git to pack. +func (s *reftableStorer) PackRefs() error { + return nil +} diff --git a/cmd/entire/cli/gitrepo/reftable_test.go b/cmd/entire/cli/gitrepo/reftable_test.go new file mode 100644 index 0000000000..9066a6e8fe --- /dev/null +++ b/cmd/entire/cli/gitrepo/reftable_test.go @@ -0,0 +1,621 @@ +package gitrepo + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/go-git/go-git/v6/plumbing" + gitstorage "github.com/go-git/go-git/v6/storage" + "github.com/stretchr/testify/require" +) + +// initReftableRepo creates a repository using the reftable ref backend via the +// git CLI, or skips the test when the installed git is too old to support it. +// It returns the repo dir and the initial commit hash. +func initReftableRepo(t *testing.T, name, content string) (string, string) { + t.Helper() + repoDir := t.TempDir() + + env := append(os.Environ(), + "GIT_CONFIG_GLOBAL="+filepath.Join(t.TempDir(), "gitconfig"), + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_TERMINAL_PROMPT=0", + ) + + initCmd := exec.Command("git", "init", "-b", "main", "--ref-format=reftable", repoDir) //nolint:noctx // test capability probe + initCmd.Env = env + if out, err := initCmd.CombinedOutput(); err != nil { + t.Skipf("git does not support reftable repositories: %v\n%s", err, out) + } + + git := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) //nolint:noctx // test helper + cmd.Dir = repoDir + cmd.Env = env + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %s: %s", strings.Join(args, " "), out) + return strings.TrimSpace(string(out)) + } + + if got := git("rev-parse", "--show-ref-format"); got != "reftable" { + t.Skipf("git initialized ref format %q, not reftable", got) + } + git("config", "user.name", "Test User") + git("config", "user.email", "test@example.com") + git("config", "commit.gpgsign", "false") + require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(content), 0o644)) + git("add", name) + git("commit", "-m", "initial") + + return repoDir, git("rev-parse", "HEAD") +} + +// reftableCommit adds a file and commits it in an existing reftable repo, +// returning the new HEAD hash. The repo's user identity is already configured +// by initReftableRepo, so only an isolated global/system config is supplied. +func reftableCommit(t *testing.T, repoDir, name, content string) string { + t.Helper() + env := append(os.Environ(), + "GIT_CONFIG_GLOBAL="+filepath.Join(t.TempDir(), "gitconfig"), + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_TERMINAL_PROMPT=0", + ) + run := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) //nolint:noctx // test helper + cmd.Dir = repoDir + cmd.Env = env + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %s: %s", strings.Join(args, " "), out) + return strings.TrimSpace(string(out)) + } + require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(content), 0o644)) + run("add", name) + run("commit", "-m", content) + return run("rev-parse", "HEAD") +} + +// TestCheckAndSetReference_ConflictVsError verifies that CheckAndSetReference +// maps only a genuine compare-and-swap conflict to storage.ErrReferenceHasChanged +// and surfaces unrelated failures (a new value pointing at a nonexistent object) +// as themselves. Callers such as strategy.atomicSetV1Ref branch on that sentinel +// to decide whether a privacy-critical push aborts because of concurrency or a +// real storage error, so misclassifying an I/O/object failure as a conflict is a +// correctness bug. Regression for the coarse error mapping in #547. +func TestCheckAndSetReference_ConflictVsError(t *testing.T) { + t.Parallel() + repoDir, headHash := initReftableRepo(t, "file.txt", "hello\n") + + repo, err := OpenPath(repoDir) + require.NoError(t, err) + defer repo.Close() + + refName := plumbing.ReferenceName("refs/entire/cas") + head := plumbing.NewHash(headHash) + require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, head))) + + // A distinct, real object to swap the ref to. + secondHash := plumbing.NewHash(reftableCommit(t, repoDir, "second.txt", "second\n")) + require.NotEqual(t, head, secondHash) + + // Correct compare-and-swap succeeds: ref is at head, swap head -> second. + require.NoError(t, repo.Storer.CheckAndSetReference( + plumbing.NewHashReference(refName, secondHash), + plumbing.NewHashReference(refName, head), + )) + + // Genuine conflict: ref is now at second, but we claim it is still at head. + err = repo.Storer.CheckAndSetReference( + plumbing.NewHashReference(refName, head), + plumbing.NewHashReference(refName, head), + ) + require.ErrorIs(t, err, gitstorage.ErrReferenceHasChanged, + "a stale expected-old value must be reported as a CAS conflict") + + // Non-conflict failure: the expected-old value is correct (ref is at second), + // but the new value points at a nonexistent object. git rejects the object, + // not the CAS, so this must NOT be reported as a concurrency conflict. + bogus := plumbing.NewHash("1111111111111111111111111111111111111111") + err = repo.Storer.CheckAndSetReference( + plumbing.NewHashReference(refName, bogus), + plumbing.NewHashReference(refName, secondHash), + ) + require.Error(t, err) + require.NotErrorIs(t, err, gitstorage.ErrReferenceHasChanged, + "a nonexistent-object write must not be misreported as a CAS conflict") + + // The failed CAS must not have advanced the ref. + cur, err := repo.Storer.Reference(refName) + require.NoError(t, err) + require.Equal(t, secondHash, cur.Hash()) +} + +// TestReftableStorer_RefNamesAreArgvNotShell verifies that ref names are passed +// to git as argv, never interpolated into a shell command line. The injected +// name embeds a backtick command substitution with output redirection; if any +// method shelled out, the shell would create the marker file. Every method must +// instead treat the whole string as a literal ref name that round-trips. +func TestReftableStorer_RefNamesAreArgvNotShell(t *testing.T) { + t.Parallel() + repoDir, headHash := initReftableRepo(t, "file.txt", "hello\n") + + repo, err := OpenPath(repoDir) + require.NoError(t, err) + defer repo.Close() + + marker := filepath.Join(t.TempDir(), "PWNED") + require.NotContains(t, marker, " ", "test temp path must be space-free for a valid ref name") + // `>marker` is a shell redirection inside a backtick command substitution: + // it creates marker if (and only if) the name is evaluated by a shell. + injected := plumbing.ReferenceName("refs/entire/inj`>" + marker + "`tail") + head := plumbing.NewHash(headHash) + + require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(injected, head))) + require.NoFileExists(t, marker, "ref name must not be evaluated by a shell on write") + + got, err := repo.Storer.Reference(injected) + require.NoError(t, err) + require.Equal(t, head, got.Hash()) + require.NoFileExists(t, marker, "ref name must not be evaluated by a shell on read") + + iter, err := repo.Storer.IterReferences() + require.NoError(t, err) + found := false + require.NoError(t, iter.ForEach(func(r *plumbing.Reference) error { + if r.Name() == injected { + found = true + } + return nil + })) + iter.Close() + require.True(t, found, "injected ref name must appear verbatim in iteration") + + require.NoError(t, repo.Storer.RemoveReference(injected)) + require.NoFileExists(t, marker, "ref name must not be evaluated by a shell on remove") + _, err = repo.Storer.Reference(injected) + require.ErrorIs(t, err, plumbing.ErrReferenceNotFound) +} + +// git subcommand names used by the runGitFn fakes below (named to satisfy +// goconst, which flags the repeated literals across the injected switches). +const ( + gitSymbolicRef = "symbolic-ref" + gitRevParse = "rev-parse" + gitUpdateRef = "update-ref" + gitForEachRef = "for-each-ref" + + // testHeadHash is an arbitrary valid-looking SHA-1 used as a fixture in the + // runGitFn fakes below. + testHeadHash = "20b4de1033d986a83837177f961c80bb799161e6" +) + +// realExitError returns a genuine *exec.ExitError (git ran and exited non-zero) +// so runGitFn injections can distinguish "git reported absence" from a spawn or +// timeout failure that never produced an exit code. +func realExitError(t *testing.T) error { + t.Helper() + err := exec.Command("sh", "-c", "exit 1").Run() //nolint:noctx // test helper + var ee *exec.ExitError + require.ErrorAs(t, err, &ee) + return err +} + +// timeoutError mimics the error execGit produces when its context deadline +// fires and the git process is killed. +func timeoutError() error { + return fmt.Errorf("git rev-parse timed out: %w", context.DeadlineExceeded) +} + +// lastEnvValue returns the last value for key in an environment slice, matching +// os/exec's de-duplication (last value wins for a duplicate key). +func lastEnvValue(env []string, key string) (string, bool) { + value, found := "", false + for _, kv := range env { + if k, v, ok := strings.Cut(kv, "="); ok && k == key { + value, found = v, true + } + } + return value, found +} + +// TestGitPlumbingEnv_ForcesCLocale verifies that reftable git plumbing always +// runs under a C locale, so git's stderr is never translated and the +// English-substring classification (isRefCASConflict, RemoveReference +// idempotency) is correct even when the caller's environment is localized. The +// forced values must win over any inherited LANG/LC_ALL/LC_MESSAGES. +func TestGitPlumbingEnv_ForcesCLocale(t *testing.T) { + // Not parallel: t.Setenv is incompatible with t.Parallel. + t.Setenv("LANG", "de_DE.UTF-8") + t.Setenv("LC_ALL", "de_DE.UTF-8") + t.Setenv("LC_MESSAGES", "fr_FR.UTF-8") + + env := gitPlumbingEnv() + for key, want := range map[string]string{"LC_ALL": "C", "LANG": "C", "GIT_TERMINAL_PROMPT": "0"} { + got, found := lastEnvValue(env, key) + require.Truef(t, found, "%s must be set", key) + require.Equalf(t, want, got, "effective %s must be forced regardless of the caller's environment", key) + } +} + +// TestRefLookupAbsent_IsLocaleIndependent verifies the absence classifier keys +// on exit code + empty stderr, so it is correct in any locale: a translated, +// non-empty diagnostic is still surfaced as a real error, not absence. +func TestRefLookupAbsent_IsLocaleIndependent(t *testing.T) { + t.Parallel() + germanFatal := []byte("schwerwiegend: Referenz existiert nicht\n") + require.False(t, refLookupAbsent(realExitError(t), germanFatal), + "a non-empty (translated) diagnostic must be surfaced regardless of language") + require.True(t, refLookupAbsent(realExitError(t), nil), + "exit non-zero with empty stderr is absence in any locale") + require.True(t, refLookupAbsent(realExitError(t), []byte(" \n")), + "whitespace-only stderr counts as empty") + require.False(t, refLookupAbsent(timeoutError(), nil), + "a timeout is never absence") +} + +// TestReference_ClassifiesLookupFailures verifies that Reference maps only a +// genuine "git ran and the ref is absent" result to ErrReferenceNotFound, and +// surfaces spawn/timeout/I-O failures instead. Reporting a transient git +// failure as "ref not found" can make the strategy treat a live checkpoint ref +// as absent (orphan reset, lost linkage), so this distinction is load-bearing. +func TestReference_ClassifiesLookupFailures(t *testing.T) { + t.Parallel() + name := plumbing.ReferenceName("refs/entire/probe") + + // symbolic-ref always fails "not symbolic" so every case exercises the + // rev-parse hash path, whose result is controlled per test. + storerWith := func(revParseOut string, revParseStderr []byte, revParseErr error) *reftableStorer { + return &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", nil, realExitError(t) + case gitRevParse: + return revParseOut, revParseStderr, revParseErr + default: + return "", nil, nil + } + }} + } + + t.Run("genuine absence maps to ErrReferenceNotFound", func(t *testing.T) { + t.Parallel() + _, err := storerWith("", nil, realExitError(t)).Reference(name) + require.ErrorIs(t, err, plumbing.ErrReferenceNotFound) + }) + + t.Run("timeout is surfaced, not absent", func(t *testing.T) { + t.Parallel() + _, err := storerWith("", nil, timeoutError()).Reference(name) + require.Error(t, err) + require.NotErrorIs(t, err, plumbing.ErrReferenceNotFound) + }) + + t.Run("spawn failure is surfaced, not absent", func(t *testing.T) { + t.Parallel() + spawn := &exec.Error{Name: "git", Err: errors.New("executable file not found in $PATH")} + _, err := storerWith("", nil, spawn).Reference(name) + require.Error(t, err) + require.NotErrorIs(t, err, plumbing.ErrReferenceNotFound) + }) + + t.Run("non-zero exit WITH stderr is surfaced, not absent", func(t *testing.T) { + t.Parallel() + _, err := storerWith("", []byte("fatal: unable to read reftable stack\n"), realExitError(t)).Reference(name) + require.Error(t, err) + require.NotErrorIs(t, err, plumbing.ErrReferenceNotFound) + }) + + t.Run("transient symbolic-ref probe failure is surfaced, not downgraded to a hash", func(t *testing.T) { + t.Parallel() + revParseCalled := false + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", nil, timeoutError() // probe times out on a possibly-symbolic ref + case gitRevParse: + revParseCalled = true + return testHeadHash, nil, nil // would wrongly succeed + default: + return "", nil, nil + } + }} + _, err := s.Reference(plumbing.ReferenceName("HEAD")) + require.Error(t, err) + require.NotErrorIs(t, err, plumbing.ErrReferenceNotFound) + require.False(t, revParseCalled, + "a transient symbolic-ref probe failure must be surfaced, not silently downgraded via rev-parse") + }) +} + +// TestIterReferences_HEADFailureSurfaces verifies the HEAD-resolution path in +// IterReferences surfaces a real git failure rather than silently dropping HEAD, +// while still omitting a genuinely-absent HEAD and preserving detached HEAD. +func TestIterReferences_HEADFailureSurfaces(t *testing.T) { + t.Parallel() + + t.Run("git failure resolving HEAD is surfaced", func(t *testing.T) { + t.Parallel() + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef, gitRevParse: + return "", nil, timeoutError() + default: + return "", nil, nil + } + }} + _, err := s.IterReferences() + require.Error(t, err) + }) + + t.Run("detached HEAD resolves to a hash reference", func(t *testing.T) { + t.Parallel() + hash := testHeadHash + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", nil, realExitError(t) // detached: not symbolic + case gitRevParse: + return hash, nil, nil + case gitForEachRef: + return "", nil, nil + default: + return "", nil, nil + } + }} + iter, err := s.IterReferences() + require.NoError(t, err) + var head *plumbing.Reference + require.NoError(t, iter.ForEach(func(r *plumbing.Reference) error { + if r.Name() == plumbing.HEAD { + head = r + } + return nil + })) + iter.Close() + require.NotNil(t, head, "detached HEAD must be present in iteration") + require.Equal(t, plumbing.NewHash(hash), head.Hash()) + }) + + t.Run("genuinely absent HEAD is omitted without error", func(t *testing.T) { + t.Parallel() + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", nil, realExitError(t) // not symbolic + case gitRevParse: + return "", nil, realExitError(t) // absent: exited non-zero, silent + case gitForEachRef: + return "", nil, nil + default: + return "", nil, nil + } + }} + iter, err := s.IterReferences() + require.NoError(t, err) + count := 0 + require.NoError(t, iter.ForEach(func(_ *plumbing.Reference) error { count++; return nil })) + iter.Close() + require.Equal(t, 0, count, "an unborn/absent HEAD must yield no references, not an error") + }) + + t.Run("transient HEAD symbolic-ref probe failure is surfaced, not downgraded to a hash", func(t *testing.T) { + t.Parallel() + revParseCalled := false + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", nil, timeoutError() // HEAD probe times out on a branch + case gitRevParse: + revParseCalled = true + return testHeadHash, nil, nil // would wrongly succeed + case gitForEachRef: + return "", nil, nil + default: + return "", nil, nil + } + }} + _, err := s.IterReferences() + require.Error(t, err) + require.False(t, revParseCalled, + "a transient HEAD symbolic-ref probe failure must be surfaced, not downgraded to a hash HEAD") + }) +} + +// TestRemoveReference_DeleteFailureNotSwallowed verifies that a failed deletion +// is only treated as idempotent success when git ran and reported the ref +// already absent (exit 0, or an explicit "does not exist"). A non-zero exit with +// empty stderr — as produced by a killed/timed-out git — must surface as an +// error, not a phantom successful deletion. +func TestRemoveReference_DeleteFailureNotSwallowed(t *testing.T) { + t.Parallel() + name := plumbing.ReferenceName("refs/entire/rm") + + storerWith := func(updateRefStderr []byte, updateRefErr error) *reftableStorer { + return &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", nil, realExitError(t) // not symbolic -> update-ref -d path + case gitUpdateRef: + return "", updateRefStderr, updateRefErr + default: + return "", nil, nil + } + }} + } + + t.Run("exit 0 is idempotent success", func(t *testing.T) { + t.Parallel() + require.NoError(t, storerWith(nil, nil).RemoveReference(name)) + }) + + t.Run("explicit does-not-exist is idempotent success", func(t *testing.T) { + t.Parallel() + require.NoError(t, storerWith([]byte("error: refs/entire/rm does not exist"), realExitError(t)).RemoveReference(name)) + }) + + t.Run("non-zero exit with empty stderr is an error", func(t *testing.T) { + t.Parallel() + require.Error(t, storerWith(nil, realExitError(t)).RemoveReference(name), + "a delete failure with empty stderr must not be swallowed as success") + }) + + t.Run("timeout is an error", func(t *testing.T) { + t.Parallel() + require.Error(t, storerWith(nil, timeoutError()).RemoveReference(name)) + }) +} + +// TestRemoveReference_SymbolicProbeFailureSurfaced verifies that the +// symbolic-ref -q probe classifies its failure: a genuine "not a symbolic ref" +// (exit non-zero, empty stderr) falls through to update-ref -d, but a transient +// failure (timeout/spawn/I-O) is surfaced and never routed into update-ref -d. +// Routing a transient failure into update-ref -d is destructive: on a symbolic +// ref (e.g. HEAD) update-ref -d deletes the ref it points at, silently losing a +// branch pointer. +func TestRemoveReference_SymbolicProbeFailureSurfaced(t *testing.T) { + t.Parallel() + name := plumbing.ReferenceName("refs/entire/rm") + + t.Run("transient probe failure is surfaced, never routed to update-ref -d", func(t *testing.T) { + t.Parallel() + updateRefCalled := false + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", nil, timeoutError() + case gitUpdateRef: + updateRefCalled = true + return "", nil, nil + default: + return "", nil, nil + } + }} + err := s.RemoveReference(name) + require.Error(t, err) + require.False(t, updateRefCalled, + "a transient symbolic-ref probe failure must not fall through to the destructive update-ref -d") + }) + + t.Run("fatal probe error with stderr is surfaced, not routed to update-ref -d", func(t *testing.T) { + t.Parallel() + updateRefCalled := false + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", []byte("fatal: unable to read reftable stack\n"), realExitError(t) + case gitUpdateRef: + updateRefCalled = true + return "", nil, nil + default: + return "", nil, nil + } + }} + require.Error(t, s.RemoveReference(name)) + require.False(t, updateRefCalled) + }) + + t.Run("genuine not-symbolic falls through to update-ref -d", func(t *testing.T) { + t.Parallel() + updateRefCalled := false + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch args[0] { + case gitSymbolicRef: + return "", nil, realExitError(t) // exit non-zero, empty stderr = not symbolic + case gitUpdateRef: + updateRefCalled = true + return "", nil, nil + default: + return "", nil, nil + } + }} + require.NoError(t, s.RemoveReference(name)) + require.True(t, updateRefCalled, "a non-symbolic ref must be deleted via update-ref -d") + }) + + t.Run("symbolic ref is deleted via symbolic-ref -d, never update-ref -d", func(t *testing.T) { + t.Parallel() + symbolicDeleteCalled, updateRefCalled := false, false + s := &reftableStorer{gitDir: "unused", runGitFn: func(args ...string) (string, []byte, error) { + switch { + case args[0] == gitSymbolicRef && len(args) > 1 && args[1] == "-d": + symbolicDeleteCalled = true + return "", nil, nil + case args[0] == gitSymbolicRef: // the -q probe + return "refs/heads/main", nil, nil + case args[0] == "update-ref": + updateRefCalled = true + return "", nil, nil + default: + return "", nil, nil + } + }} + require.NoError(t, s.RemoveReference(name)) + require.True(t, symbolicDeleteCalled, "a symbolic ref must be deleted with symbolic-ref -d") + require.False(t, updateRefCalled, "a symbolic ref must not be deleted with update-ref -d") + }) +} + +// TestOpenPath_ReftableRepository verifies that a reftable repository, which +// go-git's filesystem storer cannot open, is opened successfully and that ref +// read/write/list/remove all round-trip through the git-CLI-backed storer. +func TestOpenPath_ReftableRepository(t *testing.T) { + t.Parallel() + repoDir, headHash := initReftableRepo(t, "file.txt", "hello\n") + + repo, err := OpenPath(repoDir) + require.NoError(t, err, "reftable repository should open") + defer repo.Close() + + // HEAD resolves to the real branch, not the reftable .invalid stub. + head, err := repo.Head() + require.NoError(t, err) + require.Equal(t, "refs/heads/main", head.Name().String()) + require.Equal(t, headHash, head.Hash().String()) + + // Write a new ref via go-git (routed through git update-ref) and read it back. + newRef := plumbing.NewHashReference(plumbing.ReferenceName("refs/entire/test/one"), head.Hash()) + require.NoError(t, repo.Storer.SetReference(newRef)) + + got, err := repo.Storer.Reference(newRef.Name()) + require.NoError(t, err) + require.Equal(t, head.Hash(), got.Hash()) + + // The new ref appears in iteration. + iter, err := repo.Storer.IterReferences() + require.NoError(t, err) + found := false + require.NoError(t, iter.ForEach(func(r *plumbing.Reference) error { + if r.Name() == newRef.Name() { + found = true + } + return nil + })) + iter.Close() + require.True(t, found, "written ref should appear in IterReferences") + + // Removal round-trips, and removing again is a no-op. + require.NoError(t, repo.Storer.RemoveReference(newRef.Name())) + _, err = repo.Storer.Reference(newRef.Name()) + require.ErrorIs(t, err, plumbing.ErrReferenceNotFound) + require.NoError(t, repo.Storer.RemoveReference(newRef.Name())) +} + +// TestRepoUsesReftable_Detection checks that reftable detection distinguishes +// reftable repositories from classic files-backend repositories. +func TestRepoUsesReftable_Detection(t *testing.T) { + t.Parallel() + + reftableRepo, _ := initReftableRepo(t, "a.txt", "a\n") + require.True(t, repoUsesReftable(filepath.Join(reftableRepo, ".git"), filepath.Join(reftableRepo, ".git"))) + + filesRepo := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(filesRepo, ".git", "refs"), 0o755)) + require.False(t, repoUsesReftable(filepath.Join(filesRepo, ".git"), filepath.Join(filesRepo, ".git"))) +} diff --git a/cmd/entire/cli/gitrepo/repository.go b/cmd/entire/cli/gitrepo/repository.go index 74adc359f3..fbef9888fd 100644 --- a/cmd/entire/cli/gitrepo/repository.go +++ b/cmd/entire/cli/gitrepo/repository.go @@ -118,7 +118,23 @@ func openPathWithAlternates(repoRoot string) (*git.Repository, error) { AlternatesFS: newAlternatesFilesystem(), }, ) - repo, err := git.Open(storage, osfs.New(repoRoot, osfs.WithBoundOS())) + + // go-git's filesystem storer cannot read the reftable ref backend: it reads + // refs from .git/refs, packed-refs and .git/HEAD, none of which are + // authoritative in a reftable repository, and its extension check rejects + // extensions.refstorage=reftable outright. Route ref operations through the + // git CLI for such repositories while keeping object storage on go-git. + worktreeFS := osfs.New(repoRoot, osfs.WithBoundOS()) + if repoUsesReftable(dotGitPath, commonGitPath) { + repo, err := git.Open(newReftableStorer(storage, dotGitPath), worktreeFS) + if err != nil { + _ = storage.Close() + return nil, fmt.Errorf("open reftable repository storage: %w", err) + } + return repo, nil + } + + repo, err := git.Open(storage, worktreeFS) if err != nil { _ = storage.Close() return nil, fmt.Errorf("open repository storage: %w", err) diff --git a/cmd/entire/cli/integration_test/reftable_repo_test.go b/cmd/entire/cli/integration_test/reftable_repo_test.go new file mode 100644 index 0000000000..5d480eae99 --- /dev/null +++ b/cmd/entire/cli/integration_test/reftable_repo_test.go @@ -0,0 +1,190 @@ +//go:build integration + +package integration + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// TestReftableRepository_EnableAndFirstCheckpoint exercises the full capture +// flow (enable -> session start -> file changes -> stop -> user commit -> +// checkpoint) against a repository using the reftable ref backend. go-git's +// filesystem storer cannot read reftable refs, so this verifies the git-CLI +// ref routing in gitrepo.reftableStorer works end to end. Regression for #547. +func TestReftableRepository_EnableAndFirstCheckpoint(t *testing.T) { + t.Parallel() + requireGitReftableSupport(t) + + env := NewTestEnv(t) + + // Set up the reftable repo and initial commit directly via git CLI, matching + // the sha256 repo test: integration tests avoid the enable bootstrap path and + // drive hooks through getTestBinary() so they exercise the binary under test. + gitOutput(t, "", "init", "--ref-format=reftable", env.RepoDir) + gitOutput(t, env.RepoDir, "config", "user.name", "Test User") + gitOutput(t, env.RepoDir, "config", "user.email", "test@example.com") + gitOutput(t, env.RepoDir, "config", "commit.gpgsign", "false") + env.WriteFile("README.md", "# reftable repo\n") + gitOutput(t, env.RepoDir, "add", "README.md") + gitOutput(t, env.RepoDir, "commit", "-m", "Initial reftable commit") + + if got := gitOutput(t, env.RepoDir, "rev-parse", "--show-ref-format"); got != "reftable" { + t.Fatalf("repository ref format = %q, want reftable", got) + } + + output := env.RunCLI( + "enable", + "--no-github", + "--agent", "claude-code", + "--telemetry=false", + ) + if !strings.Contains(output, paths.MetadataBranchName) { + t.Fatalf("expected enable to create %s branch, got output:\n%s", paths.MetadataBranchName, output) + } + + // The metadata branch is a real ref; resolving it proves the reftable-backed + // ref write during enable succeeded. + initialHead := gitOutput(t, env.RepoDir, "rev-parse", "HEAD") + initialMetadataHead := gitOutput(t, env.RepoDir, "rev-parse", paths.MetadataBranchName) + + sess := env.NewSession() + prompt := "Create a file in the reftable repo" + if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(sess.ID, prompt, sess.TranscriptPath); err != nil { + t.Fatalf("user-prompt-submit failed: %v", err) + } + + const mainContent = "package main\n\nfunc main() {}\n" + env.WriteFile("main.go", mainContent) + sess.CreateTranscript(prompt, []FileChange{{Path: "main.go", Content: mainContent}}) + if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil { + t.Fatalf("stop hook failed creating first checkpoint: %v", err) + } + + state, err := env.GetSessionState(sess.ID) + if err != nil { + t.Fatalf("GetSessionState failed: %v", err) + } + if state == nil || state.StepCount != 1 { + t.Fatalf("session StepCount after first checkpoint = %#v, want 1", state) + } + + // The shadow branch is created and advanced via reftable ref writes. + shadowBranch := env.GetShadowBranchNameForCommit(initialHead) + if got := gitOutput(t, env.RepoDir, "rev-parse", shadowBranch); got == "" { + t.Fatalf("expected shadow branch %s to resolve", shadowBranch) + } + + env.GitCommitWithShadowHooks("Add reftable main", "main.go") + userHead := gitOutput(t, env.RepoDir, "rev-parse", "HEAD") + if userHead == initialHead { + t.Fatal("expected user commit to advance HEAD") + } + + // The condensation on user commit advances the metadata branch (a ref write) + // and links the checkpoint via a commit trailer on the user commit. + metadataHead := gitOutput(t, env.RepoDir, "rev-parse", paths.MetadataBranchName) + if metadataHead == initialMetadataHead { + t.Fatal("expected metadata branch to advance after condensing the first checkpoint") + } + + subject := gitOutput(t, env.RepoDir, "log", "-1", "--format=%s", paths.MetadataBranchName) + if !strings.HasPrefix(subject, "Checkpoint: ") { + t.Fatalf("metadata branch latest subject = %q, want Checkpoint: ", subject) + } + checkpointID := strings.TrimPrefix(subject, "Checkpoint: ") + + userBody := gitOutput(t, env.RepoDir, "log", "-1", "--format=%B", "HEAD") + if !strings.Contains(userBody, "Entire-Checkpoint: "+checkpointID) { + t.Fatalf("user commit body missing Entire-Checkpoint trailer for %s:\n%s", checkpointID, userBody) + } + + if _, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionMetadataPath(checkpointID)); !found { + t.Fatalf("expected session metadata for checkpoint %s on %s", checkpointID, paths.MetadataBranchName) + } + + // checkpoint list must work against the reftable repo (read path). + listOut := env.RunCLI("checkpoint", "list") + if !strings.Contains(listOut, checkpointID) { + t.Fatalf("checkpoint list missing checkpoint %s:\n%s", checkpointID, listOut) + } +} + +// TestReftableRepository_LinkedWorktree verifies the capture flow works inside a +// linked worktree of a reftable repository, where the shared reftable stack +// lives under the common git dir rather than the worktree git dir. +func TestReftableRepository_LinkedWorktree(t *testing.T) { + t.Parallel() + requireGitReftableSupport(t) + + env := NewTestEnv(t) + + gitOutput(t, "", "init", "--ref-format=reftable", env.RepoDir) + gitOutput(t, env.RepoDir, "config", "user.name", "Test User") + gitOutput(t, env.RepoDir, "config", "user.email", "test@example.com") + gitOutput(t, env.RepoDir, "config", "commit.gpgsign", "false") + env.WriteFile("README.md", "# reftable repo\n") + gitOutput(t, env.RepoDir, "add", "README.md") + gitOutput(t, env.RepoDir, "commit", "-m", "Initial reftable commit") + + // Create a linked worktree on a feature branch. + worktreePath := filepath.Join(t.TempDir(), "wt") + gitOutput(t, env.RepoDir, "worktree", "add", "-b", "feature/wt", worktreePath) + + // Enable and drive a checkpoint from within the worktree by pointing the CLI + // at the worktree directory. + runCLIIn(t, env, worktreePath, "enable", "--no-github", "--agent", "claude-code", "--telemetry=false") + + if got := gitOutput(t, worktreePath, "rev-parse", "--show-ref-format"); got != "reftable" { + t.Fatalf("worktree ref format = %q, want reftable", got) + } + + // A ref read against the worktree (HEAD resolution through the reftable + // storer, whose HEAD stub go-git cannot read) must return the real branch. + branch := gitOutput(t, worktreePath, "rev-parse", "--abbrev-ref", "HEAD") + if branch != "feature/wt" { + t.Fatalf("worktree branch = %q, want feature/wt", branch) + } + + metadataHead := gitOutput(t, worktreePath, "rev-parse", paths.MetadataBranchName) + if metadataHead == "" { + t.Fatalf("expected metadata branch to resolve from worktree") + } +} + +// runCLIIn runs the built entire binary in an arbitrary directory (e.g. a linked +// worktree) with the same isolated environment RunCLI uses, detached from any +// controlling TTY (matching TestEnv.RunCLIWithError) so an interactive prompt +// path can't hang the test. +func runCLIIn(t *testing.T, env *TestEnv, dir string, args ...string) string { + t.Helper() + cmd := execx.NonInteractive(context.Background(), getTestBinary(), args...) + cmd.Dir = dir + cmd.Env = env.cliEnv() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("entire %s (in %s) failed: %v\n%s", strings.Join(args, " "), dir, err, out) + } + return string(out) +} + +func requireGitReftableSupport(t *testing.T) { + t.Helper() + + dir := t.TempDir() + cmd := exec.Command("git", "init", "--ref-format=reftable", dir) //nolint:noctx // test capability probe + cmd.Env = testutil.GitIsolatedEnv() + if output, err := cmd.CombinedOutput(); err != nil { + t.Skipf("git does not support reftable repositories: %v\n%s", err, output) + } + if got := gitOutput(t, dir, "rev-parse", "--show-ref-format"); got != "reftable" { + t.Skipf("git initialized ref format %q, not reftable", got) + } +}