From 8915b71e31885fd15f101dd81171c64f81841f27 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 21:37:28 +0000 Subject: [PATCH 01/27] Add hybrid roadmap and Go CLI bridge foundation Co-authored-by: Ben Schellenberger --- cmd/git-testkit-cli/main.go | 246 +++++++++++++++++ fixtures.go | 251 +++++++++++------- snapshots.go | 107 +++++--- testkit/GIT_TESTKIT_SPEC.md | 195 ++++++++++++++ testkit/ROADMAP.md | 50 ++++ testkit/java/pom.xml | 34 +++ .../java/io/gitfire/testkit/CliBridge.java | 244 +++++++++++++++++ .../io/gitfire/testkit/CliBridgeTest.java | 54 ++++ testkit/python/README.md | 9 + testkit/python/git_testkit/__init__.py | 3 + testkit/python/git_testkit/cli.py | 122 +++++++++ testkit/python/pyproject.toml | 21 ++ testkit/python/tests/test_fixtures.py | 76 ++++++ testkit/python/tests/test_scenarios.py | 9 + testkit/python/tests/test_snapshots.py | 35 +++ 15 files changed, 1315 insertions(+), 141 deletions(-) create mode 100644 cmd/git-testkit-cli/main.go create mode 100644 testkit/GIT_TESTKIT_SPEC.md create mode 100644 testkit/ROADMAP.md create mode 100644 testkit/java/pom.xml create mode 100644 testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java create mode 100644 testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java create mode 100644 testkit/python/README.md create mode 100644 testkit/python/git_testkit/__init__.py create mode 100644 testkit/python/git_testkit/cli.py create mode 100644 testkit/python/pyproject.toml create mode 100644 testkit/python/tests/test_fixtures.py create mode 100644 testkit/python/tests/test_scenarios.py create mode 100644 testkit/python/tests/test_snapshots.py diff --git a/cmd/git-testkit-cli/main.go b/cmd/git-testkit-cli/main.go new file mode 100644 index 0000000..2c198dd --- /dev/null +++ b/cmd/git-testkit-cli/main.go @@ -0,0 +1,246 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + testutil "github.com/git-fire/git-testkit" +) + +type request struct { + Op string `json:"op"` + BaseDir string `json:"baseDir,omitempty"` + RepoPath string `json:"repoPath,omitempty"` + Args []string `json:"args,omitempty"` + Options *repoOptionsInput `json:"options,omitempty"` + + SnapshotPath string `json:"snapshotPath,omitempty"` +} + +type repoOptionsInput struct { + Name string `json:"name"` + Dirty bool `json:"dirty,omitempty"` + Files map[string]string `json:"files,omitempty"` + Remotes map[string]string `json:"remotes,omitempty"` + Branches []string `json:"branches,omitempty"` + InitialCommit string `json:"initialCommit,omitempty"` +} + +type response struct { + OK bool `json:"ok"` + + Error string `json:"error,omitempty"` + + RepoPath string `json:"repoPath,omitempty"` + RemotePath string `json:"remotePath,omitempty"` + FSRoot string `json:"fsRoot,omitempty"` + Output string `json:"output,omitempty"` + Dirty *bool `json:"dirty,omitempty"` + Remotes map[string]string `json:"remotes,omitempty"` + SHA string `json:"sha,omitempty"` + Branches []string `json:"branches,omitempty"` + SnapshotName string `json:"snapshotName,omitempty"` + SnapshotSize int `json:"snapshotSize,omitempty"` + RestorePath string `json:"restorePath,omitempty"` +} + +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) { + raw, err := os.ReadFile("/dev/stdin") + if err != nil { + return request{}, fmt.Errorf("failed reading stdin: %w", err) + } + var req request + if err := json.Unmarshal(raw, &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 "create_test_repo": + base, err := ensureBaseDir(req.BaseDir) + if err != nil { + return response{}, err + } + if req.Options == nil { + return response{}, fmt.Errorf("missing options") + } + repoPath, err := testutil.CreateTestRepoInDir(base, testutil.RepoOptions{ + Name: req.Options.Name, + Dirty: req.Options.Dirty, + Files: req.Options.Files, + Remotes: req.Options.Remotes, + Branches: req.Options.Branches, + InitialCommit: req.Options.InitialCommit, + }) + if err != nil { + return response{}, err + } + return response{OK: true, RepoPath: repoPath}, nil + + case "create_bare_remote": + base, err := ensureBaseDir(req.BaseDir) + if err != nil { + return response{}, err + } + if req.Options == nil || req.Options.Name == "" { + return response{}, fmt.Errorf("missing options.name") + } + remotePath, err := testutil.CreateBareRemoteInDir(base, req.Options.Name) + if err != nil { + return response{}, err + } + return response{OK: true, RemotePath: remotePath}, nil + + case "setup_fake_filesystem": + base, err := ensureBaseDir(req.BaseDir) + if err != nil { + return response{}, err + } + root, err := testutil.SetupFakeFilesystemInDir(base) + if err != nil { + return response{}, err + } + return response{OK: true, FSRoot: root}, nil + + case "run_git_cmd": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + output, err := testutil.RunGitCmdE(req.RepoPath, req.Args...) + if err != nil { + return response{}, err + } + return response{OK: true, Output: output}, nil + + case "is_dirty": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + dirty, err := testutil.IsDirtyE(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Dirty: &dirty}, nil + + case "get_remotes": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + remotes, err := testutil.GetRemotesE(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Remotes: remotes}, nil + + case "get_current_sha": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + sha, err := testutil.GetCurrentSHAE(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, SHA: sha}, nil + + case "get_branches": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + branches, err := testutil.GetBranchesE(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, Branches: branches}, nil + + case "snapshot_repo": + if req.RepoPath == "" { + return response{}, fmt.Errorf("missing repoPath") + } + snapshot, err := testutil.SnapshotRepoE(req.RepoPath) + if err != nil { + return response{}, err + } + return response{OK: true, SnapshotName: snapshot.Name(), SnapshotSize: snapshot.Size()}, nil + + case "snapshot_save": + if req.RepoPath == "" || req.SnapshotPath == "" { + return response{}, fmt.Errorf("missing repoPath or snapshotPath") + } + snapshot, err := testutil.SnapshotRepoE(req.RepoPath) + if err != nil { + return response{}, err + } + if err := testutil.SaveSnapshotToDiskE(snapshot, req.SnapshotPath); err != nil { + return response{}, err + } + return response{OK: true, SnapshotName: snapshot.Name(), SnapshotSize: snapshot.Size()}, nil + + case "snapshot_load_restore": + if req.SnapshotPath == "" { + return response{}, fmt.Errorf("missing snapshotPath") + } + base, err := ensureBaseDir(req.BaseDir) + if err != nil { + return response{}, err + } + snapshot, err := testutil.LoadSnapshotFromDiskE(req.SnapshotPath) + if err != nil { + return response{}, err + } + restorePath, err := testutil.RestoreSnapshotToDir(snapshot, base) + if err != nil { + return response{}, err + } + return response{ + OK: true, + RestorePath: restorePath, + SnapshotName: snapshot.Name(), + SnapshotSize: snapshot.Size(), + }, nil + + default: + return response{}, fmt.Errorf("unsupported op: %s", req.Op) + } +} + +func ensureBaseDir(baseDir string) (string, error) { + if strings.TrimSpace(baseDir) == "" { + return "", fmt.Errorf("missing baseDir") + } + clean := filepath.Clean(baseDir) + if err := os.MkdirAll(clean, 0755); err != nil { + return "", err + } + return clean, nil +} + +func writeResponse(res response) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(res); err != nil { + fmt.Fprintf(os.Stderr, `{"ok":false,"error":"failed writing response: %s"}`+"\n", err.Error()) + } +} diff --git a/fixtures.go b/fixtures.go index 7251431..f3c5bd6 100644 --- a/fixtures.go +++ b/fixtures.go @@ -1,6 +1,7 @@ package testutil import ( + "fmt" "os" "os/exec" "path/filepath" @@ -34,102 +35,128 @@ type RepoOptions struct { func CreateTestRepo(t *testing.T, opts RepoOptions) string { t.Helper() - // Create temp directory - tmpDir := t.TempDir() - repoPath := filepath.Join(tmpDir, opts.Name) + repoPath, err := CreateTestRepoInDir(t.TempDir(), opts) + if err != nil { + t.Fatalf("Failed to create test repo: %v", err) + } + return repoPath +} + +// CreateTestRepoInDir creates a test repository under the provided base directory. +func CreateTestRepoInDir(baseDir string, opts RepoOptions) (string, error) { + repoPath := filepath.Join(baseDir, opts.Name) if err := os.MkdirAll(repoPath, 0755); err != nil { - t.Fatalf("Failed to create repo directory: %v", err) + return "", fmt.Errorf("failed to create repo directory: %w", err) } - // Initialize git repo - runGit(t, repoPath, "init") - runGit(t, repoPath, "config", "user.email", "test@example.com") - runGit(t, repoPath, "config", "user.name", "Test User") + if _, err := RunGitCmdE(repoPath, "init"); err != nil { + return "", err + } + if _, err := RunGitCmdE(repoPath, "config", "user.email", "test@example.com"); err != nil { + return "", err + } + if _, err := RunGitCmdE(repoPath, "config", "user.name", "Test User"); err != nil { + return "", err + } - // Create initial commit (required for most operations) - initialFile := filepath.Join(repoPath, "README.md") commitMsg := opts.InitialCommit if commitMsg == "" { commitMsg = "Initial commit" } - + initialFile := filepath.Join(repoPath, "README.md") if err := os.WriteFile(initialFile, []byte("# Test Repo\n"), 0644); err != nil { - t.Fatalf("Failed to create README: %v", err) + return "", fmt.Errorf("failed to create README: %w", err) + } + if _, err := RunGitCmdE(repoPath, "add", "README.md"); err != nil { + return "", err + } + if _, err := RunGitCmdE(repoPath, "commit", "-m", commitMsg); err != nil { + return "", err } - runGit(t, repoPath, "add", "README.md") - runGit(t, repoPath, "commit", "-m", commitMsg) - - // Create additional files if specified for filename, content := range opts.Files { filePath := filepath.Join(repoPath, filename) - - // Create parent directories if needed - dir := filepath.Dir(filePath) - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatalf("Failed to create directory for %s: %v", filename, err) + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return "", fmt.Errorf("failed to create directory for %s: %w", filename, err) } - if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to create file %s: %v", filename, err) + return "", fmt.Errorf("failed to create file %s: %w", filename, err) + } + if _, err := RunGitCmdE(repoPath, "add", filename); err != nil { + return "", err + } + if _, err := RunGitCmdE(repoPath, "commit", "-m", "Add "+filename); err != nil { + return "", err } - runGit(t, repoPath, "add", filename) - runGit(t, repoPath, "commit", "-m", "Add "+filename) } - // Add remotes for name, url := range opts.Remotes { - runGit(t, repoPath, "remote", "add", name, url) + if _, err := RunGitCmdE(repoPath, "remote", "add", name, url); err != nil { + return "", err + } } - // Create branches for _, branch := range opts.Branches { - runGit(t, repoPath, "checkout", "-b", branch) + if _, err := RunGitCmdE(repoPath, "checkout", "-b", branch); err != nil { + return "", err + } } - // Return to main/master branch if len(opts.Branches) > 0 { - // Try main first, fallback to master - if err := exec.Command("git", "-C", repoPath, "checkout", "main").Run(); err != nil { - runGit(t, repoPath, "checkout", "master") + if _, err := RunGitCmdE(repoPath, "checkout", "main"); err != nil { + if _, fallbackErr := RunGitCmdE(repoPath, "checkout", "master"); fallbackErr != nil { + return "", fallbackErr + } } } - // Make repo dirty if requested if opts.Dirty { dirtyFile := filepath.Join(repoPath, "uncommitted.txt") if err := os.WriteFile(dirtyFile, []byte("uncommitted changes\n"), 0644); err != nil { - t.Fatalf("Failed to create dirty file: %v", err) + return "", fmt.Errorf("failed to create dirty file: %w", err) } } - return repoPath + return repoPath, nil } // CreateBareRemote creates a bare git repository to use as a remote func CreateBareRemote(t *testing.T, name string) string { t.Helper() - tmpDir := t.TempDir() - remotePath := filepath.Join(tmpDir, name+".git") + remotePath, err := CreateBareRemoteInDir(t.TempDir(), name) + if err != nil { + t.Fatalf("Failed to create bare remote: %v", err) + } + return remotePath +} +// CreateBareRemoteInDir creates a bare remote repository under the provided base directory. +func CreateBareRemoteInDir(baseDir, name string) (string, error) { + remotePath := filepath.Join(baseDir, name+".git") if err := os.MkdirAll(remotePath, 0755); err != nil { - t.Fatalf("Failed to create bare repo directory: %v", err) + return "", fmt.Errorf("failed to create bare repo directory: %w", err) } - - runGit(t, remotePath, "init", "--bare") - - return remotePath + if _, err := RunGitCmdE(remotePath, "init", "--bare"); err != nil { + return "", err + } + return remotePath, nil } // SetupFakeFilesystem creates a fake filesystem structure for scanning tests func SetupFakeFilesystem(t *testing.T) string { t.Helper() - tmpDir := t.TempDir() + root, err := SetupFakeFilesystemInDir(t.TempDir()) + if err != nil { + t.Fatalf("Failed to setup fake filesystem: %v", err) + } + return root +} - // Create directory structure +// SetupFakeFilesystemInDir creates a deterministic fake filesystem tree under baseDir. +func SetupFakeFilesystemInDir(baseDir string) (string, error) { dirs := []string{ "home/testuser/projects", "home/testuser/src", @@ -138,27 +165,22 @@ func SetupFakeFilesystem(t *testing.T) string { "root/sys", "root/proc", } - for _, dir := range dirs { - path := filepath.Join(tmpDir, dir) + path := filepath.Join(baseDir, dir) if err := os.MkdirAll(path, 0755); err != nil { - t.Fatalf("Failed to create directory %s: %v", dir, err) + return "", fmt.Errorf("failed to create directory %s: %w", dir, err) } } - - return tmpDir + return baseDir, nil } // runGit is a helper to run git commands in a specific directory func runGit(t *testing.T, dir string, args ...string) { t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - - output, err := cmd.CombinedOutput() + _, err := RunGitCmdE(dir, args...) if err != nil { - t.Fatalf("Git command failed: git %v\nOutput: %s\nError: %v", args, output, err) + t.Fatalf("%v", err) } } @@ -166,39 +188,89 @@ func runGit(t *testing.T, dir string, args ...string) { func IsDirty(t *testing.T, repoPath string) bool { t.Helper() - cmd := exec.Command("git", "status", "--porcelain") - cmd.Dir = repoPath - - output, err := cmd.Output() + dirty, err := IsDirtyE(repoPath) if err != nil { t.Fatalf("Failed to check git status: %v", err) } - - return len(output) > 0 + return dirty } // GetRemotes returns the configured remotes for a repo func GetRemotes(t *testing.T, repoPath string) map[string]string { t.Helper() - cmd := exec.Command("git", "remote", "-v") - cmd.Dir = repoPath - - output, err := cmd.Output() + remotes, err := GetRemotesE(repoPath) if err != nil { t.Fatalf("Failed to get remotes: %v", err) } + return remotes +} - // Parse output into map - // Format: "origin /path/to/remote (fetch)" - // "origin /path/to/remote (push)" - remotes := make(map[string]string) +// RunGitCmd runs a git command and fails the test if it errors +// Exported version of runGit for use in other test packages +func RunGitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + runGit(t, dir, args...) +} + +// RunGitCmdE runs git command in dir and returns trimmed command output. +func RunGitCmdE(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir - lines := strings.TrimSpace(string(output)) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git command failed: git %v\nOutput: %s\nError: %w", args, output, err) + } + return strings.TrimSpace(string(output)), nil +} + +// GetCurrentSHA returns the current commit SHA +func GetCurrentSHA(t *testing.T, repoPath string) string { + t.Helper() + + sha, err := GetCurrentSHAE(repoPath) + if err != nil { + t.Fatalf("Failed to get current SHA: %v", err) + } + return sha +} + +// GetBranches returns all branches in the repo +func GetBranches(t *testing.T, repoPath string) []string { + t.Helper() + + branches, err := GetBranchesE(repoPath) + if err != nil { + t.Fatalf("Failed to get branches: %v", err) + } + return branches +} + +// IsDirtyE checks if a git repo has uncommitted changes. +func IsDirtyE(repoPath string) (bool, error) { + output, err := RunGitCmdE(repoPath, "status", "--porcelain") + if err != nil { + return false, err + } + return len(output) > 0, nil +} + +// GetRemotesE returns configured remotes for a repo. +func GetRemotesE(repoPath string) (map[string]string, error) { + output, err := RunGitCmdE(repoPath, "remote", "-v") + if err != nil { + return nil, err + } + return parseRemotesOutput(output), nil +} + +func parseRemotesOutput(output string) map[string]string { + remotes := make(map[string]string) + lines := strings.TrimSpace(output) if lines == "" { return remotes } - for _, line := range strings.Split(lines, "\n") { line = strings.TrimSpace(line) if line == "" { @@ -206,7 +278,6 @@ func GetRemotes(t *testing.T, repoPath string) map[string]string { } name, remainder, ok := strings.Cut(line, "\t") if !ok { - // Fallback for unusual formatting that does not use tabs. idx := strings.IndexAny(line, " \t") if idx == -1 { continue @@ -218,8 +289,6 @@ func GetRemotes(t *testing.T, repoPath string) map[string]string { remainder = strings.TrimSpace(remainder) } - // Strip only the trailing git remote role suffix once so paths that end - // with text like " (push)" are not damaged by sequential TrimSuffix calls. if strings.HasSuffix(remainder, " (fetch)") { remainder = strings.TrimSuffix(remainder, " (fetch)") } else if strings.HasSuffix(remainder, " (push)") { @@ -230,42 +299,22 @@ func GetRemotes(t *testing.T, repoPath string) map[string]string { remotes[name] = remainder } } - return remotes } -// RunGitCmd runs a git command and fails the test if it errors -// Exported version of runGit for use in other test packages -func RunGitCmd(t *testing.T, dir string, args ...string) { - t.Helper() - runGit(t, dir, args...) -} - -// GetCurrentSHA returns the current commit SHA -func GetCurrentSHA(t *testing.T, repoPath string) string { - t.Helper() - - cmd := exec.Command("git", "rev-parse", "HEAD") - cmd.Dir = repoPath - - output, err := cmd.Output() - if err != nil { - t.Fatalf("Failed to get current SHA: %v", err) - } - - return strings.TrimSpace(string(output)) +// GetCurrentSHAE returns the current commit SHA. +func GetCurrentSHAE(repoPath string) (string, error) { + return RunGitCmdE(repoPath, "rev-parse", "HEAD") } -// GetBranches returns all branches in the repo -func GetBranches(t *testing.T, repoPath string) []string { - t.Helper() - +// GetBranchesE returns all branches in a repo. +func GetBranchesE(repoPath string) ([]string, error) { cmd := exec.Command("git", "branch", "--format=%(refname:short)") cmd.Dir = repoPath output, err := cmd.Output() if err != nil { - t.Fatalf("Failed to get branches: %v", err) + return nil, err } branches := strings.Split(strings.TrimSpace(string(output)), "\n") @@ -278,5 +327,5 @@ func GetBranches(t *testing.T, repoPath string) []string { } } - return result + return result, nil } diff --git a/snapshots.go b/snapshots.go index 56a4667..2ce4fc7 100644 --- a/snapshots.go +++ b/snapshots.go @@ -26,11 +26,26 @@ type Snapshot struct { tarball []byte // Compressed repository state in memory } +// NewSnapshot creates a snapshot instance from raw data. +func NewSnapshot(name string, payload []byte) *Snapshot { + copied := make([]byte, len(payload)) + copy(copied, payload) + return &Snapshot{name: name, tarball: copied} +} + // SnapshotRepo creates an in-memory snapshot of a repository // This allows fast restoration of expensive test setups func SnapshotRepo(t *testing.T, repoPath string) *Snapshot { t.Helper() + snapshot, err := SnapshotRepoE(repoPath) + if err != nil { + t.Fatalf("Failed to create snapshot: %v", err) + } + return snapshot +} +// SnapshotRepoE creates an in-memory snapshot of a repository and returns errors. +func SnapshotRepoE(repoPath string) (*Snapshot, error) { var buf bytes.Buffer gzipWriter := gzip.NewWriter(&buf) tarWriter := tar.NewWriter(gzipWriter) @@ -77,23 +92,20 @@ func SnapshotRepo(t *testing.T, repoPath string) *Snapshot { return nil }) - if err != nil { - t.Fatalf("Failed to create snapshot: %v", err) + return nil, err } - - // Close writers if err := tarWriter.Close(); err != nil { - t.Fatalf("Failed to close tar writer: %v", err) + return nil, fmt.Errorf("failed to close tar writer: %w", err) } if err := gzipWriter.Close(); err != nil { - t.Fatalf("Failed to close gzip writer: %v", err) + return nil, fmt.Errorf("failed to close gzip writer: %w", err) } return &Snapshot{ name: normalizeSnapshotName(repoPath), tarball: buf.Bytes(), - } + }, nil } // RestoreSnapshot restores a snapshot to a new temporary directory @@ -101,75 +113,72 @@ func SnapshotRepo(t *testing.T, repoPath string) *Snapshot { func RestoreSnapshot(t *testing.T, snapshot *Snapshot) string { t.Helper() - // Create temp directory for restoration - tmpDir := t.TempDir() - restorePath, err := safeJoin(tmpDir, snapshot.name) + restorePath, err := RestoreSnapshotToDir(snapshot, t.TempDir()) + if err != nil { + t.Fatalf("Failed to restore snapshot: %v", err) + } + return restorePath +} + +// RestoreSnapshotToDir restores a snapshot under baseDir and returns restore path. +func RestoreSnapshotToDir(snapshot *Snapshot, baseDir string) (string, error) { + restorePath, err := safeJoin(baseDir, snapshot.name) if err != nil { - t.Fatalf("Invalid snapshot name %q: %v", snapshot.name, err) + return "", fmt.Errorf("invalid snapshot name %q: %w", snapshot.name, err) } if err := os.MkdirAll(restorePath, 0755); err != nil { - t.Fatalf("Failed to create restore directory: %v", err) + return "", fmt.Errorf("failed to create restore directory: %w", err) } - // Create readers gzipReader, err := gzip.NewReader(bytes.NewReader(snapshot.tarball)) if err != nil { - t.Fatalf("Failed to create gzip reader: %v", err) + return "", fmt.Errorf("failed to create gzip reader: %w", err) } defer gzipReader.Close() - tarReader := tar.NewReader(gzipReader) - // Extract files from tarball for { header, err := tarReader.Next() if err == io.EOF { - break // End of archive + break } if err != nil { - t.Fatalf("Failed to read tar header: %v", err) + return "", fmt.Errorf("failed to read tar header: %w", err) } - // Construct full path targetPath, err := safeJoin(restorePath, header.Name) if err != nil { - t.Fatalf("Invalid snapshot path %q: %v", header.Name, err) + return "", fmt.Errorf("invalid snapshot path %q: %w", header.Name, err) } - // Handle different file types switch header.Typeflag { case tar.TypeDir: - // Create directory if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { - t.Fatalf("Failed to create directory %s: %v", targetPath, err) + return "", fmt.Errorf("failed to create directory %s: %w", targetPath, err) } - case tar.TypeReg: - // Create parent directory if needed dir := filepath.Dir(targetPath) if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatalf("Failed to create parent directory for %s: %v", targetPath, err) + return "", fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err) } - - // Create and write file file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) if err != nil { - t.Fatalf("Failed to create file %s: %v", targetPath, err) + return "", fmt.Errorf("failed to create file %s: %w", targetPath, err) } - if _, err := io.Copy(file, tarReader); err != nil { file.Close() - t.Fatalf("Failed to write file %s: %v", targetPath, err) + return "", fmt.Errorf("failed to write file %s: %w", targetPath, err) + } + if err := file.Close(); err != nil { + return "", fmt.Errorf("failed closing file %s: %w", targetPath, err) } - file.Close() - default: - t.Logf("Skipping unsupported file type %v for %s", header.Typeflag, header.Name) + continue } } - return restorePath + return restorePath, nil } func safeJoin(base, name string) (string, error) { @@ -206,11 +215,17 @@ func (s *Snapshot) Name() string { return s.name } +// Payload returns a copy of snapshot bytes. +func (s *Snapshot) Payload() []byte { + copied := make([]byte, len(s.tarball)) + copy(copied, s.tarball) + return copied +} + // SaveSnapshotToDisk saves a snapshot to a file (for debugging or caching) func SaveSnapshotToDisk(t *testing.T, snapshot *Snapshot, filepath string) { t.Helper() - - if err := os.WriteFile(filepath, snapshot.tarball, 0644); err != nil { + if err := SaveSnapshotToDiskE(snapshot, filepath); err != nil { t.Fatalf("Failed to save snapshot to disk: %v", err) } } @@ -218,16 +233,28 @@ func SaveSnapshotToDisk(t *testing.T, snapshot *Snapshot, filepath string) { // LoadSnapshotFromDisk loads a snapshot from a file func LoadSnapshotFromDisk(t *testing.T, filePath string) *Snapshot { t.Helper() - - data, err := os.ReadFile(filePath) + snapshot, err := LoadSnapshotFromDiskE(filePath) if err != nil { t.Fatalf("Failed to load snapshot from disk: %v", err) } + return snapshot +} + +// SaveSnapshotToDiskE saves a snapshot to disk and returns errors. +func SaveSnapshotToDiskE(snapshot *Snapshot, filePath string) error { + return os.WriteFile(filePath, snapshot.tarball, 0644) +} +// LoadSnapshotFromDiskE loads a snapshot from disk and returns errors. +func LoadSnapshotFromDiskE(filePath string) (*Snapshot, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } return &Snapshot{ name: normalizeSnapshotName(filePath), tarball: data, - } + }, nil } // Example usage in tests: diff --git a/testkit/GIT_TESTKIT_SPEC.md b/testkit/GIT_TESTKIT_SPEC.md new file mode 100644 index 0000000..488d01d --- /dev/null +++ b/testkit/GIT_TESTKIT_SPEC.md @@ -0,0 +1,195 @@ +# GIT_TESTKIT_SPEC + +Language-agnostic behavioral contract for the polyglot `testkit` implementations. + +## 1) Scope and non-goals + +This spec defines fixture, scenario, and snapshot behavior implemented against the real `git` executable. + +The existing Go module (`github.com/git-fire/git-testkit`) remains the source behavior reference. The first cross-language delivery uses a Go CLI bridge with thin wrappers in Python/Java. Future native ports (Option B) must satisfy the same document. + +## 2) Global guarantees + +- **Real git only**: all repository operations execute the system `git` binary. +- **No mocking**: tests exercise actual repositories on disk. +- **Failure is fatal**: + - Go: test-abort semantics (`t.Fatalf`) for legacy test-facing APIs. + - Bridge APIs: error-returning core + CLI process exit with JSON error. + - Python/Java wrappers: bridge failures surface as exceptions. +- **Ephemeral filesystem**: + - All APIs are intended to operate under per-test temporary directories. + - Callers own temporary root lifecycle (`t.TempDir`, `tmp_path`, `@TempDir`). + +## 3) Fixtures contract + +Bridge equivalents are exposed as CLI methods with JSON request/response payloads and deterministic field names. + +## 3.1 RepoOptions + +Logical options: + +- `name`: repository directory name. +- `dirty`: if true, leaves uncommitted changes. +- `files`: map of path -> content for additional committed files. +- `remotes`: map of remote name -> URL/path. +- `branches`: list of branches to create. +- `initialCommitMsg`: optional first commit message (default: `"Initial commit"`). + +## 3.2 createTestRepo + +Creates a non-bare git repository at `/` and returns its path. + +Behavior: + +1. Initializes git repo and configures user identity. +2. Creates `README.md`, stages, and commits initial commit. +3. For each `files` entry: + - creates parent directories, + - writes content, + - stages and commits with message `Add `. +4. Adds configured remotes. +5. Creates each branch in `branches` via checkout-new branch. +6. If branches were created, returns checkout to `main` if present, otherwise `master`. +7. If `dirty` is true, writes an uncommitted file (unstaged). + +Postconditions: + +- Returned path exists. +- Repository has at least one commit. +- Clean unless `dirty=true`. + +## 3.3 createBareRemote + +Creates bare repo at `/.git`, returns its path. + +Postconditions: + +- Returned path exists. +- It is a valid bare git repository (e.g., has `HEAD`/`config`). + +## 3.4 setupFakeFilesystem + +Creates a deterministic fake directory tree for path-scanning tests and returns the root path. + +Minimum directories: + +- `home/testuser/projects` +- `home/testuser/src` +- `home/testuser/.cache` +- `home/testuser/node_modules` +- `root/sys` +- `root/proc` + +## 3.5 runGitCmd + +Runs `git ` in a target repo path. + +- Returns command stdout when successful. +- On failure throws/aborts immediately. + +## 3.6 isDirty + +Returns whether `git status --porcelain` is non-empty. + +## 3.7 getRemotes + +Returns map `remoteName -> remoteURL`. + +- Parses `git remote -v`. +- Handles both `(fetch)` and `(push)` suffixes. +- Handles paths containing spaces and literal text like `" (push)"`. + +## 3.8 getCurrentSHA + +Returns `git rev-parse HEAD` result trimmed. + +## 3.9 getBranches + +Returns branch short names from `git branch --format=%(refname:short)`. + +## 4) Scenario contract + +## 4.1 Scenario + +Scenario tracks named repos under one test temp root. + +Core operations: + +- `createRepo(name)` -> `ScenarioRepo` +- `createBareRepo(name)` -> `ScenarioRepo` +- `getRepo(name)` -> `ScenarioRepo` + +## 4.2 ScenarioRepo fluent API + +Mutating operations are fluent: each returns the same logical repository object (or a worktree repository object for `addWorktree`) and apply side effects immediately. + +Methods: + +- `withRemote(remoteName, remoteRepo)` +- `withBranch(branchName)` +- `addFile(name, content)` (writes + stages) +- `modifyFile(name, content)` (writes only) +- `stageFile(name)` +- `commit(msg)` +- `push(remote, branch)` +- `checkout(branch)` +- `addWorktree(branch, path)` -> `ScenarioRepo` +- `path()` -> string +- `getDefaultBranch()` -> `"main"` if exists, else `"master"` if exists, else `"main"` + +## 4.3 Prebuilt scenarios + +Implementations should provide parity helpers: + +- `createCleanRepoScenario` +- `createConflictScenario` +- `createDirtyRepoScenario` +- `createDetachedHeadScenario` +- `createMultiRemoteScenario` +- `createMultiBranchScenario` +- `createLargeRepoScenario` +- `createWorktreeScenario` + +Each helper must construct real repositories using fixture/scenario primitives. + +## 5) Snapshot contract + +## 5.1 snapshotRepo + +Captures complete repository filesystem state into an in-memory snapshot object. + +- Includes `.git` metadata and working tree files. +- Produces deterministic restoration behavior. + +## 5.2 restoreSnapshot + +Restores snapshot into `/` and returns restored repo path. + +Security/validity: + +- Reject absolute or traversal (`..`) snapshot names/entries. + +## 5.3 save/load + +- `saveSnapshotToDisk(snapshot, filePath)` writes snapshot bytes. +- `loadSnapshotFromDisk(filePath)` loads bytes and creates snapshot object. + +## 5.4 Snapshot metadata + +Snapshot exposes: + +- `name()`: logical snapshot name. + - For `snapshotRepo(path)`, derived from source directory basename. + - For `loadSnapshotFromDisk(path)`, derived from file basename. + - `"."`, `".."`, and root-like basenames normalize to `"snapshot"`. +- `size()`: byte size of serialized snapshot payload. + +## 6) Cross-language smoke flow + +Both language ports must support the same end-to-end path: + +1. Create bare remote. +2. Create local repo. +3. Add remote and commit. +4. Push default branch. +5. Validate SHA/branch/remotes. diff --git a/testkit/ROADMAP.md b/testkit/ROADMAP.md new file mode 100644 index 0000000..e1ed56d --- /dev/null +++ b/testkit/ROADMAP.md @@ -0,0 +1,50 @@ +# Polyglot testkit roadmap + +This roadmap adopts a hybrid strategy: + +- **Option A (now):** single reusable Go core with a JSON CLI bridge. +- **Option B (later):** native Python and Java implementations validated against shared behavior tests. + +## Goals + +- Maximize reuse by keeping one behavior source-of-truth first. +- Maximize DevEx by exposing thin language wrappers that feel simple to call. +- Prove adoption path with smoke tests and executable examples. + +## Phase 1 (Option A): Go core + CLI bridge + +Deliverables: + +1. Keep existing Go `testing.T` APIs for backward compatibility. +2. Add reusable error-returning Go APIs that do not depend on `testing.T`. +3. Add CLI binary (`testkit/cli`) with JSON request/response protocol. +4. Add Python and Java thin wrappers that shell out to the CLI. +5. Add smoke tests proving fixture -> scenario-like flow -> snapshot round-trip. + +Success criteria: + +- Existing Go tests stay green. +- CLI handles core fixture and snapshot operations. +- Python and Java smoke tests pass against real `git`. + +## Phase 2 (Option B): Native ports + +Deliverables: + +1. Native Python implementation (fixtures/scenarios/snapshots). +2. Native Java implementation (fixtures/scenarios/snapshots). +3. Cross-language conformance tests generated from `GIT_TESTKIT_SPEC.md`. +4. Optional dual mode in wrappers: + - `mode=cli` (Go bridge) + - `mode=native` (language-native) + +Success criteria: + +- Native implementations pass conformance tests and smoke tests. +- CLI mode remains available as stable fallback. + +## Adoption strategy + +- Start users on thin wrapper + CLI mode for reliability. +- Keep wrapper API stable while internals evolve. +- Introduce native mode only when parity and maintenance plan are ready. diff --git a/testkit/java/pom.xml b/testkit/java/pom.xml new file mode 100644 index 0000000..b67d643 --- /dev/null +++ b/testkit/java/pom.xml @@ -0,0 +1,34 @@ + + 4.0.0 + io.gitfire + git-testkit-java-wrapper + 0.1.0 + git-testkit-java-wrapper + + + 21 + UTF-8 + 5.11.0 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.1 + + + + diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java new file mode 100644 index 0000000..e092850 --- /dev/null +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -0,0 +1,244 @@ +package io.gitfire.testkit; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class CliBridge { + private static final Pattern ERROR_PATTERN = Pattern.compile("\"error\"\\s*:\\s*\"([^\"]*)\""); + private static final Pattern REPO_PATH_PATTERN = Pattern.compile("\"repoPath\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern REMOTE_PATH_PATTERN = Pattern.compile("\"remotePath\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern OUTPUT_PATTERN = Pattern.compile("\"output\"\\s*:\\s*\"([^\"]*)\""); + private static final Pattern SHA_PATTERN = Pattern.compile("\"sha\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern DIRTY_PATTERN = Pattern.compile("\"dirty\"\\s*:\\s*(true|false)"); + private static final Pattern BRANCHES_PATTERN = Pattern.compile("\"branches\"\\s*:\\s*\\[(.*?)]"); + private static final Pattern REMOTES_PATTERN = Pattern.compile("\"remotes\"\\s*:\\s*\\{(.*?)}"); + private static final Pattern SNAPSHOT_NAME_PATTERN = Pattern.compile("\"snapshotName\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern SNAPSHOT_SIZE_PATTERN = Pattern.compile("\"snapshotSize\"\\s*:\\s*([0-9]+)"); + private static final Pattern RESTORE_PATH_PATTERN = Pattern.compile("\"restorePath\"\\s*:\\s*\"([^\"]+)\""); + + private static final class CliResult { + private final String stdout; + private final String stderr; + private final int code; + + private CliResult(String stdout, String stderr, int code) { + this.stdout = stdout; + this.stderr = stderr; + this.code = code; + } + } + + public record SnapshotInfo(String name, int size) {} + + public record RestoredSnapshot(String path, String name, int size) {} + + private final String cliCommand; + private final Path workspaceRoot; + + public CliBridge(Path workspaceRoot) { + this(workspaceRoot, "go run ./cmd/git-testkit-cli"); + } + + public CliBridge() { + this(Path.of(System.getProperty("user.dir"))); + } + + public CliBridge(Path workspaceRoot, String cliCommand) { + this.workspaceRoot = workspaceRoot; + this.cliCommand = cliCommand; + } + + public String createTestRepo(Path baseDir, String name) { + String payload = + "{\"op\":\"create_test_repo\",\"baseDir\":\"" + + escape(baseDir.toString()) + + "\",\"options\":{\"name\":\"" + + escape(name) + + "\"}}"; + return extractRequired(invoke(payload), REPO_PATH_PATTERN, "repoPath"); + } + + public String createBareRemote(Path baseDir, String name) { + String payload = + "{\"op\":\"create_bare_remote\",\"baseDir\":\"" + + escape(baseDir.toString()) + + "\",\"options\":{\"name\":\"" + + escape(name) + + "\"}}"; + return extractRequired(invoke(payload), REMOTE_PATH_PATTERN, "remotePath"); + } + + public String runGitCmd(String repoPath, String... args) { + StringBuilder payload = + new StringBuilder( + "{\"op\":\"run_git_cmd\",\"repoPath\":\"" + escape(repoPath) + "\",\"args\":["); + for (int i = 0; i < args.length; i++) { + if (i > 0) { + payload.append(','); + } + payload.append('"').append(escape(args[i])).append('"'); + } + payload.append("]}"); + String json = invoke(payload.toString()); + return extractRequired(json, OUTPUT_PATTERN, "output"); + } + + public boolean isDirty(String repoPath) { + String payload = "{\"op\":\"is_dirty\",\"repoPath\":\"" + escape(repoPath) + "\"}"; + String json = invoke(payload); + String dirty = extractRequired(json, DIRTY_PATTERN, "dirty"); + return "true".equals(dirty); + } + + public Map getRemotes(String repoPath) { + String payload = "{\"op\":\"get_remotes\",\"repoPath\":\"" + escape(repoPath) + "\"}"; + String json = invoke(payload); + Matcher matcher = REMOTES_PATTERN.matcher(json); + Map remotes = new LinkedHashMap<>(); + if (!matcher.find()) { + return remotes; + } + String body = matcher.group(1).trim(); + if (body.isEmpty()) { + return remotes; + } + String[] pairs = body.split(","); + for (String pair : pairs) { + String[] kv = pair.split(":", 2); + if (kv.length != 2) { + continue; + } + String key = unquote(kv[0].trim()); + String value = unquote(kv[1].trim()); + remotes.put(key, value); + } + return remotes; + } + + public String getCurrentSha(String repoPath) { + String payload = "{\"op\":\"get_current_sha\",\"repoPath\":\"" + escape(repoPath) + "\"}"; + return extractRequired(invoke(payload), SHA_PATTERN, "sha"); + } + + public List getBranches(String repoPath) { + String payload = "{\"op\":\"get_branches\",\"repoPath\":\"" + escape(repoPath) + "\"}"; + String json = invoke(payload); + Matcher matcher = BRANCHES_PATTERN.matcher(json); + List branches = new ArrayList<>(); + if (!matcher.find()) { + return branches; + } + String body = matcher.group(1).trim(); + if (body.isEmpty()) { + return branches; + } + String[] items = body.split(","); + for (String item : items) { + branches.add(unquote(item.trim())); + } + return branches; + } + + public SnapshotInfo snapshotRepo(String repoPath) { + String payload = "{\"op\":\"snapshot_repo\",\"repoPath\":\"" + escape(repoPath) + "\"}"; + String json = invoke(payload); + String name = extractRequired(json, SNAPSHOT_NAME_PATTERN, "snapshotName"); + int size = Integer.parseInt(extractRequired(json, SNAPSHOT_SIZE_PATTERN, "snapshotSize")); + return new SnapshotInfo(name, size); + } + + public SnapshotInfo snapshotSave(String repoPath, String snapshotPath) { + String payload = + "{\"op\":\"snapshot_save\",\"repoPath\":\"" + + escape(repoPath) + + "\",\"snapshotPath\":\"" + + escape(snapshotPath) + + "\"}"; + String json = invoke(payload); + String name = extractRequired(json, SNAPSHOT_NAME_PATTERN, "snapshotName"); + int size = Integer.parseInt(extractRequired(json, SNAPSHOT_SIZE_PATTERN, "snapshotSize")); + return new SnapshotInfo(name, size); + } + + public RestoredSnapshot snapshotLoadRestore(Path baseDir, String snapshotPath) { + String payload = + "{\"op\":\"snapshot_load_restore\",\"baseDir\":\"" + + escape(baseDir.toString()) + + "\",\"snapshotPath\":\"" + + escape(snapshotPath) + + "\"}"; + String json = invoke(payload); + String restorePath = extractRequired(json, RESTORE_PATH_PATTERN, "restorePath"); + String name = extractRequired(json, SNAPSHOT_NAME_PATTERN, "snapshotName"); + int size = Integer.parseInt(extractRequired(json, SNAPSHOT_SIZE_PATTERN, "snapshotSize")); + return new RestoredSnapshot(restorePath, name, size); + } + + private String invoke(String payload) { + CliResult result = runCli(payload); + if (result.code != 0) { + String stderr = result.stderr == null ? "" : result.stderr; + throw new RuntimeException("CLI failed with code " + result.code + ": " + stderr); + } + if (result.stdout == null || result.stdout.isBlank()) { + throw new RuntimeException("CLI returned empty response"); + } + String stdout = result.stdout.trim(); + if (!stdout.contains("\"ok\":true")) { + String error = extractOptional(stdout, ERROR_PATTERN); + throw new RuntimeException(error.isEmpty() ? "CLI returned failure response" : error); + } + return stdout; + } + + private CliResult runCli(String payload) { + try { + ProcessBuilder pb = new ProcessBuilder("bash", "-lc", cliCommand); + pb.directory(workspaceRoot.toFile()); + Process process = pb.start(); + process.getOutputStream().write(payload.getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().close(); + int code = process.waitFor(); + String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + return new CliResult(stdout, stderr, code); + } catch (IOException ex) { + throw new RuntimeException("failed to invoke CLI", ex); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new RuntimeException("interrupted while invoking CLI", ex); + } + } + + private static String extractRequired(String json, Pattern pattern, String fieldName) { + Matcher matcher = pattern.matcher(json); + if (!matcher.find()) { + throw new RuntimeException("missing field " + fieldName + " in response: " + json); + } + return matcher.group(1); + } + + private static String extractOptional(String json, Pattern pattern) { + Matcher matcher = pattern.matcher(json); + return matcher.find() ? matcher.group(1) : ""; + } + + private static String unquote(String value) { + String out = value; + if (out.startsWith("\"") && out.endsWith("\"") && out.length() >= 2) { + out = out.substring(1, out.length() - 1); + } + return out.replace("\\\"", "\"").replace("\\\\", "\\"); + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java new file mode 100644 index 0000000..ac02270 --- /dev/null +++ b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java @@ -0,0 +1,54 @@ +package io.gitfire.testkit; + +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CliBridgeTest { + @TempDir Path tmp; + + @Test + void createTestRepoProducesCleanRepoAndBranches() { + CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); + String repo = bridge.createTestRepo(tmp, "subject"); + + assertTrue(Files.exists(Path.of(repo, ".git"))); + assertFalse(bridge.isDirty(repo)); + assertFalse(bridge.getBranches(repo).isEmpty()); + } + + @Test + void createBareRemoteAndPushSmokeFlow() { + CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); + String remote = bridge.createBareRemote(tmp, "origin"); + String local = bridge.createTestRepo(tmp, "local"); + + bridge.runGitCmd(local, "remote", "add", "origin", remote); + bridge.runGitCmd(local, "checkout", "-b", "feature"); + bridge.runGitCmd(local, "add", "README.md"); + bridge.runGitCmd(local, "commit", "-m", "update readme"); + bridge.runGitCmd(local, "push", "origin", "feature"); + + String localSha = bridge.getCurrentSha(local); + String remoteSha = bridge.runGitCmd(remote, "rev-parse", "feature").trim(); + assertEquals(localSha, remoteSha); + } + + @Test + void snapshotRoundtripSmoke() { + CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); + String repo = bridge.createTestRepo(tmp, "snap"); + Path snapshotPath = tmp.resolve("snapshots").resolve("snap.tar.gz"); + CliBridge.SnapshotInfo info = bridge.snapshotSave(repo, snapshotPath.toString()); + CliBridge.RestoredSnapshot restored = bridge.snapshotLoadRestore(tmp, snapshotPath.toString()); + + assertTrue(info.size() > 0); + assertTrue(Files.exists(Path.of(restored.path()))); + assertEquals(bridge.getCurrentSha(repo), bridge.getCurrentSha(restored.path())); + } +} diff --git a/testkit/python/README.md b/testkit/python/README.md new file mode 100644 index 0000000..4d2f2d3 --- /dev/null +++ b/testkit/python/README.md @@ -0,0 +1,9 @@ +# testkit/python + +Thin Python wrapper over `git-testkit-cli` (Option A bridge). + +The wrapper prioritizes: + +- a clean Pythonic API surface, +- zero drift from Go behavior by delegating execution to the Go core, +- easy migration to optional native implementation in a later phase. diff --git a/testkit/python/git_testkit/__init__.py b/testkit/python/git_testkit/__init__.py new file mode 100644 index 0000000..0f77153 --- /dev/null +++ b/testkit/python/git_testkit/__init__.py @@ -0,0 +1,3 @@ +from .cli import GitTestKitClient, RepoOptions + +__all__ = ["GitTestKitClient", "RepoOptions"] diff --git a/testkit/python/git_testkit/cli.py b/testkit/python/git_testkit/cli.py new file mode 100644 index 0000000..102f689 --- /dev/null +++ b/testkit/python/git_testkit/cli.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _cli_cmd() -> list[str]: + return ["go", "run", "./cmd/git-testkit-cli"] + + +def _call(op: str, **payload: Any) -> dict[str, Any]: + request = {"op": op, **payload} + proc = subprocess.run( + _cli_cmd(), + cwd=_repo_root(), + input=json.dumps(request), + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(f"git-testkit-cli exited {proc.returncode}: {proc.stderr.strip()}") + + response = json.loads(proc.stdout) + if not response.get("ok", False): + raise RuntimeError(response.get("error", "unknown git-testkit-cli error")) + return response + + +@dataclass(slots=True) +class RepoOptions: + name: str + dirty: bool = False + files: dict[str, str] = field(default_factory=dict) + remotes: dict[str, str] = field(default_factory=dict) + branches: list[str] = field(default_factory=list) + initial_commit: str = "" + + def to_payload(self) -> dict[str, Any]: + return { + "name": self.name, + "dirty": self.dirty, + "files": self.files, + "remotes": self.remotes, + "branches": self.branches, + "initialCommit": self.initial_commit, + } + + +class GitTestKitClient: + def create_test_repo( + self, + base_dir: Path | str, + options: RepoOptions | None = None, + **kwargs: Any, + ) -> str: + if options is None: + options = RepoOptions(**kwargs) + res = _call("create_test_repo", baseDir=str(base_dir), options=options.to_payload()) + return str(res["repoPath"]) + + def create_bare_remote(self, base_dir: Path | str, name: str) -> str: + res = _call( + "create_bare_remote", + baseDir=str(base_dir), + options={"name": name}, + ) + return str(res["remotePath"]) + + def setup_fake_filesystem(self, base_dir: Path | str) -> str: + res = _call("setup_fake_filesystem", baseDir=str(base_dir)) + return str(res["fsRoot"]) + + def run_git_cmd(self, repo_path: str, *args: str) -> str: + res = _call("run_git_cmd", repoPath=repo_path, args=list(args)) + return str(res.get("output", "")) + + def is_dirty(self, repo_path: str) -> bool: + res = _call("is_dirty", repoPath=repo_path) + return bool(res["dirty"]) + + def get_remotes(self, repo_path: str) -> dict[str, str]: + res = _call("get_remotes", repoPath=repo_path) + return dict(res.get("remotes", {})) + + def get_current_sha(self, repo_path: str) -> str: + res = _call("get_current_sha", repoPath=repo_path) + return str(res["sha"]) + + def get_branches(self, repo_path: str) -> list[str]: + res = _call("get_branches", repoPath=repo_path) + return [str(b) for b in res.get("branches", [])] + + def save_snapshot(self, repo_path: str, snapshot_path: Path | str) -> tuple[str, int]: + res = _call("snapshot_save", repoPath=repo_path, snapshotPath=str(snapshot_path)) + return str(res["snapshotName"]), int(res["snapshotSize"]) + + def load_restore_snapshot(self, snapshot_path: Path | str, base_dir: Path | str) -> str: + res = _call( + "snapshot_load_restore", + snapshotPath=str(snapshot_path), + baseDir=str(base_dir), + ) + return str(res["restorePath"]) + + # Backward-compatible aliases for smoke tests/docs. + def snapshot_save(self, repo_path: str, snapshot_path: Path | str) -> dict[str, Any]: + name, size = self.save_snapshot(repo_path, snapshot_path) + return {"snapshot_name": name, "snapshot_size": size} + + def snapshot_load_restore(self, snapshot_path: Path | str, base_dir: Path | str) -> str: + return self.load_restore_snapshot(snapshot_path, base_dir) + + +GitTestKitCLI = GitTestKitClient diff --git a/testkit/python/pyproject.toml b/testkit/python/pyproject.toml new file mode 100644 index 0000000..522e496 --- /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-testkit-polyglot-python" +version = "0.1.0" +description = "Python wrapper for git-testkit CLI bridge" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["git_testkit*"] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/testkit/python/tests/test_fixtures.py b/testkit/python/tests/test_fixtures.py new file mode 100644 index 0000000..a1c8904 --- /dev/null +++ b/testkit/python/tests/test_fixtures.py @@ -0,0 +1,76 @@ +from pathlib import Path +import subprocess + +import pytest + +from git_testkit import GitTestKitClient, RepoOptions + + +def test_create_test_repo_clean(tmp_path: Path) -> None: + cli = GitTestKitClient() + repo = cli.create_test_repo(tmp_path, RepoOptions(name="subject")) + assert Path(repo, ".git").exists() + assert not cli.is_dirty(repo) + assert cli.get_branches(repo) != [] + assert cli.get_current_sha(repo) + + +def test_create_test_repo_dirty(tmp_path: Path) -> None: + cli = GitTestKitClient() + repo = cli.create_test_repo(tmp_path, RepoOptions(name="dirty", dirty=True)) + assert cli.is_dirty(repo) + assert Path(repo, "uncommitted.txt").exists() + + +def test_create_test_repo_with_files_and_branches(tmp_path: Path) -> None: + cli = GitTestKitClient() + repo = cli.create_test_repo( + tmp_path, + RepoOptions( + name="files", + files={"src/main.py": "print('ok')\n", "config/app.yml": "port: 8080\n"}, + branches=["feature-a", "feature-b"], + ), + ) + assert Path(repo, "src/main.py").exists() + assert Path(repo, "config/app.yml").exists() + branches = cli.get_branches(repo) + assert "feature-a" in branches + assert "feature-b" in branches + + +def test_create_bare_remote(tmp_path: Path) -> None: + cli = GitTestKitClient() + bare = cli.create_bare_remote(tmp_path, "origin") + assert Path(bare, "HEAD").exists() + assert Path(bare, "config").exists() + + +def test_get_remotes_handles_path_with_spaces(tmp_path: Path) -> None: + cli = GitTestKitClient() + bare = cli.create_bare_remote(tmp_path, "origin with space") + repo = cli.create_test_repo(tmp_path, RepoOptions(name="local", remotes={"origin": bare})) + remotes = cli.get_remotes(repo) + assert remotes["origin"] == bare + + +def test_get_remotes_handles_path_with_push_suffix_literal(tmp_path: Path) -> None: + cli = GitTestKitClient() + weird_remote = tmp_path / "origin (push)" + weird_remote.mkdir(parents=True) + subprocess.run( + ["git", "init", "--bare", str(weird_remote)], + check=True, + capture_output=True, + text=True, + ) + repo = cli.create_test_repo(tmp_path, RepoOptions(name="local", remotes={"origin": str(weird_remote)})) + remotes = cli.get_remotes(repo) + assert remotes["origin"] == str(weird_remote) + + +def test_run_git_cmd_failure_raises(tmp_path: Path) -> None: + cli = GitTestKitClient() + repo = cli.create_test_repo(tmp_path, RepoOptions(name="r")) + with pytest.raises(RuntimeError): + cli.run_git_cmd(repo, "nonexistent-command") diff --git a/testkit/python/tests/test_scenarios.py b/testkit/python/tests/test_scenarios.py new file mode 100644 index 0000000..3ab27d1 --- /dev/null +++ b/testkit/python/tests/test_scenarios.py @@ -0,0 +1,9 @@ +from pathlib import Path + +from git_testkit import GitTestKitClient, RepoOptions + + +def test_python_wrapper_bridge_smoke(tmp_path: Path) -> None: + client = GitTestKitClient() + repo = client.create_test_repo(tmp_path, RepoOptions(name="bridge-scenario")) + assert Path(repo, ".git").exists() diff --git a/testkit/python/tests/test_snapshots.py b/testkit/python/tests/test_snapshots.py new file mode 100644 index 0000000..c624224 --- /dev/null +++ b/testkit/python/tests/test_snapshots.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from git_testkit import GitTestKitClient, RepoOptions + + +def test_snapshot_roundtrip(tmp_path: Path) -> None: + client = GitTestKitClient() + repo = client.create_test_repo(tmp_path, RepoOptions(name="snap")) + snapshot_path = tmp_path / "snapshots" / "snap.tar.gz" + name, size = client.save_snapshot(repo, snapshot_path) + restored = client.load_restore_snapshot(snapshot_path, tmp_path) + + assert name == "snap" + assert size > 0 + assert Path(restored).exists() + assert client.get_current_sha(restored) == client.get_current_sha(repo) + assert set(client.get_branches(restored)) == set(client.get_branches(repo)) + + +def test_smoke_remote_push_and_sha(tmp_path: Path) -> None: + client = GitTestKitClient() + remote = client.create_bare_remote(tmp_path, "origin") + local = client.create_test_repo(tmp_path, RepoOptions(name="local")) + client.run_git_cmd(local, "remote", "add", "origin", remote) + client.run_git_cmd(local, "checkout", "-b", "feature") + readme = Path(local) / "README.md" + readme.write_text(readme.read_text(encoding="utf-8") + "feature\n", encoding="utf-8") + client.run_git_cmd(local, "add", "README.md") + client.run_git_cmd(local, "commit", "-m", "feature commit") + client.run_git_cmd(local, "push", "origin", "feature") + + local_sha = client.get_current_sha(local) + remote_sha = client.run_git_cmd(remote, "rev-parse", "feature") + assert local_sha == remote_sha + From 36109dd090f0bd8b2901b58382effb71c641c281 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 21:44:48 +0000 Subject: [PATCH 02/27] Fix wrapper smoke test edge cases and Java build config Co-authored-by: Ben Schellenberger --- snapshots.go | 3 +++ testkit/java/pom.xml | 8 ++++++++ .../java/src/main/java/io/gitfire/testkit/CliBridge.java | 3 +++ .../src/test/java/io/gitfire/testkit/CliBridgeTest.java | 4 +++- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/snapshots.go b/snapshots.go index 2ce4fc7..4e15e77 100644 --- a/snapshots.go +++ b/snapshots.go @@ -242,6 +242,9 @@ func LoadSnapshotFromDisk(t *testing.T, filePath string) *Snapshot { // SaveSnapshotToDiskE saves a snapshot to disk and returns errors. func SaveSnapshotToDiskE(snapshot *Snapshot, filePath string) error { + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return err + } return os.WriteFile(filePath, snapshot.tarball, 0644) } diff --git a/testkit/java/pom.xml b/testkit/java/pom.xml index b67d643..4a8ba14 100644 --- a/testkit/java/pom.xml +++ b/testkit/java/pom.xml @@ -24,6 +24,14 @@ + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${maven.compiler.release} + + org.apache.maven.plugins maven-surefire-plugin diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index e092850..bdb38d8 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -87,6 +87,9 @@ public String runGitCmd(String repoPath, String... args) { } payload.append("]}"); String json = invoke(payload.toString()); + if (!json.contains("\"output\"")) { + return ""; + } return extractRequired(json, OUTPUT_PATTERN, "output"); } diff --git a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java index ac02270..980f3c9 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java @@ -6,6 +6,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -23,13 +24,14 @@ void createTestRepoProducesCleanRepoAndBranches() { } @Test - void createBareRemoteAndPushSmokeFlow() { + void createBareRemoteAndPushSmokeFlow() throws Exception { CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); String remote = bridge.createBareRemote(tmp, "origin"); String local = bridge.createTestRepo(tmp, "local"); bridge.runGitCmd(local, "remote", "add", "origin", remote); bridge.runGitCmd(local, "checkout", "-b", "feature"); + Files.writeString(Path.of(local, "README.md"), "feature\n", StandardOpenOption.APPEND); bridge.runGitCmd(local, "add", "README.md"); bridge.runGitCmd(local, "commit", "-m", "update readme"); bridge.runGitCmd(local, "push", "origin", "feature"); From 776d38bc6e87711312e3050b82a0fa6aa3ac5e67 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 22:22:48 +0000 Subject: [PATCH 03/27] Add reverse spec-kit scaffold and conformance checks Co-authored-by: Ben Schellenberger --- testkit/.specify/memory/constitution.md | 27 ++++++ .../checklists/quality.md | 8 ++ .../contracts/cli-protocol.json | 59 ++++++++++++ .../specs/001-polyglot-testkit/plan.md | 43 +++++++++ .../specs/001-polyglot-testkit/spec.md | 94 +++++++++++++++++++ .../specs/001-polyglot-testkit/tasks.md | 28 ++++++ testkit/README.md | 27 ++++++ testkit/ROADMAP.md | 7 ++ .../io/gitfire/testkit/CliBridgeTest.java | 13 +++ .../python/tests/test_specify_conformance.py | 16 ++++ 10 files changed, 322 insertions(+) create mode 100644 testkit/.specify/memory/constitution.md create mode 100644 testkit/.specify/specs/001-polyglot-testkit/checklists/quality.md create mode 100644 testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json create mode 100644 testkit/.specify/specs/001-polyglot-testkit/plan.md create mode 100644 testkit/.specify/specs/001-polyglot-testkit/spec.md create mode 100644 testkit/.specify/specs/001-polyglot-testkit/tasks.md create mode 100644 testkit/README.md create mode 100644 testkit/python/tests/test_specify_conformance.py diff --git a/testkit/.specify/memory/constitution.md b/testkit/.specify/memory/constitution.md new file mode 100644 index 0000000..bea9ba3 --- /dev/null +++ b/testkit/.specify/memory/constitution.md @@ -0,0 +1,27 @@ +# Testkit Constitution + +## Core Principles + +### I. Real Git Only +All implementations and tests MUST run against the real `git` binary on `PATH`. +No mocks or fake git output are permitted for conformance validation. + +### II. Single Behavior Source +Option A (Go core + bridge) is the authoritative behavior source for polyglot consumers. +Language wrappers MUST delegate behavior to the Go bridge unless explicitly running an approved native mode. + +### III. Backward Compatibility +Existing Go test APIs that accept `*testing.T` MUST remain stable and compatible. +New reusable APIs SHOULD be additive and return errors rather than aborting. + +### IV. Deterministic Fixtures and Snapshots +Fixture creation and snapshot restoration MUST be deterministic and bounded to temporary roots. +Path traversal and unsafe archive extraction MUST be rejected. + +### V. Test-First and Smoke Proof +Every new cross-language integration step MUST include executable smoke proof: +fixture creation, remote push flow, and snapshot roundtrip at minimum. + +### VI. Simplicity Before Native Reimplementation +Polyglot adoption SHOULD start with thin wrappers over the bridge. +Native ports (Option B) are allowed only after conformance contracts and parity tests exist. diff --git a/testkit/.specify/specs/001-polyglot-testkit/checklists/quality.md b/testkit/.specify/specs/001-polyglot-testkit/checklists/quality.md new file mode 100644 index 0000000..908c95f --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-testkit/checklists/quality.md @@ -0,0 +1,8 @@ +# Quality Checklist: 001-polyglot-testkit + +- [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] Existing Go API compatibility preserved (`testing.T` helper surfaces unchanged). diff --git a/testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json b/testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json new file mode 100644 index 0000000..f436063 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json @@ -0,0 +1,59 @@ +{ + "name": "git-testkit-cli protocol", + "version": "1.0.0", + "transport": "stdin JSON request -> stdout JSON response", + "request_schema": { + "type": "object", + "required": ["op"], + "properties": { + "op": {"type": "string"}, + "baseDir": {"type": "string"}, + "repoPath": {"type": "string"}, + "snapshotPath": {"type": "string"}, + "args": {"type": "array", "items": {"type": "string"}}, + "options": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "dirty": {"type": "boolean"}, + "files": {"type": "object"}, + "remotes": {"type": "object"}, + "branches": {"type": "array", "items": {"type": "string"}}, + "initialCommit": {"type": "string"} + } + } + } + }, + "response_schema": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": {"type": "boolean"}, + "error": {"type": "string"}, + "repoPath": {"type": "string"}, + "remotePath": {"type": "string"}, + "fsRoot": {"type": "string"}, + "output": {"type": "string"}, + "dirty": {"type": "boolean"}, + "remotes": {"type": "object"}, + "sha": {"type": "string"}, + "branches": {"type": "array", "items": {"type": "string"}}, + "snapshotName": {"type": "string"}, + "snapshotSize": {"type": "number"}, + "restorePath": {"type": "string"} + } + }, + "supported_ops": [ + "create_test_repo", + "create_bare_remote", + "setup_fake_filesystem", + "run_git_cmd", + "is_dirty", + "get_remotes", + "get_current_sha", + "get_branches", + "snapshot_repo", + "snapshot_save", + "snapshot_load_restore" + ] +} diff --git a/testkit/.specify/specs/001-polyglot-testkit/plan.md b/testkit/.specify/specs/001-polyglot-testkit/plan.md new file mode 100644 index 0000000..9ef48e7 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-testkit/plan.md @@ -0,0 +1,43 @@ +# Implementation Plan: Polyglot testkit via Go bridge (Option A) + +**Feature**: `001-polyglot-testkit` +**Input**: `testkit/.specify/specs/001-polyglot-testkit/spec.md` +**Status**: Implemented (baseline) + +## Summary + +Deliver a reusable polyglot interface to `git-testkit` by exposing Go core behavior through a JSON CLI bridge and thin wrappers in Python and Java. Keep Option B (native ports) as a future phase. + +## Technical decisions + +1. Preserve existing Go `testing.T` APIs for backward compatibility. +2. Add error-returning Go helpers for non-test callers. +3. Expose fixtures/query/snapshot operations through `cmd/git-testkit-cli`. +4. Wrap CLI in Python (`GitTestKitClient`) and Java (`CliBridge`) with minimal logic. +5. Validate using smoke tests that execute real `git`. + +## Artifact map + +- Go core updates: + - `fixtures.go` + - `snapshots.go` +- Go bridge: + - `cmd/git-testkit-cli/main.go` +- Python wrapper: + - `testkit/python/git_testkit/cli.py` + - `testkit/python/tests/*` +- Java wrapper: + - `testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java` + - `testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java` + +## Risks and mitigations + +- **Risk**: Wrapper drift from Go behavior. + - **Mitigation**: Keep wrappers thin; assert behavior through smoke tests using real repos. +- **Risk**: Snapshot edge cases (unsafe paths, parent dirs). + - **Mitigation**: enforce safe joins and explicit snapshot save directory creation. + +## Phase 2 placeholder (Option B) + +- Add native Python/Java implementations behind same wrapper surface. +- Add conformance matrix that runs in both bridge and native modes. diff --git a/testkit/.specify/specs/001-polyglot-testkit/spec.md b/testkit/.specify/specs/001-polyglot-testkit/spec.md new file mode 100644 index 0000000..08b1f03 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-testkit/spec.md @@ -0,0 +1,94 @@ +# Feature Specification: Polyglot git-testkit (Hybrid Option A first) + +**Feature Branch**: `001-polyglot-testkit` +**Created**: 2026-04-07 +**Status**: Draft +**Input**: User description: "reverse-spec existing Go git-testkit API and deliver polyglot reuse with high DevEx and adoption" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Reuse Go behavior from other languages (Priority: P1) + +As a test author in Python or Java, I can invoke git-testkit operations without reimplementing git semantics, so my tests share the same behavior guarantees as the Go source. + +**Why this priority**: Reuse and behavior consistency are the highest-value outcome for fast adoption. + +**Independent Test**: Create repos/remotes via wrapper and validate real git state without using Go test APIs directly. + +**Acceptance Scenarios**: + +1. **Given** a temp directory, **When** Python wrapper requests `create_test_repo`, **Then** a valid git repo path is returned and has a commit. +2. **Given** a local repo and bare remote, **When** wrapper runs remote-add/push flow, **Then** remote branch SHA matches local branch SHA. + +--- + +### User Story 2 - Keep Go API backward-compatible (Priority: P1) + +As an existing Go consumer, I continue using current `testing.T` helpers unchanged while the bridge adds non-`testing.T` reusable APIs. + +**Why this priority**: Backward compatibility is required to avoid adoption blockers/regressions. + +**Independent Test**: Run full existing Go test suite unchanged. + +**Acceptance Scenarios**: + +1. **Given** current Go tests, **When** code adds bridge support, **Then** all tests remain green. +2. **Given** existing exported Go fixture/scenario/snapshot APIs, **When** consumers compile, **Then** no API break is introduced. + +--- + +### User Story 3 - Prove bridge architecture with smoke conformance (Priority: P2) + +As a maintainer, I can run smoke conformance tests per language to verify fixture/snapshot contracts in a repeatable workflow. + +**Why this priority**: Confidence and maintainability require executable evidence. + +**Independent Test**: Run Python and Java smoke tests via their native test runners. + +**Acceptance Scenarios**: + +1. **Given** wrapper clients, **When** smoke tests run, **Then** create-repo, push-flow, and snapshot-roundtrip pass. +2. **Given** bridge JSON protocol, **When** operations fail, **Then** wrappers surface deterministic errors. + +--- + +### Edge Cases + +- Remote paths containing spaces or literal suffix-like text such as `" (push)"`. +- Snapshot save path parent directory missing. +- Git command with no stdout should still be treated as success. +- Default branch differs (`main` vs `master`). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST preserve existing Go test-facing APIs and semantics. +- **FR-002**: System MUST provide reusable Go error-returning fixture/snapshot helpers callable without `testing.T`. +- **FR-003**: System MUST expose core operations through a JSON CLI bridge process. +- **FR-004**: Python wrapper MUST invoke bridge operations and expose ergonomic methods for fixtures/queries/snapshots. +- **FR-005**: Java wrapper MUST invoke bridge operations and expose ergonomic methods for fixtures/queries/snapshots. +- **FR-006**: Wrapper smoke tests MUST validate create repo, push SHA equivalence, and snapshot restore roundtrip against real git. +- **FR-007**: Bridge responses MUST include structured success/error payloads and deterministic field names. + +### Key Entities + +- **RepoOptions**: input contract for repository construction (`name`, `dirty`, `files`, `remotes`, `branches`, `initialCommit`). +- **CLI Request**: JSON operation payload containing `op` and operation-specific fields. +- **CLI Response**: JSON operation result containing `ok`, optional `error`, and operation data fields. +- **Snapshot**: name + byte payload representation of archived repository filesystem state. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: `go test ./...` passes on the branch after bridge integration. +- **SC-002**: Python wrapper smoke suite passes via `python3 -m pytest tests/ -v`. +- **SC-003**: Java wrapper smoke suite passes via `mvn test`. +- **SC-004**: Branch includes roadmap and spec artifacts describing Option A now and Option B follow-up. + +## Assumptions + +- `git` is available on PATH in all test environments. +- Wrappers initially prioritize correctness/reuse over minimizing process-spawn overhead. +- Native Option B ports are intentionally deferred after Option A merge. diff --git a/testkit/.specify/specs/001-polyglot-testkit/tasks.md b/testkit/.specify/specs/001-polyglot-testkit/tasks.md new file mode 100644 index 0000000..bcd3610 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-testkit/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Polyglot git-testkit Option A bridge + +## Phase 1 - Core bridge plumbing + +- [x] T001 Add error-returning fixture APIs in `fixtures.go` +- [x] T002 Add error-returning snapshot APIs in `snapshots.go` +- [x] T003 Add CLI entrypoint `cmd/git-testkit-cli/main.go` with JSON protocol + +## Phase 2 - Wrapper clients + +- [x] T004 Add Python wrapper client in `testkit/python/git_testkit/cli.py` +- [x] T005 Add Java wrapper client in `testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java` + +## Phase 3 - Validation and smoke flows + +- [x] T006 Add Python smoke tests for fixture + snapshot + push flow +- [x] T007 Add Java smoke tests for fixture + snapshot + push flow +- [x] T008 Run Go regressions: `go vet ./...`, `go test ./...` +- [x] T009 Run Python smoke tests via `python3 -m pytest tests/ -v` +- [x] T010 Run Java smoke tests via `mvn test` + +## Phase 4 - Spec-kit alignment artifacts + +- [x] T011 Add `.specify/memory/constitution.md` +- [x] T012 Add spec-kit-style spec in `.specify/specs/001-polyglot-testkit/spec.md` +- [x] T013 Add spec-kit-style plan in `.specify/specs/001-polyglot-testkit/plan.md` +- [x] T014 Add this task ledger in `.specify/specs/001-polyglot-testkit/tasks.md` +- [ ] T015 Add reverse spec-kit command workflow doc + shell helper diff --git a/testkit/README.md b/testkit/README.md new file mode 100644 index 0000000..2a44885 --- /dev/null +++ b/testkit/README.md @@ -0,0 +1,27 @@ +## Reverse spec-kit integration + +This repository now includes a reverse-engineered spec-kit style workspace under `testkit/.specify`. + +### Structure + +- `testkit/.specify/memory/constitution.md` +- `testkit/.specify/specs/001-polyglot-testkit/spec.md` +- `testkit/.specify/specs/001-polyglot-testkit/plan.md` +- `testkit/.specify/specs/001-polyglot-testkit/tasks.md` +- `testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json` +- `testkit/.specify/specs/001-polyglot-testkit/checklists/quality.md` + +### Why this exists + +- Preserves one source-of-truth specification flow in spec-kit format. +- Keeps current implementation strategy hybrid: + - Option A now: Go core + JSON CLI + thin Python/Java wrappers + - Option B later: native Python/Java implementations validated against these artifacts + +### Conformance execution + +Use existing smoke tests as the executable conformance path: + +- Python: `cd testkit/python && python3 -m pytest tests/ -v` +- Java: `cd testkit/java && mvn test` +- Go regression: `cd /workspace && go test ./...` diff --git a/testkit/ROADMAP.md b/testkit/ROADMAP.md index e1ed56d..ed52b14 100644 --- a/testkit/ROADMAP.md +++ b/testkit/ROADMAP.md @@ -20,12 +20,19 @@ Deliverables: 3. Add CLI binary (`testkit/cli`) with JSON request/response protocol. 4. Add Python and Java thin wrappers that shell out to the CLI. 5. Add smoke tests proving fixture -> scenario-like flow -> snapshot round-trip. +6. Add reverse-spec-kit artifact set under `.specify/`: + - constitution (`.specify/memory/constitution.md`) + - feature spec (`.specify/specs/001-polyglot-testkit/spec.md`) + - implementation plan (`.specify/specs/001-polyglot-testkit/plan.md`) + - tasks (`.specify/specs/001-polyglot-testkit/tasks.md`) + - protocol contract and quality checklist Success criteria: - Existing Go tests stay green. - CLI handles core fixture and snapshot operations. - Python and Java smoke tests pass against real `git`. +- `.specify` artifacts remain executable and aligned with test commands in `tasks.md`. ## Phase 2 (Option B): Native ports diff --git a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java index 980f3c9..2ec7c6d 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java @@ -53,4 +53,17 @@ void snapshotRoundtripSmoke() { assertTrue(Files.exists(Path.of(restored.path()))); assertEquals(bridge.getCurrentSha(repo), bridge.getCurrentSha(restored.path())); } + + @Test + void specKitLayoutExists() { + Path workspaceRoot = Path.of("../..").toAbsolutePath().normalize(); + assertTrue(Files.exists(workspaceRoot.resolve("testkit/.specify/memory/constitution.md"))); + assertTrue(Files.exists(workspaceRoot.resolve("testkit/.specify/specs/001-polyglot-testkit/spec.md"))); + assertTrue(Files.exists(workspaceRoot.resolve("testkit/.specify/specs/001-polyglot-testkit/plan.md"))); + assertTrue(Files.exists(workspaceRoot.resolve("testkit/.specify/specs/001-polyglot-testkit/tasks.md"))); + assertTrue( + Files.exists( + workspaceRoot.resolve( + "testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json"))); + } } diff --git a/testkit/python/tests/test_specify_conformance.py b/testkit/python/tests/test_specify_conformance.py new file mode 100644 index 0000000..0f95ac4 --- /dev/null +++ b/testkit/python/tests/test_specify_conformance.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from git_testkit import GitTestKitClient + + +def test_specify_contract_snapshot_smoke(tmp_path: Path) -> None: + client = GitTestKitClient() + repo = client.create_test_repo(tmp_path, name="specify-contract") + snapshot_path = tmp_path / "snapshots" / "specify-contract.tar.gz" + snapshot_name, snapshot_size = client.save_snapshot(repo, snapshot_path) + restored = client.load_restore_snapshot(snapshot_path, tmp_path) + + assert snapshot_name == "specify-contract" + assert snapshot_size > 0 + assert Path(restored).exists() + assert client.get_current_sha(restored) == client.get_current_sha(repo) From 223fc2bc14aa5f2645f68a08f8d895e58554ae71 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 22:39:51 +0000 Subject: [PATCH 04/27] Normalize to canonical spec-kit enforcement and CI gates Co-authored-by: Ben Schellenberger --- .github/workflows/ci.yml | 46 +++++++++++++++++++ testkit/.specify/scripts/validate_specify.sh | 30 ++++++++++++ .../specs/001-polyglot-testkit/plan.md | 11 ++++- .../specs/001-polyglot-testkit/spec.md | 2 +- .../specs/001-polyglot-testkit/tasks.md | 3 +- testkit/README.md | 13 +++++- 6 files changed, 100 insertions(+), 5 deletions(-) create mode 100755 testkit/.specify/scripts/validate_specify.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b08eec..9dc70a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,52 @@ on: pull_request: jobs: + spec-kit-conformance: + runs-on: ubuntu-latest + steps: + - name: Checkout + 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-testkit/spec.md + test -f testkit/.specify/specs/001-polyglot-testkit/plan.md + test -f testkit/.specify/specs/001-polyglot-testkit/tasks.md + test -f testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json + test -f testkit/.specify/specs/001-polyglot-testkit/checklists/quality.md + + - name: Validate spec-kit scaffold and status + run: ./testkit/.specify/scripts/validate_specify.sh + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Run Python spec conformance smoke tests + run: | + cd testkit/python + python -m pip install -e ".[dev]" + python -m pytest tests/ -v + + - name: Run Java spec conformance smoke tests + run: | + cd testkit/java + mvn test + test: runs-on: ubuntu-latest strategy: diff --git a/testkit/.specify/scripts/validate_specify.sh b/testkit/.specify/scripts/validate_specify.sh new file mode 100755 index 0000000..6c6bc01 --- /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-testkit" + +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-testkit/plan.md b/testkit/.specify/specs/001-polyglot-testkit/plan.md index 9ef48e7..902d247 100644 --- a/testkit/.specify/specs/001-polyglot-testkit/plan.md +++ b/testkit/.specify/specs/001-polyglot-testkit/plan.md @@ -2,7 +2,7 @@ **Feature**: `001-polyglot-testkit` **Input**: `testkit/.specify/specs/001-polyglot-testkit/spec.md` -**Status**: Implemented (baseline) +**Status**: Implemented (canonical spec-kit baseline) ## Summary @@ -37,6 +37,15 @@ Deliver a reusable polyglot interface to `git-testkit` by exposing Go core behav - **Risk**: Snapshot edge cases (unsafe paths, parent dirs). - **Mitigation**: enforce safe joins and explicit snapshot save directory creation. +## CI/CD wiring (spec-kit) + +The root CI workflow enforces spec-kit + implementation alignment: + +1. Existing Go matrix checks (`go vet`, `go test ./...`). +2. Spec-kit artifact validation via `testkit/.specify/scripts/validate_specify.sh`. +3. Python bridge conformance (`python3 -m pytest tests/ -v` in `testkit/python`). +4. Java bridge conformance (`mvn test` in `testkit/java`). + ## Phase 2 placeholder (Option B) - Add native Python/Java implementations behind same wrapper surface. diff --git a/testkit/.specify/specs/001-polyglot-testkit/spec.md b/testkit/.specify/specs/001-polyglot-testkit/spec.md index 08b1f03..e70df55 100644 --- a/testkit/.specify/specs/001-polyglot-testkit/spec.md +++ b/testkit/.specify/specs/001-polyglot-testkit/spec.md @@ -2,7 +2,7 @@ **Feature Branch**: `001-polyglot-testkit` **Created**: 2026-04-07 -**Status**: Draft +**Status**: Implemented (canonical spec-kit baseline) **Input**: User description: "reverse-spec existing Go git-testkit API and deliver polyglot reuse with high DevEx and adoption" ## User Scenarios & Testing *(mandatory)* diff --git a/testkit/.specify/specs/001-polyglot-testkit/tasks.md b/testkit/.specify/specs/001-polyglot-testkit/tasks.md index bcd3610..bd3a01d 100644 --- a/testkit/.specify/specs/001-polyglot-testkit/tasks.md +++ b/testkit/.specify/specs/001-polyglot-testkit/tasks.md @@ -25,4 +25,5 @@ - [x] T012 Add spec-kit-style spec in `.specify/specs/001-polyglot-testkit/spec.md` - [x] T013 Add spec-kit-style plan in `.specify/specs/001-polyglot-testkit/plan.md` - [x] T014 Add this task ledger in `.specify/specs/001-polyglot-testkit/tasks.md` -- [ ] T015 Add reverse spec-kit command workflow doc + shell helper +- [x] T015 Add spec-kit command workflow doc + shell helper +- [x] T016 Wire CI workflow to enforce spec-kit artifacts + polyglot smoke suites diff --git a/testkit/README.md b/testkit/README.md index 2a44885..e39fa85 100644 --- a/testkit/README.md +++ b/testkit/README.md @@ -1,6 +1,6 @@ -## Reverse spec-kit integration +## Spec-kit integration -This repository now includes a reverse-engineered spec-kit style workspace under `testkit/.specify`. +This repository uses a spec-kit style workspace under `testkit/.specify`. ### Structure @@ -25,3 +25,12 @@ Use existing smoke tests as the executable conformance path: - Python: `cd testkit/python && python3 -m pytest tests/ -v` - Java: `cd testkit/java && mvn test` - Go regression: `cd /workspace && go test ./...` + +### CI/CD wiring + +`/.github/workflows/ci.yml` now enforces spec-kit alignment and bridge conformance on pull requests: + +1. spec-kit artifact validation via `testkit/.specify/scripts/validate_specify.sh` +2. Go vet + tests +3. Python wrapper conformance tests +4. Java wrapper conformance tests From 199142a06da3a0985fc3fe7c2f2cbc9c845a7bc8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 22:48:13 +0000 Subject: [PATCH 05/27] Fix CLI stream deadlock and stdout-only git queries Co-authored-by: Ben Schellenberger --- fixtures.go | 13 +++++- fixtures_test.go | 46 +++++++++++++++++++ snapshots.go | 14 ------ .../java/io/gitfire/testkit/CliBridge.java | 22 ++++++++- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/fixtures.go b/fixtures.go index f3c5bd6..fdb76e8 100644 --- a/fixtures.go +++ b/fixtures.go @@ -218,9 +218,18 @@ func RunGitCmdE(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = dir - output, err := cmd.CombinedOutput() + output, err := cmd.Output() if err != nil { - return "", fmt.Errorf("git command failed: git %v\nOutput: %s\nError: %w", args, output, err) + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf( + "git command failed: git %v\nStdout: %s\nStderr: %s\nError: %w", + args, + strings.TrimSpace(string(output)), + strings.TrimSpace(string(exitErr.Stderr)), + err, + ) + } + return "", fmt.Errorf("git command failed: git %v\nError: %w", args, err) } return strings.TrimSpace(string(output)), nil } diff --git a/fixtures_test.go b/fixtures_test.go index 7157e58..658f4b4 100644 --- a/fixtures_test.go +++ b/fixtures_test.go @@ -2,7 +2,9 @@ package testutil_test import ( "os" + "os/exec" "path/filepath" + "strings" "testing" testutil "github.com/git-fire/git-testkit" @@ -151,3 +153,47 @@ func TestSetupFakeFilesystem(t *testing.T) { } } } + +func TestQueryHelpersIgnoreGitTraceStderr(t *testing.T) { + t.Setenv("GIT_TRACE", "1") + + remotePath := testutil.CreateBareRemote(t, "origin") + repoPath := testutil.CreateTestRepo(t, testutil.RepoOptions{ + Name: "trace-safe-repo", + Remotes: map[string]string{ + "origin": remotePath, + }, + }) + + dirty, err := testutil.IsDirtyE(repoPath) + if err != nil { + t.Fatalf("IsDirtyE failed: %v", err) + } + if dirty { + t.Fatal("expected clean repo to remain clean when git writes trace to stderr") + } + + sha, err := testutil.GetCurrentSHAE(repoPath) + if err != nil { + t.Fatalf("GetCurrentSHAE failed: %v", err) + } + + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = repoPath + expectedSHABytes, err := cmd.Output() + if err != nil { + t.Fatalf("failed to get expected sha: %v", err) + } + expectedSHA := strings.TrimSpace(string(expectedSHABytes)) + if sha != expectedSHA { + t.Fatalf("expected sha %q, got %q", expectedSHA, sha) + } + + remotes, err := testutil.GetRemotesE(repoPath) + if err != nil { + t.Fatalf("GetRemotesE failed: %v", err) + } + if got := remotes["origin"]; got != remotePath { + t.Fatalf("expected origin remote %q, got %q", remotePath, got) + } +} diff --git a/snapshots.go b/snapshots.go index 4e15e77..2cb11e6 100644 --- a/snapshots.go +++ b/snapshots.go @@ -26,13 +26,6 @@ type Snapshot struct { tarball []byte // Compressed repository state in memory } -// NewSnapshot creates a snapshot instance from raw data. -func NewSnapshot(name string, payload []byte) *Snapshot { - copied := make([]byte, len(payload)) - copy(copied, payload) - return &Snapshot{name: name, tarball: copied} -} - // SnapshotRepo creates an in-memory snapshot of a repository // This allows fast restoration of expensive test setups func SnapshotRepo(t *testing.T, repoPath string) *Snapshot { @@ -215,13 +208,6 @@ func (s *Snapshot) Name() string { return s.name } -// Payload returns a copy of snapshot bytes. -func (s *Snapshot) Payload() []byte { - copied := make([]byte, len(s.tarball)) - copy(copied, s.tarball) - return copied -} - // SaveSnapshotToDisk saves a snapshot to a file (for debugging or caching) func SaveSnapshotToDisk(t *testing.T, snapshot *Snapshot, filepath string) { t.Helper() diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index bdb38d8..e39cb16 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -7,6 +7,10 @@ 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.regex.Matcher; import java.util.regex.Pattern; @@ -202,21 +206,35 @@ private String invoke(String payload) { } private CliResult runCli(String payload) { + ExecutorService streamReaderPool = null; try { ProcessBuilder pb = new ProcessBuilder("bash", "-lc", cliCommand); pb.directory(workspaceRoot.toFile()); Process process = pb.start(); + streamReaderPool = Executors.newFixedThreadPool(2); + Future stdoutFuture = + streamReaderPool.submit( + () -> new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + Future stderrFuture = + streamReaderPool.submit( + () -> new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8)); process.getOutputStream().write(payload.getBytes(StandardCharsets.UTF_8)); process.getOutputStream().close(); int code = process.waitFor(); - String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + String stdout = stdoutFuture.get(); + String stderr = stderrFuture.get(); return new CliResult(stdout, stderr, 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 (streamReaderPool != null) { + streamReaderPool.shutdownNow(); + } } } From c2cf9b06c05ec13c460360899ebf12433a32fd99 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 22:51:07 +0000 Subject: [PATCH 06/27] Address PR review findings and harden bridge behavior Co-authored-by: Ben Schellenberger --- cmd/git-testkit-cli/main.go | 6 +- fixtures.go | 62 +++++++++++++----- fixtures_test.go | 65 +++++++++++++++++++ snapshots.go | 5 +- testkit/GIT_TESTKIT_SPEC.md | 2 +- testkit/README.md | 4 +- testkit/ROADMAP.md | 4 +- .../java/io/gitfire/testkit/CliBridge.java | 13 ++-- testkit/python/git_testkit/cli.py | 15 ++++- .../python/tests/test_specify_conformance.py | 2 +- 10 files changed, 142 insertions(+), 36 deletions(-) diff --git a/cmd/git-testkit-cli/main.go b/cmd/git-testkit-cli/main.go index 2c198dd..bc8fe92 100644 --- a/cmd/git-testkit-cli/main.go +++ b/cmd/git-testkit-cli/main.go @@ -63,12 +63,8 @@ func main() { } func parseRequest() (request, error) { - raw, err := os.ReadFile("/dev/stdin") - if err != nil { - return request{}, fmt.Errorf("failed reading stdin: %w", err) - } var req request - if err := json.Unmarshal(raw, &req); err != nil { + if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil { return request{}, fmt.Errorf("invalid JSON request: %w", err) } if strings.TrimSpace(req.Op) == "" { diff --git a/fixtures.go b/fixtures.go index fdb76e8..d23338b 100644 --- a/fixtures.go +++ b/fixtures.go @@ -1,6 +1,7 @@ package testutil import ( + "bytes" "fmt" "os" "os/exec" @@ -44,7 +45,11 @@ func CreateTestRepo(t *testing.T, opts RepoOptions) string { // CreateTestRepoInDir creates a test repository under the provided base directory. func CreateTestRepoInDir(baseDir string, opts RepoOptions) (string, error) { - repoPath := filepath.Join(baseDir, opts.Name) + repoName, err := validateSimpleName(opts.Name) + if err != nil { + return "", fmt.Errorf("invalid repo name %q: %w", opts.Name, err) + } + repoPath := filepath.Join(baseDir, repoName) if err := os.MkdirAll(repoPath, 0755); err != nil { return "", fmt.Errorf("failed to create repo directory: %w", err) @@ -74,6 +79,10 @@ func CreateTestRepoInDir(baseDir string, opts RepoOptions) (string, error) { if _, err := RunGitCmdE(repoPath, "commit", "-m", commitMsg); err != nil { return "", err } + originalBranch, err := RunGitCmdE(repoPath, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", err + } for filename, content := range opts.Files { filePath := filepath.Join(repoPath, filename) @@ -98,16 +107,17 @@ func CreateTestRepoInDir(baseDir string, opts RepoOptions) (string, error) { } for _, branch := range opts.Branches { + if branch == originalBranch { + continue + } if _, err := RunGitCmdE(repoPath, "checkout", "-b", branch); err != nil { return "", err } } if len(opts.Branches) > 0 { - if _, err := RunGitCmdE(repoPath, "checkout", "main"); err != nil { - if _, fallbackErr := RunGitCmdE(repoPath, "checkout", "master"); fallbackErr != nil { - return "", fallbackErr - } + if _, err := RunGitCmdE(repoPath, "checkout", originalBranch); err != nil { + return "", err } } @@ -134,7 +144,11 @@ func CreateBareRemote(t *testing.T, name string) string { // CreateBareRemoteInDir creates a bare remote repository under the provided base directory. func CreateBareRemoteInDir(baseDir, name string) (string, error) { - remotePath := filepath.Join(baseDir, name+".git") + remoteName, err := validateSimpleName(name) + if err != nil { + return "", fmt.Errorf("invalid remote name %q: %w", name, err) + } + remotePath := filepath.Join(baseDir, remoteName+".git") if err := os.MkdirAll(remotePath, 0755); err != nil { return "", fmt.Errorf("failed to create bare repo directory: %w", err) } @@ -217,23 +231,39 @@ func RunGitCmd(t *testing.T, dir string, args ...string) { func RunGitCmdE(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr output, err := cmd.Output() if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf( - "git command failed: git %v\nStdout: %s\nStderr: %s\nError: %w", - args, - strings.TrimSpace(string(output)), - strings.TrimSpace(string(exitErr.Stderr)), - err, - ) - } - return "", fmt.Errorf("git command failed: git %v\nError: %w", args, err) + return "", fmt.Errorf( + "git command failed: git %v\nStdout: %s\nStderr: %s\nError: %w", + args, + strings.TrimSpace(string(output)), + strings.TrimSpace(stderr.String()), + err, + ) } return strings.TrimSpace(string(output)), nil } +func validateSimpleName(name string) (string, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "", fmt.Errorf("name cannot be empty") + } + if filepath.IsAbs(trimmed) { + return "", fmt.Errorf("absolute paths are not allowed") + } + if trimmed == "." || trimmed == ".." { + return "", fmt.Errorf("relative traversal segments are not allowed") + } + if strings.ContainsAny(trimmed, `/\`) { + return "", fmt.Errorf("path separators are not allowed") + } + return trimmed, nil +} + // GetCurrentSHA returns the current commit SHA func GetCurrentSHA(t *testing.T, repoPath string) string { t.Helper() diff --git a/fixtures_test.go b/fixtures_test.go index 658f4b4..f971098 100644 --- a/fixtures_test.go +++ b/fixtures_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -197,3 +198,67 @@ func TestQueryHelpersIgnoreGitTraceStderr(t *testing.T) { t.Fatalf("expected origin remote %q, got %q", remotePath, got) } } + +func TestCreateTestRepoInDir_InvalidName(t *testing.T) { + tmp := t.TempDir() + + if _, err := testutil.CreateTestRepoInDir(tmp, testutil.RepoOptions{Name: ""}); err == nil { + t.Fatal("expected error for empty repo name") + } + if _, err := testutil.CreateTestRepoInDir(tmp, testutil.RepoOptions{Name: "../escape"}); err == nil { + t.Fatal("expected error for traversal repo name") + } + if _, err := testutil.CreateTestRepoInDir(tmp, testutil.RepoOptions{Name: "nested/repo"}); err == nil { + t.Fatal("expected error for nested repo name") + } + if _, err := testutil.CreateTestRepoInDir(tmp, testutil.RepoOptions{Name: `nested\repo`}); err == nil { + t.Fatal("expected error for separator in repo name") + } + + absoluteName := "/tmp/abs-repo" + if runtime.GOOS == "windows" { + absoluteName = `C:\abs-repo` + } + if _, err := testutil.CreateTestRepoInDir(tmp, testutil.RepoOptions{Name: absoluteName}); err == nil { + t.Fatal("expected error for absolute repo name") + } +} + +func TestCreateBareRemoteInDir_InvalidName(t *testing.T) { + tmp := t.TempDir() + + if _, err := testutil.CreateBareRemoteInDir(tmp, ""); err == nil { + t.Fatal("expected error for empty remote name") + } + if _, err := testutil.CreateBareRemoteInDir(tmp, "../escape"); err == nil { + t.Fatal("expected error for traversal remote name") + } + if _, err := testutil.CreateBareRemoteInDir(tmp, "nested/remote"); err == nil { + t.Fatal("expected error for nested remote name") + } + if _, err := testutil.CreateBareRemoteInDir(tmp, `nested\remote`); err == nil { + t.Fatal("expected error for separator in remote name") + } +} + +func TestCreateTestRepoInDir_RestoresOriginalBranch(t *testing.T) { + tmp := t.TempDir() + repoPath, err := testutil.CreateTestRepoInDir(tmp, testutil.RepoOptions{ + Name: "branch-restore", + Branches: []string{"feature-a", "feature-b"}, + }) + if err != nil { + t.Fatalf("CreateTestRepoInDir failed: %v", err) + } + + currentBranch, err := testutil.RunGitCmdE(repoPath, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + t.Fatalf("failed to read current branch: %v", err) + } + if currentBranch == "feature-a" || currentBranch == "feature-b" { + t.Fatalf("expected repo to restore original branch, got branch %q", currentBranch) + } + if _, err := testutil.RunGitCmdE(repoPath, "show-ref", "--verify", "--quiet", "refs/heads/"+currentBranch); err != nil { + t.Fatalf("expected current branch %q to exist: %v", currentBranch, err) + } +} diff --git a/snapshots.go b/snapshots.go index 2cb11e6..df76401 100644 --- a/snapshots.go +++ b/snapshots.go @@ -76,11 +76,14 @@ func SnapshotRepoE(repoPath string) (*Snapshot, error) { if err != nil { return fmt.Errorf("failed to open file %s: %w", path, err) } - defer file.Close() if _, err := io.Copy(tarWriter, file); err != nil { + file.Close() return fmt.Errorf("failed to write file %s to tar: %w", path, err) } + if err := file.Close(); err != nil { + return fmt.Errorf("failed to close file %s: %w", path, err) + } } return nil diff --git a/testkit/GIT_TESTKIT_SPEC.md b/testkit/GIT_TESTKIT_SPEC.md index 488d01d..75a2526 100644 --- a/testkit/GIT_TESTKIT_SPEC.md +++ b/testkit/GIT_TESTKIT_SPEC.md @@ -49,7 +49,7 @@ Behavior: - stages and commits with message `Add `. 4. Adds configured remotes. 5. Creates each branch in `branches` via checkout-new branch. -6. If branches were created, returns checkout to `main` if present, otherwise `master`. +6. If branches were created, returns checkout to the original branch that was active right after initial repository creation. 7. If `dirty` is true, writes an uncommitted file (unstaged). Postconditions: diff --git a/testkit/README.md b/testkit/README.md index e39fa85..f6868de 100644 --- a/testkit/README.md +++ b/testkit/README.md @@ -1,6 +1,6 @@ ## Spec-kit integration -This repository uses a spec-kit style workspace under `testkit/.specify`. +This repository uses a spec-kit-style workspace under `testkit/.specify`. ### Structure @@ -24,7 +24,7 @@ Use existing smoke tests as the executable conformance path: - Python: `cd testkit/python && python3 -m pytest tests/ -v` - Java: `cd testkit/java && mvn test` -- Go regression: `cd /workspace && go test ./...` +- Go regression: from repository root, run `go test ./...` ### CI/CD wiring diff --git a/testkit/ROADMAP.md b/testkit/ROADMAP.md index ed52b14..4d85961 100644 --- a/testkit/ROADMAP.md +++ b/testkit/ROADMAP.md @@ -17,10 +17,10 @@ Deliverables: 1. Keep existing Go `testing.T` APIs for backward compatibility. 2. Add reusable error-returning Go APIs that do not depend on `testing.T`. -3. Add CLI binary (`testkit/cli`) with JSON request/response protocol. +3. Add CLI binary (`cmd/git-testkit-cli`) with JSON request/response protocol. 4. Add Python and Java thin wrappers that shell out to the CLI. 5. Add smoke tests proving fixture -> scenario-like flow -> snapshot round-trip. -6. Add reverse-spec-kit artifact set under `.specify/`: +6. Add spec-kit artifact set under `.specify/`: - constitution (`.specify/memory/constitution.md`) - feature spec (`.specify/specs/001-polyglot-testkit/spec.md`) - implementation plan (`.specify/specs/001-polyglot-testkit/plan.md`) diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index e39cb16..a87cc81 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -190,17 +190,18 @@ public RestoredSnapshot snapshotLoadRestore(Path baseDir, String snapshotPath) { private String invoke(String payload) { CliResult result = runCli(payload); - if (result.code != 0) { - String stderr = result.stderr == null ? "" : result.stderr; + 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 (result.stdout == null || result.stdout.isBlank()) { + if (stdout.isBlank()) { throw new RuntimeException("CLI returned empty response"); } - String stdout = result.stdout.trim(); - if (!stdout.contains("\"ok\":true")) { + if (result.code != 0 || !stdout.contains("\"ok\":true")) { String error = extractOptional(stdout, ERROR_PATTERN); - throw new RuntimeException(error.isEmpty() ? "CLI returned failure response" : error); + throw new RuntimeException( + error.isEmpty() ? "CLI failed with code " + result.code + ": " + stderr : error); } return stdout; } diff --git a/testkit/python/git_testkit/cli.py b/testkit/python/git_testkit/cli.py index 102f689..39ef7c3 100644 --- a/testkit/python/git_testkit/cli.py +++ b/testkit/python/git_testkit/cli.py @@ -25,10 +25,21 @@ def _call(op: str, **payload: Any) -> dict[str, Any]: capture_output=True, check=False, ) + stdout = (proc.stdout or "").strip() + stderr = (proc.stderr or "").strip() if proc.returncode != 0: - raise RuntimeError(f"git-testkit-cli exited {proc.returncode}: {proc.stderr.strip()}") + 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-testkit-cli exited {proc.returncode}: {stderr}; stdout: {stdout}" + ) - response = json.loads(proc.stdout) + response = json.loads(stdout) if not response.get("ok", False): raise RuntimeError(response.get("error", "unknown git-testkit-cli error")) return response diff --git a/testkit/python/tests/test_specify_conformance.py b/testkit/python/tests/test_specify_conformance.py index 0f95ac4..874e71a 100644 --- a/testkit/python/tests/test_specify_conformance.py +++ b/testkit/python/tests/test_specify_conformance.py @@ -10,7 +10,7 @@ def test_specify_contract_snapshot_smoke(tmp_path: Path) -> None: snapshot_name, snapshot_size = client.save_snapshot(repo, snapshot_path) restored = client.load_restore_snapshot(snapshot_path, tmp_path) - assert snapshot_name == "specify-contract" + assert snapshot_name == Path(repo).name assert snapshot_size > 0 assert Path(restored).exists() assert client.get_current_sha(restored) == client.get_current_sha(repo) From 8c1a543b7f7f5ce344ca35ca36c306335348f54a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 23:17:43 +0000 Subject: [PATCH 07/27] Fix CLI bridge JSON parsing and branch helper consistency Co-authored-by: Ben Schellenberger --- fixtures.go | 7 +- fixtures_test.go | 21 +++ .../java/io/gitfire/testkit/CliBridge.java | 175 ++++++++++++++---- .../io/gitfire/testkit/CliBridgeTest.java | 48 +++++ 4 files changed, 213 insertions(+), 38 deletions(-) diff --git a/fixtures.go b/fixtures.go index d23338b..87a88ae 100644 --- a/fixtures.go +++ b/fixtures.go @@ -348,15 +348,12 @@ func GetCurrentSHAE(repoPath string) (string, error) { // GetBranchesE returns all branches in a repo. func GetBranchesE(repoPath string) ([]string, error) { - cmd := exec.Command("git", "branch", "--format=%(refname:short)") - cmd.Dir = repoPath - - output, err := cmd.Output() + output, err := RunGitCmdE(repoPath, "branch", "--format=%(refname:short)") if err != nil { return nil, err } - branches := strings.Split(strings.TrimSpace(string(output)), "\n") + branches := strings.Split(strings.TrimSpace(output), "\n") // Filter out empty lines var result []string diff --git a/fixtures_test.go b/fixtures_test.go index f971098..9e75701 100644 --- a/fixtures_test.go +++ b/fixtures_test.go @@ -197,6 +197,27 @@ func TestQueryHelpersIgnoreGitTraceStderr(t *testing.T) { if got := remotes["origin"]; got != remotePath { t.Fatalf("expected origin remote %q, got %q", remotePath, got) } + + branches, err := testutil.GetBranchesE(repoPath) + if err != nil { + t.Fatalf("GetBranchesE failed: %v", err) + } + if len(branches) == 0 { + t.Fatal("expected at least one branch") + } +} + +func TestGetBranchesE_UsesRunGitCmdEErrorFormatting(t *testing.T) { + _, err := testutil.GetBranchesE(filepath.Join(t.TempDir(), "missing-repo")) + if err == nil { + t.Fatal("expected GetBranchesE to fail for missing repo") + } + if !strings.Contains(err.Error(), "git command failed: git [branch --format=%(refname:short)]") { + t.Fatalf("expected wrapped git command context, got: %v", err) + } + if !strings.Contains(err.Error(), "Stderr:") { + t.Fatalf("expected stderr details in error, got: %v", err) + } } func TestCreateTestRepoInDir_InvalidName(t *testing.T) { diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index a87cc81..4cd8136 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -15,17 +15,26 @@ import java.util.regex.Pattern; public final class CliBridge { - private static final Pattern ERROR_PATTERN = Pattern.compile("\"error\"\\s*:\\s*\"([^\"]*)\""); - private static final Pattern REPO_PATH_PATTERN = Pattern.compile("\"repoPath\"\\s*:\\s*\"([^\"]+)\""); - private static final Pattern REMOTE_PATH_PATTERN = Pattern.compile("\"remotePath\"\\s*:\\s*\"([^\"]+)\""); - private static final Pattern OUTPUT_PATTERN = Pattern.compile("\"output\"\\s*:\\s*\"([^\"]*)\""); - private static final Pattern SHA_PATTERN = Pattern.compile("\"sha\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern ERROR_PATTERN = + Pattern.compile("\"error\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); + private static final Pattern REPO_PATH_PATTERN = + Pattern.compile("\"repoPath\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); + private static final Pattern REMOTE_PATH_PATTERN = + Pattern.compile("\"remotePath\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); + private static final Pattern OUTPUT_PATTERN = + Pattern.compile("\"output\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); + private static final Pattern SHA_PATTERN = + Pattern.compile("\"sha\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); private static final Pattern DIRTY_PATTERN = Pattern.compile("\"dirty\"\\s*:\\s*(true|false)"); - private static final Pattern BRANCHES_PATTERN = Pattern.compile("\"branches\"\\s*:\\s*\\[(.*?)]"); - private static final Pattern REMOTES_PATTERN = Pattern.compile("\"remotes\"\\s*:\\s*\\{(.*?)}"); - private static final Pattern SNAPSHOT_NAME_PATTERN = Pattern.compile("\"snapshotName\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern JSON_STRING_ITEM_PATTERN = + Pattern.compile("\\s*\"((?:\\\\.|[^\\\\\"])*)\"\\s*(?:,|$)"); + private static final Pattern JSON_STRING_PAIR_PATTERN = + Pattern.compile("\\s*\"((?:\\\\.|[^\\\\\"])*)\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\"\\s*(?:,|$)"); + private static final Pattern SNAPSHOT_NAME_PATTERN = + Pattern.compile("\"snapshotName\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); private static final Pattern SNAPSHOT_SIZE_PATTERN = Pattern.compile("\"snapshotSize\"\\s*:\\s*([0-9]+)"); - private static final Pattern RESTORE_PATH_PATTERN = Pattern.compile("\"restorePath\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern RESTORE_PATH_PATTERN = + Pattern.compile("\"restorePath\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); private static final class CliResult { private final String stdout; @@ -107,24 +116,20 @@ public boolean isDirty(String repoPath) { public Map getRemotes(String repoPath) { String payload = "{\"op\":\"get_remotes\",\"repoPath\":\"" + escape(repoPath) + "\"}"; String json = invoke(payload); - Matcher matcher = REMOTES_PATTERN.matcher(json); Map remotes = new LinkedHashMap<>(); - if (!matcher.find()) { - return remotes; - } - String body = matcher.group(1).trim(); + String body = extractContainerBody(json, "remotes", '{', '}').trim(); if (body.isEmpty()) { return remotes; } - String[] pairs = body.split(","); - for (String pair : pairs) { - String[] kv = pair.split(":", 2); - if (kv.length != 2) { - continue; + int index = 0; + while (index < body.length()) { + Matcher pairMatcher = JSON_STRING_PAIR_PATTERN.matcher(body); + pairMatcher.region(index, body.length()); + if (!pairMatcher.lookingAt()) { + throw new RuntimeException("invalid remotes payload: " + json); } - String key = unquote(kv[0].trim()); - String value = unquote(kv[1].trim()); - remotes.put(key, value); + remotes.put(unquote(pairMatcher.group(1)), unquote(pairMatcher.group(2))); + index = pairMatcher.end(); } return remotes; } @@ -137,18 +142,20 @@ public String getCurrentSha(String repoPath) { public List getBranches(String repoPath) { String payload = "{\"op\":\"get_branches\",\"repoPath\":\"" + escape(repoPath) + "\"}"; String json = invoke(payload); - Matcher matcher = BRANCHES_PATTERN.matcher(json); List branches = new ArrayList<>(); - if (!matcher.find()) { - return branches; - } - String body = matcher.group(1).trim(); + String body = extractContainerBody(json, "branches", '[', ']').trim(); if (body.isEmpty()) { return branches; } - String[] items = body.split(","); - for (String item : items) { - branches.add(unquote(item.trim())); + int index = 0; + while (index < body.length()) { + Matcher itemMatcher = JSON_STRING_ITEM_PATTERN.matcher(body); + itemMatcher.region(index, body.length()); + if (!itemMatcher.lookingAt()) { + throw new RuntimeException("invalid branches payload: " + json); + } + branches.add(unquote(itemMatcher.group(1))); + index = itemMatcher.end(); } return branches; } @@ -244,12 +251,60 @@ private static String extractRequired(String json, Pattern pattern, String field if (!matcher.find()) { throw new RuntimeException("missing field " + fieldName + " in response: " + json); } - return matcher.group(1); + return unquote(matcher.group(1)); } private static String extractOptional(String json, Pattern pattern) { Matcher matcher = pattern.matcher(json); - return matcher.find() ? matcher.group(1) : ""; + return matcher.find() ? unquote(matcher.group(1)) : ""; + } + + private static String extractContainerBody(String json, String fieldName, char open, char close) { + String fieldToken = "\"" + fieldName + "\""; + int fieldStart = json.indexOf(fieldToken); + if (fieldStart < 0) { + return ""; + } + int colon = json.indexOf(':', fieldStart + fieldToken.length()); + if (colon < 0) { + throw new RuntimeException("invalid JSON response for field " + fieldName + ": " + json); + } + + int valueStart = colon + 1; + while (valueStart < json.length() && Character.isWhitespace(json.charAt(valueStart))) { + valueStart++; + } + if (valueStart >= json.length() || json.charAt(valueStart) != open) { + throw new RuntimeException("field " + fieldName + " has unexpected JSON type: " + json); + } + + boolean inString = false; + boolean escaping = false; + int depth = 1; + for (int i = valueStart + 1; i < json.length(); i++) { + char ch = json.charAt(i); + if (inString) { + if (escaping) { + escaping = false; + } else if (ch == '\\') { + escaping = true; + } else if (ch == '"') { + inString = false; + } + continue; + } + if (ch == '"') { + inString = true; + } else if (ch == open) { + depth++; + } else if (ch == close) { + depth--; + if (depth == 0) { + return json.substring(valueStart + 1, i); + } + } + } + throw new RuntimeException("unterminated field " + fieldName + " in response: " + json); } private static String unquote(String value) { @@ -257,7 +312,61 @@ private static String unquote(String value) { if (out.startsWith("\"") && out.endsWith("\"") && out.length() >= 2) { out = out.substring(1, out.length() - 1); } - return out.replace("\\\"", "\"").replace("\\\\", "\\"); + + StringBuilder sb = new StringBuilder(out.length()); + for (int i = 0; i < out.length(); i++) { + char ch = out.charAt(i); + if (ch != '\\') { + sb.append(ch); + continue; + } + if (i + 1 >= out.length()) { + sb.append('\\'); + break; + } + char next = out.charAt(++i); + switch (next) { + case '"': + sb.append('"'); + break; + case '\\': + sb.append('\\'); + break; + case '/': + sb.append('/'); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'u': + if (i + 4 >= out.length()) { + throw new RuntimeException("invalid unicode escape in JSON string: " + value); + } + String hex = out.substring(i + 1, i + 5); + try { + sb.append((char) Integer.parseInt(hex, 16)); + } catch (NumberFormatException ex) { + throw new RuntimeException("invalid unicode escape in JSON string: " + value, ex); + } + i += 4; + break; + default: + sb.append(next); + } + } + return sb.toString(); } private static String escape(String value) { diff --git a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java index 2ec7c6d..ee701c0 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java @@ -7,12 +7,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; class CliBridgeTest { @TempDir Path tmp; + private CliBridge bridgeWithJsonResponse(String json) { + String cliCommand = "cat >/dev/null; printf '%s\\n' '" + json + "'"; + return new CliBridge(Path.of("../..").toAbsolutePath().normalize(), cliCommand); + } + @Test void createTestRepoProducesCleanRepoAndBranches() { CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); @@ -66,4 +72,46 @@ void specKitLayoutExists() { workspaceRoot.resolve( "testkit/.specify/specs/001-polyglot-testkit/contracts/cli-protocol.json"))); } + + @Test + void runGitCmdParsesQuotedOutput() { + CliBridge bridge = + bridgeWithJsonResponse("{\"ok\":true,\"output\":\"hello \\\"world\\\" from git\"}"); + + String output = bridge.runGitCmd("/tmp/repo", "log", "-1"); + + assertEquals("hello \"world\" from git", output); + } + + @Test + void createTestRepoUnescapesBackslashes() { + CliBridge bridge = bridgeWithJsonResponse("{\"ok\":true,\"repoPath\":\"C:\\\\Users\\\\test\"}"); + + String repoPath = bridge.createTestRepo(tmp, "subject"); + + assertEquals("C:\\Users\\test", repoPath); + } + + @Test + void getRemotesParsesCommasAndBracesInsideValues() { + CliBridge bridge = + bridgeWithJsonResponse( + "{\"ok\":true,\"remotes\":{\"origin\":\"/tmp/repo,name}suffix\",\"upstream\":\"ssh://example.com/r,2\"}}"); + + var remotes = bridge.getRemotes("/tmp/repo"); + + assertEquals("/tmp/repo,name}suffix", remotes.get("origin")); + assertEquals("ssh://example.com/r,2", remotes.get("upstream")); + } + + @Test + void getBranchesParsesBracketAndQuotesInsideValues() { + CliBridge bridge = + bridgeWithJsonResponse( + "{\"ok\":true,\"branches\":[\"main\",\"feature]x\",\"quoted \\\"branch\\\"\"]}"); + + var branches = bridge.getBranches("/tmp/repo"); + + assertEquals(List.of("main", "feature]x", "quoted \"branch\""), branches); + } } From b70b94c2ed100b3486359a4d94af03d06521aea2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 23:36:54 +0000 Subject: [PATCH 08/27] Fix JSON escaping and always emit snapshotSize Co-authored-by: Ben Schellenberger --- cmd/git-testkit-cli/main.go | 2 +- .../java/io/gitfire/testkit/CliBridge.java | 35 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/cmd/git-testkit-cli/main.go b/cmd/git-testkit-cli/main.go index bc8fe92..6bc8032 100644 --- a/cmd/git-testkit-cli/main.go +++ b/cmd/git-testkit-cli/main.go @@ -43,7 +43,7 @@ type response struct { SHA string `json:"sha,omitempty"` Branches []string `json:"branches,omitempty"` SnapshotName string `json:"snapshotName,omitempty"` - SnapshotSize int `json:"snapshotSize,omitempty"` + SnapshotSize int `json:"snapshotSize"` RestorePath string `json:"restorePath,omitempty"` } diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index 4cd8136..a92ebbd 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -370,6 +370,39 @@ private static String unquote(String value) { } private static String escape(String value) { - return value.replace("\\", "\\\\").replace("\"", "\\\""); + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (ch < 0x20) { + sb.append(String.format("\\u%04x", (int) ch)); + } else { + sb.append(ch); + } + } + } + return sb.toString(); } } From 0dd9a6c7d5d1281f0f2b62c7d9e28aa3f7fb1a02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 01:23:34 +0000 Subject: [PATCH 09/27] Resolve remaining PR feedback on CLI JSON responses Co-authored-by: Ben Schellenberger --- cmd/git-testkit-cli/main.go | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/cmd/git-testkit-cli/main.go b/cmd/git-testkit-cli/main.go index 6bc8032..9a5b573 100644 --- a/cmd/git-testkit-cli/main.go +++ b/cmd/git-testkit-cli/main.go @@ -43,7 +43,7 @@ type response struct { SHA string `json:"sha,omitempty"` Branches []string `json:"branches,omitempty"` SnapshotName string `json:"snapshotName,omitempty"` - SnapshotSize int `json:"snapshotSize"` + SnapshotSize *int `json:"snapshotSize,omitempty"` RestorePath string `json:"restorePath,omitempty"` } @@ -179,7 +179,11 @@ func handle(req request) (response, error) { if err != nil { return response{}, err } - return response{OK: true, SnapshotName: snapshot.Name(), SnapshotSize: snapshot.Size()}, nil + return response{ + OK: true, + SnapshotName: snapshot.Name(), + SnapshotSize: intPtr(snapshot.Size()), + }, nil case "snapshot_save": if req.RepoPath == "" || req.SnapshotPath == "" { @@ -192,7 +196,11 @@ func handle(req request) (response, error) { if err := testutil.SaveSnapshotToDiskE(snapshot, req.SnapshotPath); err != nil { return response{}, err } - return response{OK: true, SnapshotName: snapshot.Name(), SnapshotSize: snapshot.Size()}, nil + return response{ + OK: true, + SnapshotName: snapshot.Name(), + SnapshotSize: intPtr(snapshot.Size()), + }, nil case "snapshot_load_restore": if req.SnapshotPath == "" { @@ -214,7 +222,7 @@ func handle(req request) (response, error) { OK: true, RestorePath: restorePath, SnapshotName: snapshot.Name(), - SnapshotSize: snapshot.Size(), + SnapshotSize: intPtr(snapshot.Size()), }, nil default: @@ -237,6 +245,18 @@ func writeResponse(res response) { enc := json.NewEncoder(os.Stdout) enc.SetEscapeHTML(false) if err := enc.Encode(res); err != nil { - fmt.Fprintf(os.Stderr, `{"ok":false,"error":"failed writing response: %s"}`+"\n", err.Error()) + 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) + } } } + +func intPtr(v int) *int { + return &v +} From 59b16b483901d78c24217977eab7164339d2467f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 01:52:04 +0000 Subject: [PATCH 10/27] Add runnable Python/Java smoke samples and CI validation Co-authored-by: Ben Schellenberger --- .github/workflows/ci.yml | 12 +++++ cmd/git-testkit-cli/main.go | 8 ++- testkit/java/README.md | 19 +++++++ .../java/io/gitfire/testkit/CliBridge.java | 4 +- .../gitfire/testkit/SampleRepoFlowSmoke.java | 49 +++++++++++++++++++ .../testkit/SampleSnapshotFlowSmoke.java | 33 +++++++++++++ testkit/python/README.md | 12 +++++ testkit/python/samples/README.md | 11 +++++ testkit/python/samples/smoke_repo_flow.py | 29 +++++++++++ testkit/python/samples/smoke_snapshot_flow.py | 30 ++++++++++++ 10 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 testkit/java/README.md create mode 100644 testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java create mode 100644 testkit/java/src/test/java/io/gitfire/testkit/SampleSnapshotFlowSmoke.java create mode 100644 testkit/python/samples/README.md create mode 100644 testkit/python/samples/smoke_repo_flow.py create mode 100644 testkit/python/samples/smoke_snapshot_flow.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dc70a0..5fb8b4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: uses: actions/setup-go@v5 with: go-version: "stable" + - name: Build git-testkit CLI binary once + run: go build ./cmd/git-testkit-cli - name: Setup Python uses: actions/setup-python@v5 @@ -47,11 +49,21 @@ jobs: cd testkit/python python -m pip install -e ".[dev]" python -m pytest tests/ -v + - name: Run Python sample smoke implementations + run: | + cd testkit/python + python -m samples.smoke_repo_flow + python -m samples.smoke_snapshot_flow - name: Run Java spec conformance smoke tests run: | cd testkit/java mvn test + - name: Run Java sample smoke implementations + run: | + cd testkit/java + mvn -Dtest=SampleRepoFlowSmoke test + mvn -Dtest=SampleSnapshotFlowSmoke test test: runs-on: ubuntu-latest diff --git a/cmd/git-testkit-cli/main.go b/cmd/git-testkit-cli/main.go index 9a5b573..aab8631 100644 --- a/cmd/git-testkit-cli/main.go +++ b/cmd/git-testkit-cli/main.go @@ -37,7 +37,7 @@ type response struct { RepoPath string `json:"repoPath,omitempty"` RemotePath string `json:"remotePath,omitempty"` FSRoot string `json:"fsRoot,omitempty"` - Output string `json:"output,omitempty"` + Output *string `json:"output,omitempty"` Dirty *bool `json:"dirty,omitempty"` Remotes map[string]string `json:"remotes,omitempty"` SHA string `json:"sha,omitempty"` @@ -129,7 +129,7 @@ func handle(req request) (response, error) { if err != nil { return response{}, err } - return response{OK: true, Output: output}, nil + return response{OK: true, Output: stringPtr(output)}, nil case "is_dirty": if req.RepoPath == "" { @@ -260,3 +260,7 @@ func writeResponse(res response) { func intPtr(v int) *int { return &v } + +func stringPtr(v string) *string { + return &v +} diff --git a/testkit/java/README.md b/testkit/java/README.md new file mode 100644 index 0000000..d363636 --- /dev/null +++ b/testkit/java/README.md @@ -0,0 +1,19 @@ +## testkit/java + +Thin Java wrapper over `git-testkit-cli` (Option A bridge). + +### Sample smoke implementations + +Two executable sample smoke implementations verify the wrapper API: + +- `SampleRepoFlowSmoke` validates repository + remote + push flow. +- `SampleSnapshotFlowSmoke` validates snapshot save/load/restore flow. + +Run them from `testkit/java`: + +- `mvn -Dtest=SampleRepoFlowSample test` +- `mvn -Dtest=SampleSnapshotFlowSmoke test` + +Or run all Java wrapper tests: + +- `mvn test` diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index a92ebbd..4466ee9 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -17,6 +17,7 @@ public final class CliBridge { private static final Pattern ERROR_PATTERN = Pattern.compile("\"error\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); + private static final Pattern OK_PATTERN = Pattern.compile("\"ok\"\\s*:\\s*(true|false)"); private static final Pattern REPO_PATH_PATTERN = Pattern.compile("\"repoPath\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); private static final Pattern REMOTE_PATH_PATTERN = @@ -205,7 +206,8 @@ private String invoke(String payload) { if (stdout.isBlank()) { throw new RuntimeException("CLI returned empty response"); } - if (result.code != 0 || !stdout.contains("\"ok\":true")) { + String ok = extractOptional(stdout, OK_PATTERN); + if (result.code != 0 || !"true".equals(ok)) { String error = extractOptional(stdout, ERROR_PATTERN); throw new RuntimeException( error.isEmpty() ? "CLI failed with code " + result.code + ": " + stderr : error); diff --git a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java new file mode 100644 index 0000000..1c50e12 --- /dev/null +++ b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java @@ -0,0 +1,49 @@ +package io.gitfire.testkit; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Runnable sample that exercises repo/remote push flow. */ +public class SampleRepoFlowSmoke { + @org.junit.jupiter.api.Test + void sampleRepoFlowRuns() throws Exception { + Path workspaceRoot = Path.of(".").toAbsolutePath().normalize().resolve("..").resolve(".."); + CliBridge bridge = new CliBridge(workspaceRoot); + Path tmp = Files.createTempDirectory("git-testkit-java-sample-repo"); + + try { + String remote = bridge.createBareRemote(tmp, "origin"); + String local = bridge.createTestRepo(tmp, "local"); + + bridge.runGitCmd(local, "remote", "add", "origin", remote); + bridge.runGitCmd(local, "checkout", "-b", "feature"); + Files.writeString(Path.of(local, "README.md"), "sample update\n", java.nio.file.StandardOpenOption.APPEND); + bridge.runGitCmd(local, "add", "README.md"); + bridge.runGitCmd(local, "commit", "-m", "sample update"); + bridge.runGitCmd(local, "push", "origin", "feature"); + + String localSha = bridge.getCurrentSha(local); + String remoteSha = bridge.runGitCmd(remote, "rev-parse", "feature").trim(); + if (!localSha.equals(remoteSha)) { + throw new IllegalStateException("SHA mismatch between local and remote feature branch"); + } + } finally { + deleteRecursively(tmp); + } + } + + private static void deleteRecursively(Path root) throws Exception { + if (!Files.exists(root)) { + return; + } + try (var stream = Files.walk(root)) { + stream.sorted((a, b) -> b.compareTo(a)).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + } + } +} diff --git a/testkit/java/src/test/java/io/gitfire/testkit/SampleSnapshotFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/testkit/SampleSnapshotFlowSmoke.java new file mode 100644 index 0000000..e6f1137 --- /dev/null +++ b/testkit/java/src/test/java/io/gitfire/testkit/SampleSnapshotFlowSmoke.java @@ -0,0 +1,33 @@ +package io.gitfire.testkit; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class SampleSnapshotFlowSmoke { + @TempDir Path tmp; + + @Test + void sampleSnapshotRoundtrip() { + CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); + String repo = bridge.createTestRepo(tmp, "sample-snapshot"); + Path snapshotPath = tmp.resolve("snapshots").resolve("sample-snapshot.tar.gz"); + CliBridge.SnapshotInfo info = bridge.snapshotSave(repo, snapshotPath.toString()); + CliBridge.RestoredSnapshot restored = bridge.snapshotLoadRestore(tmp, snapshotPath.toString()); + + if (info.size() <= 0) { + throw new IllegalStateException("expected snapshot size > 0"); + } + if (!Files.exists(Path.of(restored.path()))) { + throw new IllegalStateException("expected restored repo path to exist"); + } + + String originalSha = bridge.getCurrentSha(repo); + String restoredSha = bridge.getCurrentSha(restored.path()); + if (!originalSha.equals(restoredSha)) { + throw new IllegalStateException( + "snapshot roundtrip SHA mismatch: " + originalSha + " vs " + restoredSha); + } + } +} diff --git a/testkit/python/README.md b/testkit/python/README.md index 4d2f2d3..4ae75a3 100644 --- a/testkit/python/README.md +++ b/testkit/python/README.md @@ -7,3 +7,15 @@ The wrapper prioritizes: - a clean Pythonic API surface, - zero drift from Go behavior by delegating execution to the Go core, - easy migration to optional native implementation in a later phase. + +## Runnable smoke samples + +Two sample implementations exercise and verify the wrapper end-to-end: + +- `samples/smoke_repo_flow.py` validates repo creation, remote wiring, push, and SHA parity. +- `samples/smoke_snapshot_flow.py` validates snapshot save/load/restore and SHA parity. + +Run from repository root: + +- `python3 testkit/python/samples/smoke_repo_flow.py` +- `python3 testkit/python/samples/smoke_snapshot_flow.py` diff --git a/testkit/python/samples/README.md b/testkit/python/samples/README.md new file mode 100644 index 0000000..2e3f935 --- /dev/null +++ b/testkit/python/samples/README.md @@ -0,0 +1,11 @@ +## Python sample smoke implementations + +These scripts are runnable examples that exercise the bridge API end-to-end and +act as smoke verification flows. + +Run from repo root: + +- `python3 testkit/python/samples/smoke_repo_flow.py` +- `python3 testkit/python/samples/smoke_snapshot_flow.py` + +They exit non-zero on failure. diff --git a/testkit/python/samples/smoke_repo_flow.py b/testkit/python/samples/smoke_repo_flow.py new file mode 100644 index 0000000..4182424 --- /dev/null +++ b/testkit/python/samples/smoke_repo_flow.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile + +from git_testkit import GitTestKitClient + + +def main() -> int: + client = GitTestKitClient() + with tempfile.TemporaryDirectory(prefix="git-testkit-py-repo-") as tmp: + base = Path(tmp) + remote = client.create_bare_remote(base, "origin") + repo = client.create_test_repo(base, name="local-sample") + + client.run_git_cmd(repo, "remote", "add", "origin", remote) + client.run_git_cmd(repo, "checkout", "-b", "feature/sample") + client.run_git_cmd(repo, "push", "-u", "origin", "feature/sample") + + local_sha = client.get_current_sha(repo) + remote_sha = client.run_git_cmd(remote, "rev-parse", "feature/sample").strip() + if local_sha != remote_sha: + raise RuntimeError(f"sha mismatch local={local_sha} remote={remote_sha}") + print("python sample repo flow: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testkit/python/samples/smoke_snapshot_flow.py b/testkit/python/samples/smoke_snapshot_flow.py new file mode 100644 index 0000000..eab280f --- /dev/null +++ b/testkit/python/samples/smoke_snapshot_flow.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +from git_testkit import GitTestKitClient + + +def main() -> None: + client = GitTestKitClient() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + repo = client.create_test_repo(root, name="smoke-snapshot") + + snapshot_path = root / "snapshots" / "smoke-snapshot.tar.gz" + snapshot_name, snapshot_size = client.save_snapshot(repo, snapshot_path) + restored_path = client.load_restore_snapshot(snapshot_path, root) + + assert snapshot_name == Path(repo).name, "unexpected snapshot name" + assert snapshot_size > 0, "expected non-empty snapshot" + assert Path(restored_path).exists(), "restored path must exist" + assert client.get_current_sha(restored_path) == client.get_current_sha(repo), ( + "snapshot restore must preserve HEAD SHA" + ) + + print("python sample smoke_snapshot_flow: OK") + + +if __name__ == "__main__": + main() From 65dcb8924f2eefe2838adc443922a05232fcc27c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 02:34:43 +0000 Subject: [PATCH 11/27] Resolve PR feedback: path safety and portability Co-authored-by: Ben Schellenberger --- .github/workflows/ci.yml | 3 +- fixtures.go | 27 ++++++++++- fixtures_test.go | 24 ++++++++++ snapshots.go | 6 ++- snapshots_test.go | 45 +++++++++++++++++++ .../java/io/gitfire/testkit/CliBridge.java | 15 ++++++- .../io/gitfire/testkit/CliBridgeTest.java | 8 +++- testkit/python/git_testkit/cli.py | 2 - 8 files changed, 121 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fb8b4c..63b7099 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,8 +62,7 @@ jobs: - name: Run Java sample smoke implementations run: | cd testkit/java - mvn -Dtest=SampleRepoFlowSmoke test - mvn -Dtest=SampleSnapshotFlowSmoke test + mvn -Dtest=SampleRepoFlowSmoke,SampleSnapshotFlowSmoke test test: runs-on: ubuntu-latest diff --git a/fixtures.go b/fixtures.go index 87a88ae..aafcfb9 100644 --- a/fixtures.go +++ b/fixtures.go @@ -85,14 +85,18 @@ func CreateTestRepoInDir(baseDir string, opts RepoOptions) (string, error) { } for filename, content := range opts.Files { - filePath := filepath.Join(repoPath, filename) + relPath, err := validateFixturePath(filename) + if err != nil { + return "", fmt.Errorf("invalid file path %q: %w", filename, err) + } + filePath := filepath.Join(repoPath, relPath) if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { return "", fmt.Errorf("failed to create directory for %s: %w", filename, err) } if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { return "", fmt.Errorf("failed to create file %s: %w", filename, err) } - if _, err := RunGitCmdE(repoPath, "add", filename); err != nil { + if _, err := RunGitCmdE(repoPath, "add", "--", filepath.ToSlash(relPath)); err != nil { return "", err } if _, err := RunGitCmdE(repoPath, "commit", "-m", "Add "+filename); err != nil { @@ -264,6 +268,25 @@ func validateSimpleName(name string) (string, error) { return trimmed, nil } +func validateFixturePath(name string) (string, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "", fmt.Errorf("path cannot be empty") + } + clean := filepath.Clean(trimmed) + if filepath.IsAbs(clean) { + return "", fmt.Errorf("absolute paths are not allowed") + } + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path traversal is not allowed") + } + parts := strings.Split(clean, string(filepath.Separator)) + if len(parts) > 0 && strings.EqualFold(parts[0], ".git") { + return "", fmt.Errorf(".git paths are not allowed") + } + return clean, nil +} + // GetCurrentSHA returns the current commit SHA func GetCurrentSHA(t *testing.T, repoPath string) string { t.Helper() diff --git a/fixtures_test.go b/fixtures_test.go index 9e75701..3a95633 100644 --- a/fixtures_test.go +++ b/fixtures_test.go @@ -283,3 +283,27 @@ func TestCreateTestRepoInDir_RestoresOriginalBranch(t *testing.T) { t.Fatalf("expected current branch %q to exist: %v", currentBranch, err) } } + +func TestCreateTestRepoInDir_RejectsUnsafeFixturePaths(t *testing.T) { + tmp := t.TempDir() + + _, err := testutil.CreateTestRepoInDir(tmp, testutil.RepoOptions{ + Name: "unsafe-files", + Files: map[string]string{ + "../escape.txt": "nope", + }, + }) + if err == nil { + t.Fatal("expected traversal fixture path to be rejected") + } + + _, err = testutil.CreateTestRepoInDir(tmp, testutil.RepoOptions{ + Name: "unsafe-git-files", + Files: map[string]string{ + ".git/config": "nope", + }, + }) + if err == nil { + t.Fatal("expected .git fixture path to be rejected") + } +} diff --git a/snapshots.go b/snapshots.go index df76401..01e4416 100644 --- a/snapshots.go +++ b/snapshots.go @@ -170,7 +170,11 @@ func RestoreSnapshotToDir(snapshot *Snapshot, baseDir string) (string, error) { return "", fmt.Errorf("failed closing file %s: %w", targetPath, err) } default: - continue + return "", fmt.Errorf( + "unsupported snapshot entry type %d for %q", + header.Typeflag, + header.Name, + ) } } diff --git a/snapshots_test.go b/snapshots_test.go index 49574d1..5f8018d 100644 --- a/snapshots_test.go +++ b/snapshots_test.go @@ -1,8 +1,12 @@ package testutil import ( + "archive/tar" + "bytes" + "compress/gzip" "os" "path/filepath" + "strings" "testing" ) @@ -116,3 +120,44 @@ func TestLoadSnapshotFromDiskUsesBaseName(t *testing.T) { t.Fatalf("expected restore dir base %q, got %q", want, got) } } + +func TestRestoreSnapshotRejectsUnsupportedEntryTypes(t *testing.T) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + // Add directory root entry for restore target. + if err := tw.WriteHeader(&tar.Header{ + Name: "repo", + Typeflag: tar.TypeDir, + Mode: 0755, + }); err != nil { + t.Fatalf("failed to write dir header: %v", err) + } + + // Add symlink entry that restore does not support. + if err := tw.WriteHeader(&tar.Header{ + Name: "repo/link", + Typeflag: tar.TypeSymlink, + Linkname: "target", + Mode: 0777, + }); err != nil { + t.Fatalf("failed to write symlink header: %v", err) + } + + if err := tw.Close(); err != nil { + t.Fatalf("failed to close tar writer: %v", err) + } + if err := gw.Close(); err != nil { + t.Fatalf("failed to close gzip writer: %v", err) + } + + snapshot := &Snapshot{name: "repo", tarball: buf.Bytes()} + _, err := RestoreSnapshotToDir(snapshot, t.TempDir()) + if err == nil { + t.Fatal("expected RestoreSnapshotToDir to fail on unsupported tar entry") + } + if got := err.Error(); !strings.Contains(got, "unsupported snapshot entry type") { + t.Fatalf("expected unsupported entry error, got: %v", err) + } +} diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index 4466ee9..f3d21fc 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -61,7 +61,7 @@ public CliBridge(Path workspaceRoot) { } public CliBridge() { - this(Path.of(System.getProperty("user.dir"))); + this(detectWorkspaceRoot()); } public CliBridge(Path workspaceRoot, String cliCommand) { @@ -69,6 +69,19 @@ public CliBridge(Path workspaceRoot, String cliCommand) { this.cliCommand = cliCommand; } + private static Path detectWorkspaceRoot() { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + Path probe = current; + while (probe != null) { + if (java.nio.file.Files.exists(probe.resolve("go.mod")) + && java.nio.file.Files.exists(probe.resolve("cmd/git-testkit-cli/main.go"))) { + return probe; + } + probe = probe.getParent(); + } + return current; + } + public String createTestRepo(Path baseDir, String name) { String payload = "{\"op\":\"create_test_repo\",\"baseDir\":\"" diff --git a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java index ee701c0..38b6a96 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java @@ -4,9 +4,11 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.Base64; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -15,7 +17,11 @@ class CliBridgeTest { @TempDir Path tmp; private CliBridge bridgeWithJsonResponse(String json) { - String cliCommand = "cat >/dev/null; printf '%s\\n' '" + json + "'"; + String encoded = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + String cliCommand = + "python3 -c \"import base64;print(base64.b64decode('" + + encoded + + "').decode('utf-8'))\""; return new CliBridge(Path.of("../..").toAbsolutePath().normalize(), cliCommand); } diff --git a/testkit/python/git_testkit/cli.py b/testkit/python/git_testkit/cli.py index 39ef7c3..5be528d 100644 --- a/testkit/python/git_testkit/cli.py +++ b/testkit/python/git_testkit/cli.py @@ -129,5 +129,3 @@ def snapshot_save(self, repo_path: str, snapshot_path: Path | str) -> dict[str, def snapshot_load_restore(self, snapshot_path: Path | str, base_dir: Path | str) -> str: return self.load_restore_snapshot(snapshot_path, base_dir) - -GitTestKitCLI = GitTestKitClient From ca494d5982afbba28063a6654214cf073217277e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 02:51:30 +0000 Subject: [PATCH 12/27] Make Java parser tests use in-memory CLI stub Co-authored-by: Ben Schellenberger --- .../java/io/gitfire/testkit/CliBridge.java | 20 +++++++++++++++++-- .../io/gitfire/testkit/CliBridgeTest.java | 11 +++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index f3d21fc..6b70b0b 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -37,12 +37,12 @@ public final class CliBridge { private static final Pattern RESTORE_PATH_PATTERN = Pattern.compile("\"restorePath\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); - private static final class CliResult { + static final class CliResult { private final String stdout; private final String stderr; private final int code; - private CliResult(String stdout, String stderr, int code) { + CliResult(String stdout, String stderr, int code) { this.stdout = stdout; this.stderr = stderr; this.code = code; @@ -55,6 +55,7 @@ public record RestoredSnapshot(String path, String name, int size) {} private final String cliCommand; private final Path workspaceRoot; + private final java.util.function.Function cliInvoker; public CliBridge(Path workspaceRoot) { this(workspaceRoot, "go run ./cmd/git-testkit-cli"); @@ -65,8 +66,20 @@ public CliBridge() { } public CliBridge(Path workspaceRoot, String cliCommand) { + this(workspaceRoot, cliCommand, null); + } + + CliBridge(Path workspaceRoot, java.util.function.Function cliInvoker) { + this(workspaceRoot, "", cliInvoker); + } + + CliBridge( + Path workspaceRoot, + String cliCommand, + java.util.function.Function cliInvoker) { this.workspaceRoot = workspaceRoot; this.cliCommand = cliCommand; + this.cliInvoker = cliInvoker; } private static Path detectWorkspaceRoot() { @@ -229,6 +242,9 @@ private String invoke(String payload) { } private CliResult runCli(String payload) { + if (cliInvoker != null) { + return cliInvoker.apply(payload); + } ExecutorService streamReaderPool = null; try { ProcessBuilder pb = new ProcessBuilder("bash", "-lc", cliCommand); diff --git a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java index 38b6a96..18e29eb 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java @@ -4,11 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.Base64; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -17,12 +15,9 @@ class CliBridgeTest { @TempDir Path tmp; private CliBridge bridgeWithJsonResponse(String json) { - String encoded = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); - String cliCommand = - "python3 -c \"import base64;print(base64.b64decode('" - + encoded - + "').decode('utf-8'))\""; - return new CliBridge(Path.of("../..").toAbsolutePath().normalize(), cliCommand); + return new CliBridge( + Path.of("../..").toAbsolutePath().normalize(), + payload -> new CliBridge.CliResult(json, "", 0)); } @Test From bb70df404351c3416a12fc9edea5bceccfb8d449 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 03:08:33 +0000 Subject: [PATCH 13/27] Normalize Java sample workspace root path Co-authored-by: Ben Schellenberger --- .../src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java index 1c50e12..abdb9a9 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java @@ -7,7 +7,7 @@ public class SampleRepoFlowSmoke { @org.junit.jupiter.api.Test void sampleRepoFlowRuns() throws Exception { - Path workspaceRoot = Path.of(".").toAbsolutePath().normalize().resolve("..").resolve(".."); + Path workspaceRoot = Path.of("../..").toAbsolutePath().normalize(); CliBridge bridge = new CliBridge(workspaceRoot); Path tmp = Files.createTempDirectory("git-testkit-java-sample-repo"); From 97632251e834335a5a95b42fcf1d96ef94c08174 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 03:43:30 +0000 Subject: [PATCH 14/27] Enhance Java wrapper API with typed repo options Co-authored-by: Ben Schellenberger --- testkit/java/README.md | 18 ++- .../java/io/gitfire/testkit/CliBridge.java | 134 +++++++++++++++++- .../io/gitfire/testkit/CliBridgeTest.java | 33 +++++ 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/testkit/java/README.md b/testkit/java/README.md index d363636..15b159a 100644 --- a/testkit/java/README.md +++ b/testkit/java/README.md @@ -2,6 +2,22 @@ Thin Java wrapper over `git-testkit-cli` (Option A bridge). +### API ergonomics + +`CliBridge` now supports typed repo creation options, similar to Python: + +- `CliBridge.RepoOptions.builder("repo-name")` + - `.dirty(true)` + - `.putFile("src/main.go", "package main\n")` + - `.putRemote("origin", "/tmp/origin.git")` + - `.addBranch("feature/demo")` + - `.initialCommit("Initial commit")` + +Use with: + +- `bridge.createTestRepo(baseDir, options)` +- `bridge.setupFakeFilesystem(baseDir)` + ### Sample smoke implementations Two executable sample smoke implementations verify the wrapper API: @@ -11,7 +27,7 @@ Two executable sample smoke implementations verify the wrapper API: Run them from `testkit/java`: -- `mvn -Dtest=SampleRepoFlowSample test` +- `mvn -Dtest=SampleRepoFlowSmoke test` - `mvn -Dtest=SampleSnapshotFlowSmoke test` Or run all Java wrapper tests: diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index 6b70b0b..9d82d7d 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -22,6 +22,8 @@ public final class CliBridge { Pattern.compile("\"repoPath\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); private static final Pattern REMOTE_PATH_PATTERN = Pattern.compile("\"remotePath\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); + private static final Pattern FS_ROOT_PATTERN = + Pattern.compile("\"fsRoot\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); private static final Pattern OUTPUT_PATTERN = Pattern.compile("\"output\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); private static final Pattern SHA_PATTERN = @@ -53,6 +55,96 @@ public record SnapshotInfo(String name, int size) {} public record RestoredSnapshot(String path, String name, int size) {} + public static final class RepoOptions { + private final String name; + private boolean dirty; + private String initialCommit = ""; + private final Map files = new LinkedHashMap<>(); + private final Map remotes = new LinkedHashMap<>(); + private final List branches = new ArrayList<>(); + + public RepoOptions(String name) { + this.name = name; + } + + public static RepoOptions builder(String name) { + return new RepoOptions(name); + } + + public RepoOptions dirty(boolean value) { + this.dirty = value; + return this; + } + + public RepoOptions initialCommit(String value) { + this.initialCommit = value == null ? "" : value; + return this; + } + + public RepoOptions file(String path, String content) { + files.put(path, content); + return this; + } + + public RepoOptions files(Map entries) { + if (entries != null) { + files.putAll(entries); + } + return this; + } + + public RepoOptions remote(String remoteName, String remoteUrl) { + remotes.put(remoteName, remoteUrl); + return this; + } + + public RepoOptions remotes(Map entries) { + if (entries != null) { + remotes.putAll(entries); + } + return this; + } + + public RepoOptions branch(String branchName) { + branches.add(branchName); + return this; + } + + public RepoOptions branches(List entries) { + if (entries != null) { + branches.addAll(entries); + } + return this; + } + + public RepoOptions build() { + return this; + } + + private String toJson() { + StringBuilder sb = new StringBuilder(); + sb.append('{'); + sb.append("\"name\":\"").append(escape(name)).append('"'); + if (dirty) { + sb.append(",\"dirty\":true"); + } + if (!initialCommit.isEmpty()) { + sb.append(",\"initialCommit\":\"").append(escape(initialCommit)).append('"'); + } + if (!files.isEmpty()) { + sb.append(",\"files\":").append(stringMapToJson(files)); + } + if (!remotes.isEmpty()) { + sb.append(",\"remotes\":").append(stringMapToJson(remotes)); + } + if (!branches.isEmpty()) { + sb.append(",\"branches\":").append(stringListToJson(branches)); + } + sb.append('}'); + return sb.toString(); + } + } + private final String cliCommand; private final Path workspaceRoot; private final java.util.function.Function cliInvoker; @@ -96,12 +188,16 @@ private static Path detectWorkspaceRoot() { } public String createTestRepo(Path baseDir, String name) { + return createTestRepo(baseDir, new RepoOptions(name)); + } + + public String createTestRepo(Path baseDir, RepoOptions options) { String payload = "{\"op\":\"create_test_repo\",\"baseDir\":\"" + escape(baseDir.toString()) - + "\",\"options\":{\"name\":\"" - + escape(name) - + "\"}}"; + + "\",\"options\":" + + options.toJson() + + "}"; return extractRequired(invoke(payload), REPO_PATH_PATTERN, "repoPath"); } @@ -115,6 +211,11 @@ public String createBareRemote(Path baseDir, String name) { return extractRequired(invoke(payload), REMOTE_PATH_PATTERN, "remotePath"); } + public String setupFakeFilesystem(Path baseDir) { + String payload = "{\"op\":\"setup_fake_filesystem\",\"baseDir\":\"" + escape(baseDir.toString()) + "\"}"; + return extractRequired(invoke(payload), FS_ROOT_PATTERN, "fsRoot"); + } + public String runGitCmd(String repoPath, String... args) { StringBuilder payload = new StringBuilder( @@ -436,4 +537,31 @@ private static String escape(String value) { } return sb.toString(); } + + private static String stringMapToJson(Map values) { + StringBuilder sb = new StringBuilder(); + sb.append('{'); + int i = 0; + for (Map.Entry entry : values.entrySet()) { + if (i++ > 0) { + sb.append(','); + } + sb.append('"').append(escape(entry.getKey())).append("\":\"").append(escape(entry.getValue())).append('"'); + } + sb.append('}'); + return sb.toString(); + } + + private static String stringListToJson(List values) { + StringBuilder sb = new StringBuilder(); + sb.append('['); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append('"').append(escape(values.get(i))).append('"'); + } + sb.append(']'); + return sb.toString(); + } } diff --git a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java index 18e29eb..563248f 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.Map; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -30,6 +31,38 @@ void createTestRepoProducesCleanRepoAndBranches() { assertFalse(bridge.getBranches(repo).isEmpty()); } + @Test + void createTestRepoWithOptionsAppliesFileAndBranchState() throws Exception { + CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); + String remotePath = bridge.createBareRemote(tmp, "remote"); + CliBridge.RepoOptions options = + CliBridge.RepoOptions.builder("subject") + .dirty(true) + .file("src/main.txt", "hello\n") + .remote("origin", remotePath) + .branch("feature/a") + .initialCommit("seed commit") + ; + + String repo = bridge.createTestRepo(tmp, options); + + assertTrue(Files.exists(Path.of(repo, "src/main.txt"))); + assertTrue(bridge.getBranches(repo).contains("feature/a")); + assertEquals(remotePath, bridge.getRemotes(repo).get("origin")); + assertTrue(bridge.isDirty(repo)); + } + + @Test + void setupFakeFilesystemCreatesExpectedTree() { + CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); + String root = bridge.setupFakeFilesystem(tmp); + Path fsRoot = Path.of(root); + + assertTrue(Files.exists(fsRoot.resolve("home/testuser/projects"))); + assertTrue(Files.exists(fsRoot.resolve("home/testuser/.cache"))); + assertTrue(Files.exists(fsRoot.resolve("root/sys"))); + } + @Test void createBareRemoteAndPushSmokeFlow() throws Exception { CliBridge bridge = new CliBridge(Path.of("../..").toAbsolutePath().normalize()); From 3af83497a83f2e88fce14ca90b58b1b4675e467a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 03:58:51 +0000 Subject: [PATCH 15/27] Harden bridge portability and expand CI OS coverage Co-authored-by: Ben Schellenberger --- .github/workflows/ci.yml | 41 +++++++++++++++++++ .../java/io/gitfire/testkit/CliBridge.java | 26 ++++++++---- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63b7099..b4b8653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,3 +85,44 @@ jobs: - name: Run tests run: go test ./... + + wrapper-cross-platform: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Run Python wrapper smoke tests + shell: bash + run: | + cd testkit/python + python -m pip install -e ".[dev]" + python -m pytest tests/test_fixtures.py tests/test_snapshots.py -v + + - name: Run Java wrapper smoke tests + shell: bash + run: | + cd testkit/java + mvn -Dtest=CliBridgeTest,SampleRepoFlowSmoke,SampleSnapshotFlowSmoke test diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index 9d82d7d..bc1760f 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -145,12 +146,12 @@ private String toJson() { } } - private final String cliCommand; + private final List cliCommandArgs; private final Path workspaceRoot; private final java.util.function.Function cliInvoker; public CliBridge(Path workspaceRoot) { - this(workspaceRoot, "go run ./cmd/git-testkit-cli"); + this(workspaceRoot, List.of("go", "run", "./cmd/git-testkit-cli"), null); } public CliBridge() { @@ -158,22 +159,33 @@ public CliBridge() { } public CliBridge(Path workspaceRoot, String cliCommand) { - this(workspaceRoot, cliCommand, null); + this(workspaceRoot, shellCommand(cliCommand), null); } CliBridge(Path workspaceRoot, java.util.function.Function cliInvoker) { - this(workspaceRoot, "", cliInvoker); + this(workspaceRoot, List.of("go", "run", "./cmd/git-testkit-cli"), cliInvoker); } CliBridge( Path workspaceRoot, - String cliCommand, + List cliCommandArgs, java.util.function.Function cliInvoker) { this.workspaceRoot = workspaceRoot; - this.cliCommand = cliCommand; + this.cliCommandArgs = List.copyOf(cliCommandArgs); this.cliInvoker = cliInvoker; } + private static List shellCommand(String cliCommand) { + if (isWindows()) { + return List.of("cmd", "/c", cliCommand); + } + return List.of("sh", "-lc", cliCommand); + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"); + } + private static Path detectWorkspaceRoot() { Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); Path probe = current; @@ -348,7 +360,7 @@ private CliResult runCli(String payload) { } ExecutorService streamReaderPool = null; try { - ProcessBuilder pb = new ProcessBuilder("bash", "-lc", cliCommand); + ProcessBuilder pb = new ProcessBuilder(cliCommandArgs); pb.directory(workspaceRoot.toFile()); Process process = pb.start(); streamReaderPool = Executors.newFixedThreadPool(2); From 58969a61162ca0df55b7471c67938abb855413a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 04:02:45 +0000 Subject: [PATCH 16/27] Resolve latest PR feedback on bridge robustness Co-authored-by: Ben Schellenberger --- .../java/io/gitfire/testkit/CliBridge.java | 2 +- testkit/python/git_testkit/cli.py | 31 +++++++++++++------ testkit/python/samples/smoke_snapshot_flow.py | 1 + 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index bc1760f..997e2a1 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -241,7 +241,7 @@ public String runGitCmd(String repoPath, String... args) { payload.append("]}"); String json = invoke(payload.toString()); if (!json.contains("\"output\"")) { - return ""; + throw new IllegalStateException("missing output in bridge response: " + json); } return extractRequired(json, OUTPUT_PATTERN, "output"); } diff --git a/testkit/python/git_testkit/cli.py b/testkit/python/git_testkit/cli.py index 5be528d..8b7fe7b 100644 --- a/testkit/python/git_testkit/cli.py +++ b/testkit/python/git_testkit/cli.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Any +_CLI_TIMEOUT_SECONDS = 60 + def _repo_root() -> Path: return Path(__file__).resolve().parents[3] @@ -17,14 +19,20 @@ def _cli_cmd() -> list[str]: def _call(op: str, **payload: Any) -> dict[str, Any]: request = {"op": op, **payload} - proc = subprocess.run( - _cli_cmd(), - cwd=_repo_root(), - input=json.dumps(request), - text=True, - capture_output=True, - check=False, - ) + 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-testkit-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: @@ -39,7 +47,12 @@ def _call(op: str, **payload: Any) -> dict[str, Any]: f"git-testkit-cli exited {proc.returncode}: {stderr}; stdout: {stdout}" ) - response = json.loads(stdout) + try: + response = json.loads(stdout) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"invalid JSON from git-testkit-cli: {stdout!r}; stderr: {stderr}" + ) from exc if not response.get("ok", False): raise RuntimeError(response.get("error", "unknown git-testkit-cli error")) return response diff --git a/testkit/python/samples/smoke_snapshot_flow.py b/testkit/python/samples/smoke_snapshot_flow.py index eab280f..5048c45 100644 --- a/testkit/python/samples/smoke_snapshot_flow.py +++ b/testkit/python/samples/smoke_snapshot_flow.py @@ -13,6 +13,7 @@ def main() -> None: repo = client.create_test_repo(root, name="smoke-snapshot") snapshot_path = root / "snapshots" / "smoke-snapshot.tar.gz" + snapshot_path.parent.mkdir(parents=True, exist_ok=True) snapshot_name, snapshot_size = client.save_snapshot(repo, snapshot_path) restored_path = client.load_restore_snapshot(snapshot_path, root) From aee8e6518443b43a8761d5033b384719a249cde9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 04:12:26 +0000 Subject: [PATCH 17/27] Harden Java sample cleanup for Windows file locks Co-authored-by: Ben Schellenberger --- .../gitfire/testkit/SampleRepoFlowSmoke.java | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java index abdb9a9..92a500f 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java @@ -36,14 +36,29 @@ private static void deleteRecursively(Path root) throws Exception { if (!Files.exists(root)) { return; } - try (var stream = Files.walk(root)) { - stream.sorted((a, b) -> b.compareTo(a)).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (Exception ex) { - throw new RuntimeException(ex); + RuntimeException lastFailure = null; + // Windows can hold file handles briefly after git child processes exit. + for (int attempt = 1; attempt <= 10; attempt++) { + try (var stream = Files.walk(root)) { + stream.sorted((a, b) -> b.compareTo(a)).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + } catch (RuntimeException ex) { + lastFailure = ex; + if (attempt == 10) { + throw ex; } - }); + Thread.sleep(100L * attempt); + continue; + } + return; + } + if (lastFailure != null) { + throw lastFailure; } } } From 112878be2135c0ae78e518e561e00da5973b4ebe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 04:27:07 +0000 Subject: [PATCH 18/27] Make Java sample cleanup tolerant to Windows locks Co-authored-by: Ben Schellenberger --- .../gitfire/testkit/SampleRepoFlowSmoke.java | 58 ++++--------------- 1 file changed, 12 insertions(+), 46 deletions(-) diff --git a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java index 92a500f..345a5df 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java @@ -11,54 +11,20 @@ void sampleRepoFlowRuns() throws Exception { CliBridge bridge = new CliBridge(workspaceRoot); Path tmp = Files.createTempDirectory("git-testkit-java-sample-repo"); - try { - String remote = bridge.createBareRemote(tmp, "origin"); - String local = bridge.createTestRepo(tmp, "local"); + String remote = bridge.createBareRemote(tmp, "origin"); + String local = bridge.createTestRepo(tmp, "local"); - bridge.runGitCmd(local, "remote", "add", "origin", remote); - bridge.runGitCmd(local, "checkout", "-b", "feature"); - Files.writeString(Path.of(local, "README.md"), "sample update\n", java.nio.file.StandardOpenOption.APPEND); - bridge.runGitCmd(local, "add", "README.md"); - bridge.runGitCmd(local, "commit", "-m", "sample update"); - bridge.runGitCmd(local, "push", "origin", "feature"); + bridge.runGitCmd(local, "remote", "add", "origin", remote); + bridge.runGitCmd(local, "checkout", "-b", "feature"); + Files.writeString(Path.of(local, "README.md"), "sample update\n", java.nio.file.StandardOpenOption.APPEND); + bridge.runGitCmd(local, "add", "README.md"); + bridge.runGitCmd(local, "commit", "-m", "sample update"); + bridge.runGitCmd(local, "push", "origin", "feature"); - String localSha = bridge.getCurrentSha(local); - String remoteSha = bridge.runGitCmd(remote, "rev-parse", "feature").trim(); - if (!localSha.equals(remoteSha)) { - throw new IllegalStateException("SHA mismatch between local and remote feature branch"); - } - } finally { - deleteRecursively(tmp); - } - } - - private static void deleteRecursively(Path root) throws Exception { - if (!Files.exists(root)) { - return; - } - RuntimeException lastFailure = null; - // Windows can hold file handles briefly after git child processes exit. - for (int attempt = 1; attempt <= 10; attempt++) { - try (var stream = Files.walk(root)) { - stream.sorted((a, b) -> b.compareTo(a)).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (Exception ex) { - throw new RuntimeException(ex); - } - }); - } catch (RuntimeException ex) { - lastFailure = ex; - if (attempt == 10) { - throw ex; - } - Thread.sleep(100L * attempt); - continue; - } - return; - } - if (lastFailure != null) { - throw lastFailure; + String localSha = bridge.getCurrentSha(local); + String remoteSha = bridge.runGitCmd(remote, "rev-parse", "feature").trim(); + if (!localSha.equals(remoteSha)) { + throw new IllegalStateException("SHA mismatch between local and remote feature branch"); } } } From 054f0b3d2e37ee60eacdc84316c2928f0598605d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 05:24:47 +0000 Subject: [PATCH 19/27] Use TempDir in Java sample to avoid temp leaks Co-authored-by: Ben Schellenberger --- .../src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java index 345a5df..db8b268 100644 --- a/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java +++ b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java @@ -2,14 +2,16 @@ import java.nio.file.Files; import java.nio.file.Path; +import org.junit.jupiter.api.io.TempDir; /** Runnable sample that exercises repo/remote push flow. */ public class SampleRepoFlowSmoke { + @TempDir Path tmp; + @org.junit.jupiter.api.Test void sampleRepoFlowRuns() throws Exception { Path workspaceRoot = Path.of("../..").toAbsolutePath().normalize(); CliBridge bridge = new CliBridge(workspaceRoot); - Path tmp = Files.createTempDirectory("git-testkit-java-sample-repo"); String remote = bridge.createBareRemote(tmp, "origin"); String local = bridge.createTestRepo(tmp, "local"); From 087e484b4058e8cd56552e1e608cb89d4cf8f93c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 05:59:31 +0000 Subject: [PATCH 20/27] Restore symlink entries in snapshot roundtrips Co-authored-by: Ben Schellenberger --- snapshots.go | 33 ++++++++++++++++++++++++++++-- snapshots_test.go | 51 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/snapshots.go b/snapshots.go index 01e4416..462277e 100644 --- a/snapshots.go +++ b/snapshots.go @@ -49,8 +49,17 @@ func SnapshotRepoE(repoPath string) (*Snapshot, error) { return err } + linkTarget := "" + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return fmt.Errorf("failed to read symlink %s: %w", path, err) + } + linkTarget = target + } + // Create tar header - header, err := tar.FileInfoHeader(info, "") + header, err := tar.FileInfoHeader(info, linkTarget) if err != nil { return fmt.Errorf("failed to create tar header: %w", err) } @@ -153,7 +162,7 @@ func RestoreSnapshotToDir(snapshot *Snapshot, baseDir string) (string, error) { if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { return "", fmt.Errorf("failed to create directory %s: %w", targetPath, err) } - case tar.TypeReg: + case tar.TypeReg, tar.TypeRegA: dir := filepath.Dir(targetPath) if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err) @@ -169,6 +178,26 @@ func RestoreSnapshotToDir(snapshot *Snapshot, baseDir string) (string, error) { if err := file.Close(); err != nil { return "", fmt.Errorf("failed closing file %s: %w", targetPath, err) } + case tar.TypeSymlink: + dir := filepath.Dir(targetPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err) + } + if err := os.Symlink(header.Linkname, targetPath); err != nil { + return "", fmt.Errorf("failed to create symlink %s -> %s: %w", targetPath, header.Linkname, err) + } + case tar.TypeLink: + dir := filepath.Dir(targetPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err) + } + linkTarget, err := safeJoin(restorePath, header.Linkname) + if err != nil { + return "", fmt.Errorf("invalid hard link target %q: %w", header.Linkname, err) + } + if err := os.Link(linkTarget, targetPath); err != nil { + return "", fmt.Errorf("failed to create hard link %s -> %s: %w", targetPath, linkTarget, err) + } default: return "", fmt.Errorf( "unsupported snapshot entry type %d for %q", diff --git a/snapshots_test.go b/snapshots_test.go index 5f8018d..bc26ed5 100644 --- a/snapshots_test.go +++ b/snapshots_test.go @@ -135,14 +135,13 @@ func TestRestoreSnapshotRejectsUnsupportedEntryTypes(t *testing.T) { t.Fatalf("failed to write dir header: %v", err) } - // Add symlink entry that restore does not support. + // Add character device entry that restore does not support. if err := tw.WriteHeader(&tar.Header{ - Name: "repo/link", - Typeflag: tar.TypeSymlink, - Linkname: "target", - Mode: 0777, + Name: "repo/chardev", + Typeflag: tar.TypeChar, + Mode: 0600, }); err != nil { - t.Fatalf("failed to write symlink header: %v", err) + t.Fatalf("failed to write unsupported header: %v", err) } if err := tw.Close(); err != nil { @@ -161,3 +160,43 @@ func TestRestoreSnapshotRejectsUnsupportedEntryTypes(t *testing.T) { t.Fatalf("expected unsupported entry error, got: %v", err) } } + +func TestSnapshotRoundtripRestoresSymlinkEntries(t *testing.T) { + if _, err := os.Stat("/"); err != nil { + t.Skip("symlink test requires filesystem support") + } + + root := t.TempDir() + repoPath, err := CreateTestRepoInDir(root, RepoOptions{Name: "symlink-repo"}) + if err != nil { + t.Fatalf("failed creating repo: %v", err) + } + + targetFile := filepath.Join(repoPath, "target.txt") + if err := os.WriteFile(targetFile, []byte("hello"), 0644); err != nil { + t.Fatalf("failed writing target file: %v", err) + } + linkPath := filepath.Join(repoPath, "link.txt") + if err := os.Symlink("target.txt", linkPath); err != nil { + t.Skipf("symlink not supported on this platform: %v", err) + } + + snapshot := SnapshotRepo(t, repoPath) + restorePath := RestoreSnapshot(t, snapshot) + restoredLink := filepath.Join(restorePath, "link.txt") + + info, err := os.Lstat(restoredLink) + if err != nil { + t.Fatalf("expected symlink to exist after restore: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected %s to be a symlink", restoredLink) + } + destination, err := os.Readlink(restoredLink) + if err != nil { + t.Fatalf("failed to read restored symlink: %v", err) + } + if destination != "target.txt" { + t.Fatalf("expected symlink target %q, got %q", "target.txt", destination) + } +} From b87d1d8ea43ec184c9d0675abee3810690274e00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 07:06:24 +0000 Subject: [PATCH 21/27] Add gofmt check to CI test job Co-authored-by: Ben Schellenberger --- .github/workflows/ci.yml | 3 +++ scenarios.go | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4b8653..d82fa65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,9 @@ jobs: - name: Run vet run: go vet ./... + - name: Check gofmt + run: test -z "$(gofmt -l *.go)" + - name: Run tests run: go test ./... diff --git a/scenarios.go b/scenarios.go index 136ceb2..97fd1de 100644 --- a/scenarios.go +++ b/scenarios.go @@ -16,10 +16,10 @@ type Scenario struct { // ScenarioRepo represents a repository in a scenario type ScenarioRepo struct { - path string - name string - remotes map[string]string - t *testing.T + path string + name string + remotes map[string]string + t *testing.T } // NewScenario creates a new test scenario From 366c69e2542b579bc697f14b6f0bd7b1921cd347 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 07:29:19 +0000 Subject: [PATCH 22/27] Fix snapshot archive/restore entry symmetry Co-authored-by: Ben Schellenberger --- snapshots.go | 11 +++++++++++ snapshots_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/snapshots.go b/snapshots.go index 462277e..4e89279 100644 --- a/snapshots.go +++ b/snapshots.go @@ -49,6 +49,12 @@ func SnapshotRepoE(repoPath string) (*Snapshot, error) { return err } + // Keep snapshot/restore symmetric: only archive entry types restore supports. + // This intentionally skips device files, sockets, and FIFOs. + if !supportsSnapshotEntry(info) { + return nil + } + linkTarget := "" if info.Mode()&os.ModeSymlink != 0 { target, err := os.Readlink(path) @@ -113,6 +119,11 @@ func SnapshotRepoE(repoPath string) (*Snapshot, error) { }, nil } +func supportsSnapshotEntry(info os.FileInfo) bool { + mode := info.Mode() + return mode.IsDir() || mode.IsRegular() || mode&os.ModeSymlink != 0 +} + // RestoreSnapshot restores a snapshot to a new temporary directory // Returns the path to the restored repository func RestoreSnapshot(t *testing.T, snapshot *Snapshot) string { diff --git a/snapshots_test.go b/snapshots_test.go index bc26ed5..e299058 100644 --- a/snapshots_test.go +++ b/snapshots_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestRestoreSnapshotRejectsUnsafeSnapshotNames(t *testing.T) { @@ -200,3 +201,37 @@ func TestSnapshotRoundtripRestoresSymlinkEntries(t *testing.T) { t.Fatalf("expected symlink target %q, got %q", "target.txt", destination) } } + +type stubFileInfo struct { + mode os.FileMode +} + +func (s stubFileInfo) Name() string { return "stub" } +func (s stubFileInfo) Size() int64 { return 0 } +func (s stubFileInfo) Mode() os.FileMode { return s.mode } +func (s stubFileInfo) ModTime() time.Time { return time.Time{} } +func (s stubFileInfo) IsDir() bool { return s.mode.IsDir() } +func (s stubFileInfo) Sys() any { return nil } + +func TestSupportsSnapshotEntry(t *testing.T) { + tests := []struct { + name string + mode os.FileMode + want bool + }{ + {name: "regular file", mode: 0644, want: true}, + {name: "directory", mode: os.ModeDir | 0755, want: true}, + {name: "symlink", mode: os.ModeSymlink, want: true}, + {name: "named pipe", mode: os.ModeNamedPipe, want: false}, + {name: "character device", mode: os.ModeCharDevice, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := supportsSnapshotEntry(stubFileInfo{mode: tt.mode}) + if got != tt.want { + t.Fatalf("supportsSnapshotEntry(%v) = %v, want %v", tt.mode, got, tt.want) + } + }) + } +} From 372011c1515f43631b3c443355efe5bb571930fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 07:29:20 +0000 Subject: [PATCH 23/27] Optimize CI wrapper tests with prebuilt CLI Co-authored-by: Ben Schellenberger --- .github/workflows/ci.yml | 16 +++++++++++++++- .../main/java/io/gitfire/testkit/CliBridge.java | 12 ++++++++++-- testkit/python/git_testkit/cli.py | 4 ++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d82fa65..f338f11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: run: go vet ./... - name: Check gofmt - run: test -z "$(gofmt -l *.go)" + run: test -z "$(gofmt -l .)" - name: Run tests run: go test ./... @@ -105,6 +105,16 @@ jobs: with: go-version: "stable" + - name: Build git-testkit CLI binary once + shell: bash + run: | + mkdir -p bin + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then + go build -o ./bin/git-testkit-cli.exe ./cmd/git-testkit-cli + else + go build -o ./bin/git-testkit-cli ./cmd/git-testkit-cli + fi + - name: Setup Python uses: actions/setup-python@v5 with: @@ -119,6 +129,8 @@ jobs: - name: Run Python wrapper smoke tests shell: bash + env: + GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-testkit-cli.exe' || './bin/git-testkit-cli' }} run: | cd testkit/python python -m pip install -e ".[dev]" @@ -126,6 +138,8 @@ jobs: - name: Run Java wrapper smoke tests shell: bash + env: + GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-testkit-cli.exe' || './bin/git-testkit-cli' }} run: | cd testkit/java mvn -Dtest=CliBridgeTest,SampleRepoFlowSmoke,SampleSnapshotFlowSmoke test diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index 997e2a1..b57d7b8 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -151,7 +151,7 @@ private String toJson() { private final java.util.function.Function cliInvoker; public CliBridge(Path workspaceRoot) { - this(workspaceRoot, List.of("go", "run", "./cmd/git-testkit-cli"), null); + this(workspaceRoot, defaultCliCommandArgs(), null); } public CliBridge() { @@ -163,7 +163,7 @@ public CliBridge(Path workspaceRoot, String cliCommand) { } CliBridge(Path workspaceRoot, java.util.function.Function cliInvoker) { - this(workspaceRoot, List.of("go", "run", "./cmd/git-testkit-cli"), cliInvoker); + this(workspaceRoot, defaultCliCommandArgs(), cliInvoker); } CliBridge( @@ -182,6 +182,14 @@ private static List shellCommand(String cliCommand) { return List.of("sh", "-lc", cliCommand); } + private static List defaultCliCommandArgs() { + String configuredCli = System.getenv("GIT_TESTKIT_CLI"); + if (configuredCli != null && !configuredCli.isBlank()) { + return shellCommand(configuredCli); + } + return List.of("go", "run", "./cmd/git-testkit-cli"); + } + private static boolean isWindows() { return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"); } diff --git a/testkit/python/git_testkit/cli.py b/testkit/python/git_testkit/cli.py index 8b7fe7b..5da1d9a 100644 --- a/testkit/python/git_testkit/cli.py +++ b/testkit/python/git_testkit/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import subprocess from dataclasses import dataclass, field from pathlib import Path @@ -14,6 +15,9 @@ def _repo_root() -> Path: def _cli_cmd() -> list[str]: + cli = os.environ.get("GIT_TESTKIT_CLI", "").strip() + if cli: + return [cli] return ["go", "run", "./cmd/git-testkit-cli"] From 33df51f6c2a8d425c8bfa0cdfdea4f831a308940 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 07:37:06 +0000 Subject: [PATCH 24/27] Fix wrapper CI CLI path after directory changes Co-authored-by: Ben Schellenberger --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f338f11..2c5a792 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: - name: Run Python wrapper smoke tests shell: bash env: - GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-testkit-cli.exe' || './bin/git-testkit-cli' }} + GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && '../../bin/git-testkit-cli.exe' || '../../bin/git-testkit-cli' }} run: | cd testkit/python python -m pip install -e ".[dev]" @@ -139,7 +139,7 @@ jobs: - name: Run Java wrapper smoke tests shell: bash env: - GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-testkit-cli.exe' || './bin/git-testkit-cli' }} + GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && '../../bin/git-testkit-cli.exe' || '../../bin/git-testkit-cli' }} run: | cd testkit/java mvn -Dtest=CliBridgeTest,SampleRepoFlowSmoke,SampleSnapshotFlowSmoke test From 347c4e8ff0483ab2db7d25b93d27b36363483fb5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 07:42:35 +0000 Subject: [PATCH 25/27] Fix wrapper CLI path resolution across CI jobs Co-authored-by: Ben Schellenberger --- .github/workflows/ci.yml | 4 ++-- .../java/src/main/java/io/gitfire/testkit/CliBridge.java | 7 ++++++- testkit/python/git_testkit/cli.py | 5 ++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c5a792..f338f11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: - name: Run Python wrapper smoke tests shell: bash env: - GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && '../../bin/git-testkit-cli.exe' || '../../bin/git-testkit-cli' }} + GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-testkit-cli.exe' || './bin/git-testkit-cli' }} run: | cd testkit/python python -m pip install -e ".[dev]" @@ -139,7 +139,7 @@ jobs: - name: Run Java wrapper smoke tests shell: bash env: - GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && '../../bin/git-testkit-cli.exe' || '../../bin/git-testkit-cli' }} + GIT_TESTKIT_CLI: ${{ matrix.os == 'windows-latest' && './bin/git-testkit-cli.exe' || './bin/git-testkit-cli' }} run: | cd testkit/java mvn -Dtest=CliBridgeTest,SampleRepoFlowSmoke,SampleSnapshotFlowSmoke test diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index b57d7b8..b45ec9c 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -14,6 +14,7 @@ import java.util.concurrent.Future; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.nio.file.Paths; public final class CliBridge { private static final Pattern ERROR_PATTERN = @@ -185,7 +186,11 @@ private static List shellCommand(String cliCommand) { private static List defaultCliCommandArgs() { String configuredCli = System.getenv("GIT_TESTKIT_CLI"); if (configuredCli != null && !configuredCli.isBlank()) { - return shellCommand(configuredCli); + Path cliPath = Paths.get(configuredCli); + if (!cliPath.isAbsolute()) { + cliPath = detectWorkspaceRoot().resolve(cliPath).normalize(); + } + return shellCommand(cliPath.toString()); } return List.of("go", "run", "./cmd/git-testkit-cli"); } diff --git a/testkit/python/git_testkit/cli.py b/testkit/python/git_testkit/cli.py index 5da1d9a..daf92f8 100644 --- a/testkit/python/git_testkit/cli.py +++ b/testkit/python/git_testkit/cli.py @@ -17,7 +17,10 @@ def _repo_root() -> Path: def _cli_cmd() -> list[str]: cli = os.environ.get("GIT_TESTKIT_CLI", "").strip() if cli: - return [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-testkit-cli"] From 6cc6ae324cbcdc743cc8df84a3c20121ed8bd64d Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:58:25 +0000 Subject: [PATCH 26/27] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit --- .../java/io/gitfire/testkit/CliBridge.java | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index b45ec9c..7a24893 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -12,6 +12,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.nio.file.Paths; @@ -152,7 +153,7 @@ private String toJson() { private final java.util.function.Function cliInvoker; public CliBridge(Path workspaceRoot) { - this(workspaceRoot, defaultCliCommandArgs(), null); + this(workspaceRoot, defaultCliCommandArgs(workspaceRoot), null); } public CliBridge() { @@ -164,7 +165,7 @@ public CliBridge(Path workspaceRoot, String cliCommand) { } CliBridge(Path workspaceRoot, java.util.function.Function cliInvoker) { - this(workspaceRoot, defaultCliCommandArgs(), cliInvoker); + this(workspaceRoot, defaultCliCommandArgs(workspaceRoot), cliInvoker); } CliBridge( @@ -183,14 +184,14 @@ private static List shellCommand(String cliCommand) { return List.of("sh", "-lc", cliCommand); } - private static List defaultCliCommandArgs() { + private static List defaultCliCommandArgs(Path workspaceRoot) { String configuredCli = System.getenv("GIT_TESTKIT_CLI"); if (configuredCli != null && !configuredCli.isBlank()) { Path cliPath = Paths.get(configuredCli); if (!cliPath.isAbsolute()) { - cliPath = detectWorkspaceRoot().resolve(cliPath).normalize(); + cliPath = workspaceRoot.resolve(cliPath).normalize(); } - return shellCommand(cliPath.toString()); + return List.of(cliPath.toString()); } return List.of("go", "run", "./cmd/git-testkit-cli"); } @@ -371,21 +372,28 @@ private CliResult runCli(String payload) { if (cliInvoker != null) { return cliInvoker.apply(payload); } + Process process = null; ExecutorService streamReaderPool = null; + Future stdoutFuture = null; + Future stderrFuture = null; try { ProcessBuilder pb = new ProcessBuilder(cliCommandArgs); pb.directory(workspaceRoot.toFile()); - Process process = pb.start(); + process = pb.start(); streamReaderPool = Executors.newFixedThreadPool(2); - Future stdoutFuture = + stdoutFuture = streamReaderPool.submit( () -> new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); - Future stderrFuture = + stderrFuture = streamReaderPool.submit( () -> new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8)); process.getOutputStream().write(payload.getBytes(StandardCharsets.UTF_8)); process.getOutputStream().close(); - int code = process.waitFor(); + boolean completed = process.waitFor(120, TimeUnit.SECONDS); + if (!completed) { + throw new RuntimeException("CLI process timed out after 120 seconds"); + } + int code = process.exitValue(); String stdout = stdoutFuture.get(); String stderr = stderrFuture.get(); return new CliResult(stdout, stderr, code); @@ -397,8 +405,22 @@ private CliResult runCli(String payload) { 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(); + } } } } @@ -589,4 +611,4 @@ private static String stringListToJson(List values) { sb.append(']'); return sb.toString(); } -} +} \ No newline at end of file From 21de82212d03422e8aa2b0c78f7fdf5df4633cd6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 12:26:50 +0000 Subject: [PATCH 27/27] Fix CodeRabbit Java lambda compile regression Co-authored-by: Ben Schellenberger --- .../src/main/java/io/gitfire/testkit/CliBridge.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java index 7a24893..faba401 100644 --- a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -373,6 +373,7 @@ private CliResult runCli(String payload) { return cliInvoker.apply(payload); } Process process = null; + final Process[] processRef = new Process[1]; ExecutorService streamReaderPool = null; Future stdoutFuture = null; Future stderrFuture = null; @@ -380,13 +381,18 @@ private CliResult runCli(String payload) { ProcessBuilder pb = new ProcessBuilder(cliCommandArgs); pb.directory(workspaceRoot.toFile()); process = pb.start(); + processRef[0] = process; streamReaderPool = Executors.newFixedThreadPool(2); stdoutFuture = streamReaderPool.submit( - () -> new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + () -> + new String( + processRef[0].getInputStream().readAllBytes(), StandardCharsets.UTF_8)); stderrFuture = streamReaderPool.submit( - () -> new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8)); + () -> + 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);