diff --git a/cmd/gradle-cache/main.go b/cmd/gradle-cache/main.go index b26a2ff..fd228a1 100644 --- a/cmd/gradle-cache/main.go +++ b/cmd/gradle-cache/main.go @@ -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"` @@ -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, diff --git a/gradlecache/git.go b/gradlecache/git.go index 5ad4b8d..e545b0b 100644 --- a/gradlecache/git.go +++ b/gradlecache/git.go @@ -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 ` 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 diff --git a/gradlecache/git_test.go b/gradlecache/git_test.go index 0cdded9..f41124d 100644 --- a/gradlecache/git_test.go +++ b/gradlecache/git_test.go @@ -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") diff --git a/gradlecache/restore.go b/gradlecache/restore.go index 29de3e2..f1d3538 100644 --- a/gradlecache/restore.go +++ b/gradlecache/restore.go @@ -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. @@ -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