Skip to content

Commit 3de0dbf

Browse files
committed
feat: Adding support for commit refs in CAS
1 parent b04a540 commit 3de0dbf

9 files changed

Lines changed: 955 additions & 128 deletions

File tree

internal/cas/cas.go

Lines changed: 201 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -159,35 +159,29 @@ func (c *CAS) SynthStore() *Store { return c.synthStore }
159159
//
160160
// TODO: Make options optional
161161
func (c *CAS) Clone(ctx context.Context, l log.Logger, opts *CloneOptions, url string) error {
162-
// Ensure the store paths exist
163-
if err := c.fs.MkdirAll(c.blobStore.Path(), DefaultDirPerms); err != nil {
164-
return fmt.Errorf("failed to create blob store path: %w", err)
165-
}
166-
167-
if err := c.fs.MkdirAll(c.treeStore.Path(), DefaultDirPerms); err != nil {
168-
return fmt.Errorf("failed to create tree store path: %w", err)
162+
if err := c.ensureCloneStores(); err != nil {
163+
return err
169164
}
170165

171166
return telemetry.TelemeterFromContext(ctx).Collect(ctx, "cas_clone", map[string]any{
172167
"url": url,
173168
"branch": opts.Branch,
174169
}, func(childCtx context.Context) error {
175-
hash, err := c.resolveReference(childCtx, url, opts.Branch)
170+
ref, err := c.resolveReference(childCtx, url, opts.Branch)
176171
if err != nil {
177172
return err
178173
}
179174

180175
targetDir := c.prepareTargetDirectory(opts.Dir, url)
181176

182-
if c.treeStore.NeedsWrite(hash) {
183-
if err := c.populateTreeFromGit(childCtx, l, opts, url, hash); err != nil {
184-
return err
185-
}
177+
canonicalHash, err := c.populateTreeFromRef(childCtx, l, opts, ref)
178+
if err != nil {
179+
return err
186180
}
187181

188182
treeContent := NewContent(c.treeStore)
189183

190-
treeData, err := treeContent.Read(hash)
184+
treeData, err := treeContent.Read(canonicalHash)
191185
if err != nil {
192186
return err
193187
}
@@ -201,46 +195,172 @@ func (c *CAS) Clone(ctx context.Context, l log.Logger, opts *CloneOptions, url s
201195
})
202196
}
203197

204-
// populateTreeFromGit fetches the commit at hash and stores its tree and
205-
// reachable blobs in the CAS. It tries the central git store first. Any
206-
// error from the central store is logged as a warning, and the function
207-
// then falls back to a bare clone in a temporary directory.
208-
func (c *CAS) populateTreeFromGit(ctx context.Context, l log.Logger, opts *CloneOptions, url, hash string) error {
198+
// ensureCloneStores creates the blob and tree store directories that
199+
// [CAS.Clone] writes to. Defensive: [New] already creates them, but a
200+
// long-lived [CAS] instance could see them removed between calls.
201+
func (c *CAS) ensureCloneStores() error {
202+
for _, s := range []*Store{c.blobStore, c.treeStore} {
203+
if err := c.fs.MkdirAll(s.Path(), DefaultDirPerms); err != nil {
204+
return fmt.Errorf("create CAS store path %s: %w", s.Path(), err)
205+
}
206+
}
207+
208+
return nil
209+
}
210+
211+
// populateTreeFromRef dispatches by ref kind, short-circuiting on a
212+
// cached [symbolicRef] and otherwise calling the kind-specific
213+
// populate. Returns the canonical commit hash.
214+
func (c *CAS) populateTreeFromRef(
215+
ctx context.Context,
216+
l log.Logger,
217+
opts *CloneOptions,
218+
ref resolvedRef,
219+
) (string, error) {
220+
switch ref := ref.(type) {
221+
case *symbolicRef:
222+
if !c.treeStore.NeedsWrite(ref.Hash) {
223+
return ref.Hash, nil
224+
}
225+
226+
if err := c.populateTreeFromSymbolicRef(ctx, l, opts, ref); err != nil {
227+
return "", err
228+
}
229+
230+
return ref.Hash, nil
231+
232+
case *commitRef:
233+
return c.populateTreeFromCommitRef(ctx, l, opts, ref)
234+
235+
default:
236+
return "", fmt.Errorf("unsupported resolved ref type %T", ref)
237+
}
238+
}
239+
240+
// populateTreeFromSymbolicRef stores the tree and reachable blobs
241+
// for ref.Hash in the CAS. Tries the central [GitStore] first; on
242+
// any error from it, logs a warning and falls back to a bare clone
243+
// in a temporary directory.
244+
func (c *CAS) populateTreeFromSymbolicRef(
245+
ctx context.Context,
246+
l log.Logger,
247+
opts *CloneOptions,
248+
ref *symbolicRef,
249+
) error {
209250
depth := resolveCloneDepth(opts.Depth, c.cloneDepth)
210251

211-
repoPath, unlocker, err := c.gitStore.EnsureRef(ctx, l, c.fs, url, opts.Branch, hash, depth)
252+
repo, err := c.gitStore.EnsureRef(ctx, l, c.fs, ref.URL, ref.Branch, ref.Hash, depth)
253+
if err == nil {
254+
defer repo.Release(l)
255+
256+
runner := c.git.WithWorkDir(repo.Path)
257+
258+
return c.storeRootTreeFrom(ctx, l, runner, ref.Hash, opts)
259+
}
260+
261+
l.Warnf("central git store unavailable for %s, falling back to temporary clone: %v", ref.URL, err)
262+
263+
tempDir, cleanup, err := c.makeFallbackCloneDir(l)
264+
if err != nil {
265+
return err
266+
}
267+
268+
defer cleanup()
269+
270+
runner := c.git.WithWorkDir(tempDir)
271+
272+
if err := runner.Clone(ctx, ref.URL, true, depth, ref.Branch); err != nil {
273+
return err
274+
}
275+
276+
return c.storeRootTreeFrom(ctx, l, runner, ref.Hash, opts)
277+
}
278+
279+
// populateTreeFromCommitRef resolves ref via [GitStore.EnsureCommit]
280+
// (full-depth fetch on a cache miss) and stores its tree in the CAS.
281+
// Returns the canonical commit hash. Falls back to a temporary bare
282+
// clone if the central [GitStore] is unavailable.
283+
func (c *CAS) populateTreeFromCommitRef(
284+
ctx context.Context,
285+
l log.Logger,
286+
opts *CloneOptions,
287+
ref *commitRef,
288+
) (string, error) {
289+
repo, err := c.gitStore.EnsureCommit(ctx, l, c.fs, ref.URL, ref.RawRef)
212290
if err == nil {
213-
defer func() {
214-
if unlockErr := unlocker.Unlock(); unlockErr != nil {
215-
l.Warnf("git store: failed to release lock for %s: %v", url, unlockErr)
291+
defer repo.Release(l)
292+
293+
if !c.treeStore.NeedsWrite(repo.Hash) {
294+
return repo.Hash, nil
295+
}
296+
297+
runner := c.git.WithWorkDir(repo.Path)
298+
299+
if err := c.storeRootTreeFrom(ctx, l, runner, repo.Hash, opts); err != nil {
300+
return "", err
301+
}
302+
303+
return repo.Hash, nil
304+
}
305+
306+
if errors.Is(err, git.ErrNoMatchingReference) {
307+
return "", err
308+
}
309+
310+
l.Warnf("central git store unavailable for %s, falling back to temporary clone: %v", ref.URL, err)
311+
312+
tempDir, cleanup, err := c.makeFallbackCloneDir(l)
313+
if err != nil {
314+
return "", err
315+
}
316+
317+
defer cleanup()
318+
319+
runner := c.git.WithWorkDir(tempDir)
320+
321+
if err := runner.Clone(ctx, ref.URL, true, 0, ""); err != nil {
322+
return "", err
323+
}
324+
325+
canonicalHash, err := runner.RevParseCommit(ctx, ref.RawRef)
326+
if err != nil {
327+
if errors.Is(err, git.ErrUnknownRevision) {
328+
return "", &git.WrappedError{
329+
Op: "git_clone_resolve",
330+
Context: fmt.Sprintf("%q in %s", ref.RawRef, ref.URL),
331+
Err: git.ErrNoMatchingReference,
216332
}
217-
}()
333+
}
218334

219-
runner := c.git.WithWorkDir(repoPath)
335+
return "", err
336+
}
337+
338+
if !c.treeStore.NeedsWrite(canonicalHash) {
339+
return canonicalHash, nil
340+
}
220341

221-
return c.storeRootTreeFrom(ctx, l, runner, hash, opts)
342+
if err := c.storeRootTreeFrom(ctx, l, runner, canonicalHash, opts); err != nil {
343+
return "", err
222344
}
223345

224-
l.Warnf("central git store unavailable for %s, falling back to temporary clone: %v", url, err)
346+
return canonicalHash, nil
347+
}
225348

349+
// makeFallbackCloneDir creates a temporary directory for a bare clone
350+
// fallback and returns a cleanup function that removes it.
351+
func (c *CAS) makeFallbackCloneDir(l log.Logger) (string, func(), error) {
226352
tempDir, err := vfs.MkdirTemp(c.fs, "", "terragrunt-cas-fallback-*")
227353
if err != nil {
228-
return fmt.Errorf("create fallback clone dir: %w", errors.Join(ErrFallbackCloneDir, err))
354+
return "", nil, fmt.Errorf("create fallback clone dir: %w", errors.Join(ErrFallbackCloneDir, err))
229355
}
230356

231-
defer func() {
357+
cleanup := func() {
232358
if rmErr := c.fs.RemoveAll(tempDir); rmErr != nil {
233359
l.Warnf("cleanup error: %v", rmErr)
234360
}
235-
}()
236-
237-
runner := c.git.WithWorkDir(tempDir)
238-
239-
if err := runner.Clone(ctx, url, true, depth, opts.Branch); err != nil {
240-
return err
241361
}
242362

243-
return c.storeRootTreeFrom(ctx, l, runner, hash, opts)
363+
return tempDir, cleanup, nil
244364
}
245365

246366
func resolveCloneDepth(optDepth, casDepth int) int {
@@ -269,21 +389,59 @@ func (c *CAS) prepareTargetDirectory(dir, url string) string {
269389
return filepath.Clean(targetDir)
270390
}
271391

272-
func (c *CAS) resolveReference(ctx context.Context, url, branch string) (string, error) {
392+
// resolvedRef is what [CAS.resolveReference] returns: a [symbolicRef]
393+
// when ls-remote resolved the input to a branch, tag, or HEAD; a
394+
// [commitRef] when it did not. Sealed by package visibility.
395+
type resolvedRef interface {
396+
// CommitHash returns the canonical commit hash for [symbolicRef]
397+
// and the user-supplied SHA for [commitRef]. Abbreviated SHAs
398+
// passed in as commit refs remain abbreviated.
399+
CommitHash() string
400+
}
401+
402+
// symbolicRef carries an ls-remote-resolved branch, tag, or HEAD.
403+
type symbolicRef struct {
404+
URL string
405+
Branch string // ref name, used for the per-ref fetch
406+
Hash string // canonical commit hash
407+
}
408+
409+
// CommitHash returns the canonical commit hash ls-remote resolved.
410+
func (r *symbolicRef) CommitHash() string { return r.Hash }
411+
412+
// commitRef carries a commit-form ref ls-remote did not recognize.
413+
// Resolution against the central git store happens later via
414+
// rev-parse, with a full-depth fetch on a cache miss.
415+
type commitRef struct {
416+
URL string
417+
RawRef string // user-supplied SHA, full or abbreviated
418+
}
419+
420+
// CommitHash returns the user-supplied SHA before central-store
421+
// canonicalization.
422+
func (r *commitRef) CommitHash() string { return r.RawRef }
423+
424+
// resolveReference resolves branch into a [resolvedRef]. ls-remote is
425+
// the authoritative source for symbolic refs (branches, tags, HEAD);
426+
// a hit returns a [*symbolicRef] carrying the canonical hash. A miss
427+
// returns a [*commitRef] so the caller resolves the input locally
428+
// against the central git store, where commit-form SHAs (full or
429+
// abbreviated) are looked up via rev-parse and a full-history fetch.
430+
func (c *CAS) resolveReference(ctx context.Context, url, branch string) (resolvedRef, error) {
273431
results, err := c.git.LsRemote(ctx, url, branch)
274432
if err != nil {
275-
return "", err
433+
if errors.Is(err, git.ErrNoMatchingReference) {
434+
return &commitRef{URL: url, RawRef: branch}, nil
435+
}
436+
437+
return nil, err
276438
}
277439

278440
if len(results) == 0 {
279-
return "", &WrappedError{
280-
Op: "clone",
281-
Context: "no matching reference",
282-
Err: ErrNoMatchingReference,
283-
}
441+
return &commitRef{URL: url, RawRef: branch}, nil
284442
}
285443

286-
return results[0].Hash, nil
444+
return &symbolicRef{URL: url, Branch: branch, Hash: results[0].Hash}, nil
287445
}
288446

289447
// storeRootTreeFrom reads the recursive tree at hash from the supplied

0 commit comments

Comments
 (0)