Skip to content

Commit 3a8acae

Browse files
committed
fix: Addressing PR feedback
1 parent 19882e4 commit 3a8acae

7 files changed

Lines changed: 97 additions & 6 deletions

File tree

docs/src/content/docs/03-features/07-caching/04-cas.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,15 @@ In the event that hard linking fails due to some operating system / host incompa
110110

111111
## Storage
112112

113-
The CAS is stored under the platform user cache directory. On Linux this is `~/.cache/terragrunt/cas` by default and honors `XDG_CACHE_HOME`, on macOS it resolves to `~/Library/Caches/terragrunt/cas`, and on Windows it resolves under `%LocalAppData%\terragrunt\cas`. This directory can be deleted to reclaim disk space when no Terragrunt processes are running against it; Terragrunt will regenerate the CAS on the next run. Avoid deleting it while a Terragrunt operation is in progress, since that can race with in-flight reads, writes, and locks in the store.
113+
The CAS lives under the platform user cache directory:
114+
115+
| Platform | Path |
116+
| --- | --- |
117+
| Linux | `$XDG_CACHE_HOME/terragrunt/cas`, falling back to `~/.cache/terragrunt/cas` |
118+
| macOS | `~/Library/Caches/terragrunt/cas` |
119+
| Windows | `%LocalAppData%\terragrunt\cas` |
120+
121+
This directory can be deleted to reclaim disk space when no Terragrunt processes are running against it. Terragrunt will regenerate the CAS on the next run. Avoid deleting it while a Terragrunt operation is in progress, since that can race with in-flight reads, writes, and locks in the store.
114122

115123
Avoid partial deletions of the CAS directory without care, as that might result in partially cloned repositories and unexpected behavior.
116124

internal/cas/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ var (
6868
ErrNoWorkDir = errors.New("working directory not set")
6969
ErrGitStorePath = errors.New("failed to prepare git store path")
7070
ErrGitStoreLock = errors.New("failed to acquire git store lock")
71+
ErrGitStoreFSNotOS = errors.New("git store requires an OS-backed filesystem")
7172
ErrFallbackCloneDir = errors.New("failed to create fallback clone directory")
7273
)
7374

internal/cas/gitstore.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,15 @@ type GitStore struct {
4141
// NewGitStore returns a GitStore rooted at rootPath, creating the directory
4242
// on fs if needed. The filesystem is not retained; callers pass one explicitly
4343
// to EnsureRef.
44+
//
45+
// The git store shells out to `git`, which only sees the real disk. Callers
46+
// must pass an OS-backed [vfs.FS] from [vfs.NewOSFS]; an in-memory backing
47+
// returns [ErrGitStoreFSNotOS].
4448
func NewGitStore(fs vfs.FS, runner *git.GitRunner, rootPath string) (*GitStore, error) {
49+
if !vfs.IsOSFS(fs) {
50+
return nil, ErrGitStoreFSNotOS
51+
}
52+
4553
if err := fs.MkdirAll(rootPath, DefaultDirPerms); err != nil {
4654
return nil, fmt.Errorf("create git store at %s: %w", rootPath, errors.Join(ErrGitStorePath, err))
4755
}
@@ -69,6 +77,10 @@ func (s *GitStore) EnsureRef(
6977
url, ref, hash string,
7078
depth int,
7179
) (string, vfs.Unlocker, error) {
80+
if !vfs.IsOSFS(fs) {
81+
return "", nil, ErrGitStoreFSNotOS
82+
}
83+
7284
dir, repoPath, lockPath := s.repoPaths(url)
7385

7486
if err := fs.MkdirAll(dir, DefaultDirPerms); err != nil {
@@ -101,8 +113,15 @@ func (s *GitStore) EnsureRef(
101113

102114
runner := s.runner.WithWorkDir(repoPath)
103115

104-
if err := runner.InitBare(ctx); err != nil {
105-
return "", nil, err
116+
initialized, err := bareRepoInitialized(fs, repoPath)
117+
if err != nil {
118+
return "", nil, fmt.Errorf("inspect bare repo %s: %w", repoPath, errors.Join(ErrGitStorePath, err))
119+
}
120+
121+
if !initialized {
122+
if err := runner.InitBare(ctx); err != nil {
123+
return "", nil, err
124+
}
106125
}
107126

108127
has, err := runner.HasObject(ctx, hash)
@@ -156,3 +175,11 @@ func (s *GitStore) repoPaths(url string) (dir, repo, lockPath string) {
156175

157176
return dir, repo, lockPath
158177
}
178+
179+
// bareRepoInitialized reports whether repoPath already holds a bare git
180+
// repository. Checking for HEAD is enough: `git init --bare` writes it as
181+
// part of repository setup, so its presence lets EnsureRef skip the
182+
// per-call init spawn once a store entry exists.
183+
func bareRepoInitialized(fs vfs.FS, repoPath string) (bool, error) {
184+
return vfs.FileExists(fs, filepath.Join(repoPath, "HEAD"))
185+
}

internal/cas/gitstore_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,27 @@ func TestGitStoreEnsureRef_FetchFailureSurfacesError(t *testing.T) {
186186
require.Error(t, err)
187187
}
188188

189+
func TestGitStoreRejectsNonOSFilesystem(t *testing.T) {
190+
t.Parallel()
191+
192+
runner, err := git.NewGitRunner(vexec.NewOSExec())
193+
require.NoError(t, err)
194+
195+
root := filepath.Join(helpers.TmpDirWOSymlinks(t), "gitstore")
196+
197+
_, err = cas.NewGitStore(vfs.NewMemMapFS(), runner, root)
198+
require.ErrorIs(t, err, cas.ErrGitStoreFSNotOS)
199+
200+
store, err := cas.NewGitStore(vfs.NewOSFS(), runner, root)
201+
require.NoError(t, err)
202+
203+
_, _, err = store.EnsureRef(
204+
t.Context(), logger.CreateLogger(), vfs.NewMemMapFS(),
205+
"file:///does/not/exist", "main", "deadbeef", 0,
206+
)
207+
require.ErrorIs(t, err, cas.ErrGitStoreFSNotOS)
208+
}
209+
189210
func newTestGitStore(t *testing.T) (*cas.GitStore, vfs.FS, string) {
190211
t.Helper()
191212

internal/git/git.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ import (
3636

3737
const (
3838
minGitPartsLength = 2
39+
40+
// catFileMissingExitCode is the exit code `git cat-file -e` returns when
41+
// the requested object is absent. Any other non-zero exit is an
42+
// execution failure (e.g. 128 from a fatal error).
43+
catFileMissingExitCode = 1
3944
)
4045

4146
// GitRunner handles git command execution
@@ -362,8 +367,10 @@ func (g *GitRunner) Fetch(ctx context.Context, repo, ref string, depth int) erro
362367
}
363368

364369
// HasObject reports whether the given object exists in the configured
365-
// working-directory repository. A non-zero exit from `git cat-file -e`
366-
// is treated as a missing object, not an execution failure.
370+
// working-directory repository. Exit code 1 from `git cat-file -e` means
371+
// the object is absent. Other non-zero exits (e.g. 128 for a corrupted
372+
// repo or unreadable .git) are returned as errors so callers do not loop
373+
// into a refetch against a broken store.
367374
func (g *GitRunner) HasObject(ctx context.Context, hash string) (bool, error) {
368375
if err := g.RequiresWorkDir(); err != nil {
369376
return false, err
@@ -376,7 +383,7 @@ func (g *GitRunner) HasObject(ctx context.Context, hash string) (bool, error) {
376383
cmd.SetStderr(&stderr)
377384

378385
if err := cmd.Run(); err != nil {
379-
if vexec.ExitCode(err) > 0 {
386+
if vexec.ExitCode(err) == catFileMissingExitCode {
380387
return false, nil
381388
}
382389

internal/git/git_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,21 @@ func TestGitRunner_FetchAndHasObject(t *testing.T) {
315315
require.NoError(t, err)
316316
assert.True(t, has)
317317
}
318+
319+
func TestGitRunner_HasObjectSurfacesNonMissingFailures(t *testing.T) {
320+
t.Parallel()
321+
322+
dir := helpers.TmpDirWOSymlinks(t)
323+
324+
runner, err := git.NewGitRunner(vexec.NewOSExec())
325+
require.NoError(t, err)
326+
327+
runner = runner.WithWorkDir(dir)
328+
require.NoError(t, runner.InitBare(t.Context()))
329+
330+
// A malformed object name returns exit 128 (fatal). HasObject must
331+
// return an error rather than report missing, so a corrupted store
332+
// does not trigger a refetch loop.
333+
_, err = runner.HasObject(t.Context(), "not-a-hash")
334+
require.Error(t, err)
335+
}

internal/vfs/vfs.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ func NewOSFS() FS {
8080
return &osFS{afero.NewOsFs()}
8181
}
8282

83+
// IsOSFS reports whether fs is the OS-backed filesystem from [NewOSFS].
84+
// Callers that shell out to processes which only see the real disk (e.g.
85+
// `git`) should reject other filesystems up front rather than failing
86+
// inside the subprocess.
87+
func IsOSFS(fs FS) bool {
88+
_, ok := fs.(*osFS)
89+
return ok
90+
}
91+
8392
// NewMemMapFS returns an in-memory filesystem for testing purposes.
8493
// The returned filesystem supports symlink operations via an in-memory link table.
8594
func NewMemMapFS() FS {

0 commit comments

Comments
 (0)