Skip to content

Commit 9a4beae

Browse files
coopernetesclaude
andcommitted
feat: tag push support in S&F hook chain and proxy filters
Closes #19 Lightweight and annotated tags now flow through both push modes: Store-and-forward hooks: - CheckEmptyBranchHook, CheckHiddenCommitsHook: skip tag refs (no commit chain to validate) - CommitInspectionService: guard pushedCommits null/empty before walking Transparent proxy filters: - ParseGitRequestFilter: sets branch from packet-line ref for tag pushes; GitReceivePackParser only handles OBJ_COMMIT so commit is left null for tags — downstream filters must tolerate this - CheckUserPushPermissionFilter, CheckEmptyBranchFilter: skip refs/tags/* (no author email or commit list available) - CheckHiddenCommitsFilter: dereference commitTo via ^{commit} to peel annotated tag objects before walking; zero-object packs resolve to null and are safely skipped - AllowApprovedPushFilter: remove null-commit guard so approved tag pushes are recognised as pre-approved on re-push Tests: new CheckHiddenCommitsFilterTest (lightweight + annotated tag cases using real JGit in-memory repo); AllowApprovedPushFilterTest extended with tagPush_approvedRecord_setsPreApproved; CheckUserPushPermissionFilterTest and CheckEmptyBranchFilterTest extended with tag-skip cases. Integration scripts: test/push-pass-tag.sh (S&F mode) and test/proxy-pass-tag.sh (proxy mode, approval flow). Docs: jgit-proxy-core/GIT_INTERNALS.md explains lightweight vs annotated tag wire format, ^{commit} dereference, and per-filter rationale. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 827613f commit 9a4beae

18 files changed

Lines changed: 619 additions & 12 deletions

jgit-proxy-core/GIT_INTERNALS.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Git internals reference
2+
3+
Notes on git/JGit behaviour that inform how filters and hooks are written.
4+
Add a section here when you hit a non-obvious edge case so the next person doesn't have to rediscover it.
5+
6+
---
7+
8+
## Tag objects
9+
10+
### Lightweight vs annotated tags
11+
12+
Git has two kinds of tags, and they behave very differently at the object level.
13+
14+
**Lightweight tag** — just a named pointer, stored as a ref file.
15+
The ref value is the SHA of the commit it points to directly.
16+
There is no tag object in the object store.
17+
18+
```
19+
refs/tags/v1.0 → a3f9c1... (commit)
20+
```
21+
22+
**Annotated tag** — a first-class git object of type `tag`.
23+
The ref points to the tag object SHA, not the commit SHA.
24+
The tag object contains metadata (tagger, date, message) and a pointer to the tagged commit.
25+
26+
```
27+
refs/tags/v1.0 → b7d2e4... (tag object)
28+
└─→ a3f9c1... (commit)
29+
```
30+
31+
The key consequence: **`cmd.getNewId()` for an annotated tag push returns the tag object SHA, not a commit SHA.**
32+
Any code that calls `RevWalk.parseCommit(cmd.getNewId())` directly will throw `IncorrectObjectTypeException` for annotated tags.
33+
34+
### The `^{commit}` dereference
35+
36+
Both git and JGit support a peeling suffix to follow any chain of tag objects to the final commit:
37+
38+
```java
39+
// Safe for both lightweight and annotated tags:
40+
ObjectId commitId = repository.resolve(sha + "^{commit}");
41+
```
42+
43+
For a lightweight tag, `sha` is already a commit SHA — `^{commit}` is a no-op.
44+
For an annotated tag, JGit follows the tag → commit chain.
45+
For a chain of tags (a tag of a tag), it follows all the way down.
46+
47+
`^{tree}` works the same way but stops at a tree object instead of a commit.
48+
49+
`resolve()` returns `null` when the peel fails — for example when a tag points to a blob or tree
50+
rather than a commit (legal but extremely rare). Always null-check the result.
51+
52+
### What git sends over the wire for a tag push
53+
54+
When you run `git push origin refs/tags/v1.0`:
55+
56+
- The packet line header is `<oldOid> <newOid> refs/tags/v1.0` (same format as a branch push).
57+
- For a **lightweight tag** to an already-upstream commit, git sends a thin pack with **zero objects**
58+
because the commit already exists at the remote. Trying to read a pack entry from an empty pack
59+
produces garbage or a `DataFormatException` — this is normal, not a corruption.
60+
- For an **annotated tag**, git sends a pack containing the tag object (type 4, `OBJ_TAG`).
61+
The tagged commit is not included if it already exists upstream.
62+
- `commitFrom` (`oldOid`) is all-zeros for a new tag — the same value used for a new branch.
63+
Code that uses `commitFrom == zeros` as a signal for "new branch" must also account for new tags.
64+
65+
### How each hook/filter handles tags
66+
67+
#### S&F hooks (`CheckEmptyBranchHook`, `CheckHiddenCommitsHook`)
68+
69+
Tags push commits that already exist upstream.
70+
`CommitInspectionService.getCommitRange()` returns an empty list — the commit at the tag tip is
71+
already reachable from existing heads, so it is not "new".
72+
73+
**`CheckEmptyBranchHook`** — an empty commit range on a zero-oldId ref would normally mean the branch
74+
has no new commits (a reject condition). For tags this is always the case and is legitimate, so the
75+
hook skips any ref whose name starts with `refs/tags/`.
76+
77+
**`CheckHiddenCommitsHook`** — calls `walk.parseCommit()` on `cmd.getNewId()`.
78+
For an annotated tag this throws. Fix: resolve through `^{commit}` first.
79+
80+
```java
81+
ObjectId commitId = repo.resolve(cmd.getNewId().name() + "^{commit}");
82+
if (commitId == null) continue;
83+
walk.markStart(walk.parseCommit(commitId));
84+
```
85+
86+
All other S&F hooks (`AuthorEmailValidationHook`, `CommitMessageValidationHook`, etc.) delegate to
87+
`CommitInspectionService.getCommitDetails()` or `getCommitRange()`, both of which use `^{commit}`.
88+
They are safe transitively.
89+
90+
#### Proxy-mode filters
91+
92+
The proxy pipeline sees the same two objects as the S&F hooks — the packet line SHAs and the pack data —
93+
but runs as servlet filters without JGit's `ReceivePack` infrastructure.
94+
95+
**`ParseGitRequestFilter`** — extracts `branch`, `commitFrom`, `commitTo` from the packet line,
96+
then tries to parse the first pack object as a commit.
97+
For a tag push this fails (the pack contains a tag object or no new objects).
98+
The parse exception is caught; `requestDetails.commit` is left null.
99+
`requestDetails.branch` is set to the full ref name (e.g. `refs/tags/v1.0`),
100+
so `GitRequestDetails.isTagPush()` works correctly downstream.
101+
102+
**`CheckUserPushPermissionFilter`** — uses the commit author email to identify the pushing user.
103+
Null commit → null email → rejects with "Unknown User".
104+
Fix: skip the email check for tag pushes; the user is already verified by HTTP basic auth.
105+
106+
**`CheckEmptyBranchFilter`** — empty `pushedCommits` + zero `commitFrom` looks like an empty branch push.
107+
Fix: skip for tag refs, same reasoning as the S&F hook.
108+
109+
**`CheckHiddenCommitsFilter`** — calls `walk.parseCommit(repo.resolve(toCommit))` where `toCommit`
110+
is the tag object SHA.
111+
Fix: use `repo.resolve(toCommit + "^{commit}")`, consistent with all other `CommitInspectionService` callers.
112+
113+
**`EnrichPushCommitsFilter`** — unpacks the pack objects into the local repo clone using JGit's
114+
`PackParser`, which handles tag objects fine. Then calls `CommitInspectionService.getCommitRange()`
115+
(fixed via `^{commit}`), which returns empty for a tag on an existing commit. Normal behaviour.
116+
117+
**`ScanDiffFilter`** — calls `getDiff(repo, fromCommit, toCommit)`.
118+
`toCommit + "^{tree}"` peels through the tag chain to the tree; this works correctly.
119+
For a new tag (`fromCommit == zeros`) the diff base falls through to `findNewBranchBase()`,
120+
which also uses `^{commit}` and returns null (no new commits), so the diff is against the empty tree.
121+
This produces a full-snapshot diff of the tagged commit, which is harmless for typical content checks.
122+
123+
**`SecretScanningFilter`** — passes `commitFrom`/`commitTo` to `gitleaks git`.
124+
Gitleaks calls native `git log`, which peels tags natively. No special handling needed.

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/CheckEmptyBranchHook.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
4343
for (ReceiveCommand cmd : commands) {
4444
if (cmd.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED) continue;
4545
if (cmd.getType() == ReceiveCommand.Type.DELETE) continue;
46+
// Tags legitimately point to existing commits — skip the "empty branch" check
47+
if (cmd.getRefName().startsWith("refs/tags/")) continue;
4648

4749
try {
4850
List<Commit> commits = getCommits(repo, cmd);

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/CheckHiddenCommitsHook.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ private Set<String> collectAllNewCommits(Repository repo, Collection<ReceiveComm
125125
for (ReceiveCommand cmd : commands) {
126126
if (cmd.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED) continue;
127127
if (cmd.getType() == ReceiveCommand.Type.DELETE) continue;
128-
walk.markStart(walk.parseCommit(cmd.getNewId()));
128+
// Dereference annotated tags to their target commit before walking
129+
ObjectId commitId = repo.resolve(cmd.getNewId().name() + "^{commit}");
130+
if (commitId == null) continue; // non-commit object (blob/tree tag) — skip
131+
walk.markStart(walk.parseCommit(commitId));
129132
}
130133

131134
for (Ref ref : repo.getRefDatabase().getRefsByPrefix("refs/")) {

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/CommitInspectionService.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public class CommitInspectionService {
3737
*/
3838
public static Commit getCommitDetails(Repository repository, String commitId) throws IOException {
3939
try (RevWalk revWalk = new RevWalk(repository)) {
40-
ObjectId objectId = repository.resolve(commitId);
40+
// Use "^{commit}" to dereference annotated tags to their target commit
41+
ObjectId objectId = repository.resolve(commitId + "^{commit}");
4142
if (objectId == null) {
4243
throw new IOException("Commit not found: " + commitId);
4344
}
@@ -63,7 +64,8 @@ public static List<Commit> getCommitRange(Repository repository, String fromComm
6364

6465
try (Git git = new Git(repository)) {
6566
ObjectId fromId = repository.resolve(fromCommit);
66-
ObjectId toId = repository.resolve(toCommit);
67+
// Use "^{commit}" to dereference annotated tags to their target commit
68+
ObjectId toId = repository.resolve(toCommit + "^{commit}");
6769

6870
if (toId == null) {
6971
throw new IOException("Commit not found: " + toCommit);
@@ -181,7 +183,8 @@ private static boolean isNullCommit(String commitId) {
181183
* {@code null} if the oldest new commit is a root commit (base is the empty tree).
182184
*/
183185
private static ObjectId findNewBranchBase(Repository repository, String toCommit) throws IOException {
184-
ObjectId toId = repository.resolve(toCommit);
186+
// Use "^{commit}" to dereference annotated tags to their target commit
187+
ObjectId toId = repository.resolve(toCommit + "^{commit}");
185188
if (toId == null) return null;
186189

187190
try (Git git = new Git(repository)) {

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/GitRequestDetails.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ public boolean isRefDeletion() {
4040
return commitTo != null && commitTo.matches("^0+$");
4141
}
4242

43+
/** Returns true when this push targets a tag ref ({@code refs/tags/} prefix). */
44+
public boolean isTagPush() {
45+
return branch != null && branch.startsWith("refs/tags/");
46+
}
47+
4348
@Builder
4449
@Getter
4550
public static class RepoRef {

jgit-proxy-core/src/main/java/org/finos/gitproxy/servlet/filter/AllowApprovedPushFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public void doHttpFilter(HttpServletRequest request, HttpServletResponse respons
5353
}
5454

5555
var details = (GitRequestDetails) request.getAttribute(GIT_REQUEST_ATTR);
56-
if (details == null || details.getCommit() == null) {
56+
if (details == null) {
5757
return;
5858
}
5959

jgit-proxy-core/src/main/java/org/finos/gitproxy/servlet/filter/CheckEmptyBranchFilter.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ public void doHttpFilter(HttpServletRequest request, HttpServletResponse respons
5151
return;
5252
}
5353

54+
// Tags legitimately point to existing commits — pushedCommits will be empty but that is expected.
55+
if (requestDetails.isTagPush()) {
56+
log.debug("Tag push detected (ref={}), skipping empty branch check", requestDetails.getBranch());
57+
return;
58+
}
59+
5460
var commits = requestDetails.getPushedCommits();
5561
if (commits != null && !commits.isEmpty()) {
5662
return;

jgit-proxy-core/src/main/java/org/finos/gitproxy/servlet/filter/CheckHiddenCommitsFilter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ private Set<String> collectAllNewCommits(Repository repo, String toCommit) throw
119119
Set<String> result = new HashSet<>();
120120

121121
try (RevWalk walk = new RevWalk(repo)) {
122-
ObjectId tip = repo.resolve(toCommit);
122+
// Dereference annotated tags to their target commit before walking
123+
ObjectId tip = repo.resolve(toCommit + "^{commit}");
123124
if (tip == null) {
124125
log.warn("Could not resolve commitTo {} in cached repository", toCommit);
125126
return result;

jgit-proxy-core/src/main/java/org/finos/gitproxy/servlet/filter/CheckUserPushPermissionFilter.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,17 @@ public void doHttpFilter(HttpServletRequest request, HttpServletResponse respons
5050
return;
5151
}
5252

53-
// Branch deletions send commitTo = 0000...0 (zero SHA) with no new commits.
54-
// There is no author to extract identity from; the user is already authenticated
55-
// by HTTP basic auth at the transport layer, so let deletions pass through.
53+
// Branch deletions and tag pushes carry no commit author to identify the user;
54+
// identity is already verified by HTTP basic auth at the transport layer.
5655
String commitTo = requestDetails.getCommitTo();
5756
if (commitTo != null && commitTo.matches("^0+$")) {
5857
log.debug("Branch deletion detected (commitTo={}), skipping user permission check", commitTo);
5958
return;
6059
}
60+
if (requestDetails.isTagPush()) {
61+
log.debug("Tag push detected (ref={}), skipping user email check", requestDetails.getBranch());
62+
return;
63+
}
6164

6265
// Extract user email from commit author
6366
String userEmail = null;

jgit-proxy-core/src/test/java/org/finos/gitproxy/git/CheckEmptyBranchHookTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,48 @@ void alreadyRejectedCommand_skipped() throws Exception {
142142
assertEquals("pre-rejected", cmd.getMessage());
143143
}
144144

145+
@Test
146+
void lightweightTag_skipped() throws Exception {
147+
// Lightweight tags point directly to a commit — the empty-branch check must not fire
148+
ObjectId c1 = createCommit("Tagged commit");
149+
ReceivePack rp = new ReceivePack(repo);
150+
ReceiveCommand cmd = new ReceiveCommand(ObjectId.zeroId(), c1, "refs/tags/v1.0");
151+
152+
CheckEmptyBranchHook hook = new CheckEmptyBranchHook(new PushContext());
153+
hook.onPreReceive(rp, List.of(cmd));
154+
155+
assertEquals(
156+
ReceiveCommand.Result.NOT_ATTEMPTED,
157+
cmd.getResult(),
158+
"Lightweight tag push must not be rejected by CheckEmptyBranchHook");
159+
}
160+
161+
@Test
162+
void annotatedTag_skipped() throws Exception {
163+
// Annotated tags point to a tag object, not a commit directly
164+
ObjectId c1 = createCommit("Tagged commit");
165+
// Create an annotated tag via JGit
166+
org.eclipse.jgit.lib.ObjectInserter inserter = repo.newObjectInserter();
167+
org.eclipse.jgit.lib.TagBuilder tb = new org.eclipse.jgit.lib.TagBuilder();
168+
tb.setTag("v2.0");
169+
tb.setObjectId(repo.parseCommit(c1));
170+
tb.setTagger(new PersonIdent("Dev", "dev@example.com"));
171+
tb.setMessage("Release 2.0");
172+
ObjectId tagId = inserter.insert(tb);
173+
inserter.flush();
174+
175+
ReceivePack rp = new ReceivePack(repo);
176+
ReceiveCommand cmd = new ReceiveCommand(ObjectId.zeroId(), tagId, "refs/tags/v2.0");
177+
178+
CheckEmptyBranchHook hook = new CheckEmptyBranchHook(new PushContext());
179+
hook.onPreReceive(rp, List.of(cmd));
180+
181+
assertEquals(
182+
ReceiveCommand.Result.NOT_ATTEMPTED,
183+
cmd.getResult(),
184+
"Annotated tag push must not be rejected by CheckEmptyBranchHook");
185+
}
186+
145187
@Test
146188
void step_recordedOnPass() throws Exception {
147189
ObjectId c1 = createCommit("First");

0 commit comments

Comments
 (0)