perf(git): optimize fetch for repos with many tags and branches - #453
zendesk-slowery23 wants to merge 4 commits into
Conversation
When syncing a git source with a fixed ref (tag or branch name), vendir previously fetched all remote refs and all tags, which is very slow for repos with thousands of branches or hundreds of tags. Changes: - Named refs (tags, branches) now use a targeted refspec fetch (`git fetch origin <ref> --no-tags`) and check out FETCH_HEAD, avoiding negotiation of every remote ref. - `--no-tags` tagOpt is set for named refs; `--tags` is kept only for RefSelection (semver matching) and bare SHAs. - In `--locked` mode, `DirectoryContentsGit.Lock()` now preserves the original ref in a new `OriginalRef` field (not serialized) before overwriting `Ref` with the locked SHA. The fetcher uses `OriginalRef` for the targeted fetch, then verifies the resulting commit matches the locked SHA — catching force-pushed tags. - Plain SHA refs without an OriginalRef (edge case) fall back to the previous full fetch with `--tags`. Observed improvement on a repo with thousands of branches: vendir sync: 43s → 11s (74% faster) vendir sync --locked: 86s → 9s (89% faster) Adds unit tests covering each fetch path and e2e tests verifying the targeted fetch behaviour and tampered-lock detection. Signed-off-by: Steve Lowery <steve.lowery@zendesk.com>
| if hasUser { | ||
| password := string(secret.Data[ | ||
| ctlconf.SecretK8sCorev1BasicAuthPasswordKey]) | ||
| password := string(secret.Data[ctlconf.SecretK8sCorev1BasicAuthPasswordKey]) |
There was a problem hiding this comment.
result of go fmt from hack/build.sh
There was a problem hiding this comment.
Pull request overview
This PR speeds up vendir sync for git sources by avoiding full ref negotiation and unconditional tag downloads when only a single named ref is needed, and adds test coverage around the new fetch behaviors (including --locked).
Changes:
- Add an internal
OriginalReffield to preserve the pre-lock ref name for optimized fetching in--lockedmode. - Update the git fetcher to use targeted single-refspec fetches and conditionally disable tag fetching.
- Add unit + e2e tests validating fetch command shapes and locked-SHA mismatch detection.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
pkg/vendir/fetch/git/git.go |
Implements targeted fetch logic, conditional --tags/--no-tags, FETCH_HEAD checkout handling, and locked SHA verification. |
pkg/vendir/config/directory.go |
Adds OriginalRef and sets it during Lock() before overwriting Ref with the locked SHA. |
pkg/vendir/fetch/git/git_test.go |
Adds unit tests covering the new fetch paths and helper utilities for command assertions. |
test/e2e/git_test.go |
Adds an end-to-end test asserting optimized fetch behavior and tampered lock detection. |
pkg/vendir/fetch/http/sync.go |
Minor formatting change in basic auth password extraction. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Strip origin/ prefix from OriginalRef before using it as a fetch
refspec in locked mode (origin/main is not a valid remote refspec)
- In locked mode, verify FETCH_HEAD^{commit} matches the locked SHA
before checkout, then check out the SHA directly rather than
FETCH_HEAD; this ensures mutable refs like origin/main stay pinned
to the locked commit rather than tracking the branch tip
- Accept uppercase hex in isHexSHA (git SHAs are case-insensitive)
- Fix e2e test bare-fetch assertion (needle had a spurious \n that
prevented it from ever triggering)
- Add unit test covering origin/ prefix stripping in locked mode
- Update locked-mode unit test to match revised checkout behavior
Signed-off-by: Steve Lowery <steve.lowery@zendesk.com>
|
Are any of the maintainers available to approve the workflows for my PR and/or to discuss it? We use vendir pretty heavily and this performance improvement will have a big boost in our engineer's productivity. |
|
Nudge on this. Anybody still maintaining vendir? |
joaopapereira
left a comment
There was a problem hiding this comment.
Sorry about the delay, Lgtm
|
Do you mind reading this or so that we have the tests back? Thanks |
The origin/-prefixed ref case in the targeted fetch switch didn't set useFetchHead, so checkout tried a local ref name (e.g. a tag) that was never created since fetch only populated FETCH_HEAD — breaking ref: origin/<tag> syncs. Also cleans up the lint issues flagged in CI: missing switch default, cognitive complexity, magic numbers, line length, and unhandled errors in the e2e tests. Signed-off-by: Steve Lowery <steve.lowery@zendesk.com>
52b1819 to
3c251aa
Compare
|
Pushed a fix for the fetch bug that was causing I ran both That said, I can't confirm the actual GitHub Actions checks will pass since they're still pending workflow approval on this PR — happy to fix anything else that surfaces once they run. Thanks for flagging this! |
|
I just let them run and they are failing do you mind taking a look? |
Signed-off-by: Steve Lowery <steve.lowery@zendesk.com>
e457e8f to
d29bc66
Compare
|
Thanks for letting me know. I've tested the changes locally, including the previously failing Git/cache/locked E2E cases and lint, and I think they should address the checks that are awaiting validation. I pushed the fixes in |
|
Looks like everything is green now 🎉 . Any reason to hold off on merging/releasing? |
joaopapereira
left a comment
There was a problem hiding this comment.
Sorry for the late review, I think the PR is going in the right direction but there are some points that we will have to address in order to be able to get this merged
| // before checkout so a force-pushed tag is caught early. | ||
| if useFetchHead && isHexSHA(t.opts.Ref) { | ||
| rp := []string{"rev-parse", fetchHeadCommit} | ||
| out, _, runErr := t.cmdRunner.Run(rp, nil, dstPath) |
There was a problem hiding this comment.
Not passing env so we lose all the env variables that we might have set
| return remoteRefTag, nil | ||
| } | ||
|
|
||
| out, _, err := t.cmdRunner.Run( |
There was a problem hiding this comment.
Not passing env so we lose all the env variables that we might have set
| // In locked mode we fetch by the original named ref for speed, but must | ||
| // check out the exact locked SHA. Verify FETCH_HEAD resolves to that SHA | ||
| // before checkout so a force-pushed tag is caught early. | ||
| if useFetchHead && isHexSHA(t.opts.Ref) { |
There was a problem hiding this comment.
the "fetched SHA must equal locked SHA" hard-check runs even when OriginalRef is a branch (not a tag). Since branches move, running vendir sync --locked after the branch has advanced normally will now hard-fail with a "tag may have been moved" error, when the locked commit is still perfectly valid and reachable.
| // The original ref may have been deleted or renamed since the config | ||
| // was locked. Fetching the locked object directly retains locked-sync | ||
| // behavior without requiring the old name to remain available. | ||
| fallbackArgs := []string{"fetch", "origin", t.opts.Ref, noTagsFlag} |
There was a problem hiding this comment.
when the named-ref targeted fetch fails, it falls back to git fetch origin <sha> --no-tags. Many git servers disallow fetching an arbitrary non-tip SHA (uploadpack.allowReachableSHA1InWant not enabled). Pre-PR, a full mirror fetch would have pulled the SHA in via any other ref containing it; this fallback has no further recovery path.
| if len(fields) < minRemoteRefFields { | ||
| continue | ||
| } | ||
| switch fields[1] { |
There was a problem hiding this comment.
remoteRefType() returns on the first ls-remote match; since output is alphabetically sorted, refs/heads/<name> sorts before refs/tags/<name>, so a name that exists as both is always classified as a branch. This is the opposite of git's own precedence (tags win per gitrevisions(7)), so a repo with both v2 branch and v2 tag will now checkout a different commit than before.
| // isHexSHA reports whether s looks like a full or abbreviated git commit SHA | ||
| // (7–40 hex characters, case-insensitive). SHAs cannot be fetched by refspec | ||
| // name and require a full fetch to be resolved. | ||
| func isHexSHA(s string) bool { |
There was a problem hiding this comment.
This function can produce false positives because it is possible to have words or mix of words with numbers that look HEX but are just normal branch names ex cafebabe or something.
| return info, nil | ||
| } | ||
|
|
||
| func (t *Git) fetch(dstPath string, tempArea ctlfetch.TempArea, bundle string) error { |
There was a problem hiding this comment.
Despite pkg/vendir/fetch/git/sync.go:55 was not modified by this PR, the URL-only bundle cache key predates it. But git.go's new targeted-fetch behavior means a sync now typically populates the local bundle with only one ref's objects, instead of the full mirror the old unconditional full-fetch produced. Since the cache key doesn't include the ref, a vendir.yml with two directories: entries on the same URL but different refs can now overwrite each other's cached bundle each run, a caching regression this PR introduces by changing what goes into the bundle, even though it doesn't touch the cache-key code itself.
| // named ref to target. A targeted tag fetch explicitly creates just that | ||
| // tag; a targeted branch fetch uses Git's default auto-follow behavior, | ||
| // which fetches only tags reachable from the branch tip. | ||
| if !t.canTargetFetch() || refType == remoteRefUnknown || |
There was a problem hiding this comment.
targeted branch fetches leave tagOpt at git's default (auto-follow tags on newly transferred objects only) instead of the old unconditional --tags, so git describe --tags HEAD in Retrieve() can silently produce worse/empty tags: data in vendir.lock.yml, especially with depth: 1 or a warm bundle cache.
There was a problem hiding this comment.
Ex: vendir.yml has ref: main. Someone tagged an older commit on main (e.g., v1.2.0 on a commit from last week) as a release marker. On a sync where the bundle cache already has most of main's history, git only transfers the handful of new commits, v1.2.0's commit isn't among them, so auto-follow skips that tag. Later, Retrieve() calls git describe --tags HEAD to populate tags: in vendir.lock.yml, and it comes back empty or points to some other/older tag it does know about. Silently less accurate lock-file metadata than before the PR, with no error raised.
Problem
vendir syncis very slow for git repositories with large numbers of branches and tags. The root cause is that the git fetcher unconditionally:remote.origin.tagOpt = --tags, causing all tag objects to be downloaded on every fetchgit fetch, causing all remote refs to be negotiatedOn a repo with thousands of branches and tags, this turns every sync into a multi-megabyte download regardless of what ref you actually need.
Solution
When a fixed named ref is specified (tag or branch, not a SHA), use a targeted single-refspec fetch:
Then check out
FETCH_HEAD(which git populates from a single-refspec fetch) instead of the ref name.--tagsis kept only where it's actually needed:refSelection(semver matching must enumerate all tags)OriginalRef(SHAs can't be fetched by name)--lockedpath--lockedsubstitutes the locked SHA for the original ref before reaching the fetcher, which previously forced a full--tagsfetch every time. This PR preserves the original ref in a new unexportedOriginalReffield onDirectoryContentsGit(set byLock(), not serialized to YAML). The fetcher uses it for the same targeted fetch path, then verifies the resulting commit SHA matches the lock — catching force-pushed tags.Performance
Measured against an internal repo with thousands of branches and hundreds of tags:
vendir syncvendir sync --lockedChanges
pkg/vendir/config/directory.go— addOriginalRef string(unexported from YAML) toDirectoryContentsGit; set it inLock()before overwritingRefwith the SHApkg/vendir/fetch/git/git.go— targeted refspec fetch for named refs and locked-SHA-with-OriginalRef; conditional--tags/--no-tags; post-checkout SHA verification in locked mode;isHexSHA()helperpkg/vendir/fetch/git/git_test.go— unit tests for each fetch path (named tag,origin/branch, locked SHA+OriginalRef, plain SHA, depth ordering)test/e2e/git_test.go—TestGitFetchOptimizations: verifies targeted fetch line in--jsonoutput, locked sync uses OriginalRef, tampered lock SHA is detectedTesting
🤖 Generated with Claude Code