feat(repo-server): patch multiple files in one atomic commit - #9
Merged
Merged
Conversation
- reject leading-dash paths after cleaning (./-n.yaml) - restore from HEAD so staged changes of a failed commit are dropped - push a pending local commit when a retried batch changes nothing - validate batches client-side before sending
Owner
Dual review: Claude Fable 5.1 (high) + Codex gpt-6-astra (high)Both reviewers independently returned DO NOT MERGE on a31f22e. Fixed in b0de979.
Not addressed, pre-existing: lock held through push backoff sleeps, Design change worth noting: the "commit stays local and is pushed on retry" behaviour from the PR description is gone. A failed push now discards the commit and the caller retries. Both reviewers recommended this; the retry design is what made finding 1 reachable. Verified with |
Owner
|
PR description updated to match b0de979. Sections that changed:
CI on this PR is waiting for a manual workflow approval. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
PUT /api/v1/patches, which patches any number of files in one request andcommits all of them as a single commit. Previously a caller had to send one
request per file and got one commit per file, with no atomicity across files.
PUT /api/v1/patchis unchanged and stays wire compatible.API
{ "actor": "ci-bot", "files": [ { "filePath": "applications/dev/service-foo/values.yaml", "patches": [{ "selector": ".service.image.tag", "value": "v42.0.1" }] }, { "filePath": "applications/dev/service-bar/values.yaml", "patches": [{ "selector": ".service.image.tag", "value": "v42.0.1" }] } ] }Response
200 {"message":"ok","commit":"<sha>"}. The commit id is empty when thebatch did not change anything.
actoris optional and becomes aTriggered by:footer on the shared commit message.
Atomicity
All files are read and patched in memory before anything is written, so a missing
file, an unreadable file, a symlink, an untracked file or a selector that does not
match fails the whole request with no file touched.
Every failure after that point, a write error, a failed commit, a rebase conflict or
a push that keeps failing, runs one recovery:
git rebase --abort(if a rebase isin progress) followed by
git reset --hard origin/<branch>. The request answers500, nothing stays half written, staged or committed but unpushed, and the callerretries against fresh state. A commit whose push fails is discarded rather than kept
local: a kept commit that later conflicts with an upstream change would leave the
long-running clone in an active rebase that nothing clears, taking both endpoints
down until the pod restarts.
The returned commit id is
HEADafter the push, so it is the rebased commit whenpull --rebasehad to move the batch on top of a concurrent change.Validation
Rejected with
400and a message naming the offending index, before the git lockis taken so invalid requests do not block concurrent patches:
files, a file withoutpatches, an emptyselector-or:A leading dash would be parsed as a git option, a leading colon as pathspec magic
(
:!*.yaml).git addadditionally gets a--separator. The checks run on thecleaned path, so
./-n.yamlis rejected too. The Go client runs the same validationbefore sending a request.
Inside the git lock, a target that is not a regular file (
Lstat, so symlinks areseen as such and cannot redirect the write inside or outside the repository) is
rejected with
400. A target that is not tracked by git fails with500, becauseit would be written but never committed.
Incidental fixes
GitPatcher.PatchbehindPUT /api/v1/patchnow runs throughPatchBatch, sothe single file endpoint gets the same in-memory prepare, the same recovery and the
same
400for invalid input (previously500). This also removes two pre-existingbugs in the old implementation:
os.Statwas only logged, causing a nil pointer dereference on afile that does not exist (and, with no
gin.Recovery(), a dropped connection)remaining task and the push
Notable internals
Server, so both patchendpoints serialize their git operations against each other
Server.GitPatcherbecomes thepatch.PatchMethodinterface, which makes thehandlers testable without a git repository
internal/githelpers:ResetToUpstream,RequireTracked,RevParse,CommitFilesRepositoryServerPatchergains a matchingPatchBatchand no longer discardseverything past the first task
Tests
31 new test functions, ~1000 lines of test code.
httptestand a fakePatchMethod: success, empty commit, each validation error, patcher error,missing and wrong API key, plus regressions that
/patchand/healthstillbehave as before
httptest.NewServerasserting method, path, headers andthat every file is sent
(
CommitFiles(sha)), that the commit exists in an independent clone (checkedwith
git show, sincerev-parseechoes any 40-hex string with exit 0), that amissing file, a bad selector, a symlink, an untracked file or a failing write all
leave the working tree clean, that a pending commit conflicting with upstream
leaves a clean and usable clone behind, that the returned id is the rebased
commit when upstream moved before the push, and that
ResetToUpstreamclearslocal commits, staged changes and an active rebase
Verified end to end against the repo-server running in Docker against soft-serve:
one commit for a two file batch, empty commit id on replay, 400 on a traversing
path, 500 with nothing changed on a missing file, and the old endpoint unaffected.
Note for CI:
internal/gitandinternal/patchpush to the same soft-serve repoand
go test ./internal/...runs them concurrently. This is pre-existing, but theadded integration tests push more often. If
TestGitPullFastForwardstartsflaking with "fetch first", running the tests with
-p 1fixes it. Tests ininternal/gitmust not modify the shared fixture fileapplications/dev/service-test/values.yaml,TestGitSshPatchdepends on it.Review
b0de979 addresses a dual review (Claude Fable 5.1 and Codex gpt-6-astra, both
high effort), see the review comment below for the finding-by-fix table.
Out of scope
gitops patchCLI, which keeps its single file formgin.Recovery(), non constant time API keycomparison, missing
returnafter the empty key abort in the auth middleware,the patch lock being held through the push backoff sleeps,
HasChangescheckingthe whole tree instead of the batch's files