From 8aa3ec8d1f81f994e3188d88e3359e98a1bfe5eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 02:32:12 +0000 Subject: [PATCH 01/14] feat: add git-harness-cli JSON bridge and Python/Java wrappers 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 --- .github/workflows/ci.yml | 93 +++- .gitignore | 6 + README.md | 4 + cmd/git-harness-cli/main.go | 501 ++++++++++++++++++ wrappers/java/.gitkeep | 0 wrappers/java/README.md | 12 + wrappers/java/pom.xml | 47 ++ .../java/io/gitfire/harness/CliBridge.java | 388 ++++++++++++++ .../io/gitfire/harness/CliBridgeTest.java | 95 ++++ wrappers/python/.gitkeep | 0 wrappers/python/README.md | 13 + wrappers/python/git_harness/__init__.py | 3 + wrappers/python/git_harness/cli.py | 234 ++++++++ wrappers/python/pyproject.toml | 21 + wrappers/python/tests/test_cli_bridge.py | 83 +++ 15 files changed, 1499 insertions(+), 1 deletion(-) create mode 100644 cmd/git-harness-cli/main.go delete mode 100644 wrappers/java/.gitkeep create mode 100644 wrappers/java/README.md create mode 100644 wrappers/java/pom.xml create mode 100644 wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java create mode 100644 wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java delete mode 100644 wrappers/python/.gitkeep create mode 100644 wrappers/python/README.md create mode 100644 wrappers/python/git_harness/__init__.py create mode 100644 wrappers/python/git_harness/cli.py create mode 100644 wrappers/python/pyproject.toml create mode 100644 wrappers/python/tests/test_cli_bridge.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8daf8ca..13e0e55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,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 +31,94 @@ jobs: - name: Test run: go test -race -count=1 ./... + + wrappers: + runs-on: ubuntu-latest + needs: 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: 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 wrappers/python + python -m pip install -e ".[dev]" + python -m pytest tests/ -v + + - name: Run Java wrapper tests + env: + GIT_HARNESS_CLI: ./bin/git-harness-cli + run: | + cd wrappers/java + mvn test + + wrapper-cross-platform: + runs-on: ${{ matrix.os }} + needs: 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 wrappers/python + python -m pip install -e ".[dev]" + python -m pytest tests/ -v + + - 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 wrappers/java + mvn test diff --git a/.gitignore b/.gitignore index 6aa0f27..0af950c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ # Local reference clones (not part of this module) /mnt/ + +# Wrapper build artifacts +wrappers/python/**/__pycache__/ +wrappers/python/**/*.egg-info/ +wrappers/python/.pytest_cache/ +wrappers/java/target/ diff --git a/README.md b/README.md index 6eeeadb..f2744cd 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 talk to the same JSON stdin/stdout bridge as [git-testkit](https://github.com/git-fire/git-testkit): build `cmd/git-harness-cli`, then point `GIT_HARNESS_CLI` at the binary (or use `go run ./cmd/git-harness-cli` from the repo root). See `wrappers/python` and `wrappers/java`. + ## 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..efccaa6 --- /dev/null +++ b/cmd/git-harness-cli/main.go @@ -0,0 +1,501 @@ +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,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 + 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 := mergeScanOptions(req.ScanOptions) + 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 { + opts := git.DefaultScanOptions() + if in == nil { + return opts + } + 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 { + // Invalid duration falls back to default rather than failing merge. + _ = err + } else { + 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 +} + +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}) + } + return repositoryOut{ + Path: r.Path, + Name: r.Name, + Remotes: rem, + Branches: r.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/wrappers/java/.gitkeep b/wrappers/java/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/wrappers/java/README.md b/wrappers/java/README.md new file mode 100644 index 0000000..e40e768 --- /dev/null +++ b/wrappers/java/README.md @@ -0,0 +1,12 @@ +# 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 wrappers/java +mvn test +``` diff --git a/wrappers/java/pom.xml b/wrappers/java/pom.xml new file mode 100644 index 0000000..9916131 --- /dev/null +++ b/wrappers/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/wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java b/wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java new file mode 100644 index 0000000..e9b612b --- /dev/null +++ b/wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java @@ -0,0 +1,388 @@ +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()) { + cliPath = workspaceRoot.resolve(cliPath).normalize(); + } + return List.of(cliPath.toString()); + } + 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 safetySanitizeText(String text) { + JsonObject req = new JsonObject(); + req.addProperty("op", "safety_sanitize_text"); + req.addProperty("text", text == null ? "" : text); + return invokeObject(req).get("text").getAsString(); + } + + public String safetySecurityNotice() { + JsonObject req = new JsonObject(); + req.addProperty("op", "safety_security_notice"); + return invokeObject(req).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) { + String raw = invokeRaw(GSON.toJson(request)); + JsonObject obj = JsonParser.parseString(raw).getAsJsonObject(); + if (!obj.has("ok") || !obj.get("ok").getAsBoolean()) { + String err = obj.has("error") ? obj.get("error").getAsString() : "unknown error"; + throw new RuntimeException(err); + } + return obj; + } + + private String 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 stdout; + } + + 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()); + } +} diff --git a/wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java b/wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java new file mode 100644 index 0000000..7cc6148 --- /dev/null +++ b/wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java @@ -0,0 +1,95 @@ +package io.gitfire.harness; + +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; + + private 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"); + 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()); + assertTrue(meta.path().endsWith("r") || meta.path().contains("r")); + } + + @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"); + 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/wrappers/python/.gitkeep b/wrappers/python/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/wrappers/python/README.md b/wrappers/python/README.md new file mode 100644 index 0000000..0768757 --- /dev/null +++ b/wrappers/python/README.md @@ -0,0 +1,13 @@ +# 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 wrappers/python +python -m pip install -e ".[dev]" +python -m pytest tests/ -v +``` diff --git a/wrappers/python/git_harness/__init__.py b/wrappers/python/git_harness/__init__.py new file mode 100644 index 0000000..fe21d58 --- /dev/null +++ b/wrappers/python/git_harness/__init__.py @@ -0,0 +1,3 @@ +from .cli import GitHarnessClient, ScanOptions + +__all__ = ["GitHarnessClient", "ScanOptions"] diff --git a/wrappers/python/git_harness/cli.py b/wrappers/python/git_harness/cli.py new file mode 100644 index 0000000..5066ee5 --- /dev/null +++ b/wrappers/python/git_harness/cli.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_CLI_TIMEOUT_SECONDS = 120 + + +def _repo_root() -> Path: + # wrappers/python/git_harness/cli.py -> repo root + return Path(__file__).resolve().parents[3] + + +def _cli_cmd() -> list[str]: + cli = os.environ.get("GIT_HARNESS_CLI", "").strip() + if cli: + cli_path = Path(cli) + if not cli_path.is_absolute(): + cli_path = _repo_root() / cli_path + return [str(cli_path)] + return ["go", "run", "./cmd/git-harness-cli"] + + +def _call(op: str, **payload: Any) -> dict[str, Any]: + request = {"op": op, **payload} + try: + proc = subprocess.run( + _cli_cmd(), + cwd=_repo_root(), + input=json.dumps(request), + text=True, + 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 + 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, + use_dual_branch: bool = True, + return_to_original: bool = True, + ) -> None: + _call( + "git_auto_commit_dirty", + repoPath=repo_path, + message=message, + addAll=add_all, + useDualBranch=use_dual_branch, + returnToOriginal=return_to_original, + ) + + 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["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["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["notice"]) + + def safety_format_warning(self, files: list[dict[str, Any]]) -> str: + res = _call("safety_format_warning", suspiciousFiles=files) + return str(res["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/wrappers/python/pyproject.toml b/wrappers/python/pyproject.toml new file mode 100644 index 0000000..597b9bf --- /dev/null +++ b/wrappers/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/wrappers/python/tests/test_cli_bridge.py b/wrappers/python/tests/test_cli_bridge.py new file mode 100644 index 0000000..8139b3f --- /dev/null +++ b/wrappers/python/tests/test_cli_bridge.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from git_harness import GitHarnessClient + + +def _run_git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def test_analyze_repository_finds_git_dir(tmp_path: Path) -> None: + repo = tmp_path / "r" + repo.mkdir() + _run_git(repo, "init") + (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 meta["path"] == str(repo.resolve()) + 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_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") + (inner / "f").write_text("1\n") + _run_git(inner, "add", "f") + _run_git(inner, "commit", "-m", "c") + + client = GitHarnessClient() + from git_harness import ScanOptions + + repos = client.scan_repositories( + ScanOptions(root_path=str(outer), use_cache=False, max_depth=20) + ) + paths = {r["path"] for r in repos} + assert str(inner.resolve()) in paths + + +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( + ["go", "run", "./cmd/git-harness-cli"], + cwd=root, + input=json.dumps({"op": "safety_security_notice"}), + text=True, + capture_output=True, + check=False, + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + body = json.loads(proc.stdout.strip()) + assert body["ok"] is True + assert "notice" in body and len(body["notice"]) > 0 From 10c80db8f73e20c08c5b391e99e64d7349322fed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 02:49:17 +0000 Subject: [PATCH 02/14] fix(ci): git identity and UTF-8 subprocess; add wrapper samples 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 --- .github/workflows/ci.yml | 32 +++++++ README.md | 2 +- wrappers/java/README.md | 6 ++ .../java/io/gitfire/harness/CliBridge.java | 8 ++ .../io/gitfire/harness/CliBridgeTest.java | 6 +- .../gitfire/harness/SampleRepoFlowSmoke.java | 93 +++++++++++++++++++ .../harness/SampleSafetyFlowSmoke.java | 18 ++++ wrappers/python/README.md | 2 + wrappers/python/pyproject.toml | 2 +- wrappers/python/samples/README.md | 18 ++++ wrappers/python/samples/__init__.py | 0 wrappers/python/samples/smoke_repo_flow.py | 73 +++++++++++++++ wrappers/python/samples/smoke_safety_flow.py | 20 ++++ wrappers/python/tests/conftest.py | 34 +++++++ wrappers/python/tests/test_cli_bridge.py | 24 ++++- 15 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java create mode 100644 wrappers/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java create mode 100644 wrappers/python/samples/README.md create mode 100644 wrappers/python/samples/__init__.py create mode 100644 wrappers/python/samples/smoke_repo_flow.py create mode 100644 wrappers/python/samples/smoke_safety_flow.py create mode 100644 wrappers/python/tests/conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13e0e55..76880a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,14 @@ jobs: 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 wrappers/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 @@ -71,6 +79,13 @@ jobs: cd wrappers/java mvn test + - name: Run Java sample smoke implementations + env: + GIT_HARNESS_CLI: ./bin/git-harness-cli + run: | + cd wrappers/java + mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test + wrapper-cross-platform: runs-on: ${{ matrix.os }} needs: test @@ -115,6 +130,15 @@ jobs: 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 wrappers/python + python -m samples.smoke_repo_flow + python -m samples.smoke_safety_flow + - name: Run Java wrapper smoke tests shell: bash env: @@ -122,3 +146,11 @@ jobs: run: | cd wrappers/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 wrappers/java + mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test diff --git a/README.md b/README.md index f2744cd..aeb5047 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Go library extracted from [git-fire](https://github.com/git-fire/git-fire): subp ## Polyglot wrappers -Python and Java clients talk to the same JSON stdin/stdout bridge as [git-testkit](https://github.com/git-fire/git-testkit): build `cmd/git-harness-cli`, then point `GIT_HARNESS_CLI` at the binary (or use `go run ./cmd/git-harness-cli` from the repo root). See `wrappers/python` and `wrappers/java`. +Python and Java clients talk to the same JSON stdin/stdout bridge as [git-testkit](https://github.com/git-fire/git-testkit): build `cmd/git-harness-cli`, then point `GIT_HARNESS_CLI` at the binary (or use `go run ./cmd/git-harness-cli` from the repo root). See `wrappers/python` and `wrappers/java`. Runnable samples live under `wrappers/python/samples/` and in the Java `Sample*Smoke` tests. ## Requirements diff --git a/wrappers/java/README.md b/wrappers/java/README.md index e40e768..ba53dcb 100644 --- a/wrappers/java/README.md +++ b/wrappers/java/README.md @@ -10,3 +10,9 @@ Set `GIT_HARNESS_CLI` to a prebuilt binary, or use `go run ./cmd/git-harness-cli cd wrappers/java mvn test ``` + +Sample smoke tests (also run in CI): + +```bash +mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test +``` diff --git a/wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java b/wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java index e9b612b..5c10930 100644 --- a/wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java +++ b/wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java @@ -232,6 +232,14 @@ public String getCurrentBranch(String 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"); diff --git a/wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java b/wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java index 7cc6148..78c9567 100644 --- a/wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java +++ b/wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java @@ -12,7 +12,7 @@ class CliBridgeTest { @TempDir Path tmp; - private static Path workspaceRoot() { + static Path workspaceRoot() { return Path.of("../..").toAbsolutePath().normalize(); } @@ -41,6 +41,8 @@ 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"); @@ -79,6 +81,8 @@ void scanRepositoriesFindsNestedRepo() throws Exception { 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"); diff --git a/wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java b/wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java new file mode 100644 index 0000000..ff6026d --- /dev/null +++ b/wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java @@ -0,0 +1,93 @@ +package io.gitfire.harness; + +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 Path workspaceRoot() { + return Path.of("../..").toAbsolutePath().normalize(); + } + + private static void runGit(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"); + } + 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 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(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); + ProcessBuilder rev = + new ProcessBuilder("git", "rev-parse", branch).directory(remote.toFile()); + Process pr = rev.start(); + boolean done = pr.waitFor(60, TimeUnit.SECONDS); + if (!done) { + pr.destroyForcibly(); + throw new RuntimeException("git rev-parse timeout"); + } + if (pr.exitValue() != 0) { + throw new RuntimeException( + new String(pr.getErrorStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + } + String remoteSha = + new String(pr.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8) + .trim(); + if (!localSha.equals(remoteSha)) { + throw new IllegalStateException("SHA mismatch local=" + localSha + " remote=" + remoteSha); + } + + List repos = + bridge.scanRepositories( + new CliBridge.ScanOptions().rootPath(base.toString()).useCache(false).maxDepth(10)); + Path localAbs = local.toAbsolutePath().normalize(); + boolean found = + repos.stream().anyMatch(r -> Path.of(r.path()).toAbsolutePath().normalize().equals(localAbs)); + if (!found) { + throw new IllegalStateException("scan_repositories did not find local repo"); + } + } +} diff --git a/wrappers/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java b/wrappers/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java new file mode 100644 index 0000000..ca4ca33 --- /dev/null +++ b/wrappers/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java @@ -0,0 +1,18 @@ +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("ghp_")); + String notice = bridge.safetySecurityNotice(); + assertTrue(notice.length() > 10); + } +} diff --git a/wrappers/python/README.md b/wrappers/python/README.md index 0768757..90bf5f6 100644 --- a/wrappers/python/README.md +++ b/wrappers/python/README.md @@ -11,3 +11,5 @@ cd wrappers/python python -m pip install -e ".[dev]" python -m pytest tests/ -v ``` + +Samples: see `samples/README.md`. diff --git a/wrappers/python/pyproject.toml b/wrappers/python/pyproject.toml index 597b9bf..5674eba 100644 --- a/wrappers/python/pyproject.toml +++ b/wrappers/python/pyproject.toml @@ -15,7 +15,7 @@ dev = ["pytest"] [tool.setuptools.packages.find] where = ["."] -include = ["git_harness*"] +include = ["git_harness*", "samples*"] [tool.pytest.ini_options] pythonpath = ["."] diff --git a/wrappers/python/samples/README.md b/wrappers/python/samples/README.md new file mode 100644 index 0000000..e490de8 --- /dev/null +++ b/wrappers/python/samples/README.md @@ -0,0 +1,18 @@ +## 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 wrappers/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=wrappers/python python3 wrappers/python/samples/smoke_repo_flow.py +``` diff --git a/wrappers/python/samples/__init__.py b/wrappers/python/samples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wrappers/python/samples/smoke_repo_flow.py b/wrappers/python/samples/smoke_repo_flow.py new file mode 100644 index 0000000..3e3bc28 --- /dev/null +++ b/wrappers/python/samples/smoke_repo_flow.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +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=10) + ) + paths = {r["path"] for r in repos} + if str(local.resolve()) not in paths: + 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/wrappers/python/samples/smoke_safety_flow.py b/wrappers/python/samples/smoke_safety_flow.py new file mode 100644 index 0000000..4740b01 --- /dev/null +++ b/wrappers/python/samples/smoke_safety_flow.py @@ -0,0 +1,20 @@ +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 "ghp_" in out: + raise RuntimeError("expected token to be redacted") + 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/wrappers/python/tests/conftest.py b/wrappers/python/tests/conftest.py new file mode 100644 index 0000000..9fbf920 --- /dev/null +++ b/wrappers/python/tests/conftest.py @@ -0,0 +1,34 @@ +"""Pytest fixtures for wrapper tests.""" + +from __future__ import annotations + +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.""" + 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", "config", "--global", "user.email", "pytest@example.com"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + subprocess.run( + ["git", "config", "--global", "user.name", "pytest"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + yield diff --git a/wrappers/python/tests/test_cli_bridge.py b/wrappers/python/tests/test_cli_bridge.py index 8139b3f..ee612f0 100644 --- a/wrappers/python/tests/test_cli_bridge.py +++ b/wrappers/python/tests/test_cli_bridge.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import os +import shutil import subprocess from pathlib import Path @@ -8,7 +10,15 @@ def _run_git(repo: Path, *args: str) -> None: - subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + 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: @@ -68,16 +78,24 @@ def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None: def test_subprocess_json_contract_smoke() -> None: """Guardrail: stdin JSON shape accepted by the Go CLI.""" 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: + # Relative path from repo root (typical in CI) + cmd = [str((root / cli).resolve())] proc = subprocess.run( - ["go", "run", "./cmd/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 - body = json.loads(proc.stdout.strip()) + stdout = (proc.stdout or "").strip() + body = json.loads(stdout) assert body["ok"] is True assert "notice" in body and len(body["notice"]) > 0 From 86fba826c7560471db6572ac103b438c72d3f737 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 04:18:30 +0000 Subject: [PATCH 03/14] fix(ci): normalize paths in scan smoke samples for macOS/Windows 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 --- .../io/gitfire/harness/SampleRepoFlowSmoke.java | 15 ++++++++++++--- wrappers/python/samples/smoke_repo_flow.py | 8 +++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java b/wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java index ff6026d..454cda2 100644 --- a/wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java +++ b/wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java @@ -1,5 +1,6 @@ package io.gitfire.harness; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -82,10 +83,18 @@ void sampleRepoFlowRuns() throws Exception { List repos = bridge.scanRepositories( - new CliBridge.ScanOptions().rootPath(base.toString()).useCache(false).maxDepth(10)); - Path localAbs = local.toAbsolutePath().normalize(); + new CliBridge.ScanOptions().rootPath(base.toString()).useCache(false).maxDepth(30)); + Path localReal = local.toRealPath(); boolean found = - repos.stream().anyMatch(r -> Path.of(r.path()).toAbsolutePath().normalize().equals(localAbs)); + repos.stream() + .anyMatch( + r -> { + try { + return Path.of(r.path()).toRealPath().equals(localReal); + } catch (IOException e) { + return false; + } + }); if (!found) { throw new IllegalStateException("scan_repositories did not find local repo"); } diff --git a/wrappers/python/samples/smoke_repo_flow.py b/wrappers/python/samples/smoke_repo_flow.py index 3e3bc28..6364b7c 100644 --- a/wrappers/python/samples/smoke_repo_flow.py +++ b/wrappers/python/samples/smoke_repo_flow.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import subprocess import tempfile from pathlib import Path @@ -59,10 +60,11 @@ def main() -> int: raise RuntimeError(f"sha mismatch local={local_sha} remote={out}") repos = client.scan_repositories( - ScanOptions(root_path=str(base), use_cache=False, max_depth=10) + ScanOptions(root_path=str(base), use_cache=False, max_depth=30) ) - paths = {r["path"] for r in repos} - if str(local.resolve()) not in paths: + # 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") From d15c99aef7ddd73daf58c1864bcc379d96f0dec7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 04:57:06 +0000 Subject: [PATCH 04/14] refactor: align polyglot layout with git-testkit (testkit/) 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 --- .github/workflows/ci.yml | 39 ++++++++---- .gitignore | 10 +-- CURSOR_ULTRA_PLAN.md | 17 +++--- README.md | 2 +- testkit/.specify/memory/constitution.md | 19 ++++++ testkit/.specify/scripts/validate_specify.sh | 30 +++++++++ .../checklists/quality.md | 8 +++ .../contracts/cli-protocol.json | 61 +++++++++++++++++++ .../specs/001-polyglot-harness/plan.md | 16 +++++ .../specs/001-polyglot-harness/spec.md | 18 ++++++ .../specs/001-polyglot-harness/tasks.md | 25 ++++++++ testkit/GIT_HARNESS_SPEC.md | 20 ++++++ testkit/README.md | 13 ++++ {wrappers => testkit}/java/README.md | 2 +- {wrappers => testkit}/java/pom.xml | 0 .../java/io/gitfire/harness/CliBridge.java | 0 .../io/gitfire/harness/CliBridgeTest.java | 0 .../gitfire/harness/SampleRepoFlowSmoke.java | 0 .../harness/SampleSafetyFlowSmoke.java | 0 {wrappers => testkit}/python/README.md | 2 +- .../python/git_harness/__init__.py | 0 .../python/git_harness/cli.py | 2 +- {wrappers => testkit}/python/pyproject.toml | 0 .../python/samples/README.md | 4 +- .../python/samples/__init__.py | 0 .../python/samples/smoke_repo_flow.py | 0 .../python/samples/smoke_safety_flow.py | 0 .../python/tests/conftest.py | 0 .../python/tests/test_cli_bridge.py | 0 wrappers/README.md | 5 ++ 30 files changed, 262 insertions(+), 31 deletions(-) create mode 100644 testkit/.specify/memory/constitution.md create mode 100755 testkit/.specify/scripts/validate_specify.sh create mode 100644 testkit/.specify/specs/001-polyglot-harness/checklists/quality.md create mode 100644 testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json create mode 100644 testkit/.specify/specs/001-polyglot-harness/plan.md create mode 100644 testkit/.specify/specs/001-polyglot-harness/spec.md create mode 100644 testkit/.specify/specs/001-polyglot-harness/tasks.md create mode 100644 testkit/GIT_HARNESS_SPEC.md create mode 100644 testkit/README.md rename {wrappers => testkit}/java/README.md (95%) rename {wrappers => testkit}/java/pom.xml (100%) rename {wrappers => testkit}/java/src/main/java/io/gitfire/harness/CliBridge.java (100%) rename {wrappers => testkit}/java/src/test/java/io/gitfire/harness/CliBridgeTest.java (100%) rename {wrappers => testkit}/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java (100%) rename {wrappers => testkit}/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java (100%) rename {wrappers => testkit}/python/README.md (95%) rename {wrappers => testkit}/python/git_harness/__init__.py (100%) rename {wrappers => testkit}/python/git_harness/cli.py (99%) rename {wrappers => testkit}/python/pyproject.toml (100%) rename {wrappers => testkit}/python/samples/README.md (79%) rename {wrappers => testkit}/python/samples/__init__.py (100%) rename {wrappers => testkit}/python/samples/smoke_repo_flow.py (100%) rename {wrappers => testkit}/python/samples/smoke_safety_flow.py (100%) rename {wrappers => testkit}/python/tests/conftest.py (100%) rename {wrappers => testkit}/python/tests/test_cli_bridge.py (100%) create mode 100644 wrappers/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76880a5..4e5309a 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: @@ -32,9 +49,9 @@ jobs: - name: Test run: go test -race -count=1 ./... - wrappers: + testkit: runs-on: ubuntu-latest - needs: test + needs: [spec-kit-conformance, test] steps: - uses: actions/checkout@v4 @@ -60,7 +77,7 @@ jobs: env: GIT_HARNESS_CLI: ./bin/git-harness-cli run: | - cd wrappers/python + cd testkit/python python -m pip install -e ".[dev]" python -m pytest tests/ -v @@ -68,7 +85,7 @@ jobs: env: GIT_HARNESS_CLI: ./bin/git-harness-cli run: | - cd wrappers/python + cd testkit/python python -m samples.smoke_repo_flow python -m samples.smoke_safety_flow @@ -76,19 +93,19 @@ jobs: env: GIT_HARNESS_CLI: ./bin/git-harness-cli run: | - cd wrappers/java + cd testkit/java mvn test - name: Run Java sample smoke implementations env: GIT_HARNESS_CLI: ./bin/git-harness-cli run: | - cd wrappers/java + cd testkit/java mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test wrapper-cross-platform: runs-on: ${{ matrix.os }} - needs: test + needs: [spec-kit-conformance, test] strategy: fail-fast: false matrix: @@ -126,7 +143,7 @@ jobs: env: GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }} run: | - cd wrappers/python + cd testkit/python python -m pip install -e ".[dev]" python -m pytest tests/ -v @@ -135,7 +152,7 @@ jobs: env: GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }} run: | - cd wrappers/python + cd testkit/python python -m samples.smoke_repo_flow python -m samples.smoke_safety_flow @@ -144,7 +161,7 @@ jobs: env: GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }} run: | - cd wrappers/java + cd testkit/java mvn test - name: Run Java sample smoke implementations @@ -152,5 +169,5 @@ jobs: env: GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }} run: | - cd wrappers/java + cd testkit/java mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test diff --git a/.gitignore b/.gitignore index 0af950c..3fc354d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ # Local reference clones (not part of this module) /mnt/ -# Wrapper build artifacts -wrappers/python/**/__pycache__/ -wrappers/python/**/*.egg-info/ -wrappers/python/.pytest_cache/ -wrappers/java/target/ +# 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 0910ee7..6ac6474 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 git-fcuk` > 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 aeb5047..6076791 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Go library extracted from [git-fire](https://github.com/git-fire/git-fire): subp ## Polyglot wrappers -Python and Java clients talk to the same JSON stdin/stdout bridge as [git-testkit](https://github.com/git-fire/git-testkit): build `cmd/git-harness-cli`, then point `GIT_HARNESS_CLI` at the binary (or use `go run ./cmd/git-harness-cli` from the repo root). See `wrappers/python` and `wrappers/java`. Runnable samples live under `wrappers/python/samples/` and in the Java `Sample*Smoke` tests. +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 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..55c29a9 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json @@ -0,0 +1,61 @@ +{ + "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"}, + "branch": {"type": "string"}, + "remote": {"type": "string"}, + "scanOptions": {"type": "object"}, + "text": {"type": "string"}, + "files": {"type": "array", "items": {"type": "string"}} + } + }, + "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"}, + "branches": {"type": "array", "items": {"type": "string"}}, + "text": {"type": "string"}, + "notice": {"type": "string"}, + "suspiciousFiles": {"type": "array"} + } + }, + "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/wrappers/java/README.md b/testkit/java/README.md similarity index 95% rename from wrappers/java/README.md rename to testkit/java/README.md index ba53dcb..4d7efea 100644 --- a/wrappers/java/README.md +++ b/testkit/java/README.md @@ -7,7 +7,7 @@ Set `GIT_HARNESS_CLI` to a prebuilt binary, or use `go run ./cmd/git-harness-cli ## Build and test ```bash -cd wrappers/java +cd testkit/java mvn test ``` diff --git a/wrappers/java/pom.xml b/testkit/java/pom.xml similarity index 100% rename from wrappers/java/pom.xml rename to testkit/java/pom.xml diff --git a/wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java similarity index 100% rename from wrappers/java/src/main/java/io/gitfire/harness/CliBridge.java rename to testkit/java/src/main/java/io/gitfire/harness/CliBridge.java diff --git a/wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java similarity index 100% rename from wrappers/java/src/test/java/io/gitfire/harness/CliBridgeTest.java rename to testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java diff --git a/wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java similarity index 100% rename from wrappers/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java rename to testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java diff --git a/wrappers/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java similarity index 100% rename from wrappers/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java rename to testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java diff --git a/wrappers/python/README.md b/testkit/python/README.md similarity index 95% rename from wrappers/python/README.md rename to testkit/python/README.md index 90bf5f6..48e5e78 100644 --- a/wrappers/python/README.md +++ b/testkit/python/README.md @@ -7,7 +7,7 @@ Set `GIT_HARNESS_CLI` to a prebuilt binary path (recommended in CI), or rely on ## Development ```bash -cd wrappers/python +cd testkit/python python -m pip install -e ".[dev]" python -m pytest tests/ -v ``` diff --git a/wrappers/python/git_harness/__init__.py b/testkit/python/git_harness/__init__.py similarity index 100% rename from wrappers/python/git_harness/__init__.py rename to testkit/python/git_harness/__init__.py diff --git a/wrappers/python/git_harness/cli.py b/testkit/python/git_harness/cli.py similarity index 99% rename from wrappers/python/git_harness/cli.py rename to testkit/python/git_harness/cli.py index 5066ee5..7108576 100644 --- a/wrappers/python/git_harness/cli.py +++ b/testkit/python/git_harness/cli.py @@ -11,7 +11,7 @@ def _repo_root() -> Path: - # wrappers/python/git_harness/cli.py -> repo root + # testkit/python/git_harness/cli.py -> repository root return Path(__file__).resolve().parents[3] diff --git a/wrappers/python/pyproject.toml b/testkit/python/pyproject.toml similarity index 100% rename from wrappers/python/pyproject.toml rename to testkit/python/pyproject.toml diff --git a/wrappers/python/samples/README.md b/testkit/python/samples/README.md similarity index 79% rename from wrappers/python/samples/README.md rename to testkit/python/samples/README.md index e490de8..4fc2200 100644 --- a/wrappers/python/samples/README.md +++ b/testkit/python/samples/README.md @@ -5,7 +5,7 @@ Runnable examples that exercise the bridge end-to-end. They exit non-zero on fai From the repository root (with `GIT_HARNESS_CLI` pointing at a built binary, recommended): ```bash -cd wrappers/python +cd testkit/python python -m pip install -e ".[dev]" python -m samples.smoke_repo_flow python -m samples.smoke_safety_flow @@ -14,5 +14,5 @@ python -m samples.smoke_safety_flow Or from repo root using `PYTHONPATH`: ```bash -PYTHONPATH=wrappers/python python3 wrappers/python/samples/smoke_repo_flow.py +PYTHONPATH=testkit/python python3 testkit/python/samples/smoke_repo_flow.py ``` diff --git a/wrappers/python/samples/__init__.py b/testkit/python/samples/__init__.py similarity index 100% rename from wrappers/python/samples/__init__.py rename to testkit/python/samples/__init__.py diff --git a/wrappers/python/samples/smoke_repo_flow.py b/testkit/python/samples/smoke_repo_flow.py similarity index 100% rename from wrappers/python/samples/smoke_repo_flow.py rename to testkit/python/samples/smoke_repo_flow.py diff --git a/wrappers/python/samples/smoke_safety_flow.py b/testkit/python/samples/smoke_safety_flow.py similarity index 100% rename from wrappers/python/samples/smoke_safety_flow.py rename to testkit/python/samples/smoke_safety_flow.py diff --git a/wrappers/python/tests/conftest.py b/testkit/python/tests/conftest.py similarity index 100% rename from wrappers/python/tests/conftest.py rename to testkit/python/tests/conftest.py diff --git a/wrappers/python/tests/test_cli_bridge.py b/testkit/python/tests/test_cli_bridge.py similarity index 100% rename from wrappers/python/tests/test_cli_bridge.py rename to testkit/python/tests/test_cli_bridge.py 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`**. From bf4a6793baadbff5201be2b46894a76f4ba9a267 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Apr 2026 05:00:30 +0000 Subject: [PATCH 05/14] fix(windows): decode git-harness-cli stdout/stderr as UTF-8 in Python 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 --- testkit/python/git_harness/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testkit/python/git_harness/cli.py b/testkit/python/git_harness/cli.py index 7108576..a72a5b0 100644 --- a/testkit/python/git_harness/cli.py +++ b/testkit/python/git_harness/cli.py @@ -33,6 +33,8 @@ def _call(op: str, **payload: Any) -> dict[str, Any]: cwd=_repo_root(), input=json.dumps(request), text=True, + encoding="utf-8", + errors="replace", capture_output=True, check=False, timeout=_CLI_TIMEOUT_SECONDS, From 77ab935dded6b25f789b042a6eabf6f291074a6e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 08:06:01 +0000 Subject: [PATCH 06/14] chore: address CodeRabbit PR feedback (CI, CLI, testkit) - 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 --- .github/workflows/ci.yml | 4 +- cmd/git-harness-cli/main.go | 23 ++++--- .../contracts/cli-protocol.json | 63 ++++++++++++++++++- .../io/gitfire/harness/CliBridgeTest.java | 6 +- .../gitfire/harness/SampleRepoFlowSmoke.java | 34 ++++------ .../harness/SampleSafetyFlowSmoke.java | 3 +- testkit/python/pyproject.toml | 2 +- testkit/python/samples/README.md | 1 + testkit/python/samples/smoke_safety_flow.py | 6 +- testkit/python/tests/conftest.py | 9 ++- testkit/python/tests/test_cli_bridge.py | 3 +- 11 files changed, 112 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e5309a..c1778ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,9 @@ jobs: cache: true - name: Build git-harness CLI binary once - run: go build -o ./bin/git-harness-cli ./cmd/git-harness-cli + run: | + mkdir -p bin + go build -o ./bin/git-harness-cli ./cmd/git-harness-cli - uses: actions/setup-python@v5 with: diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index efccaa6..af4f6c4 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -150,7 +150,10 @@ func parseRequest() (request, error) { func handle(req request) (response, error) { switch req.Op { case "scan_repositories": - opts := mergeScanOptions(req.ScanOptions) + opts, err := mergeScanOptions(req.ScanOptions) + if err != nil { + return response{}, err + } repos, err := git.ScanRepositories(opts) if err != nil { return response{}, err @@ -428,10 +431,10 @@ func handle(req request) (response, error) { } } -func mergeScanOptions(in *scanOptionsInput) git.ScanOptions { +func mergeScanOptions(in *scanOptionsInput) (git.ScanOptions, error) { opts := git.DefaultScanOptions() if in == nil { - return opts + return opts, nil } if in.RootPath != "" { opts.RootPath = in.RootPath @@ -451,11 +454,9 @@ func mergeScanOptions(in *scanOptionsInput) git.ScanOptions { if in.CacheTTL != "" { d, err := time.ParseDuration(in.CacheTTL) if err != nil { - // Invalid duration falls back to default rather than failing merge. - _ = err - } else { - opts.CacheTTL = d + return git.ScanOptions{}, fmt.Errorf("invalid scanOptions.cacheTTL %q: %w", in.CacheTTL, err) } + opts.CacheTTL = d } if in.Workers > 0 { opts.Workers = in.Workers @@ -464,7 +465,7 @@ func mergeScanOptions(in *scanOptionsInput) git.ScanOptions { opts.KnownPaths = in.KnownPaths } opts.DisableScan = in.DisableScan - return opts + return opts, nil } func repoToOut(r git.Repository) repositoryOut { @@ -472,11 +473,15 @@ func repoToOut(r git.Repository) repositoryOut { 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: r.Branches, + Branches: branches, IsDirty: r.IsDirty, LastModified: r.LastModified, Selected: r.Selected, diff --git a/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json index 55c29a9..42c2655 100644 --- a/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json +++ b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json @@ -9,11 +9,32 @@ "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"}, + "args": {"type": "array", "items": {"type": "string"}}, "scanOptions": {"type": "object"}, "text": {"type": "string"}, - "files": {"type": "array", "items": {"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": { @@ -25,11 +46,49 @@ "repositories": {"type": "array"}, "repository": {"type": "object"}, "dirty": {"type": "boolean"}, + "output": {"type": "string"}, "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"}, - "suspiciousFiles": {"type": "array"} + "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": [ diff --git a/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java index 78c9567..75cb663 100644 --- a/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java +++ b/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java @@ -1,5 +1,6 @@ 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; @@ -52,7 +53,10 @@ void analyzeRepositorySeesCleanRepo() throws Exception { assertTrue(Files.exists(repo.resolve(".git"))); assertFalse(meta.isDirty()); - assertTrue(meta.path().endsWith("r") || meta.path().contains("r")); + assertEquals( + repo.toAbsolutePath().normalize().toString(), + meta.path(), + "analyzeRepository should return the exact repository path"); } @Test diff --git a/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java index 454cda2..2c14c66 100644 --- a/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java +++ b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java @@ -1,5 +1,8 @@ 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; @@ -18,6 +21,10 @@ private static Path workspaceRoot() { } 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) { @@ -31,11 +38,14 @@ private static void runGit(Path dir, String... args) throws Exception { 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 @@ -62,24 +72,8 @@ void sampleRepoFlowRuns() throws Exception { runGit(local, "push", "-u", "origin", branch); String localSha = bridge.getCommitSHA(local.toString(), branch); - ProcessBuilder rev = - new ProcessBuilder("git", "rev-parse", branch).directory(remote.toFile()); - Process pr = rev.start(); - boolean done = pr.waitFor(60, TimeUnit.SECONDS); - if (!done) { - pr.destroyForcibly(); - throw new RuntimeException("git rev-parse timeout"); - } - if (pr.exitValue() != 0) { - throw new RuntimeException( - new String(pr.getErrorStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); - } - String remoteSha = - new String(pr.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8) - .trim(); - if (!localSha.equals(remoteSha)) { - throw new IllegalStateException("SHA mismatch local=" + localSha + " remote=" + remoteSha); - } + String remoteSha = runGitStdout(remote, "rev-parse", branch); + assertEquals(localSha, remoteSha, "SHA mismatch between local and remote"); List repos = bridge.scanRepositories( @@ -95,8 +89,6 @@ void sampleRepoFlowRuns() throws Exception { return false; } }); - if (!found) { - throw new IllegalStateException("scan_repositories did not find local repo"); - } + 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 index ca4ca33..24ed700 100644 --- a/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java +++ b/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java @@ -11,7 +11,8 @@ void sampleSafetyFlowRuns() { CliBridge bridge = new CliBridge(CliBridgeTest.workspaceRoot()); String token = "ghp_" + "a".repeat(36); String out = bridge.safetySanitizeText("export TOKEN=" + token); - assertFalse(out.contains("ghp_")); + assertFalse(out.contains(token)); + assertTrue(out.contains("[REDACTED]")); String notice = bridge.safetySecurityNotice(); assertTrue(notice.length() > 10); } diff --git a/testkit/python/pyproject.toml b/testkit/python/pyproject.toml index 5674eba..597b9bf 100644 --- a/testkit/python/pyproject.toml +++ b/testkit/python/pyproject.toml @@ -15,7 +15,7 @@ dev = ["pytest"] [tool.setuptools.packages.find] where = ["."] -include = ["git_harness*", "samples*"] +include = ["git_harness*"] [tool.pytest.ini_options] pythonpath = ["."] diff --git a/testkit/python/samples/README.md b/testkit/python/samples/README.md index 4fc2200..e9e69b8 100644 --- a/testkit/python/samples/README.md +++ b/testkit/python/samples/README.md @@ -15,4 +15,5 @@ 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/testkit/python/samples/smoke_safety_flow.py b/testkit/python/samples/smoke_safety_flow.py index 4740b01..f8a900e 100644 --- a/testkit/python/samples/smoke_safety_flow.py +++ b/testkit/python/samples/smoke_safety_flow.py @@ -7,8 +7,10 @@ def main() -> int: client = GitHarnessClient() token = "ghp_" + ("a" * 36) out = client.safety_sanitize_text(f"export TOKEN={token}") - if "ghp_" in out: - raise RuntimeError("expected token to be redacted") + 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") diff --git a/testkit/python/tests/conftest.py b/testkit/python/tests/conftest.py index 9fbf920..7ca9ed4 100644 --- a/testkit/python/tests/conftest.py +++ b/testkit/python/tests/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shutil import subprocess import sys @@ -11,12 +12,16 @@ @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", "config", "--global", "user.email", "pytest@example.com"], + [git_bin, "config", "--global", "user.email", "pytest@example.com"], check=True, capture_output=True, text=True, @@ -24,7 +29,7 @@ def _git_identity_for_tests(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: p errors="replace", ) subprocess.run( - ["git", "config", "--global", "user.name", "pytest"], + [git_bin, "config", "--global", "user.name", "pytest"], check=True, capture_output=True, text=True, diff --git a/testkit/python/tests/test_cli_bridge.py b/testkit/python/tests/test_cli_bridge.py index ee612f0..574faeb 100644 --- a/testkit/python/tests/test_cli_bridge.py +++ b/testkit/python/tests/test_cli_bridge.py @@ -6,7 +6,7 @@ import subprocess from pathlib import Path -from git_harness import GitHarnessClient +from git_harness import GitHarnessClient, ScanOptions def _run_git(repo: Path, *args: str) -> None: @@ -66,7 +66,6 @@ def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None: _run_git(inner, "commit", "-m", "c") client = GitHarnessClient() - from git_harness import ScanOptions repos = client.scan_repositories( ScanOptions(root_path=str(outer), use_cache=False, max_depth=20) From 89b0a19f6c54c27179c67dd0d308490d751d538d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 08:20:59 +0000 Subject: [PATCH 07/14] fix: always emit text/warning in CLI JSON; align path tests with Go Abs 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 --- cmd/git-harness-cli/main.go | 4 ++-- testkit/python/tests/test_cli_bridge.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index af4f6c4..5f0b01e 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -84,9 +84,9 @@ type response struct { StagedBranch string `json:"stagedBranch,omitempty"` FullBranch string `json:"fullBranch,omitempty"` BothCreated *bool `json:"bothCreated,omitempty"` - Text string `json:"text,omitempty"` + Text string `json:"text"` Lines []string `json:"lines,omitempty"` - Warning string `json:"warning,omitempty"` + Warning string `json:"warning"` Notice string `json:"notice,omitempty"` SuspiciousFiles []suspiciousFileOutput `json:"suspiciousFiles,omitempty"` } diff --git a/testkit/python/tests/test_cli_bridge.py b/testkit/python/tests/test_cli_bridge.py index 574faeb..761dd08 100644 --- a/testkit/python/tests/test_cli_bridge.py +++ b/testkit/python/tests/test_cli_bridge.py @@ -32,7 +32,7 @@ def test_analyze_repository_finds_git_dir(tmp_path: Path) -> None: client = GitHarnessClient() meta = client.analyze_repository(repo) - assert meta["path"] == str(repo.resolve()) + assert meta["path"] == str(repo.absolute()) assert meta["name"] == "r" assert meta["isDirty"] is False @@ -71,7 +71,7 @@ def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None: ScanOptions(root_path=str(outer), use_cache=False, max_depth=20) ) paths = {r["path"] for r in repos} - assert str(inner.resolve()) in paths + assert str(inner.absolute()) in paths def test_subprocess_json_contract_smoke() -> None: From e3a99e0a440b6a835a29322b1d983222c141828c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 16:54:33 +0000 Subject: [PATCH 08/14] fix: resolve PR review issues (CI dedupe, JSON omitempty, paths) - 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 --- .github/workflows/ci.yml | 3 ++- cmd/git-harness-cli/main.go | 2 +- .../main/java/io/gitfire/harness/CliBridge.java | 8 ++++++-- .../io/gitfire/harness/SampleRepoFlowSmoke.java | 6 +----- testkit/python/git_harness/cli.py | 8 ++++---- testkit/python/tests/test_cli_bridge.py | 17 ++++++++++++++--- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1778ef..b987e72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # 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 diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index 5f0b01e..7fd6c25 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -87,7 +87,7 @@ type response struct { Text string `json:"text"` Lines []string `json:"lines,omitempty"` Warning string `json:"warning"` - Notice string `json:"notice,omitempty"` + Notice string `json:"notice"` SuspiciousFiles []suspiciousFileOutput `json:"suspiciousFiles,omitempty"` } diff --git a/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java index 5c10930..9cee848 100644 --- a/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java @@ -244,13 +244,17 @@ public String safetySanitizeText(String text) { JsonObject req = new JsonObject(); req.addProperty("op", "safety_sanitize_text"); req.addProperty("text", text == null ? "" : text); - return invokeObject(req).get("text").getAsString(); + 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"); - return invokeObject(req).get("notice").getAsString(); + JsonObject res = invokeObject(req); + return res.has("notice") && !res.get("notice").isJsonNull() + ? res.get("notice").getAsString() + : ""; } public List listWorktrees(String repoPath) { diff --git a/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java index 2c14c66..acaee2b 100644 --- a/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java +++ b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java @@ -16,10 +16,6 @@ class SampleRepoFlowSmoke { @TempDir Path tmp; - private static Path workspaceRoot() { - return Path.of("../..").toAbsolutePath().normalize(); - } - private static void runGit(Path dir, String... args) throws Exception { runGitStdout(dir, args); } @@ -65,7 +61,7 @@ void sampleRepoFlowRuns() throws Exception { runGit(local, "add", "README.md"); runGit(local, "commit", "-m", "init"); - CliBridge bridge = new CliBridge(workspaceRoot()); + CliBridge bridge = new CliBridge(CliBridgeTest.workspaceRoot()); String branch = bridge.getCurrentBranch(local.toString()); runGit(local, "remote", "add", "origin", remote.toAbsolutePath().normalize().toString()); diff --git a/testkit/python/git_harness/cli.py b/testkit/python/git_harness/cli.py index a72a5b0..1ab1597 100644 --- a/testkit/python/git_harness/cli.py +++ b/testkit/python/git_harness/cli.py @@ -204,7 +204,7 @@ def create_fire_branch(self, repo_path: str, original_branch: str, local_sha: st originalBranch=original_branch, localSHA=local_sha, ) - return str(res["fireBranch"]) + return str(res.get("fireBranch", "")) def fetch_remote(self, repo_path: str, remote: str) -> None: _call("git_fetch_remote", repoPath=repo_path, remote=remote) @@ -217,7 +217,7 @@ def push_all_branches(self, repo_path: str, remote: str) -> None: def safety_sanitize_text(self, text: str) -> str: res = _call("safety_sanitize_text", text=text) - return str(res["text"]) + return str(res.get("text", "")) def safety_recommended_gitignore_patterns(self) -> list[str]: res = _call("safety_recommended_gitignore_patterns") @@ -225,11 +225,11 @@ def safety_recommended_gitignore_patterns(self) -> list[str]: def safety_security_notice(self) -> str: res = _call("safety_security_notice") - return str(res["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["warning"]) + 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) diff --git a/testkit/python/tests/test_cli_bridge.py b/testkit/python/tests/test_cli_bridge.py index 761dd08..6ca1cbb 100644 --- a/testkit/python/tests/test_cli_bridge.py +++ b/testkit/python/tests/test_cli_bridge.py @@ -2,6 +2,7 @@ import json import os +import os.path import shutil import subprocess from pathlib import Path @@ -32,7 +33,7 @@ def test_analyze_repository_finds_git_dir(tmp_path: Path) -> None: client = GitHarnessClient() meta = client.analyze_repository(repo) - assert meta["path"] == str(repo.absolute()) + assert os.path.realpath(meta["path"]) == os.path.realpath(str(repo)) assert meta["name"] == "r" assert meta["isDirty"] is False @@ -56,6 +57,16 @@ def test_safety_sanitize_text_masks_token() -> None: 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" @@ -70,8 +81,8 @@ def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None: repos = client.scan_repositories( ScanOptions(root_path=str(outer), use_cache=False, max_depth=20) ) - paths = {r["path"] for r in repos} - assert str(inner.absolute()) in paths + paths = {os.path.realpath(r["path"]) for r in repos} + assert os.path.realpath(str(inner)) in paths def test_subprocess_json_contract_smoke() -> None: From 927585a892f1c4e8585df01eadec823f37212f62 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 05:56:30 +0000 Subject: [PATCH 09/14] fix: stricter JSON protocol and resilient Python client (PR feedback) - 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 --- cmd/git-harness-cli/main.go | 4 +++- testkit/python/git_harness/cli.py | 7 ++++++- testkit/python/tests/test_cli_bridge.py | 28 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index 7fd6c25..01d7dde 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -138,7 +138,9 @@ func main() { 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) == "" { diff --git a/testkit/python/git_harness/cli.py b/testkit/python/git_harness/cli.py index 1ab1597..a69e2d8 100644 --- a/testkit/python/git_harness/cli.py +++ b/testkit/python/git_harness/cli.py @@ -27,9 +27,10 @@ def _cli_cmd() -> list[str]: def _call(op: str, **payload: Any) -> dict[str, Any]: request = {"op": op, **payload} + cmd = _cli_cmd() try: proc = subprocess.run( - _cli_cmd(), + cmd, cwd=_repo_root(), input=json.dumps(request), text=True, @@ -43,6 +44,10 @@ def _call(op: str, **payload: Any) -> dict[str, Any]: 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: diff --git a/testkit/python/tests/test_cli_bridge.py b/testkit/python/tests/test_cli_bridge.py index 6ca1cbb..fd0de24 100644 --- a/testkit/python/tests/test_cli_bridge.py +++ b/testkit/python/tests/test_cli_bridge.py @@ -26,6 +26,8 @@ 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") @@ -72,6 +74,8 @@ def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None: 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") @@ -85,6 +89,30 @@ def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None: assert os.path.realpath(str(inner)) in paths +def test_cli_rejects_unknown_json_keys() -> None: + """parseRequest uses DisallowUnknownFields — typos must fail fast.""" + 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())] + proc = subprocess.run( + 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_subprocess_json_contract_smoke() -> None: """Guardrail: stdin JSON shape accepted by the Go CLI.""" root = Path(__file__).resolve().parents[3] From c5d21839dd4b844fd0368468f50cf9b5596f56e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 06:09:40 +0000 Subject: [PATCH 10/14] fix: omit empty CLI response strings; parse JSON once in CliBridge Co-authored-by: Ben Schellenberger --- cmd/git-harness-cli/main.go | 6 +++--- .../src/main/java/io/gitfire/harness/CliBridge.java | 12 +++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index 01d7dde..3c5a584 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -84,10 +84,10 @@ type response struct { StagedBranch string `json:"stagedBranch,omitempty"` FullBranch string `json:"fullBranch,omitempty"` BothCreated *bool `json:"bothCreated,omitempty"` - Text string `json:"text"` + Text string `json:"text,omitempty"` Lines []string `json:"lines,omitempty"` - Warning string `json:"warning"` - Notice string `json:"notice"` + Warning string `json:"warning,omitempty"` + Notice string `json:"notice,omitempty"` SuspiciousFiles []suspiciousFileOutput `json:"suspiciousFiles,omitempty"` } diff --git a/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java index 9cee848..2ed4c4f 100644 --- a/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java @@ -279,16 +279,10 @@ public List listWorktrees(String repoPath) { } private JsonObject invokeObject(JsonObject request) { - String raw = invokeRaw(GSON.toJson(request)); - JsonObject obj = JsonParser.parseString(raw).getAsJsonObject(); - if (!obj.has("ok") || !obj.get("ok").getAsBoolean()) { - String err = obj.has("error") ? obj.get("error").getAsString() : "unknown error"; - throw new RuntimeException(err); - } - return obj; + return invokeRaw(GSON.toJson(request)); } - private String invokeRaw(String payload) { + 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(); @@ -303,7 +297,7 @@ private String invokeRaw(String payload) { String err = head.has("error") ? head.get("error").getAsString() : stderr; throw new RuntimeException(err.isEmpty() ? "CLI failed with code " + result.code : err); } - return stdout; + return head; } private CliResult runCli(String payload) { From e7feb7ad7bfdd44f2d93fc6d37ef1953ee1befad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 06:22:27 +0000 Subject: [PATCH 11/14] fix: align auto_commit_dirty API with behavior; drop dead request args 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 --- cmd/git-harness-cli/main.go | 30 ++++++++----------- .../contracts/cli-protocol.json | 1 - testkit/python/git_harness/cli.py | 4 --- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index 3c5a584..317ee01 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -23,17 +23,16 @@ type request struct { // 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"` + 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"` @@ -300,16 +299,13 @@ func handle(req request) (response, error) { 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 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 } diff --git a/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json index 42c2655..73fb532 100644 --- a/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json +++ b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json @@ -19,7 +19,6 @@ "addAll": {"type": "boolean"}, "useDualBranch": {"type": "boolean"}, "returnToOriginal": {"type": "boolean"}, - "args": {"type": "array", "items": {"type": "string"}}, "scanOptions": {"type": "object"}, "text": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}, diff --git a/testkit/python/git_harness/cli.py b/testkit/python/git_harness/cli.py index a69e2d8..32b73dc 100644 --- a/testkit/python/git_harness/cli.py +++ b/testkit/python/git_harness/cli.py @@ -172,16 +172,12 @@ def auto_commit_dirty( *, message: str = "", add_all: bool = False, - use_dual_branch: bool = True, - return_to_original: bool = True, ) -> None: _call( "git_auto_commit_dirty", repoPath=repo_path, message=message, addAll=add_all, - useDualBranch=use_dual_branch, - returnToOriginal=return_to_original, ) def auto_commit_dirty_with_strategy( From 65e68dfc0e1f86d63a24a5c0d84cb80b7a0bf552 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 05:23:47 +0000 Subject: [PATCH 12/14] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit --- cmd/git-harness-cli/main.go | 16 +++++++++++----- .../main/java/io/gitfire/harness/CliBridge.java | 16 +++++++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index 317ee01..39654c7 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -49,7 +49,7 @@ type scanOptionsInput struct { CacheTTL string `json:"cacheTTL,omitempty"` Workers int `json:"workers,omitempty"` KnownPaths map[string]bool `json:"knownPaths,omitempty"` - DisableScan bool `json:"disableScan,omitempty"` + DisableScan *bool `json:"disableScan,omitempty"` } type suspiciousFileInput struct { @@ -132,7 +132,9 @@ func main() { writeResponse(response{OK: false, Error: err.Error()}) os.Exit(1) } - writeResponse(res) + if err := writeResponse(res); err != nil { + os.Exit(1) + } } func parseRequest() (request, error) { @@ -462,7 +464,9 @@ func mergeScanOptions(in *scanOptionsInput) (git.ScanOptions, error) { if in.KnownPaths != nil { opts.KnownPaths = in.KnownPaths } - opts.DisableScan = in.DisableScan + if in.DisableScan != nil { + opts.DisableScan = *in.DisableScan + } return opts, nil } @@ -487,7 +491,7 @@ func repoToOut(r git.Repository) repositoryOut { } } -func writeResponse(res response) { +func writeResponse(res response) error { enc := json.NewEncoder(os.Stdout) enc.SetEscapeHTML(false) if err := enc.Encode(res); err != nil { @@ -500,5 +504,7 @@ func writeResponse(res response) { 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/java/src/main/java/io/gitfire/harness/CliBridge.java b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java index 2ed4c4f..d27bf33 100644 --- a/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java @@ -174,10 +174,20 @@ 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()) { + 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()); } - 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"); } @@ -391,4 +401,4 @@ private static RepositoryMeta parseRepository(JsonObject o) { o.get("selected").getAsBoolean(), o.get("mode").getAsString()); } -} +} \ No newline at end of file From e5b3af123574f6417addde25ccc608aaa886f59e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 05:25:58 +0000 Subject: [PATCH 13/14] Remove unused response output field from CLI protocol Co-authored-by: Ben Schellenberger --- cmd/git-harness-cli/main.go | 1 - .../specs/001-polyglot-harness/contracts/cli-protocol.json | 1 - 2 files changed, 2 deletions(-) diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index 39654c7..c3f3cb9 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -67,7 +67,6 @@ type response struct { 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"` diff --git a/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json index 73fb532..c6db06d 100644 --- a/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json +++ b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json @@ -45,7 +45,6 @@ "repositories": {"type": "array"}, "repository": {"type": "object"}, "dirty": {"type": "boolean"}, - "output": {"type": "string"}, "sha": {"type": "string"}, "branch": {"type": "string"}, "hasConflict": {"type": "boolean"}, From f8311b367a61ef72f80f1b5444cb509167f8cdfe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 06:01:30 +0000 Subject: [PATCH 14/14] fix: reject trailing JSON in CLI; resolve GIT_HARNESS_CLI via PATH 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 --- cmd/git-harness-cli/main.go | 10 +++- testkit/python/git_harness/cli.py | 19 ++++++-- testkit/python/tests/test_cli_bridge.py | 61 +++++++++++++++++++++---- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go index c3f3cb9..80723ae 100644 --- a/cmd/git-harness-cli/main.go +++ b/cmd/git-harness-cli/main.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "io" "os" "strings" "time" @@ -143,6 +144,13 @@ func parseRequest() (request, error) { 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") } @@ -506,4 +514,4 @@ func writeResponse(res response) error { return err } return nil -} \ No newline at end of file +} diff --git a/testkit/python/git_harness/cli.py b/testkit/python/git_harness/cli.py index 32b73dc..fff6cc0 100644 --- a/testkit/python/git_harness/cli.py +++ b/testkit/python/git_harness/cli.py @@ -2,6 +2,7 @@ import json import os +import shutil import subprocess from dataclasses import dataclass, field from pathlib import Path @@ -17,12 +18,20 @@ def _repo_root() -> Path: def _cli_cmd() -> list[str]: cli = os.environ.get("GIT_HARNESS_CLI", "").strip() - if cli: - cli_path = Path(cli) - if not cli_path.is_absolute(): - cli_path = _repo_root() / cli_path + 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)] - return ["go", "run", "./cmd/git-harness-cli"] + # 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]: diff --git a/testkit/python/tests/test_cli_bridge.py b/testkit/python/tests/test_cli_bridge.py index fd0de24..9c1d29c 100644 --- a/testkit/python/tests/test_cli_bridge.py +++ b/testkit/python/tests/test_cli_bridge.py @@ -7,7 +7,10 @@ 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: @@ -89,15 +92,20 @@ def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None: assert os.path.realpath(str(inner)) in paths -def test_cli_rejects_unknown_json_keys() -> None: - """parseRequest uses DisallowUnknownFields — typos must fail fast.""" +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( - cmd, + _git_harness_cli_cmd(), cwd=root, input=json.dumps({"op": "safety_security_notice", "typoField": 1}), text=True, @@ -113,16 +121,51 @@ def test_cli_rejects_unknown_json_keys() -> None: 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] - 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: - # Relative path from repo root (typical in CI) - cmd = [str((root / cli).resolve())] proc = subprocess.run( - cmd, + _git_harness_cli_cmd(), cwd=root, input=json.dumps({"op": "safety_security_notice"}), text=True,