diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8daf8ca..b987e72 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,23 @@ permissions:
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 @@ jobs:
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 @@ jobs:
- 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
index 6aa0f27..3fc354d 100644
--- 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
index 498d019..e7754b7 100644
--- a/CURSOR_ULTRA_PLAN.md
+++ b/CURSOR_ULTRA_PLAN.md
@@ -113,11 +113,10 @@ git-harness/
.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 @@ Commit: `refactor(git-harness): remove extracted internals from companion CLI`
> 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 @@ Commit: `feat(git-harness): Python wrapper`
> 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
index 1c750c4..689d1d8 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,10 @@ Go library extracted from [git-fire](https://github.com/git-fire/git-fire): subp
- **`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](https://github.com/git-fire/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
index 0000000..80723ae
--- /dev/null
+++ b/cmd/git-harness-cli/main.go
@@ -0,0 +1,517 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "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)
+ }
+ var trailer json.RawMessage
+ if err := dec.Decode(&trailer); err != io.EOF {
+ if err == nil {
+ return request{}, fmt.Errorf("invalid JSON request: multiple JSON values")
+ }
+ return request{}, fmt.Errorf("invalid JSON request: trailing data: %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
+}
diff --git a/testkit/.specify/memory/constitution.md b/testkit/.specify/memory/constitution.md
new file mode 100644
index 0000000..9d010c4
--- /dev/null
+++ b/testkit/.specify/memory/constitution.md
@@ -0,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 100755
index 0000000..06a4d5c
--- /dev/null
+++ b/testkit/.specify/scripts/validate_specify.sh
@@ -0,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
+ exit 1
+ fi
+done
+
+grep -q "Status\\*\\*: Implemented (canonical spec-kit baseline)" "$SPEC_DIR/spec.md"
+grep -q "Status\\*\\*: Implemented (canonical spec-kit baseline)" "$SPEC_DIR/plan.md"
+grep -q "T015 Add spec-kit command workflow doc + shell helper" "$SPEC_DIR/tasks.md"
+grep -q "\\[x\\] T015" "$SPEC_DIR/tasks.md"
+grep -q "\"supported_ops\"" "$SPEC_DIR/contracts/cli-protocol.json"
+grep -q "\\[x\\] Smoke test coverage exists for Go, Python wrapper, and Java wrapper paths." "$SPEC_DIR/checklists/quality.md"
+
+echo "spec-kit artifacts validated"
diff --git a/testkit/.specify/specs/001-polyglot-harness/checklists/quality.md b/testkit/.specify/specs/001-polyglot-harness/checklists/quality.md
new file mode 100644
index 0000000..bf2f206
--- /dev/null
+++ b/testkit/.specify/specs/001-polyglot-harness/checklists/quality.md
@@ -0,0 +1,8 @@
+# Quality checklist: 001-polyglot-harness
+
+- [x] Spec defines measurable success criteria.
+- [x] Plan maps spec requirements to concrete implementation phases.
+- [x] Tasks are executable and ordered.
+- [x] Contract schema exists for CLI request/response protocol.
+- [x] Smoke test coverage exists for Go, Python wrapper, and Java wrapper paths.
+- [x] Go library API for `git` / `safety` remains the primary integration surface for Go consumers.
diff --git a/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
new file mode 100644
index 0000000..c6db06d
--- /dev/null
+++ b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json
@@ -0,0 +1,118 @@
+{
+ "name": "git-harness-cli protocol",
+ "version": "1.0.0",
+ "transport": "stdin JSON request -> stdout JSON response",
+ "request_schema": {
+ "type": "object",
+ "required": ["op"],
+ "properties": {
+ "op": {"type": "string"},
+ "repoPath": {"type": "string"},
+ "ref": {"type": "string"},
+ "ancestorRef": {"type": "string"},
+ "descendantRef": {"type": "string"},
+ "branch": {"type": "string"},
+ "remote": {"type": "string"},
+ "originalBranch": {"type": "string"},
+ "localSHA": {"type": "string"},
+ "message": {"type": "string"},
+ "addAll": {"type": "boolean"},
+ "useDualBranch": {"type": "boolean"},
+ "returnToOriginal": {"type": "boolean"},
+ "scanOptions": {"type": "object"},
+ "text": {"type": "string"},
+ "files": {"type": "array", "items": {"type": "string"}},
+ "suspiciousFiles": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "reason": {"type": "string"},
+ "patterns": {"type": "array", "items": {"type": "string"}},
+ "lineNumbers": {"type": "array", "items": {"type": "integer"}}
+ }
+ }
+ }
+ }
+ },
+ "response_schema": {
+ "type": "object",
+ "required": ["ok"],
+ "properties": {
+ "ok": {"type": "boolean"},
+ "error": {"type": "string"},
+ "repositories": {"type": "array"},
+ "repository": {"type": "object"},
+ "dirty": {"type": "boolean"},
+ "sha": {"type": "string"},
+ "branch": {"type": "string"},
+ "hasConflict": {"type": "boolean"},
+ "localSHA": {"type": "string"},
+ "remoteSHA": {"type": "string"},
+ "isAncestor": {"type": "boolean"},
+ "staged": {"type": "boolean"},
+ "unstaged": {"type": "boolean"},
+ "paths": {"type": "array", "items": {"type": "string"}},
+ "branches": {"type": "array", "items": {"type": "string"}},
+ "worktrees": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "branch": {"type": "string"},
+ "head": {"type": "string"},
+ "isMain": {"type": "boolean"}
+ }
+ }
+ },
+ "fireBranch": {"type": "string"},
+ "stagedBranch": {"type": "string"},
+ "fullBranch": {"type": "string"},
+ "bothCreated": {"type": "boolean"},
+ "lines": {"type": "array", "items": {"type": "string"}},
+ "text": {"type": "string"},
+ "notice": {"type": "string"},
+ "warning": {"type": "string"},
+ "suspiciousFiles": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "reason": {"type": "string"},
+ "patterns": {"type": "array", "items": {"type": "string"}},
+ "lineNumbers": {"type": "array", "items": {"type": "integer"}}
+ }
+ }
+ }
+ }
+ },
+ "supported_ops": [
+ "scan_repositories",
+ "analyze_repository",
+ "git_is_dirty",
+ "git_get_current_branch",
+ "git_get_commit_sha",
+ "git_list_local_branches",
+ "git_list_remote_branches",
+ "git_ref_is_ancestor",
+ "git_detect_conflict",
+ "git_has_staged_changes",
+ "git_has_unstaged_changes",
+ "git_get_uncommitted_files",
+ "git_list_worktrees",
+ "git_auto_commit_dirty",
+ "git_auto_commit_dirty_with_strategy",
+ "git_create_fire_branch",
+ "git_fetch_remote",
+ "git_push_branch",
+ "git_push_all_branches",
+ "safety_sanitize_text",
+ "safety_recommended_gitignore_patterns",
+ "safety_security_notice",
+ "safety_format_warning",
+ "safety_scan_files"
+ ]
+}
diff --git a/testkit/.specify/specs/001-polyglot-harness/plan.md b/testkit/.specify/specs/001-polyglot-harness/plan.md
new file mode 100644
index 0000000..bfff288
--- /dev/null
+++ b/testkit/.specify/specs/001-polyglot-harness/plan.md
@@ -0,0 +1,16 @@
+# Implementation plan: Polyglot git-harness
+
+**Feature**: `001-polyglot-harness`
+**Input**: `testkit/.specify/specs/001-polyglot-harness/spec.md`
+**Status**: Implemented (canonical spec-kit baseline)
+
+## Summary
+
+Ship **`cmd/git-harness-cli`** (JSON stdin → JSON stdout) and thin clients under **`testkit/python`** (`git_harness`) and **`testkit/java`** (`io.gitfire.harness`), mirroring [git-testkit](https://github.com/git-fire/git-testkit).
+
+## Artifact map
+
+- Bridge: `cmd/git-harness-cli/main.go`
+- Python: `testkit/python/git_harness/`, `testkit/python/tests/`, `testkit/python/samples/`
+- Java: `testkit/java/` (Maven, Gson)
+- Spec-kit: `testkit/.specify/**`
diff --git a/testkit/.specify/specs/001-polyglot-harness/spec.md b/testkit/.specify/specs/001-polyglot-harness/spec.md
new file mode 100644
index 0000000..ebf6e67
--- /dev/null
+++ b/testkit/.specify/specs/001-polyglot-harness/spec.md
@@ -0,0 +1,18 @@
+# Feature specification: Polyglot git-harness (CLI bridge)
+
+**Feature branch**: `001-polyglot-harness`
+**Status**: Implemented (canonical spec-kit baseline)
+
+## Goal
+
+Expose **git-harness** (`git` + `safety` packages) to Python and Java via **`cmd/git-harness-cli`**, using the same repository layout pattern as **git-testkit** (`testkit/python`, `testkit/java`, `testkit/.specify`).
+
+## User scenarios
+
+1. **Python / Java test authors** invoke scan, git metadata, and safety helpers without reimplementing subprocess orchestration.
+2. **CI** validates spec-kit artifacts, builds the CLI once, and runs wrapper tests plus sample smoke flows on Linux and a cross-platform matrix.
+
+## Acceptance
+
+- `testkit/.specify/scripts/validate_specify.sh` passes.
+- `go test ./...`, `pytest` under `testkit/python`, and `mvn test` under `testkit/java` pass when `GIT_HARNESS_CLI` points at a built binary.
diff --git a/testkit/.specify/specs/001-polyglot-harness/tasks.md b/testkit/.specify/specs/001-polyglot-harness/tasks.md
new file mode 100644
index 0000000..60d0f89
--- /dev/null
+++ b/testkit/.specify/specs/001-polyglot-harness/tasks.md
@@ -0,0 +1,25 @@
+# Tasks: Polyglot git-harness CLI bridge
+
+## Phase 1 — Bridge
+
+- [x] T001 Add `cmd/git-harness-cli` JSON protocol for `git` and `safety` operations
+
+## Phase 2 — Wrappers
+
+- [x] T002 Add Python client under `testkit/python/git_harness/`
+- [x] T003 Add Java client under `testkit/java/` (Maven + Gson)
+
+## Phase 3 — Smoke and CI
+
+- [x] T004 Add Python pytest coverage and sample modules under `testkit/python/samples/`
+- [x] T005 Add Java JUnit tests and `Sample*Smoke` flows
+- [x] T006 Wire `.github/workflows/ci.yml` (Go + testkit jobs + cross-platform matrix)
+
+## Phase 4 — Spec-kit alignment
+
+- [x] T011 Add `testkit/.specify/memory/constitution.md`
+- [x] T012 Add spec in `testkit/.specify/specs/001-polyglot-harness/spec.md`
+- [x] T013 Add plan in `testkit/.specify/specs/001-polyglot-harness/plan.md`
+- [x] T014 Add tasks ledger (this file)
+- [x] T015 Add spec-kit command workflow doc + shell helper
+- [x] T016 Add CLI contract JSON and quality checklist
diff --git a/testkit/GIT_HARNESS_SPEC.md b/testkit/GIT_HARNESS_SPEC.md
new file mode 100644
index 0000000..05eee12
--- /dev/null
+++ b/testkit/GIT_HARNESS_SPEC.md
@@ -0,0 +1,20 @@
+# git-harness polyglot spec (summary)
+
+Polyglot layout matches **git-testkit**: repository root Go module, **`cmd/git-harness-cli`** JSON bridge, and language clients under **`testkit/`**.
+
+## Layout
+
+| Path | Role |
+|------|------|
+| `cmd/git-harness-cli/` | stdin JSON → stdout JSON |
+| `testkit/python/` | `git_harness` package (subprocess client) |
+| `testkit/java/` | `io.gitfire.harness.CliBridge` (Maven) |
+| `testkit/.specify/` | Spec-kit artifacts + `validate_specify.sh` |
+
+## Environment
+
+- **`GIT_HARNESS_CLI`**: path to the built `git-harness-cli` binary (recommended in CI). If unset, clients use `go run ./cmd/git-harness-cli` from the repository root.
+
+## Canonical contract
+
+Machine-readable op list: `testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json`.
diff --git a/testkit/README.md b/testkit/README.md
new file mode 100644
index 0000000..f55f52e
--- /dev/null
+++ b/testkit/README.md
@@ -0,0 +1,13 @@
+## Polyglot testkit (git-harness)
+
+This tree mirrors [git-testkit](https://github.com/git-fire/git-testkit): **Go core** at the repository root, **`cmd/git-harness-cli`** as the JSON stdin/stdout bridge, and thin **Python** / **Java** clients under `testkit/python` and `testkit/java`.
+
+Spec-kit style metadata lives under `testkit/.specify/` (validated in CI).
+
+### Run conformance locally
+
+- Python: `cd testkit/python && python3 -m pip install -e ".[dev]" && python3 -m pytest tests/ -v`
+- Java: `cd testkit/java && mvn test`
+- Go: from repository root, `go test ./...`
+
+Set `GIT_HARNESS_CLI` to a prebuilt `./bin/git-harness-cli` when you want to avoid `go run` during wrapper tests.
diff --git a/testkit/java/README.md b/testkit/java/README.md
new file mode 100644
index 0000000..4d7efea
--- /dev/null
+++ b/testkit/java/README.md
@@ -0,0 +1,18 @@
+# git-harness (Java)
+
+JSON-over-subprocess client for `git-harness-cli`, using Gson to parse responses.
+
+Set `GIT_HARNESS_CLI` to a prebuilt binary, or use `go run ./cmd/git-harness-cli` from the repository root (default when unset).
+
+## Build and test
+
+```bash
+cd testkit/java
+mvn test
+```
+
+Sample smoke tests (also run in CI):
+
+```bash
+mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test
+```
diff --git a/testkit/java/pom.xml b/testkit/java/pom.xml
new file mode 100644
index 0000000..9916131
--- /dev/null
+++ b/testkit/java/pom.xml
@@ -0,0 +1,47 @@
+
+ 4.0.0
+ io.gitfire
+ git-harness-java-wrapper
+ 0.1.0
+ git-harness-java-wrapper
+
+
+ 21
+ UTF-8
+ 5.11.0
+
+
+
+
+ com.google.code.gson
+ gson
+ 2.11.0
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ ${maven.compiler.release}
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.3.1
+
+
+
+
diff --git a/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java
new file mode 100644
index 0000000..d27bf33
--- /dev/null
+++ b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java
@@ -0,0 +1,404 @@
+package io.gitfire.harness;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+public final class CliBridge {
+ private static final Gson GSON = new Gson();
+
+ public record RemoteEntry(String name, String url) {}
+
+ public record RepositoryMeta(
+ String path,
+ String name,
+ List remotes,
+ List branches,
+ boolean isDirty,
+ String lastModified,
+ boolean selected,
+ String mode) {}
+
+ public record WorktreeInfo(String path, String branch, String head, boolean isMain) {}
+
+ static final class CliResult {
+ final String stdout;
+ final String stderr;
+ final int code;
+
+ CliResult(String stdout, String stderr, int code) {
+ this.stdout = stdout;
+ this.stderr = stderr;
+ this.code = code;
+ }
+ }
+
+ public static final class ScanOptions {
+ private String rootPath = ".";
+ private final List exclude = new ArrayList<>();
+ private int maxDepth;
+ private Boolean useCache;
+ private String cacheFile = "";
+ private String cacheTtl = "";
+ private int workers;
+ private final Map knownPaths = new LinkedHashMap<>();
+ private boolean disableScan;
+
+ public ScanOptions rootPath(String v) {
+ this.rootPath = v;
+ return this;
+ }
+
+ public ScanOptions exclude(List v) {
+ exclude.clear();
+ if (v != null) {
+ exclude.addAll(v);
+ }
+ return this;
+ }
+
+ public ScanOptions maxDepth(int v) {
+ this.maxDepth = v;
+ return this;
+ }
+
+ public ScanOptions useCache(boolean v) {
+ this.useCache = v;
+ return this;
+ }
+
+ public ScanOptions cacheFile(String v) {
+ this.cacheFile = v == null ? "" : v;
+ return this;
+ }
+
+ public ScanOptions cacheTtl(String v) {
+ this.cacheTtl = v == null ? "" : v;
+ return this;
+ }
+
+ public ScanOptions workers(int v) {
+ this.workers = v;
+ return this;
+ }
+
+ public ScanOptions knownPath(String path, boolean rescanSubmodules) {
+ knownPaths.put(path, rescanSubmodules);
+ return this;
+ }
+
+ public ScanOptions disableScan(boolean v) {
+ this.disableScan = v;
+ return this;
+ }
+
+ private JsonObject toJson() {
+ JsonObject o = new JsonObject();
+ o.addProperty("rootPath", rootPath);
+ o.addProperty("disableScan", disableScan);
+ if (!exclude.isEmpty()) {
+ JsonArray arr = new JsonArray();
+ for (String s : exclude) {
+ arr.add(s);
+ }
+ o.add("exclude", arr);
+ }
+ if (maxDepth > 0) {
+ o.addProperty("maxDepth", maxDepth);
+ }
+ if (useCache != null) {
+ o.addProperty("useCache", useCache);
+ }
+ if (!cacheFile.isEmpty()) {
+ o.addProperty("cacheFile", cacheFile);
+ }
+ if (!cacheTtl.isEmpty()) {
+ o.addProperty("cacheTTL", cacheTtl);
+ }
+ if (workers > 0) {
+ o.addProperty("workers", workers);
+ }
+ if (!knownPaths.isEmpty()) {
+ JsonObject kp = new JsonObject();
+ for (Map.Entry e : knownPaths.entrySet()) {
+ kp.addProperty(e.getKey(), e.getValue());
+ }
+ o.add("knownPaths", kp);
+ }
+ return o;
+ }
+ }
+
+ private final List cliCommandArgs;
+ private final Path workspaceRoot;
+ private final java.util.function.Function cliInvoker;
+
+ public CliBridge(Path workspaceRoot) {
+ this(workspaceRoot, defaultCliCommandArgs(workspaceRoot), null);
+ }
+
+ public CliBridge() {
+ this(detectWorkspaceRoot());
+ }
+
+ CliBridge(Path workspaceRoot, java.util.function.Function cliInvoker) {
+ this(workspaceRoot, defaultCliCommandArgs(workspaceRoot), cliInvoker);
+ }
+
+ CliBridge(
+ Path workspaceRoot,
+ List cliCommandArgs,
+ java.util.function.Function cliInvoker) {
+ this.workspaceRoot = workspaceRoot;
+ this.cliCommandArgs = List.copyOf(cliCommandArgs);
+ this.cliInvoker = cliInvoker;
+ }
+
+ private static List defaultCliCommandArgs(Path workspaceRoot) {
+ String configuredCli = System.getenv("GIT_HARNESS_CLI");
+ if (configuredCli != null && !configuredCli.isBlank()) {
+ Path cliPath = Paths.get(configuredCli);
+ if (cliPath.isAbsolute()) {
+ return List.of(cliPath.toString());
+ }
+ // Check if it's a path-like value (contains separators or starts with ./ or ../)
+ boolean isPathLike = configuredCli.contains("/")
+ || configuredCli.contains(java.io.File.separator)
+ || configuredCli.startsWith("./")
+ || configuredCli.startsWith("../");
+ if (isPathLike) {
+ cliPath = workspaceRoot.resolve(cliPath).normalize();
+ return List.of(cliPath.toString());
+ }
+ // Bare executable name - let ProcessBuilder perform PATH lookup
+ return List.of(configuredCli);
+ }
+ return List.of("go", "run", "./cmd/git-harness-cli");
+ }
+
+ private static Path detectWorkspaceRoot() {
+ Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
+ Path probe = current;
+ while (probe != null) {
+ if (Files.exists(probe.resolve("go.mod"))
+ && Files.exists(probe.resolve("cmd/git-harness-cli/main.go"))) {
+ return probe;
+ }
+ probe = probe.getParent();
+ }
+ return current;
+ }
+
+ public List scanRepositories(ScanOptions options) {
+ JsonObject req = new JsonObject();
+ req.addProperty("op", "scan_repositories");
+ req.add("scanOptions", options.toJson());
+ JsonObject res = invokeObject(req);
+ List out = new ArrayList<>();
+ if (!res.has("repositories")) {
+ return out;
+ }
+ for (JsonElement el : res.getAsJsonArray("repositories")) {
+ out.add(parseRepository(el.getAsJsonObject()));
+ }
+ return out;
+ }
+
+ public RepositoryMeta analyzeRepository(Path repoPath) {
+ JsonObject req = new JsonObject();
+ req.addProperty("op", "analyze_repository");
+ req.addProperty("repoPath", repoPath.toString());
+ JsonObject res = invokeObject(req);
+ return parseRepository(res.getAsJsonObject("repository"));
+ }
+
+ public boolean isDirty(String repoPath) {
+ JsonObject req = new JsonObject();
+ req.addProperty("op", "git_is_dirty");
+ req.addProperty("repoPath", repoPath);
+ return invokeObject(req).get("dirty").getAsBoolean();
+ }
+
+ public String getCurrentBranch(String repoPath) {
+ JsonObject req = new JsonObject();
+ req.addProperty("op", "git_get_current_branch");
+ req.addProperty("repoPath", repoPath);
+ return invokeObject(req).get("branch").getAsString();
+ }
+
+ public String getCommitSHA(String repoPath, String ref) {
+ JsonObject req = new JsonObject();
+ req.addProperty("op", "git_get_commit_sha");
+ req.addProperty("repoPath", repoPath);
+ req.addProperty("ref", ref);
+ return invokeObject(req).get("sha").getAsString();
+ }
+
+ public String safetySanitizeText(String text) {
+ JsonObject req = new JsonObject();
+ req.addProperty("op", "safety_sanitize_text");
+ req.addProperty("text", text == null ? "" : text);
+ JsonObject res = invokeObject(req);
+ return res.has("text") && !res.get("text").isJsonNull() ? res.get("text").getAsString() : "";
+ }
+
+ public String safetySecurityNotice() {
+ JsonObject req = new JsonObject();
+ req.addProperty("op", "safety_security_notice");
+ JsonObject res = invokeObject(req);
+ return res.has("notice") && !res.get("notice").isJsonNull()
+ ? res.get("notice").getAsString()
+ : "";
+ }
+
+ public List listWorktrees(String repoPath) {
+ JsonObject req = new JsonObject();
+ req.addProperty("op", "git_list_worktrees");
+ req.addProperty("repoPath", repoPath);
+ JsonObject res = invokeObject(req);
+ List out = new ArrayList<>();
+ if (!res.has("worktrees")) {
+ return out;
+ }
+ for (JsonElement el : res.getAsJsonArray("worktrees")) {
+ JsonObject w = el.getAsJsonObject();
+ out.add(
+ new WorktreeInfo(
+ w.get("path").getAsString(),
+ w.get("branch").getAsString(),
+ w.get("head").getAsString(),
+ w.get("isMain").getAsBoolean()));
+ }
+ return out;
+ }
+
+ private JsonObject invokeObject(JsonObject request) {
+ return invokeRaw(GSON.toJson(request));
+ }
+
+ private JsonObject invokeRaw(String payload) {
+ CliResult result = runCli(payload);
+ String stdout = result.stdout == null ? "" : result.stdout.trim();
+ String stderr = result.stderr == null ? "" : result.stderr.trim();
+ if (stdout.isBlank() && result.code != 0) {
+ throw new RuntimeException("CLI failed with code " + result.code + ": " + stderr);
+ }
+ if (stdout.isBlank()) {
+ throw new RuntimeException("CLI returned empty response");
+ }
+ JsonObject head = JsonParser.parseString(stdout).getAsJsonObject();
+ if (result.code != 0 || !head.has("ok") || !head.get("ok").getAsBoolean()) {
+ String err = head.has("error") ? head.get("error").getAsString() : stderr;
+ throw new RuntimeException(err.isEmpty() ? "CLI failed with code " + result.code : err);
+ }
+ return head;
+ }
+
+ private CliResult runCli(String payload) {
+ if (cliInvoker != null) {
+ return cliInvoker.apply(payload);
+ }
+ Process process = null;
+ final Process[] processRef = new Process[1];
+ ExecutorService streamReaderPool = null;
+ Future stdoutFuture = null;
+ Future stderrFuture = null;
+ try {
+ ProcessBuilder pb = new ProcessBuilder(cliCommandArgs);
+ pb.directory(workspaceRoot.toFile());
+ process = pb.start();
+ processRef[0] = process;
+ streamReaderPool = Executors.newFixedThreadPool(2);
+ stdoutFuture =
+ streamReaderPool.submit(
+ () ->
+ new String(
+ processRef[0].getInputStream().readAllBytes(), StandardCharsets.UTF_8));
+ stderrFuture =
+ streamReaderPool.submit(
+ () ->
+ new String(
+ processRef[0].getErrorStream().readAllBytes(), StandardCharsets.UTF_8));
+ process.getOutputStream().write(payload.getBytes(StandardCharsets.UTF_8));
+ process.getOutputStream().close();
+ boolean completed = process.waitFor(120, TimeUnit.SECONDS);
+ if (!completed) {
+ throw new RuntimeException("CLI process timed out after 120 seconds");
+ }
+ int code = process.exitValue();
+ String out = stdoutFuture.get();
+ String err = stderrFuture.get();
+ return new CliResult(out, err, code);
+ } catch (IOException ex) {
+ throw new RuntimeException("failed to invoke CLI", ex);
+ } catch (ExecutionException ex) {
+ throw new RuntimeException("failed to read CLI output", ex);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("interrupted while invoking CLI", ex);
+ } finally {
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
+ }
+ if (stdoutFuture != null) {
+ stdoutFuture.cancel(true);
+ }
+ if (stderrFuture != null) {
+ stderrFuture.cancel(true);
+ }
+ if (streamReaderPool != null) {
+ streamReaderPool.shutdownNow();
+ try {
+ streamReaderPool.awaitTermination(5, TimeUnit.SECONDS);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ }
+
+ private static RepositoryMeta parseRepository(JsonObject o) {
+ List remotes = new ArrayList<>();
+ if (o.has("remotes") && !o.get("remotes").isJsonNull()) {
+ for (JsonElement el : o.getAsJsonArray("remotes")) {
+ JsonObject r = el.getAsJsonObject();
+ remotes.add(new RemoteEntry(r.get("name").getAsString(), r.get("url").getAsString()));
+ }
+ }
+ List branches = new ArrayList<>();
+ if (o.has("branches") && !o.get("branches").isJsonNull()) {
+ for (JsonElement el : o.getAsJsonArray("branches")) {
+ branches.add(el.getAsString());
+ }
+ }
+ String lastMod = "";
+ if (o.has("lastModified") && !o.get("lastModified").isJsonNull()) {
+ lastMod = o.get("lastModified").getAsString();
+ }
+ return new RepositoryMeta(
+ o.get("path").getAsString(),
+ o.get("name").getAsString(),
+ remotes,
+ branches,
+ o.get("isDirty").getAsBoolean(),
+ lastMod,
+ o.get("selected").getAsBoolean(),
+ o.get("mode").getAsString());
+ }
+}
\ No newline at end of file
diff --git a/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java
new file mode 100644
index 0000000..75cb663
--- /dev/null
+++ b/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java
@@ -0,0 +1,103 @@
+package io.gitfire.harness;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class CliBridgeTest {
+ @TempDir Path tmp;
+
+ static Path workspaceRoot() {
+ return Path.of("../..").toAbsolutePath().normalize();
+ }
+
+ private static void runGit(Path dir, String... args) throws Exception {
+ List cmd = new java.util.ArrayList<>();
+ cmd.add("git");
+ for (String a : args) {
+ cmd.add(a);
+ }
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.directory(dir.toFile());
+ Process p = pb.start();
+ boolean ok = p.waitFor(60, java.util.concurrent.TimeUnit.SECONDS);
+ if (!ok) {
+ p.destroyForcibly();
+ throw new RuntimeException("git timeout");
+ }
+ if (p.exitValue() != 0) {
+ String err = new String(p.getErrorStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
+ throw new RuntimeException("git failed: " + err);
+ }
+ }
+
+ @Test
+ void analyzeRepositorySeesCleanRepo() throws Exception {
+ Path repo = tmp.resolve("r");
+ Files.createDirectories(repo);
+ runGit(repo, "init");
+ runGit(repo, "config", "user.email", "harness-test@example.com");
+ runGit(repo, "config", "user.name", "git-harness test");
+ Files.writeString(repo.resolve("a.txt"), "x\n");
+ runGit(repo, "add", "a.txt");
+ runGit(repo, "commit", "-m", "init");
+
+ CliBridge bridge = new CliBridge(workspaceRoot());
+ CliBridge.RepositoryMeta meta = bridge.analyzeRepository(repo);
+
+ assertTrue(Files.exists(repo.resolve(".git")));
+ assertFalse(meta.isDirty());
+ assertEquals(
+ repo.toAbsolutePath().normalize().toString(),
+ meta.path(),
+ "analyzeRepository should return the exact repository path");
+ }
+
+ @Test
+ void isDirtyDetectsUntracked() throws Exception {
+ Path repo = tmp.resolve("d");
+ Files.createDirectories(repo);
+ runGit(repo, "init");
+ Files.writeString(repo.resolve("b.txt"), "y\n");
+
+ CliBridge bridge = new CliBridge(workspaceRoot());
+ assertTrue(bridge.isDirty(repo.toString()));
+ }
+
+ @Test
+ void safetySanitizeTextRemovesToken() {
+ CliBridge bridge = new CliBridge(workspaceRoot());
+ String token = "ghp_" + "a".repeat(36);
+ String out = bridge.safetySanitizeText("pat " + token);
+ assertFalse(out.contains("ghp_"));
+ assertTrue(out.contains("[REDACTED]"));
+ }
+
+ @Test
+ void scanRepositoriesFindsNestedRepo() throws Exception {
+ Path outer = tmp.resolve("outer");
+ Path inner = outer.resolve("nested").resolve("proj");
+ Files.createDirectories(inner);
+ runGit(inner, "init");
+ runGit(inner, "config", "user.email", "harness-test@example.com");
+ runGit(inner, "config", "user.name", "git-harness test");
+ Files.writeString(inner.resolve("f"), "1\n");
+ runGit(inner, "add", "f");
+ runGit(inner, "commit", "-m", "c");
+
+ CliBridge bridge = new CliBridge(workspaceRoot());
+ List repos =
+ bridge.scanRepositories(
+ new CliBridge.ScanOptions().rootPath(outer.toString()).useCache(false).maxDepth(20));
+
+ boolean found =
+ repos.stream().anyMatch(r -> r.path().equals(inner.toAbsolutePath().normalize().toString()));
+ assertTrue(found);
+ }
+}
diff --git a/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java
new file mode 100644
index 0000000..acaee2b
--- /dev/null
+++ b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java
@@ -0,0 +1,90 @@
+package io.gitfire.harness;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Runnable sample that exercises scan + analyze + SHA round-trip with a real git repo. */
+class SampleRepoFlowSmoke {
+ @TempDir Path tmp;
+
+ private static void runGit(Path dir, String... args) throws Exception {
+ runGitStdout(dir, args);
+ }
+
+ private static String runGitStdout(Path dir, String... args) throws Exception {
+ List cmd = new ArrayList<>();
+ cmd.add("git");
+ for (String a : args) {
+ cmd.add(a);
+ }
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.directory(dir.toFile());
+ Process p = pb.start();
+ boolean ok = p.waitFor(120, TimeUnit.SECONDS);
+ if (!ok) {
+ p.destroyForcibly();
+ throw new RuntimeException("git timeout");
+ }
+ String stdout =
+ new String(p.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
+ if (p.exitValue() != 0) {
+ String err =
+ new String(p.getErrorStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
+ throw new RuntimeException("git failed: " + err);
+ }
+ return stdout.trim();
+ }
+
+ @Test
+ void sampleRepoFlowRuns() throws Exception {
+ Path base = tmp;
+ Path remote = base.resolve("origin.git");
+ Path local = base.resolve("local");
+ Files.createDirectories(remote);
+ Files.createDirectories(local);
+
+ runGit(remote, "init", "--bare");
+
+ runGit(local, "init");
+ runGit(local, "config", "user.email", "harness-sample@example.com");
+ runGit(local, "config", "user.name", "git-harness sample");
+ Files.writeString(local.resolve("README.md"), "hello\n");
+ runGit(local, "add", "README.md");
+ runGit(local, "commit", "-m", "init");
+
+ CliBridge bridge = new CliBridge(CliBridgeTest.workspaceRoot());
+ String branch = bridge.getCurrentBranch(local.toString());
+
+ runGit(local, "remote", "add", "origin", remote.toAbsolutePath().normalize().toString());
+ runGit(local, "push", "-u", "origin", branch);
+
+ String localSha = bridge.getCommitSHA(local.toString(), branch);
+ String remoteSha = runGitStdout(remote, "rev-parse", branch);
+ assertEquals(localSha, remoteSha, "SHA mismatch between local and remote");
+
+ List repos =
+ bridge.scanRepositories(
+ new CliBridge.ScanOptions().rootPath(base.toString()).useCache(false).maxDepth(30));
+ Path localReal = local.toRealPath();
+ boolean found =
+ repos.stream()
+ .anyMatch(
+ r -> {
+ try {
+ return Path.of(r.path()).toRealPath().equals(localReal);
+ } catch (IOException e) {
+ return false;
+ }
+ });
+ assertTrue(found, "scan_repositories did not find local repo");
+ }
+}
diff --git a/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java
new file mode 100644
index 0000000..24ed700
--- /dev/null
+++ b/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java
@@ -0,0 +1,19 @@
+package io.gitfire.harness;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class SampleSafetyFlowSmoke {
+ @Test
+ void sampleSafetyFlowRuns() {
+ CliBridge bridge = new CliBridge(CliBridgeTest.workspaceRoot());
+ String token = "ghp_" + "a".repeat(36);
+ String out = bridge.safetySanitizeText("export TOKEN=" + token);
+ assertFalse(out.contains(token));
+ assertTrue(out.contains("[REDACTED]"));
+ String notice = bridge.safetySecurityNotice();
+ assertTrue(notice.length() > 10);
+ }
+}
diff --git a/testkit/python/README.md b/testkit/python/README.md
new file mode 100644
index 0000000..48e5e78
--- /dev/null
+++ b/testkit/python/README.md
@@ -0,0 +1,15 @@
+# git-harness (Python)
+
+Thin JSON-over-subprocess client for the `git-harness-cli` tool in this repository.
+
+Set `GIT_HARNESS_CLI` to a prebuilt binary path (recommended in CI), or rely on `go run ./cmd/git-harness-cli` from the repository root.
+
+## Development
+
+```bash
+cd testkit/python
+python -m pip install -e ".[dev]"
+python -m pytest tests/ -v
+```
+
+Samples: see `samples/README.md`.
diff --git a/testkit/python/git_harness/__init__.py b/testkit/python/git_harness/__init__.py
new file mode 100644
index 0000000..fe21d58
--- /dev/null
+++ b/testkit/python/git_harness/__init__.py
@@ -0,0 +1,3 @@
+from .cli import GitHarnessClient, ScanOptions
+
+__all__ = ["GitHarnessClient", "ScanOptions"]
diff --git a/testkit/python/git_harness/cli.py b/testkit/python/git_harness/cli.py
new file mode 100644
index 0000000..fff6cc0
--- /dev/null
+++ b/testkit/python/git_harness/cli.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+_CLI_TIMEOUT_SECONDS = 120
+
+
+def _repo_root() -> Path:
+ # testkit/python/git_harness/cli.py -> repository root
+ return Path(__file__).resolve().parents[3]
+
+
+def _cli_cmd() -> list[str]:
+ cli = os.environ.get("GIT_HARNESS_CLI", "").strip()
+ if not cli:
+ return ["go", "run", "./cmd/git-harness-cli"]
+
+ expanded = os.path.expanduser(cli)
+ cli_path = Path(expanded)
+ if cli_path.is_absolute():
+ return [str(cli_path)]
+ # Repo-relative paths include any directory component; bare names use PATH.
+ if cli_path.parent != Path("."):
+ return [str((_repo_root() / cli_path).resolve())]
+ resolved = shutil.which(cli)
+ if resolved:
+ return [resolved]
+ return [str((_repo_root() / cli_path).resolve())]
+
+
+def _call(op: str, **payload: Any) -> dict[str, Any]:
+ request = {"op": op, **payload}
+ cmd = _cli_cmd()
+ try:
+ proc = subprocess.run(
+ cmd,
+ cwd=_repo_root(),
+ input=json.dumps(request),
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ capture_output=True,
+ check=False,
+ timeout=_CLI_TIMEOUT_SECONDS,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise RuntimeError(
+ f"git-harness-cli timed out after {_CLI_TIMEOUT_SECONDS}s (op={op})"
+ ) from exc
+ except OSError as exc:
+ raise RuntimeError(
+ f"git-harness-cli failed to start (op={op}): {exc}"
+ ) from exc
+ stdout = (proc.stdout or "").strip()
+ stderr = (proc.stderr or "").strip()
+ if proc.returncode != 0:
+ if stdout:
+ try:
+ response = json.loads(stdout)
+ except json.JSONDecodeError:
+ response = {}
+ if not response.get("ok", True) and response.get("error"):
+ raise RuntimeError(str(response["error"]))
+ raise RuntimeError(
+ f"git-harness-cli exited {proc.returncode}: {stderr}; stdout: {stdout}"
+ )
+
+ try:
+ response = json.loads(stdout)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError(
+ f"invalid JSON from git-harness-cli: {stdout!r}; stderr: {stderr}"
+ ) from exc
+ if not response.get("ok", False):
+ raise RuntimeError(response.get("error", "unknown git-harness-cli error"))
+ return response
+
+
+@dataclass(slots=True)
+class ScanOptions:
+ root_path: str = "."
+ exclude: list[str] | None = None
+ max_depth: int = 0
+ use_cache: bool | None = None
+ cache_file: str = ""
+ cache_ttl: str = ""
+ workers: int = 0
+ known_paths: dict[str, bool] | None = None
+ disable_scan: bool = False
+
+ def to_payload(self) -> dict[str, Any]:
+ d: dict[str, Any] = {
+ "rootPath": self.root_path,
+ "disableScan": self.disable_scan,
+ }
+ if self.exclude is not None:
+ d["exclude"] = self.exclude
+ if self.max_depth > 0:
+ d["maxDepth"] = self.max_depth
+ if self.use_cache is not None:
+ d["useCache"] = self.use_cache
+ if self.cache_file:
+ d["cacheFile"] = self.cache_file
+ if self.cache_ttl:
+ d["cacheTTL"] = self.cache_ttl
+ if self.workers > 0:
+ d["workers"] = self.workers
+ if self.known_paths is not None:
+ d["knownPaths"] = self.known_paths
+ return d
+
+
+class GitHarnessClient:
+ def scan_repositories(self, options: ScanOptions | None = None) -> list[dict[str, Any]]:
+ opts = options or ScanOptions()
+ res = _call("scan_repositories", scanOptions=opts.to_payload())
+ return list(res.get("repositories", []))
+
+ def analyze_repository(self, repo_path: str | Path) -> dict[str, Any]:
+ res = _call("analyze_repository", repoPath=str(repo_path))
+ return dict(res["repository"])
+
+ def is_dirty(self, repo_path: str) -> bool:
+ res = _call("git_is_dirty", repoPath=repo_path)
+ return bool(res["dirty"])
+
+ def get_current_branch(self, repo_path: str) -> str:
+ res = _call("git_get_current_branch", repoPath=repo_path)
+ return str(res["branch"])
+
+ def get_commit_sha(self, repo_path: str, ref: str) -> str:
+ res = _call("git_get_commit_sha", repoPath=repo_path, ref=ref)
+ return str(res["sha"])
+
+ def list_local_branches(self, repo_path: str) -> list[str]:
+ res = _call("git_list_local_branches", repoPath=repo_path)
+ return [str(b) for b in res.get("branches", [])]
+
+ def list_remote_branches(self, repo_path: str, remote: str) -> list[str]:
+ res = _call("git_list_remote_branches", repoPath=repo_path, remote=remote)
+ return [str(b) for b in res.get("branches", [])]
+
+ def ref_is_ancestor(self, repo_path: str, ancestor_ref: str, descendant_ref: str) -> bool:
+ res = _call(
+ "git_ref_is_ancestor",
+ repoPath=repo_path,
+ ancestorRef=ancestor_ref,
+ descendantRef=descendant_ref,
+ )
+ return bool(res["isAncestor"])
+
+ def detect_conflict(self, repo_path: str, branch: str, remote: str) -> tuple[bool, str, str]:
+ res = _call("git_detect_conflict", repoPath=repo_path, branch=branch, remote=remote)
+ return bool(res["hasConflict"]), str(res.get("localSHA", "")), str(res.get("remoteSHA", ""))
+
+ def has_staged_changes(self, repo_path: str) -> bool:
+ res = _call("git_has_staged_changes", repoPath=repo_path)
+ return bool(res["staged"])
+
+ def has_unstaged_changes(self, repo_path: str) -> bool:
+ res = _call("git_has_unstaged_changes", repoPath=repo_path)
+ return bool(res["unstaged"])
+
+ def get_uncommitted_files(self, repo_path: str) -> list[str]:
+ res = _call("git_get_uncommitted_files", repoPath=repo_path)
+ return [str(p) for p in res.get("paths", [])]
+
+ def list_worktrees(self, repo_path: str) -> list[dict[str, Any]]:
+ res = _call("git_list_worktrees", repoPath=repo_path)
+ return list(res.get("worktrees", []))
+
+ def auto_commit_dirty(
+ self,
+ repo_path: str,
+ *,
+ message: str = "",
+ add_all: bool = False,
+ ) -> None:
+ _call(
+ "git_auto_commit_dirty",
+ repoPath=repo_path,
+ message=message,
+ addAll=add_all,
+ )
+
+ def auto_commit_dirty_with_strategy(
+ self,
+ repo_path: str,
+ *,
+ message: str = "",
+ add_all: bool = False,
+ use_dual_branch: bool = True,
+ return_to_original: bool = True,
+ ) -> dict[str, Any]:
+ return _call(
+ "git_auto_commit_dirty_with_strategy",
+ repoPath=repo_path,
+ message=message,
+ addAll=add_all,
+ useDualBranch=use_dual_branch,
+ returnToOriginal=return_to_original,
+ )
+
+ def create_fire_branch(self, repo_path: str, original_branch: str, local_sha: str) -> str:
+ res = _call(
+ "git_create_fire_branch",
+ repoPath=repo_path,
+ originalBranch=original_branch,
+ localSHA=local_sha,
+ )
+ return str(res.get("fireBranch", ""))
+
+ def fetch_remote(self, repo_path: str, remote: str) -> None:
+ _call("git_fetch_remote", repoPath=repo_path, remote=remote)
+
+ def push_branch(self, repo_path: str, remote: str, branch: str) -> None:
+ _call("git_push_branch", repoPath=repo_path, remote=remote, branch=branch)
+
+ def push_all_branches(self, repo_path: str, remote: str) -> None:
+ _call("git_push_all_branches", repoPath=repo_path, remote=remote)
+
+ def safety_sanitize_text(self, text: str) -> str:
+ res = _call("safety_sanitize_text", text=text)
+ return str(res.get("text", ""))
+
+ def safety_recommended_gitignore_patterns(self) -> list[str]:
+ res = _call("safety_recommended_gitignore_patterns")
+ return [str(x) for x in res.get("lines", [])]
+
+ def safety_security_notice(self) -> str:
+ res = _call("safety_security_notice")
+ return str(res.get("notice", ""))
+
+ def safety_format_warning(self, files: list[dict[str, Any]]) -> str:
+ res = _call("safety_format_warning", suspiciousFiles=files)
+ return str(res.get("warning", ""))
+
+ def safety_scan_files(self, repo_path: str, files: list[str]) -> list[dict[str, Any]]:
+ res = _call("safety_scan_files", repoPath=repo_path, files=files)
+ return list(res.get("suspiciousFiles", []))
diff --git a/testkit/python/pyproject.toml b/testkit/python/pyproject.toml
new file mode 100644
index 0000000..597b9bf
--- /dev/null
+++ b/testkit/python/pyproject.toml
@@ -0,0 +1,21 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "git-harness-polyglot-python"
+version = "0.1.0"
+description = "Python wrapper for git-harness CLI bridge"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = []
+
+[project.optional-dependencies]
+dev = ["pytest"]
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["git_harness*"]
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
diff --git a/testkit/python/samples/README.md b/testkit/python/samples/README.md
new file mode 100644
index 0000000..e9e69b8
--- /dev/null
+++ b/testkit/python/samples/README.md
@@ -0,0 +1,19 @@
+## Python sample smoke implementations
+
+Runnable examples that exercise the bridge end-to-end. They exit non-zero on failure.
+
+From the repository root (with `GIT_HARNESS_CLI` pointing at a built binary, recommended):
+
+```bash
+cd testkit/python
+python -m pip install -e ".[dev]"
+python -m samples.smoke_repo_flow
+python -m samples.smoke_safety_flow
+```
+
+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
+```
diff --git a/wrappers/java/.gitkeep b/testkit/python/samples/__init__.py
similarity index 100%
rename from wrappers/java/.gitkeep
rename to testkit/python/samples/__init__.py
diff --git a/testkit/python/samples/smoke_repo_flow.py b/testkit/python/samples/smoke_repo_flow.py
new file mode 100644
index 0000000..6364b7c
--- /dev/null
+++ b/testkit/python/samples/smoke_repo_flow.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+from git_harness import GitHarnessClient, ScanOptions
+
+
+def _run_git(repo: Path, *args: str) -> None:
+ subprocess.run(
+ ["git", *args],
+ cwd=repo,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+
+
+def _git_init(repo: Path) -> None:
+ _run_git(repo, "init")
+ _run_git(repo, "config", "user.email", "harness-sample@example.com")
+ _run_git(repo, "config", "user.name", "git-harness sample")
+
+
+def main() -> int:
+ client = GitHarnessClient()
+ with tempfile.TemporaryDirectory(prefix="git-harness-py-repo-") as tmp:
+ base = Path(tmp)
+ remote = base / "origin.git"
+ local = base / "local"
+ remote.mkdir()
+ local.mkdir()
+
+ _run_git(remote, "init", "--bare")
+
+ _git_init(local)
+ (local / "README.md").write_text("hello\n", encoding="utf-8")
+ _run_git(local, "add", "README.md")
+ _run_git(local, "commit", "-m", "init")
+ branch = client.get_current_branch(str(local))
+
+ _run_git(local, "remote", "add", "origin", str(remote.resolve()))
+ _run_git(local, "push", "-u", "origin", branch)
+
+ local_sha = client.get_commit_sha(str(local), branch)
+ out = subprocess.run(
+ ["git", "rev-parse", branch],
+ cwd=remote,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ ).stdout.strip()
+ if local_sha != out:
+ raise RuntimeError(f"sha mismatch local={local_sha} remote={out}")
+
+ repos = client.scan_repositories(
+ ScanOptions(root_path=str(base), use_cache=False, max_depth=30)
+ )
+ # macOS often differs between symlinked paths (/var vs /private/var); compare real paths.
+ local_key = Path(os.path.realpath(local))
+ if not any(Path(os.path.realpath(r["path"])) == local_key for r in repos):
+ raise RuntimeError("scan_repositories did not find local repo")
+
+ print("python sample repo flow: OK")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/testkit/python/samples/smoke_safety_flow.py b/testkit/python/samples/smoke_safety_flow.py
new file mode 100644
index 0000000..f8a900e
--- /dev/null
+++ b/testkit/python/samples/smoke_safety_flow.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from git_harness import GitHarnessClient
+
+
+def main() -> int:
+ client = GitHarnessClient()
+ token = "ghp_" + ("a" * 36)
+ out = client.safety_sanitize_text(f"export TOKEN={token}")
+ if token in out:
+ raise RuntimeError("expected full token to be redacted")
+ if "[REDACTED]" not in out:
+ raise RuntimeError("expected redaction marker in sanitized output")
+ notice = client.safety_security_notice()
+ if len(notice) < 10:
+ raise RuntimeError("expected security notice body")
+ print("python sample safety flow: OK")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/testkit/python/tests/conftest.py b/testkit/python/tests/conftest.py
new file mode 100644
index 0000000..7ca9ed4
--- /dev/null
+++ b/testkit/python/tests/conftest.py
@@ -0,0 +1,39 @@
+"""Pytest fixtures for wrapper tests."""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+import sys
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _git_identity_for_tests(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> None:
+ """CI images often have no global git user; commits in tests must still work."""
+ git_bin = shutil.which("git")
+ if git_bin is None:
+ pytest.fail("git executable not found on PATH")
+
+ home = tmp_path_factory.mktemp("gh_pytest_git_home")
+ monkeypatch.setenv("HOME", str(home))
+ if sys.platform == "win32":
+ monkeypatch.setenv("USERPROFILE", str(home))
+ subprocess.run(
+ [git_bin, "config", "--global", "user.email", "pytest@example.com"],
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ subprocess.run(
+ [git_bin, "config", "--global", "user.name", "pytest"],
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ yield
diff --git a/testkit/python/tests/test_cli_bridge.py b/testkit/python/tests/test_cli_bridge.py
new file mode 100644
index 0000000..9c1d29c
--- /dev/null
+++ b/testkit/python/tests/test_cli_bridge.py
@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+import json
+import os
+import os.path
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from git_harness import GitHarnessClient, ScanOptions
+from git_harness.cli import _cli_cmd
+
+
+def _run_git(repo: Path, *args: str) -> None:
+ subprocess.run(
+ ["git", *args],
+ cwd=repo,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+
+
+def test_analyze_repository_finds_git_dir(tmp_path: Path) -> None:
+ repo = tmp_path / "r"
+ repo.mkdir()
+ _run_git(repo, "init")
+ _run_git(repo, "config", "user.email", "harness-test@example.com")
+ _run_git(repo, "config", "user.name", "git-harness test")
+ (repo / "a.txt").write_text("x\n")
+ _run_git(repo, "add", "a.txt")
+ _run_git(repo, "commit", "-m", "init")
+
+ client = GitHarnessClient()
+ meta = client.analyze_repository(repo)
+
+ assert os.path.realpath(meta["path"]) == os.path.realpath(str(repo))
+ assert meta["name"] == "r"
+ assert meta["isDirty"] is False
+
+
+def test_is_dirty_detects_untracked(tmp_path: Path) -> None:
+ repo = tmp_path / "d"
+ repo.mkdir()
+ _run_git(repo, "init")
+ (repo / "b.txt").write_text("y\n")
+
+ client = GitHarnessClient()
+ assert client.is_dirty(str(repo)) is True
+
+
+def test_safety_sanitize_text_masks_token() -> None:
+ client = GitHarnessClient()
+ # SanitizeText matches GitHub PATs with ghp_ + at least 36 alphanumerics.
+ token = "ghp_" + ("a" * 36)
+ out = client.safety_sanitize_text(f"pat {token}")
+ assert "ghp_" not in out
+ assert "[REDACTED]" in out
+
+
+def test_safety_sanitize_text_empty_string_round_trip() -> None:
+ client = GitHarnessClient()
+ assert client.safety_sanitize_text("") == ""
+
+
+def test_safety_format_warning_empty_list() -> None:
+ client = GitHarnessClient()
+ assert client.safety_format_warning([]) == ""
+
+
+def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None:
+ outer = tmp_path / "outer"
+ inner = outer / "nested" / "proj"
+ inner.mkdir(parents=True)
+ _run_git(inner, "init")
+ _run_git(inner, "config", "user.email", "harness-test@example.com")
+ _run_git(inner, "config", "user.name", "git-harness test")
+ (inner / "f").write_text("1\n")
+ _run_git(inner, "add", "f")
+ _run_git(inner, "commit", "-m", "c")
+
+ client = GitHarnessClient()
+
+ repos = client.scan_repositories(
+ ScanOptions(root_path=str(outer), use_cache=False, max_depth=20)
+ )
+ paths = {os.path.realpath(r["path"]) for r in repos}
+ assert os.path.realpath(str(inner)) in paths
+
+
+def _git_harness_cli_cmd() -> list[str]:
+ root = Path(__file__).resolve().parents[3]
+ cli = os.environ.get("GIT_HARNESS_CLI", "").strip()
+ cmd = [cli] if cli else ["go", "run", "./cmd/git-harness-cli"]
+ if cli and not Path(cli).is_file() and shutil.which(cli) is None:
+ cmd = [str((root / cli).resolve())]
+ return cmd
+
+
+def test_cli_rejects_unknown_json_keys() -> None:
+ """parseRequest uses DisallowUnknownFields — typos must fail fast."""
+ root = Path(__file__).resolve().parents[3]
+ proc = subprocess.run(
+ _git_harness_cli_cmd(),
+ cwd=root,
+ input=json.dumps({"op": "safety_security_notice", "typoField": 1}),
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ capture_output=True,
+ check=False,
+ timeout=120,
+ )
+ assert proc.returncode != 0
+ body = json.loads((proc.stdout or "").strip() or "{}")
+ assert body.get("ok") is False
+ assert "error" in body
+
+
+def test_cli_rejects_trailing_second_json_value() -> None:
+ root = Path(__file__).resolve().parents[3]
+ payload = (
+ json.dumps({"op": "safety_security_notice"})
+ + json.dumps({"op": "git_is_dirty"})
+ )
+ proc = subprocess.run(
+ _git_harness_cli_cmd(),
+ cwd=root,
+ input=payload,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ capture_output=True,
+ check=False,
+ timeout=120,
+ )
+ assert proc.returncode != 0
+ body = json.loads((proc.stdout or "").strip() or "{}")
+ assert body.get("ok") is False
+ err = str(body.get("error", "")).lower()
+ assert "multiple" in err or "trailing" in err
+
+
+def test_cli_cmd_prefers_path_lookup_for_bare_executable_name(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.delenv("GIT_HARNESS_CLI", raising=False)
+ monkeypatch.setenv("GIT_HARNESS_CLI", "git-harness-cli")
+ fake = "/opt/bin/git-harness-cli"
+
+ def fake_which(cmd: str, path: str | None = None) -> str | None:
+ if cmd == "git-harness-cli":
+ return fake
+ return shutil.which(cmd, path=path)
+
+ monkeypatch.setattr(shutil, "which", fake_which)
+ assert _cli_cmd() == [fake]
+
+
+def test_subprocess_json_contract_smoke() -> None:
+ """Guardrail: stdin JSON shape accepted by the Go CLI."""
+ root = Path(__file__).resolve().parents[3]
+ proc = subprocess.run(
+ _git_harness_cli_cmd(),
+ cwd=root,
+ input=json.dumps({"op": "safety_security_notice"}),
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ capture_output=True,
+ check=False,
+ timeout=120,
+ )
+ assert proc.returncode == 0, proc.stderr
+ stdout = (proc.stdout or "").strip()
+ body = json.loads(stdout)
+ assert body["ok"] is True
+ assert "notice" in body and len(body["notice"]) > 0
diff --git a/wrappers/README.md b/wrappers/README.md
new file mode 100644
index 0000000..587c4c0
--- /dev/null
+++ b/wrappers/README.md
@@ -0,0 +1,5 @@
+# Moved: polyglot clients live under `testkit/`
+
+This repository follows the same layout as [git-testkit](https://github.com/git-fire/git-testkit): Python and Java clients are in **`testkit/python`** and **`testkit/java`**, not here.
+
+See **`testkit/README.md`**.
diff --git a/wrappers/python/.gitkeep b/wrappers/python/.gitkeep
deleted file mode 100644
index e69de29..0000000