diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8daf8ca..b987e72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,23 @@ permissions: contents: read jobs: + spec-kit-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate spec-kit artifact set + run: | + test -f testkit/.specify/memory/constitution.md + test -f testkit/.specify/specs/001-polyglot-harness/spec.md + test -f testkit/.specify/specs/001-polyglot-harness/plan.md + test -f testkit/.specify/specs/001-polyglot-harness/tasks.md + test -f testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json + test -f testkit/.specify/specs/001-polyglot-harness/checklists/quality.md + + - name: Validate spec-kit scaffold and status + run: ./testkit/.specify/scripts/validate_specify.sh + test: runs-on: ubuntu-latest steps: @@ -21,7 +38,7 @@ jobs: cache: true - name: Check gofmt - run: test -z "$(gofmt -l git safety)" + run: test -z "$(gofmt -l git safety cmd)" - name: Build run: go build ./... @@ -31,3 +48,129 @@ jobs: - name: Test run: go test -race -count=1 ./... + + testkit: + runs-on: ubuntu-latest + needs: [spec-kit-conformance, test] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build git-harness CLI binary once + run: | + mkdir -p bin + go build -o ./bin/git-harness-cli ./cmd/git-harness-cli + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Run Python wrapper tests + env: + GIT_HARNESS_CLI: ./bin/git-harness-cli + run: | + cd testkit/python + python -m pip install -e ".[dev]" + python -m pytest tests/ -v + + - name: Run Python sample smoke implementations + env: + GIT_HARNESS_CLI: ./bin/git-harness-cli + run: | + cd testkit/python + python -m samples.smoke_repo_flow + python -m samples.smoke_safety_flow + + - name: Run Java wrapper tests + env: + GIT_HARNESS_CLI: ./bin/git-harness-cli + run: | + cd testkit/java + mvn test + + - name: Run Java sample smoke implementations + env: + GIT_HARNESS_CLI: ./bin/git-harness-cli + run: | + cd testkit/java + mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test + + wrapper-cross-platform: + runs-on: ${{ matrix.os }} + needs: [spec-kit-conformance, test] + strategy: + fail-fast: false + matrix: + # Linux wrapper path is covered by the `testkit` job; matrix only cross-checks macOS + Windows. + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build git-harness CLI binary once + shell: bash + run: | + mkdir -p bin + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then + go build -o ./bin/git-harness-cli.exe ./cmd/git-harness-cli + else + go build -o ./bin/git-harness-cli ./cmd/git-harness-cli + fi + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Run Python wrapper smoke tests + shell: bash + env: + GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }} + run: | + cd testkit/python + python -m pip install -e ".[dev]" + python -m pytest tests/ -v + + - name: Run Python sample smoke implementations + shell: bash + env: + GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }} + run: | + cd testkit/python + python -m samples.smoke_repo_flow + python -m samples.smoke_safety_flow + + - name: Run Java wrapper smoke tests + shell: bash + env: + GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }} + run: | + cd testkit/java + mvn test + + - name: Run Java sample smoke implementations + shell: bash + env: + GIT_HARNESS_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-harness-cli.exe' || './bin/git-harness-cli' }} + run: | + cd testkit/java + mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test diff --git a/.gitignore b/.gitignore index 6aa0f27..3fc354d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ # Local reference clones (not part of this module) /mnt/ + +# testkit (polyglot) build artifacts +testkit/python/**/__pycache__/ +testkit/python/**/*.egg-info/ +testkit/python/.pytest_cache/ +testkit/java/target/ diff --git a/CURSOR_ULTRA_PLAN.md b/CURSOR_ULTRA_PLAN.md index 498d019..e7754b7 100644 --- a/CURSOR_ULTRA_PLAN.md +++ b/CURSOR_ULTRA_PLAN.md @@ -113,11 +113,10 @@ git-harness/ .github/ workflows/ ci.yml # Go build + test + vet - wrappers/ # placeholder dirs for Phase 5 & 6 + testkit/ # polyglot layout (mirror git-testkit) python/ - .gitkeep java/ - .gitkeep + .specify/ ``` > **Local wiring (temporary):** Add `replace github.com/git-fire/git-harness => ../git-harness` @@ -170,11 +169,11 @@ Commit: `refactor(git-harness): remove extracted internals from companion CLI` > Follow git-testkit's Python wrapper structure exactly. Read it before writing anything here. ### 5.1 Scaffold -Mirror whatever structure git-testkit uses under `wrappers/python/`. This likely means: -- A Python package under `wrappers/python/git_harness/` +Mirror whatever structure git-testkit uses under `testkit/python/`. This likely means: +- A Python package under `testkit/python/git_harness/` - Build tooling (cffi, ctypes, subprocess bridge, or whatever git-testkit uses) - `pyproject.toml` / `setup.py` -- `wrappers/python/README.md` +- `testkit/python/README.md` ### 5.2 Implement Expose the same surface area as the Go module — subprocess runner, safety/sanitize, repo introspection. @@ -198,10 +197,10 @@ Commit: `feat(git-harness): Python wrapper` > Follow git-testkit's Java wrapper structure exactly. Read it before writing anything here. ### 6.1 Scaffold -Mirror whatever structure git-testkit uses under `wrappers/java/`. Likely: -- Maven or Gradle project under `wrappers/java/` +Mirror whatever structure git-testkit uses under `testkit/java/`. Likely: +- Maven or Gradle project under `testkit/java/` - `src/main/java/io/gitfire/harness/` -- `wrappers/java/README.md` +- `testkit/java/README.md` ### 6.2 Implement Expose the same surface area as the Go module. diff --git a/README.md b/README.md index 1c750c4..689d1d8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ Go library extracted from [git-fire](https://github.com/git-fire/git-fire): subp - **`git`** — repository scanning, status, commits, pushes, worktrees, and related helpers. - **`safety`** — redaction and secret-pattern scanning helpers used by git error paths. +## Polyglot wrappers + +Python and Java clients use the same layout as [git-testkit](https://github.com/git-fire/git-testkit) under **`testkit/`**: build `cmd/git-harness-cli`, set **`GIT_HARNESS_CLI`** to that binary (or rely on `go run ./cmd/git-harness-cli` from the repo root). Code lives in `testkit/python` and `testkit/java`; runnable samples are `testkit/python/samples/` and the Java `Sample*Smoke` tests. + ## Requirements - Go **1.24**+ (see `go.mod`). diff --git a/cmd/git-harness-cli/main.go b/cmd/git-harness-cli/main.go new file mode 100644 index 0000000..80723ae --- /dev/null +++ b/cmd/git-harness-cli/main.go @@ -0,0 +1,517 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/git-fire/git-harness/git" + "github.com/git-fire/git-harness/safety" +) + +type request struct { + Op string `json:"op"` + + // scan_repositories + ScanOptions *scanOptionsInput `json:"scanOptions,omitempty"` + + // analyze_repository, git_* ops + RepoPath string `json:"repoPath,omitempty"` + + // git_get_commit_sha, git_ref_is_ancestor + Ref string `json:"ref,omitempty"` + // git_ref_is_ancestor + AncestorRef string `json:"ancestorRef,omitempty"` + DescendantRef string `json:"descendantRef,omitempty"` + Branch string `json:"branch,omitempty"` + Remote string `json:"remote,omitempty"` + OriginalBranch string `json:"originalBranch,omitempty"` + LocalSHA string `json:"localSHA,omitempty"` + Message string `json:"message,omitempty"` + AddAll *bool `json:"addAll,omitempty"` + UseDualBranch *bool `json:"useDualBranch,omitempty"` + ReturnToOriginal *bool `json:"returnToOriginal,omitempty"` + + // safety + Text string `json:"text,omitempty"` + Files []string `json:"files,omitempty"` + FilesSuspicious []suspiciousFileInput `json:"suspiciousFiles,omitempty"` +} + +type scanOptionsInput struct { + RootPath string `json:"rootPath,omitempty"` + Exclude []string `json:"exclude,omitempty"` + MaxDepth int `json:"maxDepth,omitempty"` + UseCache *bool `json:"useCache,omitempty"` + CacheFile string `json:"cacheFile,omitempty"` + CacheTTL string `json:"cacheTTL,omitempty"` + Workers int `json:"workers,omitempty"` + KnownPaths map[string]bool `json:"knownPaths,omitempty"` + DisableScan *bool `json:"disableScan,omitempty"` +} + +type suspiciousFileInput struct { + Path string `json:"path"` + Reason string `json:"reason"` + Patterns []string `json:"patterns,omitempty"` + LineNumbers []int `json:"lineNumbers,omitempty"` +} + +type response struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + + Repositories []repositoryOut `json:"repositories,omitempty"` + Repository *repositoryOut `json:"repository,omitempty"` + + Dirty *bool `json:"dirty,omitempty"` + SHA string `json:"sha,omitempty"` + Branches []string `json:"branches,omitempty"` + HasConflict *bool `json:"hasConflict,omitempty"` + LocalSHA string `json:"localSHA,omitempty"` + RemoteSHA string `json:"remoteSHA,omitempty"` + IsAncestor *bool `json:"isAncestor,omitempty"` + Branch string `json:"branch,omitempty"` + Staged *bool `json:"staged,omitempty"` + Unstaged *bool `json:"unstaged,omitempty"` + Paths []string `json:"paths,omitempty"` + Worktrees []worktreeOut `json:"worktrees,omitempty"` + FireBranch string `json:"fireBranch,omitempty"` + StagedBranch string `json:"stagedBranch,omitempty"` + FullBranch string `json:"fullBranch,omitempty"` + BothCreated *bool `json:"bothCreated,omitempty"` + Text string `json:"text,omitempty"` + Lines []string `json:"lines,omitempty"` + Warning string `json:"warning,omitempty"` + Notice string `json:"notice,omitempty"` + SuspiciousFiles []suspiciousFileOutput `json:"suspiciousFiles,omitempty"` +} + +type suspiciousFileOutput struct { + Path string `json:"path"` + Reason string `json:"reason"` + Patterns []string `json:"patterns"` + LineNumbers []int `json:"lineNumbers"` +} + +type remoteOut struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type repositoryOut struct { + Path string `json:"path"` + Name string `json:"name"` + Remotes []remoteOut `json:"remotes"` + Branches []string `json:"branches"` + IsDirty bool `json:"isDirty"` + LastModified time.Time `json:"lastModified"` + Selected bool `json:"selected"` + Mode string `json:"mode"` +} + +type worktreeOut struct { + Path string `json:"path"` + Branch string `json:"branch"` + Head string `json:"head"` + IsMain bool `json:"isMain"` +} + +func main() { + req, err := parseRequest() + if err != nil { + writeResponse(response{OK: false, Error: err.Error()}) + os.Exit(1) + } + + res, err := handle(req) + if err != nil { + writeResponse(response{OK: false, Error: err.Error()}) + os.Exit(1) + } + if err := writeResponse(res); err != nil { + os.Exit(1) + } +} + +func parseRequest() (request, error) { + var req request + dec := json.NewDecoder(os.Stdin) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + return request{}, fmt.Errorf("invalid JSON request: %w", err) + } + var trailer json.RawMessage + if err := dec.Decode(&trailer); err != io.EOF { + if err == nil { + return request{}, fmt.Errorf("invalid JSON request: multiple JSON values") + } + return request{}, fmt.Errorf("invalid JSON request: trailing data: %w", err) + } + if strings.TrimSpace(req.Op) == "" { + return request{}, fmt.Errorf("missing required field: op") + } + return req, nil +} + +func handle(req request) (response, error) { + switch req.Op { + case "scan_repositories": + opts, err := mergeScanOptions(req.ScanOptions) + if err != nil { + return response{}, err + } + repos, err := git.ScanRepositories(opts) + if err != nil { + return response{}, err + } + out := make([]repositoryOut, 0, len(repos)) + for _, r := range repos { + out = append(out, repoToOut(r)) + } + return response{OK: true, Repositories: out}, nil + + case "analyze_repository": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + r, err := git.AnalyzeRepository(req.RepoPath) + if err != nil { + return response{}, err + } + ro := repoToOut(r) + return response{OK: true, Repository: &ro}, nil + + case "git_is_dirty": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + d, err := git.IsDirty(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Dirty: &d}, nil + + case "git_get_current_branch": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + b, err := git.GetCurrentBranch(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Branch: b}, nil + + case "git_get_commit_sha": + if req.RepoPath == "" || req.Ref == "" { + return response{}, fmt.Errorf("missing repoPath or ref") + } + sha, err := git.GetCommitSHA(req.RepoPath, req.Ref) + if err != nil { + return response{}, err + } + return response{OK: true, SHA: sha}, nil + + case "git_list_local_branches": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + br, err := git.ListLocalBranches(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Branches: br}, nil + + case "git_list_remote_branches": + if req.RepoPath == "" || req.Remote == "" { + return response{}, fmt.Errorf("missing repoPath or remote") + } + br, err := git.ListRemoteBranches(req.RepoPath, req.Remote) + if err != nil { + return response{}, err + } + return response{OK: true, Branches: br}, nil + + case "git_ref_is_ancestor": + if req.RepoPath == "" || req.AncestorRef == "" || req.DescendantRef == "" { + return response{}, fmt.Errorf("missing repoPath, ancestorRef, or descendantRef") + } + ok, err := git.RefIsAncestor(req.RepoPath, req.AncestorRef, req.DescendantRef) + if err != nil { + return response{}, err + } + return response{OK: true, IsAncestor: &ok}, nil + + case "git_detect_conflict": + if req.RepoPath == "" || req.Branch == "" || req.Remote == "" { + return response{}, fmt.Errorf("missing repoPath, branch, or remote") + } + has, local, remote, err := git.DetectConflict(req.RepoPath, req.Branch, req.Remote) + if err != nil { + return response{}, err + } + return response{OK: true, HasConflict: &has, LocalSHA: local, RemoteSHA: remote}, nil + + case "git_has_staged_changes": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + v, err := git.HasStagedChanges(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Staged: &v}, nil + + case "git_has_unstaged_changes": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + v, err := git.HasUnstagedChanges(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Unstaged: &v}, nil + + case "git_get_uncommitted_files": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + paths, err := git.GetUncommittedFiles(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Paths: paths}, nil + + case "git_list_worktrees": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + wts, err := git.ListWorktrees(req.RepoPath) + if err != nil { + return response{}, err + } + out := make([]worktreeOut, 0, len(wts)) + for _, w := range wts { + out = append(out, worktreeOut{ + Path: w.Path, + Branch: w.Branch, + Head: w.Head, + IsMain: w.IsMain, + }) + } + return response{OK: true, Worktrees: out}, nil + + case "git_auto_commit_dirty": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + if req.UseDualBranch != nil || req.ReturnToOriginal != nil { + return response{}, fmt.Errorf("git_auto_commit_dirty does not support useDualBranch or returnToOriginal; use git_auto_commit_dirty_with_strategy") + } + co := git.CommitOptions{Message: req.Message} + if req.AddAll != nil { + co.AddAll = *req.AddAll + } + if err := git.AutoCommitDirty(req.RepoPath, co); err != nil { + return response{}, err + } + return response{OK: true}, nil + + case "git_auto_commit_dirty_with_strategy": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + co := git.CommitOptions{Message: req.Message} + if req.AddAll != nil { + co.AddAll = *req.AddAll + } + if req.UseDualBranch != nil { + co.UseDualBranch = *req.UseDualBranch + } + if req.ReturnToOriginal != nil { + co.ReturnToOriginal = *req.ReturnToOriginal + } + res, err := git.AutoCommitDirtyWithStrategy(req.RepoPath, co) + if err != nil { + return response{}, err + } + bc := res.BothCreated + return response{ + OK: true, + StagedBranch: res.StagedBranch, + FullBranch: res.FullBranch, + BothCreated: &bc, + }, nil + + case "git_create_fire_branch": + if req.RepoPath == "" || req.OriginalBranch == "" || req.LocalSHA == "" { + return response{}, fmt.Errorf("missing repoPath, originalBranch, or localSHA") + } + name, err := git.CreateFireBranch(req.RepoPath, req.OriginalBranch, req.LocalSHA) + if err != nil { + return response{}, err + } + return response{OK: true, FireBranch: name}, nil + + case "git_fetch_remote": + if req.RepoPath == "" || req.Remote == "" { + return response{}, fmt.Errorf("missing repoPath or remote") + } + if err := git.FetchRemote(req.RepoPath, req.Remote); err != nil { + return response{}, err + } + return response{OK: true}, nil + + case "git_push_branch": + if req.RepoPath == "" || req.Remote == "" || req.Branch == "" { + return response{}, fmt.Errorf("missing repoPath, remote, or branch") + } + if err := git.PushBranch(req.RepoPath, req.Remote, req.Branch); err != nil { + return response{}, err + } + return response{OK: true}, nil + + case "git_push_all_branches": + if req.RepoPath == "" || req.Remote == "" { + return response{}, fmt.Errorf("missing repoPath or remote") + } + if err := git.PushAllBranches(req.RepoPath, req.Remote); err != nil { + return response{}, err + } + return response{OK: true}, nil + + case "safety_sanitize_text": + return response{OK: true, Text: safety.SanitizeText(req.Text)}, nil + + case "safety_recommended_gitignore_patterns": + p := safety.RecommendedGitignorePatterns() + return response{OK: true, Lines: p}, nil + + case "safety_security_notice": + return response{OK: true, Notice: safety.SecurityNotice()}, nil + + case "safety_format_warning": + files := make([]safety.SuspiciousFile, 0, len(req.FilesSuspicious)) + for _, f := range req.FilesSuspicious { + files = append(files, safety.SuspiciousFile{ + Path: f.Path, + Reason: f.Reason, + Patterns: f.Patterns, + LineNumbers: f.LineNumbers, + }) + } + return response{OK: true, Warning: safety.FormatWarning(files)}, nil + + case "safety_scan_files": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + sc := safety.NewSecretScanner() + found, err := sc.ScanFiles(req.RepoPath, req.Files) + if err != nil { + return response{}, err + } + out := make([]suspiciousFileOutput, 0, len(found)) + for _, f := range found { + patterns := f.Patterns + if patterns == nil { + patterns = []string{} + } + lines := f.LineNumbers + if lines == nil { + lines = []int{} + } + out = append(out, suspiciousFileOutput{ + Path: f.Path, + Reason: f.Reason, + Patterns: patterns, + LineNumbers: lines, + }) + } + return response{OK: true, SuspiciousFiles: out}, nil + + default: + return response{}, fmt.Errorf("unsupported op: %s", req.Op) + } +} + +func mergeScanOptions(in *scanOptionsInput) (git.ScanOptions, error) { + opts := git.DefaultScanOptions() + if in == nil { + return opts, nil + } + if in.RootPath != "" { + opts.RootPath = in.RootPath + } + if in.Exclude != nil { + opts.Exclude = in.Exclude + } + if in.MaxDepth > 0 { + opts.MaxDepth = in.MaxDepth + } + if in.UseCache != nil { + opts.UseCache = *in.UseCache + } + if in.CacheFile != "" { + opts.CacheFile = in.CacheFile + } + if in.CacheTTL != "" { + d, err := time.ParseDuration(in.CacheTTL) + if err != nil { + return git.ScanOptions{}, fmt.Errorf("invalid scanOptions.cacheTTL %q: %w", in.CacheTTL, err) + } + opts.CacheTTL = d + } + if in.Workers > 0 { + opts.Workers = in.Workers + } + if in.KnownPaths != nil { + opts.KnownPaths = in.KnownPaths + } + if in.DisableScan != nil { + opts.DisableScan = *in.DisableScan + } + return opts, nil +} + +func repoToOut(r git.Repository) repositoryOut { + rem := make([]remoteOut, 0, len(r.Remotes)) + for _, x := range r.Remotes { + rem = append(rem, remoteOut{Name: x.Name, URL: x.URL}) + } + branches := r.Branches + if branches == nil { + branches = []string{} + } + return repositoryOut{ + Path: r.Path, + Name: r.Name, + Remotes: rem, + Branches: branches, + IsDirty: r.IsDirty, + LastModified: r.LastModified, + Selected: r.Selected, + Mode: r.Mode.String(), + } +} + +func writeResponse(res response) error { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(res); err != nil { + fallback := response{ + OK: false, + Error: fmt.Sprintf("failed writing response: %s", err.Error()), + } + stderrEnc := json.NewEncoder(os.Stderr) + stderrEnc.SetEscapeHTML(false) + if encodeErr := stderrEnc.Encode(fallback); encodeErr != nil { + fmt.Fprintf(os.Stderr, "failed writing fallback response: %v\n", encodeErr) + } + return err + } + return nil +} diff --git a/testkit/.specify/memory/constitution.md b/testkit/.specify/memory/constitution.md new file mode 100644 index 0000000..9d010c4 --- /dev/null +++ b/testkit/.specify/memory/constitution.md @@ -0,0 +1,19 @@ +# git-harness testkit constitution + +## Core principles + +### I. Real git only + +Conformance tests run against the real `git` binary on `PATH`. Wrappers delegate to **`git-harness-cli`**, which shells out to git for repository operations. + +### II. Single behavior source + +The **Go** packages (`git`, `safety`) and **`cmd/git-harness-cli`** are the behavior source. Python and Java clients are thin bridges over the JSON protocol. + +### III. Deterministic and bounded + +Samples and tests use temporary directories under the OS temp root. No network remotes beyond local bare repos created on disk. + +### IV. Executable proof + +Every polyglot change keeps **Go tests**, **Python pytest**, and **Java Maven** smoke paths green in CI. diff --git a/testkit/.specify/scripts/validate_specify.sh b/testkit/.specify/scripts/validate_specify.sh new file mode 100755 index 0000000..06a4d5c --- /dev/null +++ b/testkit/.specify/scripts/validate_specify.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +SPEC_DIR="$ROOT_DIR/testkit/.specify/specs/001-polyglot-harness" + +required_files=( + "$ROOT_DIR/testkit/.specify/memory/constitution.md" + "$SPEC_DIR/spec.md" + "$SPEC_DIR/plan.md" + "$SPEC_DIR/tasks.md" + "$SPEC_DIR/contracts/cli-protocol.json" + "$SPEC_DIR/checklists/quality.md" +) + +for file in "${required_files[@]}"; do + if [[ ! -f "$file" ]]; then + echo "missing required spec-kit artifact: $file" >&2 + exit 1 + fi +done + +grep -q "Status\\*\\*: Implemented (canonical spec-kit baseline)" "$SPEC_DIR/spec.md" +grep -q "Status\\*\\*: Implemented (canonical spec-kit baseline)" "$SPEC_DIR/plan.md" +grep -q "T015 Add spec-kit command workflow doc + shell helper" "$SPEC_DIR/tasks.md" +grep -q "\\[x\\] T015" "$SPEC_DIR/tasks.md" +grep -q "\"supported_ops\"" "$SPEC_DIR/contracts/cli-protocol.json" +grep -q "\\[x\\] Smoke test coverage exists for Go, Python wrapper, and Java wrapper paths." "$SPEC_DIR/checklists/quality.md" + +echo "spec-kit artifacts validated" diff --git a/testkit/.specify/specs/001-polyglot-harness/checklists/quality.md b/testkit/.specify/specs/001-polyglot-harness/checklists/quality.md new file mode 100644 index 0000000..bf2f206 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-harness/checklists/quality.md @@ -0,0 +1,8 @@ +# Quality checklist: 001-polyglot-harness + +- [x] Spec defines measurable success criteria. +- [x] Plan maps spec requirements to concrete implementation phases. +- [x] Tasks are executable and ordered. +- [x] Contract schema exists for CLI request/response protocol. +- [x] Smoke test coverage exists for Go, Python wrapper, and Java wrapper paths. +- [x] Go library API for `git` / `safety` remains the primary integration surface for Go consumers. diff --git a/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json new file mode 100644 index 0000000..c6db06d --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json @@ -0,0 +1,118 @@ +{ + "name": "git-harness-cli protocol", + "version": "1.0.0", + "transport": "stdin JSON request -> stdout JSON response", + "request_schema": { + "type": "object", + "required": ["op"], + "properties": { + "op": {"type": "string"}, + "repoPath": {"type": "string"}, + "ref": {"type": "string"}, + "ancestorRef": {"type": "string"}, + "descendantRef": {"type": "string"}, + "branch": {"type": "string"}, + "remote": {"type": "string"}, + "originalBranch": {"type": "string"}, + "localSHA": {"type": "string"}, + "message": {"type": "string"}, + "addAll": {"type": "boolean"}, + "useDualBranch": {"type": "boolean"}, + "returnToOriginal": {"type": "boolean"}, + "scanOptions": {"type": "object"}, + "text": {"type": "string"}, + "files": {"type": "array", "items": {"type": "string"}}, + "suspiciousFiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "reason": {"type": "string"}, + "patterns": {"type": "array", "items": {"type": "string"}}, + "lineNumbers": {"type": "array", "items": {"type": "integer"}} + } + } + } + } + }, + "response_schema": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": {"type": "boolean"}, + "error": {"type": "string"}, + "repositories": {"type": "array"}, + "repository": {"type": "object"}, + "dirty": {"type": "boolean"}, + "sha": {"type": "string"}, + "branch": {"type": "string"}, + "hasConflict": {"type": "boolean"}, + "localSHA": {"type": "string"}, + "remoteSHA": {"type": "string"}, + "isAncestor": {"type": "boolean"}, + "staged": {"type": "boolean"}, + "unstaged": {"type": "boolean"}, + "paths": {"type": "array", "items": {"type": "string"}}, + "branches": {"type": "array", "items": {"type": "string"}}, + "worktrees": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "branch": {"type": "string"}, + "head": {"type": "string"}, + "isMain": {"type": "boolean"} + } + } + }, + "fireBranch": {"type": "string"}, + "stagedBranch": {"type": "string"}, + "fullBranch": {"type": "string"}, + "bothCreated": {"type": "boolean"}, + "lines": {"type": "array", "items": {"type": "string"}}, + "text": {"type": "string"}, + "notice": {"type": "string"}, + "warning": {"type": "string"}, + "suspiciousFiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "reason": {"type": "string"}, + "patterns": {"type": "array", "items": {"type": "string"}}, + "lineNumbers": {"type": "array", "items": {"type": "integer"}} + } + } + } + } + }, + "supported_ops": [ + "scan_repositories", + "analyze_repository", + "git_is_dirty", + "git_get_current_branch", + "git_get_commit_sha", + "git_list_local_branches", + "git_list_remote_branches", + "git_ref_is_ancestor", + "git_detect_conflict", + "git_has_staged_changes", + "git_has_unstaged_changes", + "git_get_uncommitted_files", + "git_list_worktrees", + "git_auto_commit_dirty", + "git_auto_commit_dirty_with_strategy", + "git_create_fire_branch", + "git_fetch_remote", + "git_push_branch", + "git_push_all_branches", + "safety_sanitize_text", + "safety_recommended_gitignore_patterns", + "safety_security_notice", + "safety_format_warning", + "safety_scan_files" + ] +} diff --git a/testkit/.specify/specs/001-polyglot-harness/plan.md b/testkit/.specify/specs/001-polyglot-harness/plan.md new file mode 100644 index 0000000..bfff288 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-harness/plan.md @@ -0,0 +1,16 @@ +# Implementation plan: Polyglot git-harness + +**Feature**: `001-polyglot-harness` +**Input**: `testkit/.specify/specs/001-polyglot-harness/spec.md` +**Status**: Implemented (canonical spec-kit baseline) + +## Summary + +Ship **`cmd/git-harness-cli`** (JSON stdin → JSON stdout) and thin clients under **`testkit/python`** (`git_harness`) and **`testkit/java`** (`io.gitfire.harness`), mirroring [git-testkit](https://github.com/git-fire/git-testkit). + +## Artifact map + +- Bridge: `cmd/git-harness-cli/main.go` +- Python: `testkit/python/git_harness/`, `testkit/python/tests/`, `testkit/python/samples/` +- Java: `testkit/java/` (Maven, Gson) +- Spec-kit: `testkit/.specify/**` diff --git a/testkit/.specify/specs/001-polyglot-harness/spec.md b/testkit/.specify/specs/001-polyglot-harness/spec.md new file mode 100644 index 0000000..ebf6e67 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-harness/spec.md @@ -0,0 +1,18 @@ +# Feature specification: Polyglot git-harness (CLI bridge) + +**Feature branch**: `001-polyglot-harness` +**Status**: Implemented (canonical spec-kit baseline) + +## Goal + +Expose **git-harness** (`git` + `safety` packages) to Python and Java via **`cmd/git-harness-cli`**, using the same repository layout pattern as **git-testkit** (`testkit/python`, `testkit/java`, `testkit/.specify`). + +## User scenarios + +1. **Python / Java test authors** invoke scan, git metadata, and safety helpers without reimplementing subprocess orchestration. +2. **CI** validates spec-kit artifacts, builds the CLI once, and runs wrapper tests plus sample smoke flows on Linux and a cross-platform matrix. + +## Acceptance + +- `testkit/.specify/scripts/validate_specify.sh` passes. +- `go test ./...`, `pytest` under `testkit/python`, and `mvn test` under `testkit/java` pass when `GIT_HARNESS_CLI` points at a built binary. diff --git a/testkit/.specify/specs/001-polyglot-harness/tasks.md b/testkit/.specify/specs/001-polyglot-harness/tasks.md new file mode 100644 index 0000000..60d0f89 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-harness/tasks.md @@ -0,0 +1,25 @@ +# Tasks: Polyglot git-harness CLI bridge + +## Phase 1 — Bridge + +- [x] T001 Add `cmd/git-harness-cli` JSON protocol for `git` and `safety` operations + +## Phase 2 — Wrappers + +- [x] T002 Add Python client under `testkit/python/git_harness/` +- [x] T003 Add Java client under `testkit/java/` (Maven + Gson) + +## Phase 3 — Smoke and CI + +- [x] T004 Add Python pytest coverage and sample modules under `testkit/python/samples/` +- [x] T005 Add Java JUnit tests and `Sample*Smoke` flows +- [x] T006 Wire `.github/workflows/ci.yml` (Go + testkit jobs + cross-platform matrix) + +## Phase 4 — Spec-kit alignment + +- [x] T011 Add `testkit/.specify/memory/constitution.md` +- [x] T012 Add spec in `testkit/.specify/specs/001-polyglot-harness/spec.md` +- [x] T013 Add plan in `testkit/.specify/specs/001-polyglot-harness/plan.md` +- [x] T014 Add tasks ledger (this file) +- [x] T015 Add spec-kit command workflow doc + shell helper +- [x] T016 Add CLI contract JSON and quality checklist diff --git a/testkit/GIT_HARNESS_SPEC.md b/testkit/GIT_HARNESS_SPEC.md new file mode 100644 index 0000000..05eee12 --- /dev/null +++ b/testkit/GIT_HARNESS_SPEC.md @@ -0,0 +1,20 @@ +# git-harness polyglot spec (summary) + +Polyglot layout matches **git-testkit**: repository root Go module, **`cmd/git-harness-cli`** JSON bridge, and language clients under **`testkit/`**. + +## Layout + +| Path | Role | +|------|------| +| `cmd/git-harness-cli/` | stdin JSON → stdout JSON | +| `testkit/python/` | `git_harness` package (subprocess client) | +| `testkit/java/` | `io.gitfire.harness.CliBridge` (Maven) | +| `testkit/.specify/` | Spec-kit artifacts + `validate_specify.sh` | + +## Environment + +- **`GIT_HARNESS_CLI`**: path to the built `git-harness-cli` binary (recommended in CI). If unset, clients use `go run ./cmd/git-harness-cli` from the repository root. + +## Canonical contract + +Machine-readable op list: `testkit/.specify/specs/001-polyglot-harness/contracts/cli-protocol.json`. diff --git a/testkit/README.md b/testkit/README.md new file mode 100644 index 0000000..f55f52e --- /dev/null +++ b/testkit/README.md @@ -0,0 +1,13 @@ +## Polyglot testkit (git-harness) + +This tree mirrors [git-testkit](https://github.com/git-fire/git-testkit): **Go core** at the repository root, **`cmd/git-harness-cli`** as the JSON stdin/stdout bridge, and thin **Python** / **Java** clients under `testkit/python` and `testkit/java`. + +Spec-kit style metadata lives under `testkit/.specify/` (validated in CI). + +### Run conformance locally + +- Python: `cd testkit/python && python3 -m pip install -e ".[dev]" && python3 -m pytest tests/ -v` +- Java: `cd testkit/java && mvn test` +- Go: from repository root, `go test ./...` + +Set `GIT_HARNESS_CLI` to a prebuilt `./bin/git-harness-cli` when you want to avoid `go run` during wrapper tests. diff --git a/testkit/java/README.md b/testkit/java/README.md new file mode 100644 index 0000000..4d7efea --- /dev/null +++ b/testkit/java/README.md @@ -0,0 +1,18 @@ +# git-harness (Java) + +JSON-over-subprocess client for `git-harness-cli`, using Gson to parse responses. + +Set `GIT_HARNESS_CLI` to a prebuilt binary, or use `go run ./cmd/git-harness-cli` from the repository root (default when unset). + +## Build and test + +```bash +cd testkit/java +mvn test +``` + +Sample smoke tests (also run in CI): + +```bash +mvn -Dtest=SampleRepoFlowSmoke,SampleSafetyFlowSmoke test +``` diff --git a/testkit/java/pom.xml b/testkit/java/pom.xml new file mode 100644 index 0000000..9916131 --- /dev/null +++ b/testkit/java/pom.xml @@ -0,0 +1,47 @@ + + 4.0.0 + io.gitfire + git-harness-java-wrapper + 0.1.0 + git-harness-java-wrapper + + + 21 + UTF-8 + 5.11.0 + + + + + com.google.code.gson + gson + 2.11.0 + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.1 + + + + diff --git a/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java new file mode 100644 index 0000000..d27bf33 --- /dev/null +++ b/testkit/java/src/main/java/io/gitfire/harness/CliBridge.java @@ -0,0 +1,404 @@ +package io.gitfire.harness; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +public final class CliBridge { + private static final Gson GSON = new Gson(); + + public record RemoteEntry(String name, String url) {} + + public record RepositoryMeta( + String path, + String name, + List remotes, + List branches, + boolean isDirty, + String lastModified, + boolean selected, + String mode) {} + + public record WorktreeInfo(String path, String branch, String head, boolean isMain) {} + + static final class CliResult { + final String stdout; + final String stderr; + final int code; + + CliResult(String stdout, String stderr, int code) { + this.stdout = stdout; + this.stderr = stderr; + this.code = code; + } + } + + public static final class ScanOptions { + private String rootPath = "."; + private final List exclude = new ArrayList<>(); + private int maxDepth; + private Boolean useCache; + private String cacheFile = ""; + private String cacheTtl = ""; + private int workers; + private final Map knownPaths = new LinkedHashMap<>(); + private boolean disableScan; + + public ScanOptions rootPath(String v) { + this.rootPath = v; + return this; + } + + public ScanOptions exclude(List v) { + exclude.clear(); + if (v != null) { + exclude.addAll(v); + } + return this; + } + + public ScanOptions maxDepth(int v) { + this.maxDepth = v; + return this; + } + + public ScanOptions useCache(boolean v) { + this.useCache = v; + return this; + } + + public ScanOptions cacheFile(String v) { + this.cacheFile = v == null ? "" : v; + return this; + } + + public ScanOptions cacheTtl(String v) { + this.cacheTtl = v == null ? "" : v; + return this; + } + + public ScanOptions workers(int v) { + this.workers = v; + return this; + } + + public ScanOptions knownPath(String path, boolean rescanSubmodules) { + knownPaths.put(path, rescanSubmodules); + return this; + } + + public ScanOptions disableScan(boolean v) { + this.disableScan = v; + return this; + } + + private JsonObject toJson() { + JsonObject o = new JsonObject(); + o.addProperty("rootPath", rootPath); + o.addProperty("disableScan", disableScan); + if (!exclude.isEmpty()) { + JsonArray arr = new JsonArray(); + for (String s : exclude) { + arr.add(s); + } + o.add("exclude", arr); + } + if (maxDepth > 0) { + o.addProperty("maxDepth", maxDepth); + } + if (useCache != null) { + o.addProperty("useCache", useCache); + } + if (!cacheFile.isEmpty()) { + o.addProperty("cacheFile", cacheFile); + } + if (!cacheTtl.isEmpty()) { + o.addProperty("cacheTTL", cacheTtl); + } + if (workers > 0) { + o.addProperty("workers", workers); + } + if (!knownPaths.isEmpty()) { + JsonObject kp = new JsonObject(); + for (Map.Entry e : knownPaths.entrySet()) { + kp.addProperty(e.getKey(), e.getValue()); + } + o.add("knownPaths", kp); + } + return o; + } + } + + private final List cliCommandArgs; + private final Path workspaceRoot; + private final java.util.function.Function cliInvoker; + + public CliBridge(Path workspaceRoot) { + this(workspaceRoot, defaultCliCommandArgs(workspaceRoot), null); + } + + public CliBridge() { + this(detectWorkspaceRoot()); + } + + CliBridge(Path workspaceRoot, java.util.function.Function cliInvoker) { + this(workspaceRoot, defaultCliCommandArgs(workspaceRoot), cliInvoker); + } + + CliBridge( + Path workspaceRoot, + List cliCommandArgs, + java.util.function.Function cliInvoker) { + this.workspaceRoot = workspaceRoot; + this.cliCommandArgs = List.copyOf(cliCommandArgs); + this.cliInvoker = cliInvoker; + } + + private static List defaultCliCommandArgs(Path workspaceRoot) { + String configuredCli = System.getenv("GIT_HARNESS_CLI"); + if (configuredCli != null && !configuredCli.isBlank()) { + Path cliPath = Paths.get(configuredCli); + if (cliPath.isAbsolute()) { + return List.of(cliPath.toString()); + } + // Check if it's a path-like value (contains separators or starts with ./ or ../) + boolean isPathLike = configuredCli.contains("/") + || configuredCli.contains(java.io.File.separator) + || configuredCli.startsWith("./") + || configuredCli.startsWith("../"); + if (isPathLike) { + cliPath = workspaceRoot.resolve(cliPath).normalize(); + return List.of(cliPath.toString()); + } + // Bare executable name - let ProcessBuilder perform PATH lookup + return List.of(configuredCli); + } + return List.of("go", "run", "./cmd/git-harness-cli"); + } + + private static Path detectWorkspaceRoot() { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + Path probe = current; + while (probe != null) { + if (Files.exists(probe.resolve("go.mod")) + && Files.exists(probe.resolve("cmd/git-harness-cli/main.go"))) { + return probe; + } + probe = probe.getParent(); + } + return current; + } + + public List scanRepositories(ScanOptions options) { + JsonObject req = new JsonObject(); + req.addProperty("op", "scan_repositories"); + req.add("scanOptions", options.toJson()); + JsonObject res = invokeObject(req); + List out = new ArrayList<>(); + if (!res.has("repositories")) { + return out; + } + for (JsonElement el : res.getAsJsonArray("repositories")) { + out.add(parseRepository(el.getAsJsonObject())); + } + return out; + } + + public RepositoryMeta analyzeRepository(Path repoPath) { + JsonObject req = new JsonObject(); + req.addProperty("op", "analyze_repository"); + req.addProperty("repoPath", repoPath.toString()); + JsonObject res = invokeObject(req); + return parseRepository(res.getAsJsonObject("repository")); + } + + public boolean isDirty(String repoPath) { + JsonObject req = new JsonObject(); + req.addProperty("op", "git_is_dirty"); + req.addProperty("repoPath", repoPath); + return invokeObject(req).get("dirty").getAsBoolean(); + } + + public String getCurrentBranch(String repoPath) { + JsonObject req = new JsonObject(); + req.addProperty("op", "git_get_current_branch"); + req.addProperty("repoPath", repoPath); + return invokeObject(req).get("branch").getAsString(); + } + + public String getCommitSHA(String repoPath, String ref) { + JsonObject req = new JsonObject(); + req.addProperty("op", "git_get_commit_sha"); + req.addProperty("repoPath", repoPath); + req.addProperty("ref", ref); + return invokeObject(req).get("sha").getAsString(); + } + + public String safetySanitizeText(String text) { + JsonObject req = new JsonObject(); + req.addProperty("op", "safety_sanitize_text"); + req.addProperty("text", text == null ? "" : text); + JsonObject res = invokeObject(req); + return res.has("text") && !res.get("text").isJsonNull() ? res.get("text").getAsString() : ""; + } + + public String safetySecurityNotice() { + JsonObject req = new JsonObject(); + req.addProperty("op", "safety_security_notice"); + JsonObject res = invokeObject(req); + return res.has("notice") && !res.get("notice").isJsonNull() + ? res.get("notice").getAsString() + : ""; + } + + public List listWorktrees(String repoPath) { + JsonObject req = new JsonObject(); + req.addProperty("op", "git_list_worktrees"); + req.addProperty("repoPath", repoPath); + JsonObject res = invokeObject(req); + List out = new ArrayList<>(); + if (!res.has("worktrees")) { + return out; + } + for (JsonElement el : res.getAsJsonArray("worktrees")) { + JsonObject w = el.getAsJsonObject(); + out.add( + new WorktreeInfo( + w.get("path").getAsString(), + w.get("branch").getAsString(), + w.get("head").getAsString(), + w.get("isMain").getAsBoolean())); + } + return out; + } + + private JsonObject invokeObject(JsonObject request) { + return invokeRaw(GSON.toJson(request)); + } + + private JsonObject invokeRaw(String payload) { + CliResult result = runCli(payload); + String stdout = result.stdout == null ? "" : result.stdout.trim(); + String stderr = result.stderr == null ? "" : result.stderr.trim(); + if (stdout.isBlank() && result.code != 0) { + throw new RuntimeException("CLI failed with code " + result.code + ": " + stderr); + } + if (stdout.isBlank()) { + throw new RuntimeException("CLI returned empty response"); + } + JsonObject head = JsonParser.parseString(stdout).getAsJsonObject(); + if (result.code != 0 || !head.has("ok") || !head.get("ok").getAsBoolean()) { + String err = head.has("error") ? head.get("error").getAsString() : stderr; + throw new RuntimeException(err.isEmpty() ? "CLI failed with code " + result.code : err); + } + return head; + } + + private CliResult runCli(String payload) { + if (cliInvoker != null) { + return cliInvoker.apply(payload); + } + Process process = null; + final Process[] processRef = new Process[1]; + ExecutorService streamReaderPool = null; + Future stdoutFuture = null; + Future stderrFuture = null; + try { + ProcessBuilder pb = new ProcessBuilder(cliCommandArgs); + pb.directory(workspaceRoot.toFile()); + process = pb.start(); + processRef[0] = process; + streamReaderPool = Executors.newFixedThreadPool(2); + stdoutFuture = + streamReaderPool.submit( + () -> + new String( + processRef[0].getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + stderrFuture = + streamReaderPool.submit( + () -> + new String( + processRef[0].getErrorStream().readAllBytes(), StandardCharsets.UTF_8)); + process.getOutputStream().write(payload.getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().close(); + boolean completed = process.waitFor(120, TimeUnit.SECONDS); + if (!completed) { + throw new RuntimeException("CLI process timed out after 120 seconds"); + } + int code = process.exitValue(); + String out = stdoutFuture.get(); + String err = stderrFuture.get(); + return new CliResult(out, err, code); + } catch (IOException ex) { + throw new RuntimeException("failed to invoke CLI", ex); + } catch (ExecutionException ex) { + throw new RuntimeException("failed to read CLI output", ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new RuntimeException("interrupted while invoking CLI", ex); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + if (stdoutFuture != null) { + stdoutFuture.cancel(true); + } + if (stderrFuture != null) { + stderrFuture.cancel(true); + } + if (streamReaderPool != null) { + streamReaderPool.shutdownNow(); + try { + streamReaderPool.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + } + } + + private static RepositoryMeta parseRepository(JsonObject o) { + List remotes = new ArrayList<>(); + if (o.has("remotes") && !o.get("remotes").isJsonNull()) { + for (JsonElement el : o.getAsJsonArray("remotes")) { + JsonObject r = el.getAsJsonObject(); + remotes.add(new RemoteEntry(r.get("name").getAsString(), r.get("url").getAsString())); + } + } + List branches = new ArrayList<>(); + if (o.has("branches") && !o.get("branches").isJsonNull()) { + for (JsonElement el : o.getAsJsonArray("branches")) { + branches.add(el.getAsString()); + } + } + String lastMod = ""; + if (o.has("lastModified") && !o.get("lastModified").isJsonNull()) { + lastMod = o.get("lastModified").getAsString(); + } + return new RepositoryMeta( + o.get("path").getAsString(), + o.get("name").getAsString(), + remotes, + branches, + o.get("isDirty").getAsBoolean(), + lastMod, + o.get("selected").getAsBoolean(), + o.get("mode").getAsString()); + } +} \ No newline at end of file diff --git a/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java new file mode 100644 index 0000000..75cb663 --- /dev/null +++ b/testkit/java/src/test/java/io/gitfire/harness/CliBridgeTest.java @@ -0,0 +1,103 @@ +package io.gitfire.harness; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CliBridgeTest { + @TempDir Path tmp; + + static Path workspaceRoot() { + return Path.of("../..").toAbsolutePath().normalize(); + } + + private static void runGit(Path dir, String... args) throws Exception { + List cmd = new java.util.ArrayList<>(); + cmd.add("git"); + for (String a : args) { + cmd.add(a); + } + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(dir.toFile()); + Process p = pb.start(); + boolean ok = p.waitFor(60, java.util.concurrent.TimeUnit.SECONDS); + if (!ok) { + p.destroyForcibly(); + throw new RuntimeException("git timeout"); + } + if (p.exitValue() != 0) { + String err = new String(p.getErrorStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + throw new RuntimeException("git failed: " + err); + } + } + + @Test + void analyzeRepositorySeesCleanRepo() throws Exception { + Path repo = tmp.resolve("r"); + Files.createDirectories(repo); + runGit(repo, "init"); + runGit(repo, "config", "user.email", "harness-test@example.com"); + runGit(repo, "config", "user.name", "git-harness test"); + Files.writeString(repo.resolve("a.txt"), "x\n"); + runGit(repo, "add", "a.txt"); + runGit(repo, "commit", "-m", "init"); + + CliBridge bridge = new CliBridge(workspaceRoot()); + CliBridge.RepositoryMeta meta = bridge.analyzeRepository(repo); + + assertTrue(Files.exists(repo.resolve(".git"))); + assertFalse(meta.isDirty()); + assertEquals( + repo.toAbsolutePath().normalize().toString(), + meta.path(), + "analyzeRepository should return the exact repository path"); + } + + @Test + void isDirtyDetectsUntracked() throws Exception { + Path repo = tmp.resolve("d"); + Files.createDirectories(repo); + runGit(repo, "init"); + Files.writeString(repo.resolve("b.txt"), "y\n"); + + CliBridge bridge = new CliBridge(workspaceRoot()); + assertTrue(bridge.isDirty(repo.toString())); + } + + @Test + void safetySanitizeTextRemovesToken() { + CliBridge bridge = new CliBridge(workspaceRoot()); + String token = "ghp_" + "a".repeat(36); + String out = bridge.safetySanitizeText("pat " + token); + assertFalse(out.contains("ghp_")); + assertTrue(out.contains("[REDACTED]")); + } + + @Test + void scanRepositoriesFindsNestedRepo() throws Exception { + Path outer = tmp.resolve("outer"); + Path inner = outer.resolve("nested").resolve("proj"); + Files.createDirectories(inner); + runGit(inner, "init"); + runGit(inner, "config", "user.email", "harness-test@example.com"); + runGit(inner, "config", "user.name", "git-harness test"); + Files.writeString(inner.resolve("f"), "1\n"); + runGit(inner, "add", "f"); + runGit(inner, "commit", "-m", "c"); + + CliBridge bridge = new CliBridge(workspaceRoot()); + List repos = + bridge.scanRepositories( + new CliBridge.ScanOptions().rootPath(outer.toString()).useCache(false).maxDepth(20)); + + boolean found = + repos.stream().anyMatch(r -> r.path().equals(inner.toAbsolutePath().normalize().toString())); + assertTrue(found); + } +} diff --git a/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java new file mode 100644 index 0000000..acaee2b --- /dev/null +++ b/testkit/java/src/test/java/io/gitfire/harness/SampleRepoFlowSmoke.java @@ -0,0 +1,90 @@ +package io.gitfire.harness; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Runnable sample that exercises scan + analyze + SHA round-trip with a real git repo. */ +class SampleRepoFlowSmoke { + @TempDir Path tmp; + + private static void runGit(Path dir, String... args) throws Exception { + runGitStdout(dir, args); + } + + private static String runGitStdout(Path dir, String... args) throws Exception { + List cmd = new ArrayList<>(); + cmd.add("git"); + for (String a : args) { + cmd.add(a); + } + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(dir.toFile()); + Process p = pb.start(); + boolean ok = p.waitFor(120, TimeUnit.SECONDS); + if (!ok) { + p.destroyForcibly(); + throw new RuntimeException("git timeout"); + } + String stdout = + new String(p.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + if (p.exitValue() != 0) { + String err = + new String(p.getErrorStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + throw new RuntimeException("git failed: " + err); + } + return stdout.trim(); + } + + @Test + void sampleRepoFlowRuns() throws Exception { + Path base = tmp; + Path remote = base.resolve("origin.git"); + Path local = base.resolve("local"); + Files.createDirectories(remote); + Files.createDirectories(local); + + runGit(remote, "init", "--bare"); + + runGit(local, "init"); + runGit(local, "config", "user.email", "harness-sample@example.com"); + runGit(local, "config", "user.name", "git-harness sample"); + Files.writeString(local.resolve("README.md"), "hello\n"); + runGit(local, "add", "README.md"); + runGit(local, "commit", "-m", "init"); + + CliBridge bridge = new CliBridge(CliBridgeTest.workspaceRoot()); + String branch = bridge.getCurrentBranch(local.toString()); + + runGit(local, "remote", "add", "origin", remote.toAbsolutePath().normalize().toString()); + runGit(local, "push", "-u", "origin", branch); + + String localSha = bridge.getCommitSHA(local.toString(), branch); + String remoteSha = runGitStdout(remote, "rev-parse", branch); + assertEquals(localSha, remoteSha, "SHA mismatch between local and remote"); + + List repos = + bridge.scanRepositories( + new CliBridge.ScanOptions().rootPath(base.toString()).useCache(false).maxDepth(30)); + Path localReal = local.toRealPath(); + boolean found = + repos.stream() + .anyMatch( + r -> { + try { + return Path.of(r.path()).toRealPath().equals(localReal); + } catch (IOException e) { + return false; + } + }); + assertTrue(found, "scan_repositories did not find local repo"); + } +} diff --git a/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java new file mode 100644 index 0000000..24ed700 --- /dev/null +++ b/testkit/java/src/test/java/io/gitfire/harness/SampleSafetyFlowSmoke.java @@ -0,0 +1,19 @@ +package io.gitfire.harness; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class SampleSafetyFlowSmoke { + @Test + void sampleSafetyFlowRuns() { + CliBridge bridge = new CliBridge(CliBridgeTest.workspaceRoot()); + String token = "ghp_" + "a".repeat(36); + String out = bridge.safetySanitizeText("export TOKEN=" + token); + assertFalse(out.contains(token)); + assertTrue(out.contains("[REDACTED]")); + String notice = bridge.safetySecurityNotice(); + assertTrue(notice.length() > 10); + } +} diff --git a/testkit/python/README.md b/testkit/python/README.md new file mode 100644 index 0000000..48e5e78 --- /dev/null +++ b/testkit/python/README.md @@ -0,0 +1,15 @@ +# git-harness (Python) + +Thin JSON-over-subprocess client for the `git-harness-cli` tool in this repository. + +Set `GIT_HARNESS_CLI` to a prebuilt binary path (recommended in CI), or rely on `go run ./cmd/git-harness-cli` from the repository root. + +## Development + +```bash +cd testkit/python +python -m pip install -e ".[dev]" +python -m pytest tests/ -v +``` + +Samples: see `samples/README.md`. diff --git a/testkit/python/git_harness/__init__.py b/testkit/python/git_harness/__init__.py new file mode 100644 index 0000000..fe21d58 --- /dev/null +++ b/testkit/python/git_harness/__init__.py @@ -0,0 +1,3 @@ +from .cli import GitHarnessClient, ScanOptions + +__all__ = ["GitHarnessClient", "ScanOptions"] diff --git a/testkit/python/git_harness/cli.py b/testkit/python/git_harness/cli.py new file mode 100644 index 0000000..fff6cc0 --- /dev/null +++ b/testkit/python/git_harness/cli.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_CLI_TIMEOUT_SECONDS = 120 + + +def _repo_root() -> Path: + # testkit/python/git_harness/cli.py -> repository root + return Path(__file__).resolve().parents[3] + + +def _cli_cmd() -> list[str]: + cli = os.environ.get("GIT_HARNESS_CLI", "").strip() + if not cli: + return ["go", "run", "./cmd/git-harness-cli"] + + expanded = os.path.expanduser(cli) + cli_path = Path(expanded) + if cli_path.is_absolute(): + return [str(cli_path)] + # Repo-relative paths include any directory component; bare names use PATH. + if cli_path.parent != Path("."): + return [str((_repo_root() / cli_path).resolve())] + resolved = shutil.which(cli) + if resolved: + return [resolved] + return [str((_repo_root() / cli_path).resolve())] + + +def _call(op: str, **payload: Any) -> dict[str, Any]: + request = {"op": op, **payload} + cmd = _cli_cmd() + try: + proc = subprocess.run( + cmd, + cwd=_repo_root(), + input=json.dumps(request), + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + check=False, + timeout=_CLI_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"git-harness-cli timed out after {_CLI_TIMEOUT_SECONDS}s (op={op})" + ) from exc + except OSError as exc: + raise RuntimeError( + f"git-harness-cli failed to start (op={op}): {exc}" + ) from exc + stdout = (proc.stdout or "").strip() + stderr = (proc.stderr or "").strip() + if proc.returncode != 0: + if stdout: + try: + response = json.loads(stdout) + except json.JSONDecodeError: + response = {} + if not response.get("ok", True) and response.get("error"): + raise RuntimeError(str(response["error"])) + raise RuntimeError( + f"git-harness-cli exited {proc.returncode}: {stderr}; stdout: {stdout}" + ) + + try: + response = json.loads(stdout) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"invalid JSON from git-harness-cli: {stdout!r}; stderr: {stderr}" + ) from exc + if not response.get("ok", False): + raise RuntimeError(response.get("error", "unknown git-harness-cli error")) + return response + + +@dataclass(slots=True) +class ScanOptions: + root_path: str = "." + exclude: list[str] | None = None + max_depth: int = 0 + use_cache: bool | None = None + cache_file: str = "" + cache_ttl: str = "" + workers: int = 0 + known_paths: dict[str, bool] | None = None + disable_scan: bool = False + + def to_payload(self) -> dict[str, Any]: + d: dict[str, Any] = { + "rootPath": self.root_path, + "disableScan": self.disable_scan, + } + if self.exclude is not None: + d["exclude"] = self.exclude + if self.max_depth > 0: + d["maxDepth"] = self.max_depth + if self.use_cache is not None: + d["useCache"] = self.use_cache + if self.cache_file: + d["cacheFile"] = self.cache_file + if self.cache_ttl: + d["cacheTTL"] = self.cache_ttl + if self.workers > 0: + d["workers"] = self.workers + if self.known_paths is not None: + d["knownPaths"] = self.known_paths + return d + + +class GitHarnessClient: + def scan_repositories(self, options: ScanOptions | None = None) -> list[dict[str, Any]]: + opts = options or ScanOptions() + res = _call("scan_repositories", scanOptions=opts.to_payload()) + return list(res.get("repositories", [])) + + def analyze_repository(self, repo_path: str | Path) -> dict[str, Any]: + res = _call("analyze_repository", repoPath=str(repo_path)) + return dict(res["repository"]) + + def is_dirty(self, repo_path: str) -> bool: + res = _call("git_is_dirty", repoPath=repo_path) + return bool(res["dirty"]) + + def get_current_branch(self, repo_path: str) -> str: + res = _call("git_get_current_branch", repoPath=repo_path) + return str(res["branch"]) + + def get_commit_sha(self, repo_path: str, ref: str) -> str: + res = _call("git_get_commit_sha", repoPath=repo_path, ref=ref) + return str(res["sha"]) + + def list_local_branches(self, repo_path: str) -> list[str]: + res = _call("git_list_local_branches", repoPath=repo_path) + return [str(b) for b in res.get("branches", [])] + + def list_remote_branches(self, repo_path: str, remote: str) -> list[str]: + res = _call("git_list_remote_branches", repoPath=repo_path, remote=remote) + return [str(b) for b in res.get("branches", [])] + + def ref_is_ancestor(self, repo_path: str, ancestor_ref: str, descendant_ref: str) -> bool: + res = _call( + "git_ref_is_ancestor", + repoPath=repo_path, + ancestorRef=ancestor_ref, + descendantRef=descendant_ref, + ) + return bool(res["isAncestor"]) + + def detect_conflict(self, repo_path: str, branch: str, remote: str) -> tuple[bool, str, str]: + res = _call("git_detect_conflict", repoPath=repo_path, branch=branch, remote=remote) + return bool(res["hasConflict"]), str(res.get("localSHA", "")), str(res.get("remoteSHA", "")) + + def has_staged_changes(self, repo_path: str) -> bool: + res = _call("git_has_staged_changes", repoPath=repo_path) + return bool(res["staged"]) + + def has_unstaged_changes(self, repo_path: str) -> bool: + res = _call("git_has_unstaged_changes", repoPath=repo_path) + return bool(res["unstaged"]) + + def get_uncommitted_files(self, repo_path: str) -> list[str]: + res = _call("git_get_uncommitted_files", repoPath=repo_path) + return [str(p) for p in res.get("paths", [])] + + def list_worktrees(self, repo_path: str) -> list[dict[str, Any]]: + res = _call("git_list_worktrees", repoPath=repo_path) + return list(res.get("worktrees", [])) + + def auto_commit_dirty( + self, + repo_path: str, + *, + message: str = "", + add_all: bool = False, + ) -> None: + _call( + "git_auto_commit_dirty", + repoPath=repo_path, + message=message, + addAll=add_all, + ) + + def auto_commit_dirty_with_strategy( + self, + repo_path: str, + *, + message: str = "", + add_all: bool = False, + use_dual_branch: bool = True, + return_to_original: bool = True, + ) -> dict[str, Any]: + return _call( + "git_auto_commit_dirty_with_strategy", + repoPath=repo_path, + message=message, + addAll=add_all, + useDualBranch=use_dual_branch, + returnToOriginal=return_to_original, + ) + + def create_fire_branch(self, repo_path: str, original_branch: str, local_sha: str) -> str: + res = _call( + "git_create_fire_branch", + repoPath=repo_path, + originalBranch=original_branch, + localSHA=local_sha, + ) + return str(res.get("fireBranch", "")) + + def fetch_remote(self, repo_path: str, remote: str) -> None: + _call("git_fetch_remote", repoPath=repo_path, remote=remote) + + def push_branch(self, repo_path: str, remote: str, branch: str) -> None: + _call("git_push_branch", repoPath=repo_path, remote=remote, branch=branch) + + def push_all_branches(self, repo_path: str, remote: str) -> None: + _call("git_push_all_branches", repoPath=repo_path, remote=remote) + + def safety_sanitize_text(self, text: str) -> str: + res = _call("safety_sanitize_text", text=text) + return str(res.get("text", "")) + + def safety_recommended_gitignore_patterns(self) -> list[str]: + res = _call("safety_recommended_gitignore_patterns") + return [str(x) for x in res.get("lines", [])] + + def safety_security_notice(self) -> str: + res = _call("safety_security_notice") + return str(res.get("notice", "")) + + def safety_format_warning(self, files: list[dict[str, Any]]) -> str: + res = _call("safety_format_warning", suspiciousFiles=files) + return str(res.get("warning", "")) + + def safety_scan_files(self, repo_path: str, files: list[str]) -> list[dict[str, Any]]: + res = _call("safety_scan_files", repoPath=repo_path, files=files) + return list(res.get("suspiciousFiles", [])) diff --git a/testkit/python/pyproject.toml b/testkit/python/pyproject.toml new file mode 100644 index 0000000..597b9bf --- /dev/null +++ b/testkit/python/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "git-harness-polyglot-python" +version = "0.1.0" +description = "Python wrapper for git-harness CLI bridge" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["git_harness*"] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/testkit/python/samples/README.md b/testkit/python/samples/README.md new file mode 100644 index 0000000..e9e69b8 --- /dev/null +++ b/testkit/python/samples/README.md @@ -0,0 +1,19 @@ +## Python sample smoke implementations + +Runnable examples that exercise the bridge end-to-end. They exit non-zero on failure. + +From the repository root (with `GIT_HARNESS_CLI` pointing at a built binary, recommended): + +```bash +cd testkit/python +python -m pip install -e ".[dev]" +python -m samples.smoke_repo_flow +python -m samples.smoke_safety_flow +``` + +Or from repo root using `PYTHONPATH`: + +```bash +PYTHONPATH=testkit/python python3 testkit/python/samples/smoke_repo_flow.py +PYTHONPATH=testkit/python python3 testkit/python/samples/smoke_safety_flow.py +``` diff --git a/wrappers/java/.gitkeep b/testkit/python/samples/__init__.py similarity index 100% rename from wrappers/java/.gitkeep rename to testkit/python/samples/__init__.py diff --git a/testkit/python/samples/smoke_repo_flow.py b/testkit/python/samples/smoke_repo_flow.py new file mode 100644 index 0000000..6364b7c --- /dev/null +++ b/testkit/python/samples/smoke_repo_flow.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from git_harness import GitHarnessClient, ScanOptions + + +def _run_git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + +def _git_init(repo: Path) -> None: + _run_git(repo, "init") + _run_git(repo, "config", "user.email", "harness-sample@example.com") + _run_git(repo, "config", "user.name", "git-harness sample") + + +def main() -> int: + client = GitHarnessClient() + with tempfile.TemporaryDirectory(prefix="git-harness-py-repo-") as tmp: + base = Path(tmp) + remote = base / "origin.git" + local = base / "local" + remote.mkdir() + local.mkdir() + + _run_git(remote, "init", "--bare") + + _git_init(local) + (local / "README.md").write_text("hello\n", encoding="utf-8") + _run_git(local, "add", "README.md") + _run_git(local, "commit", "-m", "init") + branch = client.get_current_branch(str(local)) + + _run_git(local, "remote", "add", "origin", str(remote.resolve())) + _run_git(local, "push", "-u", "origin", branch) + + local_sha = client.get_commit_sha(str(local), branch) + out = subprocess.run( + ["git", "rev-parse", branch], + cwd=remote, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ).stdout.strip() + if local_sha != out: + raise RuntimeError(f"sha mismatch local={local_sha} remote={out}") + + repos = client.scan_repositories( + ScanOptions(root_path=str(base), use_cache=False, max_depth=30) + ) + # macOS often differs between symlinked paths (/var vs /private/var); compare real paths. + local_key = Path(os.path.realpath(local)) + if not any(Path(os.path.realpath(r["path"])) == local_key for r in repos): + raise RuntimeError("scan_repositories did not find local repo") + + print("python sample repo flow: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testkit/python/samples/smoke_safety_flow.py b/testkit/python/samples/smoke_safety_flow.py new file mode 100644 index 0000000..f8a900e --- /dev/null +++ b/testkit/python/samples/smoke_safety_flow.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from git_harness import GitHarnessClient + + +def main() -> int: + client = GitHarnessClient() + token = "ghp_" + ("a" * 36) + out = client.safety_sanitize_text(f"export TOKEN={token}") + if token in out: + raise RuntimeError("expected full token to be redacted") + if "[REDACTED]" not in out: + raise RuntimeError("expected redaction marker in sanitized output") + notice = client.safety_security_notice() + if len(notice) < 10: + raise RuntimeError("expected security notice body") + print("python sample safety flow: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testkit/python/tests/conftest.py b/testkit/python/tests/conftest.py new file mode 100644 index 0000000..7ca9ed4 --- /dev/null +++ b/testkit/python/tests/conftest.py @@ -0,0 +1,39 @@ +"""Pytest fixtures for wrapper tests.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys + +import pytest + + +@pytest.fixture(autouse=True) +def _git_identity_for_tests(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> None: + """CI images often have no global git user; commits in tests must still work.""" + git_bin = shutil.which("git") + if git_bin is None: + pytest.fail("git executable not found on PATH") + + home = tmp_path_factory.mktemp("gh_pytest_git_home") + monkeypatch.setenv("HOME", str(home)) + if sys.platform == "win32": + monkeypatch.setenv("USERPROFILE", str(home)) + subprocess.run( + [git_bin, "config", "--global", "user.email", "pytest@example.com"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + subprocess.run( + [git_bin, "config", "--global", "user.name", "pytest"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + yield diff --git a/testkit/python/tests/test_cli_bridge.py b/testkit/python/tests/test_cli_bridge.py new file mode 100644 index 0000000..9c1d29c --- /dev/null +++ b/testkit/python/tests/test_cli_bridge.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import json +import os +import os.path +import shutil +import subprocess +from pathlib import Path + +import pytest + +from git_harness import GitHarnessClient, ScanOptions +from git_harness.cli import _cli_cmd + + +def _run_git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + +def test_analyze_repository_finds_git_dir(tmp_path: Path) -> None: + repo = tmp_path / "r" + repo.mkdir() + _run_git(repo, "init") + _run_git(repo, "config", "user.email", "harness-test@example.com") + _run_git(repo, "config", "user.name", "git-harness test") + (repo / "a.txt").write_text("x\n") + _run_git(repo, "add", "a.txt") + _run_git(repo, "commit", "-m", "init") + + client = GitHarnessClient() + meta = client.analyze_repository(repo) + + assert os.path.realpath(meta["path"]) == os.path.realpath(str(repo)) + assert meta["name"] == "r" + assert meta["isDirty"] is False + + +def test_is_dirty_detects_untracked(tmp_path: Path) -> None: + repo = tmp_path / "d" + repo.mkdir() + _run_git(repo, "init") + (repo / "b.txt").write_text("y\n") + + client = GitHarnessClient() + assert client.is_dirty(str(repo)) is True + + +def test_safety_sanitize_text_masks_token() -> None: + client = GitHarnessClient() + # SanitizeText matches GitHub PATs with ghp_ + at least 36 alphanumerics. + token = "ghp_" + ("a" * 36) + out = client.safety_sanitize_text(f"pat {token}") + assert "ghp_" not in out + assert "[REDACTED]" in out + + +def test_safety_sanitize_text_empty_string_round_trip() -> None: + client = GitHarnessClient() + assert client.safety_sanitize_text("") == "" + + +def test_safety_format_warning_empty_list() -> None: + client = GitHarnessClient() + assert client.safety_format_warning([]) == "" + + +def test_scan_repositories_finds_nested_repo(tmp_path: Path) -> None: + outer = tmp_path / "outer" + inner = outer / "nested" / "proj" + inner.mkdir(parents=True) + _run_git(inner, "init") + _run_git(inner, "config", "user.email", "harness-test@example.com") + _run_git(inner, "config", "user.name", "git-harness test") + (inner / "f").write_text("1\n") + _run_git(inner, "add", "f") + _run_git(inner, "commit", "-m", "c") + + client = GitHarnessClient() + + repos = client.scan_repositories( + ScanOptions(root_path=str(outer), use_cache=False, max_depth=20) + ) + paths = {os.path.realpath(r["path"]) for r in repos} + assert os.path.realpath(str(inner)) in paths + + +def _git_harness_cli_cmd() -> list[str]: + root = Path(__file__).resolve().parents[3] + cli = os.environ.get("GIT_HARNESS_CLI", "").strip() + cmd = [cli] if cli else ["go", "run", "./cmd/git-harness-cli"] + if cli and not Path(cli).is_file() and shutil.which(cli) is None: + cmd = [str((root / cli).resolve())] + return cmd + + +def test_cli_rejects_unknown_json_keys() -> None: + """parseRequest uses DisallowUnknownFields — typos must fail fast.""" + root = Path(__file__).resolve().parents[3] + proc = subprocess.run( + _git_harness_cli_cmd(), + cwd=root, + input=json.dumps({"op": "safety_security_notice", "typoField": 1}), + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + check=False, + timeout=120, + ) + assert proc.returncode != 0 + body = json.loads((proc.stdout or "").strip() or "{}") + assert body.get("ok") is False + assert "error" in body + + +def test_cli_rejects_trailing_second_json_value() -> None: + root = Path(__file__).resolve().parents[3] + payload = ( + json.dumps({"op": "safety_security_notice"}) + + json.dumps({"op": "git_is_dirty"}) + ) + proc = subprocess.run( + _git_harness_cli_cmd(), + cwd=root, + input=payload, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + check=False, + timeout=120, + ) + assert proc.returncode != 0 + body = json.loads((proc.stdout or "").strip() or "{}") + assert body.get("ok") is False + err = str(body.get("error", "")).lower() + assert "multiple" in err or "trailing" in err + + +def test_cli_cmd_prefers_path_lookup_for_bare_executable_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GIT_HARNESS_CLI", raising=False) + monkeypatch.setenv("GIT_HARNESS_CLI", "git-harness-cli") + fake = "/opt/bin/git-harness-cli" + + def fake_which(cmd: str, path: str | None = None) -> str | None: + if cmd == "git-harness-cli": + return fake + return shutil.which(cmd, path=path) + + monkeypatch.setattr(shutil, "which", fake_which) + assert _cli_cmd() == [fake] + + +def test_subprocess_json_contract_smoke() -> None: + """Guardrail: stdin JSON shape accepted by the Go CLI.""" + root = Path(__file__).resolve().parents[3] + proc = subprocess.run( + _git_harness_cli_cmd(), + cwd=root, + input=json.dumps({"op": "safety_security_notice"}), + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + check=False, + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + stdout = (proc.stdout or "").strip() + body = json.loads(stdout) + assert body["ok"] is True + assert "notice" in body and len(body["notice"]) > 0 diff --git a/wrappers/README.md b/wrappers/README.md new file mode 100644 index 0000000..587c4c0 --- /dev/null +++ b/wrappers/README.md @@ -0,0 +1,5 @@ +# Moved: polyglot clients live under `testkit/` + +This repository follows the same layout as [git-testkit](https://github.com/git-fire/git-testkit): Python and Java clients are in **`testkit/python`** and **`testkit/java`**, not here. + +See **`testkit/README.md`**. diff --git a/wrappers/python/.gitkeep b/wrappers/python/.gitkeep deleted file mode 100644 index e69de29..0000000