Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/gradle-cache/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ type RestoreCmd struct {
CacheKey string `help:"Bundle identifier, e.g. 'my-project:assembleRelease'." required:""`
GitDir string `help:"Path to the git repository used for history walking." default:"." type:"path" hidden:""`
Ref string `help:"Git ref used to search for a base bundle. When --branch is set, history walks from the merge-base of HEAD and this ref." default:"HEAD"`
Commit string `help:"Specific commit SHA to try directly, skipping history walk."`
Commits string `help:"Comma-separated list of commit specs to probe, skipping history walk. Each item is either a SHA/ref or a git revspec containing '..' (e.g. 'A..HEAD'), expanded via 'git rev-list --first-parent'."`
MaxBlocks int `help:"Number of distinct-author commit blocks to search." default:"20"`
GradleUserHome string `help:"Path to GRADLE_USER_HOME." env:"GRADLE_USER_HOME" type:"path"`
ProjectDir string `help:"Project directory containing included builds and .gradle/." default:"." type:"path"`
Expand All @@ -116,7 +116,7 @@ func (c *RestoreCmd) Run(ctx context.Context, metrics gradlecache.MetricsClient)
CacheKey: c.CacheKey,
GitDir: c.GitDir,
Ref: c.Ref,
Commit: c.Commit,
Commits: c.Commits,
MaxBlocks: c.MaxBlocks,
GradleUserHome: c.GradleUserHome,
ProjectDir: c.ProjectDir,
Expand Down
44 changes: 44 additions & 0 deletions gradlecache/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,50 @@ func historyCommits(ctx context.Context, gitDir, ref string, maxBlocks int) ([]s
return commits, errors.Wrap(scanner.Err(), "scan git log")
}

// resolveCommits expands a --commits spec into an ordered candidate list.
// The spec is a comma-separated list of items, each of which is either:
// - a single ref/SHA, included as-is, or
// - any revspec containing "..", passed through to `git rev-list
// --first-parent` (e.g. "A..HEAD", "A..feature", "A^..HEAD").
func resolveCommits(ctx context.Context, gitDir, spec string) ([]string, error) {
var out []string
for _, raw := range strings.Split(spec, ",") {
item := strings.TrimSpace(raw)
if item == "" {
continue
}
if strings.Contains(item, "..") {
commits, err := revListFirstParent(ctx, gitDir, item)
if err != nil {
return nil, err
}
out = append(out, commits...)
continue
}
out = append(out, item)
}
return out, nil
}

// revListFirstParent runs `git rev-list --first-parent <revspec>` and returns
// the resulting commit SHAs in the order git emits them (newest first).
func revListFirstParent(ctx context.Context, gitDir, revspec string) ([]string, error) {
//nolint:gosec
cmd := exec.CommandContext(ctx, "git", "-C", gitDir, "rev-list", "--first-parent", revspec, "--")
out, err := cmd.Output()
if err != nil {
return nil, errors.Errorf("git rev-list %s: %w", revspec, err)
}
var commits []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line != "" {
commits = append(commits, line)
}
}
return commits, nil
}

func branchSlug(branch string) string {
s := strings.ReplaceAll(branch, "/", "--")
var b strings.Builder
Expand Down
134 changes: 134 additions & 0 deletions gradlecache/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,140 @@ func TestHistoryCommits(t *testing.T) {
})
}

func TestResolveCommits(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}

ctx := context.Background()
repo := t.TempDir()

run := func(args ...string) string {
t.Helper()
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repo}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Test",
"GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=Test",
"GIT_COMMITTER_EMAIL=test@test.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}

commit := func(msg string) string {
t.Helper()
run("commit", "--allow-empty", "-m", msg)
return run("rev-parse", "HEAD")
}

run("init")
run("config", "user.email", "test@test.com")
run("config", "user.name", "Test")

c1 := commit("one")
c2 := commit("two")
c3 := commit("three")
c4 := commit("four")
head := c4

t.Run("single SHA passes through", func(t *testing.T) {
got, err := resolveCommits(ctx, repo, c2)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != c2 {
t.Fatalf("got %v, want [%s]", got, c2)
}
})

t.Run("comma-separated list preserves order", func(t *testing.T) {
got, err := resolveCommits(ctx, repo, c3+","+c1+","+c2)
if err != nil {
t.Fatal(err)
}
want := []string{c3, c1, c2}
if !equalSlices(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})

t.Run("A..HEAD walks HEAD back to A exclusive of A", func(t *testing.T) {
got, err := resolveCommits(ctx, repo, c2+"..HEAD")
if err != nil {
t.Fatal(err)
}
want := []string{head, c3}
if !equalSlices(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})

t.Run("A^..HEAD includes A", func(t *testing.T) {
got, err := resolveCommits(ctx, repo, c2+"^..HEAD")
if err != nil {
t.Fatal(err)
}
want := []string{head, c3, c2}
if !equalSlices(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})

t.Run("range when A equals B is empty", func(t *testing.T) {
got, err := resolveCommits(ctx, repo, head+"..HEAD")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("got %v, want []", got)
}
})

t.Run("whitespace around items is tolerated", func(t *testing.T) {
got, err := resolveCommits(ctx, repo, " "+c1+" , "+c2+" ")
if err != nil {
t.Fatal(err)
}
want := []string{c1, c2}
if !equalSlices(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})

t.Run("range mixed with single in CSV", func(t *testing.T) {
got, err := resolveCommits(ctx, repo, c3+"..HEAD,"+c1)
if err != nil {
t.Fatal(err)
}
want := []string{head, c1}
if !equalSlices(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})

t.Run("unknown ref in range errors", func(t *testing.T) {
if _, err := resolveCommits(ctx, repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef..HEAD"); err == nil {
t.Fatal("expected error for unknown anchor")
}
})
}

func equalSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

func TestMergeBase(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
Expand Down
14 changes: 10 additions & 4 deletions gradlecache/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,11 @@ type RestoreConfig struct {
// Ref is the git ref used to search for a base bundle. When Branch is set,
// history walks from the merge-base of HEAD and Ref. Defaults to "HEAD".
Ref string
// Commit is a specific commit SHA to try directly, skipping history walk.
Commit string
// Commits is a comma-separated list of commit specs to probe in order,
// skipping the default history walk. Each item is either a single ref/SHA
// or a revspec containing ".." (e.g. "A..HEAD") passed through to
// `git rev-list --first-parent`.
Commits string
// MaxBlocks is the number of distinct-author commit blocks to search. Defaults to 20.
MaxBlocks int
// GradleUserHome is the path to GRADLE_USER_HOME. Defaults to ~/.gradle.
Expand Down Expand Up @@ -317,8 +320,11 @@ func Restore(ctx context.Context, cfg RestoreConfig) error {
findStart := time.Now()

var commits []string
if cfg.Commit != "" {
commits = []string{cfg.Commit}
if cfg.Commits != "" {
commits, err = resolveCommits(ctx, cfg.GitDir, cfg.Commits)
if err != nil {
return errors.Wrap(err, "resolve --commits spec")
}
} else {
ref := cfg.Ref
// When restoring on a branch (PR), resolve the merge-base between HEAD
Expand Down
Loading