@@ -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.
144176func 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
0 commit comments