Skip to content

Commit ad81ae5

Browse files
bschellenberger2600cursoragentBen Schellenbergercoderabbitai[bot]CodeRabbit
authored
Hybrid polyglot testkit: Go CLI bridge + wrapper smoke tests (#6)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary - add a hybrid roadmap that makes Option A (Go core + JSON CLI bridge) the immediate path and keeps Option B (native Python/Java) as a planned phase - add reusable error-returning Go helpers for fixtures and snapshots so non-`testing.T` callers can reuse the same behavior - add `cmd/git-testkit-cli` to expose fixture/query/snapshot operations via JSON request-response - add thin Python and Java wrappers that call the Go CLI - add smoke tests in Python and Java for repo creation, bare remote push flow, and snapshot roundtrip - add two runnable Python sample smoke implementations (`testkit/python/samples/smoke_repo_flow.py`, `testkit/python/samples/smoke_snapshot_flow.py`) that exercise and verify the wrapper API end-to-end - add two runnable Java sample smoke implementations (`SampleRepoFlowSmoke`, `SampleSnapshotFlowSmoke`) that exercise and verify the wrapper API end-to-end - add sample docs for both languages (`testkit/python/samples/README.md`, `testkit/java/README.md`) - add spec-kit artifact scaffold under `testkit/.specify` with constitution, spec, plan, tasks, contract, and checklist - normalize from reverse-spec wording to canonical spec-kit enforcement naming - wire CI/CD to enforce spec-kit artifact validation plus Go/Python/Java conformance suites - wire CI/CD runners to execute both Python and Java sample implementations as part of the `spec-kit-conformance` job - resolve remaining PR feedback by hardening CLI fallback JSON encoding and response-field pointer semantics for optional payloads ## Why - maximize reuse and reduce drift by keeping one implementation source first - keep DevEx strong by offering language wrappers and runnable samples now, while preserving a clean path to native ports later - prove API behavior with executable sample implementations in both languages - enforce spec-kit-first behavior so current code state and future changes are both governed by executable spec artifacts and CI gates ## Validation plan - go vet + go test regression checks - Python wrapper conformance tests - Java wrapper conformance tests - Python sample smoke scripts - Java sample smoke classes - spec-kit artifact validation script <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-0c178c6d-6a98-4d1a-8076-ef84930680e8"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-0c178c6d-6a98-4d1a-8076-ef84930680e8"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a JSON CLI bridge plus thin Python and Java wrapper clients for creating repos, running git commands, and snapshot workflows. * **Fixture & Snapshot** * Safer, directory-aware fixture APIs with input validation; snapshot save/load/restore with symlink/hardlink support and stricter extraction safety. * **Documentation** * Added polyglot spec, roadmap, plan, contract, READMEs, and runnable Python/Java sample scripts. * **Tests / CI** * Expanded Go/Python/Java unit and smoke tests and new CI jobs validating spec-kit conformance and cross-language flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Ben Schellenberger <TBRX103@users.noreply.github.com> Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
1 parent 4dc79f1 commit ad81ae5

34 files changed

Lines changed: 2883 additions & 153 deletions

‎.github/workflows/ci.yml‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,63 @@ on:
77
pull_request:
88

99
jobs:
10+
spec-kit-conformance:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Validate spec-kit artifact set
17+
run: |
18+
test -f testkit/.specify/memory/constitution.md
19+
test -f testkit/.specify/specs/001-polyglot-testkit/spec.md
20+
test -f testkit/.specify/specs/001-polyglot-testkit/plan.md
21+
test -f testkit/.specify/specs/001-polyglot-testkit/tasks.md
22+
test -f testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json
23+
test -f testkit/.specify/specs/001-polyglot-testkit/checklists/quality.md
24+
25+
- name: Validate spec-kit scaffold and status
26+
run: ./testkit/.specify/scripts/validate_specify.sh
27+
28+
- name: Setup Go
29+
uses: actions/setup-go@v5
30+
with:
31+
go-version: "stable"
32+
- name: Build git-testkit CLI binary once
33+
run: go build ./cmd/git-testkit-cli
34+
35+
- name: Setup Python
36+
uses: actions/setup-python@v5
37+
with:
38+
python-version: "3.12"
39+
40+
- name: Setup Java
41+
uses: actions/setup-java@v4
42+
with:
43+
distribution: temurin
44+
java-version: "21"
45+
cache: maven
46+
47+
- name: Run Python spec conformance smoke tests
48+
run: |
49+
cd testkit/python
50+
python -m pip install -e ".[dev]"
51+
python -m pytest tests/ -v
52+
- name: Run Python sample smoke implementations
53+
run: |
54+
cd testkit/python
55+
python -m samples.smoke_repo_flow
56+
python -m samples.smoke_snapshot_flow
57+
58+
- name: Run Java spec conformance smoke tests
59+
run: |
60+
cd testkit/java
61+
mvn test
62+
- name: Run Java sample smoke implementations
63+
run: |
64+
cd testkit/java
65+
mvn -Dtest=SampleRepoFlowSmoke,SampleSnapshotFlowSmoke test
66+
1067
test:
1168
runs-on: ubuntu-latest
1269
strategy:
@@ -26,5 +83,63 @@ jobs:
2683
- name: Run vet
2784
run: go vet ./...
2885

86+
- name: Check gofmt
87+
run: test -z "$(gofmt -l .)"
88+
2989
- name: Run tests
3090
run: go test ./...
91+
92+
wrapper-cross-platform:
93+
runs-on: ${{ matrix.os }}
94+
strategy:
95+
fail-fast: false
96+
matrix:
97+
os: [ubuntu-latest, macos-latest, windows-latest]
98+
99+
steps:
100+
- name: Checkout
101+
uses: actions/checkout@v4
102+
103+
- name: Setup Go
104+
uses: actions/setup-go@v5
105+
with:
106+
go-version: "stable"
107+
108+
- name: Build git-testkit CLI binary once
109+
shell: bash
110+
run: |
111+
mkdir -p bin
112+
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
113+
go build -o ./bin/git-testkit-cli.exe ./cmd/git-testkit-cli
114+
else
115+
go build -o ./bin/git-testkit-cli ./cmd/git-testkit-cli
116+
fi
117+
118+
- name: Setup Python
119+
uses: actions/setup-python@v5
120+
with:
121+
python-version: "3.12"
122+
123+
- name: Setup Java
124+
uses: actions/setup-java@v4
125+
with:
126+
distribution: temurin
127+
java-version: "21"
128+
cache: maven
129+
130+
- name: Run Python wrapper smoke tests
131+
shell: bash
132+
env:
133+
GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-testkit-cli.exe' || './bin/git-testkit-cli' }}
134+
run: |
135+
cd testkit/python
136+
python -m pip install -e ".[dev]"
137+
python -m pytest tests/test_fixtures.py tests/test_snapshots.py -v
138+
139+
- name: Run Java wrapper smoke tests
140+
shell: bash
141+
env:
142+
GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-testkit-cli.exe' || './bin/git-testkit-cli' }}
143+
run: |
144+
cd testkit/java
145+
mvn -Dtest=CliBridgeTest,SampleRepoFlowSmoke,SampleSnapshotFlowSmoke test

‎cmd/git-testkit-cli/main.go‎

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
testutil "github.com/git-fire/git-testkit"
11+
)
12+
13+
type request struct {
14+
Op string `json:"op"`
15+
BaseDir string `json:"baseDir,omitempty"`
16+
RepoPath string `json:"repoPath,omitempty"`
17+
Args []string `json:"args,omitempty"`
18+
Options *repoOptionsInput `json:"options,omitempty"`
19+
20+
SnapshotPath string `json:"snapshotPath,omitempty"`
21+
}
22+
23+
type repoOptionsInput struct {
24+
Name string `json:"name"`
25+
Dirty bool `json:"dirty,omitempty"`
26+
Files map[string]string `json:"files,omitempty"`
27+
Remotes map[string]string `json:"remotes,omitempty"`
28+
Branches []string `json:"branches,omitempty"`
29+
InitialCommit string `json:"initialCommit,omitempty"`
30+
}
31+
32+
type response struct {
33+
OK bool `json:"ok"`
34+
35+
Error string `json:"error,omitempty"`
36+
37+
RepoPath string `json:"repoPath,omitempty"`
38+
RemotePath string `json:"remotePath,omitempty"`
39+
FSRoot string `json:"fsRoot,omitempty"`
40+
Output *string `json:"output,omitempty"`
41+
Dirty *bool `json:"dirty,omitempty"`
42+
Remotes map[string]string `json:"remotes,omitempty"`
43+
SHA string `json:"sha,omitempty"`
44+
Branches []string `json:"branches,omitempty"`
45+
SnapshotName string `json:"snapshotName,omitempty"`
46+
SnapshotSize *int `json:"snapshotSize,omitempty"`
47+
RestorePath string `json:"restorePath,omitempty"`
48+
}
49+
50+
func main() {
51+
req, err := parseRequest()
52+
if err != nil {
53+
writeResponse(response{OK: false, Error: err.Error()})
54+
os.Exit(1)
55+
}
56+
57+
res, err := handle(req)
58+
if err != nil {
59+
writeResponse(response{OK: false, Error: err.Error()})
60+
os.Exit(1)
61+
}
62+
writeResponse(res)
63+
}
64+
65+
func parseRequest() (request, error) {
66+
var req request
67+
if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
68+
return request{}, fmt.Errorf("invalid JSON request: %w", err)
69+
}
70+
if strings.TrimSpace(req.Op) == "" {
71+
return request{}, fmt.Errorf("missing required field: op")
72+
}
73+
return req, nil
74+
}
75+
76+
func handle(req request) (response, error) {
77+
switch req.Op {
78+
case "create_test_repo":
79+
base, err := ensureBaseDir(req.BaseDir)
80+
if err != nil {
81+
return response{}, err
82+
}
83+
if req.Options == nil {
84+
return response{}, fmt.Errorf("missing options")
85+
}
86+
repoPath, err := testutil.CreateTestRepoInDir(base, testutil.RepoOptions{
87+
Name: req.Options.Name,
88+
Dirty: req.Options.Dirty,
89+
Files: req.Options.Files,
90+
Remotes: req.Options.Remotes,
91+
Branches: req.Options.Branches,
92+
InitialCommit: req.Options.InitialCommit,
93+
})
94+
if err != nil {
95+
return response{}, err
96+
}
97+
return response{OK: true, RepoPath: repoPath}, nil
98+
99+
case "create_bare_remote":
100+
base, err := ensureBaseDir(req.BaseDir)
101+
if err != nil {
102+
return response{}, err
103+
}
104+
if req.Options == nil || req.Options.Name == "" {
105+
return response{}, fmt.Errorf("missing options.name")
106+
}
107+
remotePath, err := testutil.CreateBareRemoteInDir(base, req.Options.Name)
108+
if err != nil {
109+
return response{}, err
110+
}
111+
return response{OK: true, RemotePath: remotePath}, nil
112+
113+
case "setup_fake_filesystem":
114+
base, err := ensureBaseDir(req.BaseDir)
115+
if err != nil {
116+
return response{}, err
117+
}
118+
root, err := testutil.SetupFakeFilesystemInDir(base)
119+
if err != nil {
120+
return response{}, err
121+
}
122+
return response{OK: true, FSRoot: root}, nil
123+
124+
case "run_git_cmd":
125+
if req.RepoPath == "" {
126+
return response{}, fmt.Errorf("missing repoPath")
127+
}
128+
output, err := testutil.RunGitCmdE(req.RepoPath, req.Args...)
129+
if err != nil {
130+
return response{}, err
131+
}
132+
return response{OK: true, Output: stringPtr(output)}, nil
133+
134+
case "is_dirty":
135+
if req.RepoPath == "" {
136+
return response{}, fmt.Errorf("missing repoPath")
137+
}
138+
dirty, err := testutil.IsDirtyE(req.RepoPath)
139+
if err != nil {
140+
return response{}, err
141+
}
142+
return response{OK: true, Dirty: &dirty}, nil
143+
144+
case "get_remotes":
145+
if req.RepoPath == "" {
146+
return response{}, fmt.Errorf("missing repoPath")
147+
}
148+
remotes, err := testutil.GetRemotesE(req.RepoPath)
149+
if err != nil {
150+
return response{}, err
151+
}
152+
return response{OK: true, Remotes: remotes}, nil
153+
154+
case "get_current_sha":
155+
if req.RepoPath == "" {
156+
return response{}, fmt.Errorf("missing repoPath")
157+
}
158+
sha, err := testutil.GetCurrentSHAE(req.RepoPath)
159+
if err != nil {
160+
return response{}, err
161+
}
162+
return response{OK: true, SHA: sha}, nil
163+
164+
case "get_branches":
165+
if req.RepoPath == "" {
166+
return response{}, fmt.Errorf("missing repoPath")
167+
}
168+
branches, err := testutil.GetBranchesE(req.RepoPath)
169+
if err != nil {
170+
return response{}, err
171+
}
172+
return response{OK: true, Branches: branches}, nil
173+
174+
case "snapshot_repo":
175+
if req.RepoPath == "" {
176+
return response{}, fmt.Errorf("missing repoPath")
177+
}
178+
snapshot, err := testutil.SnapshotRepoE(req.RepoPath)
179+
if err != nil {
180+
return response{}, err
181+
}
182+
return response{
183+
OK: true,
184+
SnapshotName: snapshot.Name(),
185+
SnapshotSize: intPtr(snapshot.Size()),
186+
}, nil
187+
188+
case "snapshot_save":
189+
if req.RepoPath == "" || req.SnapshotPath == "" {
190+
return response{}, fmt.Errorf("missing repoPath or snapshotPath")
191+
}
192+
snapshot, err := testutil.SnapshotRepoE(req.RepoPath)
193+
if err != nil {
194+
return response{}, err
195+
}
196+
if err := testutil.SaveSnapshotToDiskE(snapshot, req.SnapshotPath); err != nil {
197+
return response{}, err
198+
}
199+
return response{
200+
OK: true,
201+
SnapshotName: snapshot.Name(),
202+
SnapshotSize: intPtr(snapshot.Size()),
203+
}, nil
204+
205+
case "snapshot_load_restore":
206+
if req.SnapshotPath == "" {
207+
return response{}, fmt.Errorf("missing snapshotPath")
208+
}
209+
base, err := ensureBaseDir(req.BaseDir)
210+
if err != nil {
211+
return response{}, err
212+
}
213+
snapshot, err := testutil.LoadSnapshotFromDiskE(req.SnapshotPath)
214+
if err != nil {
215+
return response{}, err
216+
}
217+
restorePath, err := testutil.RestoreSnapshotToDir(snapshot, base)
218+
if err != nil {
219+
return response{}, err
220+
}
221+
return response{
222+
OK: true,
223+
RestorePath: restorePath,
224+
SnapshotName: snapshot.Name(),
225+
SnapshotSize: intPtr(snapshot.Size()),
226+
}, nil
227+
228+
default:
229+
return response{}, fmt.Errorf("unsupported op: %s", req.Op)
230+
}
231+
}
232+
233+
func ensureBaseDir(baseDir string) (string, error) {
234+
if strings.TrimSpace(baseDir) == "" {
235+
return "", fmt.Errorf("missing baseDir")
236+
}
237+
clean := filepath.Clean(baseDir)
238+
if err := os.MkdirAll(clean, 0755); err != nil {
239+
return "", err
240+
}
241+
return clean, nil
242+
}
243+
244+
func writeResponse(res response) {
245+
enc := json.NewEncoder(os.Stdout)
246+
enc.SetEscapeHTML(false)
247+
if err := enc.Encode(res); err != nil {
248+
fallback := response{
249+
OK: false,
250+
Error: fmt.Sprintf("failed writing response: %s", err.Error()),
251+
}
252+
stderrEnc := json.NewEncoder(os.Stderr)
253+
stderrEnc.SetEscapeHTML(false)
254+
if encodeErr := stderrEnc.Encode(fallback); encodeErr != nil {
255+
fmt.Fprintf(os.Stderr, "failed writing fallback response: %v\n", encodeErr)
256+
}
257+
}
258+
}
259+
260+
func intPtr(v int) *int {
261+
return &v
262+
}
263+
264+
func stringPtr(v string) *string {
265+
return &v
266+
}

0 commit comments

Comments
 (0)