Add JSON CLI bridge and Python/Java wrappers#4
Conversation
Introduce cmd/git-harness-cli for stdin JSON ops over the git and safety packages. Add wrappers under wrappers/python and wrappers/java mirroring the git-testkit polyglot pattern (subprocess + GIT_HARNESS_CLI). Extend CI with wrapper jobs and cross-platform smoke matrix. Ignore wrapper build artifacts. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Configure git user for Python tests via conftest (HOME/USERPROFILE). Use UTF-8 decoding for git subprocess and prefer GIT_HARNESS_CLI in the JSON contract test so Windows does not require Go on PATH. Add Python samples (repo push + scan, safety) and Java Sample*Smoke tests; extend CliBridge with getCommitSHA. Run samples in CI after wrapper tests. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Compare scan_repositories results using real/canonical paths so temp dirs under symlinked roots (e.g. /var vs /private/var) still match. Bump scan maxDepth to 30 for deeper temp layouts. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Move Python/Java clients from wrappers/ to testkit/ to mirror git-testkit. Add testkit/.specify scaffold (001-polyglot-harness), CLI contract JSON, validate_specify.sh, and testkit README + GIT_HARNESS_SPEC. CI: add spec-kit-conformance job, rename wrappers job to testkit, point all paths at testkit/python and testkit/java. Leave wrappers/README.md as a pointer for old links. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
|
@coderabbitai review |
… client subprocess.run(text=True) used the process locale (cp1252 on Windows), which broke JSON parsing when the Go binary wrote UTF-8 (e.g. safety notice). Match test_cli_bridge and use encoding=utf-8, errors=replace. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a JSON-over-stdin/stdout Go CLI bridge ( Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Python/Java Client
participant CLI as git-harness-cli
participant GitPkg as git package
participant Safety as safety package
Client->>CLI: write JSON request (stdin)
activate CLI
CLI->>CLI: parse & validate request
alt repo scan / analysis
CLI->>GitPkg: scan or analyze repositories (options)
GitPkg-->>CLI: repo metadata / list
else git operation
CLI->>GitPkg: execute git operation (status/branch/commit/fetch/push/worktree)
GitPkg-->>CLI: operation result
else safety operation
CLI->>Safety: sanitize / notice / scan files
Safety-->>CLI: safety result
end
CLI->>CLI: marshal response JSON
CLI-->>Client: write JSON response (stdout)
deactivate CLI
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (8)
testkit/python/samples/README.md (1)
14-18: Document both smoke scripts in thePYTHONPATHflow.This section introduces repo-root execution for samples, but only includes
smoke_repo_flow.py. Addingsmoke_safety_flow.pykeeps it consistent with the earlier section.📘 Proposed docs update
Or from repo root using `PYTHONPATH`: ```bash PYTHONPATH=testkit/python python3 testkit/python/samples/smoke_repo_flow.py +PYTHONPATH=testkit/python python3 testkit/python/samples/smoke_safety_flow.py</details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against the current code and only fix it if needed.
In
@testkit/python/samples/README.mdaround lines 14 - 18, Update the README
snippet that shows running samples from the repo root to document both sample
scripts: add the smoke_safety_flow.py invocation alongside smoke_repo_flow.py so
the PYTHONPATH example lists "python3 testkit/python/samples/smoke_repo_flow.py"
and "python3 testkit/python/samples/smoke_safety_flow.py"; edit the README entry
in testkit/python/samples/README.md to include the smoke_safety_flow.py command
to match the earlier section that documents both scripts.</details> </blockquote></details> <details> <summary>testkit/python/pyproject.toml (1)</summary><blockquote> `16-18`: **Consider excluding `samples*` from installable package discovery.** Including `samples*` can unintentionally publish smoke/sample modules as part of runtime distribution. Prefer packaging only `git_harness*` unless samples are intentionally part of the public package surface. <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against the current code and only fix it if needed.
In
@testkit/python/pyproject.tomlaround lines 16 - 18, The current package
discovery includes "samples*" which can accidentally publish sample modules;
update the [tool.setuptools.packages.find] config so only production packages
are discovered by either removing "samples*" from the include list (leave
include = ["git_harness*"]) or add an explicit exclude = ["samples*"] entry to
prevent packaging sample modules; target the keys in the pyproject.toml section
[tool.setuptools.packages.find] (the where/include/exclude settings) to
implement the change.</details> </blockquote></details> <details> <summary>testkit/python/samples/smoke_safety_flow.py (1)</summary><blockquote> `9-12`: **Strengthen the sanitize smoke assertion.** At Line 10, checking only that `"ghp_"` is gone can pass even if content is dropped incorrectly. Also assert the redaction marker is present. <details> <summary>✅ Suggested tweak</summary> ```diff out = client.safety_sanitize_text(f"export TOKEN={token}") if "ghp_" in out: raise RuntimeError("expected token to be redacted") + if "[REDACTED]" not in out: + raise RuntimeError("expected redaction marker in sanitized output")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@testkit/python/samples/smoke_safety_flow.py` around lines 9 - 12, The current smoke test only checks that the substring "ghp_" is removed which can pass if content is truncated; update the assertions around client.safety_sanitize_text to (1) assert the full original token string is not present in out and (2) assert that out contains the sanitizer's redaction marker (e.g., "REDACTED" or "[REDACTED]") so you verify redaction rather than omission; use the same client.safety_sanitize_text call and replace the simple "ghp_" check with these two explicit assertions before calling client.safety_security_notice().testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java (2)
80-82: Prefer JUnit assertions for better test diagnostics.Using
assertEqualsorassertTruewould integrate better with JUnit's failure reporting and provide clearer test output.💡 Suggested improvement
- if (!localSha.equals(remoteSha)) { - throw new IllegalStateException("SHA mismatch local=" + localSha + " remote=" + remoteSha); - } + assertEquals(remoteSha, localSha, "SHA mismatch between local and remote");Add import:
import static org.junit.jupiter.api.Assertions.assertEquals;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java` around lines 80 - 82, Replace the manual IllegalStateException throw with a JUnit assertion to get better test diagnostics: in SampleRepoFlowSmoke (the method comparing localSha and remoteSha) remove the throw and use assertEquals(remoteSha, localSha, "SHA mismatch") and add the static import for assertEquals from org.junit.jupiter.api.Assertions; ensure the assertion message includes both SHAs if desired for clarity.
64-82: Consider reusingrunGithelper for the rev-parse call.The inline
ProcessBuilderlogic forgit rev-parseduplicates the timeout/error handling already inrunGit. You could extendrunGitto return stdout when needed, or create an overload.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java` around lines 64 - 82, Replace the inline ProcessBuilder "git rev-parse" block with the existing runGit helper: extend runGit (or add an overload) to return stdout (or a small result object with stdout/stderr/exitCode) so callers can get the command output and rely on its timeout/error handling, then call runGit("rev-parse", branch) from SampleRepoFlowSmoke and compare the returned trimmed stdout to bridge.getCommitSHA(local.toString(), branch); ensure runGit still throws or signals on timeout/non-zero exit so the existing exception logic stays consistent.cmd/git-harness-cli/main.go (1)
451-458: Consider logging or returning an error for invalidcacheTTLvalues.Currently, an invalid duration string silently falls back to the default. While this is documented in the comment, users may not realize their configuration is being ignored.
💡 Option: return an error for invalid cacheTTL
if in.CacheTTL != "" { d, err := time.ParseDuration(in.CacheTTL) - if err != nil { - // Invalid duration falls back to default rather than failing merge. - _ = err - } else { + if err != nil { + return git.ScanOptions{}, fmt.Errorf("invalid cacheTTL %q: %w", in.CacheTTL, err) + } opts.CacheTTL = d - } }This would require changing
mergeScanOptionsto return(git.ScanOptions, error)and handling it in thescan_repositoriescase.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmd/git-harness-cli/main.go` around lines 451 - 458, The code silently ignores invalid duration strings for in.CacheTTL in mergeScanOptions; change mergeScanOptions to return (git.ScanOptions, error) so parse failures surface: when time.ParseDuration(in.CacheTTL) returns an error, return a wrapped error instead of discarding it, update callers (notably the scan_repositories handling) to handle and propagate/log the error and only set opts.CacheTTL when parsing succeeds; this ensures invalid cacheTTL values are reported to the user rather than silently falling back.testkit/python/tests/test_cli_bridge.py (1)
9-9: MoveScanOptionsimport to the module level.
ScanOptionsis imported inline at line 69, but it's already available viagit_harness. Consider importing it at the top alongsideGitHarnessClientfor consistency.💡 Suggested fix
-from git_harness import GitHarnessClient +from git_harness import GitHarnessClient, ScanOptionsThen remove line 69:
from git_harness import ScanOptions🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@testkit/python/tests/test_cli_bridge.py` at line 9, Import ScanOptions at the module level alongside GitHarnessClient (i.e., add ScanOptions to the top-level import from git_harness) and remove the inline "from git_harness import ScanOptions" at line 69 so all uses of ScanOptions in test_cli_bridge.py reference the top-level import.testkit/java/src/main/java/io/gitfire/harness/CliBridge.java (1)
277-303: Minor: JSON is parsed twice (ininvokeRawandinvokeObject).
invokeRawparses JSON at line 297 to check theokflag, theninvokeObjectparses the same string again at line 279. This is a minor inefficiency that could be avoided by returning the parsedJsonObjectfrominvokeRaw.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@testkit/java/src/main/java/io/gitfire/harness/CliBridge.java` around lines 277 - 303, invokeRaw currently returns a String and parses JSON internally, then invokeObject parses the same JSON again; change invokeRaw to return the parsed JsonObject (update its signature from String invokeRaw(String) to JsonObject invokeRaw(String)) and move the error checks to operate on that JsonObject so invokeObject can call invokeRaw(GSON.toJson(request)) and return the JsonObject directly without re-parsing; update any callers of invokeRaw (or create a new helper like invokeRawJson and keep the old invokeRaw as a thin wrapper) so compilation succeeds and avoid duplicate parsing in invokeObject and invokeRaw.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.github/workflows/ci.yml:
- Around line 63-64: The CI step that runs "go build -o ./bin/git-harness-cli
./cmd/git-harness-cli" fails on clean checkouts because the ./bin directory
isn't created; update the testkit job to create the directory first by inserting
a step or prefix that runs "mkdir -p bin" before the go build command so the
output file can be written (locate the build invocation referencing
./bin/git-harness-cli in the job and add the mkdir -p bin preparation).
In `@cmd/git-harness-cli/main.go`:
- Around line 470-485: repoToOut may emit null for Branches (and potentially
Remotes) when the source slices are nil; ensure both fields are converted to
non-nil empty slices before constructing repositoryOut. In repoToOut, create a
non-nil rem slice (you already do with rem := make([]remoteOut, 0,
len(r.Remotes))) and likewise assign branches := r.Branches; if branches == nil
{ branches = make([]string, 0) } then use branches in the repositoryOut.Branches
field; this guarantees JSON will contain [] instead of null for Branches (and
leaves Remotes as an explicit empty slice).
In `@testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json`:
- Around line 5-33: The contract JSON's request_schema and response_schema are
missing fields used at runtime; update request_schema (object properties) to
include "suspiciousFiles" as an array of strings and any other request-level
fields clients use, and update response_schema (object properties) to include
"branch" (string), "hasConflict" (boolean), "staged" (boolean), "unstaged"
(boolean), "paths" (array of strings), "worktrees" (array of objects or strings
as used), "lines" (number), and "warning" (string) with appropriate types and
item schemas so that the request_schema and response_schema objects reflect
runtime fields used by callers (refer to request_schema and response_schema in
the diff when applying changes).
In `@testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java`:
- Around line 53-56: Replace the permissive assertion on meta.path() (the
current endsWith("r") || contains("r") check) with an exact normalized path
equality: compute the expected Path (e.g., Paths.get(repo.toString(), "r") or
equivalent) and normalize both expected and actual via Path#normalize() (or
Paths.get(meta.path()).normalize()) and
assertEquals(normalizedExpected.toString(), normalizedActual.toString()) against
meta.path(); update the assertion around meta.path() so it verifies exact
normalized equality rather than a substring match.
In `@testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java`:
- Around line 12-15: The test currently only checks for the "ghp_" prefix which
can miss most of the secret; update the assertion to verify the full secret is
not present by asserting that the sanitized output from safetySanitizeText
(variable out) does not contain the complete token variable (token) instead of
just "ghp_". Locate SampleSafetyFlowSmoke where token is defined and out =
bridge.safetySanitizeText(...), and replace the weak check
assertFalse(out.contains("ghp_")) with an assertion that references token (e.g.,
assertFalse(out.contains(token))) so the full secret is validated as removed.
In `@testkit/python/tests/conftest.py`:
- Around line 5-33: The subprocess.run calls in the _git_identity_for_tests
fixture should use an absolute git executable resolved with shutil.which to
satisfy the S607 lint and provide a clear error when git is missing; update the
fixture to import shutil, call shutil.which("git") once (e.g., git_path =
shutil.which("git")), if git_path is None raise RuntimeError or pytest.skip with
a clear message, and replace ["git", ...] with [git_path, ...] for both
subprocess.run invocations so the git executable is explicit and checked before
use.
---
Nitpick comments:
In `@cmd/git-harness-cli/main.go`:
- Around line 451-458: The code silently ignores invalid duration strings for
in.CacheTTL in mergeScanOptions; change mergeScanOptions to return
(git.ScanOptions, error) so parse failures surface: when
time.ParseDuration(in.CacheTTL) returns an error, return a wrapped error instead
of discarding it, update callers (notably the scan_repositories handling) to
handle and propagate/log the error and only set opts.CacheTTL when parsing
succeeds; this ensures invalid cacheTTL values are reported to the user rather
than silently falling back.
In `@testkit/java/src/main/java/io/gitfire/harness/CliBridge.java`:
- Around line 277-303: invokeRaw currently returns a String and parses JSON
internally, then invokeObject parses the same JSON again; change invokeRaw to
return the parsed JsonObject (update its signature from String invokeRaw(String)
to JsonObject invokeRaw(String)) and move the error checks to operate on that
JsonObject so invokeObject can call invokeRaw(GSON.toJson(request)) and return
the JsonObject directly without re-parsing; update any callers of invokeRaw (or
create a new helper like invokeRawJson and keep the old invokeRaw as a thin
wrapper) so compilation succeeds and avoid duplicate parsing in invokeObject and
invokeRaw.
In `@testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java`:
- Around line 80-82: Replace the manual IllegalStateException throw with a JUnit
assertion to get better test diagnostics: in SampleRepoFlowSmoke (the method
comparing localSha and remoteSha) remove the throw and use
assertEquals(remoteSha, localSha, "SHA mismatch") and add the static import for
assertEquals from org.junit.jupiter.api.Assertions; ensure the assertion message
includes both SHAs if desired for clarity.
- Around line 64-82: Replace the inline ProcessBuilder "git rev-parse" block
with the existing runGit helper: extend runGit (or add an overload) to return
stdout (or a small result object with stdout/stderr/exitCode) so callers can get
the command output and rely on its timeout/error handling, then call
runGit("rev-parse", branch) from SampleRepoFlowSmoke and compare the returned
trimmed stdout to bridge.getCommitSHA(local.toString(), branch); ensure runGit
still throws or signals on timeout/non-zero exit so the existing exception logic
stays consistent.
In `@testkit/python/pyproject.toml`:
- Around line 16-18: The current package discovery includes "samples*" which can
accidentally publish sample modules; update the [tool.setuptools.packages.find]
config so only production packages are discovered by either removing "samples*"
from the include list (leave include = ["git_harness*"]) or add an explicit
exclude = ["samples*"] entry to prevent packaging sample modules; target the
keys in the pyproject.toml section [tool.setuptools.packages.find] (the
where/include/exclude settings) to implement the change.
In `@testkit/python/samples/README.md`:
- Around line 14-18: Update the README snippet that shows running samples from
the repo root to document both sample scripts: add the smoke_safety_flow.py
invocation alongside smoke_repo_flow.py so the PYTHONPATH example lists "python3
testkit/python/samples/smoke_repo_flow.py" and "python3
testkit/python/samples/smoke_safety_flow.py"; edit the README entry in
testkit/python/samples/README.md to include the smoke_safety_flow.py command to
match the earlier section that documents both scripts.
In `@testkit/python/samples/smoke_safety_flow.py`:
- Around line 9-12: The current smoke test only checks that the substring "ghp_"
is removed which can pass if content is truncated; update the assertions around
client.safety_sanitize_text to (1) assert the full original token string is not
present in out and (2) assert that out contains the sanitizer's redaction marker
(e.g., "REDACTED" or "[REDACTED]") so you verify redaction rather than omission;
use the same client.safety_sanitize_text call and replace the simple "ghp_"
check with these two explicit assertions before calling
client.safety_security_notice().
In `@testkit/python/tests/test_cli_bridge.py`:
- Line 9: Import ScanOptions at the module level alongside GitHarnessClient
(i.e., add ScanOptions to the top-level import from git_harness) and remove the
inline "from git_harness import ScanOptions" at line 69 so all uses of
ScanOptions in test_cli_bridge.py reference the top-level import.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 205d8ddf-676f-44cd-a06b-10f86f65aae8
📒 Files selected for processing (32)
.github/workflows/ci.yml.gitignoreCURSOR_ULTRA_PLAN.mdREADME.mdcmd/git-harness-cli/main.gotestkit/.specify/memory/constitution.mdtestkit/.specify/scripts/validate_specify.shtestkit/.specify/specs/001-polyglot-harness/checklists/quality.mdtestkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.jsontestkit/.specify/specs/001-polyglot-harness/plan.mdtestkit/.specify/specs/001-polyglot-harness/spec.mdtestkit/.specify/specs/001-polyglot-harness/tasks.mdtestkit/GIT_HARNESS_SPEC.mdtestkit/README.mdtestkit/java/README.mdtestkit/java/pom.xmltestkit/java/src/main/java/io/gitfire/harness/CliBridge.javatestkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.javatestkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.javatestkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.javatestkit/python/README.mdtestkit/python/git_harness/__init__.pytestkit/python/git_harness/cli.pytestkit/python/pyproject.tomltestkit/python/samples/README.mdtestkit/python/samples/__init__.pytestkit/python/samples/smoke_repo_flow.pytestkit/python/samples/smoke_safety_flow.pytestkit/python/tests/conftest.pytestkit/python/tests/test_cli_bridge.pywrappers/README.mdwrappers/python/.gitkeep
|
@cursoragent address pr feedback |
|
Here is what was implemented from the CodeRabbit / PR feedback thread, all pushed on CI (
|
- CI: mkdir -p bin before building git-harness-cli in testkit job - CLI: return error on invalid scanOptions.cacheTTL; emit [] for nil branches - Contract: expand cli-protocol.json request/response fields - Java: exact path assert in CliBridgeTest; harden SampleSafetyFlowSmoke; SampleRepoFlowSmoke uses runGitStdout + JUnit assertEquals/assertTrue - Python: shutil.which(git) in conftest; top-level ScanOptions import; pyproject only packages git_harness; samples README + smoke_safety_flow Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Response
omitemptyomits primary result fields, crashing wrappers- Removed
omitemptyfrom thetextandwarningJSON tags so empty strings are always serialized for safety_sanitize_text and safety_format_warning.
- Removed
- ✅ Fixed: Python tests use symlink-resolving paths, fail on macOS
- Replaced
Path.resolve()withPath.absolute()in path assertions so expectations match Go’sfilepath.Abswithout resolving/varsymlinks.
- Replaced
Preview (89b0a19f6c)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,23 @@
contents: read
jobs:
+ spec-kit-conformance:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate spec-kit artifact set
+ run: |
+ test -f testkit/.specify/memory/constitution.md
+ test -f testkit/.specify/specs/001-polyglot-harness/spec.md
+ test -f testkit/.specify/specs/001-polyglot-harness/plan.md
+ test -f testkit/.specify/specs/001-polyglot-harness/tasks.md
+ test -f testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
+ test -f testkit/.specify/specs/001-polyglot-harness/checklists/quality.md
+
+ - name: Validate spec-kit scaffold and status
+ run: ./testkit/.specify/scripts/validate_specify.sh
+
test:
runs-on: ubuntu-latest
steps:
@@ -21,7 +38,7 @@
cache: true
- name: Check gofmt
- run: test -z "$(gofmt -l git safety)"
+ run: test -z "$(gofmt -l git safety cmd)"
- name: Build
run: go build ./...
@@ -31,3 +48,128 @@
- name: Test
run: go test -race -count=1 ./...
+
+ testkit:
+ runs-on: ubuntu-latest
+ needs: [spec-kit-conformance, test]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build git-harness CLI binary once
+ run: |
+ mkdir -p bin
+ go build -o ./bin/git-harness-cli ./cmd/git-harness-cli
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run Python wrapper tests
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/python
+ python -m pip install -e ".[dev]"
+ python -m pytest tests/ -v
+
+ - name: Run Python sample smoke implementations
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/python
+ python -m samples.smoke_repo_flow
+ python -m samples.smoke_safety_flow
+
+ - name: Run Java wrapper tests
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/java
+ mvn test
+
+ - name: Run Java sample smoke implementations
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/java
+ mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test
+
+ wrapper-cross-platform:
+ runs-on: ${{ matrix.os }}
+ needs: [spec-kit-conformance, test]
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build git-harness CLI binary once
+ shell: bash
+ run: |
+ mkdir -p bin
+ if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
+ go build -o ./bin/git-harness-cli.exe ./cmd/git-harness-cli
+ else
+ go build -o ./bin/git-harness-cli ./cmd/git-harness-cli
+ fi
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run Python wrapper smoke tests
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/python
+ python -m pip install -e ".[dev]"
+ python -m pytest tests/ -v
+
+ - name: Run Python sample smoke implementations
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/python
+ python -m samples.smoke_repo_flow
+ python -m samples.smoke_safety_flow
+
+ - name: Run Java wrapper smoke tests
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/java
+ mvn test
+
+ - name: Run Java sample smoke implementations
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/java
+ mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,8 @@
# Local reference clones (not part of this module)
/mnt/
+
+# testkit (polyglot) build artifacts
+testkit/python/**/__pycache__/
+testkit/python/**/*.egg-info/
+testkit/python/.pytest_cache/
+testkit/java/target/
diff --git a/CURSOR_ULTRA_PLAN.md b/CURSOR_ULTRA_PLAN.md
--- a/CURSOR_ULTRA_PLAN.md
+++ b/CURSOR_ULTRA_PLAN.md
@@ -113,11 +113,10 @@
.github/
workflows/
ci.yml # Go build + test + vet
- wrappers/ # placeholder dirs for Phase 5 & 6
+ testkit/ # polyglot layout (mirror git-testkit)
python/
- .gitkeep
java/
- .gitkeep
+ .specify/Local wiring (temporary): Add
replace github.com/git-fire/git-harness => ../git-harness
@@ -170,11 +169,11 @@
Follow git-testkit's Python wrapper structure exactly. Read it before writing anything here.
5.1 Scaffold
-Mirror whatever structure git-testkit uses under wrappers/python/. This likely means:
-- A Python package under wrappers/python/git_harness/
+Mirror whatever structure git-testkit uses under testkit/python/. This likely means:
+- A Python package under testkit/python/git_harness/
- Build tooling (cffi, ctypes, subprocess bridge, or whatever git-testkit uses)
pyproject.toml/setup.py
--wrappers/python/README.md
+-testkit/python/README.md
5.2 Implement
Expose the same surface area as the Go module — subprocess runner, safety/sanitize, repo introspection.
@@ -198,10 +197,10 @@
Follow git-testkit's Java wrapper structure exactly. Read it before writing anything here.
6.1 Scaffold
-Mirror whatever structure git-testkit uses under wrappers/java/. Likely:
-- Maven or Gradle project under wrappers/java/
+Mirror whatever structure git-testkit uses under testkit/java/. Likely:
+- Maven or Gradle project under testkit/java/
src/main/java/io/gitfire/harness/
--wrappers/java/README.md
+-testkit/java/README.md
6.2 Implement
Expose the same surface area as the Go module.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -9,6 +9,10 @@
git— repository scanning, status, commits, pushes, worktrees, and related helpers.safety— redaction and secret-pattern scanning helpers used by git error paths.
+## Polyglot wrappers
+
+Python and Java clients use the same layout as git-testkit under testkit/: build cmd/git-harness-cli, set GIT_HARNESS_CLI to that binary (or rely on go run ./cmd/git-harness-cli from the repo root). Code lives in testkit/python and testkit/java; runnable samples are testkit/python/samples/ and the Java Sample*Smoke tests.
+
Requirements
- Go 1.24+ (see
go.mod).
diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go
new file mode 100644
--- /dev/null
+++ b/cmd/git-harness-cli/main.go
@@ -1,0 +1,506 @@
+package main
+
+import (
- "encoding/json"
- "fmt"
- "os"
- "strings"
- "time"
- "github.com/git-fire/git-harness/git"
- "github.com/git-fire/git-harness/safety"
+)
+type request struct {
- Op string
json:"op" - // scan_repositories
- ScanOptions *scanOptionsInput
json:"scanOptions,omitempty" - // analyze_repository, git_* ops
- RepoPath string
json:"repoPath,omitempty" - // git_get_commit_sha, git_ref_is_ancestor
- Ref string
json:"ref,omitempty" - // git_ref_is_ancestor
- AncestorRef string
json:"ancestorRef,omitempty" - DescendantRef string
json:"descendantRef,omitempty" - Branch string
json:"branch,omitempty" - Remote string
json:"remote,omitempty" - OriginalBranch string
json:"originalBranch,omitempty" - LocalSHA string
json:"localSHA,omitempty" - Message string
json:"message,omitempty" - AddAll *bool
json:"addAll,omitempty" - UseDualBranch *bool
json:"useDualBranch,omitempty" - ReturnToOriginal *bool
json:"returnToOriginal,omitempty" - Args []string
json:"args,omitempty" - // safety
- Text string
json:"text,omitempty" - Files []string
json:"files,omitempty" - FilesSuspicious []suspiciousFileInput
json:"suspiciousFiles,omitempty"
+}
+type scanOptionsInput struct {
- RootPath string
json:"rootPath,omitempty" - Exclude []string
json:"exclude,omitempty" - MaxDepth int
json:"maxDepth,omitempty" - UseCache *bool
json:"useCache,omitempty" - CacheFile string
json:"cacheFile,omitempty" - CacheTTL string
json:"cacheTTL,omitempty" - Workers int
json:"workers,omitempty" - KnownPaths map[string]bool
json:"knownPaths,omitempty" - DisableScan bool
json:"disableScan,omitempty"
+}
+type suspiciousFileInput struct {
- Path string
json:"path" - Reason string
json:"reason" - Patterns []string
json:"patterns,omitempty" - LineNumbers []int
json:"lineNumbers,omitempty"
+}
+type response struct {
- OK bool
json:"ok" - Error string
json:"error,omitempty" - Repositories []repositoryOut
json:"repositories,omitempty" - Repository *repositoryOut
json:"repository,omitempty" - Dirty *bool
json:"dirty,omitempty" - Output *string
json:"output,omitempty" - SHA string
json:"sha,omitempty" - Branches []string
json:"branches,omitempty" - HasConflict *bool
json:"hasConflict,omitempty" - LocalSHA string
json:"localSHA,omitempty" - RemoteSHA string
json:"remoteSHA,omitempty" - IsAncestor *bool
json:"isAncestor,omitempty" - Branch string
json:"branch,omitempty" - Staged *bool
json:"staged,omitempty" - Unstaged *bool
json:"unstaged,omitempty" - Paths []string
json:"paths,omitempty" - Worktrees []worktreeOut
json:"worktrees,omitempty" - FireBranch string
json:"fireBranch,omitempty" - StagedBranch string
json:"stagedBranch,omitempty" - FullBranch string
json:"fullBranch,omitempty" - BothCreated *bool
json:"bothCreated,omitempty" - Text string
json:"text" - Lines []string
json:"lines,omitempty" - Warning string
json:"warning" - Notice string
json:"notice,omitempty" - SuspiciousFiles []suspiciousFileOutput
json:"suspiciousFiles,omitempty"
+}
+type suspiciousFileOutput struct {
- Path string
json:"path" - Reason string
json:"reason" - Patterns []string
json:"patterns" - LineNumbers []int
json:"lineNumbers"
+}
+type remoteOut struct {
- Name string
json:"name" - URL string
json:"url"
+}
+type repositoryOut struct {
- Path string
json:"path" - Name string
json:"name" - Remotes []remoteOut
json:"remotes" - Branches []string
json:"branches" - IsDirty bool
json:"isDirty" - LastModified time.Time
json:"lastModified" - Selected bool
json:"selected" - Mode string
json:"mode"
+}
+type worktreeOut struct {
- Path string
json:"path" - Branch string
json:"branch" - Head string
json:"head" - IsMain bool
json:"isMain"
+}
+func main() {
- req, err := parseRequest()
- if err != nil {
-
writeResponse(response{OK: false, Error: err.Error()}) -
os.Exit(1) - }
- res, err := handle(req)
- if err != nil {
-
writeResponse(response{OK: false, Error: err.Error()}) -
os.Exit(1) - }
- writeResponse(res)
+}
+func parseRequest() (request, error) {
- var req request
- if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
-
return request{}, fmt.Errorf("invalid JSON request: %w", err) - }
- if strings.TrimSpace(req.Op) == "" {
-
return request{}, fmt.Errorf("missing required field: op") - }
- return req, nil
+}
+func handle(req request) (response, error) {
- switch req.Op {
- case "scan_repositories":
-
opts, err := mergeScanOptions(req.ScanOptions) -
if err != nil { -
return response{}, err -
} -
repos, err := git.ScanRepositories(opts) -
if err != nil { -
return response{}, err -
} -
out := make([]repositoryOut, 0, len(repos)) -
for _, r := range repos { -
out = append(out, repoToOut(r)) -
} -
return response{OK: true, Repositories: out}, nil - case "analyze_repository":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
r, err := git.AnalyzeRepository(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
ro := repoToOut(r) -
return response{OK: true, Repository: &ro}, nil - case "git_is_dirty":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
d, err := git.IsDirty(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Dirty: &d}, nil - case "git_get_current_branch":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
b, err := git.GetCurrentBranch(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branch: b}, nil - case "git_get_commit_sha":
-
if req.RepoPath == "" || req.Ref == "" { -
return response{}, fmt.Errorf("missing repoPath or ref") -
} -
sha, err := git.GetCommitSHA(req.RepoPath, req.Ref) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, SHA: sha}, nil - case "git_list_local_branches":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
br, err := git.ListLocalBranches(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branches: br}, nil - case "git_list_remote_branches":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
br, err := git.ListRemoteBranches(req.RepoPath, req.Remote) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branches: br}, nil - case "git_ref_is_ancestor":
-
if req.RepoPath == "" || req.AncestorRef == "" || req.DescendantRef == "" { -
return response{}, fmt.Errorf("missing repoPath, ancestorRef, or descendantRef") -
} -
ok, err := git.RefIsAncestor(req.RepoPath, req.AncestorRef, req.DescendantRef) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, IsAncestor: &ok}, nil - case "git_detect_conflict":
-
if req.RepoPath == "" || req.Branch == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath, branch, or remote") -
} -
has, local, remote, err := git.DetectConflict(req.RepoPath, req.Branch, req.Remote) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, HasConflict: &has, LocalSHA: local, RemoteSHA: remote}, nil - case "git_has_staged_changes":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
v, err := git.HasStagedChanges(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Staged: &v}, nil - case "git_has_unstaged_changes":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
v, err := git.HasUnstagedChanges(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Unstaged: &v}, nil - case "git_get_uncommitted_files":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
paths, err := git.GetUncommittedFiles(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Paths: paths}, nil - case "git_list_worktrees":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
wts, err := git.ListWorktrees(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
out := make([]worktreeOut, 0, len(wts)) -
for _, w := range wts { -
out = append(out, worktreeOut{ -
Path: w.Path, -
Branch: w.Branch, -
Head: w.Head, -
IsMain: w.IsMain, -
}) -
} -
return response{OK: true, Worktrees: out}, nil - case "git_auto_commit_dirty":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
co := git.CommitOptions{Message: req.Message} -
if req.AddAll != nil { -
co.AddAll = *req.AddAll -
} -
if req.UseDualBranch != nil { -
co.UseDualBranch = *req.UseDualBranch -
} -
if req.ReturnToOriginal != nil { -
co.ReturnToOriginal = *req.ReturnToOriginal -
} -
if err := git.AutoCommitDirty(req.RepoPath, co); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_auto_commit_dirty_with_strategy":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
co := git.CommitOptions{Message: req.Message} -
if req.AddAll != nil { -
co.AddAll = *req.AddAll -
} -
if req.UseDualBranch != nil { -
co.UseDualBranch = *req.UseDualBranch -
} -
if req.ReturnToOriginal != nil { -
co.ReturnToOriginal = *req.ReturnToOriginal -
} -
res, err := git.AutoCommitDirtyWithStrategy(req.RepoPath, co) -
if err != nil { -
return response{}, err -
} -
bc := res.BothCreated -
return response{ -
OK: true, -
StagedBranch: res.StagedBranch, -
FullBranch: res.FullBranch, -
BothCreated: &bc, -
}, nil - case "git_create_fire_branch":
-
if req.RepoPath == "" || req.OriginalBranch == "" || req.LocalSHA == "" { -
return response{}, fmt.Errorf("missing repoPath, originalBranch, or localSHA") -
} -
name, err := git.CreateFireBranch(req.RepoPath, req.OriginalBranch, req.LocalSHA) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, FireBranch: name}, nil - case "git_fetch_remote":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
if err := git.FetchRemote(req.RepoPath, req.Remote); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_push_branch":
-
if req.RepoPath == "" || req.Remote == "" || req.Branch == "" { -
return response{}, fmt.Errorf("missing repoPath, remote, or branch") -
} -
if err := git.PushBranch(req.RepoPath, req.Remote, req.Branch); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_push_all_branches":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
if err := git.PushAllBranches(req.RepoPath, req.Remote); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "safety_sanitize_text":
-
return response{OK: true, Text: safety.SanitizeText(req.Text)}, nil - case "safety_recommended_gitignore_patterns":
-
p := safety.RecommendedGitignorePatterns() -
return response{OK: true, Lines: p}, nil - case "safety_security_notice":
-
return response{OK: true, Notice: safety.SecurityNotice()}, nil - case "safety_format_warning":
-
files := make([]safety.SuspiciousFile, 0, len(req.FilesSuspicious)) -
for _, f := range req.FilesSuspicious { -
files = append(files, safety.SuspiciousFile{ -
Path: f.Path, -
Reason: f.Reason, -
Patterns: f.Patterns, -
LineNumbers: f.LineNumbers, -
}) -
} -
return response{OK: true, Warning: safety.FormatWarning(files)}, nil - case "safety_scan_files":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
sc := safety.NewSecretScanner() -
found, err := sc.ScanFiles(req.RepoPath, req.Files) -
if err != nil { -
return response{}, err -
} -
out := make([]suspiciousFileOutput, 0, len(found)) -
for _, f := range found { -
patterns := f.Patterns -
if patterns == nil { -
patterns = []string{} -
} -
lines := f.LineNumbers -
if lines == nil { -
lines = []int{} -
} -
out = append(out, suspiciousFileOutput{ -
Path: f.Path, -
Reason: f.Reason, -
Patterns: patterns, -
LineNumbers: lines, -
}) -
} -
return response{OK: true, SuspiciousFiles: out}, nil - default:
-
return response{}, fmt.Errorf("unsupported op: %s", req.Op) - }
+}
+func mergeScanOptions(in *scanOptionsInput) (git.ScanOptions, error) {
- opts := git.DefaultScanOptions()
- if in == nil {
-
return opts, nil - }
- if in.RootPath != "" {
-
opts.RootPath = in.RootPath - }
- if in.Exclude != nil {
-
opts.Exclude = in.Exclude - }
- if in.MaxDepth > 0 {
-
opts.MaxDepth = in.MaxDepth - }
- if in.UseCache != nil {
-
opts.UseCache = *in.UseCache - }
- if in.CacheFile != "" {
-
opts.CacheFile = in.CacheFile - }
- if in.CacheTTL != "" {
-
d, err := time.ParseDuration(in.CacheTTL) -
if err != nil { -
return git.ScanOptions{}, fmt.Errorf("invalid scanOptions.cacheTTL %q: %w", in.CacheTTL, err) -
} -
opts.CacheTTL = d - }
- if in.Workers > 0 {
-
opts.Workers = in.Workers - }
- if in.KnownPaths != nil {
-
opts.KnownPaths = in.KnownPaths - }
- opts.DisableScan = in.DisableScan
- return opts, nil
+}
+func repoToOut(r git.Repository) repositoryOut {
- rem := make([]remoteOut, 0, len(r.Remotes))
- for _, x := range r.Remotes {
-
rem = append(rem, remoteOut{Name: x.Name, URL: x.URL}) - }
- branches := r.Branches
- if branches == nil {
-
branches = []string{} - }
- return repositoryOut{
-
Path: r.Path, -
Name: r.Name, -
Remotes: rem, -
Branches: branches, -
IsDirty: r.IsDirty, -
LastModified: r.LastModified, -
Selected: r.Selected, -
Mode: r.Mode.String(), - }
+}
+func writeResponse(res response) {
- enc := json.NewEncoder(os.Stdout)
- enc.SetEscapeHTML(false)
- if err := enc.Encode(res); err != nil {
-
fallback := response{ -
OK: false, -
Error: fmt.Sprintf("failed writing response: %s", err.Error()), -
} -
stderrEnc := json.NewEncoder(os.Stderr) -
stderrEnc.SetEscapeHTML(false) -
if encodeErr := stderrEnc.Encode(fallback); encodeErr != nil { -
fmt.Fprintf(os.Stderr, "failed writing fallback response: %v\n", encodeErr) -
} - }
+}
diff --git a/testkit/.specify/memory/constitution.md b/testkit/.specify/memory/constitution.md
new file mode 100644
--- /dev/null
+++ b/testkit/.specify/memory/constitution.md
@@ -1,0 +1,19 @@
+# git-harness testkit constitution
+
+## Core principles
+
+### I. Real git only
+
+Conformance tests run against the real git binary on PATH. Wrappers delegate to git-harness-cli, which shells out to git for repository operations.
+
+### II. Single behavior source
+
+The Go packages (git, safety) and cmd/git-harness-cli are the behavior source. Python and Java clients are thin bridges over the JSON protocol.
+
+### III. Deterministic and bounded
+
+Samples and tests use temporary directories under the OS temp root. No network remotes beyond local bare repos created on disk.
+
+### IV. Executable proof
+
+Every polyglot change keeps Go tests, Python pytest, and Java Maven smoke paths green in CI.
diff --git a/testkit/.specify/scripts/validate_specify.sh b/testkit/.specify/scripts/validate_specify.sh
new file mode 100644
--- /dev/null
+++ b/testkit/.specify/scripts/validate_specify.sh
@@ -1,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+SPEC_DIR="$ROOT_DIR/testkit/.specify/specs/001-polyglot-harness"
+
+required_files=(
- "$ROOT_DIR/testkit/.specify/memory/constitution.md"
- "$SPEC_DIR/spec.md"
- "$SPEC_DIR/plan.md"
- "$SPEC_DIR/tasks.md"
- "$SPEC_DIR/contracts/cli-protocol.json"
- "$SPEC_DIR/checklists/quality.md"
+)
+for file in "${required_files[@]}"; do
- if [[ ! -f "$file" ]]; then
... diff truncated: showing 800 of 2388 lines
</details>
<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-7be9b244-45fc-4ac6-a919-db0c04f37ccf">here</a>.</sub>
</details>
Remove omitempty from response text and warning so empty results still serialize keys expected by Python and Java wrappers. Use Path.absolute() in pytest path assertions to match filepath.Abs without symlink resolution (fixes macOS /var vs /private/var mismatches). Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
.github/workflows/ci.yml (1)
52-54: Avoid running the Linux wrapper flow twice.
testkitalready covers the full Linux Python+Java wrapper path, andwrapper-cross-platformrepeats the same sequence again onubuntu-latest. That doubles CI time and creates two step lists that can drift. I'd either dropubuntu-latestfrom the matrix or fold the dedicated Linux job into the matrix.Also applies to: 108-115
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.github/workflows/ci.yml around lines 52 - 54, The CI duplicates the Linux wrapper flow: the job testkit already runs the full Linux Python+Java wrapper on ubuntu-latest, and wrapper-cross-platform repeats it; update the workflow to avoid running the same ubuntu-latest path twice by either removing ubuntu-latest from the wrapper-cross-platform matrix or folding the Linux case into the existing testkit job (i.e., keep a single job that runs the Linux wrapper path and adjust the matrix or needs list so testkit and wrapper-cross-platform no longer both run ubuntu-latest).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java`:
- Around line 19-21: Replace the hard-coded Path.of("../..") in the local
workspaceRoot() with the shared workspace-root resolver used by
SampleSafetyFlowSmoke: remove the local implementation and call the shared
helper (the same workspace-root resolver referenced from SampleSafetyFlowSmoke)
so the tests no longer depend on the current working directory when locating
GIT_HARNESS_CLI and ./cmd/git-harness-cli.
---
Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 52-54: The CI duplicates the Linux wrapper flow: the job testkit
already runs the full Linux Python+Java wrapper on ubuntu-latest, and
wrapper-cross-platform repeats it; update the workflow to avoid running the same
ubuntu-latest path twice by either removing ubuntu-latest from the
wrapper-cross-platform matrix or folding the Linux case into the existing
testkit job (i.e., keep a single job that runs the Linux wrapper path and adjust
the matrix or needs list so testkit and wrapper-cross-platform no longer both
run ubuntu-latest).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f1c53a7b-00c8-49f7-97d6-4ec5283299af
📒 Files selected for processing (11)
.github/workflows/ci.ymlcmd/git-harness-cli/main.gotestkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.jsontestkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.javatestkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.javatestkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.javatestkit/python/pyproject.tomltestkit/python/samples/README.mdtestkit/python/samples/smoke_safety_flow.pytestkit/python/tests/conftest.pytestkit/python/tests/test_cli_bridge.py
✅ Files skipped from review due to trivial changes (7)
- testkit/python/samples/README.md
- testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java
- testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java
- testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
- testkit/python/tests/test_cli_bridge.py
- testkit/python/pyproject.toml
- cmd/git-harness-cli/main.go
🚧 Files skipped from review as they are similar to previous changes (2)
- testkit/python/samples/smoke_safety_flow.py
- testkit/python/tests/conftest.py
|
@cursoragent resolve issues |
|
@cursoragent also 🧹 Nitpick comments (1) |
|
Here is what was fixed and pushed ( 1. CI duplication (CodeRabbit)
2. Bugbot: empty
|
- CI: run wrapper-cross-platform only on macOS + Windows (Linux covered by testkit) - CLI: always emit text, warning, notice keys (no omitempty) for empty strings - Python: .get for text/notice/warning/fireBranch; realpath assertions for Go paths - Java: null-safe text/notice reads; SampleRepoFlowSmoke uses CliBridgeTest.workspaceRoot - Tests: empty sanitize + empty format_warning round-trips Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
cmd/git-harness-cli/main.go (1)
139-147: Reject unknown JSON keys at the protocol boundary.Line 141 uses the default decoder, so misspelled fields are silently ignored. In this bridge that turns client bugs like
cacheTtlvscacheTTLinto confusing default behavior instead of a clear contract error.DisallowUnknownFields()would make drift fail fast.Proposed fix
func parseRequest() (request, error) { var req request - if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil { + dec := json.NewDecoder(os.Stdin) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { return request{}, fmt.Errorf("invalid JSON request: %w", err) } if strings.TrimSpace(req.Op) == "" { return request{}, fmt.Errorf("missing required field: op") }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cmd/git-harness-cli/main.go` around lines 139 - 147, The JSON decoder in parseRequest currently ignores unknown keys, so update parseRequest to create a json.Decoder for os.Stdin, call decoder.DisallowUnknownFields() before decoding into the request struct, and propagate a clear error when decoding fails so misspelled or unexpected fields (e.g., client sending cacheTtl vs cacheTTL) are rejected at the protocol boundary; reference the parseRequest function and the request type when making the change and ensure the error returned includes the underlying decode error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@testkit/python/git_harness/cli.py`:
- Around line 30-45: The _call() function currently only handles
subprocess.TimeoutExpired; wrap the subprocess.run invocation in an except
OSError (or FileNotFoundError) block and rethrow a RuntimeError so startup
failures present a consistent client error. Specifically, in the _call()
function around the subprocess.run(...) call (which uses _cli_cmd(),
_repo_root(), and _CLI_TIMEOUT_SECONDS), catch OSError as exc and raise
RuntimeError(f"git-harness-cli failed to start (op={op}): {exc}") from exc so
callers always receive RuntimeError for both timeouts and process startup
failures.
In `@testkit/python/tests/test_cli_bridge.py`:
- Around line 25-31: The test creates a fresh git repo and makes commits without
configuring identity, which fails on CI; before any commits in
test_analyze_repository_finds_git_dir call _run_git to set local git config
user.name and user.email (e.g., "CI Test" and "ci@example.com") on the repo so
subsequent _run_git(..., "commit", ...) succeeds; apply the same local git
config setup in other tests in this file that commit (e.g., the test around line
77) to mirror the Java smoke test pattern.
---
Nitpick comments:
In `@cmd/git-harness-cli/main.go`:
- Around line 139-147: The JSON decoder in parseRequest currently ignores
unknown keys, so update parseRequest to create a json.Decoder for os.Stdin, call
decoder.DisallowUnknownFields() before decoding into the request struct, and
propagate a clear error when decoding fails so misspelled or unexpected fields
(e.g., client sending cacheTtl vs cacheTTL) are rejected at the protocol
boundary; reference the parseRequest function and the request type when making
the change and ensure the error returned includes the underlying decode error.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0a7e534f-6cec-4415-aa6f-cf38314bea6c
📒 Files selected for processing (6)
.github/workflows/ci.ymlcmd/git-harness-cli/main.gotestkit/java/src/main/java/io/gitfire/harness/CliBridge.javatestkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.javatestkit/python/git_harness/cli.pytestkit/python/tests/test_cli_bridge.py
🚧 Files skipped from review as they are similar to previous changes (1)
- .github/workflows/ci.yml
1526e3f to
077af2d
Compare
|
@cursoragent resolve feedback |
|
Here is what was implemented for the latest CodeRabbit / “resolve feedback” items and pushed as 1.
|
- parseRequest: DisallowUnknownFields on stdin decoder for fail-fast typos - git_harness._call: catch OSError from subprocess.run; include op in message - tests: local git user.* before commits; assert unknown keys return ok:false Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cmd/git-harness-cli/main.go`:
- Around line 139-149: The parser currently decodes only the first JSON value in
parseRequest() (using dec.Decode(&req)) and therefore accepts trailing JSON;
after successfully decoding req, attempt a second decode (e.g., var extra
json.RawMessage or interface{}; err := dec.Decode(&extra)) and require that it
returns io.EOF—if it returns nil or any non-EOF error, return an error like
"unexpected trailing data after JSON request". Update parseRequest() to perform
this extra check so trailing JSON is rejected.
In `@testkit/python/git_harness/cli.py`:
- Around line 18-25: _cli_cmd currently treats any non-absolute GIT_HARNESS_CLI
value as a repo-relative path which breaks when the user specifies a bare
executable name on PATH; change _cli_cmd to detect bare command names (no path
separators like os.sep or "/" and not containing os.pathsep) and call
shutil.which on that value to prefer a PATH lookup, falling back to
repo-relative resolution only if which returns None; keep existing behavior for
absolute paths or values containing separators (resolve relative to
_repo_root()), and continue returning the default ["go", "run",
"./cmd/git-harness-cli"] when GIT_HARNESS_CLI is empty.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 679d63ea-60ec-4696-93c4-33b466a04cd8
📒 Files selected for processing (3)
cmd/git-harness-cli/main.gotestkit/python/git_harness/cli.pytestkit/python/tests/test_cli_bridge.py
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Wrapper exposes no-op parameters for auto_commit_dirty
- Removed use_dual_branch and return_to_original from the Python wrapper and git_auto_commit_dirty handler, and return an explicit error if those JSON fields are sent so callers must use git_auto_commit_dirty_with_strategy.
- ✅ Fixed: Request struct
Argsfield is dead code- Deleted the unused Args field from the request struct and removed args from cli-protocol.json so the contract matches the implementation.
Preview (e7feb7ad7b)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,23 @@
contents: read
jobs:
+ spec-kit-conformance:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate spec-kit artifact set
+ run: |
+ test -f testkit/.specify/memory/constitution.md
+ test -f testkit/.specify/specs/001-polyglot-harness/spec.md
+ test -f testkit/.specify/specs/001-polyglot-harness/plan.md
+ test -f testkit/.specify/specs/001-polyglot-harness/tasks.md
+ test -f testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
+ test -f testkit/.specify/specs/001-polyglot-harness/checklists/quality.md
+
+ - name: Validate spec-kit scaffold and status
+ run: ./testkit/.specify/scripts/validate_specify.sh
+
test:
runs-on: ubuntu-latest
steps:
@@ -21,7 +38,7 @@
cache: true
- name: Check gofmt
- run: test -z "$(gofmt -l git safety)"
+ run: test -z "$(gofmt -l git safety cmd)"
- name: Build
run: go build ./...
@@ -31,3 +48,129 @@
- name: Test
run: go test -race -count=1 ./...
+
+ testkit:
+ runs-on: ubuntu-latest
+ needs: [spec-kit-conformance, test]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build git-harness CLI binary once
+ run: |
+ mkdir -p bin
+ go build -o ./bin/git-harness-cli ./cmd/git-harness-cli
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run Python wrapper tests
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/python
+ python -m pip install -e ".[dev]"
+ python -m pytest tests/ -v
+
+ - name: Run Python sample smoke implementations
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/python
+ python -m samples.smoke_repo_flow
+ python -m samples.smoke_safety_flow
+
+ - name: Run Java wrapper tests
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/java
+ mvn test
+
+ - name: Run Java sample smoke implementations
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/java
+ mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test
+
+ wrapper-cross-platform:
+ runs-on: ${{ matrix.os }}
+ needs: [spec-kit-conformance, test]
+ strategy:
+ fail-fast: false
+ matrix:
+ # Linux wrapper path is covered by the `testkit` job; matrix only cross-checks macOS + Windows.
+ os: [macos-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build git-harness CLI binary once
+ shell: bash
+ run: |
+ mkdir -p bin
+ if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
+ go build -o ./bin/git-harness-cli.exe ./cmd/git-harness-cli
+ else
+ go build -o ./bin/git-harness-cli ./cmd/git-harness-cli
+ fi
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run Python wrapper smoke tests
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/python
+ python -m pip install -e ".[dev]"
+ python -m pytest tests/ -v
+
+ - name: Run Python sample smoke implementations
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/python
+ python -m samples.smoke_repo_flow
+ python -m samples.smoke_safety_flow
+
+ - name: Run Java wrapper smoke tests
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/java
+ mvn test
+
+ - name: Run Java sample smoke implementations
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/java
+ mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,8 @@
# Local reference clones (not part of this module)
/mnt/
+
+# testkit (polyglot) build artifacts
+testkit/python/**/__pycache__/
+testkit/python/**/*.egg-info/
+testkit/python/.pytest_cache/
+testkit/java/target/
diff --git a/CURSOR_ULTRA_PLAN.md b/CURSOR_ULTRA_PLAN.md
--- a/CURSOR_ULTRA_PLAN.md
+++ b/CURSOR_ULTRA_PLAN.md
@@ -113,11 +113,10 @@
.github/
workflows/
ci.yml # Go build + test + vet
- wrappers/ # placeholder dirs for Phase 5 & 6
+ testkit/ # polyglot layout (mirror git-testkit)
python/
- .gitkeep
java/
- .gitkeep
+ .specify/Local wiring (temporary): Add
replace github.com/git-fire/git-harness => ../git-harness
@@ -170,11 +169,11 @@
Follow git-testkit's Python wrapper structure exactly. Read it before writing anything here.
5.1 Scaffold
-Mirror whatever structure git-testkit uses under wrappers/python/. This likely means:
-- A Python package under wrappers/python/git_harness/
+Mirror whatever structure git-testkit uses under testkit/python/. This likely means:
+- A Python package under testkit/python/git_harness/
- Build tooling (cffi, ctypes, subprocess bridge, or whatever git-testkit uses)
pyproject.toml/setup.py
--wrappers/python/README.md
+-testkit/python/README.md
5.2 Implement
Expose the same surface area as the Go module — subprocess runner, safety/sanitize, repo introspection.
@@ -198,10 +197,10 @@
Follow git-testkit's Java wrapper structure exactly. Read it before writing anything here.
6.1 Scaffold
-Mirror whatever structure git-testkit uses under wrappers/java/. Likely:
-- Maven or Gradle project under wrappers/java/
+Mirror whatever structure git-testkit uses under testkit/java/. Likely:
+- Maven or Gradle project under testkit/java/
src/main/java/io/gitfire/harness/
--wrappers/java/README.md
+-testkit/java/README.md
6.2 Implement
Expose the same surface area as the Go module.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -9,6 +9,10 @@
git— repository scanning, status, commits, pushes, worktrees, and related helpers.safety— redaction and secret-pattern scanning helpers used by git error paths.
+## Polyglot wrappers
+
+Python and Java clients use the same layout as git-testkit under testkit/: build cmd/git-harness-cli, set GIT_HARNESS_CLI to that binary (or rely on go run ./cmd/git-harness-cli from the repo root). Code lives in testkit/python and testkit/java; runnable samples are testkit/python/samples/ and the Java Sample*Smoke tests.
+
Requirements
- Go 1.24+ (see
go.mod).
diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go
new file mode 100644
--- /dev/null
+++ b/cmd/git-harness-cli/main.go
@@ -1,0 +1,504 @@
+package main
+
+import (
- "encoding/json"
- "fmt"
- "os"
- "strings"
- "time"
- "github.com/git-fire/git-harness/git"
- "github.com/git-fire/git-harness/safety"
+)
+type request struct {
- Op string
json:"op" - // scan_repositories
- ScanOptions *scanOptionsInput
json:"scanOptions,omitempty" - // analyze_repository, git_* ops
- RepoPath string
json:"repoPath,omitempty" - // git_get_commit_sha, git_ref_is_ancestor
- Ref string
json:"ref,omitempty" - // git_ref_is_ancestor
- AncestorRef string
json:"ancestorRef,omitempty" - DescendantRef string
json:"descendantRef,omitempty" - Branch string
json:"branch,omitempty" - Remote string
json:"remote,omitempty" - OriginalBranch string
json:"originalBranch,omitempty" - LocalSHA string
json:"localSHA,omitempty" - Message string
json:"message,omitempty" - AddAll *bool
json:"addAll,omitempty" - UseDualBranch *bool
json:"useDualBranch,omitempty" - ReturnToOriginal *bool
json:"returnToOriginal,omitempty" - // safety
- Text string
json:"text,omitempty" - Files []string
json:"files,omitempty" - FilesSuspicious []suspiciousFileInput
json:"suspiciousFiles,omitempty"
+}
+type scanOptionsInput struct {
- RootPath string
json:"rootPath,omitempty" - Exclude []string
json:"exclude,omitempty" - MaxDepth int
json:"maxDepth,omitempty" - UseCache *bool
json:"useCache,omitempty" - CacheFile string
json:"cacheFile,omitempty" - CacheTTL string
json:"cacheTTL,omitempty" - Workers int
json:"workers,omitempty" - KnownPaths map[string]bool
json:"knownPaths,omitempty" - DisableScan bool
json:"disableScan,omitempty"
+}
+type suspiciousFileInput struct {
- Path string
json:"path" - Reason string
json:"reason" - Patterns []string
json:"patterns,omitempty" - LineNumbers []int
json:"lineNumbers,omitempty"
+}
+type response struct {
- OK bool
json:"ok" - Error string
json:"error,omitempty" - Repositories []repositoryOut
json:"repositories,omitempty" - Repository *repositoryOut
json:"repository,omitempty" - Dirty *bool
json:"dirty,omitempty" - Output *string
json:"output,omitempty" - SHA string
json:"sha,omitempty" - Branches []string
json:"branches,omitempty" - HasConflict *bool
json:"hasConflict,omitempty" - LocalSHA string
json:"localSHA,omitempty" - RemoteSHA string
json:"remoteSHA,omitempty" - IsAncestor *bool
json:"isAncestor,omitempty" - Branch string
json:"branch,omitempty" - Staged *bool
json:"staged,omitempty" - Unstaged *bool
json:"unstaged,omitempty" - Paths []string
json:"paths,omitempty" - Worktrees []worktreeOut
json:"worktrees,omitempty" - FireBranch string
json:"fireBranch,omitempty" - StagedBranch string
json:"stagedBranch,omitempty" - FullBranch string
json:"fullBranch,omitempty" - BothCreated *bool
json:"bothCreated,omitempty" - Text string
json:"text,omitempty" - Lines []string
json:"lines,omitempty" - Warning string
json:"warning,omitempty" - Notice string
json:"notice,omitempty" - SuspiciousFiles []suspiciousFileOutput
json:"suspiciousFiles,omitempty"
+}
+type suspiciousFileOutput struct {
- Path string
json:"path" - Reason string
json:"reason" - Patterns []string
json:"patterns" - LineNumbers []int
json:"lineNumbers"
+}
+type remoteOut struct {
- Name string
json:"name" - URL string
json:"url"
+}
+type repositoryOut struct {
- Path string
json:"path" - Name string
json:"name" - Remotes []remoteOut
json:"remotes" - Branches []string
json:"branches" - IsDirty bool
json:"isDirty" - LastModified time.Time
json:"lastModified" - Selected bool
json:"selected" - Mode string
json:"mode"
+}
+type worktreeOut struct {
- Path string
json:"path" - Branch string
json:"branch" - Head string
json:"head" - IsMain bool
json:"isMain"
+}
+func main() {
- req, err := parseRequest()
- if err != nil {
-
writeResponse(response{OK: false, Error: err.Error()}) -
os.Exit(1) - }
- res, err := handle(req)
- if err != nil {
-
writeResponse(response{OK: false, Error: err.Error()}) -
os.Exit(1) - }
- writeResponse(res)
+}
+func parseRequest() (request, error) {
- var req request
- dec := json.NewDecoder(os.Stdin)
- dec.DisallowUnknownFields()
- if err := dec.Decode(&req); err != nil {
-
return request{}, fmt.Errorf("invalid JSON request: %w", err) - }
- if strings.TrimSpace(req.Op) == "" {
-
return request{}, fmt.Errorf("missing required field: op") - }
- return req, nil
+}
+func handle(req request) (response, error) {
- switch req.Op {
- case "scan_repositories":
-
opts, err := mergeScanOptions(req.ScanOptions) -
if err != nil { -
return response{}, err -
} -
repos, err := git.ScanRepositories(opts) -
if err != nil { -
return response{}, err -
} -
out := make([]repositoryOut, 0, len(repos)) -
for _, r := range repos { -
out = append(out, repoToOut(r)) -
} -
return response{OK: true, Repositories: out}, nil - case "analyze_repository":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
r, err := git.AnalyzeRepository(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
ro := repoToOut(r) -
return response{OK: true, Repository: &ro}, nil - case "git_is_dirty":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
d, err := git.IsDirty(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Dirty: &d}, nil - case "git_get_current_branch":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
b, err := git.GetCurrentBranch(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branch: b}, nil - case "git_get_commit_sha":
-
if req.RepoPath == "" || req.Ref == "" { -
return response{}, fmt.Errorf("missing repoPath or ref") -
} -
sha, err := git.GetCommitSHA(req.RepoPath, req.Ref) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, SHA: sha}, nil - case "git_list_local_branches":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
br, err := git.ListLocalBranches(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branches: br}, nil - case "git_list_remote_branches":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
br, err := git.ListRemoteBranches(req.RepoPath, req.Remote) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branches: br}, nil - case "git_ref_is_ancestor":
-
if req.RepoPath == "" || req.AncestorRef == "" || req.DescendantRef == "" { -
return response{}, fmt.Errorf("missing repoPath, ancestorRef, or descendantRef") -
} -
ok, err := git.RefIsAncestor(req.RepoPath, req.AncestorRef, req.DescendantRef) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, IsAncestor: &ok}, nil - case "git_detect_conflict":
-
if req.RepoPath == "" || req.Branch == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath, branch, or remote") -
} -
has, local, remote, err := git.DetectConflict(req.RepoPath, req.Branch, req.Remote) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, HasConflict: &has, LocalSHA: local, RemoteSHA: remote}, nil - case "git_has_staged_changes":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
v, err := git.HasStagedChanges(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Staged: &v}, nil - case "git_has_unstaged_changes":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
v, err := git.HasUnstagedChanges(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Unstaged: &v}, nil - case "git_get_uncommitted_files":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
paths, err := git.GetUncommittedFiles(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Paths: paths}, nil - case "git_list_worktrees":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
wts, err := git.ListWorktrees(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
out := make([]worktreeOut, 0, len(wts)) -
for _, w := range wts { -
out = append(out, worktreeOut{ -
Path: w.Path, -
Branch: w.Branch, -
Head: w.Head, -
IsMain: w.IsMain, -
}) -
} -
return response{OK: true, Worktrees: out}, nil - case "git_auto_commit_dirty":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
if req.UseDualBranch != nil || req.ReturnToOriginal != nil { -
return response{}, fmt.Errorf("git_auto_commit_dirty does not support useDualBranch or returnToOriginal; use git_auto_commit_dirty_with_strategy") -
} -
co := git.CommitOptions{Message: req.Message} -
if req.AddAll != nil { -
co.AddAll = *req.AddAll -
} -
if err := git.AutoCommitDirty(req.RepoPath, co); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_auto_commit_dirty_with_strategy":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
co := git.CommitOptions{Message: req.Message} -
if req.AddAll != nil { -
co.AddAll = *req.AddAll -
} -
if req.UseDualBranch != nil { -
co.UseDualBranch = *req.UseDualBranch -
} -
if req.ReturnToOriginal != nil { -
co.ReturnToOriginal = *req.ReturnToOriginal -
} -
res, err := git.AutoCommitDirtyWithStrategy(req.RepoPath, co) -
if err != nil { -
return response{}, err -
} -
bc := res.BothCreated -
return response{ -
OK: true, -
StagedBranch: res.StagedBranch, -
FullBranch: res.FullBranch, -
BothCreated: &bc, -
}, nil - case "git_create_fire_branch":
-
if req.RepoPath == "" || req.OriginalBranch == "" || req.LocalSHA == "" { -
return response{}, fmt.Errorf("missing repoPath, originalBranch, or localSHA") -
} -
name, err := git.CreateFireBranch(req.RepoPath, req.OriginalBranch, req.LocalSHA) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, FireBranch: name}, nil - case "git_fetch_remote":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
if err := git.FetchRemote(req.RepoPath, req.Remote); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_push_branch":
-
if req.RepoPath == "" || req.Remote == "" || req.Branch == "" { -
return response{}, fmt.Errorf("missing repoPath, remote, or branch") -
} -
if err := git.PushBranch(req.RepoPath, req.Remote, req.Branch); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_push_all_branches":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
if err := git.PushAllBranches(req.RepoPath, req.Remote); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "safety_sanitize_text":
-
return response{OK: true, Text: safety.SanitizeText(req.Text)}, nil - case "safety_recommended_gitignore_patterns":
-
p := safety.RecommendedGitignorePatterns() -
return response{OK: true, Lines: p}, nil - case "safety_security_notice":
-
return response{OK: true, Notice: safety.SecurityNotice()}, nil - case "safety_format_warning":
-
files := make([]safety.SuspiciousFile, 0, len(req.FilesSuspicious)) -
for _, f := range req.FilesSuspicious { -
files = append(files, safety.SuspiciousFile{ -
Path: f.Path, -
Reason: f.Reason, -
Patterns: f.Patterns, -
LineNumbers: f.LineNumbers, -
}) -
} -
return response{OK: true, Warning: safety.FormatWarning(files)}, nil - case "safety_scan_files":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
sc := safety.NewSecretScanner() -
found, err := sc.ScanFiles(req.RepoPath, req.Files) -
if err != nil { -
return response{}, err -
} -
out := make([]suspiciousFileOutput, 0, len(found)) -
for _, f := range found { -
patterns := f.Patterns -
if patterns == nil { -
patterns = []string{} -
} -
lines := f.LineNumbers -
if lines == nil { -
lines = []int{} -
} -
out = append(out, suspiciousFileOutput{ -
Path: f.Path, -
Reason: f.Reason, -
Patterns: patterns, -
LineNumbers: lines, -
}) -
} -
return response{OK: true, SuspiciousFiles: out}, nil - default:
-
return response{}, fmt.Errorf("unsupported op: %s", req.Op) - }
+}
+func mergeScanOptions(in *scanOptionsInput) (git.ScanOptions, error) {
- opts := git.DefaultScanOptions()
- if in == nil {
-
return opts, nil - }
- if in.RootPath != "" {
-
opts.RootPath = in.RootPath - }
- if in.Exclude != nil {
-
opts.Exclude = in.Exclude - }
- if in.MaxDepth > 0 {
-
opts.MaxDepth = in.MaxDepth - }
- if in.UseCache != nil {
-
opts.UseCache = *in.UseCache - }
- if in.CacheFile != "" {
-
opts.CacheFile = in.CacheFile - }
- if in.CacheTTL != "" {
-
d, err := time.ParseDuration(in.CacheTTL) -
if err != nil { -
return git.ScanOptions{}, fmt.Errorf("invalid scanOptions.cacheTTL %q: %w", in.CacheTTL, err) -
} -
opts.CacheTTL = d - }
- if in.Workers > 0 {
-
opts.Workers = in.Workers - }
- if in.KnownPaths != nil {
-
opts.KnownPaths = in.KnownPaths - }
- opts.DisableScan = in.DisableScan
- return opts, nil
+}
+func repoToOut(r git.Repository) repositoryOut {
- rem := make([]remoteOut, 0, len(r.Remotes))
- for _, x := range r.Remotes {
-
rem = append(rem, remoteOut{Name: x.Name, URL: x.URL}) - }
- branches := r.Branches
- if branches == nil {
-
branches = []string{} - }
- return repositoryOut{
-
Path: r.Path, -
Name: r.Name, -
Remotes: rem, -
Branches: branches, -
IsDirty: r.IsDirty, -
LastModified: r.LastModified, -
Selected: r.Selected, -
Mode: r.Mode.String(), - }
+}
+func writeResponse(res response) {
- enc := json.NewEncoder(os.Stdout)
- enc.SetEscapeHTML(false)
- if err := enc.Encode(res); err != nil {
-
fallback := response{ -
OK: false, -
Error: fmt.Sprintf("failed writing response: %s", err.Error()), -
} -
stderrEnc := json.NewEncoder(os.Stderr) -
stderrEnc.SetEscapeHTML(false) -
if encodeErr := stderrEnc.Encode(fallback); encodeErr != nil { -
fmt.Fprintf(os.Stderr, "failed writing fallback response: %v\n", encodeErr) -
} - }
+}
diff --git a/testkit/.specify/memory/constitution.md b/testkit/.specify/memory/constitution.md
new file mode 100644
--- /dev/null
+++ b/testkit/.specify/memory/constitution.md
@@ -1,0 +1,19 @@
+# git-harness testkit constitution
+
+## Core principles
+
+### I. Real git only
+
+Conformance tests run against the real git binary on PATH. Wrappers delegate to git-harness-cli, which shells out to git for repository operations.
+
+### II. Single behavior source
+
+The Go packages (git, safety) and cmd/git-harness-cli are the behavior source. Python and Java clients are thin bridges over the JSON protocol.
+
+### III. Deterministic and bounded
+
+Samples and tests use temporary directories under the OS temp root. No network remotes beyond local bare repos created on disk.
+
+### IV. Executable proof
+
+Every polyglot change keeps Go tests, Python pytest, and Java Maven smoke paths green in CI.
diff --git a/testkit/.specify/scripts/validate_specify.sh b/testkit/.specify/scripts/validate_specify.sh
new file mode 100644
--- /dev/null
+++ b/testkit/.specify/scripts/validate_specify.sh
@@ -1,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+SPEC_DIR="$ROOT_DIR/testkit/.specify/specs/001-polyglot-harness"
+
+required_files=(
- "$ROOT_DIR/testkit/.specify/memory/constitution.md"
- "$SPEC_DIR/spec.md"
- "$SPEC_DIR/plan.md"
- "$SPEC_DIR/tasks.md"
- "$SPEC_DIR/contracts/cli-protocol.json"
- "$SPEC_DIR/checklists/quality.md"
+)
+for file in "${required_files[@]}"; do
- if [[ ! -f "$file" ]]; then
- echo "missing required spec-kit artifact: $file" >&2
... diff truncated: showing 800 of 2420 lines
</details>
<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-ac0c3f5f-51eb-412b-8f78-b6cb376d7d40">here</a>.</sub>
</details>
Remove useDualBranch/returnToOriginal from git_auto_commit_dirty and the Python wrapper since AutoCommitDirty ignores them; reject those fields on the op so JSON callers get a clear error. Remove unused Args from the CLI request struct and cli-protocol contract. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cmd/git-harness-cli/main.go`:
- Around line 432-467: The code unconditionally assigns
scanOptionsInput.DisableScan (bare bool) into git.ScanOptions.DisableScan in
mergeScanOptions which breaks the tri-state pattern used by UseCache; change the
scanOptionsInput.DisableScan field to *bool and update mergeScanOptions to only
set opts.DisableScan = *in.DisableScan when in.DisableScan != nil (similar to
the UseCache branch), and then update any callers/constructors that populate
scanOptionsInput to use a pointer value (or helper functions) so the
missing/true/false semantics are preserved while keeping DefaultScanOptions()
behavior intact.
- Around line 123-136: The success path currently ignores failures from
writeResponse, so if JSON encoding or writing to stdout fails the process still
exits 0; change writeResponse to return an error (or bool) indicating whether
the primary stdout write/encode succeeded, keep the existing stderr fallback
behavior inside writeResponse but have it return the original encode/write error
when that occurs, then update main to check the returned error from
writeResponse(res) after handle(req) and call os.Exit(1) (or propagate the
error) when writeResponse reports a failure; refer to the writeResponse function
and main function to implement these changes.
In `@testkit/java/src/main/java/io/gitfire/harness/CliBridge.java`:
- Around line 173-183: The code in defaultCliCommandArgs currently treats any
non-absolute GIT_HARNESS_CLI value as a workspace-relative path; instead detect
whether configuredCli is a path-like value (contains a path separator '/' or
File.separator, or begins with "./" or "../") and only resolve those against
workspaceRoot; if it is a bare executable name (no separator) return
List.of(configuredCli) so ProcessBuilder can perform PATH lookup. Update
defaultCliCommandArgs to check configuredCli for separators (e.g.
configuredCli.contains("/") || configuredCli.contains(File.separator)) or
leading "./"/"../" before calling workspaceRoot.resolve(...) and keep the
existing absolute-path handling for Paths.get(configuredCli).
🪄 Autofix (Beta)
✅ Autofix completed
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6552570d-55fe-41e1-9ef4-025a0bb1f5db
📒 Files selected for processing (6)
CURSOR_ULTRA_PLAN.mdREADME.mdcmd/git-harness-cli/main.gotestkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.jsontestkit/java/src/main/java/io/gitfire/harness/CliBridge.javatestkit/python/git_harness/cli.py
✅ Files skipped from review due to trivial changes (3)
- README.md
- CURSOR_ULTRA_PLAN.md
- testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 2 file(s) based on 3 unresolved review comments. Files modified:
Commit: The changes have been pushed to the Time taken: |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Response struct
Outputfield is never populated- Removed the unused
Outputfield from theresponsestruct and theoutputproperty fromcli-protocol.jsonso the implementation matches the documented contract.
- Removed the unused
Preview (e5b3af1235)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,23 @@
contents: read
jobs:
+ spec-kit-conformance:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate spec-kit artifact set
+ run: |
+ test -f testkit/.specify/memory/constitution.md
+ test -f testkit/.specify/specs/001-polyglot-harness/spec.md
+ test -f testkit/.specify/specs/001-polyglot-harness/plan.md
+ test -f testkit/.specify/specs/001-polyglot-harness/tasks.md
+ test -f testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
+ test -f testkit/.specify/specs/001-polyglot-harness/checklists/quality.md
+
+ - name: Validate spec-kit scaffold and status
+ run: ./testkit/.specify/scripts/validate_specify.sh
+
test:
runs-on: ubuntu-latest
steps:
@@ -21,7 +38,7 @@
cache: true
- name: Check gofmt
- run: test -z "$(gofmt -l git safety)"
+ run: test -z "$(gofmt -l git safety cmd)"
- name: Build
run: go build ./...
@@ -31,3 +48,129 @@
- name: Test
run: go test -race -count=1 ./...
+
+ testkit:
+ runs-on: ubuntu-latest
+ needs: [spec-kit-conformance, test]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build git-harness CLI binary once
+ run: |
+ mkdir -p bin
+ go build -o ./bin/git-harness-cli ./cmd/git-harness-cli
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run Python wrapper tests
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/python
+ python -m pip install -e ".[dev]"
+ python -m pytest tests/ -v
+
+ - name: Run Python sample smoke implementations
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/python
+ python -m samples.smoke_repo_flow
+ python -m samples.smoke_safety_flow
+
+ - name: Run Java wrapper tests
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/java
+ mvn test
+
+ - name: Run Java sample smoke implementations
+ env:
+ GIT_HARNESS_CLI: ./bin/git-harness-cli
+ run: |
+ cd testkit/java
+ mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test
+
+ wrapper-cross-platform:
+ runs-on: ${{ matrix.os }}
+ needs: [spec-kit-conformance, test]
+ strategy:
+ fail-fast: false
+ matrix:
+ # Linux wrapper path is covered by the `testkit` job; matrix only cross-checks macOS + Windows.
+ os: [macos-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build git-harness CLI binary once
+ shell: bash
+ run: |
+ mkdir -p bin
+ if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
+ go build -o ./bin/git-harness-cli.exe ./cmd/git-harness-cli
+ else
+ go build -o ./bin/git-harness-cli ./cmd/git-harness-cli
+ fi
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run Python wrapper smoke tests
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/python
+ python -m pip install -e ".[dev]"
+ python -m pytest tests/ -v
+
+ - name: Run Python sample smoke implementations
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/python
+ python -m samples.smoke_repo_flow
+ python -m samples.smoke_safety_flow
+
+ - name: Run Java wrapper smoke tests
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/java
+ mvn test
+
+ - name: Run Java sample smoke implementations
+ shell: bash
+ env:
+ GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }}
+ run: |
+ cd testkit/java
+ mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,8 @@
# Local reference clones (not part of this module)
/mnt/
+
+# testkit (polyglot) build artifacts
+testkit/python/**/__pycache__/
+testkit/python/**/*.egg-info/
+testkit/python/.pytest_cache/
+testkit/java/target/
diff --git a/CURSOR_ULTRA_PLAN.md b/CURSOR_ULTRA_PLAN.md
--- a/CURSOR_ULTRA_PLAN.md
+++ b/CURSOR_ULTRA_PLAN.md
@@ -113,11 +113,10 @@
.github/
workflows/
ci.yml # Go build + test + vet
- wrappers/ # placeholder dirs for Phase 5 & 6
+ testkit/ # polyglot layout (mirror git-testkit)
python/
- .gitkeep
java/
- .gitkeep
+ .specify/Local wiring (temporary): Add
replace github.com/git-fire/git-harness => ../git-harness
@@ -170,11 +169,11 @@
Follow git-testkit's Python wrapper structure exactly. Read it before writing anything here.
5.1 Scaffold
-Mirror whatever structure git-testkit uses under wrappers/python/. This likely means:
-- A Python package under wrappers/python/git_harness/
+Mirror whatever structure git-testkit uses under testkit/python/. This likely means:
+- A Python package under testkit/python/git_harness/
- Build tooling (cffi, ctypes, subprocess bridge, or whatever git-testkit uses)
pyproject.toml/setup.py
--wrappers/python/README.md
+-testkit/python/README.md
5.2 Implement
Expose the same surface area as the Go module — subprocess runner, safety/sanitize, repo introspection.
@@ -198,10 +197,10 @@
Follow git-testkit's Java wrapper structure exactly. Read it before writing anything here.
6.1 Scaffold
-Mirror whatever structure git-testkit uses under wrappers/java/. Likely:
-- Maven or Gradle project under wrappers/java/
+Mirror whatever structure git-testkit uses under testkit/java/. Likely:
+- Maven or Gradle project under testkit/java/
src/main/java/io/gitfire/harness/
--wrappers/java/README.md
+-testkit/java/README.md
6.2 Implement
Expose the same surface area as the Go module.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -9,6 +9,10 @@
git— repository scanning, status, commits, pushes, worktrees, and related helpers.safety— redaction and secret-pattern scanning helpers used by git error paths.
+## Polyglot wrappers
+
+Python and Java clients use the same layout as git-testkit under testkit/: build cmd/git-harness-cli, set GIT_HARNESS_CLI to that binary (or rely on go run ./cmd/git-harness-cli from the repo root). Code lives in testkit/python and testkit/java; runnable samples are testkit/python/samples/ and the Java Sample*Smoke tests.
+
Requirements
- Go 1.24+ (see
go.mod).
diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go
new file mode 100644
--- /dev/null
+++ b/cmd/git-harness-cli/main.go
@@ -1,0 +1,509 @@
+package main
+
+import (
- "encoding/json"
- "fmt"
- "os"
- "strings"
- "time"
- "github.com/git-fire/git-harness/git"
- "github.com/git-fire/git-harness/safety"
+)
+type request struct {
- Op string
json:"op" - // scan_repositories
- ScanOptions *scanOptionsInput
json:"scanOptions,omitempty" - // analyze_repository, git_* ops
- RepoPath string
json:"repoPath,omitempty" - // git_get_commit_sha, git_ref_is_ancestor
- Ref string
json:"ref,omitempty" - // git_ref_is_ancestor
- AncestorRef string
json:"ancestorRef,omitempty" - DescendantRef string
json:"descendantRef,omitempty" - Branch string
json:"branch,omitempty" - Remote string
json:"remote,omitempty" - OriginalBranch string
json:"originalBranch,omitempty" - LocalSHA string
json:"localSHA,omitempty" - Message string
json:"message,omitempty" - AddAll *bool
json:"addAll,omitempty" - UseDualBranch *bool
json:"useDualBranch,omitempty" - ReturnToOriginal *bool
json:"returnToOriginal,omitempty" - // safety
- Text string
json:"text,omitempty" - Files []string
json:"files,omitempty" - FilesSuspicious []suspiciousFileInput
json:"suspiciousFiles,omitempty"
+}
+type scanOptionsInput struct {
- RootPath string
json:"rootPath,omitempty" - Exclude []string
json:"exclude,omitempty" - MaxDepth int
json:"maxDepth,omitempty" - UseCache *bool
json:"useCache,omitempty" - CacheFile string
json:"cacheFile,omitempty" - CacheTTL string
json:"cacheTTL,omitempty" - Workers int
json:"workers,omitempty" - KnownPaths map[string]bool
json:"knownPaths,omitempty" - DisableScan *bool
json:"disableScan,omitempty"
+}
+type suspiciousFileInput struct {
- Path string
json:"path" - Reason string
json:"reason" - Patterns []string
json:"patterns,omitempty" - LineNumbers []int
json:"lineNumbers,omitempty"
+}
+type response struct {
- OK bool
json:"ok" - Error string
json:"error,omitempty" - Repositories []repositoryOut
json:"repositories,omitempty" - Repository *repositoryOut
json:"repository,omitempty" - Dirty *bool
json:"dirty,omitempty" - SHA string
json:"sha,omitempty" - Branches []string
json:"branches,omitempty" - HasConflict *bool
json:"hasConflict,omitempty" - LocalSHA string
json:"localSHA,omitempty" - RemoteSHA string
json:"remoteSHA,omitempty" - IsAncestor *bool
json:"isAncestor,omitempty" - Branch string
json:"branch,omitempty" - Staged *bool
json:"staged,omitempty" - Unstaged *bool
json:"unstaged,omitempty" - Paths []string
json:"paths,omitempty" - Worktrees []worktreeOut
json:"worktrees,omitempty" - FireBranch string
json:"fireBranch,omitempty" - StagedBranch string
json:"stagedBranch,omitempty" - FullBranch string
json:"fullBranch,omitempty" - BothCreated *bool
json:"bothCreated,omitempty" - Text string
json:"text,omitempty" - Lines []string
json:"lines,omitempty" - Warning string
json:"warning,omitempty" - Notice string
json:"notice,omitempty" - SuspiciousFiles []suspiciousFileOutput
json:"suspiciousFiles,omitempty"
+}
+type suspiciousFileOutput struct {
- Path string
json:"path" - Reason string
json:"reason" - Patterns []string
json:"patterns" - LineNumbers []int
json:"lineNumbers"
+}
+type remoteOut struct {
- Name string
json:"name" - URL string
json:"url"
+}
+type repositoryOut struct {
- Path string
json:"path" - Name string
json:"name" - Remotes []remoteOut
json:"remotes" - Branches []string
json:"branches" - IsDirty bool
json:"isDirty" - LastModified time.Time
json:"lastModified" - Selected bool
json:"selected" - Mode string
json:"mode"
+}
+type worktreeOut struct {
- Path string
json:"path" - Branch string
json:"branch" - Head string
json:"head" - IsMain bool
json:"isMain"
+}
+func main() {
- req, err := parseRequest()
- if err != nil {
-
writeResponse(response{OK: false, Error: err.Error()}) -
os.Exit(1) - }
- res, err := handle(req)
- if err != nil {
-
writeResponse(response{OK: false, Error: err.Error()}) -
os.Exit(1) - }
- if err := writeResponse(res); err != nil {
-
os.Exit(1) - }
+}
+func parseRequest() (request, error) {
- var req request
- dec := json.NewDecoder(os.Stdin)
- dec.DisallowUnknownFields()
- if err := dec.Decode(&req); err != nil {
-
return request{}, fmt.Errorf("invalid JSON request: %w", err) - }
- if strings.TrimSpace(req.Op) == "" {
-
return request{}, fmt.Errorf("missing required field: op") - }
- return req, nil
+}
+func handle(req request) (response, error) {
- switch req.Op {
- case "scan_repositories":
-
opts, err := mergeScanOptions(req.ScanOptions) -
if err != nil { -
return response{}, err -
} -
repos, err := git.ScanRepositories(opts) -
if err != nil { -
return response{}, err -
} -
out := make([]repositoryOut, 0, len(repos)) -
for _, r := range repos { -
out = append(out, repoToOut(r)) -
} -
return response{OK: true, Repositories: out}, nil - case "analyze_repository":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
r, err := git.AnalyzeRepository(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
ro := repoToOut(r) -
return response{OK: true, Repository: &ro}, nil - case "git_is_dirty":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
d, err := git.IsDirty(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Dirty: &d}, nil - case "git_get_current_branch":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
b, err := git.GetCurrentBranch(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branch: b}, nil - case "git_get_commit_sha":
-
if req.RepoPath == "" || req.Ref == "" { -
return response{}, fmt.Errorf("missing repoPath or ref") -
} -
sha, err := git.GetCommitSHA(req.RepoPath, req.Ref) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, SHA: sha}, nil - case "git_list_local_branches":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
br, err := git.ListLocalBranches(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branches: br}, nil - case "git_list_remote_branches":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
br, err := git.ListRemoteBranches(req.RepoPath, req.Remote) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Branches: br}, nil - case "git_ref_is_ancestor":
-
if req.RepoPath == "" || req.AncestorRef == "" || req.DescendantRef == "" { -
return response{}, fmt.Errorf("missing repoPath, ancestorRef, or descendantRef") -
} -
ok, err := git.RefIsAncestor(req.RepoPath, req.AncestorRef, req.DescendantRef) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, IsAncestor: &ok}, nil - case "git_detect_conflict":
-
if req.RepoPath == "" || req.Branch == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath, branch, or remote") -
} -
has, local, remote, err := git.DetectConflict(req.RepoPath, req.Branch, req.Remote) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, HasConflict: &has, LocalSHA: local, RemoteSHA: remote}, nil - case "git_has_staged_changes":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
v, err := git.HasStagedChanges(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Staged: &v}, nil - case "git_has_unstaged_changes":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
v, err := git.HasUnstagedChanges(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Unstaged: &v}, nil - case "git_get_uncommitted_files":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
paths, err := git.GetUncommittedFiles(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, Paths: paths}, nil - case "git_list_worktrees":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
wts, err := git.ListWorktrees(req.RepoPath) -
if err != nil { -
return response{}, err -
} -
out := make([]worktreeOut, 0, len(wts)) -
for _, w := range wts { -
out = append(out, worktreeOut{ -
Path: w.Path, -
Branch: w.Branch, -
Head: w.Head, -
IsMain: w.IsMain, -
}) -
} -
return response{OK: true, Worktrees: out}, nil - case "git_auto_commit_dirty":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
if req.UseDualBranch != nil || req.ReturnToOriginal != nil { -
return response{}, fmt.Errorf("git_auto_commit_dirty does not support useDualBranch or returnToOriginal; use git_auto_commit_dirty_with_strategy") -
} -
co := git.CommitOptions{Message: req.Message} -
if req.AddAll != nil { -
co.AddAll = *req.AddAll -
} -
if err := git.AutoCommitDirty(req.RepoPath, co); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_auto_commit_dirty_with_strategy":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
co := git.CommitOptions{Message: req.Message} -
if req.AddAll != nil { -
co.AddAll = *req.AddAll -
} -
if req.UseDualBranch != nil { -
co.UseDualBranch = *req.UseDualBranch -
} -
if req.ReturnToOriginal != nil { -
co.ReturnToOriginal = *req.ReturnToOriginal -
} -
res, err := git.AutoCommitDirtyWithStrategy(req.RepoPath, co) -
if err != nil { -
return response{}, err -
} -
bc := res.BothCreated -
return response{ -
OK: true, -
StagedBranch: res.StagedBranch, -
FullBranch: res.FullBranch, -
BothCreated: &bc, -
}, nil - case "git_create_fire_branch":
-
if req.RepoPath == "" || req.OriginalBranch == "" || req.LocalSHA == "" { -
return response{}, fmt.Errorf("missing repoPath, originalBranch, or localSHA") -
} -
name, err := git.CreateFireBranch(req.RepoPath, req.OriginalBranch, req.LocalSHA) -
if err != nil { -
return response{}, err -
} -
return response{OK: true, FireBranch: name}, nil - case "git_fetch_remote":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
if err := git.FetchRemote(req.RepoPath, req.Remote); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_push_branch":
-
if req.RepoPath == "" || req.Remote == "" || req.Branch == "" { -
return response{}, fmt.Errorf("missing repoPath, remote, or branch") -
} -
if err := git.PushBranch(req.RepoPath, req.Remote, req.Branch); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "git_push_all_branches":
-
if req.RepoPath == "" || req.Remote == "" { -
return response{}, fmt.Errorf("missing repoPath or remote") -
} -
if err := git.PushAllBranches(req.RepoPath, req.Remote); err != nil { -
return response{}, err -
} -
return response{OK: true}, nil - case "safety_sanitize_text":
-
return response{OK: true, Text: safety.SanitizeText(req.Text)}, nil - case "safety_recommended_gitignore_patterns":
-
p := safety.RecommendedGitignorePatterns() -
return response{OK: true, Lines: p}, nil - case "safety_security_notice":
-
return response{OK: true, Notice: safety.SecurityNotice()}, nil - case "safety_format_warning":
-
files := make([]safety.SuspiciousFile, 0, len(req.FilesSuspicious)) -
for _, f := range req.FilesSuspicious { -
files = append(files, safety.SuspiciousFile{ -
Path: f.Path, -
Reason: f.Reason, -
Patterns: f.Patterns, -
LineNumbers: f.LineNumbers, -
}) -
} -
return response{OK: true, Warning: safety.FormatWarning(files)}, nil - case "safety_scan_files":
-
if req.RepoPath == "" { -
return response{}, fmt.Errorf("missing repoPath") -
} -
sc := safety.NewSecretScanner() -
found, err := sc.ScanFiles(req.RepoPath, req.Files) -
if err != nil { -
return response{}, err -
} -
out := make([]suspiciousFileOutput, 0, len(found)) -
for _, f := range found { -
patterns := f.Patterns -
if patterns == nil { -
patterns = []string{} -
} -
lines := f.LineNumbers -
if lines == nil { -
lines = []int{} -
} -
out = append(out, suspiciousFileOutput{ -
Path: f.Path, -
Reason: f.Reason, -
Patterns: patterns, -
LineNumbers: lines, -
}) -
} -
return response{OK: true, SuspiciousFiles: out}, nil - default:
-
return response{}, fmt.Errorf("unsupported op: %s", req.Op) - }
+}
+func mergeScanOptions(in *scanOptionsInput) (git.ScanOptions, error) {
- opts := git.DefaultScanOptions()
- if in == nil {
-
return opts, nil - }
- if in.RootPath != "" {
-
opts.RootPath = in.RootPath - }
- if in.Exclude != nil {
-
opts.Exclude = in.Exclude - }
- if in.MaxDepth > 0 {
-
opts.MaxDepth = in.MaxDepth - }
- if in.UseCache != nil {
-
opts.UseCache = *in.UseCache - }
- if in.CacheFile != "" {
-
opts.CacheFile = in.CacheFile - }
- if in.CacheTTL != "" {
-
d, err := time.ParseDuration(in.CacheTTL) -
if err != nil { -
return git.ScanOptions{}, fmt.Errorf("invalid scanOptions.cacheTTL %q: %w", in.CacheTTL, err) -
} -
opts.CacheTTL = d - }
- if in.Workers > 0 {
-
opts.Workers = in.Workers - }
- if in.KnownPaths != nil {
-
opts.KnownPaths = in.KnownPaths - }
- if in.DisableScan != nil {
-
opts.DisableScan = *in.DisableScan - }
- return opts, nil
+}
+func repoToOut(r git.Repository) repositoryOut {
- rem := make([]remoteOut, 0, len(r.Remotes))
- for _, x := range r.Remotes {
-
rem = append(rem, remoteOut{Name: x.Name, URL: x.URL}) - }
- branches := r.Branches
- if branches == nil {
-
branches = []string{} - }
- return repositoryOut{
-
Path: r.Path, -
Name: r.Name, -
Remotes: rem, -
Branches: branches, -
IsDirty: r.IsDirty, -
LastModified: r.LastModified, -
Selected: r.Selected, -
Mode: r.Mode.String(), - }
+}
+func writeResponse(res response) error {
- enc := json.NewEncoder(os.Stdout)
- enc.SetEscapeHTML(false)
- if err := enc.Encode(res); err != nil {
-
fallback := response{ -
OK: false, -
Error: fmt.Sprintf("failed writing response: %s", err.Error()), -
} -
stderrEnc := json.NewEncoder(os.Stderr) -
stderrEnc.SetEscapeHTML(false) -
if encodeErr := stderrEnc.Encode(fallback); encodeErr != nil { -
fmt.Fprintf(os.Stderr, "failed writing fallback response: %v\n", encodeErr) -
} -
return err - }
- return nil
+}
\ No newline at end of file
diff --git a/testkit/.specify/memory/constitution.md b/testkit/.specify/memory/constitution.md
new file mode 100644
--- /dev/null
+++ b/testkit/.specify/memory/constitution.md
@@ -1,0 +1,19 @@
+# git-harness testkit constitution
+
+## Core principles
+
+### I. Real git only
+
+Conformance tests run against the real git binary on PATH. Wrappers delegate to git-harness-cli, which shells out to git for repository operations.
+
+### II. Single behavior source
+
+The Go packages (git, safety) and cmd/git-harness-cli are the behavior source. Python and Java clients are thin bridges over the JSON protocol.
+
+### III. Deterministic and bounded
+
+Samples and tests use temporary directories under the OS temp root. No network remotes beyond local bare repos created on disk.
+
+### IV. Executable proof
+
+Every polyglot change keeps Go tests, Python pytest, and Java Maven smoke paths green in CI.
diff --git a/testkit/.specify/scripts/validate_specify.sh b/testkit/.specify/scripts/validate_specify.sh
new file mode 100644
--- /dev/null
+++ b/testkit/.specify/scripts/validate_specify.sh
@@ -1,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+SPEC_DIR="$ROOT_DIR/testkit/.specify/specs/001-polyglot-harness"
+
+required_files=(
- "$ROOT_DIR/testkit/.specify/memory/constitution.md"
- "$SPEC_DIR/spec.md"
- "$SPEC_DIR/plan.md"
- "$SPEC_DIR/tasks.md"
- "$SPEC_DIR/contracts/cli-protocol.json"
... diff truncated: showing 800 of 2436 lines
</details>
<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-1223bb0e-030f-4ce4-bc48-e59406ded1bb">here</a>.</sub>
<!-- BUGBOT_AUTOFIX_REVIEW_FOOTNOTE_END -->
<sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 603f98b0f1f1df697428981372b2abec790ffcf8. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
Fixed 2 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
parseRequest now fails if stdin contains more than one JSON value after whitespace, matching the single-request protocol. Python _cli_cmd treats bare executable names like the Java client: try shutil.which before falling back to repo-relative paths. Add regression tests for trailing JSON and PATH lookup. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>



Follow-up (latest)
parseRequestnow rejects multiple JSON values or trailing garbage after the first object (single-request protocol).GIT_HARNESS_CLI: Bare names (e.g.git-harness-cli) are resolved withshutil.whichbefore falling back to a path under the repo root, matching JavaProcessBuilderPATH behavior._cli_cmdresolution.All inline review threads on this PR are resolved as of the latest push.
Summary by CodeRabbit
New Features
Documentation
Tests
Chores