Skip to content

Commit a906778

Browse files
authored
chore: Addressing review feedback on #5989 (#5991)
1 parent 48582dc commit a906778

3 files changed

Lines changed: 182 additions & 38 deletions

File tree

internal/cache/cache_test.go

Lines changed: 143 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package cache_test
22

33
import (
4+
"context"
5+
"path/filepath"
46
"testing"
7+
"testing/synctest"
58
"time"
69

710
"github.com/gruntwork-io/terragrunt/internal/cache"
811
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
913
)
1014

1115
func TestCacheCreation(t *testing.T) {
@@ -52,32 +56,155 @@ func TestExpiringCacheCreation(t *testing.T) {
5256
func TestExpiringCacheOperation(t *testing.T) {
5357
t.Parallel()
5458

55-
ctx := t.Context()
56-
cache := cache.NewExpiringCache[string]("test")
59+
synctest.Test(t, func(t *testing.T) {
60+
ctx := t.Context()
61+
cache := cache.NewExpiringCache[string]("test")
5762

58-
value, found := cache.Get(ctx, "potato")
63+
value, found := cache.Get(ctx, "potato")
5964

60-
assert.False(t, found)
61-
assert.Empty(t, value)
65+
assert.False(t, found)
66+
assert.Empty(t, value)
6267

63-
cache.Put(ctx, "potato", "carrot", time.Now().Add(1*time.Second))
64-
value, found = cache.Get(ctx, "potato")
68+
cache.Put(ctx, "potato", "carrot", time.Now().Add(1*time.Second))
69+
value, found = cache.Get(ctx, "potato")
6570

66-
assert.True(t, found)
67-
assert.NotEmpty(t, value)
68-
assert.Equal(t, "carrot", value)
71+
assert.True(t, found)
72+
assert.NotEmpty(t, value)
73+
assert.Equal(t, "carrot", value)
74+
})
6975
}
7076

7177
func TestExpiringCacheExpiration(t *testing.T) {
7278
t.Parallel()
7379

80+
synctest.Test(t, func(t *testing.T) {
81+
ctx := t.Context()
82+
cache := cache.NewExpiringCache[string]("test")
83+
84+
cache.Put(ctx, "potato", "carrot", time.Now().Add(time.Second))
85+
86+
// Move the bubble's virtual clock past the expiration so the Get below
87+
// observes the entry as expired without any real wallclock wait.
88+
time.Sleep(2 * time.Second)
89+
90+
value, found := cache.Get(ctx, "potato")
91+
92+
assert.False(t, found)
93+
assert.NotEmpty(t, value)
94+
assert.Equal(t, "carrot", value)
95+
})
96+
}
97+
98+
func TestContextCache(t *testing.T) {
99+
t.Parallel()
100+
74101
ctx := t.Context()
75-
cache := cache.NewExpiringCache[string]("test")
76102

77-
cache.Put(ctx, "potato", "carrot", time.Now().Add(-1*time.Second))
78-
value, found := cache.Get(ctx, "potato")
103+
// Missing entry returns a fresh detached instance.
104+
c := cache.ContextCache[int](ctx, "not-installed")
105+
require.NotNil(t, c)
106+
c.Put(ctx, "k", 7)
79107

80-
assert.False(t, found)
81-
assert.NotEmpty(t, value)
82-
assert.Equal(t, "carrot", value)
108+
// Installed entry round-trips.
109+
installed := cache.NewCache[int]("installed")
110+
ctxWith := context.WithValue(ctx, cache.RunCmdCacheContextKey, installed)
111+
112+
got := cache.ContextCache[int](ctxWith, cache.RunCmdCacheContextKey)
113+
assert.Same(t, installed, got)
114+
}
115+
116+
func TestContextWithCacheInstallsBoth(t *testing.T) {
117+
t.Parallel()
118+
119+
ctx := cache.ContextWithCache(t.Context())
120+
121+
runCmd, ok := ctx.Value(cache.RunCmdCacheContextKey).(*cache.Cache[string])
122+
require.True(t, ok)
123+
require.NotNil(t, runCmd)
124+
125+
repoRoots, ok := ctx.Value(cache.RepoRootCacheContextKey).(*cache.RepoRootCache)
126+
require.True(t, ok)
127+
require.NotNil(t, repoRoots)
128+
assert.Equal(t, 0, repoRoots.Len())
129+
}
130+
131+
func TestRepoRootCacheLookupAndAdd(t *testing.T) {
132+
t.Parallel()
133+
134+
ctx := t.Context()
135+
c := cache.NewRepoRootCache("repo")
136+
137+
// Empty cache misses.
138+
_, ok := c.Lookup(ctx, filepath.FromSlash("/a/b"))
139+
assert.False(t, ok)
140+
141+
outer := filepath.FromSlash("/repo")
142+
inner := filepath.FromSlash("/repo/sub/nested")
143+
144+
// Add deepest-first, then a shallower root, to exercise insertion ordering.
145+
c.Add(ctx, outer)
146+
c.Add(ctx, inner)
147+
// Duplicate Add is a no-op.
148+
c.Add(ctx, outer)
149+
// Empty Add is a no-op.
150+
c.Add(ctx, "")
151+
assert.Equal(t, 2, c.Len())
152+
153+
// Adding a shallower-still root exercises the "no insertAt found" branch
154+
// where the new root is shorter than every existing one and is appended.
155+
shallow := filepath.FromSlash("/r")
156+
c.Add(ctx, shallow)
157+
assert.Equal(t, 3, c.Len())
158+
159+
// Exact-path hit returns the matching root.
160+
got, ok := c.Lookup(ctx, outer)
161+
assert.True(t, ok)
162+
assert.Equal(t, outer, got)
163+
164+
// Descendant of the deeper root prefers it over the shallow one.
165+
got, ok = c.Lookup(ctx, filepath.Join(inner, "x"))
166+
assert.True(t, ok)
167+
assert.Equal(t, inner, got)
168+
169+
// Sibling that prefix-matches lexically but not on a separator boundary
170+
// is not a hit (e.g. /repobar should not match /repo).
171+
_, ok = c.Lookup(ctx, filepath.FromSlash("/repobar/x"))
172+
assert.False(t, ok)
173+
174+
// Path outside any cached root misses entirely.
175+
_, ok = c.Lookup(ctx, filepath.FromSlash("/elsewhere"))
176+
assert.False(t, ok)
177+
}
178+
179+
func TestRepoRootCacheBeginEndResolve(t *testing.T) {
180+
t.Parallel()
181+
182+
c := cache.NewRepoRootCache("repo")
183+
184+
// Round-trip the lock twice to confirm BeginResolve/EndResolve pair up
185+
// (a missing Unlock would deadlock the second BeginResolve). The lock's
186+
// mutual-exclusion semantics are the stdlib's responsibility, not this
187+
// test's.
188+
c.BeginResolve()
189+
c.EndResolve()
190+
191+
c.BeginResolve()
192+
c.EndResolve()
193+
}
194+
195+
func TestContextRepoRootCache(t *testing.T) {
196+
t.Parallel()
197+
198+
ctx := t.Context()
199+
200+
// Missing key returns a fresh detached instance, never nil.
201+
c := cache.ContextRepoRootCache(ctx, "missing")
202+
require.NotNil(t, c)
203+
assert.Equal(t, 0, c.Len())
204+
205+
installed := cache.NewRepoRootCache("installed")
206+
ctxWith := context.WithValue(ctx, cache.RepoRootCacheContextKey, installed)
207+
208+
got := cache.ContextRepoRootCache(ctxWith, cache.RepoRootCacheContextKey)
209+
assert.Same(t, installed, got)
83210
}

internal/git/git.go

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ const (
4040

4141
// GitRunner handles git command execution
4242
type GitRunner struct {
43-
goRepo *git.Repository
44-
goStorage *filesystem.Storage
45-
exec vexec.Exec
46-
repoRootOnce *sync.Once
47-
repoRootErr error
48-
GitPath string
49-
WorkDir string
50-
repoRoot string
43+
goRepo *git.Repository
44+
goStorage *filesystem.Storage
45+
exec vexec.Exec
46+
repoRootMu *sync.Mutex
47+
GitPath string
48+
WorkDir string
49+
repoRoot string
50+
repoRootCached bool
5151
}
5252

5353
// NewGitRunner creates a new GitRunner instance. The provided vexec.Exec is
@@ -63,24 +63,24 @@ func NewGitRunner(e vexec.Exec) (*GitRunner, error) {
6363
}
6464

6565
return &GitRunner{
66-
GitPath: gitPath,
67-
exec: e,
68-
repoRootOnce: &sync.Once{},
66+
GitPath: gitPath,
67+
exec: e,
68+
repoRootMu: &sync.Mutex{},
6969
}, nil
7070
}
7171

7272
// WithWorkDir returns a new GitRunner with the specified working directory
7373
func (g *GitRunner) WithWorkDir(workDir string) *GitRunner {
7474
if g == nil {
75-
return &GitRunner{WorkDir: workDir, exec: vexec.NewOSExec(), repoRootOnce: &sync.Once{}}
75+
return &GitRunner{WorkDir: workDir, exec: vexec.NewOSExec(), repoRootMu: &sync.Mutex{}}
7676
}
7777

7878
newRunner := *g
7979
newRunner.WorkDir = workDir
8080
// A different WorkDir may resolve to a different root, so reset the memo.
81-
newRunner.repoRootOnce = &sync.Once{}
81+
newRunner.repoRootMu = &sync.Mutex{}
8282
newRunner.repoRoot = ""
83-
newRunner.repoRootErr = nil
83+
newRunner.repoRootCached = false
8484

8585
return &newRunner
8686
}
@@ -111,19 +111,31 @@ func (g *GitRunner) RequiresGoRepo() error {
111111
return nil
112112
}
113113

114-
// GetRepoRoot returns the root directory of the git repository, memoized
115-
// per-runner. WithWorkDir clears the memo so a derived runner resolves its
116-
// own root.
114+
// GetRepoRoot returns the root directory of the git repository. The
115+
// successful result is memoized per-runner so subsequent calls skip the
116+
// `git rev-parse` fork; failures are not cached so callers can retry.
117+
// WithWorkDir clears the memo so a derived runner resolves its own root.
117118
func (g *GitRunner) GetRepoRoot(ctx context.Context) (string, error) {
118119
if err := g.RequiresWorkDir(); err != nil {
119120
return "", err
120121
}
121122

122-
g.repoRootOnce.Do(func() {
123-
g.repoRoot, g.repoRootErr = g.runRepoRoot(ctx)
124-
})
123+
g.repoRootMu.Lock()
124+
defer g.repoRootMu.Unlock()
125+
126+
if g.repoRootCached {
127+
return g.repoRoot, nil
128+
}
129+
130+
root, err := g.runRepoRoot(ctx)
131+
if err != nil {
132+
return "", err
133+
}
134+
135+
g.repoRoot = root
136+
g.repoRootCached = true
125137

126-
return g.repoRoot, g.repoRootErr
138+
return root, nil
127139
}
128140

129141
// runRepoRoot performs the uncached `git rev-parse --show-toplevel`. Use

internal/shell/git.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,15 @@ func hasNestedGit(path, root string) (bool, error) {
128128
return false, nil
129129
}
130130

131-
if _, err := os.Stat(filepath.Join(current, ".git")); err == nil {
131+
_, err := os.Stat(filepath.Join(current, ".git"))
132+
if err == nil {
132133
return true, nil
133134
}
134135

136+
if !os.IsNotExist(err) {
137+
return false, err
138+
}
139+
135140
parent := filepath.Dir(current)
136141
if parent == current {
137142
return false, nil

0 commit comments

Comments
 (0)