Skip to content

Improve testkit robustness and release readiness#1

Merged
bschellenberger2600 merged 13 commits into
mainfrom
chore/repo-quality-hardening
Apr 5, 2026
Merged

Improve testkit robustness and release readiness#1
bschellenberger2600 merged 13 commits into
mainfrom
chore/repo-quality-hardening

Conversation

@bschellenberger2600
Copy link
Copy Markdown
Member

@bschellenberger2600 bschellenberger2600 commented Mar 31, 2026

Summary

  • harden snapshot restoration by rejecting unsafe archive paths and skipping root pseudo-entry when creating tar snapshots
  • replace custom string splitting utilities with standard library parsing for remotes, branch lists, and SHA trimming
  • add baseline CI (go vet + go test) and expand README requirements/common usage patterns for release readiness

Test plan

  • gofmt -w fixtures.go snapshots.go
  • go test ./...
  • go vet ./...

Made with Cursor


Note

Medium Risk
Touching snapshot restore and path handling can break existing tests or introduce regressions in file extraction behavior, though changes add explicit safety checks and new test coverage. CI/toolchain updates are low risk but may surface new failures on older Go versions.

Overview
Improves release readiness by adding a GitHub Actions CI workflow that runs go vet and go test on PRs and main, and by expanding README.md plus a new DEVELOPER_GUIDE.md with usage and contribution/release guidance.

Hardens snapshot creation/restoration by skipping the tar root pseudo-entry, normalizing snapshot names, and preventing path traversal/absolute paths during RestoreSnapshot via safeJoin (with new snapshot safety tests).

Makes git-output parsing more robust by switching GetRemotes, GetBranches, and GetCurrentSHA to standard-library string trimming/splitting, adds coverage for remote URLs containing spaces, introduces new USB-volume fixture helpers (usb_fixtures.go) with tests, and updates go.mod to Go 1.22.

Reviewed by Cursor Bugbot for commit d0c6cb2. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Documentation

    • Expanded README with API overview, quickstart, usage examples
    • Added developer guide with setup, testing, contribution, and release guidance
  • Chores

    • Updated declared Go toolchain version to 1.22
    • Added CI workflow to run vet and tests on PRs and main
  • Bug Fixes

    • Safer snapshot restore/load path handling and normalized snapshot naming
    • More robust parsing of repository metadata output
  • New Features

    • Test utilities for USB-volume fixtures and related helpers
  • Tests

    • Added tests for snapshot safety, name normalization, restore behavior, remotes parsing, and USB fixture utilities

Harden snapshot restore path handling, simplify git output parsing with stdlib helpers, add CI checks, and expand README usage/requirements to improve maintainability and consumer confidence.

Made-with: Cursor
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 31, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added CI workflow and developer documentation; downgraded module Go version; refactored git output parsing; introduced safe path handling and snapshot name normalization with tests; added USB fixture helpers and corresponding tests.

Changes

Cohort / File(s) Summary
CI / Workflow
/.github/workflows/ci.yml
New GitHub Actions CI workflow on pushes to main and all PRs; matrix over Go (1.22.x, stable); runs go vet ./... and go test ./....
Documentation
README.md, DEVELOPER_GUIDE.md
Expanded README and added DEVELOPER_GUIDE.md with usage, API overview, quickstart, testing and contribution guidance, and release checklist.
Go version
go.mod
Module Go directive changed from go 1.24.2go 1.22.
Fixtures parsing
fixtures.go
Replaced custom line/whitespace split helpers with strings.TrimSpace/strings.Split/strings.Cut for parsing git remote -v, branches, and SHAs; removed splitLines/splitWhitespace.
Snapshot safety & API tweak
snapshots.go
Added normalizeSnapshotName and safeJoin; normalize "."/.."/root → "snapshot"; skip creating tar entry for "."; use safeJoin for extraction with t.Fatalf on invalid paths; renamed LoadSnapshotFromDisk(..., filepath string)(..., filePath string) and set snapshot name from normalized base.
Snapshot tests
snapshots_test.go
New tests (5) validating safeJoin rejects unsafe paths, snapshot name normalization for "."/..", LoadSnapshotFromDisk base-name behavior, and restore flows.
Fixtures tests
fixtures_test.go
Added TestCreateTestRepo_WithRemotePathContainingSpaces to assert remotes parsing handles remote names/paths with spaces.
USB fixtures
testutil/usb_fixtures.go
New USB volume fixture helpers/types (USBVolumeOptions, USBVolumeConfig), creation/validation helpers (MustUSBVolumeRoot, WriteUSBVolumeConfig, ReadUSBVolumeConfig), layout-dir validation, AssertGitDirAt, and FileURLForPath.
USB fixtures tests
usb_fixtures_test.go
New tests covering USB config read/write, layout-dir validation (reject traversal/absolute), parsing error cases, FileURLForPath, and AssertGitDirAt.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I dug a CI burrow, tidy and bright,
Wrote guides by day and tests by night,
I guard snapshot paths from every blight,
Normalize names, and keep restores tight,
Hooray — the repo hops with delight! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly reflects the main focus of the changeset: hardening snapshot robustness (path traversal prevention, name normalization) and preparing the project for release (CI workflow, go.mod update, documentation).
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/repo-quality-hardening

Comment @coderabbitai help to get the list of available commands and usage tips.

Improve README onboarding for test usage and add a developer guide covering testing, contribution expectations, and maintainer release workflow.

Made-with: Cursor
@bschellenberger2600
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 31, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Parse `git remote -v` output without truncating whitespace-containing URLs and add a regression test to lock in behavior for local remotes with spaced paths.

Made-with: Cursor
Provide reusable helpers for creating .git-fire volume roots, reading/writing marker config, asserting bare/non-bare git destinations, and file URL conversion.
@bschellenberger2600
Copy link
Copy Markdown
Member Author

@cursor bugbot review

@cursor
Copy link
Copy Markdown

cursor Bot commented Apr 5, 2026

Implemented proactive fixes for the bugbot review context and pushed them to the PR branch.

What I changed

  • Hardened snapshot restore root handling in snapshots.go:
    • RestoreSnapshot now validates snapshot.name via safeJoin before creating restore directory.
    • This prevents crafted snapshot names from escaping temp restore roots.
  • Improved load safety behavior:
    • LoadSnapshotFromDisk now sets snapshot name to filepath.Base(filePath) (safe, predictable restore dir naming), with a fallback.
  • Added regression tests in new snapshots_test.go:
    • Verifies unsafe path inputs are rejected by safeJoin (for both snapshot-name and archive-entry style cases).
    • Verifies LoadSnapshotFromDisk uses basename and restore directory matches that basename.

Validation

  • gofmt -w snapshots.go snapshots_test.go
  • go test ./...
  • go vet ./...

Git actions completed

  • Commit: 53832d7
    Message: Harden snapshot restore root and add security regressions
  • Pushed to: origin/chore/repo-quality-hardening with upstream tracking set.

I also attempted to update the PR body with the new scope, but PR body editing is restricted because it is currently non-agent-managed; branch and PR are updated with the new commit and ready for bugbot/CI re-evaluation.

Open in Web Open in Cursor 

Co-authored-by: Ben Schellenberger <TBRX103@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@DEVELOPER_GUIDE.md`:
- Around line 20-24: Update the clone example in DEVELOPER_GUIDE.md to use an
HTTPS URL (or show both HTTPS and SSH options) so first-time contributors
without SSH keys can clone easily; replace the existing "git clone
git@github.com:git-fire/git-testkit.git" line with an HTTPS variant "git clone
https://github.com/git-fire/git-testkit.git" (or include both lines labeled
"HTTPS" and "SSH") and keep the subsequent "cd git-testkit" and "go test ./..."
instructions unchanged.

In `@fixtures.go`:
- Around line 202-210: The parser that iterates over lines and uses
strings.Fields incorrectly splits remotes with spaces; update the loop that
builds remotes (the code using variables name, url and map remotes) to split
each non-empty line on the first tab only (e.g., strings.SplitN(line, "\t", 2)),
then extract name = fields[0] and raw = fields[1]; remove the trailing mode
suffix such as " (fetch)" or " (push)" with strings.TrimSuffix or by trimming
the last token (or strings.TrimSpace and strings.TrimSuffix for both modes) to
obtain url, and then assign remotes[name] = url. Ensure you skip lines that
don't contain a tab or lack a second field.

In `@snapshots.go`:
- Around line 97-100: Snapshot names are normalized in LoadSnapshotFromDisk but
not in SnapshotRepo, causing RestoreSnapshot's safeJoin(tmpDir, snapshot.name)
to reject names like "."; extract a shared normalizeSnapshotName(name string)
string (or similar) that applies the same rules used in LoadSnapshotFromDisk
(e.g., map "." and "/" to "snapshot" and otherwise sanitize via filepath.Base or
the current replacement logic) and call this function from both SnapshotRepo
(where name is set from filepath.Base(repoPath)) and LoadSnapshotFromDisk before
storing or returning snapshot.name so RestoreSnapshot and safeJoin always
receive a normalized, safe name.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1efe036b-9ddc-4f7e-8905-650be3e4df52

📥 Commits

Reviewing files that changed from the base of the PR and between 39100e1 and 53832d7.

📒 Files selected for processing (7)
  • .github/workflows/ci.yml
  • DEVELOPER_GUIDE.md
  • README.md
  • fixtures.go
  • go.mod
  • snapshots.go
  • snapshots_test.go

Comment thread DEVELOPER_GUIDE.md
Comment thread fixtures.go Outdated
Comment thread snapshots.go
- DEVELOPER_GUIDE: document HTTPS clone first, SSH as optional
- GetRemotes: parse git remote -v by tab; strip (fetch)/(push) so URLs with spaces work
- snapshots: shared normalizeSnapshotName for SnapshotRepo and LoadSnapshotFromDisk
- Add regression test for SnapshotRepo(".") after chdir into repo
@bschellenberger2600
Copy link
Copy Markdown
Member Author

bugbot run

Validate layout_dir stays relative under the fixture root, surface
invalid schema_version when parsing .git-fire, and build RFC 8089-style
file URLs on Windows drive-letter paths. Tighten FileURLForPath test.

Made-with: Cursor
Extract validateFixtureLayoutDir and readUSBVolumeConfigBytes so reads
reject bad layout_dir, invalid schema_version, and bad or empty
created_at. Normalize non-empty layout_dir on read. Add table tests
for validation and parse errors.

Made-with: Cursor
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: normalizeSnapshotName misses the ".." edge case
    • normalizeSnapshotName now maps ".." (including inputs like foo/..) to "snapshot", and a focused test was added to verify the behavior.
Preview (d088c0835f)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -1,0 +1,30 @@
+name: CI
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        go-version: ["1.22.x", "stable"]
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: ${{ matrix.go-version }}
+
+      - name: Run vet
+        run: go vet ./...
+
+      - name: Run tests
+        run: go test ./...

diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md
new file mode 100644
--- /dev/null
+++ b/DEVELOPER_GUIDE.md
@@ -1,0 +1,132 @@
+# Developer Guide
+
+This guide covers how to use `git-testkit`, contribute safely, run checks locally, and maintain releases.
+
+## Audience
+
+- **Users** writing tests that need real git repositories.
+- **Contributors** making code/docs changes.
+- **Maintainers/admins** preparing releases and keeping CI healthy.
+
+## Local setup
+
+Requirements:
+
+- Go 1.22+
+- `git` on `PATH`
+
+Clone and verify:
+
+```bash
+# HTTPS (no SSH keys required)
+git clone https://github.com/git-fire/git-testkit.git
+
+# or SSH
+# git clone git@github.com:git-fire/git-testkit.git
+
+cd git-testkit
+go test ./...
+```
+
+## Project structure
+
+- `fixtures.go`: base repo creation and command helpers.
+- `scenarios.go`: fluent scenario builder and predefined multi-repo scenarios.
+- `snapshots.go`: snapshot/restore utilities for expensive test setup reuse.
+- `fixtures_test.go`: external package tests (`testutil_test`) that validate public API usage.
+- `scenarios_test.go`: package-internal tests for scenario/snapshot behavior.
+
+## Design principles
+
+- Prefer real git commands over mocks for behavior confidence.
+- Keep helper APIs composable and minimal.
+- Fail fast in setup helpers (`t.Fatalf`) so fixture errors are obvious.
+- Keep tests isolated using `t.TempDir()`.
+
+## Testing and quality checks
+
+Run before opening a PR:
+
+```bash
+gofmt -w *.go
+go vet ./...
+go test ./...
+```
+
+Optional:
+
+```bash
+go test -short ./...
+```
+
+CI mirrors the required checks (`go vet` and `go test`) on pull requests.
+
+## Usage guidance for library consumers
+
+- Prefer scenario builders for integration-style tests with remotes/worktrees.
+- Prefer base fixtures for small unit/integration tests that only need one repo.
+- Keep assertions close to setup; helper failures already include command output.
+- Use snapshots for expensive setups that are reused across subtests.
+
+Common usage split:
+
+- `CreateTestRepo`: fast setup for single-repo state.
+- `NewScenario`: multi-repo topologies and fluent setup.
+- `SnapshotRepo`/`RestoreSnapshot`: performance optimization for repeated expensive setups.
+
+## Adding new helpers
+
+When adding new exported helpers:
+
+- Add or update tests in `fixtures_test.go` and/or `scenarios_test.go`.
+- Add usage notes/examples to `README.md` when the helper is user-facing.
+- Keep function names explicit and test-focused (avoid generic utility names).
+
+## Snapshot safety expectations
+
+- Snapshot restore must never write outside the restore root.
+- Avoid introducing behavior that can restore unsupported file types silently.
+- Keep snapshot behavior deterministic for repeatable tests.
+
+## Pull request guidance
+
+- Keep PRs focused and small when possible.
+- Include a clear "why" in the commit/PR description.
+- Include a short test plan with commands you ran locally.
+- Prefer additive, backward-compatible changes for existing exported helpers.
+
+Suggested PR checklist:
+
+- [ ] public API changes documented in `README.md`
+- [ ] tests added/updated for behavior changes
+- [ ] `gofmt`, `go vet`, and `go test` all pass locally
+- [ ] no unrelated refactors mixed into functional changes
+
+## Maintainer/admin workflow
+
+### Branch and PR flow
+
+1. Create a feature branch from `main`.
+2. Commit focused changes with clear commit messages.
+3. Open a PR with summary and test plan.
+4. Merge only after CI is green.
+
+### Release flow
+
+1. Verify `main` has passed CI.
+2. Confirm `README.md` and this guide reflect current API/behavior.
+3. Pull release notes from merged PRs (user-visible changes first).
+4. Create and push a version tag.
+5. Publish a GitHub release with:
+   - notable changes
+   - compatibility notes (for example, minimum Go version)
+   - migration notes when behavior changed
+
+## Release notes checklist
+
+Before cutting a release:
+
+- Ensure CI is green on `main`.
+- Summarize user-visible changes (new helpers, behavior changes, compatibility notes).
+- Call out any minimum Go version changes.
+- Verify examples in `README.md` still compile conceptually with current API.

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -2,18 +2,57 @@
 
 `git-testkit` provides helpers for writing Go tests that exercise real Git repositories.
 
+## Why use this
+
+- Exercise real git behavior instead of mocking command output.
+- Build common repo states quickly (dirty trees, detached HEAD, diverged remotes, worktrees).
+- Reuse expensive setups across tests with in-memory snapshots.
+
 ## Install
 
 ```bash
 go get github.com/git-fire/git-testkit

+## Requirements
+
+- git must be installed and available on PATH
+- Go 1.22+
+

What it includes

  • Repository fixtures (CreateTestRepo, CreateBareRemote, RunGitCmd)
  • Scenario builders for common multi-repo states (NewScenario, conflict/worktree helpers)
  • Snapshot helpers for capturing and restoring repository state in tests

+## API overview
+
+- CreateTestRepo(t, RepoOptions) creates a real repository with optional files/remotes/branches.
+- CreateBareRemote(t, name) creates a bare repository for remote testing.
+- NewScenario(t) returns a fluent builder for multi-repo test topologies.
+- SnapshotRepo(t, path) and RestoreSnapshot(t, snap) speed up repeated fixture setup.
+- IsDirty, GetCurrentSHA, GetBranches, GetRemotes provide common assertions/helpers.
+
+## Quickstart for using in tests
+
+1. Create fixture repos with CreateTestRepo or NewScenario.
+2. Apply setup operations with fluent helpers (AddFile, Commit, WithRemote, Push).
+3. Run your code under test against the real repository paths.
+4. Assert with helper methods (IsDirty, GetCurrentSHA, GetBranches).
+
+Minimal test flow:
+
+```go
+func TestMyGitBehavior(t *testing.T) {

  • repoPath := testutil.CreateTestRepo(t, testutil.RepoOptions{Name: "subject"})
  • testutil.RunGitCmd(t, repoPath, "checkout", "-b", "feature")
  • // call your package functions here
  • if testutil.IsDirty(t, repoPath) {
  •   t.Fatal("repo should be clean")
    
  • }
    +}
    +```

Example

@@ -34,3 +73,50 @@
	}
}

+## Cleanup behavior
+
+- All helper-created repositories use t.TempDir().
+- Repos/worktrees are automatically removed by Go's test framework at test completion.
+- As with any temp directories, force-killed test processes may leave files behind.
+
+## Common patterns
+
+### Build a conflict scenario
+
+```go
+func TestConflictFlow(t *testing.T) {

  • _, local, _ := testutil.CreateConflictScenario(t)
  • // Exercise your logic against a real diverged local clone.
  • testutil.RunGitCmd(t, local.Path(), "status")
    +}
    +```

+### Snapshot expensive setup
+
+```go
+func TestUsingSnapshot(t *testing.T) {

  • _, repo := testutil.CreateLargeRepoScenario(t, 20, 10)
  • snap := testutil.SnapshotRepo(t, repo.Path())
  • clonePath := testutil.RestoreSnapshot(t, snap)
  • // Use clonePath in assertions without rebuilding the fixture each time.
  • testutil.RunGitCmd(t, clonePath, "status")
    +}
    +```

+## Notes
+
+- Snapshots are intended for deterministic test fixtures and only restore regular files/directories.
+- Helpers fail tests immediately (t.Fatalf) when git commands fail, so errors surface close to setup code.
+- When tests build large repo graphs repeatedly, prefer snapshot/restore to reduce total runtime.
+
+## Developer docs
+
+- See DEVELOPER_GUIDE.md for:

    • testing and quality gates
    • usage guidance for library consumers
    • administration/maintenance and release workflow
    • contribution process and PR expectations

diff --git a/fixtures.go b/fixtures.go
--- a/fixtures.go
+++ b/fixtures.go
@@ -4,6 +4,7 @@
"os"
"os/exec"
"path/filepath"

  • "strings"
    "testing"
    )

@@ -193,71 +194,32 @@
// "origin /path/to/remote (push)"
remotes := make(map[string]string)

  • lines := string(output)
  • lines := strings.TrimSpace(string(output))
    if lines == "" {
    return remotes
    }
  • // Simple parsing - just extract remote names
  • // Full parsing not needed for tests
  • for _, line := range splitLines(lines) {
  • for _, line := range strings.Split(lines, "\n") {
  •   line = strings.TrimSpace(line)
      if line == "" {
      	continue
      }
    
  •   // Just check if "origin" appears in the line
    
  •   // Good enough for test validation
    
  •   if len(line) > 0 {
    
  •   	// Extract first word (remote name)
    
  •   	parts := splitWhitespace(line)
    
  •   	if len(parts) >= 2 {
    
  •   		name := parts[0]
    
  •   		url := parts[1]
    
  •   		remotes[name] = url
    
  •   	}
    
  •   name, rest, ok := strings.Cut(line, "\t")
    
  •   if !ok {
    
  •   	continue
      }
    
  •   name = strings.TrimSpace(name)
    
  •   rest = strings.TrimSpace(rest)
    
  •   rest = strings.TrimSuffix(rest, " (fetch)")
    
  •   rest = strings.TrimSuffix(rest, " (push)")
    
  •   if name != "" && rest != "" {
    
  •   	remotes[name] = rest
    
  •   }
    

    }

    return remotes
    }

-// Helper: split by newlines
-func splitLines(s string) []string {

  • var lines []string
  • current := ""
  • for _, ch := range s {
  •   if ch == '\n' {
    
  •   	lines = append(lines, current)
    
  •   	current = ""
    
  •   } else {
    
  •   	current += string(ch)
    
  •   }
    
  • }
  • if current != "" {
  •   lines = append(lines, current)
    
  • }
  • return lines
    -}

-// Helper: split by whitespace/tabs
-func splitWhitespace(s string) []string {

  • var parts []string
  • current := ""
  • for _, ch := range s {
  •   if ch == ' ' || ch == '\t' {
    
  •   	if current != "" {
    
  •   		parts = append(parts, current)
    
  •   		current = ""
    
  •   	}
    
  •   } else {
    
  •   	current += string(ch)
    
  •   }
    
  • }
  • if current != "" {
  •   parts = append(parts, current)
    
  • }
  • return parts
    -}

// 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) {
@@ -277,13 +239,7 @@
t.Fatalf("Failed to get current SHA: %v", err)
}

  • sha := string(output)
  • // Trim newline
  • if len(sha) > 0 && sha[len(sha)-1] == '\n' {
  •   sha = sha[:len(sha)-1]
    
  • }
  • return sha
  • return strings.TrimSpace(string(output))
    }

// GetBranches returns all branches in the repo
@@ -298,7 +254,7 @@
t.Fatalf("Failed to get branches: %v", err)
}

  • branches := splitLines(string(output))
  • branches := strings.Split(strings.TrimSpace(string(output)), "\n")

    // Filter out empty lines
    var result []string

diff --git a/go.mod b/go.mod
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,3 @@
module github.com/git-fire/git-testkit

-go 1.24.2
+go 1.22

diff --git a/snapshots.go b/snapshots.go
--- a/snapshots.go
+++ b/snapshots.go
@@ -11,6 +11,14 @@
"testing"
)

+func normalizeSnapshotName(path string) string {

  • name := filepath.Base(path)
  • if name == "." || name == ".." || name == string(filepath.Separator) {
  •   return "snapshot"
    
  • }
  • return name
    +}

// Snapshot represents a saved state of a git repository
type Snapshot struct {
name string
@@ -43,6 +51,9 @@
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}

  •   if relPath == "." {
    
  •   	return nil
    
  •   }
      header.Name = relPath
    
      // Write header
    

@@ -79,7 +90,7 @@
}

return &Snapshot{
  •   name:    filepath.Base(repoPath),
    
  •   name:    normalizeSnapshotName(repoPath),
      tarball: buf.Bytes(),
    

    }
    }
    @@ -91,7 +102,10 @@

    // Create temp directory for restoration
    tmpDir := t.TempDir()

  • restorePath := filepath.Join(tmpDir, snapshot.name)
  • restorePath, err := safeJoin(tmpDir, snapshot.name)

  • if err != nil {

  •   t.Fatalf("Invalid snapshot name %q: %v", snapshot.name, err)
    
  • }

    if err := os.MkdirAll(restorePath, 0755); err != nil {
    t.Fatalf("Failed to create restore directory: %v", err)
    @@ -117,7 +131,10 @@
    }

    // Construct full path
    
  •   targetPath := filepath.Join(restorePath, header.Name)
    
  •   targetPath, err := safeJoin(restorePath, header.Name)
    
  •   if err != nil {
    
  •   	t.Fatalf("Invalid snapshot path %q: %v", header.Name, err)
    
  •   }
    
      // Handle different file types
      switch header.Typeflag {
    

@@ -154,6 +171,30 @@
return restorePath
}

+func safeJoin(base, name string) (string, error) {

  • cleanName := filepath.Clean(name)
  • if cleanName == "." || cleanName == string(filepath.Separator) {
  •   return "", fmt.Errorf("empty or root path is not allowed")
    
  • }
  • if filepath.IsAbs(cleanName) {
  •   return "", fmt.Errorf("absolute paths are not allowed")
    
  • }
  • if cleanName == ".." || len(cleanName) >= 3 && cleanName[:3] == ".."+string(filepath.Separator) {
  •   return "", fmt.Errorf("path traversal is not allowed")
    
  • }
  • target := filepath.Join(base, cleanName)
  • rel, err := filepath.Rel(base, target)
  • if err != nil {
  •   return "", err
    
  • }
  • if rel == ".." || len(rel) >= 3 && rel[:3] == ".."+string(filepath.Separator) {
  •   return "", fmt.Errorf("resolved path escapes restore directory")
    
  • }
  • return target, nil
    +}

// SnapshotSize returns the size of the snapshot in bytes
func (s *Snapshot) Size() int {
return len(s.tarball)
@@ -174,16 +215,16 @@
}

// LoadSnapshotFromDisk loads a snapshot from a file
-func LoadSnapshotFromDisk(t *testing.T, filepath string) *Snapshot {
+func LoadSnapshotFromDisk(t *testing.T, filePath string) *Snapshot {
t.Helper()

  • data, err := os.ReadFile(filepath)
  • data, err := os.ReadFile(filePath)
    if err != nil {
    t.Fatalf("Failed to load snapshot from disk: %v", err)
    }

    return &Snapshot{

  •   name:    filepath,
    
  •   name:    normalizeSnapshotName(filePath),
      tarball: data,
    
    }
    }

diff --git a/snapshots_test.go b/snapshots_test.go
new file mode 100644
--- /dev/null
+++ b/snapshots_test.go
@@ -1,0 +1,118 @@
+package testutil
+
+import (

  • "os"
  • "path/filepath"
  • "testing"
    +)

+func TestRestoreSnapshotRejectsUnsafeSnapshotNames(t *testing.T) {

  • tests := []struct {
  •   name    string
    
  •   base    string
    
  •   joinArg string
    
  • }{
  •   {
    
  •   	name:    "path traversal name",
    
  •   	base:    filepath.Join("tmp", "root"),
    
  •   	joinArg: "../escape",
    
  •   },
    
  •   {
    
  •   	name:    "absolute name",
    
  •   	base:    filepath.Join("tmp", "root"),
    
  •   	joinArg: string(filepath.Separator) + "tmp/escape",
    
  •   },
    
  • }
  • for _, tt := range tests {
  •   tt := tt
    
  •   t.Run(tt.name, func(t *testing.T) {
    
  •   	if _, err := safeJoin(tt.base, tt.joinArg); err == nil {
    
  •   		t.Fatalf("expected safeJoin to reject %q", tt.joinArg)
    
  •   	}
    
  •   })
    
  • }
    +}

+func TestRestoreSnapshotRejectsUnsafeArchivePaths(t *testing.T) {

  • tests := []struct {
  •   name    string
    
  •   base    string
    
  •   joinArg string
    
  • }{
  •   {
    
  •   	name:    "relative traversal path",
    
  •   	base:    filepath.Join("tmp", "root"),
    
  •   	joinArg: "../escape.txt",
    
  •   },
    
  •   {
    
  •   	name:    "absolute path",
    
  •   	base:    filepath.Join("tmp", "root"),
    
  •   	joinArg: string(filepath.Separator) + "tmp/escape.txt",
    
  •   },
    
  • }
  • for _, tt := range tests {
  •   tt := tt
    
  •   t.Run(tt.name, func(t *testing.T) {
    
  •   	if _, err := safeJoin(tt.base, tt.joinArg); err == nil {
    
  •   		t.Fatalf("expected safeJoin to reject %q", tt.joinArg)
    
  •   	}
    
  •   })
    
  • }
    +}

+func TestSnapshotRepoNormalizesTrailingDotPath(t *testing.T) {

  • _, repo := CreateCleanRepoScenario(t)
  • oldWd, err := os.Getwd()
  • if err != nil {
  •   t.Fatal(err)
    
  • }
  • t.Cleanup(func() { _ = os.Chdir(oldWd) })
  • if err := os.Chdir(repo.path); err != nil {
  •   t.Fatal(err)
    
  • }
  • snap := SnapshotRepo(t, ".")
  • if got, want := snap.Name(), "snapshot"; got != want {
  •   t.Fatalf("expected snapshot name %q, got %q", want, got)
    
  • }
  • _ = RestoreSnapshot(t, snap)
    +}

+func TestNormalizeSnapshotNameHandlesDotDot(t *testing.T) {

  • tests := []struct {
  •   input string
    
  •   want  string
    
  • }{
  •   {input: "..", want: "snapshot"},
    
  •   {input: filepath.Join("foo", ".."), want: "snapshot"},
    
  • }
  • for _, tt := range tests {
  •   if got := normalizeSnapshotName(tt.input); got != tt.want {
    
  •   	t.Fatalf("normalizeSnapshotName(%q) = %q, want %q", tt.input, got, tt.want)
    
  •   }
    
  • }
    +}

+func TestLoadSnapshotFromDiskUsesBaseName(t *testing.T) {

  • _, repo := CreateCleanRepoScenario(t)
  • snapshot := SnapshotRepo(t, repo.path)
  • snapshotPath := filepath.Join(t.TempDir(), "nested", "snapshot.tar.gz")
  • if err := os.MkdirAll(filepath.Dir(snapshotPath), 0755); err != nil {
  •   t.Fatalf("failed to create snapshot dir: %v", err)
    
  • }
  • SaveSnapshotToDisk(t, snapshot, snapshotPath)
  • loaded := LoadSnapshotFromDisk(t, snapshotPath)
  • if got, want := loaded.Name(), filepath.Base(snapshotPath); got != want {
  •   t.Fatalf("expected loaded snapshot name %q, got %q", want, got)
    
  • }
  • restoredPath := RestoreSnapshot(t, loaded)
  • if got, want := filepath.Base(restoredPath), loaded.Name(); got != want {
  •   t.Fatalf("expected restore dir base %q, got %q", want, got)
    
  • }
    +}

</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-f43618ee-6c24-4588-a599-50bfb664c8bf">here</a>.</sub>

</details>

Comment thread snapshots.go
bschellenberger2600 and others added 3 commits April 5, 2026 16:13
…aths

Resolve GetRemotes conflict: keep tab-or-space parsing and (fetch)/(push)
trimming for paths with spaces; trim remote names and URLs consistently.

Made-with: Cursor
Fix GetRemotes parsing for URLs with spaces
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
usb_fixtures.go (1)

16-27: Align layout_dir validation semantics with snapshot path hardening.

This validator currently allows ".", while the snapshot-side safeJoin policy rejects root-like/empty targets. Keeping both checks aligned will reduce security-rule drift and avoid ambiguous “root layout” behavior.

♻️ Minimal alignment change
 func validateFixtureLayoutDir(layoutDir string) error {
 	if layoutDir == "" {
 		return nil
 	}
 	clean := filepath.Clean(layoutDir)
+	if clean == "." || clean == string(filepath.Separator) {
+		return fmt.Errorf("must be relative to fixture root: %q", layoutDir)
+	}
 	if filepath.IsAbs(clean) {
 		return fmt.Errorf("must be relative to fixture root: %q", layoutDir)
 	}
 	if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
 		return fmt.Errorf("must be relative to fixture root: %q", layoutDir)
 	}
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@usb_fixtures.go` around lines 16 - 27, The validator validateFixtureLayoutDir
currently returns nil for an empty layoutDir and allows "." but snapshot
safeJoin rejects root-like/empty targets; change validateFixtureLayoutDir so
that an empty string or a cleaned value of "." (and root-like variants like ".."
handled already) are treated as invalid and return the same error ("must be
relative to fixture root: %q", layoutDir). Concretely, remove the early return
for layoutDir == "" and add a check that if clean == "." or clean == "" (or
matches "."+Sep variants) then return the error; keep the existing absolute and
parent-dir checks unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@usb_fixtures.go`:
- Around line 114-120: The parser in readUSBVolumeConfigBytes accepts zero or
negative schema_version values; update the "schema_version" case (where
cfg.SchemaVersion is set) to validate that the parsed integer n is > 0 and
return an error if not (e.g., treat 0 or negative as invalid with a descriptive
fmt.Errorf), so invalid configs are rejected rather than accepted.

---

Nitpick comments:
In `@usb_fixtures.go`:
- Around line 16-27: The validator validateFixtureLayoutDir currently returns
nil for an empty layoutDir and allows "." but snapshot safeJoin rejects
root-like/empty targets; change validateFixtureLayoutDir so that an empty string
or a cleaned value of "." (and root-like variants like ".." handled already) are
treated as invalid and return the same error ("must be relative to fixture root:
%q", layoutDir). Concretely, remove the early return for layoutDir == "" and add
a check that if clean == "." or clean == "" (or matches "."+Sep variants) then
return the error; keep the existing absolute and parent-dir checks unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c18d7cc8-c680-4098-850e-83073b2446a7

📥 Commits

Reviewing files that changed from the base of the PR and between d088c08 and 4b81b77.

📒 Files selected for processing (4)
  • fixtures.go
  • fixtures_test.go
  • usb_fixtures.go
  • usb_fixtures_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • fixtures.go

Comment thread usb_fixtures.go
Comment on lines +114 to +120
case "schema_version":
n, err := strconv.Atoi(val)
if err != nil {
return cfg, fmt.Errorf("invalid schema_version %q: %w", val, err)
}
cfg.SchemaVersion = n
case "layout_dir":
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reject non-positive schema_version during parse.

readUSBVolumeConfigBytes currently accepts 0 and negative versions. That allows invalid config to parse as “valid” and weakens corruption detection.

🔧 Proposed fix
 		case "schema_version":
 			n, err := strconv.Atoi(val)
 			if err != nil {
 				return cfg, fmt.Errorf("invalid schema_version %q: %w", val, err)
 			}
+			if n <= 0 {
+				return cfg, fmt.Errorf("invalid schema_version %q: must be > 0", val)
+			}
 			cfg.SchemaVersion = n
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "schema_version":
n, err := strconv.Atoi(val)
if err != nil {
return cfg, fmt.Errorf("invalid schema_version %q: %w", val, err)
}
cfg.SchemaVersion = n
case "layout_dir":
case "schema_version":
n, err := strconv.Atoi(val)
if err != nil {
return cfg, fmt.Errorf("invalid schema_version %q: %w", val, err)
}
if n <= 0 {
return cfg, fmt.Errorf("invalid schema_version %q: must be > 0", val)
}
cfg.SchemaVersion = n
case "layout_dir":
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@usb_fixtures.go` around lines 114 - 120, The parser in
readUSBVolumeConfigBytes accepts zero or negative schema_version values; update
the "schema_version" case (where cfg.SchemaVersion is set) to validate that the
parsed integer n is > 0 and return an error if not (e.g., treat 0 or negative as
invalid with a descriptive fmt.Errorf), so invalid configs are rejected rather
than accepted.

Comment thread usb_fixtures.go
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Missing parentheses in security-sensitive path traversal check
    • Replaced both precedence-reliant traversal checks in safeJoin with explicit strings.HasPrefix logic matching the safer pattern used elsewhere.
  • ✅ Fixed: Asymmetric encode/decode pair corrupts special-character values
    • Made decoding symmetric with %q output by unquoting string fields via strconv.Unquote, preserving escaped backslashes and quotes on round-trip.
Preview (d0c6cb2998)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -1,0 +1,30 @@
+name: CI
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        go-version: ["1.22.x", "stable"]
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: ${{ matrix.go-version }}
+
+      - name: Run vet
+        run: go vet ./...
+
+      - name: Run tests
+        run: go test ./...

diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md
new file mode 100644
--- /dev/null
+++ b/DEVELOPER_GUIDE.md
@@ -1,0 +1,132 @@
+# Developer Guide
+
+This guide covers how to use `git-testkit`, contribute safely, run checks locally, and maintain releases.
+
+## Audience
+
+- **Users** writing tests that need real git repositories.
+- **Contributors** making code/docs changes.
+- **Maintainers/admins** preparing releases and keeping CI healthy.
+
+## Local setup
+
+Requirements:
+
+- Go 1.22+
+- `git` on `PATH`
+
+Clone and verify:
+
+```bash
+# HTTPS (no SSH keys required)
+git clone https://github.com/git-fire/git-testkit.git
+
+# or SSH
+# git clone git@github.com:git-fire/git-testkit.git
+
+cd git-testkit
+go test ./...
+```
+
+## Project structure
+
+- `fixtures.go`: base repo creation and command helpers.
+- `scenarios.go`: fluent scenario builder and predefined multi-repo scenarios.
+- `snapshots.go`: snapshot/restore utilities for expensive test setup reuse.
+- `fixtures_test.go`: external package tests (`testutil_test`) that validate public API usage.
+- `scenarios_test.go`: package-internal tests for scenario/snapshot behavior.
+
+## Design principles
+
+- Prefer real git commands over mocks for behavior confidence.
+- Keep helper APIs composable and minimal.
+- Fail fast in setup helpers (`t.Fatalf`) so fixture errors are obvious.
+- Keep tests isolated using `t.TempDir()`.
+
+## Testing and quality checks
+
+Run before opening a PR:
+
+```bash
+gofmt -w *.go
+go vet ./...
+go test ./...
+```
+
+Optional:
+
+```bash
+go test -short ./...
+```
+
+CI mirrors the required checks (`go vet` and `go test`) on pull requests.
+
+## Usage guidance for library consumers
+
+- Prefer scenario builders for integration-style tests with remotes/worktrees.
+- Prefer base fixtures for small unit/integration tests that only need one repo.
+- Keep assertions close to setup; helper failures already include command output.
+- Use snapshots for expensive setups that are reused across subtests.
+
+Common usage split:
+
+- `CreateTestRepo`: fast setup for single-repo state.
+- `NewScenario`: multi-repo topologies and fluent setup.
+- `SnapshotRepo`/`RestoreSnapshot`: performance optimization for repeated expensive setups.
+
+## Adding new helpers
+
+When adding new exported helpers:
+
+- Add or update tests in `fixtures_test.go` and/or `scenarios_test.go`.
+- Add usage notes/examples to `README.md` when the helper is user-facing.
+- Keep function names explicit and test-focused (avoid generic utility names).
+
+## Snapshot safety expectations
+
+- Snapshot restore must never write outside the restore root.
+- Avoid introducing behavior that can restore unsupported file types silently.
+- Keep snapshot behavior deterministic for repeatable tests.
+
+## Pull request guidance
+
+- Keep PRs focused and small when possible.
+- Include a clear "why" in the commit/PR description.
+- Include a short test plan with commands you ran locally.
+- Prefer additive, backward-compatible changes for existing exported helpers.
+
+Suggested PR checklist:
+
+- [ ] public API changes documented in `README.md`
+- [ ] tests added/updated for behavior changes
+- [ ] `gofmt`, `go vet`, and `go test` all pass locally
+- [ ] no unrelated refactors mixed into functional changes
+
+## Maintainer/admin workflow
+
+### Branch and PR flow
+
+1. Create a feature branch from `main`.
+2. Commit focused changes with clear commit messages.
+3. Open a PR with summary and test plan.
+4. Merge only after CI is green.
+
+### Release flow
+
+1. Verify `main` has passed CI.
+2. Confirm `README.md` and this guide reflect current API/behavior.
+3. Pull release notes from merged PRs (user-visible changes first).
+4. Create and push a version tag.
+5. Publish a GitHub release with:
+   - notable changes
+   - compatibility notes (for example, minimum Go version)
+   - migration notes when behavior changed
+
+## Release notes checklist
+
+Before cutting a release:
+
+- Ensure CI is green on `main`.
+- Summarize user-visible changes (new helpers, behavior changes, compatibility notes).
+- Call out any minimum Go version changes.
+- Verify examples in `README.md` still compile conceptually with current API.

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -2,18 +2,57 @@
 
 `git-testkit` provides helpers for writing Go tests that exercise real Git repositories.
 
+## Why use this
+
+- Exercise real git behavior instead of mocking command output.
+- Build common repo states quickly (dirty trees, detached HEAD, diverged remotes, worktrees).
+- Reuse expensive setups across tests with in-memory snapshots.
+
 ## Install
 
 ```bash
 go get github.com/git-fire/git-testkit

+## Requirements
+
+- git must be installed and available on PATH
+- Go 1.22+
+

What it includes

  • Repository fixtures (CreateTestRepo, CreateBareRemote, RunGitCmd)
  • Scenario builders for common multi-repo states (NewScenario, conflict/worktree helpers)
  • Snapshot helpers for capturing and restoring repository state in tests

+## API overview
+
+- CreateTestRepo(t, RepoOptions) creates a real repository with optional files/remotes/branches.
+- CreateBareRemote(t, name) creates a bare repository for remote testing.
+- NewScenario(t) returns a fluent builder for multi-repo test topologies.
+- SnapshotRepo(t, path) and RestoreSnapshot(t, snap) speed up repeated fixture setup.
+- IsDirty, GetCurrentSHA, GetBranches, GetRemotes provide common assertions/helpers.
+
+## Quickstart for using in tests
+
+1. Create fixture repos with CreateTestRepo or NewScenario.
+2. Apply setup operations with fluent helpers (AddFile, Commit, WithRemote, Push).
+3. Run your code under test against the real repository paths.
+4. Assert with helper methods (IsDirty, GetCurrentSHA, GetBranches).
+
+Minimal test flow:
+
+```go
+func TestMyGitBehavior(t *testing.T) {

  • repoPath := testutil.CreateTestRepo(t, testutil.RepoOptions{Name: "subject"})
  • testutil.RunGitCmd(t, repoPath, "checkout", "-b", "feature")
  • // call your package functions here
  • if testutil.IsDirty(t, repoPath) {
  •   t.Fatal("repo should be clean")
    
  • }
    +}
    +```

Example

@@ -34,3 +73,50 @@
	}
}

+## Cleanup behavior
+
+- All helper-created repositories use t.TempDir().
+- Repos/worktrees are automatically removed by Go's test framework at test completion.
+- As with any temp directories, force-killed test processes may leave files behind.
+
+## Common patterns
+
+### Build a conflict scenario
+
+```go
+func TestConflictFlow(t *testing.T) {

  • _, local, _ := testutil.CreateConflictScenario(t)
  • // Exercise your logic against a real diverged local clone.
  • testutil.RunGitCmd(t, local.Path(), "status")
    +}
    +```

+### Snapshot expensive setup
+
+```go
+func TestUsingSnapshot(t *testing.T) {

  • _, repo := testutil.CreateLargeRepoScenario(t, 20, 10)
  • snap := testutil.SnapshotRepo(t, repo.Path())
  • clonePath := testutil.RestoreSnapshot(t, snap)
  • // Use clonePath in assertions without rebuilding the fixture each time.
  • testutil.RunGitCmd(t, clonePath, "status")
    +}
    +```

+## Notes
+
+- Snapshots are intended for deterministic test fixtures and only restore regular files/directories.
+- Helpers fail tests immediately (t.Fatalf) when git commands fail, so errors surface close to setup code.
+- When tests build large repo graphs repeatedly, prefer snapshot/restore to reduce total runtime.
+
+## Developer docs
+
+- See DEVELOPER_GUIDE.md for:

    • testing and quality gates
    • usage guidance for library consumers
    • administration/maintenance and release workflow
    • contribution process and PR expectations

diff --git a/fixtures.go b/fixtures.go
--- a/fixtures.go
+++ b/fixtures.go
@@ -4,6 +4,7 @@
"os"
"os/exec"
"path/filepath"

  • "strings"
    "testing"
    )

@@ -193,69 +194,39 @@
// "origin /path/to/remote (push)"
remotes := make(map[string]string)

  • lines := string(output)
  • lines := strings.TrimSpace(string(output))
    if lines == "" {
    return remotes
    }
  • // Simple parsing - just extract remote names
  • // Full parsing not needed for tests
  • for _, line := range splitLines(lines) {
  • for _, line := range strings.Split(lines, "\n") {
  •   line = strings.TrimSpace(line)
      if line == "" {
      	continue
      }
    
  •   // Just check if "origin" appears in the line
    
  •   // Good enough for test validation
    
  •   if len(line) > 0 {
    
  •   	// Extract first word (remote name)
    
  •   	parts := splitWhitespace(line)
    
  •   	if len(parts) >= 2 {
    
  •   		name := parts[0]
    
  •   		url := parts[1]
    
  •   		remotes[name] = url
    
  •   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
      	}
    
  •   	name = strings.TrimSpace(line[:idx])
    
  •   	remainder = strings.TrimSpace(line[idx+1:])
    
  •   } else {
    
  •   	name = strings.TrimSpace(name)
    
  •   	remainder = strings.TrimSpace(remainder)
      }
    
  • }

  • return remotes
    -}

  •   remainder = strings.TrimSuffix(remainder, " (fetch)")
    
  •   remainder = strings.TrimSuffix(remainder, " (push)")
    

-// Helper: split by newlines
-func splitLines(s string) []string {

  • var lines []string
  • current := ""
  • for _, ch := range s {
  •   if ch == '\n' {
    
  •   	lines = append(lines, current)
    
  •   	current = ""
    
  •   } else {
    
  •   	current += string(ch)
    
  •   if name != "" && remainder != "" {
    
  •   	remotes[name] = remainder
      }
    
    }
  • if current != "" {
  •   lines = append(lines, current)
    
  • }
  • return lines
    -}

-// Helper: split by whitespace/tabs
-func splitWhitespace(s string) []string {

  • var parts []string
  • current := ""
  • for _, ch := range s {
  •   if ch == ' ' || ch == '\t' {
    
  •   	if current != "" {
    
  •   		parts = append(parts, current)
    
  •   		current = ""
    
  •   	}
    
  •   } else {
    
  •   	current += string(ch)
    
  •   }
    
  • }
  • if current != "" {
  •   parts = append(parts, current)
    
  • }
  • return parts
  • return remotes
    }

// RunGitCmd runs a git command and fails the test if it errors
@@ -277,13 +248,7 @@
t.Fatalf("Failed to get current SHA: %v", err)
}

  • sha := string(output)
  • // Trim newline
  • if len(sha) > 0 && sha[len(sha)-1] == '\n' {
  •   sha = sha[:len(sha)-1]
    
  • }
  • return sha
  • return strings.TrimSpace(string(output))
    }

// GetBranches returns all branches in the repo
@@ -298,7 +263,7 @@
t.Fatalf("Failed to get branches: %v", err)
}

  • branches := splitLines(string(output))
  • branches := strings.Split(strings.TrimSpace(string(output)), "\n")

    // Filter out empty lines
    var result []string

diff --git a/fixtures_test.go b/fixtures_test.go
--- a/fixtures_test.go
+++ b/fixtures_test.go
@@ -79,6 +79,26 @@
}
}

+func TestCreateTestRepo_WithRemotePathContainingSpaces(t *testing.T) {

  • remotePath := testutil.CreateBareRemote(t, "origin with space")
  • repoPath := testutil.CreateTestRepo(t, testutil.RepoOptions{
  •   Name: "remote-space-repo",
    
  •   Remotes: map[string]string{
    
  •   	"origin": remotePath,
    
  •   },
    
  • })
  • remotes := testutil.GetRemotes(t, repoPath)
  • originURL, exists := remotes["origin"]
  • if !exists {
  •   t.Fatal("Expected 'origin' remote to be configured")
    
  • }
  • if originURL != remotePath {
  •   t.Fatalf("Expected origin URL %q, got %q", remotePath, originURL)
    
  • }
    +}

func TestCreateBareRemote(t *testing.T) {
remotePath := testutil.CreateBareRemote(t, "test-remote")

diff --git a/go.mod b/go.mod
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,3 @@
module github.com/git-fire/git-testkit

-go 1.24.2
+go 1.22

diff --git a/snapshots.go b/snapshots.go
--- a/snapshots.go
+++ b/snapshots.go
@@ -8,9 +8,18 @@
"io"
"os"
"path/filepath"

  • "strings"
    "testing"
    )

+func normalizeSnapshotName(path string) string {

  • name := filepath.Base(path)
  • if name == "." || name == ".." || name == string(filepath.Separator) {
  •   return "snapshot"
    
  • }
  • return name
    +}

// Snapshot represents a saved state of a git repository
type Snapshot struct {
name string
@@ -43,6 +52,9 @@
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}

  •   if relPath == "." {
    
  •   	return nil
    
  •   }
      header.Name = relPath
    
      // Write header
    

@@ -79,7 +91,7 @@
}

return &Snapshot{
  •   name:    filepath.Base(repoPath),
    
  •   name:    normalizeSnapshotName(repoPath),
      tarball: buf.Bytes(),
    

    }
    }
    @@ -91,7 +103,10 @@

    // Create temp directory for restoration
    tmpDir := t.TempDir()

  • restorePath := filepath.Join(tmpDir, snapshot.name)
  • restorePath, err := safeJoin(tmpDir, snapshot.name)

  • if err != nil {

  •   t.Fatalf("Invalid snapshot name %q: %v", snapshot.name, err)
    
  • }

    if err := os.MkdirAll(restorePath, 0755); err != nil {
    t.Fatalf("Failed to create restore directory: %v", err)
    @@ -117,7 +132,10 @@
    }

    // Construct full path
    
  •   targetPath := filepath.Join(restorePath, header.Name)
    
  •   targetPath, err := safeJoin(restorePath, header.Name)
    
  •   if err != nil {
    
  •   	t.Fatalf("Invalid snapshot path %q: %v", header.Name, err)
    
  •   }
    
      // Handle different file types
      switch header.Typeflag {
    

@@ -154,6 +172,30 @@
return restorePath
}

+func safeJoin(base, name string) (string, error) {

  • cleanName := filepath.Clean(name)
  • if cleanName == "." || cleanName == string(filepath.Separator) {
  •   return "", fmt.Errorf("empty or root path is not allowed")
    
  • }
  • if filepath.IsAbs(cleanName) {
  •   return "", fmt.Errorf("absolute paths are not allowed")
    
  • }
  • if cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) {
  •   return "", fmt.Errorf("path traversal is not allowed")
    
  • }
  • target := filepath.Join(base, cleanName)
  • rel, err := filepath.Rel(base, target)
  • if err != nil {
  •   return "", err
    
  • }
  • if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
  •   return "", fmt.Errorf("resolved path escapes restore directory")
    
  • }
  • return target, nil
    +}

// SnapshotSize returns the size of the snapshot in bytes
func (s *Snapshot) Size() int {
return len(s.tarball)
@@ -174,16 +216,16 @@
}

// LoadSnapshotFromDisk loads a snapshot from a file
-func LoadSnapshotFromDisk(t *testing.T, filepath string) *Snapshot {
+func LoadSnapshotFromDisk(t *testing.T, filePath string) *Snapshot {
t.Helper()

  • data, err := os.ReadFile(filepath)
  • data, err := os.ReadFile(filePath)
    if err != nil {
    t.Fatalf("Failed to load snapshot from disk: %v", err)
    }

    return &Snapshot{

  •   name:    filepath,
    
  •   name:    normalizeSnapshotName(filePath),
      tarball: data,
    
    }
    }

diff --git a/snapshots_test.go b/snapshots_test.go
new file mode 100644
--- /dev/null
+++ b/snapshots_test.go
@@ -1,0 +1,118 @@
+package testutil
+
+import (

  • "os"
  • "path/filepath"
  • "testing"
    +)

+func TestRestoreSnapshotRejectsUnsafeSnapshotNames(t *testing.T) {

  • tests := []struct {
  •   name    string
    
  •   base    string
    
  •   joinArg string
    
  • }{
  •   {
    
  •   	name:    "path traversal name",
    
  •   	base:    filepath.Join("tmp", "root"),
    
  •   	joinArg: "../escape",
    
  •   },
    
  •   {
    
  •   	name:    "absolute name",
    
  •   	base:    filepath.Join("tmp", "root"),
    
  •   	joinArg: string(filepath.Separator) + "tmp/escape",
    
  •   },
    
  • }
  • for _, tt := range tests {
  •   tt := tt
    
  •   t.Run(tt.name, func(t *testing.T) {
    
  •   	if _, err := safeJoin(tt.base, tt.joinArg); err == nil {
    
  •   		t.Fatalf("expected safeJoin to reject %q", tt.joinArg)
    
  •   	}
    
  •   })
    
  • }
    +}

+func TestRestoreSnapshotRejectsUnsafeArchivePaths(t *testing.T) {

  • tests := []struct {
  •   name    string
    
  •   base    string
    
  •   joinArg string
    
  • }{
  •   {
    
  •   	name:    "relative traversal path",
    
  •   	base:    filepath.Join("tmp", "root"),
    
  •   	joinArg: "../escape.txt",
    
  •   },
    
  •   {
    
  •   	name:    "absolute path",
    
  •   	base:    filepath.Join("tmp", "root"),
    
  •   	joinArg: string(filepath.Separator) + "tmp/escape.txt",
    
  •   },
    
  • }
  • for _, tt := range tests {
  •   tt := tt
    
  •   t.Run(tt.name, func(t *testing.T) {
    
  •   	if _, err := safeJoin(tt.base, tt.joinArg); err == nil {
    
  •   		t.Fatalf("expected safeJoin to reject %q", tt.joinArg)
    
  •   	}
    
  •   })
    
  • }
    +}

+func TestSnapshotRepoNormalizesTrailingDotPath(t *testing.T) {

  • _, repo := CreateCleanRepoScenario(t)
  • oldWd, err := os.Getwd()
  • if err != nil {
  •   t.Fatal(err)
    
  • }
  • t.Cleanup(func() { _ = os.Chdir(oldWd) })
  • if err := os.Chdir(repo.path); err != nil {
  •   t.Fatal(err)
    
  • }
  • snap := SnapshotRepo(t, ".")
  • if got, want := snap.Name(), "snapshot"; got != want {
  •   t.Fatalf("expected snapshot name %q, got %q", want, got)
    
  • }
  • _ = RestoreSnapshot(t, snap)
    +}

+func TestNormalizeSnapshotNameHandlesDotDot(t *testing.T) {

  • tests := []struct {
  •   input string
    
  •   want  string
    
  • }{
  •   {input: "..", want: "snapshot"},
    
  •   {input: filepath.Join("foo", ".."), want: "snapshot"},
    
  • }
  • for _, tt := range tests {
  •   if got := normalizeSnapshotName(tt.input); got != tt.want {
    
  •   	t.Fatalf("normalizeSnapshotName(%q) = %q, want %q", tt.input, got, tt.want)
    
  •   }
    
  • }
    +}

+func TestLoadSnapshotFromDiskUsesBaseName(t *testing.T) {

  • _, repo := CreateCleanRepoScenario(t)
  • snapshot := SnapshotRepo(t, repo.path)
  • snapshotPath := filepath.Join(t.TempDir(), "nested", "snapshot.tar.gz")
  • if err := os.MkdirAll(filepath.Dir(snapshotPath), 0755); err != nil {
  •   t.Fatalf("failed to create snapshot dir: %v", err)
    
  • }
  • SaveSnapshotToDisk(t, snapshot, snapshotPath)
  • loaded := LoadSnapshotFromDisk(t, snapshotPath)
  • if got, want := loaded.Name(), filepath.Base(snapshotPath); got != want {
  •   t.Fatalf("expected loaded snapshot name %q, got %q", want, got)
    
  • }
  • restoredPath := RestoreSnapshot(t, loaded)
  • if got, want := filepath.Base(restoredPath), loaded.Name(); got != want {
  •   t.Fatalf("expected restore dir base %q, got %q", want, got)
    
  • }
    +}

diff --git a/usb_fixtures.go b/usb_fixtures.go
new file mode 100644
--- /dev/null
+++ b/usb_fixtures.go
@@ -1,0 +1,192 @@
+package testutil
+
+import (

  • "fmt"
  • "net/url"
  • "os"
  • "path/filepath"
  • "strconv"
  • "strings"
  • "testing"
  • "time"
    +)

+// validateFixtureLayoutDir reports whether layoutDir may be joined under a fixture root.
+// Empty layoutDir is allowed (caller may default it).
+func validateFixtureLayoutDir(layoutDir string) error {

  • if layoutDir == "" {
  •   return nil
    
  • }
  • clean := filepath.Clean(layoutDir)
  • if filepath.IsAbs(clean) {
  •   return fmt.Errorf("must be relative to fixture root: %q", layoutDir)
    
  • }
  • if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
  •   return fmt.Errorf("must be relative to fixture root: %q", layoutDir)
    
  • }
  • return nil
    +}

+type USBVolumeOptions struct {

  • LayoutDir string
  • Strategy string
  • CreateReposDir bool
    +}

+type USBVolumeConfig struct {

  • SchemaVersion int
  • LayoutDir string
  • Strategy string
  • CreatedAt time.Time
    +}

+func mustRelativeLayoutDir(t *testing.T, layoutDir string) string {

  • t.Helper()
  • if layoutDir == "" {
  •   return "repos"
    
  • }
  • if err := validateFixtureLayoutDir(layoutDir); err != nil {
  •   t.Fatalf("layout_dir %v", err)
    
  • }
  • return filepath.Clean(layoutDir)
    +}

+func MustUSBVolumeRoot(t *testing.T, opts USBVolumeOptions) string {

  • t.Helper()
  • root := t.TempDir()
  • cfg := USBVolumeConfig{
  •   SchemaVersion: 1,
    
  •   LayoutDir:     mustRelativeLayoutDir(t, opts.LayoutDir),
    
  •   Strategy:      opts.Strategy,
    
  •   CreatedAt:     time.Now().UTC(),
    
  • }
  • if cfg.Strategy == "" {
  •   cfg.Strategy = "git-mirror"
    
  • }
  • WriteUSBVolumeConfig(t, root, cfg)
  • if opts.CreateReposDir {
  •   if err := os.MkdirAll(filepath.Join(root, cfg.LayoutDir), 0o755); err != nil {
    
  •   	t.Fatalf("failed creating repos dir: %v", err)
    
  •   }
    
  • }
  • return root
    +}

+func WriteUSBVolumeConfig(t *testing.T, root string, cfg USBVolumeConfig) {

  • t.Helper()
  • if cfg.SchemaVersion <= 0 {
  •   cfg.SchemaVersion = 1
    
  • }
  • cfg.LayoutDir = mustRelativeLayoutDir(t, cfg.LayoutDir)
  • if cfg.Strategy == "" {
  •   cfg.Strategy = "git-mirror"
    
  • }
  • if cfg.CreatedAt.IsZero() {
  •   cfg.CreatedAt = time.Now().UTC()
    
  • }
  • content := fmt.Sprintf(
  •   "schema_version = %d\nlayout_dir = %q\nstrategy = %q\ncreated_at = %q\n",
    
  •   cfg.SchemaVersion,
    
  •   cfg.LayoutDir,
    
  •   cfg.Strategy,
    
  •   cfg.CreatedAt.Format(time.RFC3339),
    
  • )
  • if err := os.WriteFile(filepath.Join(root, ".git-fire"), []byte(content), 0o644); err != nil {
  •   t.Fatalf("failed writing .git-fire: %v", err)
    
  • }
    +}

+func readUSBVolumeConfigBytes(data []byte) (USBVolumeConfig, error) {

  • cfg := USBVolumeConfig{}
  • lines := strings.Split(string(data), "\n")
  • for _, line := range lines {
    ... diff truncated: showing 800 of 1078 lines

</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-fee2c5b4-a89b-42dd-b7d4-8922d0099437">here</a>.</sub>
<!-- BUGBOT_AUTOFIX_REVIEW_FOOTNOTE_END -->

<sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 628a737553e65efb611ac242e795968eadc548f0. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>

Comment thread snapshots.go Outdated
Comment thread usb_fixtures.go
@bschellenberger2600 bschellenberger2600 merged commit 9b81f46 into main Apr 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants