Skip to content

Add JSON CLI bridge and Python/Java wrappers#4

Merged
bschellenberger2600 merged 15 commits intomainfrom
cursor/python-java-api-a92e
Apr 19, 2026
Merged

Add JSON CLI bridge and Python/Java wrappers#4
bschellenberger2600 merged 15 commits intomainfrom
cursor/python-java-api-a92e

Conversation

@bschellenberger2600
Copy link
Copy Markdown
Member

@bschellenberger2600 bschellenberger2600 commented Apr 15, 2026

Follow-up (latest)

  • Strict stdin JSON: parseRequest now rejects multiple JSON values or trailing garbage after the first object (single-request protocol).
  • Python GIT_HARNESS_CLI: Bare names (e.g. git-harness-cli) are resolved with shutil.which before falling back to a path under the repo root, matching Java ProcessBuilder PATH behavior.
  • Tests: Added coverage for trailing JSON rejection and PATH-based _cli_cmd resolution.

All inline review threads on this PR are resolved as of the latest push.

Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • JSON CLI bridge plus Python and Java client libraries with runnable smoke samples for both languages; CLI can be prebuilt and configured via an environment variable.
  • Documentation

    • New polyglot spec, plan, checklist, and testkit READMEs describing layout, usage, validation, and quality criteria.
  • Tests

    • Added Python pytest and Java JUnit smoke/integration tests exercising end-to-end bridge behavior.
  • Chores

    • CI updated to validate spec-kit artifacts, build the CLI once, and run wrapper tests across Linux/macOS/Windows; updated ignore rules.

cursoragent and others added 4 commits April 15, 2026 02:32
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>
@bschellenberger2600 bschellenberger2600 marked this pull request as ready for review April 15, 2026 04:58
@bschellenberger2600
Copy link
Copy Markdown
Member Author

@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>
@bschellenberger2600
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a JSON-over-stdin/stdout Go CLI bridge (cmd/git-harness-cli), thin Python and Java wrappers under testkit/, spec-kit artifacts and validation scripts, smoke/unit tests and samples, README/plan updates, .gitignore entries, and CI jobs to validate the spec-kit and run cross-platform wrapper tests.

Changes

Cohort / File(s) Summary
CLI Bridge
cmd/git-harness-cli/main.go
New Go CLI entrypoint implementing strict JSON request/response dispatch, operation routing for repo scanning/analysis, many git actions (status, branches, commits, fetch/push, worktrees, auto-commit/fire-branch) and safety utilities (sanitize, notices, warnings, file scans).
Python Wrapper & Tests
testkit/python/git_harness/cli.py, testkit/python/git_harness/__init__.py, testkit/python/pyproject.toml, testkit/python/tests/*, testkit/python/samples/*, testkit/python/README.md
New subprocess JSON client (GitHarnessClient) with ScanOptions, 120s timeout, pytest autouse fixture, tests exercising repo/safety flows, and runnable sample scripts.
Java Wrapper & Tests
testkit/java/src/main/java/.../CliBridge.java, testkit/java/pom.xml, testkit/java/src/test/java/.../*.java, testkit/java/README.md
New CliBridge using ProcessBuilder with concurrent stdout/stderr capture and 120s timeout; public record types and ScanOptions builder API; Maven project and JUnit smoke/unit tests including Sample*Smoke flows.
Spec-kit / Testkit Docs & Validation
testkit/.specify/**, testkit/GIT_HARNESS_SPEC.md, testkit/README.md, README.md, CURSOR_ULTRA_PLAN.md
Added spec, plan, tasks, constitution, quality checklist, CLI contract (cli-protocol.json), and testkit/.specify/scripts/validate_specify.sh; documentation describing testkit/ layout and CI validation expectations.
CI & Misc Config
.github/workflows/ci.yml, .gitignore, wrappers/README.md
CI: new spec-kit-conformance, testkit, and wrapper-cross-platform jobs; extended gofmt gate to include cmd; .gitignore excludes Python/Java test artifacts; wrappers/README.md redirected to testkit/.
Tests / Smoke Coverage
testkit/java/src/test/java/..., testkit/python/tests/*, testkit/python/samples/*
End-to-end smoke tests and unit tests that exercise CLI contract, repo scan/discovery, dirty state detection, safety sanitization, and sample repo/safety flows across Go/Python/Java paths.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐇 I tapped a tiny JSON key,

Built bridges for Java and Py with glee,
Go whispers to git while safety hides the trace,
CI lines hum and tests run apace—
A rabbit hops: "All green? Let's see!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add JSON CLI bridge and Python/Java wrappers' directly and accurately summarizes the main change—introducing a new CLI bridge component and polyglot client wrappers for Python and Java.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/python-java-api-a92e

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (8)
testkit/python/samples/README.md (1)

14-18: Document both smoke scripts in the PYTHONPATH flow.

This section introduces repo-root execution for samples, but only includes smoke_repo_flow.py. Adding smoke_safety_flow.py keeps 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.md around 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.toml around 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 assertEquals or assertTrue would 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 reusing runGit helper for the rev-parse call.

The inline ProcessBuilder logic for git rev-parse duplicates the timeout/error handling already in runGit. You could extend runGit to 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 invalid cacheTTL values.

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 mergeScanOptions to return (git.ScanOptions, error) and handling it in the scan_repositories case.

🤖 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: Move ScanOptions import to the module level.

ScanOptions is imported inline at line 69, but it's already available via git_harness. Consider importing it at the top alongside GitHarnessClient for consistency.

💡 Suggested fix
-from git_harness import GitHarnessClient
+from git_harness import GitHarnessClient, ScanOptions

Then 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 (in invokeRaw and invokeObject).

invokeRaw parses JSON at line 297 to check the ok flag, then invokeObject parses the same string again at line 279. This is a minor inefficiency that could be avoided by returning the parsed JsonObject from invokeRaw.

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between 377c314 and bf4a679.

📒 Files selected for processing (32)
  • .github/workflows/ci.yml
  • .gitignore
  • CURSOR_ULTRA_PLAN.md
  • README.md
  • cmd/git-harness-cli/main.go
  • testkit/.specify/memory/constitution.md
  • testkit/.specify/scripts/validate_specify.sh
  • testkit/.specify/specs/001-polyglot-harness/checklists/quality.md
  • testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
  • testkit/.specify/specs/001-polyglot-harness/plan.md
  • testkit/.specify/specs/001-polyglot-harness/spec.md
  • testkit/.specify/specs/001-polyglot-harness/tasks.md
  • testkit/GIT_HARNESS_SPEC.md
  • testkit/README.md
  • testkit/java/README.md
  • testkit/java/pom.xml
  • testkit/java/src/main/java/io/gitfire/harness/CliBridge.java
  • testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java
  • testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java
  • testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java
  • testkit/python/README.md
  • testkit/python/git_harness/__init__.py
  • testkit/python/git_harness/cli.py
  • testkit/python/pyproject.toml
  • testkit/python/samples/README.md
  • testkit/python/samples/__init__.py
  • testkit/python/samples/smoke_repo_flow.py
  • testkit/python/samples/smoke_safety_flow.py
  • testkit/python/tests/conftest.py
  • testkit/python/tests/test_cli_bridge.py
  • wrappers/README.md
  • wrappers/python/.gitkeep

Comment thread .github/workflows/ci.yml Outdated
Comment thread cmd/git-harness-cli/main.go
Comment thread testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java
Comment thread testkit/python/tests/conftest.py
@bschellenberger2600
Copy link
Copy Markdown
Member Author

@cursoragent address pr feedback

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 16, 2026

Here is what was implemented from the CodeRabbit / PR feedback thread, all pushed on cursor/python-java-api-a92e as 77ab935.

CI (.github/workflows/ci.yml)

  • testkit job: run mkdir -p bin before go build -o ./bin/git-harness-cli ... so clean checkouts do not fail when ./bin is missing.

Go CLI (cmd/git-harness-cli/main.go)

  • scanOptions.cacheTTL: invalid time.ParseDuration values now return a clear error instead of silently using the default (mergeScanOptions returns (git.ScanOptions, error)).
  • repoToOut: if Branches is nil, JSON now gets [] instead of null.

Contract (testkit/.specify/.../cli-protocol.json)

  • request_schema / response_schema extended with the fields the bridge actually uses (e.g. ancestorRef, suspiciousFiles, branch, hasConflict, worktrees, warning, lines, …).

Java tests

  • CliBridgeTest: assertEquals on normalized absolute repo path vs meta.path().
  • SampleSafetyFlowSmoke: assert !out.contains(token) and [REDACTED] present.
  • SampleRepoFlowSmoke: runGitStdout helper (same timeout/error handling as runGit), assertEquals for SHAs, assertTrue for scan hit (no manual IllegalStateException).

Python / packaging

  • conftest.py: resolve git with shutil.which("git") and pytest.fail if missing (addresses Ruff S607).
  • test_cli_bridge.py: ScanOptions imported at the top with GitHarnessClient.
  • pyproject.toml: include = ["git_harness*"] only (samples are not installable package modules).
  • samples/README.md: second line for smoke_safety_flow.py under PYTHONPATH.
  • smoke_safety_flow.py: assert full token absent and [REDACTED] present.

PR #4 description was refreshed. CI should be greener; if anything still fails, paste the job log snippet.

Open in Web Open in Cursor 

- 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>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 omitempty omits primary result fields, crashing wrappers
    • Removed omitempty from the text and warning JSON tags so empty strings are always serialized for safety_sanitize_text and safety_format_warning.
  • ✅ Fixed: Python tests use symlink-resolving paths, fail on macOS
    • Replaced Path.resolve() with Path.absolute() in path assertions so expectations match Go’s filepath.Abs without resolving /var symlinks.
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>

Comment thread cmd/git-harness-cli/main.go
Comment thread testkit/python/tests/test_cli_bridge.py Outdated
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>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
.github/workflows/ci.yml (1)

52-54: Avoid running the Linux wrapper flow twice.

testkit already covers the full Linux Python+Java wrapper path, and wrapper-cross-platform repeats the same sequence again on ubuntu-latest. That doubles CI time and creates two step lists that can drift. I'd either drop ubuntu-latest from 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

📥 Commits

Reviewing files that changed from the base of the PR and between bf4a679 and 89b0a19.

📒 Files selected for processing (11)
  • .github/workflows/ci.yml
  • cmd/git-harness-cli/main.go
  • testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
  • testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java
  • testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java
  • testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java
  • testkit/python/pyproject.toml
  • testkit/python/samples/README.md
  • testkit/python/samples/smoke_safety_flow.py
  • testkit/python/tests/conftest.py
  • testkit/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

Comment thread testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java Outdated
@bschellenberger2600
Copy link
Copy Markdown
Member Author

@cursoragent resolve issues

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 16, 2026

Taking a look!

Open in Web Open in Cursor 

@bschellenberger2600
Copy link
Copy Markdown
Member Author

@cursoragent also 🧹 Nitpick comments (1)

.github/workflows/ci.yml (1)

    52-54: Avoid running the Linux wrapper flow twice.

    testkit already covers the full Linux Python+Java wrapper path, and wrapper-cross-platform repeats the same sequence again on ubuntu-latest. That doubles CI time and creates two step lists that can drift. I'd either drop ubuntu-latest from the matrix or fold the dedicated Linux job into the matrix.

    Also applies to: 108-115
    🤖 Prompt for AI Agents

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 16, 2026

Here is what was fixed and pushed (e3a99e0 on cursor/python-java-api-a92e, rebased onto your latest remote).

1. CI duplication (CodeRabbit)

  • wrapper-cross-platform matrix is now [macos-latest, windows-latest] only.
  • The full Linux Python + Java wrapper path stays in the testkit job, so Ubuntu is not run twice.

2. Bugbot: empty text / warning / notice + clients

  • cmd/git-harness-cli: Notice no longer uses omitempty (same as Text / Warning), so empty strings still appear in JSON.
  • testkit/python/git_harness/cli.py: uses .get("text" | "notice" | "warning", "") and .get("fireBranch", "") so missing keys cannot raise.
  • testkit/java/.../CliBridge.java: safetySanitizeText and safetySecurityNotice read text / notice in a null-safe way.
  • Tests: test_safety_sanitize_text_empty_string_round_trip and test_safety_format_warning_empty_list.

3. Bugbot: macOS path / symlink mismatch

  • test_cli_bridge: assertions use os.path.realpath for meta["path"] vs repo and for scan paths vs inner, so /var/... vs /private/var/... matches Go output.

4. CodeRabbit: SampleRepoFlowSmoke cwd

  • SampleRepoFlowSmoke already uses CliBridgeTest.workspaceRoot() on the rebased branch; conflict resolution kept that and the notice JSON tag fix.

Rebase had conflicts in main.go and test_cli_bridge.py; they were resolved (kept realpath tests and notice without omitempty). ./testkit/.specify/scripts/validate_specify.sh, go test ./..., pytest, and mvn test were run successfully before push. PR #4 description is updated.

Open in Web Open in Cursor 

- 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>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 cacheTtl vs cacheTTL into 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

📥 Commits

Reviewing files that changed from the base of the PR and between 89b0a19 and e3a99e0.

📒 Files selected for processing (6)
  • .github/workflows/ci.yml
  • cmd/git-harness-cli/main.go
  • testkit/java/src/main/java/io/gitfire/harness/CliBridge.java
  • testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java
  • testkit/python/git_harness/cli.py
  • testkit/python/tests/test_cli_bridge.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/ci.yml

Comment thread testkit/python/git_harness/cli.py
Comment thread testkit/python/tests/test_cli_bridge.py
@bschellenberger2600
Copy link
Copy Markdown
Member Author

@cursoragent resolve feedback

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 18, 2026

Here is what was implemented for the latest CodeRabbit / “resolve feedback” items and pushed as 927585a on cursor/python-java-api-a92e.

1. parseRequest — reject unknown JSON keys (cmd/git-harness-cli/main.go)

  • Uses json.NewDecoder(os.Stdin) with DisallowUnknownFields() before Decode, so extra/misspelled top-level fields fail with invalid JSON request: ... instead of being ignored.

2. Python _call — startup errors (testkit/python/git_harness/cli.py)

  • Builds cmd = _cli_cmd() once, passes it to subprocess.run.
  • except OSError wraps missing binary / go / etc. as RuntimeError(f"git-harness-cli failed to start (op={op}): ..."), same style as the timeout path.

3. Tests — local git identity + unknown-field contract (testkit/python/tests/test_cli_bridge.py)

  • After git init in test_analyze_repository_finds_git_dir and test_scan_repositories_finds_nested_repo, runs git config user.email / user.name on that repo (matches Java smoke pattern; still works if autouse conftest is disabled).
  • test_cli_rejects_unknown_json_keys: sends {"op":"safety_security_notice","typoField":1}, expects non-zero exit and {"ok":false,"error":...} in stdout.

PR #4 body was refreshed. go test ./... and pytest tests/ were run successfully before push.

Open in Web Open in Cursor 

- 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>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between e3a99e0 and 927585a.

📒 Files selected for processing (3)
  • cmd/git-harness-cli/main.go
  • testkit/python/git_harness/cli.py
  • testkit/python/tests/test_cli_bridge.py

Comment thread cmd/git-harness-cli/main.go
Comment thread testkit/python/git_harness/cli.py Outdated
Comment thread cmd/git-harness-cli/main.go Outdated
Comment thread testkit/java/src/main/java/io/gitfire/harness/CliBridge.java
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Args field 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>

Comment thread testkit/python/git_harness/cli.py
Comment thread cmd/git-harness-cli/main.go Outdated
cursoragent and others added 2 commits April 18, 2026 06:22
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>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 927585a and 603f98b.

📒 Files selected for processing (6)
  • CURSOR_ULTRA_PLAN.md
  • README.md
  • cmd/git-harness-cli/main.go
  • testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
  • testkit/java/src/main/java/io/gitfire/harness/CliBridge.java
  • testkit/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

Comment thread cmd/git-harness-cli/main.go
Comment thread cmd/git-harness-cli/main.go
Comment thread testkit/java/src/main/java/io/gitfire/harness/CliBridge.java
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 19, 2026

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 2 file(s) based on 3 unresolved review comments.

Files modified:

  • cmd/git-harness-cli/main.go
  • testkit/java/src/main/java/io/gitfire/harness/CliBridge.java

Commit: 65e68dfc0e1f86d63a24a5c0d84cb80b7a0bf552

The changes have been pushed to the cursor/python-java-api-a92e branch.

Time taken: 4m 36s

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Response struct Output field is never populated
    • Removed the unused Output field from the response struct and the output property from cli-protocol.json so the implementation matches the documented contract.
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>

Comment thread cmd/git-harness-cli/main.go Outdated
coderabbitai bot and others added 3 commits April 19, 2026 05:23
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>
@bschellenberger2600 bschellenberger2600 merged commit 142c344 into main Apr 19, 2026
7 checks passed
@bschellenberger2600 bschellenberger2600 deleted the cursor/python-java-api-a92e branch April 19, 2026 06:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants