Skip to content

Commit f2e3989

Browse files
authored
Merge pull request #1311 from entireio/read-v1.1
Add checkpoints v1.1 support to entire explain
2 parents 8b6eaa2 + 3ec422b commit f2e3989

8 files changed

Lines changed: 488 additions & 14 deletions

File tree

cmd/entire/cli/checkpoint/committed.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1781,13 +1781,18 @@ func (s *GitStore) getFetchingTree(ctx context.Context) (*FetchingTree, error) {
17811781
return NewFetchingTree(ctx, tree, s.repo.Storer, s.blobFetcher), nil
17821782
}
17831783

1784-
// getSessionsBranchTree returns the tree object for the entire/checkpoints/v1 branch.
1785-
// Falls back to origin/entire/checkpoints/v1 if the local branch doesn't exist.
1784+
// getSessionsBranchTree returns the tree object for the configured committed
1785+
// ref (the v1 branch by default, or the v1.1 custom ref for v1.1 read stores).
1786+
// For the default v1 branch it falls back to origin/entire/checkpoints/v1 when
1787+
// the local branch is missing; the v1.1 custom ref is local-only, so no remote
1788+
// fallback applies there.
17861789
func (s *GitStore) getSessionsBranchTree() (*object.Tree, error) {
1787-
refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName)
1788-
ref, err := s.repo.Reference(refName, true)
1790+
ref, err := s.repo.Reference(s.committedReadRef, true)
17891791
if err != nil {
1790-
// Local branch doesn't exist, try remote-tracking branch
1792+
if s.committedReadRef != defaultCommittedReadRef() {
1793+
return nil, fmt.Errorf("sessions ref %s not found: %w", s.committedReadRef, err)
1794+
}
1795+
// Local v1 branch doesn't exist, try remote-tracking branch
17911796
remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
17921797
ref, err = s.repo.Reference(remoteRefName, true)
17931798
if err != nil {
@@ -2193,11 +2198,12 @@ type Author struct {
21932198
Email string
21942199
}
21952200

2196-
// GetCheckpointAuthor retrieves the author of a checkpoint from the entire/checkpoints/v1 commit history.
2201+
// GetCheckpointAuthor retrieves the author of a checkpoint from the configured
2202+
// committed-read ref history.
21972203
// Finds the commit whose subject matches "Checkpoint: <id>" and returns its author.
21982204
// Returns empty Author if the checkpoint is not found or the sessions branch doesn't exist.
21992205
func (s *GitStore) GetCheckpointAuthor(ctx context.Context, checkpointID id.CheckpointID) (Author, error) {
2200-
return getCheckpointAuthorFromRef(ctx, s.repo, plumbing.NewBranchReferenceName(paths.MetadataBranchName), checkpointID)
2206+
return getCheckpointAuthorFromRef(ctx, s.repo, s.committedReadRef, checkpointID)
22012207
}
22022208

22032209
func getCheckpointAuthorFromRef(ctx context.Context, repo *git.Repository, refName plumbing.ReferenceName, checkpointID id.CheckpointID) (Author, error) {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package checkpoint
2+
3+
import (
4+
"context"
5+
"errors"
6+
"log/slog"
7+
8+
"github.com/go-git/go-git/v6"
9+
"github.com/go-git/go-git/v6/plumbing"
10+
11+
"github.com/entireio/cli/cmd/entire/cli/logging"
12+
"github.com/entireio/cli/cmd/entire/cli/paths"
13+
"github.com/entireio/cli/cmd/entire/cli/settings"
14+
)
15+
16+
// NewCommittedReadStore returns a GitStore for reading committed checkpoints:
17+
// the local-only v1.1 custom ref when checkpoints_version 1.1 is enabled (no v1
18+
// fallback), else the v1 branch.
19+
func NewCommittedReadStore(ctx context.Context, repo *git.Repository) *GitStore {
20+
if !settings.MirrorsToV1CustomRef(ctx) {
21+
return NewGitStore(repo)
22+
}
23+
return NewGitStoreWithRef(repo, plumbing.ReferenceName(paths.MetadataRefName))
24+
}
25+
26+
// SyncCommittedReadRef advances the v1.1 custom ref to the v1 tip before a read,
27+
// a no-op unless checkpoints_version 1.1 is enabled. The ref is local-only, so a
28+
// git pull updates v1 but not v1.1; this keeps it current. Best-effort.
29+
func SyncCommittedReadRef(ctx context.Context, repo *git.Repository) {
30+
if !settings.MirrorsToV1CustomRef(ctx) {
31+
return
32+
}
33+
syncV1CustomRefForRead(ctx, repo)
34+
}
35+
36+
// syncV1CustomRefForRead advances the v1.1 custom ref to the v1 tip (local v1
37+
// branch, or origin's on a fresh clone): seed when missing, advance when an
38+
// ancestor, no-op when equal, leave a diverged ref as-is. Failures are logged.
39+
func syncV1CustomRefForRead(ctx context.Context, repo *git.Repository) {
40+
v1Hash, ok := resolveV1Tip(repo)
41+
if !ok {
42+
logging.Debug(ctx, "v1.1 read sync skipped: no v1 tip available")
43+
return
44+
}
45+
46+
customRefName := plumbing.ReferenceName(paths.MetadataRefName)
47+
customRef, err := repo.Reference(customRefName, false)
48+
if errors.Is(err, plumbing.ErrReferenceNotFound) {
49+
setCustomRef(ctx, repo, customRefName, v1Hash) // missing — seed at v1 tip
50+
return
51+
}
52+
if err != nil {
53+
// Unexpected read error — don't overwrite the ref; read it as-is.
54+
logging.Warn(ctx, "v1.1 read sync skipped: custom ref unreadable",
55+
slog.String("ref", paths.MetadataRefName),
56+
slog.String("error", err.Error()))
57+
return
58+
}
59+
60+
if customRef.Hash() == v1Hash {
61+
return // already current
62+
}
63+
64+
customCommit, err := repo.CommitObject(customRef.Hash())
65+
if err != nil {
66+
logging.Warn(ctx, "v1.1 read sync skipped: custom ref commit unreadable",
67+
slog.String("ref", paths.MetadataRefName),
68+
slog.String("error", err.Error()))
69+
return
70+
}
71+
v1Commit, err := repo.CommitObject(v1Hash)
72+
if err != nil {
73+
logging.Warn(ctx, "v1.1 read sync skipped: v1 commit unreadable",
74+
slog.String("error", err.Error()))
75+
return
76+
}
77+
78+
isAncestor, err := customCommit.IsAncestor(v1Commit)
79+
if err != nil {
80+
logging.Warn(ctx, "v1.1 read sync skipped: ancestry check failed",
81+
slog.String("error", err.Error()))
82+
return
83+
}
84+
if !isAncestor {
85+
// Diverged from v1: leave the ref untouched and read it as-is.
86+
logging.Warn(ctx, "v1.1 custom ref diverged from v1; reading custom ref as-is",
87+
slog.String("ref", paths.MetadataRefName),
88+
slog.String("custom_hash", customRef.Hash().String()),
89+
slog.String("v1_hash", v1Hash.String()))
90+
return
91+
}
92+
93+
setCustomRef(ctx, repo, customRefName, v1Hash)
94+
}
95+
96+
// resolveV1Tip returns the v1 metadata tip, preferring the local v1 branch and
97+
// falling back to origin's remote-tracking branch (so v1.1 can seed on a fresh
98+
// clone).
99+
func resolveV1Tip(repo *git.Repository) (plumbing.Hash, bool) {
100+
if ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true); err == nil {
101+
return ref.Hash(), true
102+
}
103+
if ref, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName), true); err == nil {
104+
return ref.Hash(), true
105+
}
106+
return plumbing.ZeroHash, false
107+
}
108+
109+
// setCustomRef points refName at hash; failures are logged and swallowed so the
110+
// read can proceed against the ref as-is.
111+
func setCustomRef(ctx context.Context, repo *git.Repository, refName plumbing.ReferenceName, hash plumbing.Hash) {
112+
if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, hash)); err != nil {
113+
logging.Warn(ctx, "v1.1 read sync failed to advance custom ref",
114+
slog.String("ref", refName.String()),
115+
slog.String("error", err.Error()))
116+
return
117+
}
118+
logging.Debug(ctx, "v1.1 custom ref synced for read",
119+
slog.String("ref", refName.String()),
120+
slog.String("hash", hash.String()))
121+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package checkpoint
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
git "github.com/go-git/go-git/v6"
10+
"github.com/go-git/go-git/v6/plumbing"
11+
"github.com/go-git/go-git/v6/plumbing/object"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
16+
"github.com/entireio/cli/cmd/entire/cli/paths"
17+
"github.com/entireio/cli/cmd/entire/cli/testutil"
18+
"github.com/entireio/cli/redact"
19+
)
20+
21+
// newTestRepo creates an isolated repo with a single "init" commit and returns
22+
// its directory, an open handle, and the commit hash.
23+
func newTestRepo(t *testing.T) (string, *git.Repository, plumbing.Hash) {
24+
t.Helper()
25+
dir := t.TempDir()
26+
testutil.InitRepo(t, dir)
27+
repo, err := git.PlainOpen(dir)
28+
require.NoError(t, err)
29+
return dir, repo, commitFile(t, repo, dir, "f.txt", "init", "init")
30+
}
31+
32+
// commitFile commits content to path; successive calls build a linear chain.
33+
func commitFile(t *testing.T, repo *git.Repository, dir, path, content, msg string) plumbing.Hash {
34+
t.Helper()
35+
require.NoError(t, os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644))
36+
wt, err := repo.Worktree()
37+
require.NoError(t, err)
38+
_, err = wt.Add(path)
39+
require.NoError(t, err)
40+
h, err := wt.Commit(msg, &git.CommitOptions{Author: &object.Signature{Name: "Test", Email: "test@test.com"}})
41+
require.NoError(t, err)
42+
return h
43+
}
44+
45+
func setRef(t *testing.T, repo *git.Repository, name plumbing.ReferenceName, hash plumbing.Hash) {
46+
t.Helper()
47+
require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(name, hash)))
48+
}
49+
50+
func v1BranchRef() plumbing.ReferenceName {
51+
return plumbing.NewBranchReferenceName(paths.MetadataBranchName)
52+
}
53+
func customRef() plumbing.ReferenceName { return plumbing.ReferenceName(paths.MetadataRefName) }
54+
func originV1Ref() plumbing.ReferenceName {
55+
return plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
56+
}
57+
58+
func customRefHash(t *testing.T, repo *git.Repository) (plumbing.Hash, bool) {
59+
t.Helper()
60+
ref, err := repo.Reference(customRef(), true)
61+
if err != nil {
62+
return plumbing.ZeroHash, false
63+
}
64+
return ref.Hash(), true
65+
}
66+
67+
// writeV1Checkpoint writes a committed checkpoint to the v1 branch.
68+
func writeV1Checkpoint(t *testing.T, repo *git.Repository, cpID id.CheckpointID) {
69+
t.Helper()
70+
require.NoError(t, NewGitStore(repo).WriteCommitted(context.Background(), WriteCommittedOptions{
71+
CheckpointID: cpID,
72+
SessionID: "session",
73+
Strategy: "manual-commit",
74+
Transcript: redact.AlreadyRedacted([]byte("transcript\n")),
75+
Prompts: []string{"prompt"},
76+
AuthorName: "Test",
77+
AuthorEmail: "test@test.com",
78+
}))
79+
}
80+
81+
// enableV11 chdirs into dir and opts into checkpoints v1.1.
82+
func enableV11(t *testing.T, dir string) {
83+
t.Helper()
84+
t.Chdir(dir)
85+
writeSettings(t, dir, `"1.1"`)
86+
}
87+
88+
// writeSettings writes .entire/settings.json (empty version omits the option).
89+
func writeSettings(t *testing.T, dir, version string) {
90+
t.Helper()
91+
body := `{"enabled": true}`
92+
if version != "" {
93+
body = `{"enabled": true, "strategy_options": {"checkpoints_version": ` + version + `}}`
94+
}
95+
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".entire"), 0o755))
96+
require.NoError(t, os.WriteFile(filepath.Join(dir, ".entire", paths.SettingsFileName), []byte(body), 0o644))
97+
}
98+
99+
// blockCustomRefWrite occupies refs/entire with a file so refs/entire/* writes fail.
100+
func blockCustomRefWrite(t *testing.T, dir string) {
101+
t.Helper()
102+
require.NoError(t, os.WriteFile(filepath.Join(dir, ".git", "refs", "entire"), []byte("blocked"), 0o644))
103+
}
104+
105+
func TestGitStore_CommittedReadRef(t *testing.T) {
106+
t.Parallel()
107+
assert.Equal(t, v1BranchRef(), NewGitStore(nil).CommittedReadRef())
108+
assert.Equal(t, customRef(), NewGitStoreWithRef(nil, customRef()).CommittedReadRef())
109+
}
110+
111+
func TestSyncV1CustomRefForRead(t *testing.T) {
112+
t.Parallel()
113+
tests := []struct {
114+
name string
115+
setup func(t *testing.T, dir string, repo *git.Repository, init plumbing.Hash) (want plumbing.Hash, exists bool)
116+
}{
117+
{"seeds from local v1 when missing", func(t *testing.T, _ string, repo *git.Repository, init plumbing.Hash) (plumbing.Hash, bool) {
118+
setRef(t, repo, v1BranchRef(), init)
119+
return init, true
120+
}},
121+
{"seeds from origin when local v1 missing", func(t *testing.T, _ string, repo *git.Repository, init plumbing.Hash) (plumbing.Hash, bool) {
122+
setRef(t, repo, originV1Ref(), init)
123+
return init, true
124+
}},
125+
{"no-op when equal", func(t *testing.T, _ string, repo *git.Repository, init plumbing.Hash) (plumbing.Hash, bool) {
126+
setRef(t, repo, v1BranchRef(), init)
127+
setRef(t, repo, customRef(), init)
128+
return init, true
129+
}},
130+
{"advances when ancestor", func(t *testing.T, dir string, repo *git.Repository, init plumbing.Hash) (plumbing.Hash, bool) {
131+
setRef(t, repo, customRef(), init)
132+
newHash := commitFile(t, repo, dir, "f2.txt", "more", "second")
133+
setRef(t, repo, v1BranchRef(), newHash)
134+
return newHash, true
135+
}},
136+
{"leaves non-ancestor ref", func(t *testing.T, dir string, repo *git.Repository, init plumbing.Hash) (plumbing.Hash, bool) {
137+
ahead := commitFile(t, repo, dir, "f2.txt", "more", "second")
138+
setRef(t, repo, v1BranchRef(), init) // parent
139+
setRef(t, repo, customRef(), ahead) // child, not an ancestor of v1
140+
return ahead, true
141+
}},
142+
{"no v1 tip", func(_ *testing.T, _ string, _ *git.Repository, _ plumbing.Hash) (plumbing.Hash, bool) {
143+
return plumbing.ZeroHash, false
144+
}},
145+
{"write failure leaves ref unset", func(t *testing.T, dir string, repo *git.Repository, init plumbing.Hash) (plumbing.Hash, bool) {
146+
setRef(t, repo, v1BranchRef(), init)
147+
blockCustomRefWrite(t, dir)
148+
return plumbing.ZeroHash, false
149+
}},
150+
}
151+
for _, tt := range tests {
152+
t.Run(tt.name, func(t *testing.T) {
153+
t.Parallel()
154+
dir, repo, init := newTestRepo(t)
155+
want, exists := tt.setup(t, dir, repo, init)
156+
157+
syncV1CustomRefForRead(context.Background(), repo)
158+
159+
got, ok := customRefHash(t, repo)
160+
require.Equal(t, exists, ok)
161+
if exists {
162+
assert.Equal(t, want, got)
163+
}
164+
})
165+
}
166+
}
167+
168+
// Not parallel: uses t.Chdir() so settings.Load resolves the test repo.
169+
func TestNewCommittedReadStore_SelectsRefByVersion(t *testing.T) {
170+
dir, repo, h := newTestRepo(t)
171+
setRef(t, repo, v1BranchRef(), h)
172+
t.Chdir(dir)
173+
174+
writeSettings(t, dir, "") // v1 only
175+
assert.Equal(t, v1BranchRef(), NewCommittedReadStore(context.Background(), repo).CommittedReadRef())
176+
177+
writeSettings(t, dir, `"1.1"`)
178+
assert.Equal(t, customRef(), NewCommittedReadStore(context.Background(), repo).CommittedReadRef())
179+
}
180+
181+
// v1.1 reads always go through the custom ref (no v1 fallback): a checkpoint is
182+
// found when the ref can be synced to v1, and not found when it can't.
183+
// Not parallel: subtests use t.Chdir().
184+
func TestNewCommittedReadStore_V11Reads(t *testing.T) {
185+
tests := []struct {
186+
name string
187+
mutate func(t *testing.T, dir string, repo *git.Repository)
188+
wantFound bool
189+
}{
190+
{"reads v1 data via custom ref", func(_ *testing.T, _ string, _ *git.Repository) {}, true},
191+
{"reads remote-only metadata", func(t *testing.T, _ string, repo *git.Repository) {
192+
ref, err := repo.Reference(v1BranchRef(), true)
193+
require.NoError(t, err)
194+
setRef(t, repo, originV1Ref(), ref.Hash())
195+
require.NoError(t, repo.Storer.RemoveReference(v1BranchRef()))
196+
}, true},
197+
{"sync write fails", func(t *testing.T, dir string, _ *git.Repository) {
198+
blockCustomRefWrite(t, dir)
199+
}, false},
200+
{"custom ref diverges", func(t *testing.T, dir string, repo *git.Repository) {
201+
setRef(t, repo, customRef(), commitFile(t, repo, dir, "other.txt", "diverged", "diverged"))
202+
}, false},
203+
}
204+
for _, tt := range tests {
205+
t.Run(tt.name, func(t *testing.T) {
206+
dir, repo, _ := newTestRepo(t)
207+
enableV11(t, dir)
208+
cpID := id.MustCheckpointID("a1b2c3d4e5f6")
209+
writeV1Checkpoint(t, repo, cpID)
210+
tt.mutate(t, dir, repo)
211+
212+
SyncCommittedReadRef(context.Background(), repo)
213+
store := NewCommittedReadStore(context.Background(), repo)
214+
require.Equal(t, customRef(), store.CommittedReadRef(), "must read the custom ref, not fall back to v1")
215+
216+
summary, err := store.ReadCommitted(context.Background(), cpID)
217+
require.NoError(t, err)
218+
if tt.wantFound {
219+
require.NotNil(t, summary)
220+
assert.Equal(t, cpID, summary.CheckpointID)
221+
} else {
222+
assert.Nil(t, summary, "must not fall back to v1")
223+
}
224+
})
225+
}
226+
}

0 commit comments

Comments
 (0)