Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package digger-cli for openSUSE:Factory checked in at 2026-10-31 23:09:57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/digger-cli (Old) and /work/SRC/openSUSE:Factory/.digger-cli.new.1255 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "digger-cli" Sat Oct 31 23:09:57 2026 rev:58 rq:1394097 version:0.6.155 Changes: -------- --- /work/SRC/openSUSE:Factory/digger-cli/digger-cli.changes 2026-10-08 12:00:08.109840058 +0200 +++ /work/SRC/openSUSE:Factory/.digger-cli.new.1255/digger-cli.changes 2026-10-31 23:13:05.164653344 +0100 @@ -1,0 +2,8 @@ +Sat Oct 31 12:30:26 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.6.155: + * perf: reuse GitHub installation tokens between clients (#2745) + * feat: fetch management repo policies once per job with a sparse + clone (#2744) + +------------------------------------------------------------------- Old: ---- digger-cli-0.6.154.obscpio New: ---- digger-cli-0.6.155.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ digger-cli.spec ++++++ --- /var/tmp/diff_new_pack.17LZls/_old 2026-10-31 23:13:07.326744373 +0100 +++ /var/tmp/diff_new_pack.17LZls/_new 2026-10-31 23:13:07.330744542 +0100 @@ -19,7 +19,7 @@ %define executable_name digger Name: digger-cli -Version: 0.6.154 +Version: 0.6.155 Release: 0 Summary: CLI for the digger open source IaC orchestration tool License: Apache-2.0 @@ -28,8 +28,8 @@ Source1: vendor.tar.gz BuildRequires: bash-completion BuildRequires: fish -BuildRequires: go >= 1.22 BuildRequires: zsh +BuildRequires: golang(API) >= 1.25 %description CI/CD for Terraform is tricky. To make life easier, specialised CI systems aka ++++++ _service ++++++ --- /var/tmp/diff_new_pack.17LZls/_old 2026-10-31 23:13:07.446749426 +0100 +++ /var/tmp/diff_new_pack.17LZls/_new 2026-10-31 23:13:07.451749636 +0100 @@ -6,8 +6,8 @@ <param name="exclude">go.mod</param> <param name="exclude">go.work</param> <param name="exclude">go.work.sum</param> - <param name="revision">refs/tags/v0.6.154</param> - <param name="match-tag">v0.6.154</param> + <param name="revision">refs/tags/v0.6.155</param> + <param name="match-tag">v0.6.155</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.17LZls/_old 2026-10-31 23:13:07.511752162 +0100 +++ /var/tmp/diff_new_pack.17LZls/_new 2026-10-31 23:13:07.526752794 +0100 @@ -5,6 +5,6 @@ <param name="url">https://github.com/johanneskastl/digger</param> <param name="changesrevision">8fe377068e53e2050ff4c745388d8428d2b13bb0</param></service><service name="tar_scm"> <param name="url">https://github.com/diggerhq/digger.git</param> - <param name="changesrevision">853f6abf3f7ad9d2fa20b8956e5a758441ddca1e</param></service></servicedata> + <param name="changesrevision">7c237c093d7252db8c9a5dc93793f964d883a948</param></service></servicedata> (No newline at EOF) ++++++ digger-cli-0.6.154.obscpio -> digger-cli-0.6.155.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/digger-cli-0.6.154/backend/utils/github.go new/digger-cli-0.6.155/backend/utils/github.go --- old/digger-cli-0.6.154/backend/utils/github.go 2026-10-07 15:26:17.000000000 +0200 +++ new/digger-cli-0.6.155/backend/utils/github.go 2026-10-22 15:47:39.000000000 +0200 @@ -2,6 +2,7 @@ import ( "context" + "crypto/sha256" "encoding/base64" "fmt" "log/slog" @@ -9,6 +10,7 @@ "os" "strconv" "strings" + "sync" "time" "github.com/bradleyfalzon/ghinstallation/v2" @@ -23,6 +25,40 @@ type DiggerGithubRealClientProvider struct { } +type installationTransportKey struct { + appID, installationID int64 + privateKeyHash [32]byte +} + +var installationTransports sync.Map + +type cachedInstallationTransport struct { + *ghinstallation.Transport + key installationTransportKey +} + +func (t *cachedInstallationTransport) RoundTrip(request *net.Request) (*net.Response, error) { + response, err := t.Transport.RoundTrip(request) + if response != nil && response.StatusCode == net.StatusUnauthorized { + installationTransports.CompareAndDelete(t.key, t) + } + return response, err +} + +func getInstallationTransport(appID, installationID int64, privateKey []byte) (*cachedInstallationTransport, error) { + key := installationTransportKey{appID, installationID, sha256.Sum256(privateKey)} + if transport, ok := installationTransports.Load(key); ok { + return transport.(*cachedInstallationTransport), nil + } + transport, err := ghinstallation.New(net.DefaultTransport, appID, installationID, privateKey) + if err != nil { + return nil, err + } + // Sharing the transport preserves ghinstallation's synchronized token refresh. + cached, _ := installationTransports.LoadOrStore(key, &cachedInstallationTransport{transport, key}) + return cached.(*cachedInstallationTransport), nil +} + type DiggerGithubClientMockProvider struct { MockedHTTPClient *net.Client } @@ -63,8 +99,7 @@ } } - tr := net.DefaultTransport - itr, err := ghinstallation.New(tr, githubAppId, installationId, []byte(githubAppPrivateKey)) + itr, err := getInstallationTransport(githubAppId, installationId, []byte(githubAppPrivateKey)) if err != nil { slog.Error("Failed to initialize GitHub app installation", "githubAppId", githubAppId, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/digger-cli-0.6.154/backend/utils/github_transport_test.go new/digger-cli-0.6.155/backend/utils/github_transport_test.go --- old/digger-cli-0.6.154/backend/utils/github_transport_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/digger-cli-0.6.155/backend/utils/github_transport_test.go 2026-10-22 15:47:39.000000000 +0200 @@ -0,0 +1,154 @@ +package utils + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" +) + +func installationTestKey(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) +} + +func testInstallationTransport(t *testing.T, app, installation int64, key []byte, url string) *cachedInstallationTransport { + t.Helper() + transport, err := getInstallationTransport(app, installation, key) + if err != nil { + t.Fatal(err) + } + transport.BaseURL = url + t.Cleanup(func() { installationTransports.CompareAndDelete(transport.key, transport) }) + return transport +} + +func TestInstallationTokenReuseConcurrent(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != "POST" || r.URL.Path != "/app/installations/42/access_tokens" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]interface{}{"token": "test-token", "expires_at": time.Now().Add(time.Hour)}) + })) + defer server.Close() + key := installationTestKey(t) + t.Setenv("GITHUB_APP_PRIVATE_KEY_BASE64", base64.StdEncoding.EncodeToString(key)) + testInstallationTransport(t, 1, 42, key, server.URL) + var workers sync.WaitGroup + for i := 0; i < 12; i++ { + workers.Add(1) + go func() { + defer workers.Done() + _, token, err := (DiggerGithubRealClientProvider{}).Get(1, 42) + if err != nil { + t.Error(err) + return + } + if token == nil || *token != "test-token" { + t.Error("unexpected installation token") + } + }() + } + workers.Wait() + if got := requests.Load(); got != 1 { + t.Fatalf("token requests = %d, want 1", got) + } +} + +func TestInstallationTokenIdentityIsolation(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + json.NewEncoder(w).Encode(map[string]interface{}{"token": "test-token", "expires_at": time.Now().Add(time.Hour)}) + })) + defer server.Close() + key, rotatedKey := installationTestKey(t), installationTestKey(t) + cases := []struct { + app, installation int64 + key []byte + }{{1, 42, key}, {2, 42, key}, {1, 43, key}, {1, 42, rotatedKey}} + for _, c := range cases { + transport := testInstallationTransport(t, c.app, c.installation, c.key, server.URL) + if _, err := transport.Token(context.Background()); err != nil { + t.Fatal(err) + } + } + if got := requests.Load(); got != 4 { + t.Fatalf("token requests = %d, want 4", got) + } +} + +func TestInstallationTokenRefreshAndRevocation(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/resource" { + w.WriteHeader(http.StatusUnauthorized) + return + } + expiry := time.Now().Add(time.Hour) + if requests.Add(1) == 1 { + expiry = time.Now().Add(30 * time.Second) + } + json.NewEncoder(w).Encode(map[string]interface{}{"token": "test-token", "expires_at": expiry}) + })) + defer server.Close() + key := installationTestKey(t) + transport := testInstallationTransport(t, 1, 42, key, server.URL) + for i := 0; i < 2; i++ { + if _, err := transport.Token(context.Background()); err != nil { + t.Fatal(err) + } + } + if got := requests.Load(); got != 2 { + t.Fatalf("token requests after expiry = %d, want 2", got) + } + request, err := http.NewRequest(http.MethodGet, server.URL+"/resource", nil) + if err != nil { + t.Fatal(err) + } + response, err := transport.RoundTrip(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", response.StatusCode) + } + replacement := testInstallationTransport(t, 1, 42, key, server.URL) + if replacement == transport { + t.Fatal("revoked transport was reused") + } + if _, err := replacement.Token(context.Background()); err != nil { + t.Fatal(err) + } + if got := requests.Load(); got != 3 { + t.Fatalf("token requests after revocation = %d, want 3", got) + } + response, err = transport.RoundTrip(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + current, err := getInstallationTransport(1, 42, key) + if err != nil { + t.Fatal(err) + } + if current != replacement { + t.Fatal("a late 401 from the old transport evicted its replacement") + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/digger-cli-0.6.154/docs/ce/reference/digger.yml.mdx new/digger-cli-0.6.155/docs/ce/reference/digger.yml.mdx --- old/digger-cli-0.6.154/docs/ce/reference/digger.yml.mdx 2026-10-07 15:26:17.000000000 +0200 +++ new/digger-cli-0.6.155/docs/ce/reference/digger.yml.mdx 2026-10-22 15:47:39.000000000 +0200 @@ -216,7 +216,7 @@ </ParamField> <ParamField path="git_timeout" type="integer" default="30"> - Timeout in seconds for each git command (clone, checkout) Digger runs when loading repository configuration. Increase this for large repositories, for example `git_timeout: 120`. Digger reads this setting through the Git provider API before cloning, using the requested branch or commit. It checks `digger.yml` first, then `digger.yaml`. An omitted, zero, or negative value uses the 30-second default. The initial API lookup has its own 30-second timeout. The same value applies when a job clones the policy management repository (`DIGGER_MANAGEMENT_REPO`), including drift runs, so increase it if that repository is large. + Timeout in seconds for each git command (clone, checkout) Digger runs when loading repository configuration. Increase this for large repositories, for example `git_timeout: 120`. Digger reads this setting through the Git provider API before cloning, using the requested branch or commit. It checks `digger.yml` first, then `digger.yaml`. An omitted, zero, or negative value uses the 30-second default. The initial API lookup has its own 30-second timeout. The same value applies when a job fetches the policy management repository (`DIGGER_MANAGEMENT_REPO`). That repository is fetched once per job and only its `.rego` policy files are downloaded, so large repositories rarely need a higher value. </ParamField> <ParamField path="projects" type="array"> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/digger-cli-0.6.154/ee/cli/pkg/policy/fetch_test.go new/digger-cli-0.6.155/ee/cli/pkg/policy/fetch_test.go --- old/digger-cli-0.6.154/ee/cli/pkg/policy/fetch_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/digger-cli-0.6.155/ee/cli/pkg/policy/fetch_test.go 2026-10-22 15:47:39.000000000 +0200 @@ -0,0 +1,83 @@ +package policy + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeFile(t *testing.T, p string, contents string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, []byte(contents), 0o644)) +} + +func TestResolvePolicyFileOrder(t *testing.T) { + base := t.TempDir() + writeFile(t, filepath.Join(base, "policies", "access.rego"), "org") + writeFile(t, filepath.Join(base, "policies", "repo", "access.rego"), "repo") + writeFile(t, filepath.Join(base, "policies", "repo", "proj", "access.rego"), "project") + writeFile(t, filepath.Join(base, "dev", "access.rego"), "dev") + writeFile(t, filepath.Join(base, "dev", "vpc", "access.rego"), "dev/vpc") + + got, err := resolvePolicyFile(base, "repo", "proj", "dev/vpc/subnets", "access.rego") + require.NoError(t, err) + assert.Equal(t, "dev/vpc", got, "deepest project dir prefix wins") + + got, err = resolvePolicyFile(base, "repo", "proj", "prod/vpc", "access.rego") + require.NoError(t, err) + assert.Equal(t, "project", got, "falls back to policies/<repo>/<project>") + + got, err = resolvePolicyFile(base, "repo", "other", "prod/vpc", "access.rego") + require.NoError(t, err) + assert.Equal(t, "repo", got) + + got, err = resolvePolicyFile(base, "other-repo", "other", "prod/vpc", "access.rego") + require.NoError(t, err) + assert.Equal(t, "org", got) + + got, err = resolvePolicyFile(base, "repo", "proj", "dev/vpc", "plan.rego") + require.NoError(t, err) + assert.Equal(t, "", got, "missing file resolves to empty, not error") +} + +func TestManagementRepoFetchedOncePerProcess(t *testing.T) { + dir := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + run("init", "-q", "-b", "main") + run("config", "uploadpack.allowFilter", "true") + writeFile(t, filepath.Join(dir, "policies", "access.rego"), "package digger\nallow = false\n") + writeFile(t, filepath.Join(dir, "dev", "main.tf"), "resource {}") + run("add", ".") + run("commit", "-q", "-m", "init") + + p := DiggerRepoPolicyProvider{ManagementRepoUrl: "file://" + dir, GitTimeout: 30 * time.Second} + + first, err := p.managementRepoDir() + require.NoError(t, err) + defer os.RemoveAll(first) + second, err := p.managementRepoDir() + require.NoError(t, err) + assert.Equal(t, first, second, "second lookup reuses the checkout") + + access, err := p.GetAccessPolicy("org", "repo", "proj", "dev") + require.NoError(t, err) + assert.Equal(t, "package digger\nallow = false\n", access) + + plan, err := p.GetPlanPolicy("org", "repo", "proj", "dev") + require.NoError(t, err) + assert.Equal(t, "", plan) + + assert.NoFileExists(t, filepath.Join(first, "dev", "main.tf"), "non-policy files are not checked out") +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/digger-cli-0.6.154/ee/cli/pkg/policy/policy.go new/digger-cli-0.6.155/ee/cli/pkg/policy/policy.go --- old/digger-cli-0.6.154/ee/cli/pkg/policy/policy.go 2026-10-07 15:26:17.000000000 +0200 +++ new/digger-cli-0.6.155/ee/cli/pkg/policy/policy.go 2026-10-22 15:47:39.000000000 +0200 @@ -1,6 +1,7 @@ package policy import ( + "sync" "time" "github.com/diggerhq/digger/libs/git_utils" @@ -25,6 +26,39 @@ GitTimeout time.Duration } +const managementRepoBranch = "main" + +// policyFileNames are the only files policy resolution ever reads, so the +// management repo checkout is restricted to them. +var policyFileNames = []string{"access.rego", "plan.rego", "apply.rego"} + +// A job checks policies several times per project. The management repo is +// fetched once per process and the checkout reused for every lookup. +var managementRepoCheckouts = struct { + sync.Mutex + dirs map[string]string +}{dirs: map[string]string{}} + +func (p DiggerRepoPolicyProvider) managementRepoDir() (string, error) { + managementRepoCheckouts.Lock() + defer managementRepoCheckouts.Unlock() + + key := p.ManagementRepoUrl + "@" + managementRepoBranch + if dir, ok := managementRepoCheckouts.dirs[key]; ok { + return dir, nil + } + + start := time.Now() + dir, err := git_utils.CloneGitRepoSparseWithTimeout(p.ManagementRepoUrl, managementRepoBranch, p.GitToken, "", p.gitTimeout(), policyFileNames) + if err != nil { + return "", err + } + // TODO(measurement): remove before release; kept to compare fetch times on real jobs. + slog.Info("Management repo policies fetched", "repoUrl", p.ManagementRepoUrl, "duration", time.Since(start)) + managementRepoCheckouts.dirs[key] = dir + return dir, nil +} + func getContents(filePath string) (string, error) { if _, err := os.Stat(filePath); err != nil { return "", err @@ -65,38 +99,39 @@ } func (p DiggerRepoPolicyProvider) getPolicyFileContents(repo string, projectName string, projectDir string, fileName string) (string, error) { - var contents string - err := git_utils.CloneGitRepoAndDoActionWithTimeout(p.ManagementRepoUrl, "main", "", p.GitToken, "", p.gitTimeout(), func(basePath string) error { - // we start with the project directory path prefixes as the highest priority - prefixes := GetPrefixesForPath(path.Join(basePath, projectDir), fileName) - - // we also add a known location as a least priority item - orgAccesspath := path.Join(basePath, "policies", fileName) - repoAccesspath := path.Join(basePath, "policies", repo, fileName) - projectAccessPath := path.Join(basePath, "policies", repo, projectName, fileName) - prefixes = append(prefixes, projectAccessPath) - prefixes = append(prefixes, repoAccesspath) - prefixes = append(prefixes, orgAccesspath) - - for _, pathPrefix := range prefixes { - var err error - contents, err = getContents(pathPrefix) - if err == nil { - return nil - } - if os.IsNotExist(err) { - continue - } else { - return err - } - } - - return nil - }) + basePath, err := p.managementRepoDir() if err != nil { return "", err } - return contents, nil + return resolvePolicyFile(basePath, repo, projectName, projectDir, fileName) +} + +// resolvePolicyFile returns the contents of the first fileName found, starting +// with the project directory prefixes and ending with the well-known +// policies/ locations. Returns "" when no file exists at any location. +func resolvePolicyFile(basePath string, repo string, projectName string, projectDir string, fileName string) (string, error) { + // we start with the project directory path prefixes as the highest priority + prefixes := GetPrefixesForPath(path.Join(basePath, projectDir), fileName) + + // we also add a known location as a least priority item + orgAccesspath := path.Join(basePath, "policies", fileName) + repoAccesspath := path.Join(basePath, "policies", repo, fileName) + projectAccessPath := path.Join(basePath, "policies", repo, projectName, fileName) + prefixes = append(prefixes, projectAccessPath) + prefixes = append(prefixes, repoAccesspath) + prefixes = append(prefixes, orgAccesspath) + + for _, pathPrefix := range prefixes { + contents, err := getContents(pathPrefix) + if err == nil { + return contents, nil + } + if os.IsNotExist(err) { + continue + } + return "", err + } + return "", nil } func (p DiggerRepoPolicyProvider) gitTimeout() time.Duration { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/digger-cli-0.6.154/libs/git_utils/clone_utils.go new/digger-cli-0.6.155/libs/git_utils/clone_utils.go --- old/digger-cli-0.6.154/libs/git_utils/clone_utils.go 2026-10-07 15:26:17.000000000 +0200 +++ new/digger-cli-0.6.155/libs/git_utils/clone_utils.go 2026-10-22 15:47:39.000000000 +0200 @@ -87,6 +87,53 @@ return nil } +// CloneGitRepoSparseWithTimeout clones repoUrl at branch into a new temporary +// directory and returns its path. Only blobs matching sparsePatterns +// (gitignore-style, matched at any depth) are fetched, so a large repository +// costs little more than its tree objects. If the sparse clone fails the +// repository is cloned the ordinary shallow way instead. The caller owns the +// returned directory. +func CloneGitRepoSparseWithTimeout(repoUrl string, branch string, token string, tokenUsername string, timeout time.Duration, sparsePatterns []string) (string, error) { + dir, err := createTempDir() + if err != nil { + return "", err + } + + git := NewGitShellWithTokenAuth(dir, token, tokenUsername) + if timeout > 0 { + git.timeout = timeout + } + + err = git.CloneSparse(repoUrl, branch, sparsePatterns) + if err == nil { + return dir, nil + } + + slog.Warn("Sparse clone failed, falling back to full shallow clone", + "repoUrl", repoUrl, + "branch", branch, + "error", err, + ) + if err := os.RemoveAll(dir); err != nil { + slog.Warn("Failed to remove directory", "directory", dir, "error", err) + } + dir, err = createTempDir() + if err != nil { + return "", err + } + git = NewGitShellWithTokenAuth(dir, token, tokenUsername) + if timeout > 0 { + git.timeout = timeout + } + if err := git.Clone(repoUrl, branch); err != nil { + if rmErr := os.RemoveAll(dir); rmErr != nil { + slog.Warn("Failed to remove directory", "directory", dir, "error", rmErr) + } + return "", err + } + return dir, nil +} + type GitAuth struct { Username string Password string // Can be either password or access token @@ -213,3 +260,29 @@ _, err = g.runCommand(args...) return err } + +// CloneSparse clones only the files matching patterns (gitignore-style, +// matched at any depth). Blobs outside the patterns are never downloaded. +func (g *GitShell) CloneSparse(repoURL, branch string, patterns []string) error { + authURL, err := g.formatAuthURL(repoURL) + if err != nil { + return err + } + + args := []string{"clone"} + if branch != "" { + args = append(args, "-b", branch) + } + args = append(args, "--depth", "1", "--single-branch", "--filter=blob:none", "--no-checkout", authURL, g.workDir) + if _, err = g.runCommand(args...); err != nil { + return err + } + + args = append([]string{"sparse-checkout", "set", "--no-cone"}, patterns...) + if _, err = g.runCommand(args...); err != nil { + return err + } + + _, err = g.runCommand("checkout") + return err +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/digger-cli-0.6.154/libs/git_utils/sparse_clone_test.go new/digger-cli-0.6.155/libs/git_utils/sparse_clone_test.go --- old/digger-cli-0.6.154/libs/git_utils/sparse_clone_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/digger-cli-0.6.155/libs/git_utils/sparse_clone_test.go 2026-10-22 15:47:39.000000000 +0200 @@ -0,0 +1,62 @@ +package git_utils + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newLocalRepo creates a repository on disk with a few policy files and one +// large unrelated file, and returns a file:// URL to it. +func newLocalRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + run("init", "-q", "-b", "main") + run("config", "uploadpack.allowFilter", "true") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "dev", "vpc"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "policies"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dev", "vpc", "access.rego"), []byte("package digger\nallow = true\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "policies", "plan.rego"), []byte("package digger\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dev", "vpc", "main.tf"), make([]byte, 1<<20), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644)) + run("add", ".") + run("commit", "-q", "-m", "init") + return "file://" + dir +} + +func TestCloneGitRepoSparseWithTimeoutFetchesOnlyPolicyFiles(t *testing.T) { + url := newLocalRepo(t) + + dir, err := CloneGitRepoSparseWithTimeout(url, "main", "", "", 30*time.Second, []string{"access.rego", "plan.rego", "apply.rego"}) + require.NoError(t, err) + defer os.RemoveAll(dir) + + assert.FileExists(t, filepath.Join(dir, "dev", "vpc", "access.rego")) + assert.FileExists(t, filepath.Join(dir, "policies", "plan.rego")) + assert.NoFileExists(t, filepath.Join(dir, "dev", "vpc", "main.tf")) + assert.NoFileExists(t, filepath.Join(dir, "README.md")) + + // The large blob must not have been downloaded at all, not merely left unchecked-out. + cmd := exec.Command("git", "cat-file", "-e", "HEAD:dev/vpc/main.tf") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_NO_LAZY_FETCH=1") + assert.Error(t, cmd.Run(), "main.tf blob should be missing from the partial clone") +} + +func TestCloneGitRepoSparseWithTimeoutFallsBackOnBadBranch(t *testing.T) { + url := newLocalRepo(t) + _, err := CloneGitRepoSparseWithTimeout(url, "does-not-exist", "", "", 30*time.Second, []string{"access.rego"}) + assert.Error(t, err) +} ++++++ digger-cli.obsinfo ++++++ --- /var/tmp/diff_new_pack.17LZls/_old 2026-10-31 23:13:09.198823192 +0100 +++ /var/tmp/diff_new_pack.17LZls/_new 2026-10-31 23:13:09.202823360 +0100 @@ -1,5 +1,5 @@ name: digger-cli -version: 0.6.154 -mtime: 1791379577 -commit: 853f6abf3f7ad9d2fa20b8956e5a758441ddca1e +version: 0.6.155 +mtime: 1792676859 +commit: 7c237c093d7252db8c9a5dc93793f964d883a948 ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/digger-cli/vendor.tar.gz /work/SRC/openSUSE:Factory/.digger-cli.new.1255/vendor.tar.gz differ: char 15, line 1
