feat(vcs): compare commit versions by repository history - #184
feat(vcs): compare commit versions by repository history#184MeteorsLiu wants to merge 17 commits into
Conversation
There was a problem hiding this comment.
Review: VCS-backed version comparison
This PR adds git-history–based version comparison (vcs.CompareFunc), wiring internal/vcs.Repo.CompareFunc into the comparator runtime via RegisterExternal, and consolidates git invocation into a shared gitCommand helper. The design is well tested (context isolation + reverse-symmetry invariants) and the git shell-out is defended against option injection (--end-of-options, check-ref-format). The revisions cache correctly avoids re-resolving refs, and locking around the map is sound.
The main items worth addressing before merge are architectural rather than mechanical:
- Panic-based error handling in the comparator path.
resolveRevision/prepareHistorypanicon every failure (network error, unknown ref, git failure).CompareFuncis invoked fromlatestVersioninsideslices.MaxFunc(internal/modules/load.go:74) with norecover()on that path — onlyrunFormulaHookrecovers, and it does not wrap comparator invocation. A transientgit fetchfailure or a malformed version string therefore crashes the whole process instead of surfacing a comparable error. TheCompareFunc(...) intsignature (no error return) is the root cause; consider(int, error)or pre-resolving refs before comparison. - Lock scope defeats MVS parallelism.
CompareFuncholdshistoryMuacross the full-historygit fetchand every per-ref git subprocess. MVS deliberately parallelizes with a 10-worker pool, but per-repo comparisons are fully serialized behind this lock. NarrowhistoryMuto the map + one-time init (sync.Once) and run git/network work outside it.
Inline comments cover these plus a few smaller items. No blocking (P0) issues.
Additional findings
/workspace/llar/internal/vcs/git.go:21: [P3] gitCommand indexes args[0] without guarding empty args: The error path formatsargs[0]without checking thatargsis non-empty; a zero-arg call would panic inside the error handler. No current caller passes empty args, but this is a shared helper — a smalllen(args) > 0guard (or a default label) would make it robust.internal/vcs/repo.go:99: [P3] parseRepoPath does not validate path segments before URL construction:modPathis module-derived and flows unvalidated intofmt.Sprintf("github.com/%s", modPath)and then intorepoURL = https://github.com/<owner>/<repo>.git.parseRepoPathaccepts any characters, including.///unicode. This isn't argument injection (thehttps://scheme prevents that), but amodPathcontaining../or extra/segments could alter the effective fetch target (SSRF-style) or produce unexpected owner/name splits. A strict per-segment allowlist (e.g.^[A-Za-z0-9._-]+$) atNewRepo/parseRepoPathwould close this trust-boundary gap defensively.
| // CompareFunc compares two tags or commits using repository history and | ||
| // compareTag to order tags. |
There was a problem hiding this comment.
[P1] CompareFunc panics on transient failures on a non-recovered path
CompareFunc → resolveRevision/prepareHistory panic on every error: network/fetch failure, unresolvable ref, git subprocess failure, and timestamp parse failure. These are expected runtime conditions (offline network, bad tag, deleted repo), not programmer errors.
The critical concern is the call path: latestVersion invokes the comparator directly inside slices.MaxFunc (internal/modules/load.go:74) with no recover(). The only recovery in the package is runFormulaHook, which wraps Filter/OnRequire, not comparator invocation. So a transient git fetch failure or a malformed version string turns a recoverable "cannot compare versions" into a full process crash — and is a DoS vector for untrusted version strings that fail rev-parse.
This is also inconsistent with the rest of vcs (Tags, Latest, Sync, syncHistory all return errors). Root cause is the CompareFunc(...) int signature having no error channel; consider (int, error), or resolve/validate all refs (surfacing errors) before the comparison sort begins.
| r.historyMu.Lock() | ||
| defer r.historyMu.Unlock() | ||
|
|
||
| left := r.comparableRevision(r.resolveRevision(a), compareTag) | ||
| right := r.comparableRevision(r.resolveRevision(b), compareTag) |
There was a problem hiding this comment.
[P1] historyMu held across git subprocesses + network fetch serializes MVS
CompareFunc acquires r.historyMu and holds it for the entire body — including prepareHistory (a full-history git fetch --filter=blob:none of all heads/tags) and both resolveRevision calls (each spawning ~4–5 git subprocesses: check-ref-format, rev-parse, show -s, tag --merged, tag --points-at).
MVS deliberately parallelizes network work with a 10-worker pool, but per-repo comparisons are now fully serialized behind this lock, and the expensive fetch runs synchronously on the first comparison rather than overlapping with the rest of the load. The lock only needs to protect the revisions map and the one-time history init. Recommend: use sync.Once (or singleflight) for prepareHistory, and lock only around the revisions map read/write, running git/network work outside the lock. Also consider batching ref resolution (git for-each-ref/a single git log --format) to reduce ~5N subprocess spawns for N versions.
| func (r *repo) runGit(args ...string) ([]byte, error) { | ||
| return historyGit(context.Background(), r.historyDir, args...) | ||
| } |
There was a problem hiding this comment.
[P2] Comparison git calls ignore context cancellation
runGit hardcodes context.Background() and prepareHistory passes context.Background() to syncHistory. The surrounding Load/compareModuleVersion flow threads a real ctx everywhere else, so a caller that cancels (timeout, Ctrl-C) cannot interrupt the potentially long git fetch or the per-revision git invocations. Because the CompareFunc interface method takes no ctx, the context is structurally unavailable — another consequence of the interface shape worth revisiting alongside the panic/error-return question.
| runtime.AddCleanup(r, func(path string) { | ||
| _ = os.RemoveAll(path) | ||
| }, dir) |
There was a problem hiding this comment.
[P2] History temp dir cleanup relies on GC finalizer
On the success path the only cleanup of historyDir is runtime.AddCleanup(r, ...), which fires only when the *repo becomes unreachable and GC runs. repo instances are cached in moduleCache for the lifetime of a Load (and possibly beyond), so each full-history bare clone persists on disk until some indeterminate later GC. For a long-lived process loading many modules that use vcs.CompareFunc, these temp dirs accumulate. Consider an explicit Close/teardown hook on Repo for deterministic removal. (The cleanup func correctly captures only dir, not r — good.)
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Review: Git-backed version comparison (x/git)
Solid, well-tested addition. compareRevisions/comparableRevisionOf are pure and unit-tested with a symmetry invariant, --end-of-options and check-ref-format are used correctly to guard against ref/option injection, and --filter=blob:none keeps the fetch lean. The main concerns are the per-comparison network fetch on a hot sort path, an unbounded/uncancelable git fetch, and a modPath→URL host-redirection surface. Details inline.
Also worth noting (no reliable single line):
CompareFuncpanics on every error path (temp dir, fetch, resolve). It is wired as a formula comparator and reachesslices.MaxFuncininternal/modules/load.go:74(latestVersion) andcompareModuleVersionatload.go:94, neither of which recovers nearby — a transient git/network failure will abort resolution via panic rather than a handled error. Since thefunc(a, b) intsignature has no error return this may be intentional, but the doc comment onCompareFuncshould state that it panics and which layer is expected to recover.
| return compareRevisions(left, right, compareTag) | ||
| } | ||
|
|
||
| func fetchHistory(dir, modPath string) error { |
There was a problem hiding this comment.
Performance: full network git fetch on every pairwise comparison.
CompareFunc calls fetchHistory (init bare repo + remote add + full git fetch of all heads/tags) into a fresh temp dir on every invocation. This comparator is passed to slices.MaxFunc in internal/modules/load.go:74 (latestVersion), so selecting the latest of N tags triggers O(N) full network fetches of the same repo (O(N log N) on general sort paths), each re-downloading identical history.
Consider fetching history once per module path and reusing it — either memoize the prepared bare repo keyed on a.Path (the fetch only uses a.Path), or restructure to "fetch once → resolve all revisions → sort in memory with compareRevisions", collapsing O(N) fetches into one. Recommend addressing before this runs against real modules with many tags.
| } | ||
|
|
||
| func fetchHistory(dir, modPath string) error { | ||
| repoURL := fmt.Sprintf("https://github.com/%s.git", modPath) |
There was a problem hiding this comment.
Security: modPath is interpolated into the clone URL without validation (host-redirection / SSRF surface).
modPath flows from formula source (internal/modules/source.go) and is not validated here. A value containing @ — e.g. x@evil.com/y — produces https://github.com/x@evil.com/y.git, which git parses with host evil.com, redirecting the fetch to an attacker-controlled server. Args are passed as argv (no shell), so this is bounded, but it is still a real redirection vector.
Suggest validating modPath against an owner/repo shape (reject @, relative segments, control chars) or url.Parse + assert Host == "github.com" before building the URL. Note module.EscapePath exists but isn't applied here. For defense-in-depth against a hostile remote, also consider pinning empty credential.helper and neutralizing GIT_ASKPASS alongside the existing GIT_TERMINAL_PROMPT=0.
|
|
||
| // CompareFunc compares two versions of the same module using Git history and | ||
| // compareTag to order tags. | ||
| func CompareFunc(a, b module.Version, compareTag func(a, b string) int) int { |
There was a problem hiding this comment.
No context/timeout on the git subprocesses.
CompareFunc takes no context.Context and runGit uses execbroker.Command with no deadline, so a git fetch against an unresponsive remote can hang indefinitely. Because this runs inside dependency resolution (slices.MaxFunc), a single stalled remote blocks the whole resolution with no way to cancel. Consider threading a context through and using a command-with-context / timeout.
| return strings.Split(text, "\n"), nil | ||
| } | ||
|
|
||
| func runGit(dir string, args ...string) ([]byte, error) { |
There was a problem hiding this comment.
Minor: runGit indexes args[0] in the error path without a guard. All current callers pass at least one arg so it's not a live bug, but runGit(dir) with no args would panic with index-out-of-range and mask the real error. A defensive check would make it safer against future callers.
| const zlibCommit = "9f0f2d4f9f1f28be7e16d8bf3b4e9d4ada70aa9f" | ||
| store := setupTestStore(t) | ||
|
|
||
| matrix := runtime.GOARCH + "-" + runtime.GOOS | ||
| workspaceDir := t.TempDir() | ||
| b := &Builder{ | ||
| store: store, | ||
| matrix: matrix, | ||
| workspaceDir: workspaceDir, | ||
| cache: &localCache{workspaceDir: workspaceDir}, | ||
| newRepo: vcs.NewRepo, | ||
| } | ||
|
|
||
| main := module.Version{Path: "pnggroup/libpng", Version: "v1.6.47"} | ||
| ctx := context.Background() | ||
| mods, err := modules.Load(ctx, main, modules.Options{ | ||
| FormulaStore: store, | ||
| Matrix: classfile.Matrix{ | ||
| Options: map[string][]string{"zlib": {"commit"}}, | ||
| }, | ||
| }) | ||
| if err != nil { | ||
| t.Fatalf("modules.Load() failed: %v", err) | ||
| } | ||
| if len(mods) != 2 { | ||
| t.Fatalf("loaded modules = %d, want 2", len(mods)) | ||
| } | ||
| var zlibMod *modules.Module | ||
| for _, mod := range mods { | ||
| if mod.Path == "madler/zlib" { | ||
| zlibMod = mod | ||
| break | ||
| } | ||
| } | ||
| if zlibMod == nil { | ||
| t.Fatal("missing madler/zlib dependency") | ||
| } | ||
| if zlibMod.Version != zlibCommit { | ||
| t.Fatalf("zlib dependency version = %q, want %q", zlibMod.Version, zlibCommit) | ||
| } |
There was a problem hiding this comment.
似乎当前测试并没有表现出 commit 对比的部分
Design: #182