Skip to content

Commit f502d6b

Browse files
authored
Merge pull request #1816 from entireio/fix/explain-export-notfound
fix(explain): surface export-path failures instead of masking as not-found
2 parents 48b4b78 + 071a425 commit f502d6b

3 files changed

Lines changed: 232 additions & 12 deletions

File tree

cmd/entire/cli/explain_export.go

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"errors"
77
"fmt"
88
"io"
9+
"log/slog"
910
"strings"
1011
"time"
1112

1213
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
1314
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
15+
"github.com/entireio/cli/cmd/entire/cli/logging"
1416
"github.com/entireio/cli/cmd/entire/cli/settings"
1517
"github.com/entireio/cli/cmd/entire/cli/strategy"
1618
"github.com/entireio/cli/cmd/entire/cli/trailers"
@@ -128,28 +130,68 @@ func resolveExplainCheckpointID(ctx context.Context, errW io.Writer, opts explai
128130
_ = lookup.Close()
129131
return cpID, freshLookup, nil
130132
}
133+
// Only "the target isn't a commit at all" is a genuine miss that
134+
// keeps the checkpoint-not-found report below. Everything else —
135+
// unreadable commit, missing trailer, ambiguous ref, repo or
136+
// lookup failure — reflects a real failure, not a miss; masking
137+
// those as not-found is this path's variant of the conflation
138+
// PR #1812 fixes for the prose path in runExplainAuto (this
139+
// path's fix: issue #1814).
140+
if !errors.Is(commitErr, errExportTargetNotCommit) {
141+
if freshLookup != nil {
142+
// Defensive: resolveCheckpointFromCommitRef documents a
143+
// nil lookup on error, but a leak here would be silent.
144+
_ = freshLookup.Close()
145+
}
146+
return id.CheckpointID(""), lookup, commitErr
147+
}
131148
}
132149
return id.CheckpointID(""), lookup, fmt.Errorf("%w: %s", checkpoint.ErrCheckpointNotFound, prefix)
133150
default:
134-
return id.CheckpointID(""), lookup, fmt.Errorf("%w: %s matches %d checkpoints", errAmbiguousCommitPrefix, prefix, len(matches))
151+
ids := make([]string, len(matches))
152+
for i, m := range matches {
153+
ids[i] = m.String()
154+
}
155+
return id.CheckpointID(""), lookup, fmt.Errorf("%w: %s matches %d checkpoints (%s)", errAmbiguousCommitPrefix, prefix, len(matches), strings.Join(ids, ", "))
135156
}
136157
}
137158

159+
// errExportTargetNotCommit marks resolveCheckpointFromCommitRef's genuine
160+
// "target does not resolve to any commit" outcome, so
161+
// resolveExplainCheckpointID's positional commit fallback can fall through to
162+
// its checkpoint-not-found report only for that case and surface every other
163+
// failure verbatim.
164+
var errExportTargetNotCommit = errors.New("commit not found")
165+
138166
// resolveCheckpointFromCommitRef opens the repo, resolves a git commit-ish,
139167
// and extracts the Entire-Checkpoint trailer. If the resolved checkpoint
140168
// isn't present in the local committed list, retries once after fetching
141169
// metadata from the remote — symmetry with the prefix path so
142170
// `--commit <sha>` and `--checkpoint <prefix>` share the same fetch
143171
// behavior.
172+
//
173+
// Invariant: on every error return the lookup is nil (any lookup created
174+
// along the way is closed internally), so callers may drop the lookup slot
175+
// without closing it when err != nil.
144176
func resolveCheckpointFromCommitRef(ctx context.Context, errW io.Writer, commitRef string) (id.CheckpointID, *explainCheckpointLookup, error) {
145177
repo, err := openRepository(ctx)
146178
if err != nil {
147179
return id.CheckpointID(""), nil, fmt.Errorf("not a git repository: %w", err)
148180
}
149181
defer repo.Close()
150-
hash, _, err := resolveCommitUnambiguous(repo, commitRef)
182+
hash, ambiguousMatches, err := resolveCommitUnambiguous(repo, commitRef)
151183
if err != nil {
152-
return id.CheckpointID(""), nil, fmt.Errorf("commit not found: %s: %w", commitRef, err)
184+
if errors.Is(err, errAmbiguousCommitPrefix) {
185+
// The target IS commit-like (several commits match); reporting it
186+
// as not-found would misdirect. Surface the ambiguity, naming the
187+
// candidates so the user can disambiguate without rerunning git log.
188+
candidates := make([]string, 0, len(ambiguousMatches))
189+
for _, m := range buildAmbiguousCommitMatches(repo, ambiguousMatches) {
190+
candidates = append(candidates, m.ShortID)
191+
}
192+
return id.CheckpointID(""), nil, fmt.Errorf("ambiguous commit ref %s (matches commits %s): %w", commitRef, strings.Join(candidates, ", "), err)
193+
}
194+
return id.CheckpointID(""), nil, fmt.Errorf("%w: %s: %w", errExportTargetNotCommit, commitRef, err)
153195
}
154196
commit, err := repo.CommitObject(hash)
155197
if err != nil {
@@ -168,12 +210,21 @@ func resolveCheckpointFromCommitRef(ctx context.Context, errW io.Writer, commitR
168210
// same remote-fetch retry the prefix path uses; otherwise downstream
169211
// metadata reads would fail with an immediate "not found".
170212
if !lookupHasCheckpoint(lookup, cpID) {
171-
if matches, fresh := matchCheckpointPrefixWithRemoteFallback(ctx, errW, lookup, cpID.String()); len(matches) == 1 {
172-
if fresh != lookup {
173-
_ = lookup.Close()
174-
}
213+
matches, fresh := matchCheckpointPrefixWithRemoteFallback(ctx, errW, lookup, cpID.String())
214+
if fresh != lookup {
215+
_ = lookup.Close()
175216
lookup = fresh
176217
}
218+
if len(matches) != 1 {
219+
// The commit resolved and its trailer parsed; the checkpoint is
220+
// simply not obtainable here. Failing now with the linkage beats
221+
// succeeding and letting a downstream read die with a bare
222+
// "checkpoint not found" that misdirects the user toward the
223+
// checkpoint ID when the problem is availability (offline,
224+
// unfetchable remote, or genuinely gone).
225+
_ = lookup.Close()
226+
return id.CheckpointID(""), nil, fmt.Errorf("commit %s references checkpoint %s, which is not available locally and could not be fetched from the remote", commitRef, cpID)
227+
}
177228
}
178229
return cpID, lookup, nil
179230
}
@@ -202,11 +253,16 @@ func matchCheckpointPrefixWithRemoteFallback(ctx context.Context, errW io.Writer
202253
// git-refs primary: there is no single metadata branch to fetch — each
203254
// checkpoint is its own ref. When the prefix is a full checkpoint ID (the
204255
// Entire-Checkpoint commit trailer always is), fetch that one ref directly,
205-
// then re-list. Falls through to the v1-branch fetch below otherwise.
256+
// then re-list. A shorter prefix cannot be fetched per-ref, and under a
257+
// refs primary there is no v1 metadata branch to fetch either, so a
258+
// short-prefix miss stays local-only.
206259
if cpCfg, _ := settings.LoadCheckpointsConfig(ctx); checkpoint.PrimaryIsRefs(cpCfg) { //nolint:errcheck // fail-soft: bad config surfaces via Open elsewhere
207-
if cid, err := id.NewCheckpointID(prefix); err == nil {
260+
if cid, err := id.NewCheckpointID(prefix); err != nil {
261+
logging.Debug(ctx, "explain: prefix is not a full checkpoint ID; refs-primary store cannot fetch by prefix, treating as no match",
262+
slog.String("prefix", prefix))
263+
} else {
208264
// cid is already validated by NewCheckpointID above, so RefName can't
209-
// error here; the guard is defensive — fall back to the v1-branch path
265+
// error here; the guard is defensive — treat it as a local-only miss
210266
// rather than fetch a malformed ref.
211267
refName, refErr := checkpoint.RefName(cid)
212268
if refErr != nil {
@@ -216,12 +272,23 @@ func matchCheckpointPrefixWithRemoteFallback(ctx context.Context, errW io.Writer
216272
fetchErr := FetchCheckpointRef(ctx, refName)
217273
stop(false)
218274
if fetchErr == nil {
219-
if fresh, freshErr := newExplainCheckpointLookup(ctx); freshErr == nil {
275+
fresh, freshErr := newExplainCheckpointLookup(ctx)
276+
if freshErr == nil {
220277
if m := matchCheckpointPrefix(fresh, prefix); len(m) > 0 {
221278
return m, fresh
222279
}
223280
_ = fresh.Close()
281+
} else {
282+
// The collapse to "no match" below reads to the user as
283+
// "doesn't exist"; record what actually failed (issue #1815).
284+
logging.Debug(ctx, "explain: lookup rebuild after checkpoint ref fetch failed; treating as no match",
285+
slog.String("prefix", prefix),
286+
slog.String("error", freshErr.Error()))
224287
}
288+
} else {
289+
logging.Debug(ctx, "explain: on-demand checkpoint ref fetch failed; treating as no match",
290+
slog.String("ref", refName.String()),
291+
slog.String("error", fetchErr.Error()))
225292
}
226293
}
227294
return nil, lookup
@@ -234,10 +301,16 @@ func matchCheckpointPrefixWithRemoteFallback(ctx context.Context, errW io.Writer
234301
}
235302
stop(false)
236303
if v1Err != nil {
304+
logging.Debug(ctx, "explain: metadata branch fetch failed; treating as no match",
305+
slog.String("prefix", prefix),
306+
slog.String("error", v1Err.Error()))
237307
return nil, lookup
238308
}
239309
fresh, freshErr := newExplainCheckpointLookup(ctx)
240310
if freshErr != nil {
311+
logging.Debug(ctx, "explain: lookup rebuild after metadata fetch failed; treating as no match",
312+
slog.String("prefix", prefix),
313+
slog.String("error", freshErr.Error()))
241314
return nil, lookup
242315
}
243316
return matchCheckpointPrefix(fresh, prefix), fresh

cmd/entire/cli/explain_export_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/entireio/cli/cmd/entire/cli/paths"
1616
"github.com/entireio/cli/cmd/entire/cli/strategy"
1717
"github.com/entireio/cli/cmd/entire/cli/testutil"
18+
"github.com/entireio/cli/cmd/entire/cli/trailers"
1819
"github.com/entireio/cli/redact"
1920
"github.com/go-git/go-git/v6"
2021
"github.com/go-git/go-git/v6/plumbing/object"
@@ -191,6 +192,150 @@ func TestRunExplainExport_JSONUsesMetadataOnlyReader(t *testing.T) {
191192
require.Empty(t, envelope.Sessions[0].Error, "well-formed v1 read must not surface a per-session error")
192193
}
193194

195+
// TestRunExplainExport_CommitWithoutTrailerSurfacesTrailerError (issue #1814):
196+
// a positional target that resolves to a real commit without an
197+
// Entire-Checkpoint trailer must surface that fact — not be masked as
198+
// `checkpoint not found: <sha>`, which reads as a typo and hides that the
199+
// commit was found. Same conflation class PR #1812 fixes for the prose path.
200+
func TestRunExplainExport_CommitWithoutTrailerSurfacesTrailerError(t *testing.T) {
201+
repo := setupExportRepo(t)
202+
head, err := repo.Head()
203+
require.NoError(t, err)
204+
205+
var stdout, stderr bytes.Buffer
206+
err = runExplainExport(context.Background(), &stdout, &stderr, explainExportOptions{
207+
target: head.Hash().String(),
208+
json: true,
209+
sessionIndex: -1,
210+
})
211+
212+
require.Error(t, err)
213+
require.ErrorContains(t, err, "has no Entire-Checkpoint trailer",
214+
"a trailer-less commit target must surface the trailer failure")
215+
require.NotContains(t, err.Error(), "checkpoint not found",
216+
"a resolved commit must not be masked as an unknown checkpoint")
217+
}
218+
219+
// TestRunExplainExport_TrailerCheckpointUnavailableFailsWithCause: when a
220+
// commit's Entire-Checkpoint trailer references a checkpoint that is neither
221+
// local nor fetchable, the export path must fail naming the commit, the
222+
// checkpoint, and availability as the cause — not succeed and let a
223+
// downstream read die with a bare "checkpoint not found" that misdirects the
224+
// user toward the checkpoint ID instead of connectivity.
225+
func TestRunExplainExport_TrailerCheckpointUnavailableFailsWithCause(t *testing.T) {
226+
repo := setupExportRepo(t)
227+
228+
cpID := id.MustCheckpointID("deadbeefcafe")
229+
wt, err := repo.Worktree()
230+
require.NoError(t, err)
231+
cwd, err := os.Getwd()
232+
require.NoError(t, err)
233+
require.NoError(t, os.WriteFile(filepath.Join(cwd, "feature.txt"), []byte("feature"), 0o644))
234+
_, err = wt.Add("feature.txt")
235+
require.NoError(t, err)
236+
commitHash, err := wt.Commit(trailers.AppendCheckpointTrailer("Implement feature", cpID.String()), &git.CommitOptions{
237+
Author: &object.Signature{Name: exportTestAuthorName, Email: exportTestAuthorEmail, When: time.Now()},
238+
})
239+
require.NoError(t, err)
240+
241+
var stdout, stderr bytes.Buffer
242+
err = runExplainExport(context.Background(), &stdout, &stderr, explainExportOptions{
243+
commitRef: commitHash.String(),
244+
json: true,
245+
sessionIndex: -1,
246+
})
247+
248+
require.Error(t, err)
249+
require.ErrorContains(t, err, "not available locally",
250+
"the failure must name availability as the cause")
251+
require.ErrorContains(t, err, cpID.String())
252+
require.ErrorContains(t, err, commitHash.String()[:7],
253+
"the failure must name the commit the user typed")
254+
}
255+
256+
// TestRunExplainExport_AmbiguousCommitPrefixNamesCandidates: an ambiguous
257+
// positional prefix must be reported as ambiguity — with the candidate
258+
// commits, so the user can disambiguate without rerunning git log — and must
259+
// not be masked as "checkpoint not found" (the pre-#1814 behavior).
260+
func TestRunExplainExport_AmbiguousCommitPrefixNamesCandidates(t *testing.T) {
261+
repo := setupExportRepo(t)
262+
cwd, err := os.Getwd()
263+
require.NoError(t, err)
264+
prefix := collidingShaPrefix(t, repo, cwd)
265+
266+
var stdout, stderr bytes.Buffer
267+
err = runExplainExport(context.Background(), &stdout, &stderr, explainExportOptions{
268+
target: prefix,
269+
json: true,
270+
sessionIndex: -1,
271+
})
272+
273+
require.Error(t, err)
274+
require.ErrorIs(t, err, errAmbiguousCommitPrefix)
275+
require.ErrorContains(t, err, "ambiguous commit ref")
276+
require.ErrorContains(t, err, "matches commits",
277+
"the error must list the candidate commits")
278+
require.NotContains(t, err.Error(), "checkpoint not found",
279+
"ambiguity must not be masked as an unknown checkpoint")
280+
}
281+
282+
// TestRunExplainExport_CommitFlagNotFoundMessage pins the --commit flag
283+
// path's user-visible message: the errExportTargetNotCommit sentinel's text
284+
// is part of the rendered error, so renaming it would silently change every
285+
// --commit failure message.
286+
func TestRunExplainExport_CommitFlagNotFoundMessage(t *testing.T) {
287+
setupExportRepo(t)
288+
289+
var stdout, stderr bytes.Buffer
290+
err := runExplainExport(context.Background(), &stdout, &stderr, explainExportOptions{
291+
commitRef: "nosuchref",
292+
json: true,
293+
sessionIndex: -1,
294+
})
295+
296+
require.Error(t, err)
297+
require.ErrorContains(t, err, "commit not found: nosuchref")
298+
}
299+
300+
// TestRunExplainExport_CheckpointFlagNeverFallsBackToCommit pins the
301+
// deliberate asymmetry: an explicit --checkpoint selector is never
302+
// reinterpreted as a commit ref, even when it would resolve as one.
303+
func TestRunExplainExport_CheckpointFlagNeverFallsBackToCommit(t *testing.T) {
304+
repo := setupExportRepo(t)
305+
head, err := repo.Head()
306+
require.NoError(t, err)
307+
308+
var stdout, stderr bytes.Buffer
309+
err = runExplainExport(context.Background(), &stdout, &stderr, explainExportOptions{
310+
checkpointFlag: head.Hash().String(),
311+
json: true,
312+
sessionIndex: -1,
313+
})
314+
315+
require.Error(t, err)
316+
require.ErrorIs(t, err, checkpoint.ErrCheckpointNotFound)
317+
require.NotContains(t, err.Error(), "trailer",
318+
"--checkpoint must not be reinterpreted as a commit ref")
319+
}
320+
321+
// TestRunExplainExport_UnknownTargetStillReportsNotFound pins the genuine-miss
322+
// contract around the #1814 fix: a target that is neither a checkpoint prefix
323+
// nor a commit keeps the plain not-found report.
324+
func TestRunExplainExport_UnknownTargetStillReportsNotFound(t *testing.T) {
325+
setupExportRepo(t)
326+
327+
var stdout, stderr bytes.Buffer
328+
err := runExplainExport(context.Background(), &stdout, &stderr, explainExportOptions{
329+
target: "abababababab",
330+
json: true,
331+
sessionIndex: -1,
332+
})
333+
334+
require.Error(t, err)
335+
require.ErrorIs(t, err, checkpoint.ErrCheckpointNotFound)
336+
require.ErrorContains(t, err, "checkpoint not found: abababababab")
337+
}
338+
194339
func TestRunExplainExport_JSONNeverEmbedsTranscript(t *testing.T) {
195340
repo := setupExportRepo(t)
196341

cmd/entire/cli/git_operations.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,9 @@ func FetchCheckpointRef(ctx context.Context, ref plumbing.ReferenceName) error {
488488
RefSpecs: []string{refSpec},
489489
NoTags: true,
490490
}); err != nil {
491-
return fmt.Errorf("fetch checkpoint ref %s from %s: %w", ref, fetchTarget, err)
491+
// Redact: fetchTarget can be a remote URL with embedded credentials
492+
// (CI origin URLs), and this error is logged and shown to users.
493+
return fmt.Errorf("fetch checkpoint ref %s from %s: %w", ref, remote.RedactURL(fetchTarget), err)
492494
}
493495
return nil
494496
}

0 commit comments

Comments
 (0)