Skip to content

Commit 488acbe

Browse files
authored
Merge pull request #674 from simonchrz/feat/mirror-git-tags-submodules
Mirror pkg:git tags and submodules
2 parents 43bd67f + 0efe54f commit 488acbe

9 files changed

Lines changed: 541 additions & 27 deletions

File tree

‎docs/architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ Package metadata enrichment, built on [github.com/git-pkgs/enrichment](https://g
368368

369369
### `internal/mirror`
370370

371-
Selective package mirroring for pre-populating the proxy cache. Supports multiple input sources: individual PURLs (versioned or unversioned), CycloneDX/SPDX SBOM files, and full registry enumeration. Uses a bounded worker pool backed by `errgroup` to download artifacts in parallel, reusing `handler.Proxy.GetOrFetchArtifact()` for the actual fetch-and-cache work. Container images (`pkg:docker`, `pkg:oci`) go through `handler.ContainerHandler.MirrorImage()` instead, which walks index, platform manifests and blobs and stores them under the keys a pull reads. Git repositories (`pkg:git`) go through `handler.GitHandler.Mirror()`, which caches the repository and commit in the git cache that `/git` serves.
371+
Selective package mirroring for pre-populating the proxy cache. Supports multiple input sources: individual PURLs (versioned or unversioned), CycloneDX/SPDX SBOM files, and full registry enumeration. Uses a bounded worker pool backed by `errgroup` to download artifacts in parallel, reusing `handler.Proxy.GetOrFetchArtifact()` for the actual fetch-and-cache work. Container images (`pkg:docker`, `pkg:oci`) go through `handler.ContainerHandler.MirrorImage()` instead, which walks index, platform manifests and blobs and stores them under the keys a pull reads. Git repositories (`pkg:git`) go through `handler.GitHandler.Mirror()`, which caches the repository and the commit or tag, with its submodules, in the git cache that `/git` serves.
372372

373373
The package also provides a `MetadataCache` for storing raw upstream metadata blobs so the proxy can serve metadata responses offline. The `JobStore` manages async mirror jobs exposed via the `/api/mirror` endpoints.
374374

‎docs/configuration.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1927,16 +1927,18 @@ proxy mirror --platform linux/arm64 pkg:brew/hello@2.12.3 pkg:brew/openssl%403
19271927

19281928
### Git repositories
19291929

1930-
`pkg:git` PURLs mirror a repository of an [`upstream.git`](#git-repositories) host into the git cache, so a clone through `/git` is served from it, also when the upstream is unreachable. The PURL names the repository by host and path, and the version is a full commit ID:
1930+
`pkg:git` PURLs mirror a repository of an [`upstream.git`](#git-repositories) host into the git cache, so a clone through `/git` is served from it, also when the upstream is unreachable. The PURL names the repository by host and path, and the version is a full commit ID or a tag:
19311931

19321932
```bash
19331933
proxy mirror pkg:git/github.com/git-pkgs/purl@0123456789abcdef0123456789abcdef01234567
1934+
proxy mirror pkg:git/github.com/git-pkgs/purl@v0.2.1
19341935
proxy mirror pkg:git/github.com/git-pkgs/purl
19351936
```
19361937

19371938
- The repository is served by the `upstream.git` host whose URL its host and path start with, the longest one if several do, and has to match that host's `repos`. Others fail.
19381939
- The repository's branches and tags are cached, as a clone through the proxy caches them. A commit on none of them is fetched by ID and kept, like one a client fetches by ID, until a branch or tag includes it, or for seven days from the last mirror run that named it, as long as `git.max_size` doesn't evict the repository. Where `upstream.auth` has credentials for the host, commits aren't fetched by ID, and one on no branch or tag that isn't cached yet fails.
1939-
- Without a version the repository's branches and tags are mirrored. A tag or branch name as the version fails; give the commit ID.
1940+
- Without a version the repository's branches and tags are mirrored. A tag as the version has to be one of them, so a tag the upstream doesn't have fails. When the upstream can't be reached, a cached tag is mirrored at the commit it last pointed to, with a warning in the log. A branch name or an abbreviated commit ID as the version fails too; give the full commit ID.
1941+
- With a version, the submodules that commit records are mirrored at the commits it pins, with their submodules in turn. A relative submodule URL is resolved against the repository's, and an `ssh://`, `git://` or `git@host:path` URL names the repository by its host and path. A submodule no `upstream.git` host serves is skipped with a warning in the log, as are all of a commit's submodules when git can't read its `.gitmodules`.
19401942
- The git cache is used by one process. While the server runs, the command can't open `git.cache_dir`, so `pkg:git` items fail and the rest are mirrored; use the mirror API instead.
19411943

19421944
### Scheduled refresh

‎internal/gitcache/helpers_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ func (u *fakeUpstream) bare() string {
316316
type testCache struct {
317317
*Cache
318318
server *httptest.Server
319+
logs *lockedBuffer
319320
}
320321

321322
func newTestCache(t *testing.T, upstreamURL string, configure func(*Options)) *testCache {
@@ -347,7 +348,7 @@ func newTestCache(t *testing.T, upstreamURL string, configure func(*Options)) *t
347348
server.Close()
348349
_ = c.Close()
349350
})
350-
return &testCache{Cache: c, server: server}
351+
return &testCache{Cache: c, server: server, logs: logs}
351352
}
352353

353354
// testFront serves /git/up/{repo}/info/refs and git-upload-pack from c, as

‎internal/gitcache/mirror.go‎

Lines changed: 147 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,47 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"os/exec"
8+
"slices"
79
"strings"
810
"time"
911
)
1012

1113
// MirrorResult is what Mirror did.
1214
type MirrorResult struct {
13-
// Cached reports that the repository, and the commit if one was given,
14-
// were cached already.
15+
// Cached reports that the repository, and the commit if a version was
16+
// given, were cached already.
1517
Cached bool
1618
// Bytes is how much the cached repository grew.
1719
Bytes int64
20+
// Commit is the commit the version names, empty without a version.
21+
Commit string
22+
// Submodules are the submodules Commit records, sorted by path.
23+
Submodules []Submodule
24+
}
25+
26+
// Submodule is a submodule a commit records: its path, the URL .gitmodules
27+
// gives for it, which may be relative to the repository's, and the commit
28+
// the gitlink pins.
29+
type Submodule struct {
30+
Path, URL, Commit string
1831
}
1932

2033
// Mirror brings the repository cached under key up to date with repoURL,
21-
// cloning it if needed, and waits for that to finish. With commit, an object
22-
// ID, it also makes sure the commit is cached. One on no branch or tag is
23-
// fetched by ID when fetchWants allows it (see OpenOptions.FetchWants), and
24-
// is kept as long as a commit a client fetched by ID.
25-
func (c *Cache) Mirror(ctx context.Context, key, repoURL, commit string, fetchWants bool) (MirrorResult, error) {
26-
if commit != "" && !isObjectID(commit) {
27-
return MirrorResult{}, fmt.Errorf("%q is not a full commit ID", commit)
34+
// cloning it if needed, and waits for that to finish. version, if given, is
35+
// a full commit ID or a tag. A commit is made sure to be cached: one on no
36+
// branch or tag is fetched by ID when fetchWants allows it (see
37+
// OpenOptions.FetchWants), and is kept as long as a commit a client fetched
38+
// by ID. A tag is cached with the repository's tags.
39+
func (c *Cache) Mirror(ctx context.Context, key, repoURL, version string, fetchWants bool) (MirrorResult, error) {
40+
if lower := strings.ToLower(version); isObjectID(lower) {
41+
version = lower
42+
}
43+
tag := version != "" && !isObjectID(version)
44+
if tag {
45+
if err := c.checkTagName(ctx, version); err != nil {
46+
return MirrorResult{}, err
47+
}
2848
}
2949
lease, err := c.Open(ctx, key, repoURL, OpenOptions{FetchWants: fetchWants, NoCheck: true})
3050
if err != nil {
@@ -53,17 +73,132 @@ func (c *Cache) Mirror(ctx context.Context, key, repoURL, commit string, fetchWa
5373
if errors.Is(refreshErr, ErrTooLarge) {
5474
return MirrorResult{}, refreshErr
5575
}
56-
if commit != "" {
76+
commit := version
77+
switch {
78+
case tag:
79+
if commit, err = c.resolveTag(ctx, rp.dir, version); err != nil {
80+
if refreshErr != nil {
81+
// The tag may be one the failed refresh would have brought.
82+
return MirrorResult{}, refreshErr
83+
}
84+
if errors.Is(err, ErrNotFound) && strings.Trim(strings.ToLower(version), "0123456789abcdef") == "" {
85+
// Likely an abbreviated commit ID.
86+
return MirrorResult{}, fmt.Errorf("%s is not a full commit ID or tag: %w", version, ErrNotFound)
87+
}
88+
return MirrorResult{}, err
89+
}
90+
if refreshErr != nil {
91+
// Tags can move; this is where it pointed when last fetched.
92+
c.logger.Warn("git cache: refresh failed, mirroring the cached tag", "repository", key, "tag", version, "commit", commit, "error", refreshErr)
93+
}
94+
case version != "":
5795
if err := c.ensureCommit(ctx, rp, repoURL, commit, fetchWants); err != nil {
5896
return MirrorResult{}, err
5997
}
6098
}
6199
// A refresh that failed leaves the commit cached all the same.
62-
if refreshErr != nil && commit == "" {
100+
if refreshErr != nil && version == "" {
63101
return MirrorResult{}, refreshErr
64102
}
103+
var submodules []Submodule
104+
if commit != "" {
105+
if submodules, err = c.submodules(ctx, key, rp.dir, commit); err != nil {
106+
return MirrorResult{}, err
107+
}
108+
}
65109
grown := max(rp.size.Load()-before, 0)
66-
return MirrorResult{Cached: cached && grown == 0, Bytes: grown}, nil
110+
return MirrorResult{Cached: cached && grown == 0, Bytes: grown, Commit: commit, Submodules: submodules}, nil
111+
}
112+
113+
// checkTagName refuses a version that is no valid tag name, such as one
114+
// with .. or a revision suffix like ^{tree}.
115+
func (c *Cache) checkTagName(ctx context.Context, name string) error {
116+
if err := c.command(ctx, nil, "check-ref-format", "refs/tags/"+name).Run(); err != nil {
117+
return fmt.Errorf("%q is neither a full commit ID nor a tag name", name)
118+
}
119+
return nil
120+
}
121+
122+
// resolveTag returns the commit tag points at.
123+
func (c *Cache) resolveTag(ctx context.Context, dir, tag string) (string, error) {
124+
out, err := c.command(ctx, nil, "-C", dir, "rev-parse", "--verify", "--quiet", "--end-of-options", "refs/tags/"+tag+"^{commit}").Output()
125+
var exit *exec.ExitError
126+
if errors.As(err, &exit) && exit.ExitCode() == 1 {
127+
return "", fmt.Errorf("tag %s: %w", tag, ErrNotFound)
128+
}
129+
if err != nil {
130+
return "", fmt.Errorf("resolving tag %s: %w", tag, err)
131+
}
132+
return strings.TrimSpace(string(out)), nil
133+
}
134+
135+
// submodules lists the submodules commit records: its gitlinks that
136+
// .gitmodules names a URL for.
137+
func (c *Cache) submodules(ctx context.Context, key, dir, commit string) ([]Submodule, error) {
138+
err := c.command(ctx, nil, "-C", dir, "rev-parse", "--verify", "--quiet", "--end-of-options", commit+":.gitmodules").Run()
139+
var exit *exec.ExitError
140+
if errors.As(err, &exit) && exit.ExitCode() == 1 && ctx.Err() == nil {
141+
return nil, nil
142+
}
143+
if err != nil {
144+
if ctx.Err() != nil {
145+
return nil, ctx.Err()
146+
}
147+
return nil, fmt.Errorf("looking for .gitmodules: %w", err)
148+
}
149+
// --list rather than --get-regexp, which fails when nothing matches.
150+
out, err := c.run(ctx, dir, "config", "-z", "--blob", commit+":.gitmodules", "--list")
151+
if err != nil {
152+
if ctx.Err() != nil {
153+
return nil, ctx.Err()
154+
}
155+
// The commit is cached; a .gitmodules git can't read only loses
156+
// its submodules.
157+
c.logger.Warn("git cache: cannot read .gitmodules, skipping submodules", "repository", key, "commit", commit, "error", err)
158+
return nil, nil
159+
}
160+
paths, urls := map[string]string{}, map[string]string{}
161+
for entry := range strings.SplitSeq(out, "\x00") {
162+
key, value, ok := strings.Cut(entry, "\n")
163+
section, ok2 := strings.CutPrefix(key, "submodule.")
164+
name, field, dotted := strings.CutLast(section, ".")
165+
if !ok || !ok2 || !dotted {
166+
continue
167+
}
168+
switch field {
169+
case "path":
170+
paths[value] = name
171+
case "url":
172+
urls[name] = value
173+
}
174+
}
175+
var pathspecs []string
176+
for path, name := range paths {
177+
if path != "" && urls[name] != "" {
178+
pathspecs = append(pathspecs, path)
179+
}
180+
}
181+
if len(pathspecs) == 0 {
182+
return nil, nil
183+
}
184+
args := append([]string{"--literal-pathspecs", "ls-tree", "-z", "--full-tree", commit, "--"}, pathspecs...)
185+
tree, err := c.run(ctx, dir, args...)
186+
if err != nil {
187+
return nil, err
188+
}
189+
var submodules []Submodule
190+
for entry := range strings.SplitSeq(tree, "\x00") {
191+
meta, path, ok := strings.Cut(entry, "\t")
192+
fields := strings.Fields(meta)
193+
if !ok || len(fields) != 3 || fields[0] != "160000" {
194+
continue
195+
}
196+
if url := urls[paths[path]]; url != "" && isObjectID(fields[2]) {
197+
submodules = append(submodules, Submodule{Path: path, URL: url, Commit: fields[2]})
198+
}
199+
}
200+
slices.SortFunc(submodules, func(a, b Submodule) int { return strings.Compare(a.Path, b.Path) })
201+
return submodules, nil
67202
}
68203

69204
// ensureCommit makes sure the repository has commit, fetching it by ID when

0 commit comments

Comments
 (0)