diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b08eec..f338f11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,63 @@ 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: Build git-testkit CLI binary once + run: go build ./cmd/git-testkit-cli + + - 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 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,SampleSnapshotFlowSmoke test + test: runs-on: ubuntu-latest strategy: @@ -26,5 +83,63 @@ jobs: - name: Run vet run: go vet ./... + - name: Check gofmt + run: test -z "$(gofmt -l .)" + - 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: 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: + 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 + 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]" + python -m pytest tests/test_fixtures.py tests/test_snapshots.py -v + + - 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/cmd/git-testkit-cli/main.go b/cmd/git-testkit-cli/main.go new file mode 100644 index 0000000..aab8631 --- /dev/null +++ b/cmd/git-testkit-cli/main.go @@ -0,0 +1,266 @@ +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) { + var req request + if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil { + return request{}, fmt.Errorf("invalid JSON request: %w", err) + } + if strings.TrimSpace(req.Op) == "" { + return request{}, fmt.Errorf("missing required field: op") + } + return req, nil +} + +func handle(req request) (response, error) { + switch req.Op { + case "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: stringPtr(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: intPtr(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: intPtr(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: intPtr(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 { + 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 +} + +func stringPtr(v string) *string { + return &v +} diff --git a/fixtures.go b/fixtures.go index 7251431..aafcfb9 100644 --- a/fixtures.go +++ b/fixtures.go @@ -1,6 +1,8 @@ package testutil import ( + "bytes" + "fmt" "os" "os/exec" "path/filepath" @@ -34,102 +36,145 @@ 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) { + 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 { - 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 + } + originalBranch, err := RunGitCmdE(repoPath, "rev-parse", "--abbrev-ref", "HEAD") + if 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) + 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 { - 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", "--", filepath.ToSlash(relPath)); 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 branch == originalBranch { + continue + } + 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", originalBranch); err != nil { + return "", err } } - // 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) { + 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 { - 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 +183,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 +206,133 @@ 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 + remotes, err := GetRemotesE(repoPath) + if err != nil { + t.Fatalf("Failed to get remotes: %v", err) + } + 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...) +} + +// 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 + var stderr bytes.Buffer + cmd.Stderr = &stderr output, err := cmd.Output() if err != nil { - t.Fatalf("Failed to get remotes: %v", 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 +} - // Parse output into map - // Format: "origin /path/to/remote (fetch)" - // "origin /path/to/remote (push)" - remotes := make(map[string]string) +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 +} - lines := strings.TrimSpace(string(output)) +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() + + 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 +340,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 +351,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,45 +361,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() - - cmd := exec.Command("git", "branch", "--format=%(refname:short)") - cmd.Dir = repoPath - - output, err := cmd.Output() +// GetBranchesE returns all branches in a repo. +func GetBranchesE(repoPath string) ([]string, error) { + output, err := RunGitCmdE(repoPath, "branch", "--format=%(refname:short)") if err != nil { - t.Fatalf("Failed to get branches: %v", err) + 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 @@ -278,5 +386,5 @@ func GetBranches(t *testing.T, repoPath string) []string { } } - return result + return result, nil } diff --git a/fixtures_test.go b/fixtures_test.go index 7157e58..3a95633 100644 --- a/fixtures_test.go +++ b/fixtures_test.go @@ -2,7 +2,10 @@ package testutil_test import ( "os" + "os/exec" "path/filepath" + "runtime" + "strings" "testing" testutil "github.com/git-fire/git-testkit" @@ -151,3 +154,156 @@ 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) + } + + 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) { + 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) + } +} + +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/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 diff --git a/snapshots.go b/snapshots.go index 56a4667..4e89279 100644 --- a/snapshots.go +++ b/snapshots.go @@ -30,7 +30,15 @@ type Snapshot struct { // 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) @@ -41,8 +49,23 @@ func SnapshotRepo(t *testing.T, repoPath string) *Snapshot { 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) + 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) } @@ -68,32 +91,37 @@ func SnapshotRepo(t *testing.T, repoPath string) *Snapshot { 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 }) - 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 +} + +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 @@ -101,75 +129,96 @@ 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("Invalid snapshot name %q: %v", snapshot.name, err) + 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 { + 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 + case tar.TypeReg, tar.TypeRegA: 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) + } + 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) } - file.Close() - default: - t.Logf("Skipping unsupported file type %v for %s", header.Typeflag, header.Name) + return "", fmt.Errorf( + "unsupported snapshot entry type %d for %q", + header.Typeflag, + header.Name, + ) } } - return restorePath + return restorePath, nil } func safeJoin(base, name string) (string, error) { @@ -209,8 +258,7 @@ func (s *Snapshot) Name() string { // 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 +266,31 @@ 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 { + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return err + } + 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/snapshots_test.go b/snapshots_test.go index 49574d1..e299058 100644 --- a/snapshots_test.go +++ b/snapshots_test.go @@ -1,9 +1,14 @@ package testutil import ( + "archive/tar" + "bytes" + "compress/gzip" "os" "path/filepath" + "strings" "testing" + "time" ) func TestRestoreSnapshotRejectsUnsafeSnapshotNames(t *testing.T) { @@ -116,3 +121,117 @@ 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 character device entry that restore does not support. + if err := tw.WriteHeader(&tar.Header{ + Name: "repo/chardev", + Typeflag: tar.TypeChar, + Mode: 0600, + }); err != nil { + t.Fatalf("failed to write unsupported 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) + } +} + +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) + } +} + +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) + } + }) + } +} 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/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/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..902d247 --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-testkit/plan.md @@ -0,0 +1,52 @@ +# Implementation Plan: Polyglot testkit via Go bridge (Option A) + +**Feature**: `001-polyglot-testkit` +**Input**: `testkit/.specify/specs/001-polyglot-testkit/spec.md` +**Status**: Implemented (canonical spec-kit 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. + +## 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. +- 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..e70df55 --- /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**: 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)* + +### 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..bd3a01d --- /dev/null +++ b/testkit/.specify/specs/001-polyglot-testkit/tasks.md @@ -0,0 +1,29 @@ +# 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` +- [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/GIT_TESTKIT_SPEC.md b/testkit/GIT_TESTKIT_SPEC.md new file mode 100644 index 0000000..75a2526 --- /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 the original branch that was active right after initial repository creation. +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/README.md b/testkit/README.md new file mode 100644 index 0000000..f6868de --- /dev/null +++ b/testkit/README.md @@ -0,0 +1,36 @@ +## Spec-kit integration + +This repository uses a 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: from repository root, run `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 diff --git a/testkit/ROADMAP.md b/testkit/ROADMAP.md new file mode 100644 index 0000000..4d85961 --- /dev/null +++ b/testkit/ROADMAP.md @@ -0,0 +1,57 @@ +# 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 (`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 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 + +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/README.md b/testkit/java/README.md new file mode 100644 index 0000000..15b159a --- /dev/null +++ b/testkit/java/README.md @@ -0,0 +1,35 @@ +## testkit/java + +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: + +- `SampleRepoFlowSmoke` validates repository + remote + push flow. +- `SampleSnapshotFlowSmoke` validates snapshot save/load/restore flow. + +Run them from `testkit/java`: + +- `mvn -Dtest=SampleRepoFlowSmoke test` +- `mvn -Dtest=SampleSnapshotFlowSmoke test` + +Or run all Java wrapper tests: + +- `mvn test` diff --git a/testkit/java/pom.xml b/testkit/java/pom.xml new file mode 100644 index 0000000..4a8ba14 --- /dev/null +++ b/testkit/java/pom.xml @@ -0,0 +1,42 @@ + + 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-compiler-plugin + 3.11.0 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.1 + + + + diff --git a/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java new file mode 100644 index 0000000..faba401 --- /dev/null +++ b/testkit/java/src/main/java/io/gitfire/testkit/CliBridge.java @@ -0,0 +1,620 @@ +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.Locale; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +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 = + 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 = + 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 = + Pattern.compile("\"sha\"\\s*:\\s*\"((?:\\\\.|[^\\\\\"])*)\""); + private static final Pattern DIRTY_PATTERN = Pattern.compile("\"dirty\"\\s*:\\s*(true|false)"); + 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*\"((?:\\\\.|[^\\\\\"])*)\""); + + static final class CliResult { + private final String stdout; + private final String stderr; + private final int code; + + 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) {} + + 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 List cliCommandArgs; + private final Path workspaceRoot; + private final java.util.function.Function cliInvoker; + + public CliBridge(Path workspaceRoot) { + this(workspaceRoot, defaultCliCommandArgs(workspaceRoot), null); + } + + public CliBridge() { + this(detectWorkspaceRoot()); + } + + public CliBridge(Path workspaceRoot, String cliCommand) { + this(workspaceRoot, shellCommand(cliCommand), null); + } + + CliBridge(Path workspaceRoot, java.util.function.Function cliInvoker) { + this(workspaceRoot, defaultCliCommandArgs(workspaceRoot), cliInvoker); + } + + CliBridge( + Path workspaceRoot, + List cliCommandArgs, + java.util.function.Function cliInvoker) { + this.workspaceRoot = workspaceRoot; + this.cliCommandArgs = List.copyOf(cliCommandArgs); + this.cliInvoker = cliInvoker; + } + + private static List shellCommand(String cliCommand) { + if (isWindows()) { + return List.of("cmd", "/c", cliCommand); + } + return List.of("sh", "-lc", cliCommand); + } + + 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 = workspaceRoot.resolve(cliPath).normalize(); + } + return List.of(cliPath.toString()); + } + return List.of("go", "run", "./cmd/git-testkit-cli"); + } + + 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; + 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) { + return createTestRepo(baseDir, new RepoOptions(name)); + } + + public String createTestRepo(Path baseDir, RepoOptions options) { + String payload = + "{\"op\":\"create_test_repo\",\"baseDir\":\"" + + escape(baseDir.toString()) + + "\",\"options\":" + + options.toJson() + + "}"; + 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 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( + "{\"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()); + if (!json.contains("\"output\"")) { + throw new IllegalStateException("missing output in bridge response: " + json); + } + 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); + Map remotes = new LinkedHashMap<>(); + String body = extractContainerBody(json, "remotes", '{', '}').trim(); + if (body.isEmpty()) { + return remotes; + } + 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); + } + remotes.put(unquote(pairMatcher.group(1)), unquote(pairMatcher.group(2))); + index = pairMatcher.end(); + } + 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); + List branches = new ArrayList<>(); + String body = extractContainerBody(json, "branches", '[', ']').trim(); + if (body.isEmpty()) { + return branches; + } + 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; + } + + 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); + String stdout = result.stdout == null ? "" : result.stdout.trim(); + String stderr = result.stderr == null ? "" : result.stderr.trim(); + if (stdout.isBlank() && result.code != 0) { + throw new RuntimeException("CLI failed with code " + result.code + ": " + stderr); + } + if (stdout.isBlank()) { + throw new RuntimeException("CLI returned empty response"); + } + 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); + } + return stdout; + } + + private CliResult runCli(String payload) { + if (cliInvoker != null) { + return cliInvoker.apply(payload); + } + Process process = null; + final Process[] processRef = new Process[1]; + ExecutorService streamReaderPool = null; + Future stdoutFuture = null; + Future stderrFuture = null; + try { + ProcessBuilder pb = new ProcessBuilder(cliCommandArgs); + pb.directory(workspaceRoot.toFile()); + process = pb.start(); + processRef[0] = process; + streamReaderPool = Executors.newFixedThreadPool(2); + stdoutFuture = + streamReaderPool.submit( + () -> + new String( + processRef[0].getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + stderrFuture = + streamReaderPool.submit( + () -> + new String( + processRef[0].getErrorStream().readAllBytes(), StandardCharsets.UTF_8)); + process.getOutputStream().write(payload.getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().close(); + boolean completed = process.waitFor(120, TimeUnit.SECONDS); + if (!completed) { + throw new RuntimeException("CLI process timed out after 120 seconds"); + } + int code = process.exitValue(); + String 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 (process != null && process.isAlive()) { + process.destroyForcibly(); + } + if (stdoutFuture != null) { + stdoutFuture.cancel(true); + } + if (stderrFuture != null) { + stderrFuture.cancel(true); + } + if (streamReaderPool != null) { + streamReaderPool.shutdownNow(); + try { + streamReaderPool.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + } + } + + private static 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 unquote(matcher.group(1)); + } + + private static String extractOptional(String json, Pattern pattern) { + Matcher matcher = pattern.matcher(json); + 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) { + String out = value; + if (out.startsWith("\"") && out.endsWith("\"") && out.length() >= 2) { + out = out.substring(1, out.length() - 1); + } + + 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) { + 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(); + } + + 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(); + } +} \ No newline at end of file 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..563248f --- /dev/null +++ b/testkit/java/src/test/java/io/gitfire/testkit/CliBridgeTest.java @@ -0,0 +1,151 @@ +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 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; + +class CliBridgeTest { + @TempDir Path tmp; + + private CliBridge bridgeWithJsonResponse(String json) { + return new CliBridge( + Path.of("../..").toAbsolutePath().normalize(), + payload -> new CliBridge.CliResult(json, "", 0)); + } + + @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 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()); + 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"); + + 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())); + } + + @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"))); + } + + @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); + } +} 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..db8b268 --- /dev/null +++ b/testkit/java/src/test/java/io/gitfire/testkit/SampleRepoFlowSmoke.java @@ -0,0 +1,32 @@ +package io.gitfire.testkit; + +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); + + 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"); + } + } +} 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 new file mode 100644 index 0000000..4ae75a3 --- /dev/null +++ b/testkit/python/README.md @@ -0,0 +1,21 @@ +# 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. + +## 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/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..daf92f8 --- /dev/null +++ b/testkit/python/git_testkit/cli.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_CLI_TIMEOUT_SECONDS = 60 + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _cli_cmd() -> list[str]: + cli = os.environ.get("GIT_TESTKIT_CLI", "").strip() + if cli: + cli_path = Path(cli) + if not cli_path.is_absolute(): + cli_path = _repo_root() / cli_path + return [str(cli_path)] + return ["go", "run", "./cmd/git-testkit-cli"] + + +def _call(op: str, **payload: Any) -> dict[str, Any]: + request = {"op": op, **payload} + try: + proc = subprocess.run( + _cli_cmd(), + cwd=_repo_root(), + input=json.dumps(request), + text=True, + capture_output=True, + check=False, + timeout=_CLI_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"git-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: + 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}" + ) + + 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 + + +@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) + 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/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..5048c45 --- /dev/null +++ b/testkit/python/samples/smoke_snapshot_flow.py @@ -0,0 +1,31 @@ +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_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) + + 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() 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 + diff --git a/testkit/python/tests/test_specify_conformance.py b/testkit/python/tests/test_specify_conformance.py new file mode 100644 index 0000000..874e71a --- /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 == Path(repo).name + assert snapshot_size > 0 + assert Path(restored).exists() + assert client.get_current_sha(restored) == client.get_current_sha(repo)