diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..891c617 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..b894315 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,8 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a686e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: Golang Test +on: + pull_request: + branches: + - main +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + env: + GO111MODULE: on + steps: + - name: Setup Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 #v6 + with: + go-version: '1.26' + + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6 + + - name: Run tests + run: go test ./... -v + env: + GOFLAGS: -mod=readonly diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..fc5feb5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,101 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + pull_request: + branches: [ "main" ] + schedule: + - cron: '17 19 * * 0' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + env: + GOPROXY: proxy.golang.org,direct + + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6 + - name: Setup Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 #v6 + with: + go-version: '1.26' # The Go version to download (if necessary) and use. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 #v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 #v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/go-test-coverage.yml b/.github/workflows/go-test-coverage.yml new file mode 100644 index 0000000..dc4cda8 --- /dev/null +++ b/.github/workflows/go-test-coverage.yml @@ -0,0 +1,31 @@ +name: Go Test Coverage +on: + pull_request: + branches: + - main +jobs: + check-coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: '1.26' + check-latest: true + + - name: Generate test coverage + run: | + go test \ + -race \ + -covermode=atomic \ + -coverpkg=./internal/...,./cmd/... \ + -coverprofile=coverage.out \ + ./... + + - name: Check test coverage + uses: vladopajic/go-test-coverage@679e6807f68f2440a4c43d386442a1d0041838a9 # v2.18.3 + with: + config: ./.testcoverage.yml diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..35864f2 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,70 @@ +name: golangci-lint +on: + pull_request: + branches: + - main + +permissions: + # Required: allow read access to the content for analysis. + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + # Optional: allow write access to checks to allow the action to annotate code in the PR. + checks: write + +jobs: + detect-modules: + name: detect-modules + runs-on: ubuntu-latest + outputs: + modules: ${{ steps.set-modules.outputs.modules }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - id: set-modules + run: | + # Find all directories containing go.mod files + modules=$(find . -name "go.mod" -type f | sed 's|/go.mod$||' | sort) + + # Convert to JSON array - ensure proper JSON format + echo "$modules" | jq -R . | jq -s -c . > modules.json + + echo "Found modules:" + cat modules.json | jq -r '.[]' + + # Set output with compact JSON + echo "modules=$(cat modules.json)" >> $GITHUB_OUTPUT + + golangci: + name: golangci-lint + runs-on: ubuntu-latest + needs: detect-modules + strategy: + matrix: + module: ${{ fromJSON(needs.detect-modules.outputs.modules) }} + steps: + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: '1.26' + check-latest: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version + version: latest + + # Optional: working directory, useful for monorepos + # working-directory: somedir + + # Optional: golangci-lint command line arguments. + args: --config=${{ github.workspace }}/.golangci.yml --timeout=10m + + # Optional: show only new issues if it's a pull request. The default value is `false`. + only-new-issues: true + + # Optional: if set to true then the action will use pre-installed Go. + # skip-go-installation: true + working-directory: ${{ matrix.module }} diff --git a/.github/workflows/releasegen.yml b/.github/workflows/releasegen.yml new file mode 100644 index 0000000..2083184 --- /dev/null +++ b/.github/workflows/releasegen.yml @@ -0,0 +1,64 @@ +name: Release by Changelog + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + branch: + description: 'Branch to create a release from' + required: true + default: 'main' + version: + description: 'Specify the semantic version for the release (vX.Y.Z)' + required: true + reason: + description: 'Reason for the manual release' + required: false + +permissions: + contents: write + +jobs: + release: + name: ReleaseGen + runs-on: ubuntu-latest + + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 #v2.2.2 + with: + app-id: ${{ secrets.RELEASEGEN_APP_ID }} + private-key: ${{ secrets.RELEASEGEN_APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.inputs.branch || github.ref_name }} + fetch-depth: 0 + token: ${{ steps.generate-token.outputs.token }} + + - name: Run ReleaseGen + # custom_change_types now live in .releasegen.yaml so the validate + # and release workflows share one source of truth. Per-run values + # stay in env below. + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_REF_NAME: ${{ github.event.inputs.branch || github.ref_name }} + MANUAL_VERSION: ${{ github.event.inputs.version || '' }} + REASON: ${{ github.event.inputs.reason || '' }} + run: | + docker run --rm \ + -e GITHUB_TOKEN \ + -e GITHUB_REPOSITORY \ + -e GITHUB_ACTOR \ + -e GITHUB_REF_NAME \ + -e MANUAL_VERSION \ + -e REASON \ + -v "$(pwd):/workspace" \ + ghcr.io/c2fo/releasegen:v1 \ + --repo-root /workspace diff --git a/.github/workflows/validate-changelog.yml b/.github/workflows/validate-changelog.yml new file mode 100644 index 0000000..105723b --- /dev/null +++ b/.github/workflows/validate-changelog.yml @@ -0,0 +1,23 @@ +name: Validate Changelog + +on: + pull_request: + +permissions: + contents: read + +jobs: + validate-changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + fetch-depth: 0 + + - name: Validate changelog + run: | + docker run --rm \ + -v "$(pwd):/workspace" \ + ghcr.io/c2fo/releasegen:v1 \ + validate --repo-root /workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..073b1ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.idea/ +.vscode/ + +# Build artifacts. +/prenup +/cmd/prenup/prenup + +# Test coverage output. +coverage.out diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..45856aa --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,157 @@ +version: "2" +run: + go: "1.26" + issues-exit-code: 1 + tests: true + allow-parallel-runners: false +output: + formats: + text: + path: stdout + print-linter-name: true + print-issued-lines: true + path-prefix: "" +linters: + default: none + enable: + - bodyclose + - errcheck + - errname + - errorlint + - gocritic + - gocyclo + - gosec + - govet + - ineffassign + - intrange + - lll + - misspell + - nolintlint + - perfsprint + - prealloc + - revive + - staticcheck + - tagliatelle + - testifylint + - unconvert + - unparam + - unused + - usetesting + - wastedassign + - whitespace + settings: + errcheck: + check-type-assertions: false + check-blank: false + gocritic: + disabled-checks: + - dupImport + - ifElseChain + - octalLiteral + - whyNoLint + - wrapperFunc + - hugeParam + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + settings: + captLocal: + paramsOnly: true + elseif: + skipBalanced: true + nestingReduce: + bodyWidth: 5 + rangeExprCopy: + sizeThreshold: 512 + skipTestFuncs: true + rangeValCopy: + sizeThreshold: 128 + skipTestFuncs: true + truncateCmp: + skipArchDependent: true + underef: + skipRecvDeref: true + unnamedResult: + checkExported: true + gocyclo: + min-complexity: 15 + gosec: + excludes: + - G115 # Integer overflow conversion - too many false positives + - G117 # Exported field names matching secret patterns (false positives for Options structs) + - G101 # Hardcoded credentials detection (false positives in test files with example data) + - G703 # Path traversal taint analysis (false positives for legitimate file operations) + - G705 # XSS taint analysis (false positives for stdout/stderr output) + lll: + line-length: 140 + tab-width: 1 + misspell: + locale: US + nolintlint: + require-explanation: false + require-specific: false + allow-unused: false + revive: + severity: warning + staticcheck: + checks: + - -ST1000 + - -ST1003 + - -ST1016 + - -ST1020 + - -ST1021 + - -ST1022 + - all + tagliatelle: + # Prenup's NDJSON runner events (see internal/ui/jsonout, docs/SCHEMA.md) + # and the `.prenup.yaml` user config (see internal/config) are stable, + # snake_case contracts consumed by users and agent tooling. + case: + rules: + json: snake + yaml: snake + unparam: + check-exported: false + exclusions: + generated: lax + rules: + - linters: + - mnd + path: _test\.go + - linters: + - lll + source: '^//go:generate ' + paths: + - docs + - mocks +issues: + max-issues-per-linter: 50 + max-same-issues: 3 + uniq-by-line: true + new: false +severity: + default: error +formatters: + enable: + - gofmt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/c2fo/) + custom-order: false + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com/c2fo/ + exclusions: + generated: lax + paths: + - docs + - mocks diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..7862857 --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,10 @@ +dir: "{{.InterfaceDir}}/mocks" +filename: "mocks.go" +pkgname: "mocks" +structname: "{{.InterfaceName}}" +template: testify +packages: + github.com/c2fo/prenup/internal/runner: + interfaces: + Executor: + Sink: diff --git a/.prenup.yaml b/.prenup.yaml new file mode 100644 index 0000000..8a92dcd --- /dev/null +++ b/.prenup.yaml @@ -0,0 +1,39 @@ +# .prenup.yaml — pre-commit checks managed by prenup. +# What is this? Prenup runs configurable checks before each commit, scoped to +# the parts of the repo that changed. Learn more and install: +# https://github.com/c2fo/prenup +version: 1 +module_markers: + - go.mod +exclude: + - .github/** + - '**/*.yaml' + - '**/*.yml' +output: auto +tasks: + - name: Run tests + default_selected: true + command: go test ./... + per_module: true + paths: + - '**/*.go' + - name: Run golangci-lint + default_selected: true + command: golangci-lint run --max-same-issues 0 ./... + per_module: true + paths: + - '**/*.go' + - name: Check for changes in CHANGELOG.md + default_selected: true + # Runs the same image and subcommand the validate workflow uses in CI, + # reading custom_change_types from .releasegen.yaml. Replaces the + # legacy changelog.sh "did the changelog change?" guard. + # + # --pull=always forces Docker to check GHCR for the current digest of + # the `v1` moving tag before each run, so we stay automatically in + # sync with the latest v1.x release that CI uses. Without this flag + # Docker uses its default --pull=missing behavior and will keep + # running whatever was first cached locally, even after newer v1.x + # images ship. Cost is one HTTP HEAD per invocation; layers stay + # cached so re-downloads only happen on a real version bump. + command: docker run --rm --pull=always -v "{{.repo_root}}:/workspace" ghcr.io/c2fo/releasegen:v1 validate --repo-root /workspace diff --git a/.releasegen.yaml b/.releasegen.yaml new file mode 100644 index 0000000..da4268f --- /dev/null +++ b/.releasegen.yaml @@ -0,0 +1,13 @@ +# ReleaseGen configuration for prenup. +# +# Per-repo, rarely-changing options live here so the validate workflow and the +# release workflow read them from a single source. Per-invocation values +# (GITHUB_TOKEN, MANUAL_VERSION, etc.) stay in the workflow env / CLI flags. + +custom_change_types: + Documentation: patch + +validate: + # Require every PR that touches non-CHANGELOG files to also add at least + # one entry under [Unreleased]. + require_changelog_entry: true diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 0000000..1aaae2f --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,28 @@ +# Configuration for vladopajic/go-test-coverage. +# See https://github.com/vladopajic/go-test-coverage for full schema. + +# Path to coverage profile file produced by `go test -coverprofile`. +profile: coverage.out + +# Coverage thresholds (percentages, 0-100). Starting conservative for v0.1.0; +# raise as coverage improves, never lower. +threshold: + total: 55 + package: 50 + file: 40 + +# Exclude generated and non-testable code from coverage statistics. +exclude: + paths: + - mocks/ + # Interactive Bubble Tea TUI; exercised end-to-end via cmd/prenup/e2e_test.go. + - internal/ui/human/ + # Trivial output-mode selection shim. + - internal/ui/output.go + # Main entrypoint (single delegation call). + - cmd/prenup/main.go + +breakdown-file-name: '' + +diff: + base-breakdown-file-name: '' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c31c549 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,345 @@ +# Agent Guidelines for the Prenup Repository + +This document provides guidelines for AI agents (and humans) working on the +`prenup` repository. It captures the conventions the codebase already follows +so contributions stay consistent. + +## Table of Contents +- [Development Guidelines](#development-guidelines) +- [Testing](#testing) +- [Config Schema Versioning](#config-schema-versioning) +- [CHANGELOG and PR Process](#changelog-and-pr-process) +- [Go Version Policy](#go-version-policy) +- [Dependency Upgrades](#dependency-upgrades) +- [GitHub Actions Maintenance](#github-actions-maintenance) +- [Release Process](#release-process) +- [Repository Structure](#repository-structure) + +--- + +## Development Guidelines + +### Code Style + +- Follow standard Go idioms; run `gofmt` and `goimports`. +- Imports are grouped with `github.com/c2fo/` local — see `.golangci.yml`. +- All exported types, functions, and methods have godoc comments. +- Keep functions small and focused; prefer early returns to reduce nesting. +- **Do not add narrating comments** that restate what the code does + (e.g. `// increment counter`). Comments explain non-obvious intent, + trade-offs, or constraints — not the mechanics. + +### Error Handling + +- Handle every error explicitly; no silent failures. +- Wrap with context: `fmt.Errorf("operation failed: %w", err)`. +- Never ignore return values. +- Config, git, and hook errors are user-facing — write messages that tell the + user what to fix. + +### Interface Design + +- Prefer small, focused interfaces. +- Define interfaces in the *consuming* package, not the implementing one. +- Use dependency injection for testability. The `runner.Executor` and + `runner.Sink` interfaces are the canonical examples. + +### File Organization + +- Group related functionality in packages. One package per major feature. +- Place mocks in `mocks/` subdirectories (see `internal/runner/mocks/`). +- CLI commands live in `internal/cli/`, one file per command. + +### Linting + +- All code must pass `golangci-lint run` with 0 issues. +- Config: `.golangci.yml`. +- CI runs the linter with `--only-new-issues` on PRs. + +--- + +## Testing + +### Coverage requirements + +- **All new code must have tests.** Aim for coverage close to 100% where + practical. +- Minimum thresholds are enforced by CI via `.testcoverage.yml` and the + `go-test-coverage` GitHub Action. +- The thresholds start conservative and **only ratchet up** — never lower a + threshold to make CI pass. If a change lowers coverage, add tests until it + recovers. +- Some code is legitimately excluded from coverage: the interactive Bubble + Tea TUI (`internal/ui/human/`), the trivial output-mode shim + (`internal/ui/output.go`), and `cmd/prenup/main.go`. Update + `.testcoverage.yml` if that list needs to change. + +### Test organization + +- **Use `testify/suite`** for related tests sharing setup/teardown. One suite + per component; naming: `[Component]TestSuite` (e.g. `RunnerTestSuite`). +- Simple unit tests don't require a suite; a plain `TestXxx(t *testing.T)` + is fine for small, standalone cases. +- Use `SetupTest()` / `TearDownTest()` (or `t.TempDir()`) for isolation. + +### Test style + +- **Prefer table-driven tests.** Slice of anonymous structs with `name`, + inputs, and expected outputs. Every case gets a descriptive name. +- Use suite methods (`s.Require()` / `s.Assert()`) inside suites; use the + standalone `require`/`assert` packages outside them. +- Cover both success and error paths in the same table. +- Example (matches the style used throughout the repo): + + ```go + tests := []struct { + name string + input string + expectedOutput string + expectedError string + }{ + {name: "success", input: "valid", expectedOutput: "result"}, + {name: "invalid input", input: "", expectedError: "input is required"}, + } + for _, tt := range tests { + s.Run(tt.name, func() { + got, err := fn(tt.input) + if tt.expectedError != "" { + s.Require().Error(err) + s.Assert().Contains(err.Error(), tt.expectedError) + return + } + s.Require().NoError(err) + s.Assert().Equal(tt.expectedOutput, got) + }) + } + ``` + +### Mocking + +- **Use `mockery` v3** with the EXPECT() pattern for readability and type + safety. Do not hand-write mocks. +- Config lives in `.mockery.yaml`. Mocks land in `mocks/` subdirectories. +- Regenerate after changing a mocked interface: `mockery`. +- Example EXPECT() usage: + + ```go + exec := mocks.NewExecutor(t) + exec.EXPECT(). + Run(mock.Anything, mock.MatchedBy(func(spec runner.ExecSpec) bool { + return spec.Command == "go test ./..." + })). + Return(nil) + ``` + +### Test types + +- **Unit tests** live alongside the code (`foo_test.go` next to `foo.go`). +- **End-to-end tests** live in `cmd/prenup/e2e_test.go`; they build the + binary and drive real `git` repos in `t.TempDir()`. E2E does not + contribute to package coverage (external process), so critical logic + still needs unit tests. +- **Race detection:** `go test -race ./...` runs cleanly. + +### Running the tests + +```bash +go test ./... # full suite +go test -race ./... # with race detector +go test -coverprofile=coverage.out ./... # generate coverage profile +go tool cover -func=coverage.out # per-function coverage +go tool cover -html=coverage.out # browse coverage +``` + +--- + +## Config Schema Versioning + +The `.prenup.yaml` file declares `version:` — currently `1`. This is the +config *schema* version, a plain integer **decoupled from the prenup release +version**. Rules: + +- It bumps **only** on a backward-incompatible change to the file format. +- Additive, non-breaking changes (new optional fields with sensible defaults) + do **not** bump the version — old configs keep working. +- The current version lives in `internal/config/config.go` as + `SchemaVersion`; the JSON Schema files (`internal/config/schema.json` and + `assets/prenup.schema.json`) must stay byte-identical and be updated + together. +- Loading a config with a *newer* version than the binary understands must + fail with a clear "upgrade prenup" message. +- Configs that prenup writes (`prenup init`, future `config upgrade`) go + through `config.Marshal`, which prepends the discoverability header that + links back to the project. Preserve it. +- Do **not** silently rewrite a user's config. Any future format-migration + command must be opt-in. + +## CHANGELOG and PR Process + +### Required for every PR + +Every PR **must** add an entry under the `## [Unreleased]` section of +`CHANGELOG.md`. Do **not** add a version number — versions are assigned at +release time from the changelog contents. + +### CHANGELOG format + +Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Standard +headings: + +- `### Added` — new features +- `### Fixed` — bug fixes +- `### Changed` / `### Removed` — **breaking changes only**; the description + must include the exact phrase `BREAKING CHANGE` +- `### Deprecated` — features slated for removal +- `### Security` — security-related updates + +If a change is not breaking, use `### Added` (new features) or `### Fixed` +(bug fixes) — never `### Changed`. + +### Version bump rules + +The next release version is derived from the `[Unreleased]` sections: + +- **Major** — any `### Changed` or `### Removed` with `BREAKING CHANGE` +- **Minor** — `### Added`, `### Deprecated`, or `### Security` +- **Patch** — only `### Fixed` + +--- + +## Go Version Policy + +Prenup supports the latest Go version and the previous minor version. The +`go.mod` `go` directive pins the older of the two so both keep working. + +### Files to update on a Go version bump + +- `go.mod` — `go` directive +- `.golangci.yml` — `run.go` +- `.github/workflows/ci.yml` — `go-version` +- `.github/workflows/codeql.yml` — `go-version` +- `.github/workflows/golangci-lint.yml` — `go-version` +- `.github/workflows/go-test-coverage.yml` — `go-version` +- `.github/workflows/validate-changelog.yml` — `go-version` +- `.github/workflows/releasegen.yml` — `go-version` if the workflow builds + from source (this repo's release workflow runs a prebuilt image, so no + change usually needed) + +--- + +## Dependency Upgrades + +- Update dependencies regularly for security and bug fixes. +- **Dependency bumps are their own PR** — do not fold them into unrelated + feature or docs work. +- Test with race detection after every bump. +- Document breaking changes in `CHANGELOG.md`. + +```bash +go get -u -t ./... +go mod tidy +go test -race ./... +``` + +--- + +## GitHub Actions Maintenance + +### SHA pinning + +All actions must be pinned to a full 40-character commit SHA, with the tag +name in a trailing comment: + +```yaml +uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +``` + +### Update policy + +- Select the newest version that is **at least 10 days old**. If the latest + is too fresh, pick a slightly older one. +- Verify release dates and check for security advisories before updating. +- Test the change in a PR before merging. + +### Workflow files + +- `ci.yml` — PR test workflow +- `golangci-lint.yml` — PR lint workflow +- `codeql.yml` — security scanning (PR + weekly cron) +- `go-test-coverage.yml` — enforces `.testcoverage.yml` thresholds +- `validate-changelog.yml` — runs `releasegen validate` on PRs +- `releasegen.yml` — cuts tags and GitHub releases from `CHANGELOG.md` on + push to `main`; uses `ghcr.io/c2fo/releasegen:v1` + +--- + +## Release Process + +Releases are automated by the `releasegen.yml` workflow: + +1. Every PR updates `## [Unreleased]` with the change. +2. On merge to `main`, `releasegen` computes the next SemVer from the + `[Unreleased]` entries, promotes them into a versioned section, tags the + commit, and publishes a GitHub Release with the notes. +3. Users install via `go install github.com/c2fo/prenup/cmd/prenup@` or + `@latest`. + +Prenup does not currently ship a Docker image or prebuilt binaries. + +--- + +## Repository Structure + +``` +prenup/ +├── AGENTS.md # this file +├── CHANGELOG.md +├── CODE_OF_CONDUCT.md +├── CONTRIBUTING.md +├── LICENSE.md +├── README.md +├── SECURITY.md +├── go.mod # single Go module +├── .prenup.yaml # prenup dogfoods itself +├── .releasegen.yaml +├── .golangci.yml +├── .testcoverage.yml +├── .mockery.yaml +├── assets/prenup.schema.json # published JSON Schema (byte-identical to internal copy) +├── prenup.example.yaml # user-facing example config +├── cmd/prenup/ # binary entry point + end-to-end tests +│ ├── main.go +│ └── e2e_test.go +├── docs/ # DESIGN.md, SCHEMA.md, FUTURE.md +├── internal/ +│ ├── cli/ # cobra command tree +│ ├── config/ # .prenup.yaml schema + loading + validation +│ ├── discover/ # change + module detection +│ ├── git/ # git subprocess wrapper +│ ├── hook/ # pre-commit hook install/uninstall +│ ├── lock/ # per-repo advisory lock +│ ├── runner/ # task planning + execution +│ │ └── mocks/ # mockery-generated Executor/Sink mocks +│ ├── ui/ # output-mode dispatch +│ │ ├── human/ # Bubble Tea TUI (excluded from coverage) +│ │ ├── jsonout/ # NDJSON event stream +│ │ └── markdown/ # markdown digest +│ └── versioncheck/ # GitHub Releases update checker +└── .github/ + ├── ISSUE_TEMPLATE/ + └── workflows/ +``` + +--- + +## References + +- [Go Release Policy](https://go.dev/doc/devel/release) +- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +- [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +- [golangci-lint](https://golangci-lint.run/) +- [testify](https://github.com/stretchr/testify) +- [mockery](https://github.com/vektra/mockery) +- [releasegen](https://github.com/c2fo/releasegen) — computes the next SemVer + from `[Unreleased]` and cuts the tag + GitHub Release +- [Prenup CHANGELOG](CHANGELOG.md) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e2b0ef7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed +- `prenup plan --output` help text no longer advertises unsupported + `auto`/`human`/`markdown` modes; only `text` (default) and `json` are + implemented. +- `docs/SCHEMA.md` no longer claims the `time` field is present on every + NDJSON line — the bootstrap `agent_hint` line is a static header and + intentionally omits it. +- Version checker requests the GitHub Releases API with `?per_page=100` + so repos accumulating many releases (or non-semver tags) don't cause + the latest valid semver release to fall off the first page. +- Rate-limit exhaustion errors now render the `X-RateLimit-Reset` header + as an RFC3339 UTC time plus a "resets in" duration, instead of leaking + the raw Unix epoch integer to the operator. +- Internal comments in `internal/ui/agent.go` and + `internal/ui/jsonout/jsonout.go` (including the agent-facing + `event_types_note` hint) now point at `docs/SCHEMA.md`, matching the + actual in-repo path. +- Repo lock now resolves a *relative* `gitdir:` target in a `.git` file + (git worktrees/submodules) against the repository root instead of the + current working directory, so the lock lands in the real git metadata + directory. +- Human-mode post-run summary bounds its retained output to the most + recent lines (keeping a tail and noting how many were dropped) so a + noisy task can't grow memory without limit during a hook run. +- Version-check requests now send a `User-Agent` header, which the GitHub + REST API expects (some environments 403 without it). +- `lock` package now compiles on non-unix platforms via a build-tagged + no-op `flock` stub; advisory locking remains unix-only by design, as + documented. +- Corrected the `lock.Acquire` godoc, which incorrectly stated that + `Close` removes the lock file (it intentionally does not). + +### Added +- Initial open-source release. +- Interactive, configuration-driven Git pre-commit hook runner. +- Subcommands: `run`, `plan`, `install`, `uninstall`, `init`, + `config validate`, `config schema`, `version`. +- Per-task path filtering with doublestar globs (`paths`, `paths_ignore`, + `exclude`). +- Per-module change discovery via configurable `module_markers`, with + bounded per-module concurrency for `per_module` tasks. +- Stash-and-restore of unstaged changes (`clean_worktree`) so tasks see + exactly what will be committed. +- OS-level advisory lock on `.git/prenup.lock` to prevent concurrent + `prenup run` invocations from racing on the worktree. +- Output modes: interactive Bubble Tea UI (`human`), streaming markdown + digest (`markdown`), and NDJSON event stream (`json`) with a leading + `agent_hint` bootstrap line for LLM/agent consumers. +- Automatic staging of newly-generated files matching `output_patterns` + for tasks that declare `stage_output: true`. +- Graceful cancellation: SIGTERM with grace period on Ctrl-C or parent + timeout, then SIGKILL if the task does not exit. +- Template variables in `command` and `working_dir`: `{{.repo_root}}`, + `{{.module_root}}`, `{{.module_path}}`, `{{.module_name}}`. +- Embedded JSON Schema (config `version: 1`) for editor `$schema` integration, + published at `assets/prenup.schema.json`. +- GitHub Releases API version check with a one-line update notice; honors + `PRENUP_GITHUB_TOKEN`, `GITHUB_TOKEN`, or `GH_TOKEN` for authenticated + requests. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..24632a0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +john.judd@c2fo.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bdc7f29 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,98 @@ +# Contributing to Prenup + +Thanks for your interest in improving Prenup! Issues and pull requests are +welcome. This document covers how to get set up and the conventions the +project follows. + +By participating, you agree to abide by our +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Getting started + +Prenup is a single Go module (`github.com/c2fo/prenup`). + +```bash +git clone https://github.com/c2fo/prenup.git +cd prenup +go build ./... +go test ./... +``` + +You need Go 1.26 or newer, plus `git` and `bash` on your `PATH` (Prenup runs +tasks via `bash -c` and shells out to `git`). + +## Development workflow + +1. Fork the repo and create a topic branch off `main`. +2. Make your change, with tests. +3. Run the full local check suite (below). +4. Update `CHANGELOG.md` (see [Changelog](#changelog)). +5. Open a pull request describing the change and its motivation. + +Prenup dogfoods itself: the repo ships a `.prenup.yaml`, so once you run +`prenup install` in your clone the hook will run tests, `golangci-lint`, +`go vet`, and a `gofmt` check on commit. + +## Local checks + +Before opening a PR, make sure these pass: + +```bash +go build ./... +go test ./... +golangci-lint run +gofmt -l . # should print nothing +``` + +## Testing + +- **All new code must have tests.** Aim for coverage as close to 100% as is + practical. +- Use [`testify`](https://github.com/stretchr/testify) — prefer `suite` for + related tests, and `s.Require()` / `s.Assert()` within suites. +- Prefer **table-driven tests** with a slice of named cases. +- Generate mocks with `mockery` v3 (config in `.mockery.yaml`); do not + hand-write mocks. Regenerate with `mockery` after changing a mocked + interface. + +Coverage thresholds are enforced in CI via `.testcoverage.yml`. + +## Code style + +- Run `gofmt` / `goimports`; imports are grouped with the + `github.com/c2fo/` prefix local (see `.golangci.yml`). +- All exported types, functions, and methods need doc comments. +- Handle every error explicitly; wrap with context using + `fmt.Errorf("...: %w", err)`. +- Keep functions small and prefer early returns. +- Do not add comments that merely restate what the code does. + +## Changelog + +Every PR must add an entry under the `## [Unreleased]` section of +[CHANGELOG.md](CHANGELOG.md). The changelog follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Use the appropriate heading for your change: + +- `### Added` — new features +- `### Fixed` — bug fixes +- `### Changed` / `### Removed` — **breaking changes only**; include the exact + phrase `BREAKING CHANGE` in the description +- `### Deprecated` — features slated for removal +- `### Security` — security-related updates + +**Do not add a version number** — versions are assigned automatically at +release time from the changelog contents. + +## Pull request expectations + +- Focused, single-purpose changes are easier to review. +- CI (tests, lint, coverage, CodeQL, changelog validation) must be green. +- Update relevant docs (`README.md`, `docs/`) when behavior changes. + +## Reporting bugs and requesting features + +Use the GitHub issue templates. For security issues, please follow +[SECURITY.md](SECURITY.md) instead of opening a public issue. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d612f23 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 C2FO, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 07fd7da..d9ff97b 100644 --- a/README.md +++ b/README.md @@ -1 +1,232 @@ -# prenup \ No newline at end of file +![prenup.png](prenup.png) + +[![GitHub tag](https://img.shields.io/github/tag/c2fo/prenup.svg?style=flat)](https://github.com/c2fo/prenup/releases) +[![GoDoc](https://pkg.go.dev/badge/github.com/c2fo/prenup?utm_source=godoc)](https://pkg.go.dev/github.com/c2fo/prenup) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) +[![Last Commit](https://img.shields.io/github/last-commit/c2fo/prenup)](https://github.com/c2fo/prenup/commits/main) + +# Prenup — Interactive Pre-Commit Hook Utility + +Prenup runs user-defined tasks (tests, linters, doc generators, custom scripts) +as a Git pre-commit hook. It is designed to be fast, selective, and visible: +only the modules that actually changed get checked, task output streams live, +and the developer stays in the loop through a simple interactive UI. + +Prenup provides dedicated subcommands (`install`, `init`, `run`, `plan`), +per-task path filtering, per-module parallelism, stash-and-restore for safer +checks, and agent-friendly output modes (markdown digest when piped, NDJSON +with `--output json`). + +## Quick start + +```bash +# Install the binary. +go install github.com/c2fo/prenup/cmd/prenup@latest + +# Inside your repo: +prenup init # scaffold .prenup.yaml +prenup install # write .git/hooks/pre-commit +``` + +You commit. Prenup runs. Pass or fail, you see why. + +## Subcommands + +| Command | Purpose | +|---|---| +| `prenup` | Default: run as the Git pre-commit hook. Equivalent to `prenup run`. | +| `prenup run [flags]` | Run the pre-commit checks without committing. | +| `prenup plan` | Print the plan for the current change set without executing. | +| `prenup install` | Install `.git/hooks/pre-commit`. Prompts on conflicts. | +| `prenup uninstall` | Remove the managed hook, restoring backups when present. | +| `prenup init` | Scaffold a starter `.prenup.yaml` from repo inspection. | +| `prenup config validate [path]` | Validate a config against the schema. | +| `prenup config schema` | Print the embedded JSON schema. | +| `prenup version` | Print the binary version. | + +### `prenup run` flags + +- `--config PATH` — explicit config path. +- `--output MODE` — `auto`, `human`, `markdown`, or `json`. Default `auto`. +- `--task NAME` — run only the named task(s); repeatable, non-interactive. +- `--all` — ignore change detection; run against the full repo scope. +- `--no-interactive` — skip the selection UI; run all `default_selected` tasks. +- `--no-clean-worktree` — disable stash-and-restore. +- `--parallelism N` — cap per-module fan-out (0 = `NumCPU`). +- `--dry-run` — report what would run without executing. + +### `prenup install` conflict handling + +If a `pre-commit` hook already exists, `install` prompts for one of: + +- **replace** — back up the existing hook and write the prenup hook. +- **chain** — move the existing hook to `pre-commit.local`; the prenup hook + runs it first on every commit. +- **force** — overwrite without a backup. +- **abort** — leave everything alone. + +Non-interactive shells can pass `--force`, `--replace`, `--chain`, or +`--non-interactive` directly. When stdin is not a TTY, `install` refuses to +prompt and surfaces the conflict so CI scripts fail loudly instead of hanging. + +Use `--use-path` to record `prenup` (resolved via `$PATH` at commit time) +inside the hook script instead of the absolute path of the binary that ran +`install`. This is the right choice for shared dotfiles or any setup where +the binary may live in different locations across machines. + +## Output modes + +- **human** — full-screen Bubble Tea UI with a live task checklist and a + scrolling output viewport. Selected automatically when stdout is a TTY. +- **markdown** — plain streaming output during execution, followed by a + structured markdown digest summarizing each task. Selected automatically + when stdout is piped, when `NO_COLOR` is set, or when `TERM=dumb`. This is + the default for CI logs and for LLM-readable post-run summaries. +- **json** — one JSON object per line (NDJSON). Stable schema, safe for + agents and tooling. Always explicit: `--output json`. + +Both `markdown` and `json` modes lead with a self-describing preamble so +an AI agent (or operator) that has never heard of prenup can identify the +tool, find docs, and recognize hook-attributed failures from the very +first line of output. In `markdown` it is a `[prenup] â€Ļ` block; in `json` +it is a single `agent_hint` event before the runner stream begins. On +failure, the markdown digest's "Next steps" block also disambiguates +prenup-vs-git attribution and documents the `--no-verify` bypass. + +See [docs/SCHEMA.md](docs/SCHEMA.md) for the JSON event schema (including +the `agent_hint` bootstrap line). + +## Configuration + +Configuration lives at the repository root as `.prenup.yaml` (or `.prenup.yml`). + +```yaml +version: 1 +module_markers: + - go.mod +exclude: + - ".github/**" + - "**/*.yaml" +clean_worktree: true +max_parallelism: 0 +output: auto + +tasks: + - name: "Run tests" + default_selected: true + command: "go test ./..." + per_module: true + paths: + - "**/*.go" + paths_ignore: + - "**/*_mock.go" + + - name: "Generate CLI docs" + default_selected: true + per_module: false + command: "make docs" + output_patterns: + - "doc/cmd/**/*.md" + stage_output: true +``` + +See [prenup.example.yaml](prenup.example.yaml) for a larger example and +[docs/SCHEMA.md](docs/SCHEMA.md) for the full field reference. + +`prenup config validate` (and `prenup run` itself) checks structural fields +and the syntax of every doublestar pattern in `exclude`, `paths`, +`paths_ignore`, and `output_patterns`. Invalid patterns are reported with +the offending value and field path so they can be fixed before they silently +fail to match anything at runtime. + +### Template variables + +Available inside `command` and `working_dir`: + +| Variable | Description | +|---|---| +| `{{.repo_root}}` | Absolute path to the repo root. | +| `{{.module_root}}` | Absolute path to the current module. | +| `{{.module_path}}` | Module path relative to the repo root. | +| `{{.module_name}}` | Basename of the module directory. | + +## Behavior + +1. **Change discovery** — staged, unstaged, and untracked files (filtered by + repo-level `exclude` patterns). +2. **Module detection** — the nearest ancestor directory of each changed file + that contains any `module_markers` file. +3. **Task planning** — tasks filter their relevant files via `paths` / + `paths_ignore` (doublestar globs). `per_module: true` tasks fan out across + the resulting module set. +4. **Selection** — in human mode, an interactive checklist. In other modes, + `default_selected` tasks run automatically; `--task` overrides. +5. **Concurrency lock** — before any tasks run, prenup takes an OS-level + advisory lock on `.git/prenup.lock`. A second concurrent `prenup run` + against the same repo (e.g. a Git GUI that double-fires `git commit`) + exits non-zero with a clear "another prenup run is already in progress" + message instead of racing on the worktree. The lock auto-releases on + process exit, including crashes. `--dry-run` skips the lock. +6. **Stash-and-restore** — when `clean_worktree: true`, unstaged changes are + stashed with `--keep-index --include-untracked` before tasks run, and + restored afterward. +7. **Execution** — tasks run sequentially; a per-module task fans out across + modules concurrently (bounded by `max_parallelism`). The first failure + within a task skips remaining modules for that task and continues to the + next task. +8. **Output staging** — tasks that declare `stage_output: true` have + newly-created files matching `output_patterns` automatically `git add`-ed. + The "before" snapshot is taken immediately before each such task runs, so + one task cannot accidentally stage files generated by an earlier task in + the same run. For `per_module` tasks, staging is further scoped to the + task's modules: a task running in module `a/` will not auto-stage files + produced under module `b/`. +9. **Cancellation** — when prenup is interrupted (Ctrl-C, parent timeout, + etc.) the running task receives `SIGTERM` and is given a short grace + period to flush output and clean up before being forcefully killed. +10. **Exit** — non-zero if any task failed; zero otherwise. + +## Version check + +Each invocation queries the GitHub Releases API for the latest `v*` tag +with a short timeout. A newer version triggers a one-line update notice. +Failures are silent and never block a commit. + +For private repositories or to avoid unauthenticated rate limits, set one +of `PRENUP_GITHUB_TOKEN`, `GITHUB_TOKEN`, or `GH_TOKEN`. + +## Config schema versioning + +`.prenup.yaml` declares a `version:` — currently `1`. This is the config +*schema* version, a plain integer independent of the prenup release version. +It only changes if the file format ever changes in a backward-incompatible +way; additive, non-breaking features do not bump it, so existing configs keep +working across prenup upgrades. + +If prenup encounters a config with a `version:` newer than the running binary +understands, it fails with a clear message telling you to upgrade prenup. + +## Documentation + +- [docs/DESIGN.md](docs/DESIGN.md) — design and behavior specification. +- [docs/SCHEMA.md](docs/SCHEMA.md) — config schema and JSON event stream. +- [docs/FUTURE.md](docs/FUTURE.md) — deferred improvements. +- [CONTRIBUTING.md](CONTRIBUTING.md) — how to contribute. +- [CHANGELOG.md](CHANGELOG.md) — release history. + +## Platform support + +Prenup targets Linux and macOS. Windows support is deliberately out of scope; +see [docs/FUTURE.md](docs/FUTURE.md). + +## Contributing + +Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) +for development setup and conventions, and the [Contributor Covenant Code of +Conduct](CODE_OF_CONDUCT.md). All PRs must update the `[Unreleased]` section +of [CHANGELOG.md](CHANGELOG.md); version numbers are assigned automatically at +release time. + +## License + +MIT. See [LICENSE.md](LICENSE.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5a3fa1d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security Policy + +## Supported versions + +Prenup is distributed as a single Go binary. Security fixes are applied to the +latest released version. Please make sure you are on the most recent release +before reporting an issue. + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, +pull requests, or discussions.** + +Instead, report them privately using GitHub's private vulnerability reporting: + +1. Go to the [Security tab](https://github.com/c2fo/prenup/security) of this + repository. +2. Click **Report a vulnerability**. +3. Provide as much detail as you can, including: + - a description of the issue and its impact, + - steps to reproduce (a proof of concept if possible), + - affected version(s), and + - any suggested remediation. + +We will acknowledge your report, investigate, and keep you informed of the +resolution. Once a fix is available we will publish a release and, where +appropriate, a security advisory crediting the reporter (unless you prefer to +remain anonymous). + +## Scope + +Prenup executes user-defined commands from a repository's `.prenup.yaml` via +`bash`. Configuration files are treated as trusted input controlled by the +repository owner; running Prenup against a repository is equivalent to +trusting that repository's configured commands. Reports that rely on a +maliciously crafted `.prenup.yaml` in a repository you already control are +generally out of scope. + +Vulnerabilities of interest include (but are not limited to): + +- unintended command execution outside of configured tasks, +- path traversal or writing outside the repository during output staging, +- leaking credentials (e.g. `PRENUP_GITHUB_TOKEN`) to task output or logs, +- privilege or worktree-integrity issues in the stash/restore or hook-install + logic. + +Thank you for helping keep Prenup and its users safe. diff --git a/assets/prenup.schema.json b/assets/prenup.schema.json new file mode 100644 index 0000000..f1d6e96 --- /dev/null +++ b/assets/prenup.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/c2fo/prenup/main/assets/prenup.schema.json", + "title": "Prenup configuration", + "$comment": "This file is the canonical source. assets/prenup.schema.json must stay byte-identical. When adding a version 2, replace `version.const: 1` with a `oneOf` over per-version schemas.", + "type": "object", + "required": ["version", "tasks"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "const": 1, + "description": "Config schema version. Must be 1." + }, + "module_markers": { + "type": "array", + "items": { "type": "string" }, + "description": "File names whose presence marks a module directory. Defaults to ['go.mod']." + }, + "exclude": { + "type": "array", + "items": { "type": "string" }, + "description": "Doublestar glob patterns of files to ignore for change detection." + }, + "clean_worktree": { + "type": "boolean", + "description": "Stash unstaged changes around task execution so checks see exactly the pending commit. Default true." + }, + "max_parallelism": { + "type": "integer", + "minimum": 0, + "description": "Max concurrent per-module task runs. 0 means NumCPU." + }, + "output": { + "type": "string", + "enum": ["auto", "human", "markdown", "json"], + "description": "Default output mode. Overridable via --output." + }, + "tasks": { + "type": "array", + "items": { "$ref": "#/$defs/task" } + } + }, + "$defs": { + "task": { + "type": "object", + "required": ["name", "command"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "default_selected": { "type": "boolean" }, + "command": { "type": "string", "minLength": 1 }, + "per_module": { "type": "boolean" }, + "working_dir": { "type": "string" }, + "paths": { "type": "array", "items": { "type": "string" } }, + "paths_ignore": { "type": "array", "items": { "type": "string" } }, + "output_patterns": { "type": "array", "items": { "type": "string" } }, + "stage_output": { "type": "boolean" }, + "parallel": { "type": "boolean" }, + "clean_worktree": { "type": "boolean" }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + } + } +} diff --git a/cmd/prenup/e2e_test.go b/cmd/prenup/e2e_test.go new file mode 100644 index 0000000..12a1f5b --- /dev/null +++ b/cmd/prenup/e2e_test.go @@ -0,0 +1,261 @@ +package main_test + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildBinary compiles the prenup binary into a temporary location and +// returns the path. The compiled binary is reused across subtests. +func buildBinary(t *testing.T) string { + t.Helper() + dir := t.TempDir() + out := filepath.Join(dir, "prenup") + // Build with the host go toolchain; the binary path is constructed from + // the test's TempDir and the go binary name is fixed. + cmd := exec.Command("go", "build", "-o", out, ".") //nolint:gosec // G204: fixed binary, controlled args. + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + require.NoError(t, cmd.Run()) + return out +} + +// initRepo creates a fresh git repo rooted at dir, committing the passed +// files on top of an empty initial commit. +func initRepo(t *testing.T, dir string) { + t.Helper() + mustRunGit(t, dir, "init", "-b", "main") + mustRunGit(t, dir, "config", "user.email", "t@example.com") + mustRunGit(t, dir, "config", "user.name", "t") + mustRunGit(t, dir, "config", "commit.gpgsign", "false") + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi\n"), 0o600)) + mustRunGit(t, dir, "add", "README.md") + mustRunGit(t, dir, "commit", "-m", "init") +} + +// mustRunGit runs `git ` in dir, failing the test on error. The git +// binary is fixed; only its arguments vary. +func mustRunGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) //nolint:gosec // G204: git binary is fixed in tests. + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v: %s", args, string(out)) +} + +// runPrenup executes the built binary in dir and returns stdout + exit code. +func runPrenup(t *testing.T, binary, dir string, args ...string) (string, int) { + t.Helper() + // binary is the path returned by buildBinary in this test process. + cmd := exec.Command(binary, args...) //nolint:gosec // G204: binary is built locally for the test. + cmd.Dir = dir + // Force non-interactive output for deterministic test behavior. + cmd.Env = append(os.Environ(), "NO_COLOR=1", "TERM=dumb") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + exit := 0 + if err != nil { + ee := &exec.ExitError{} + if errors.As(err, &ee) { + exit = ee.ExitCode() + } else { + t.Fatalf("running prenup: %v\nstderr: %s", err, stderr.String()) + } + } + if testing.Verbose() { + t.Logf("stdout:\n%s", stdout.String()) + t.Logf("stderr:\n%s", stderr.String()) + } + return stdout.String(), exit +} + +func TestE2EVersion(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + initRepo(t, dir) + + out, exit := runPrenup(t, bin, dir, "version") + assert.Equal(t, 0, exit) + assert.Contains(t, out, "prenup") +} + +func TestE2ERunWithNoChanges(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + initRepo(t, dir) + + cfg := `version: 1 +tasks: + - name: "Echo" + command: "echo hello" + default_selected: true +` + require.NoError(t, os.WriteFile(filepath.Join(dir, ".prenup.yaml"), []byte(cfg), 0o600)) + // Commit the config so the worktree is clean; with no staged/unstaged/ + // untracked files we exercise the "no relevant files changed" path. + mustRunGit(t, dir, "add", ".prenup.yaml") + mustRunGit(t, dir, "commit", "-m", "add config") + + out, exit := runPrenup(t, bin, dir, "run") + assert.Equal(t, 0, exit) + assert.Contains(t, out, "No relevant files changed") +} + +func TestE2ERunPassingTaskMarkdownOutput(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + initRepo(t, dir) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/x\n\ngo 1.22\n"), 0o600)) + + cfg := `version: 1 +clean_worktree: false +tasks: + - name: "Echo" + command: "echo prenup-ran" + default_selected: true + per_module: true +` + require.NoError(t, os.WriteFile(filepath.Join(dir, ".prenup.yaml"), []byte(cfg), 0o600)) + + out, exit := runPrenup(t, bin, dir, "run", "--no-clean-worktree") + assert.Equal(t, 0, exit, "stdout: %s", out) + assert.Contains(t, out, "prenup-ran") + assert.Contains(t, out, "1 succeeded, 0 failed") + assert.Contains(t, out, "## Prenup") +} + +func TestE2ERunFailingTaskBlocksCommit(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + initRepo(t, dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/x\n\ngo 1.22\n"), 0o600)) + + cfg := `version: 1 +clean_worktree: false +tasks: + - name: "Fail" + command: "echo bad && exit 1" + default_selected: true + per_module: true +` + require.NoError(t, os.WriteFile(filepath.Join(dir, ".prenup.yaml"), []byte(cfg), 0o600)) + + out, exit := runPrenup(t, bin, dir, "run", "--no-clean-worktree") + assert.Equal(t, 1, exit, "expected non-zero exit on failure") + assert.Contains(t, out, "0 succeeded, 1 failed") +} + +func TestE2ERunJSONOutput(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + initRepo(t, dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/x\n\ngo 1.22\n"), 0o600)) + + cfg := `version: 1 +clean_worktree: false +tasks: + - name: "Echo" + command: "echo hello" + default_selected: true + per_module: true +` + require.NoError(t, os.WriteFile(filepath.Join(dir, ".prenup.yaml"), []byte(cfg), 0o600)) + + out, exit := runPrenup(t, bin, dir, "run", "--output", "json", "--no-clean-worktree") + assert.Equal(t, 0, exit) + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + require.GreaterOrEqual(t, len(lines), 2) + + // First line must be the agent_hint bootstrap so a cold-start consumer + // can identify the stream before any runner events arrive. + var hint map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &hint)) + assert.Equal(t, "agent_hint", hint["type"]) + assert.Equal(t, "prenup", hint["tool"]) + + var first map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[1]), &first)) + assert.Equal(t, "run_started", first["type"]) + + var last map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[len(lines)-1]), &last)) + assert.Equal(t, "run_completed", last["type"]) + // exit_code is omitted when zero (omitempty); its absence means success. + if v, ok := last["exit_code"]; ok { + f, isFloat := v.(float64) + require.True(t, isFloat, "exit_code should be a JSON number") + assert.InDelta(t, 0.0, f, 0.0001) + } +} + +func TestE2EPlanJSON(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + initRepo(t, dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/x\n\ngo 1.22\n"), 0o600)) + + cfg := `version: 1 +tasks: + - name: "Echo" + command: "echo hi" + default_selected: true + per_module: true +` + require.NoError(t, os.WriteFile(filepath.Join(dir, ".prenup.yaml"), []byte(cfg), 0o600)) + + out, exit := runPrenup(t, bin, dir, "plan", "--output", "json", "--all") + assert.Equal(t, 0, exit) + + var plan map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &plan)) + assert.NotEmpty(t, plan["tasks"]) +} + +func TestE2EInstallUninstallRoundTrip(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + initRepo(t, dir) + + // Use --force in case git init created a template hook under + // core.hooksPath or a default .git/hooks/pre-commit. + _, exit := runPrenup(t, bin, dir, "install", "--force") + require.Equal(t, 0, exit) + data, err := os.ReadFile(filepath.Join(dir, ".git", "hooks", "pre-commit")) //nolint:gosec // G304: path under test TempDir. + require.NoError(t, err) + assert.Contains(t, string(data), "prenup-managed-hook") + + _, exit = runPrenup(t, bin, dir, "uninstall") + require.Equal(t, 0, exit) + _, err = os.Stat(filepath.Join(dir, ".git", "hooks", "pre-commit")) + assert.True(t, os.IsNotExist(err)) +} + +func TestE2EConfigValidate(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + initRepo(t, dir) + + cfg := `version: 1 +tasks: + - name: "Echo" + command: "echo" +` + require.NoError(t, os.WriteFile(filepath.Join(dir, ".prenup.yaml"), []byte(cfg), 0o600)) + + out, exit := runPrenup(t, bin, dir, "config", "validate") + assert.Equal(t, 0, exit) + assert.Contains(t, out, "OK:") +} diff --git a/cmd/prenup/main.go b/cmd/prenup/main.go new file mode 100644 index 0000000..e817ef1 --- /dev/null +++ b/cmd/prenup/main.go @@ -0,0 +1,16 @@ +// Package main is the entry point for the prenup binary. +// +// Prenup is an interactive, configuration-driven Git pre-commit hook utility. +// See README.md and docs/DESIGN.md for the product description and docs/ for +// reference material (config schema, event schema, future work). +package main + +import ( + "os" + + "github.com/c2fo/prenup/internal/cli" +) + +func main() { + os.Exit(cli.Execute()) +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..22a96d5 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,356 @@ +# Prenup — Design & Behavior Specification + +This document describes what `prenup` does and how it behaves. It is the +reference for the tool's design decisions, configuration surface, and runtime +semantics. For a field-by-field config and event reference, see +[SCHEMA.md](SCHEMA.md). + +## 1. Overview + +**Prenup** is an interactive, configuration-driven Git pre-commit hook utility. +It gives developers a fast, visible, and selective way to run quality checks +(tests, linters, doc generators, custom scripts) immediately before a commit +lands — scoped intelligently to only the parts of the repository that actually +changed. + +Prenup targets repositories where running every check on every commit is too +slow or too noisy, and where a silent pre-commit hook either gets in the way +or gets bypassed. Prenup splits the difference: it shows the developer exactly +what is about to run, lets them choose, streams live results, and only blocks +the commit when something genuinely fails. + +While usable in any repository, Prenup is purpose-built for **Go monorepos and +multi-module repositories**, where "what changed" naturally maps to one or +more Go modules. The module concept is generalized via configurable +`module_markers`, opening the door to polyglot repos without breaking the +core experience. + +Prenup is designed to be used both by humans and by agentic tooling. Output is +TUI-first when run interactively, but auto-degrades to a structured markdown +digest when piped and can emit a stable NDJSON event stream on request. + +## 2. Goals & Non-Goals + +### Goals +- Give developers a single, declarative configuration (`.prenup.yaml`) that + describes pre-commit checks for the whole team. +- Run only what is relevant to the current commit, scoped per changed module + when appropriate and further narrowed by per-task path filters. +- Make the developer an active participant: visible task list, opt-in per + task, live output, fast escape hatch. +- Block commits that fail real checks; never block on infrastructural issues + (network, version check, stash failure, etc.). +- Automatically include generated artifacts (docs, mocks, generated code) in + the commit so the developer doesn't have to remember `git add`. +- Make the pre-commit experience safe: tasks see exactly the content that + will be committed, not a dirty worktree. +- Be usable by both humans and agentic tooling: the same binary, same + behavior, different output shape. +- Stay lightweight: a single binary, no daemon, no shared state, no server. + +### Non-Goals +- Not a general-purpose task runner or build system. +- Not a CI replacement. +- Does not generate or enforce repository structure beyond reading + `.prenup.yaml` and the configured `module_markers`. +- Does not author commits or modify staged content beyond optionally + `git add`-ing declared output files. +- Does not support Windows. + +## 3. Target Users & Use Cases + +### Primary users +- A developer in a Go repository (often a monorepo) who commits frequently + and wants pre-commit feedback that is fast, selective, and obvious. +- An AI coding agent driving `git commit` from an automated loop, consuming + prenup's markdown or JSON output to decide what to do next. + +### Primary use cases +1. **Run the right tests, only.** Only the modules with changes get tested or + linted, not the entire repo. +2. **Generated-file hygiene.** Automatically regenerate docs, mocks, or + protobuf for changed modules and stage the regenerated files. +3. **Repo-wide guardrails.** Run a single check (e.g. CHANGELOG enforcement) + once per commit. +4. **Interactive override.** Quickly skip a specific task for a one-off + commit without disabling the hook. +5. **Safe checks.** Ensure tests run against the exact content being + committed (not a dirty worktree). +6. **Agentic commits.** An agent runs `prenup run --output json --task "Run + tests"` and parses the event stream to react to failures programmatically. + +## 4. User Experience + +### 4.1 Installation +- `go install github.com/c2fo/prenup/cmd/prenup@latest` +- `prenup init` scaffolds a starter `.prenup.yaml` based on simple repo + inspection (Go modules, `.golangci.yml`, `Makefile`). +- `prenup install` writes `.git/hooks/pre-commit`. If another hook exists, + the developer is offered `replace`, `chain`, or `force`. Non-interactive + flags are available for automation; in non-interactive contexts (no TTY, + or `--no-interactive`) `install` fails fast on conflict instead of + hanging on a prompt. `--use-path` embeds `prenup` (resolved via `PATH` + at commit time) instead of the absolute path of the binary that ran + `install`, which is useful when the binary may move between installs. + +After this, Prenup is invisible until the developer runs `git commit`. + +### 4.2 Commit-time flow + +1. **Version banner** — Prenup prints its version and a non-blocking notice + when a newer release exists. +2. **Change discovery** — staged, unstaged, and untracked files minus + `exclude` patterns. +3. **Module discovery** — the nearest ancestor with a configured marker + (`module_markers`, default `[go.mod]`). +4. **Early exits (non-blocking)** — no relevant files or no modules → + commit proceeds. +5. **Interactive task selection (human mode)** — version, update warning, + module list, task checklist. Controls: + - arrow keys to move + - `space` to toggle + - `enter` to confirm + - `s` to skip all (commit proceeds) + - `q` / `ctrl+c` to cancel (commit blocked) +6. **Non-interactive modes** — markdown and JSON modes skip the selection UI + and run `default_selected` tasks (or the explicit `--task` set). +7. **Concurrency lock** — Prenup takes an OS-level advisory lock on + `.git/prenup.lock`. A second concurrent `prenup run` against the same + repo (e.g. a Git GUI that double-fires `git commit`) exits non-zero + with a clear "another prenup run is already in progress" message + instead of racing on the worktree. The lock auto-releases on process + exit, including crashes. `--dry-run` skips the lock. +8. **Stash-and-restore** — when `clean_worktree: true` (default), unstaged + changes are stashed before tasks run and restored afterward. +9. **Task execution** — tasks run sequentially; per-module tasks fan out + across modules concurrently up to `max_parallelism`. Live output streams + to the user. On first failure within a task, remaining modules for that + task are skipped and execution continues with the next task. +10. **Output staging** — `stage_output: true` tasks get their + `output_patterns` matches `git add`-ed post-success. The "before" + snapshot is taken immediately before each such task, so files + generated by an earlier task are not mis-attributed; for `per_module` + tasks, staging is further restricted to files under the task's + modules. +11. **Post-run summary** — version, update warning, full output, per-task + status with durations. +12. **Commit decision** — zero if everything passed or was intentionally + skipped; one otherwise. + +### 4.3 Skip / bypass +- `s` during selection → commit proceeds with no tasks run. +- `q` / `ctrl+c` during selection → commit blocked. +- `q` / `ctrl+c` during execution → the running task receives `SIGTERM` + with a short grace period to flush output and clean up before being + forcefully killed; commit blocked if anything failed or did not + complete. +- `git commit --no-verify` → Prenup not invoked at all. + +### 4.4 Visibility +- Everything important prints to stdout; stderr is reserved for diagnostics. +- Version prints at startup and again in the post-run summary so it survives + alt-screen teardown in wrapper clients. +- Task output streams live; the developer never waits for a long-running + command to finish to see what it's doing. +- The TUI resizes dynamically and starts small to behave well in integrated + terminals (VS Code, Windsurf) whose reported sizes can disagree. + +## 5. Configuration + +Single file, `.prenup.yaml` (or `.prenup.yml`), at the repository root. + +Glob patterns in `exclude`, `paths`, `paths_ignore`, and `output_patterns` +are validated at load time; an invalid pattern fails the run with a +pointer to the offending field rather than silently never matching. +`prenup config validate` exposes the same check as a standalone command. + +### 5.1 Top-level keys +- `version: 1` — required *integer* (not the string `"1"`). The config schema + version; see [Config schema versioning](#511-config-schema-versioning). +- `module_markers` — filenames that define a module. Default `[go.mod]`. +- `exclude` — doublestar globs filtering change detection. +- `clean_worktree` — stash unstaged changes around task runs. Default `true`. +- `max_parallelism` — bound on per-module fan-out within a task. Default + `0`, meaning `NumCPU`. +- `output` — default output mode: `auto`, `human`, `markdown`, `json`. +- `tasks` — the ordered task list. + +#### 5.1.1 Config schema versioning + +The `version` field is the config *schema* version: a plain integer, +deliberately **decoupled from the prenup release version**. It changes only +on a backward-incompatible change to the file format. Additive, non-breaking +changes (e.g. a new optional field) do **not** bump it and require no +migration — old configs keep working because new fields default sensibly. + +Version handling on load: + +- Matching version → parsed normally. +- Newer than the binary understands → rejected with a message directing the + user to upgrade prenup. +- Missing or otherwise unsupported → rejected with an "unsupported version" + error. + +When a future breaking change introduces version 2, prenup will adapt older +configs in memory (so they keep running) and offer an **opt-in** command to +rewrite the file — it will never rewrite a user's config without consent. +That machinery is intentionally deferred until there is a second version to +convert from. + +### 5.2 Per-task fields +- `name` *(required)* — display name. +- `default_selected` — pre-checked in the selection UI. +- `command` *(required)* — `bash -c`-executed command with template + expansion. +- `per_module` — run once per changed module (default working dir is the + module root) or once globally. +- `working_dir` — override for the command's working directory; template + variables supported. +- `paths` / `paths_ignore` — doublestar filters on the changed-file set the + task applies to. When no files match, the task is skipped. +- `output_patterns` — files the task may create; used by `stage_output`. +- `stage_output` — stage newly-created matches of `output_patterns`. The + before-snapshot is taken per task, and for `per_module` tasks staging + is further restricted to files under the task's modules so a generator + in module `a/` will not auto-stage files produced under module `b/`. +- `parallel` — whether per-module iterations may run concurrently. Defaults + to `true` for per-module tasks. +- `clean_worktree` — override the repo-level default. +- `env` — map of environment variables to inject. + +### 5.3 Template variables + +| Variable | Description | +|---|---| +| `{{.repo_root}}` | Absolute path to the repo root. | +| `{{.module_root}}` | Absolute path to the current module. | +| `{{.module_path}}` | Relative path to the module from the repo root. | +| `{{.module_name}}` | Basename of the module directory. | + +## 6. Behavioral Requirements + +### 6.1 Change detection +- Staged, unstaged, and untracked files are all considered "changed" for + triggering and module discovery. +- `exclude` uses doublestar globs. +- Per-task `paths` / `paths_ignore` further restrict which files a task + sees; this also restricts the module set `per_module` tasks fan out + across. +- A file is "relevant" if it is changed and not excluded. + +### 6.2 Module detection +- A module is the nearest ancestor containing any `module_markers` file. +- Modules are deduplicated and sorted. +- If no modules are detected, Prenup exits zero with an explanatory message. +- `--all` bypasses change detection and feeds a synthetic `.` module. + +### 6.3 Task scheduling +- Tasks run sequentially in configuration order. +- Per-module iterations within a task fan out concurrently when + `parallel: true` and `max_parallelism > 1`, bounded by the configured cap. +- The first module-level failure in a task skips remaining modules for that + task and cancels their in-flight work; execution continues with the next + task. +- Cancellation (Ctrl-C, parent timeout, fail-fast within a task) sends + `SIGTERM` to the running command and allows a short grace period for + it to flush output and exit cleanly before forcing termination. + +### 6.4 Stash-and-restore +- When `clean_worktree: true`, Prenup runs `git stash push --include-untracked + --keep-index` before tasks begin and pops the stash on exit. +- Failure to stash emits a notice but does not block the run. +- Per-task `clean_worktree` overrides the repo-level default. + +### 6.5 Output staging +- Before *each* `stage_output` task runs, Prenup snapshots the set of + tracked files. The per-task snapshot prevents files generated by an + earlier task in the same run from being attributed to this one. +- After success, files that are newly present in `git status` and match any + `output_patterns` are `git add`-ed. +- Pre-existing unstaged changes are not promoted. +- For `per_module` tasks, staging is further restricted to files under the + task's modules. + +### 6.6 Exit codes +- `0` — commit proceeds: nothing to do, user skipped via `s`, all tasks + succeeded or were intentionally skipped. +- `1` — commit blocked: config error (including invalid glob patterns), + change detection error, user cancel, any task failure, or another + `prenup run` already holding the per-repo lock. + +### 6.7 Version check +- Best-effort, short-timeout GitHub Releases query. +- Tokens picked up in order: `PRENUP_GITHUB_TOKEN`, `GITHUB_TOKEN`, + `GH_TOKEN`. Required only for private repos. +- Network, auth, or format failures are swallowed silently. +- When a newer version exists, a single-line warning is displayed. + +### 6.8 Concurrency safety +- Before any tasks run, Prenup acquires a non-blocking OS-level advisory + lock on `.git/prenup.lock`. +- A second concurrent `prenup run` against the same repository exits + non-zero with a clear "another prenup run is already in progress" + message including the lock-file path; it does not wait. +- The lock auto-releases on process exit, including crashes and + `kill -9`, so there is no stale-PID file to clean up. +- `--dry-run` skips the lock so a planning probe never blocks a real + commit-time run. + +## 7. Output modes + +The same run produces identical events; the rendering differs: + +- **human** — Bubble Tea TUI with a live task checklist and scrolling output + viewport. Post-run summary printed after alt-screen exit. +- **markdown** — plain-text streaming output during execution followed by a + structured markdown digest: + - `[prenup] â€Ļ` self-describing preamble identifying the tool, linking + to docs, and pointing at the structured (`--output json`) mode + - `## Prenup vX.Y.Z` header + - summary counts + - `### Task: ...` sections with status, duration, modules, and tail of + output on failure + - "Next steps" block on failure that disambiguates prenup-vs-git + attribution and documents the `--no-verify` bypass +- **json** — NDJSON event stream on stdout; one JSON object per line. Stable + schema documented in [docs/SCHEMA.md](docs/SCHEMA.md). The very first + line is an `agent_hint` event carrying the same orienting context the + markdown preamble provides, so a cold-start consumer can identify the + stream without prior knowledge of prenup. + +Auto-detection: TTY → `human`; piped or `NO_COLOR` or `TERM=dumb` → +`markdown`; `json` is always explicit. + +The agent-orienting strings and the `agent_hint` payload are intentionally +absent from the human TUI: an interactive operator already knows what +prenup is and would only see them as noise. + +## 8. Constraints & Assumptions + +- The host has `git` and `bash` on `PATH`. +- The current directory is inside a git repository. +- `.prenup.yaml` lives at the repository root. +- Commands run via `bash -c`; shell features are available. +- Linux and macOS only. + +## 9. Success Criteria + +1. Developers leave the hook installed rather than disabling it. +2. Trivial commits complete in seconds. +3. CI failures that a pre-commit task could have caught drop noticeably after + adoption. +4. Teams converge on a single shared `.prenup.yaml`. +5. Agent-driven workflows successfully parse `--output json` and react to + failures without needing a human in the loop. + +## 10. Out of scope / future considerations + +See [docs/FUTURE.md](docs/FUTURE.md) for a full catalog. Highlights: + +- Task-level DAG parallelism with `depends_on`. +- Aggregated failure summary across modules within a task. +- Caching / incremental skip for deterministic tasks. +- Plugin ecosystem beyond `module_markers`. +- `prenup stats` history and timing. +- Per-developer overrides layered on the team config. +- Windows support. diff --git a/docs/FUTURE.md b/docs/FUTURE.md new file mode 100644 index 0000000..d233dcc --- /dev/null +++ b/docs/FUTURE.md @@ -0,0 +1,165 @@ +# Prenup — future work + +This file tracks deliberate non-goals that remain good ideas for follow-up +releases. Nothing here is committed; think of it as a backlog with rationale. + +## Correctness & reliability + +### Continue-on-error within a task +Today, the first failing module in a `per_module` task cancels its +remaining modules. Offer an opt-in `fail_fast: false` per task so developers +see every failing module in one pass with an aggregated summary. + +**Acceptance:** per-task config flag; runner collects all module errors; +final `task_completed` event carries the aggregate. + +### Concurrency safety — shipped +`prenup run` takes an OS-level advisory lock on `.git/prenup.lock` before +executing any tasks. A second concurrent invocation exits non-zero with a +clear "another prenup run is already in progress for this repository" +message, including the lock-file path. The lock is released automatically +on process exit (including crashes / kill -9), so there is no stale-PID +file to clean up. `--dry-run` skips the lock so planning probes don't +contend with a real commit-time run. + +### Staging guardrails +- Warn when a `stage_output: true` task modifies files outside its + `output_patterns` (likely a config bug). +- Warn when `output_patterns` matched zero files for several runs (stale + pattern). +- Refuse to stage files covered by `.gitignore`. + +## Developer experience + +### Per-task skip in the runner UI +Today `s` skips everything. Add an in-flight "press `n` to skip this task" +affordance in the runner model, emitting a `task_completed` with +`status: skipped`. + +### `PRENUP_SKIP` env var +One-off bypass without editing config. Comma-separated task names; merged +with `--task` exclusions. + +### Caching / incremental skip +For deterministic tasks (doc generation), skip when the hash of their +inputs (files matching `paths`) is unchanged since the last successful run. +Persist hash in `.git/prenup-cache/`. + +### Result history & `prenup stats` +Persist per-task duration per run. Surface "slower than average" indicators +in the post-run summary and a `prenup stats` subcommand for trend analysis. + +### Better failure presentation +- Highlight failing lines (regex on `FAIL`, `error:`, etc.) at the top of the + summary. +- Embed `file:line` jumps where compilers produce them. + +### UI affordances +- "Module N of M" progress indicator per running task. +- Wall-clock elapsed per running task. +- Colorblind-friendly icons (don't rely on color alone). + +## Configuration surface + +### `extends` / `include` / profiles +- `extends:` a shared base config (org-wide defaults). +- `include:` merge multiple files (e.g. `.prenup.d/*.yaml`). +- `profiles: { fast: [...], full: [...] }` with `prenup run --profile`. + +### Task-level DAG parallelism +Add `depends_on` to enable independent tasks to run concurrently while +serializing generators before linters. Today's runner is +per-module-within-task only. + +### Pluggable module detectors +Beyond `module_markers`, introduce named detector plugins (Go, Node, +Python, Rust) that encode per-language intelligence (workspace detection, +monorepo conventions) rather than just a filename check. + +### Env pass-through allowlist +Today, tasks inherit prenup's environment, which under a Git hook is often +minimal. Add `pass_env: [HOME, PATH, GOPATH]` plus a globally-documented +allowlist for predictable behavior. + +### Per-developer overrides +`.prenup.local.yaml` layered on top of `.prenup.yaml` for individual +preferences (e.g. disable a slow task locally) without affecting the team +config. + +## Observability + +### Structured logging beyond event stream +In addition to the NDJSON event mode, offer an opt-in structured log file +(`--log-file PATH`) that captures the full run for later inspection — useful +for bots that need to store the trace but don't want to consume it live. + +### Opt-in telemetry +Aggregate anonymized stats on which tasks fail most often, average durations, +skip rates. Local-only unless explicitly opted in. + +## Tooling & safety + +### TUI snapshot tests +The Bubble Tea models have intricate state (selection cancel vs skip, runner +fail-fast, window resize). Add scripted input + golden-frame tests to prevent +regressions such as a cancel action inadvertently allowing the commit. + +### Hook chaining edge cases +Today `install --chain` runs `pre-commit.local` before prenup. Handle the +inverse (run prenup first, then chain) behind a flag. Also add detection for +common managed pre-commit frameworks (husky, pre-commit.com) and offer a +clean compose story. + +### Version check refinements +Distinguish "no network" (silent) from "token invalid" (one-line warning) so +misconfigured tokens get noticed without adding noise to offline setups. + +### Inter-task parallelism +Today the runner serializes tasks (`Run tests` → `Run golangci-lint` → ...) and +only fans out *within* a task across modules. For most Go repos these tasks +are independent (different binaries, different file sets, no shared mutable +state) and a developer laptop has plenty of idle headroom while a single test +run is going. A typical hook of `15s test + 8s lint + 1s changelog ≈ 24s` +collapses to `max(15, 8, 1) = 15s` if those tasks run concurrently — a 30–50% +wall-clock cut on every commit. + +Why it isn't on by default yet: +- **Resource contention can defeat the math.** Both `go test` and + `golangci-lint` already spawn `GOMAXPROCS` workers each. Running them + concurrently doubles the compile-worker count fighting for cores and the + Go build cache, so the wall-clock saving sometimes flattens or inverts. + Same hazard `make -j` users hit. +- **Output presentation needs work.** The Bubble Tea TUI assumes one + "currently running" task; parallel tasks need either per-task panes, + line prefixing, or per-task output buffering (which loses the live-stream + property we deliberately preserved). Markdown and JSON modes already + carry per-task identifiers and would handle interleaving cleanly. +- **Stash and `stage_output` semantics get subtler.** Two tasks writing + files into the worktree concurrently can race on which task gets + attribution for a generated file; the per-task `beforeTracked` snapshot + needs review under concurrent execution. +- **Failure semantics need a knob.** Today a failing task lets siblings + finish. Under parallelism we'd want `cancel_siblings_on_failure: true|false` + so a long failing test doesn't kill a near-finished lint that would have + produced useful output. + +Proposed shape (when we do this): +- New top-level `task_parallelism: 1` (default — preserves today's behavior). + Setting it to e.g. `3` allows up to N tasks to run concurrently, capped + further by the existing `max_parallelism` so the *total* concurrent + commands across all tasks is bounded. +- New per-task `serial: true` escape hatch for tasks that must run after + prior tasks complete (e.g. a final "rebuild generated code" step that + depends on tests passing). +- Default to off; recommend enabling per repo after measuring the actual + wall-clock impact, since the contention curve is workload-dependent. +- When `task_parallelism > 1` is set with `--output human`, either degrade + the TUI to a per-task running-list (no live stream) or emit a notice + recommending `--output markdown` / `--output json` for cleaner interleaving. + +## Explicitly **not** planned + +- **Windows support.** Out of scope. Many prenup primitives assume + bash, POSIX paths, and the platform's signal semantics; a Windows port + would require a non-trivial rewrite of exec and IO and is not on the + roadmap. diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md new file mode 100644 index 0000000..f35b7b0 --- /dev/null +++ b/docs/SCHEMA.md @@ -0,0 +1,181 @@ +# Prenup schemas + +This document describes the two stable contracts prenup exposes: the config +file schema (currently at `version: 1`) and the JSON event stream schema. + +## Config schema + +The authoritative machine-readable schema is +[assets/prenup.schema.json](../assets/prenup.schema.json). Editors with JSON +Schema support (VS Code YAML, IntelliJ, Zed) will offer autocomplete and +validation against it. + +### Top-level fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `version` | integer | — (required) | Config schema version; must be the integer `1`. Quoted strings (`"1"`) are rejected with a hint. | +| `module_markers` | string[] | `[go.mod]` | Filenames whose presence marks a module directory. | +| `exclude` | string[] | `[]` | Doublestar globs filtering change detection. | +| `clean_worktree` | bool | `true` | Stash unstaged changes around task execution. | +| `max_parallelism` | int | `0` | Cap on per-module fan-out. `0` means `NumCPU`. | +| `output` | string | `auto` | Default output mode. One of `auto`, `human`, `markdown`, `json`. | +| `tasks` | task[] | — (required) | Ordered task list. | + +### Task fields + +| Field | Type | Description | +|---|---|---| +| `name` | string (required) | Display name and dedup key. | +| `command` | string (required) | Shell command, run via `bash -c`. | +| `default_selected` | bool | Pre-checked in the selection UI. | +| `per_module` | bool | Run once per changed module (default working dir is the module root). | +| `working_dir` | string | Override for the command working directory. Template variables supported. | +| `paths` | string[] | Doublestar globs; restrict the task's changed-file set. Empty = all. | +| `paths_ignore` | string[] | Doublestar globs; files to exclude from the task's set. | +| `output_patterns` | string[] | Globs used by `stage_output` to identify generated files. | +| `stage_output` | bool | After success, `git add` files matching `output_patterns` that are newly present. | +| `parallel` | bool | Whether per-module iterations may run concurrently. Defaults to `true` for `per_module` tasks. | +| `clean_worktree` | bool | Override the repo-level default. | +| `env` | map[string]string | Environment variables injected for the task. | + +### Template variables + +Available inside `command` and `working_dir`: + +- `{{.repo_root}}` — absolute path to the repo root. +- `{{.module_root}}` — absolute path to the current module. +- `{{.module_path}}` — module path relative to the repo root. +- `{{.module_name}}` — basename of the module. + +## JSON event stream + +`prenup run --output json` emits NDJSON: one JSON object per line on stdout. +The stream is stable: additive changes are permitted; fields may be added, +but existing fields keep their meaning and type across minor versions. + +`prenup plan --output json` is **not** NDJSON; it emits a single +pretty-printed JSON document. See "Plan output" below. + +### Common fields + +| Field | Type | Notes | +|---|---|---| +| `type` | string | Event kind (see below). | +| `time` | RFC3339 timestamp | Present on every runner event. Omitted from the bootstrap `agent_hint` line, which is a static self-describing header rather than a timestamped event. | + +### Event kinds + +#### `agent_hint` +```json +{"type":"agent_hint","schema":"1","tool":"prenup","description":"Prenup is a Git pre-commit hook runner ...","homepage":"https://github.com/c2fo/prenup","hook_context":"This output is produced by the prenup pre-commit hook ...","bypass_hint":"Re-run `git commit` after fixing the issue, or use `git commit --no-verify` ...","stream_format":"ndjson","event_types_note":"Subsequent lines are runner events: ..."} +``` +- Always the **first** line of the stream, emitted before any runner event. +- Self-describing bootstrap so a consumer that has no prior knowledge of + prenup can identify the tool, find its docs, learn the stream format, and + know how to recover on failure -- all from the first line of output. +- `schema` versions the agent_hint payload itself; bumped when the shape + of the strings or surrounding fields changes. +- Consumers that don't care can skip any line whose `type` they don't + recognize and continue parsing as normal. +- **Not** emitted from `prenup plan --output json` (that command produces + a single JSON document, not an event stream). + +#### `run_started` +```json +{"type":"run_started","time":"...","version":"v0.1.0","repo_root":"/path/to/repo","modules":["pkg/foo"],"tasks":["Run tests"],"message":"Update available v0.2.0 ..."} +``` +- `repo_root` — absolute path of the git repository prenup was invoked + from. Anchors every subsequent task's `working_dir` so consumers do + not have to infer the root from substring matches. +- `modules` — final module list after exclude/path filters. +- `tasks` — tasks that will attempt to run. +- `message` — optional update-notice string; never blocks the run. + +#### `task_started` +```json +{"type":"task_started","time":"...","task":"Run tests","module":"pkg/foo","command":"go test ./...","working_dir":"/repo/pkg/foo"} +``` +- Emitted once per module per task invocation. +- `command` is the resolved shell command after template expansion + (`{{.repo_root}}`, `{{.module_root}}`, etc.). A consumer can copy/paste + it to reproduce the run without parsing `.prenup.yaml`. +- `working_dir` is the absolute path the command was run in. For + per-module tasks it is the module root; otherwise it is the value of + the task's `working_dir` (template-expanded) or the repo root. +- Both fields are omitted when empty (e.g. malformed task config). + +#### `line` +```json +{"type":"line","time":"...","task":"Run tests","module":"pkg/foo","stream":"stdout","text":"ok pkg/foo 0.12s"} +``` +- `stream` is `stdout` or `stderr`. +- `text` is one unredirected line; no trailing newline. +- Lines exceeding ~1 MB are split and a synthetic + `[prenup] output truncated: ... (line exceeded 1048576 bytes)` line is + emitted on the same stream, so consumers can tell that the source produced + an oversized line rather than silently losing data. + +#### `task_completed` +```json +{"type":"task_completed","time":"...","task":"Run tests","status":"done","duration_ms":120} +``` +- `status` is `done`, `failed`, or `skipped`. +- Emitted once per task after all its module iterations complete or are + skipped. +- For `skipped`, `message` may describe why. + +#### `notice` +```json +{"type":"notice","time":"...","message":"failed to restore stash: ..."} +{"type":"notice","time":"...","task":"Run tests","module":"pkg/b","message":"fail-fast: sibling module failed"} +``` +- Non-fatal diagnostic messages. Does not affect exit code. +- May carry `task` and `module` fields. In particular, when one module of a + `per_module` task fails, the remaining modules for that task are reported + via per-module `notice` events so consumers can see exactly which modules + were aborted by fail-fast. + +#### `run_completed` +```json +{"type":"run_completed","time":"...","succeeded":1,"failed":1,"exit_code":1,"failed_tasks":["Run tests"]} +``` +- Always the last event. `exit_code` matches the process exit code. +- `failed_tasks` — names of tasks that ended in `failed` status, in + selection order. Omitted when no task failed. Lets a consumer index + the failures in O(1) without rescanning every `task_completed` event. + +### Plan output (`prenup plan --output json`) + +Not NDJSON: a single pretty-printed JSON document describing the plan. + +```json +{ + "repo_root": "/abs/path", + "modules": ["pkg/foo"], + "tasks": [ + { + "name": "Run tests", + "selected": true, + "per_module": true, + "clean_worktree": true, + "parallel": true, + "modules": ["pkg/foo"] + } + ] +} +``` + +## Stability + +- The event `type` values are final. +- Existing fields may be extended with new enum values only in major + versions. +- New fields may be added at any time; consumers must ignore unknown fields. +- The config `version` integer tracks schema major versions. +- The embedded schema (consulted by `prenup config validate` and exported by + `prenup config schema`) and the public asset at + [`assets/prenup.schema.json`](../assets/prenup.schema.json) are guaranteed + to be byte-identical: a CI test fails the build if they ever diverge. + External consumers can safely fetch the asset URL without worrying about + drift from the binary's view of the schema. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d3a3aa --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module github.com/c2fo/prenup + +go 1.26 + +require ( + github.com/bmatcuk/doublestar/v4 v4.10.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/mattn/go-isatty v0.0.21 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + golang.org/x/mod v0.32.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dd90eef --- /dev/null +++ b/go.sum @@ -0,0 +1,82 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/commands_integration_test.go b/internal/cli/commands_integration_test.go new file mode 100644 index 0000000..e502482 --- /dev/null +++ b/internal/cli/commands_integration_test.go @@ -0,0 +1,168 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/suite" +) + +// CommandsIntegrationTestSuite drives runInstall, runPlan, and root Execute +// against a real temp git repo. Each of these needs the same fixture +// (git init + chdir), so a suite keeps setup DRY. +type CommandsIntegrationTestSuite struct { + suite.Suite + repo string +} + +func (s *CommandsIntegrationTestSuite) SetupTest() { + dir := s.T().TempDir() + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + {"config", "commit.gpgsign", "false"}, + } { + cmd := exec.Command("git", args...) //nolint:gosec // G204: fixed binary, internal args. + cmd.Dir = dir + out, err := cmd.CombinedOutput() + s.Require().NoErrorf(err, "git %s: %s", strings.Join(args, " "), string(out)) + } + // Some dev environments have a global `init.templateDir` or + // `core.hooksPath` that seeds .git/hooks/ with existing hook scripts + // on `git init`. Clear the hooks directory so the install-conflict + // tests can control whether a pre-commit exists. + hooksDir := filepath.Join(dir, ".git", "hooks") + entries, err := os.ReadDir(hooksDir) + if err == nil { + for _, e := range entries { + _ = os.Remove(filepath.Join(hooksDir, e.Name())) + } + } + s.repo = dir + s.T().Chdir(dir) +} + +// TestRunInstall_HappyPath verifies the fresh-repo install: no existing +// pre-commit hook, so no conflict resolution is needed and the hook file is +// created. +func (s *CommandsIntegrationTestSuite) TestRunInstall_HappyPath() { + cmd := newInstallCmd() + s.Require().NoError(runInstall(cmd)) + + info, err := os.Stat(filepath.Join(s.repo, ".git", "hooks", "pre-commit")) + s.Require().NoError(err) + s.False(info.IsDir()) + s.NotZero(info.Mode()&0o100, "hook should be executable") +} + +// TestInstallWithConflictResolution_NonInteractiveFailsFast pins the +// documented CI/scripting contract: if a hook already exists, --non-interactive +// (or a non-TTY stdin) surfaces the conflict as an error rather than +// hanging on a prompt. +func (s *CommandsIntegrationTestSuite) TestInstallWithConflictResolution_NonInteractiveFailsFast() { + hooksDir := filepath.Join(s.repo, ".git", "hooks") + s.Require().NoError(os.MkdirAll(hooksDir, 0o750)) + s.Require().NoError(os.WriteFile(filepath.Join(hooksDir, "pre-commit"), + []byte("#!/bin/sh\necho existing\n"), 0o600)) + + err := installWithConflictResolution(s.repo, "/tmp/prenup", installFlags{nonInteractive: true}) + s.Require().Error(err) + s.Contains(err.Error(), "already exists") +} + +// TestInstallWithConflictResolution_ForceReplaces confirms --force bypasses +// the conflict prompt path entirely: hook.Install returns nil (no +// ExistsError), so the conflict-resolution path is a straight passthrough. +func (s *CommandsIntegrationTestSuite) TestInstallWithConflictResolution_ForceReplaces() { + hooksDir := filepath.Join(s.repo, ".git", "hooks") + s.Require().NoError(os.MkdirAll(hooksDir, 0o750)) + s.Require().NoError(os.WriteFile(filepath.Join(hooksDir, "pre-commit"), + []byte("#!/bin/sh\necho existing\n"), 0o600)) + + err := installWithConflictResolution(s.repo, "/tmp/prenup", installFlags{force: true}) + s.Require().NoError(err) + + data, err := os.ReadFile(filepath.Join(hooksDir, "pre-commit")) //nolint:gosec // G304: test-controlled path inside t.TempDir(). + s.Require().NoError(err) + s.Contains(string(data), "prenup", "force should replace the existing hook body") +} + +// TestRunPlan_NoConfigProducesHelpfulError makes sure `prenup plan` in a +// repo without a .prenup.yaml points the user at what to do. +func (s *CommandsIntegrationTestSuite) TestRunPlan_NoConfigProducesHelpfulError() { + cmd := newPlanCmd() + err := runPlan(cmd) + s.Require().Error(err) + s.Contains(err.Error(), "no .prenup.yaml found") +} + +// TestRunPlan_WithConfigRenders exercises the successful text-render path +// against a minimal in-repo config. +func (s *CommandsIntegrationTestSuite) TestRunPlan_WithConfigRenders() { + cfg := `version: 1 +tasks: + - name: "Echo" + command: "echo hi" + default_selected: true +` + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, ".prenup.yaml"), []byte(cfg), 0o600)) + + // Stage a file so change discovery finds something and BuildPlan has + // modules to fan out over (or a "." fallback). + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, "hello.txt"), []byte("hi"), 0o600)) + add := exec.Command("git", "add", "hello.txt") + add.Dir = s.repo + s.Require().NoError(add.Run()) + + out := captureStdout(s.T(), func() error { + return runPlan(newPlanCmd()) + }) + s.Contains(out, "Repository:") + s.Contains(out, "Echo") +} + +// TestRunPlan_JSONMode covers the --output json branch. +func (s *CommandsIntegrationTestSuite) TestRunPlan_JSONMode() { + cfg := `version: 1 +tasks: + - name: "Echo" + command: "echo hi" + default_selected: true +` + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, ".prenup.yaml"), []byte(cfg), 0o600)) + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, "hello.txt"), []byte("hi"), 0o600)) + add := exec.Command("git", "add", "hello.txt") + add.Dir = s.repo + s.Require().NoError(add.Run()) + + cmd := newPlanCmd() + s.Require().NoError(cmd.Flags().Set("output", "json")) + + out := captureStdout(s.T(), func() error { return runPlan(cmd) }) + s.Contains(out, `"repo_root"`) + s.Contains(out, `"Echo"`) +} + +// TestExecute_HelpReturnsZero uses the Execute entry point with --help to +// exercise the top-level plumbing without triggering any subcommand's +// heavy work. It's an integration smoke test: os.Args → Execute → 0. +func (s *CommandsIntegrationTestSuite) TestExecute_HelpReturnsZero() { + origArgs := os.Args + s.T().Cleanup(func() { os.Args = origArgs }) + os.Args = []string{"prenup", "--help"} + + // Execute writes usage to stdout; capture it so the test output stays + // clean. + _ = captureStdout(s.T(), func() error { + s.Equal(0, Execute()) + return nil + }) +} + +func TestCommandsIntegrationSuite(t *testing.T) { + suite.Run(t, new(CommandsIntegrationTestSuite)) +} diff --git a/internal/cli/commands_metadata_test.go b/internal/cli/commands_metadata_test.go new file mode 100644 index 0000000..2d7d119 --- /dev/null +++ b/internal/cli/commands_metadata_test.go @@ -0,0 +1,127 @@ +package cli + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCommandConstructorsSetMetadata asserts that every cobra command +// constructor returns a command with a non-empty Use, Short, and any flags +// the CLI advertises. Metadata regressions (accidentally dropping a flag, +// renaming Use) are caught here without shelling out to the built binary. +func TestCommandConstructorsSetMetadata(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + build func() *cobra.Command + wantUse string + wantFlags []string + }{ + { + name: "root", + build: newRootCmd, + wantUse: "prenup", + wantFlags: []string{ + "config", "output", "task", "all", + "no-interactive", "no-clean-worktree", "parallelism", "dry-run", + }, + }, + { + name: "run", + build: newRunCmd, + wantUse: "run", + wantFlags: []string{ + "config", "output", "task", "all", + "no-interactive", "no-clean-worktree", "parallelism", "dry-run", + }, + }, + {name: "plan", build: newPlanCmd, wantUse: "plan", wantFlags: []string{"config", "output", "all"}}, + { + name: "install", + build: newInstallCmd, + wantUse: "install", + wantFlags: []string{ + "force", "replace", "chain", "binary", "use-path", "non-interactive", + }, + }, + {name: "uninstall", build: newUninstallCmd, wantUse: "uninstall"}, + {name: "init", build: newInitCmd, wantUse: "init", wantFlags: []string{"force"}}, + {name: "version", build: newVersionCmd, wantUse: "version"}, + {name: "config", build: newConfigCmd, wantUse: "config"}, + {name: "config validate", build: newConfigValidateCmd, wantUse: "validate [path]"}, + {name: "config schema", build: newConfigSchemaCmd, wantUse: "schema"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + is := assert.New(t) + must := require.New(t) + + cmd := tc.build() + must.NotNil(cmd, "constructor returned nil") + is.Equal(tc.wantUse, cmd.Use) + is.NotEmpty(cmd.Short, "Short description should not be empty") + + for _, flag := range tc.wantFlags { + is.NotNil(cmd.Flags().Lookup(flag), "expected flag %q", flag) + } + }) + } +} + +// TestRootCommandWiresSubcommands guards against a subcommand being +// dropped from the command tree. +func TestRootCommandWiresSubcommands(t *testing.T) { + t.Parallel() + + root := newRootCmd() + got := map[string]bool{} + for _, c := range root.Commands() { + got[c.Name()] = true + } + + for _, want := range []string{"run", "plan", "install", "uninstall", "init", "config", "version"} { + assert.Truef(t, got[want], "root should expose %q subcommand; got %v", want, got) + } + assert.False(t, got["migrate"], "migrate command should have been removed for v0.1.0") +} + +// TestConfigCommandWiresSubcommands pins the two `config` children. +func TestConfigCommandWiresSubcommands(t *testing.T) { + t.Parallel() + + cfg := newConfigCmd() + got := map[string]bool{} + for _, c := range cfg.Commands() { + got[c.Name()] = true + } + assert.True(t, got["validate"], "config validate should be registered") + assert.True(t, got["schema"], "config schema should be registered") +} + +// TestExitCodeError_Error covers both branches: with a wrapped err (returns +// the wrapped message) and without one (returns a generic "exit status N"). +func TestExitCodeError_Error(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err *exitCodeError + want string + }{ + {name: "wrapped error message wins", err: &exitCodeError{code: 42, err: errors.New("boom")}, want: "boom"}, + {name: "no wrapped error falls back to code", err: &exitCodeError{code: 3}, want: "exit status 3"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, tc.err.Error()) + }) + } +} diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go new file mode 100644 index 0000000..4a007b0 --- /dev/null +++ b/internal/cli/config_cmd.go @@ -0,0 +1,69 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/c2fo/prenup/internal/config" + "github.com/c2fo/prenup/internal/git" +) + +// newConfigCmd houses configuration-related subcommands. +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Inspect prenup configuration", + } + cmd.AddCommand(newConfigValidateCmd(), newConfigSchemaCmd()) + return cmd +} + +func newConfigValidateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "validate [path]", + Short: "Validate a .prenup.yaml file against the schema", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := git.RepoRoot("") + if err != nil { + return err + } + var path string + if len(args) == 1 { + path = args[0] + } else { + p, err := config.Find(repoRoot) + if err != nil { + return err + } + if p == "" { + return fmt.Errorf("no config file found in %s", repoRoot) + } + path = p + } + cfg, err := config.Load(path, repoRoot) + if err != nil { + return err + } + if _, err := fmt.Fprintf(os.Stdout, "OK: %s parses cleanly (v%d, %d tasks)\n", + cfg.Path, cfg.Version, len(cfg.Tasks)); err != nil { + return err + } + return nil + }, + } + return cmd +} + +func newConfigSchemaCmd() *cobra.Command { + return &cobra.Command{ + Use: "schema", + Short: "Print the embedded JSON schema for .prenup.yaml", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := os.Stdout.Write(config.Schema()) + return err + }, + } +} diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go new file mode 100644 index 0000000..f8c081a --- /dev/null +++ b/internal/cli/config_cmd_test.go @@ -0,0 +1,118 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/suite" +) + +// ConfigCommandTestSuite exercises the `prenup config validate` and `prenup +// config schema` handlers end-to-end. Both need a git repo (validate uses +// git.RepoRoot to anchor path lookups; schema does not, but sharing the +// fixture is cheaper than a second suite). +type ConfigCommandTestSuite struct { + suite.Suite + repo string +} + +func (s *ConfigCommandTestSuite) SetupTest() { + dir := s.T().TempDir() + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + {"config", "commit.gpgsign", "false"}, + } { + cmd := exec.Command("git", args...) //nolint:gosec // G204: fixed binary, internal args. + cmd.Dir = dir + out, err := cmd.CombinedOutput() + s.Require().NoErrorf(err, "git %s: %s", strings.Join(args, " "), string(out)) + } + s.repo = dir + s.T().Chdir(dir) +} + +// TestValidate_WithInRepoConfig covers the discover-then-parse path: +// `prenup config validate` (no args) finds .prenup.yaml at the repo root +// and reports OK. +func (s *ConfigCommandTestSuite) TestValidate_WithInRepoConfig() { + cfg := `version: 1 +tasks: + - name: "Echo" + command: "echo hi" +` + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, ".prenup.yaml"), []byte(cfg), 0o600)) + + out := captureStdout(s.T(), func() error { + return newConfigValidateCmd().RunE(nil, nil) + }) + s.Contains(out, "OK:") + s.Contains(out, "v1,") + s.Contains(out, "1 tasks") +} + +// TestValidate_ExplicitPathArg covers the explicit-arg branch: passing a +// path directly bypasses discovery. +func (s *ConfigCommandTestSuite) TestValidate_ExplicitPathArg() { + explicit := filepath.Join(s.repo, "custom.yaml") + cfg := `version: 1 +tasks: + - name: "Explicit" + command: "true" +` + s.Require().NoError(os.WriteFile(explicit, []byte(cfg), 0o600)) + + out := captureStdout(s.T(), func() error { + return newConfigValidateCmd().RunE(nil, []string{explicit}) + }) + // `config validate` prints `OK: parses cleanly (v, tasks)`; + // assert against those fields (the task *name* is intentionally not + // echoed, so don't grep for it). + s.Contains(out, "OK:") + s.Contains(out, explicit) + s.Contains(out, "v1,") + s.Contains(out, "1 tasks") +} + +// TestValidate_MissingConfig surfaces a helpful error when there is no +// .prenup.yaml at the repo root and no explicit path was given. +func (s *ConfigCommandTestSuite) TestValidate_MissingConfig() { + err := newConfigValidateCmd().RunE(nil, nil) + s.Require().Error(err) + s.Contains(err.Error(), "no config file found") +} + +// TestValidate_InvalidConfigSurfacesParseError guards the parse-error +// pass-through: a schema violation must reach the user, not silently pass. +func (s *ConfigCommandTestSuite) TestValidate_InvalidConfigSurfacesParseError() { + invalid := filepath.Join(s.repo, "bad.yaml") + s.Require().NoError(os.WriteFile(invalid, []byte(`version: "1"`+"\n"), 0o600)) + + err := newConfigValidateCmd().RunE(nil, []string{invalid}) + s.Require().Error(err) + // The exact message comes from config.Parse's versionTypeHint. + s.Contains(err.Error(), "version") +} + +// TestSchema_PrintsEmbeddedJSONSchema pins that `prenup config schema` emits +// the JSON schema embedded in the binary; the output must parse as JSON and +// declare the right title. +func (s *ConfigCommandTestSuite) TestSchema_PrintsEmbeddedJSONSchema() { + out := captureStdout(s.T(), func() error { + return newConfigSchemaCmd().RunE(nil, nil) + }) + + var schema map[string]any + s.Require().NoError(json.Unmarshal([]byte(out), &schema)) + s.Contains(schema, "$schema") + s.Contains(schema, "properties") +} + +func TestConfigCommandSuite(t *testing.T) { + suite.Run(t, new(ConfigCommandTestSuite)) +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..f852069 --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,130 @@ +package cli + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/c2fo/prenup/internal/config" + "github.com/c2fo/prenup/internal/git" +) + +// newInitCmd scaffolds a starter .prenup.yaml from light repo inspection. +func newInitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Scaffold a starter .prenup.yaml in the repository root", + RunE: func(cmd *cobra.Command, args []string) error { + return runInit(cmd) + }, + } + cmd.Flags().Bool("force", false, "overwrite an existing config file") + return cmd +} + +func runInit(cmd *cobra.Command) error { + force, _ := cmd.Flags().GetBool("force") + + repoRoot, err := git.RepoRoot("") + if err != nil { + return err + } + + target := filepath.Join(repoRoot, ".prenup.yaml") + if _, err := os.Stat(target); err == nil && !force { + return fmt.Errorf("%s already exists (use --force to overwrite)", target) + } + + cfg := config.DefaultConfig() + cfg.CleanWorktree = nil // let it inherit the default on load + + hasGoMod := scanForGoMod(repoRoot) + hasLint := scanForFile(repoRoot, ".golangci.yml", ".golangci.yaml") + hasMakefile := scanForFile(repoRoot, "Makefile") + + cfg.Exclude = []string{".github/**", "**/*.yaml", "**/*.yml"} + + if hasGoMod { + cfg.Tasks = append(cfg.Tasks, config.Task{ + Name: "Run tests", + DefaultSelected: true, + Command: "go test ./...", + PerModule: true, + Paths: []string{"**/*.go"}, + }) + if hasLint { + cfg.Tasks = append(cfg.Tasks, config.Task{ + Name: "Run golangci-lint", + DefaultSelected: true, + Command: "golangci-lint run --max-same-issues 0 ./...", + PerModule: true, + Paths: []string{"**/*.go"}, + }) + } + } + if hasMakefile { + cfg.Tasks = append(cfg.Tasks, config.Task{ + Name: "Check Makefile targets", + DefaultSelected: false, + Command: "make --dry-run help", + PerModule: false, + }) + } + if len(cfg.Tasks) == 0 { + cfg.Tasks = append(cfg.Tasks, config.Task{ + Name: "Example task", + DefaultSelected: false, + Command: "echo 'replace me with a real command'", + }) + } + + out, err := config.Marshal(cfg) + if err != nil { + return err + } + if err := os.WriteFile(target, out, 0o600); err != nil { + return err + } + if _, err := fmt.Fprintf(os.Stdout, "Wrote %s\n", target); err != nil { + return err + } + return nil +} + +func scanForGoMod(root string) bool { + found := false + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return filepath.SkipDir + } + if d.IsDir() { + base := d.Name() + if strings.HasPrefix(base, ".") && base != "." { + return filepath.SkipDir + } + if base == "vendor" || base == "node_modules" { + return filepath.SkipDir + } + return nil + } + if d.Name() == "go.mod" { + found = true + return filepath.SkipAll + } + return nil + }) + return found +} + +func scanForFile(root string, names ...string) bool { + for _, name := range names { + if _, err := os.Stat(filepath.Join(root, name)); err == nil { + return true + } + } + return false +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..566822c --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,174 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// TestScanForFile is a truth-table for the helper that answers "does any of +// these names exist at the repo root?". +func TestScanForFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Makefile"), []byte("all:\n"), 0o600)) + + cases := []struct { + name string + names []string + want bool + }{ + {name: "exact match found", names: []string{"Makefile"}, want: true}, + {name: "no match", names: []string{"nope.yml"}, want: false}, + {name: "second candidate matches", names: []string{"missing", "Makefile"}, want: true}, + {name: "empty candidate list", names: nil, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, scanForFile(dir, tc.names...)) + }) + } +} + +// TestScanForGoMod covers presence/absence and the pruning rules (hidden +// dirs, vendor/, node_modules/). Rather than a table we just build a couple +// of directory shapes since the layout is the interesting part. +func TestScanForGoMod(t *testing.T) { + t.Parallel() + + t.Run("finds nested go.mod", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + nested := filepath.Join(dir, "pkg", "sub") + require.NoError(t, os.MkdirAll(nested, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(nested, "go.mod"), []byte("module x\n"), 0o600)) + assert.True(t, scanForGoMod(dir)) + }) + + t.Run("returns false when only go.mod under vendor/", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + vend := filepath.Join(dir, "vendor", "example.com", "y") + require.NoError(t, os.MkdirAll(vend, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(vend, "go.mod"), []byte("module y\n"), 0o600)) + assert.False(t, scanForGoMod(dir), "vendor/ should be pruned") + }) + + t.Run("returns false in empty dir", func(t *testing.T) { + t.Parallel() + assert.False(t, scanForGoMod(t.TempDir())) + }) +} + +// InitCommandTestSuite exercises runInit end-to-end. Each test needs a +// freshly-initialized git repo and a chdir into it (because `prenup init` +// calls git.RepoRoot("") which is CWD-relative). A suite with SetupTest +// covers both cheaply. +type InitCommandTestSuite struct { + suite.Suite + repo string +} + +func (s *InitCommandTestSuite) SetupTest() { + dir := s.T().TempDir() + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + {"config", "commit.gpgsign", "false"}, + } { + cmd := exec.Command("git", args...) //nolint:gosec // G204: fixed binary, internal args. + cmd.Dir = dir + out, err := cmd.CombinedOutput() + s.Require().NoErrorf(err, "git %s: %s", strings.Join(args, " "), string(out)) + } + s.repo = dir + s.T().Chdir(dir) +} + +// TestRunInit_ScaffoldsHeader_and_Version verifies the two public contracts +// the fresh-init output must always satisfy: (1) the discoverability header +// linking to the project, and (2) the correct schema version. +func (s *InitCommandTestSuite) TestRunInit_ScaffoldsHeader_and_Version() { + cmd := newInitCmd() + s.Require().NoError(runInit(cmd)) + + data, err := os.ReadFile(filepath.Join(s.repo, ".prenup.yaml")) + s.Require().NoError(err) + got := string(data) + + s.Contains(got, "https://github.com/c2fo/prenup", + "scaffolded config must advertise the project URL") + s.Contains(got, "version: 1", + "scaffolded config must declare the current schema version") + s.Contains(got, "tasks:") +} + +// TestRunInit_TailorsToRepoContents demonstrates that init responds to what +// it finds in the repo: a go.mod triggers a "Run tests" task, and a +// .golangci.yml triggers a lint task. +func (s *InitCommandTestSuite) TestRunInit_TailorsToRepoContents() { + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, "go.mod"), []byte("module example.com/x\n"), 0o600)) + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, ".golangci.yml"), []byte("run: {}\n"), 0o600)) + + cmd := newInitCmd() + s.Require().NoError(runInit(cmd)) + + data, err := os.ReadFile(filepath.Join(s.repo, ".prenup.yaml")) + s.Require().NoError(err) + got := string(data) + + s.Contains(got, "Run tests") + s.Contains(got, "go test ./...") + s.Contains(got, "Run golangci-lint") + s.Contains(got, "golangci-lint run") +} + +// TestRunInit_ExampleTask_WhenRepoIsEmpty covers the fallback branch: when +// there's no go.mod / Makefile, init still produces a valid file with a +// placeholder task. +func (s *InitCommandTestSuite) TestRunInit_ExampleTask_WhenRepoIsEmpty() { + cmd := newInitCmd() + s.Require().NoError(runInit(cmd)) + + data, err := os.ReadFile(filepath.Join(s.repo, ".prenup.yaml")) + s.Require().NoError(err) + s.Contains(string(data), "Example task") +} + +// TestRunInit_RefusesToOverwrite_WithoutForce guards the safety rule: a +// second init against a repo that already has .prenup.yaml must fail unless +// --force is set. +func (s *InitCommandTestSuite) TestRunInit_RefusesToOverwrite_WithoutForce() { + first := newInitCmd() + s.Require().NoError(runInit(first)) + + second := newInitCmd() + err := runInit(second) + s.Require().Error(err) + s.Contains(err.Error(), "already exists") + s.Contains(err.Error(), "--force") +} + +// TestRunInit_ForceOverwrites verifies --force lets a re-run replace the +// prior scaffold. +func (s *InitCommandTestSuite) TestRunInit_ForceOverwrites() { + first := newInitCmd() + s.Require().NoError(runInit(first)) + + second := newInitCmd() + s.Require().NoError(second.Flags().Set("force", "true")) + s.Require().NoError(runInit(second)) +} + +func TestInitCommandSuite(t *testing.T) { + suite.Run(t, new(InitCommandTestSuite)) +} diff --git a/internal/cli/install.go b/internal/cli/install.go new file mode 100644 index 0000000..29d34f1 --- /dev/null +++ b/internal/cli/install.go @@ -0,0 +1,191 @@ +package cli + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + "github.com/c2fo/prenup/internal/git" + "github.com/c2fo/prenup/internal/hook" +) + +// newInstallCmd writes .git/hooks/pre-commit. +func newInstallCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "install", + Short: "Install prenup as the pre-commit hook in this repository", + RunE: func(cmd *cobra.Command, args []string) error { + return runInstall(cmd) + }, + } + cmd.Flags().Bool("force", false, "overwrite any existing pre-commit hook without backup") + cmd.Flags().Bool("replace", false, "back up the existing hook and replace it") + cmd.Flags().Bool("chain", false, "save the existing hook as pre-commit.local and chain it before prenup") + cmd.Flags().String("binary", "", "path to the prenup binary (defaults to the currently running executable)") + cmd.Flags().Bool("use-path", false, + "invoke `prenup` from PATH inside the hook instead of recording an absolute path (good for shared dotfiles)") + cmd.Flags().Bool("non-interactive", false, "refuse to prompt; fail if a non-prenup hook exists and no mode flag was given") + return cmd +} + +// installFlags is the parsed view of `prenup install`'s flag set. +type installFlags struct { + force bool + replace bool + chain bool + binary string + usePath bool + nonInteractive bool +} + +func readInstallFlags(cmd *cobra.Command) installFlags { + f := installFlags{} + f.force, _ = cmd.Flags().GetBool("force") + f.replace, _ = cmd.Flags().GetBool("replace") + f.chain, _ = cmd.Flags().GetBool("chain") + f.binary, _ = cmd.Flags().GetString("binary") + f.usePath, _ = cmd.Flags().GetBool("use-path") + f.nonInteractive, _ = cmd.Flags().GetBool("non-interactive") + return f +} + +// resolveInstallBinary picks the binary path to embed in the hook script. +func resolveInstallBinary(f installFlags) (string, error) { + switch { + case f.usePath && f.binary != "": + return "", errors.New("--use-path and --binary are mutually exclusive") + case f.usePath: + return "prenup", nil + case f.binary != "": + return f.binary, nil + default: + return resolveBinary() + } +} + +// installModeFromFlags returns the explicit mode requested by --force/ +// --replace/--chain, defaulting to ModeAbort when none is supplied. +func installModeFromFlags(f installFlags) hook.Mode { + switch { + case f.force: + return hook.ModeForce + case f.replace: + return hook.ModeReplace + case f.chain: + return hook.ModeChain + } + return hook.ModeAbort +} + +func runInstall(cmd *cobra.Command) error { + f := readInstallFlags(cmd) + repoRoot, err := git.RepoRoot("") + if err != nil { + return err + } + binary, err := resolveInstallBinary(f) + if err != nil { + return err + } + + if err := installWithConflictResolution(repoRoot, binary, f); err != nil { + return err + } + + _, err = fmt.Fprintf(os.Stdout, "Installed pre-commit hook at %s\n", + filepath.Join(repoRoot, ".git", "hooks", "pre-commit")) + return err +} + +// installWithConflictResolution performs the install and, on ExistsError, +// either fails fast (non-interactive contexts) or prompts the user for a +// resolution mode and retries. +func installWithConflictResolution(repoRoot, binary string, f installFlags) error { + err := hook.Install(repoRoot, binary, installModeFromFlags(f)) + var existsErr *hook.ExistsError + if !errors.As(err, &existsErr) { + return err + } + if f.nonInteractive || !isatty.IsTerminal(os.Stdin.Fd()) { + return err + } + choice, promptErr := promptInstallMode(existsErr.Path) + if promptErr != nil { + return promptErr + } + if choice == hook.ModeAbort { + return errors.New("install aborted") + } + return hook.Install(repoRoot, binary, choice) +} + +func newUninstallCmd() *cobra.Command { + return &cobra.Command{ + Use: "uninstall", + Short: "Remove the prenup pre-commit hook", + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := git.RepoRoot("") + if err != nil { + return err + } + if err := hook.Uninstall(repoRoot); err != nil { + return err + } + if _, err := fmt.Fprintln(os.Stdout, "Pre-commit hook removed."); err != nil { + return err + } + return nil + }, + } +} + +// resolveBinary returns the absolute path to the currently running prenup +// binary. Prefer os.Executable; fall back to looking it up on PATH. +func resolveBinary() (string, error) { + exe, err := os.Executable() + if err == nil { + if abs, aerr := filepath.EvalSymlinks(exe); aerr == nil { + return abs, nil + } + return exe, nil + } + if p, lerr := exec.LookPath("prenup"); lerr == nil { + return p, nil + } + return "", fmt.Errorf("could not determine prenup binary path: %w", err) +} + +func promptInstallMode(existingPath string) (hook.Mode, error) { + // Stderr writes for an interactive prompt are routinely ignorable: a + // failing terminal would also fail the subsequent ReadString. + _, _ = fmt.Fprintf(os.Stderr, "A pre-commit hook already exists at %s.\n", existingPath) + _, _ = fmt.Fprintln(os.Stderr, "Choose how to proceed:") + _, _ = fmt.Fprintln(os.Stderr, " [r]eplace (back up existing hook and replace with prenup)") + _, _ = fmt.Fprintln(os.Stderr, " [c]hain (keep existing hook as pre-commit.local, run it before prenup)") + _, _ = fmt.Fprintln(os.Stderr, " [f]orce (overwrite without backup)") + _, _ = fmt.Fprintln(os.Stderr, " [a]bort") + _, _ = fmt.Fprint(os.Stderr, "> ") + + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return hook.ModeAbort, err + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "r", "replace": + return hook.ModeReplace, nil + case "c", "chain": + return hook.ModeChain, nil + case "f", "force": + return hook.ModeForce, nil + default: + return hook.ModeAbort, nil + } +} diff --git a/internal/cli/install_helpers_test.go b/internal/cli/install_helpers_test.go new file mode 100644 index 0000000..7b905c0 --- /dev/null +++ b/internal/cli/install_helpers_test.go @@ -0,0 +1,118 @@ +package cli + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/c2fo/prenup/internal/hook" +) + +// TestInstallModeFromFlags is a truth table for the flag → hook.Mode mapping. +// force wins over replace wins over chain wins over abort (default). +func TestInstallModeFromFlags(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + flags installFlags + want hook.Mode + }{ + {name: "no flags → abort (safe default)", flags: installFlags{}, want: hook.ModeAbort}, + {name: "force wins", flags: installFlags{force: true, replace: true, chain: true}, want: hook.ModeForce}, + {name: "replace beats chain", flags: installFlags{replace: true, chain: true}, want: hook.ModeReplace}, + {name: "chain only", flags: installFlags{chain: true}, want: hook.ModeChain}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, installModeFromFlags(tc.flags)) + }) + } +} + +// TestResolveInstallBinary covers the precedence rules for picking what to +// embed in the hook script: +// - --use-path + --binary are mutually exclusive (error) +// - --use-path alone → literal "prenup" (PATH-resolved at hook run time) +// - --binary alone → that path verbatim +// - no flags → falls through to resolveBinary(), which returns something +// non-empty (the currently running test binary) +func TestResolveInstallBinary(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + flags installFlags + wantExact string // when non-empty, require exact match + wantErr string + wantNonEmpty bool + }{ + {name: "use-path and binary conflict", flags: installFlags{usePath: true, binary: "/x"}, wantErr: "mutually exclusive"}, + {name: "use-path alone", flags: installFlags{usePath: true}, wantExact: "prenup"}, + {name: "explicit binary path", flags: installFlags{binary: "/custom/prenup"}, wantExact: "/custom/prenup"}, + {name: "default falls through to resolveBinary", flags: installFlags{}, wantNonEmpty: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := resolveInstallBinary(tc.flags) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + if tc.wantExact != "" { + assert.Equal(t, tc.wantExact, got) + return + } + if tc.wantNonEmpty { + assert.NotEmpty(t, got) + } + }) + } +} + +// TestReadInstallFlags round-trips every install flag through a fresh cobra +// command to catch drift between the flag registration in newInstallCmd and +// the reader in readInstallFlags. +func TestReadInstallFlags(t *testing.T) { + t.Parallel() + + cmd := newInstallCmd() + must := require.New(t) + + must.NoError(cmd.Flags().Set("force", "true")) + must.NoError(cmd.Flags().Set("replace", "true")) + must.NoError(cmd.Flags().Set("chain", "true")) + must.NoError(cmd.Flags().Set("binary", "/opt/prenup")) + must.NoError(cmd.Flags().Set("use-path", "true")) + must.NoError(cmd.Flags().Set("non-interactive", "true")) + + got := readInstallFlags(cmd) + assert.Equal(t, installFlags{ + force: true, + replace: true, + chain: true, + binary: "/opt/prenup", + usePath: true, + nonInteractive: true, + }, got) +} + +// TestResolveBinaryReturnsAbsolute confirms the default (os.Executable) path +// works in a test binary context. We can't easily pin the exact path since +// `go test` builds a temp binary, but we can check it's non-empty and +// absolute. +func TestResolveBinaryReturnsAbsolute(t *testing.T) { + t.Parallel() + got, err := resolveBinary() + require.NoError(t, err) + assert.NotEmpty(t, got) + assert.True(t, filepath.IsAbs(got), "expected absolute path, got %q", got) +} diff --git a/internal/cli/plan.go b/internal/cli/plan.go new file mode 100644 index 0000000..4333016 --- /dev/null +++ b/internal/cli/plan.go @@ -0,0 +1,155 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/c2fo/prenup/internal/config" + "github.com/c2fo/prenup/internal/discover" + "github.com/c2fo/prenup/internal/git" + "github.com/c2fo/prenup/internal/runner" + "github.com/c2fo/prenup/internal/ui" +) + +// newPlanCmd prints the plan for the current change set without executing it. +// Respects --output so agents can consume the plan as JSON. +func newPlanCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plan", + Short: "Print what prenup would run for the current change set", + RunE: func(cmd *cobra.Command, args []string) error { + return runPlan(cmd) + }, + } + cmd.Flags().String("config", "", "path to .prenup.yaml") + cmd.Flags().String("output", "", "output mode: text (default) or json") + cmd.Flags().Bool("all", false, "ignore change detection") + return cmd +} + +func runPlan(cmd *cobra.Command) error { + cfgPath, _ := cmd.Flags().GetString("config") + outStr, _ := cmd.Flags().GetString("output") + all, _ := cmd.Flags().GetBool("all") + + repoRoot, err := git.RepoRoot("") + if err != nil { + return err + } + gitRunner := git.New(repoRoot) + + if cfgPath == "" { + p, err := config.Find(repoRoot) + if err != nil { + return err + } + if p == "" { + return fmt.Errorf("no .prenup.yaml found in %s", repoRoot) + } + cfgPath = p + } + cfg, err := config.Load(cfgPath, repoRoot) + if err != nil { + return err + } + + var changedFiles []string + if !all { + changedFiles, err = discover.ChangedFiles(gitRunner, cfg.Exclude) + if err != nil { + return err + } + } + modules := discover.Modules(repoRoot, changedFiles, cfg.ModuleMarkers) + if all || (len(modules) == 0 && len(changedFiles) > 0) { + modules = []string{"."} + } + + plan := runner.BuildPlan(cfg, repoRoot, changedFiles, modules, nil) + + mode := ui.Resolve(config.OutputMode(strings.ToLower(outStr))) + switch mode { + case config.OutputJSON: + return renderPlanJSON(plan) + default: + return renderPlanText(plan, os.Stdout) + } +} + +func renderPlanJSON(plan runner.Plan) error { + type taskView struct { + Name string `json:"name"` + Selected bool `json:"selected"` + SkipReason string `json:"skip_reason,omitempty"` + PerModule bool `json:"per_module"` + CleanWorktree bool `json:"clean_worktree"` + Parallel bool `json:"parallel"` + Modules []string `json:"modules"` + } + type view struct { + RepoRoot string `json:"repo_root"` + Modules []string `json:"modules"` + Tasks []taskView `json:"tasks"` + } + + v := view{RepoRoot: plan.RepoRoot, Modules: plan.Modules} + for i := range plan.Tasks { + pt := &plan.Tasks[i] + mods := pt.Modules + if len(mods) == 1 && mods[0] == "" { + mods = nil + } + v.Tasks = append(v.Tasks, taskView{ + Name: pt.Task.Name, + Selected: pt.Selected, + SkipReason: pt.SkipReason, + PerModule: pt.Task.PerModule, + CleanWorktree: pt.CleanWorktree, + Parallel: pt.Parallel, + Modules: mods, + }) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func renderPlanText(plan runner.Plan, w *os.File) error { + if _, err := fmt.Fprintf(w, "Repository: %s\n", plan.RepoRoot); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Modules: %s\n\n", strings.Join(plan.Modules, ", ")); err != nil { + return err + } + for i := range plan.Tasks { + pt := &plan.Tasks[i] + marker := "v" + if !pt.Selected { + marker = "-" + } + if _, err := fmt.Fprintf(w, "[%s] %s\n", marker, pt.Task.Name); err != nil { + return err + } + if pt.SkipReason != "" { + if _, err := fmt.Fprintf(w, " skipped: %s\n", pt.SkipReason); err != nil { + return err + } + } + if pt.Task.PerModule && pt.Selected { + if _, err := fmt.Fprintf(w, " per_module, modules: %s\n", + strings.Join(pt.Modules, ", ")); err != nil { + return err + } + } + if pt.Task.Command != "" { + if _, err := fmt.Fprintf(w, " command: %s\n", pt.Task.Command); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/cli/plan_render_test.go b/internal/cli/plan_render_test.go new file mode 100644 index 0000000..e4f8e35 --- /dev/null +++ b/internal/cli/plan_render_test.go @@ -0,0 +1,168 @@ +package cli + +import ( + "encoding/json" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/prenup/internal/config" + "github.com/c2fo/prenup/internal/runner" +) + +// PlanRenderTestSuite groups tests that share a canonical Plan fixture. The +// two rendering functions produce very different output formats, so a suite +// with a common `plan` field keeps each case focused on the format contract +// rather than fixture setup. +type PlanRenderTestSuite struct { + suite.Suite + plan runner.Plan +} + +func (s *PlanRenderTestSuite) SetupTest() { + // A plan with one selected per-module task, one skipped task with a + // reason, and one selected global task exercises every branch inside + // both renderers. + s.plan = runner.Plan{ + RepoRoot: "/repo", + Modules: []string{"pkg/a", "pkg/b"}, + Tasks: []runner.PlannedTask{ + { + Task: config.Task{ + Name: "Run tests", + Command: "go test ./...", + PerModule: true, + }, + Selected: true, + CleanWorktree: true, + Parallel: true, + Modules: []string{"pkg/a", "pkg/b"}, + }, + { + Task: config.Task{ + Name: "Lint (skipped)", + Command: "golangci-lint run", + }, + Selected: false, + SkipReason: "no matching files", + Modules: []string{""}, // renderPlanJSON normalizes this to nil + }, + { + Task: config.Task{ + Name: "Format check", + Command: "gofmt -l .", + }, + Selected: true, + Modules: nil, + }, + }, + } +} + +// TestRenderPlanText_ContainsHeaderAndTaskLines pins the human-readable text +// contract: header lines for the repo + modules, then a checklist-style block +// per task. +func (s *PlanRenderTestSuite) TestRenderPlanText_ContainsHeaderAndTaskLines() { + out := s.captureStdout(func(f *os.File) error { + return renderPlanText(s.plan, f) + }) + + s.Contains(out, "Repository: /repo") + s.Contains(out, "Modules: pkg/a, pkg/b") + s.Contains(out, "[v] Run tests") + s.Contains(out, "per_module, modules: pkg/a, pkg/b") + s.Contains(out, "command: go test ./...") + s.Contains(out, "[-] Lint (skipped)") + s.Contains(out, "skipped: no matching files") + s.Contains(out, "[v] Format check") +} + +// TestRenderPlanJSON_ProducesValidObject asserts the JSON contract: valid +// JSON, top-level repo_root + modules + tasks, and the SkipReason / +// per-module modules normalization is honored. +func (s *PlanRenderTestSuite) TestRenderPlanJSON_ProducesValidObject() { + out := s.captureStdout(func(_ *os.File) error { + return renderPlanJSON(s.plan) + }) + + var got struct { + RepoRoot string `json:"repo_root"` + Modules []string `json:"modules"` + Tasks []struct { + Name string `json:"name"` + Selected bool `json:"selected"` + SkipReason string `json:"skip_reason,omitempty"` + PerModule bool `json:"per_module"` + CleanWorktree bool `json:"clean_worktree"` + Parallel bool `json:"parallel"` + Modules []string `json:"modules"` + } `json:"tasks"` + } + s.Require().NoError(json.Unmarshal([]byte(out), &got)) + + s.Equal("/repo", got.RepoRoot) + s.Equal([]string{"pkg/a", "pkg/b"}, got.Modules) + s.Require().Len(got.Tasks, 3) + + s.Equal("Run tests", got.Tasks[0].Name) + s.True(got.Tasks[0].Selected) + s.True(got.Tasks[0].PerModule) + s.Equal([]string{"pkg/a", "pkg/b"}, got.Tasks[0].Modules) + + s.Equal("Lint (skipped)", got.Tasks[1].Name) + s.False(got.Tasks[1].Selected) + s.Equal("no matching files", got.Tasks[1].SkipReason) + // A single "" entry is a runner sentinel for "no modules"; JSON output + // normalizes it to a missing/empty array so consumers don't see [""]. + s.Empty(got.Tasks[1].Modules) + + s.Equal("Format check", got.Tasks[2].Name) + s.True(got.Tasks[2].Selected) + s.Empty(got.Tasks[2].Modules) +} + +// captureStdout redirects os.Stdout across the call so both renderers +// (renderPlanText writes to its *os.File arg; renderPlanJSON writes to +// os.Stdout directly) can be tested with the same helper. The renderer is +// invoked with os.Stdout as its arg during the redirect, so passing that +// pointer to renderPlanText makes both paths converge. +func (s *PlanRenderTestSuite) captureStdout(fn func(*os.File) error) string { + r, w, err := os.Pipe() + s.Require().NoError(err) + origStdout := os.Stdout + os.Stdout = w + defer func() { os.Stdout = origStdout }() + + fnErr := fn(w) + s.Require().NoError(w.Close()) + + buf, readErr := io.ReadAll(r) + s.Require().NoError(readErr) + s.Require().NoError(fnErr) + return string(buf) +} + +func TestPlanRenderSuite(t *testing.T) { + suite.Run(t, new(PlanRenderTestSuite)) +} + +// TestRenderPlanText_EmptyPlan covers the degenerate case: no modules and no +// tasks still produces well-formed headers instead of panicking. +func TestRenderPlanText_EmptyPlan(t *testing.T) { + t.Parallel() + + r, w, err := os.Pipe() + require.NoError(t, err) + + err = renderPlanText(runner.Plan{RepoRoot: "/x"}, w) + require.NoError(t, err) + require.NoError(t, w.Close()) + + buf, err := io.ReadAll(r) + require.NoError(t, err) + assert.Contains(t, string(buf), "Repository: /x") +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..36e83f0 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,83 @@ +// Package cli defines the cobra command tree for the prenup binary. +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// Execute parses os.Args, dispatches to the chosen subcommand, and returns +// the desired process exit code. A zero code means success. +func Execute() int { + root := newRootCmd() + if err := root.Execute(); err != nil { + // cobra already prints its own errors; ensure we surface a non-zero + // exit. Individual commands that want to pick a specific code use + // exitCodeError below. + ec := &exitCodeError{} + if errors.As(err, &ec) { + return ec.code + } + return 1 + } + return 0 +} + +// exitCodeError lets a subcommand propagate a specific exit code without +// printing an extra usage summary. +type exitCodeError struct { + code int + err error +} + +func (e *exitCodeError) Error() string { + if e.err == nil { + return fmt.Sprintf("exit status %d", e.code) + } + return e.err.Error() +} + +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "prenup", + Short: "Interactive pre-commit hook utility", + Long: `Prenup runs user-defined tasks (tests, linters, doc generators, custom scripts) +as a Git pre-commit hook, scoped to the modules that actually changed. + +When invoked with no subcommand, prenup behaves as the git hook entry point +and is equivalent to "prenup run". See the README for configuration.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runRun(cmd, args, defaultRunOptions()) + }, + } + + // Run's flags live on root so they also apply to the default (no-subcommand) invocation. + addRunFlags(root) + + root.AddCommand( + newRunCmd(), + newPlanCmd(), + newInstallCmd(), + newUninstallCmd(), + newInitCmd(), + newConfigCmd(), + newVersionCmd(), + ) + return root +} + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print prenup version and exit", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprintf(os.Stdout, "prenup %s\n", ResolvedVersion()) + return err + }, + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..9a9bf1a --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,409 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + + "github.com/c2fo/prenup/internal/config" + "github.com/c2fo/prenup/internal/discover" + "github.com/c2fo/prenup/internal/git" + "github.com/c2fo/prenup/internal/lock" + "github.com/c2fo/prenup/internal/runner" + "github.com/c2fo/prenup/internal/ui" + "github.com/c2fo/prenup/internal/ui/human" + "github.com/c2fo/prenup/internal/ui/jsonout" + "github.com/c2fo/prenup/internal/ui/markdown" + "github.com/c2fo/prenup/internal/versioncheck" +) + +// runOptions holds the resolved flag values for `prenup run`. +type runOptions struct { + configPath string + outputMode string + taskNames []string + all bool + noInteractive bool + noCleanWorktree bool + parallelism int + dryRun bool +} + +func defaultRunOptions() runOptions { return runOptions{} } + +// addRunFlags registers shared flags on cmd. Invoked for both the root command +// and the explicit `run` subcommand. +func addRunFlags(cmd *cobra.Command) { + f := cmd.Flags() + f.String("config", "", "path to .prenup.yaml (defaults to repo root)") + f.String("output", "", "output mode: auto, human, markdown, or json") + f.StringSlice("task", nil, "run only the named task(s); repeatable") + f.Bool("all", false, "ignore change detection and run against all configured module markers") + f.Bool("no-interactive", false, "skip the selection UI; run default_selected tasks") + f.Bool("no-clean-worktree", false, "disable stash-and-restore around task execution") + f.Int("parallelism", 0, "max concurrent per-module task runs; 0 = NumCPU") + f.Bool("dry-run", false, "print what would run without executing commands") +} + +func readRunOptions(cmd *cobra.Command) runOptions { + f := cmd.Flags() + cfgPath, _ := f.GetString("config") + out, _ := f.GetString("output") + tasks, _ := f.GetStringSlice("task") + all, _ := f.GetBool("all") + noInt, _ := f.GetBool("no-interactive") + noClean, _ := f.GetBool("no-clean-worktree") + par, _ := f.GetInt("parallelism") + dry, _ := f.GetBool("dry-run") + return runOptions{ + configPath: cfgPath, + outputMode: out, + taskNames: tasks, + all: all, + noInteractive: noInt, + noCleanWorktree: noClean, + parallelism: par, + dryRun: dry, + } +} + +func newRunCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Run the pre-commit checks without committing", + RunE: func(cmd *cobra.Command, args []string) error { + return runRun(cmd, args, readRunOptions(cmd)) + }, + } + addRunFlags(cmd) + return cmd +} + +// runRun is the entry point used by both `prenup` (default) and `prenup run`. +func runRun(cmd *cobra.Command, _ []string, opts runOptions) error { + if cmd.Flags().NFlag() > 0 { + opts = readRunOptions(cmd) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + repoRoot, err := git.RepoRoot("") + if err != nil { + return fmt.Errorf("locating git repository: %w", err) + } + gitRunner := git.New(repoRoot) + + cfg, err := loadRunConfig(repoRoot, opts.configPath) + if err != nil { + return err + } + + // Discover changes and modules. --all ignores change detection. + disc, err := discoverChangesAndModules(gitRunner, repoRoot, cfg, opts.all) + if err != nil { + return err + } + if disc.done { + return printlnSafe(disc.message) + } + + // Resolve output mode. + requested := config.OutputMode(strings.ToLower(opts.outputMode)) + if requested == "" { + requested = cfg.Output + } + mode := ui.Resolve(requested) + + verInfo := maybeVersionCheck(ctx) + + sel, err := resolveSelection(opts, mode, disc.modules, cfg, verInfo) + if err != nil { + return err + } + if sel.done { + if sel.exitCode != 0 { + return &exitCodeError{code: sel.exitCode, err: errors.New(sel.message)} + } + return printlnSafe(sel.message) + } + + plan := runner.BuildPlan(cfg, repoRoot, disc.changedFiles, disc.modules, sel.selected) + + cleanWorktree := cfg.CleanWorktreeEnabled() + if opts.noCleanWorktree { + cleanWorktree = false + } + + runOpts := runner.Options{ + Git: gitRunner, + Version: verInfo.version, + UpdateNotice: verInfo.notice, + MaxParallelism: coalescePar(opts.parallelism, cfg.MaxParallelism), + CleanWorktree: cleanWorktree, + DryRun: opts.dryRun, + } + + release, err := acquireRepoLock(repoRoot, opts.dryRun) + if err != nil { + return err + } + defer release() + + result, err := runWithMode(ctx, mode, plan, runOpts) + if err != nil { + return err + } + if result.ExitCode != 0 { + return &exitCodeError{code: result.ExitCode} + } + return nil +} + +// printlnSafe writes msg to stdout when non-empty. Returns the underlying +// write error so callers can propagate it. +func printlnSafe(msg string) error { + if msg == "" { + return nil + } + _, err := fmt.Fprintln(os.Stdout, msg) + return err +} + +// acquireRepoLock takes the per-repo advisory lock that serializes +// concurrent `prenup run` invocations. Dry-run skips the lock since it +// performs no destructive work; this lets a planning probe run alongside a +// real commit-time invocation. The returned release func is always safe to +// call (it is a no-op when no lock was taken), so callers can defer it +// unconditionally. +func acquireRepoLock(repoRoot string, dryRun bool) (func(), error) { + if dryRun { + return func() {}, nil + } + held, err := lock.Acquire(repoRoot) + if err != nil { + if errors.Is(err, lock.ErrContended) { + return nil, &exitCodeError{code: 1, err: fmt.Errorf( + "%w (lock file: %s)", err, filepath.Join(repoRoot, ".git", lock.LockFileName))} + } + return nil, err + } + return func() { _ = held.Close() }, nil +} + +func coalescePar(flag, cfg int) int { + if flag > 0 { + return flag + } + return cfg +} + +// loadRunConfig finds and loads the prenup config, defaulting to repoRoot if no +// explicit path was provided. +func loadRunConfig(repoRoot, cfgPath string) (config.Config, error) { + if cfgPath == "" { + p, err := config.Find(repoRoot) + if err != nil { + return config.Config{}, err + } + if p == "" { + return config.Config{}, fmt.Errorf("no .prenup.yaml found in %s; run `prenup init`", repoRoot) + } + cfgPath = p + } + return config.Load(cfgPath, repoRoot) +} + +// discoverOutcome describes the result of change/module discovery. +// +// - done == false: changedFiles + modules describe real work to do +// - done == true: the caller should print message (if any) and exit 0 +type discoverOutcome struct { + changedFiles []string + modules []string + done bool + message string +} + +// discoverChangesAndModules returns the changed files and modules to run +// against, or signals that there's nothing to do. +func discoverChangesAndModules(gitRunner *git.Runner, repoRoot string, cfg config.Config, all bool) (discoverOutcome, error) { + var out discoverOutcome + if !all { + files, err := discover.ChangedFiles(gitRunner, cfg.Exclude) + if err != nil { + return discoverOutcome{}, err + } + out.changedFiles = files + if len(files) == 0 { + return discoverOutcome{done: true, message: "No relevant files changed. Skipping Prenup."}, nil + } + } + + out.modules = discover.Modules(repoRoot, out.changedFiles, cfg.ModuleMarkers) + if all || (len(out.modules) == 0 && len(out.changedFiles) > 0) { + // --all or no modules detected: feed the plan a synthetic "." module + // and let per-task path filters do the work. + out.modules = []string{"."} + } + if len(out.modules) == 0 { + return discoverOutcome{done: true, message: "No changed modules detected. Skipping Prenup."}, nil + } + return out, nil +} + +// selectionOutcome describes the result of resolving which tasks to run. +// +// - done == false: selected is the (possibly nil) selection map; nil means +// "use the config's default_selected" +// - done == true: print message (if any), exit with exitCode +type selectionOutcome struct { + selected map[string]bool + done bool + message string + exitCode int +} + +// resolveSelection returns the selected task set, either from --task flags, +// the human selection UI, or nil (meaning the runner uses default_selected). +func resolveSelection(opts runOptions, mode config.OutputMode, modules []string, cfg config.Config, + verInfo versionInfo) (selectionOutcome, error) { + if len(opts.taskNames) > 0 { + selected := make(map[string]bool, len(opts.taskNames)) + for _, n := range opts.taskNames { + selected[n] = true + } + return selectionOutcome{selected: selected}, nil + } + if mode != config.OutputHuman || opts.noInteractive { + return selectionOutcome{}, nil + } + res, err := human.SelectTasks(human.SelectionInput{ + Version: verInfo.version, + Notice: verInfo.notice, + Modules: modules, + Tasks: cfg.Tasks, + }) + if errors.Is(err, human.ErrCanceled) { + return selectionOutcome{done: true, exitCode: 1, message: "canceled"}, nil + } + if err != nil { + return selectionOutcome{}, err + } + if res.Skipped { + return selectionOutcome{done: true, message: "Skipping tasks as requested."}, nil + } + if len(res.Selected) == 0 { + return selectionOutcome{done: true, message: "No tasks selected. Committing without running checks."}, nil + } + return selectionOutcome{selected: res.Selected}, nil +} + +// versionInfo carries the resolved version and optional update warning. +type versionInfo struct { + version string + notice string +} + +func maybeVersionCheck(ctx context.Context) versionInfo { + ver := ResolvedVersion() + info := versionInfo{version: ver} + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + res := versioncheck.Check(ctx, ver, githubTokenForVersionCheck()) + if res.Error == nil && res.IsOutdated { + info.notice = fmt.Sprintf( + "Update available %s - run `go install github.com/c2fo/prenup/cmd/prenup@latest`", + res.LatestVersion) + } + return info +} + +func githubTokenForVersionCheck() string { + for _, key := range []string{"PRENUP_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"} { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + } + return "" +} + +// runWithMode wires the chosen output sink to the runner and blocks until the +// run completes. +func runWithMode(ctx context.Context, mode config.OutputMode, plan runner.Plan, opts runner.Options) (runner.Result, error) { + switch mode { + case config.OutputJSON: + sink := jsonout.New(os.Stdout) + opts.Sink = sink + return runner.Run(ctx, plan, opts) + case config.OutputMarkdown: + sink := markdown.New(os.Stdout) + opts.Sink = sink + return runner.Run(ctx, plan, opts) + case config.OutputHuman: + return runHumanTUI(ctx, plan, opts) + default: + sink := markdown.New(os.Stdout) + opts.Sink = sink + return runner.Run(ctx, plan, opts) + } +} + +// runHumanTUI wires the Bubble Tea runner UI and the summary printer. +// +// Lifecycle: +// - the runner runs in a goroutine that emits into channelSink +// - the TUI runs on the main goroutine and consumes events +// - when the runner returns we Close the sink so any late deferred-cleanup +// events (stash pop notices, etc.) drop instead of pinning the goroutine +// - if the TUI itself errors early (terminal init, etc.), we still Close +// and cancel the context so the runner unblocks and tears down +func runHumanTUI(ctx context.Context, plan runner.Plan, opts runner.Options) (runner.Result, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + channelSink := human.NewEventChannelSink() + summarySink := human.NewSummarySink(os.Stdout) + opts.Sink = runner.NewMultiSink(channelSink, summarySink) + + model := human.NewRunnerModel(channelSink) + prog := tea.NewProgram(model, tea.WithAltScreen(), tea.WithOutput(os.Stdout)) + + type runOutcome struct { + result runner.Result + err error + } + runDone := make(chan runOutcome, 1) + go func() { + result, err := runner.Run(ctx, plan, opts) + _ = channelSink.Close() + runDone <- runOutcome{result: result, err: err} + }() + + uiErr := func() error { + _, err := prog.Run() + return err + }() + + // Either way (TUI exited cleanly or errored), tell the runner we're done + // listening; Emit will start dropping immediately. + cancel() + _ = channelSink.Close() + out := <-runDone + _ = summarySink.Close() + + if uiErr != nil { + return out.result, fmt.Errorf("runner UI: %w", uiErr) + } + if out.err != nil { + return out.result, out.err + } + return out.result, nil +} diff --git a/internal/cli/run_helpers_test.go b/internal/cli/run_helpers_test.go new file mode 100644 index 0000000..498d9e5 --- /dev/null +++ b/internal/cli/run_helpers_test.go @@ -0,0 +1,206 @@ +package cli + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// TestDefaultRunOptions pins the zero value contract: the returned struct is +// the type's zero value, so any future field is opt-in by default. +func TestDefaultRunOptions(t *testing.T) { + t.Parallel() + assert.Equal(t, runOptions{}, defaultRunOptions()) +} + +// TestReadRunOptions round-trips every registered flag through the cobra +// command so a rename or type mismatch between addRunFlags and +// readRunOptions is caught. +func TestReadRunOptions(t *testing.T) { + t.Parallel() + + cmd := newRunCmd() + must := require.New(t) + + must.NoError(cmd.Flags().Set("config", "/tmp/x.yaml")) + must.NoError(cmd.Flags().Set("output", "json")) + must.NoError(cmd.Flags().Set("task", "lint")) + must.NoError(cmd.Flags().Set("task", "test")) + must.NoError(cmd.Flags().Set("all", "true")) + must.NoError(cmd.Flags().Set("no-interactive", "true")) + must.NoError(cmd.Flags().Set("no-clean-worktree", "true")) + must.NoError(cmd.Flags().Set("parallelism", "4")) + must.NoError(cmd.Flags().Set("dry-run", "true")) + + got := readRunOptions(cmd) + assert.Equal(t, runOptions{ + configPath: "/tmp/x.yaml", + outputMode: "json", + taskNames: []string{"lint", "test"}, + all: true, + noInteractive: true, + noCleanWorktree: true, + parallelism: 4, + dryRun: true, + }, got) +} + +// TestPrintlnSafe covers both branches: empty input is a no-op, non-empty +// input writes exactly one line. +func TestPrintlnSafe(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {name: "empty is no-op", in: "", want: ""}, + {name: "non-empty writes line", in: "hello", want: "hello\n"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := captureStdout(t, func() error { return printlnSafe(tc.in) }) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestGithubTokenForVersionCheck exercises the env-var precedence: +// PRENUP_GITHUB_TOKEN wins over GITHUB_TOKEN wins over GH_TOKEN. +// Whitespace-only entries are treated as unset. +func TestGithubTokenForVersionCheck(t *testing.T) { + cases := []struct { + name string + env map[string]string + want string + }{ + {name: "prenup wins", env: map[string]string{"PRENUP_GITHUB_TOKEN": "p", "GITHUB_TOKEN": "g", "GH_TOKEN": "h"}, want: "p"}, + {name: "github beats gh", env: map[string]string{"GITHUB_TOKEN": "g", "GH_TOKEN": "h"}, want: "g"}, + {name: "gh only", env: map[string]string{"GH_TOKEN": "h"}, want: "h"}, + {name: "whitespace is unset", env: map[string]string{"PRENUP_GITHUB_TOKEN": " ", "GITHUB_TOKEN": "g"}, want: "g"}, + {name: "none set", env: nil, want: ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // t.Setenv restores on cleanup automatically; must-clear + // the other two keys so a leaking value in the process env + // (unlikely under `go test` but not impossible) can't + // influence the case. + for _, k := range []string{"PRENUP_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"} { + t.Setenv(k, "") + } + for k, v := range tc.env { + t.Setenv(k, v) + } + assert.Equal(t, tc.want, githubTokenForVersionCheck()) + }) + } +} + +// TestResolvedVersionUsesInjectedValue confirms the -X ldflag path: if +// Version has been overridden at link time (or by tests), ResolvedVersion +// returns it. ResetVersionCacheForTest clears the sync.OnceValue so the +// swap actually takes effect. +func TestResolvedVersionUsesInjectedValue(t *testing.T) { + orig := Version + t.Cleanup(func() { + Version = orig + ResetVersionCacheForTest() + }) + + Version = "v9.8.7" + ResetVersionCacheForTest() + assert.Equal(t, "v9.8.7", ResolvedVersion()) +} + +// TestVersionCommandPrintsResolvedVersion covers newVersionCmd end-to-end +// through cobra's Execute so we exercise the RunE handler wiring too. +func TestVersionCommandPrintsResolvedVersion(t *testing.T) { + orig := Version + t.Cleanup(func() { + Version = orig + ResetVersionCacheForTest() + }) + Version = "v1.2.3-test" + ResetVersionCacheForTest() + + got := captureStdout(t, func() error { + return newVersionCmd().Execute() + }) + assert.Contains(t, got, "prenup v1.2.3-test") +} + +// AcquireRepoLockTestSuite exercises acquireRepoLock against a real +// initialized git repo. Two cases share the same fixture, so a suite pays +// its keep. +type AcquireRepoLockTestSuite struct { + suite.Suite + repo string +} + +func (s *AcquireRepoLockTestSuite) SetupTest() { + dir := s.T().TempDir() + cmd := exec.Command("git", "init", "-b", "main") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + s.Require().NoErrorf(err, "git init: %s", string(out)) + s.repo = dir +} + +// TestDryRunSkipsLock verifies the documented dry-run behavior: no lock is +// acquired, so a subsequent lock file is not present. The release func must +// still be safe to call. +func (s *AcquireRepoLockTestSuite) TestDryRunSkipsLock() { + release, err := acquireRepoLock(s.repo, true) + s.Require().NoError(err) + s.Require().NotNil(release) + release() // must not panic + + // dry-run should not create the lock file. + _, err = os.Stat(filepath.Join(s.repo, ".git", "prenup.lock")) + s.True(os.IsNotExist(err), "dry-run should not create the lock file, but stat err was %v", err) +} + +// TestAcquireAndRelease exercises the happy path: acquire, release, and be +// able to acquire again. +func (s *AcquireRepoLockTestSuite) TestAcquireAndRelease() { + release, err := acquireRepoLock(s.repo, false) + s.Require().NoError(err) + s.Require().NotNil(release) + release() + + // After release, a second acquire should succeed. + release2, err := acquireRepoLock(s.repo, false) + s.Require().NoError(err) + release2() +} + +func TestAcquireRepoLockSuite(t *testing.T) { + suite.Run(t, new(AcquireRepoLockTestSuite)) +} + +// captureStdout redirects os.Stdout, invokes fn, and returns whatever fn +// wrote. Panics via t.Fatal on plumbing errors to keep call sites terse. +func captureStdout(t *testing.T, fn func() error) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + orig := os.Stdout + os.Stdout = w + defer func() { os.Stdout = orig }() + + fnErr := fn() + require.NoError(t, w.Close()) + + buf, readErr := io.ReadAll(r) + require.NoError(t, readErr) + require.NoError(t, fnErr) + return string(buf) +} diff --git a/internal/cli/run_internal_test.go b/internal/cli/run_internal_test.go new file mode 100644 index 0000000..b73e01d --- /dev/null +++ b/internal/cli/run_internal_test.go @@ -0,0 +1,189 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/prenup/internal/config" + "github.com/c2fo/prenup/internal/git" +) + +// RunDiscoverTestSuite groups tests for discoverChangesAndModules. +// They share a non-trivial fixture (a freshly-initialized git repo +// with a single seed commit), so a SetupTest pays for itself here. +// +// Other run_internal helpers (coalescePar, resolveSelection, +// loadRunConfig) have no shared fixture and remain flat tests below. +type RunDiscoverTestSuite struct { + suite.Suite + repo string // absolute path to the temp git repo + cfg config.Config +} + +func (s *RunDiscoverTestSuite) SetupTest() { + dir := s.T().TempDir() + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + // Disable GPG signing so the seed commit succeeds in + // environments where the dev's global git requires a key. + {"config", "commit.gpgsign", "false"}, + } { + cmd := exec.Command("git", args...) //nolint:gosec // G204: fixed git binary, internal args. + cmd.Dir = dir + out, err := cmd.CombinedOutput() + s.Require().NoErrorf(err, "git %s: %s", strings.Join(args, " "), string(out)) + } + s.Require().NoError(os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi\n"), 0o600)) + for _, args := range [][]string{{"add", "README.md"}, {"commit", "-m", "init"}} { + cmd := exec.Command("git", args...) //nolint:gosec // G204: fixed git binary, internal args. + cmd.Dir = dir + s.Require().NoError(cmd.Run()) + } + s.repo = dir + s.cfg = config.DefaultConfig() +} + +// TestNoChanges returns done with a friendly message when the worktree is clean. +func (s *RunDiscoverTestSuite) TestNoChanges() { + out, err := discoverChangesAndModules(git.New(s.repo), s.repo, s.cfg, false) + s.Require().NoError(err) + s.True(out.done) + s.Contains(out.message, "No relevant files changed") +} + +// TestAllFlagSynthesizesDot pins that --all forces a "." module even +// against a clean tree, so an operator can re-run all tasks at will. +func (s *RunDiscoverTestSuite) TestAllFlagSynthesizesDot() { + out, err := discoverChangesAndModules(git.New(s.repo), s.repo, s.cfg, true) + s.Require().NoError(err) + s.False(out.done) + s.Equal([]string{"."}, out.modules) +} + +// TestUntrackedFileCountsAsChange pins that an untracked file is +// treated as a real change (not just modifications), and that without +// module markers the runner falls back to a single "." module. +func (s *RunDiscoverTestSuite) TestUntrackedFileCountsAsChange() { + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, "added.go"), []byte("package x\n"), 0o600)) + + out, err := discoverChangesAndModules(git.New(s.repo), s.repo, s.cfg, false) + s.Require().NoError(err) + s.False(out.done) + s.Contains(out.changedFiles, "added.go") + s.Equal([]string{"."}, out.modules) +} + +func TestRunDiscoverSuite(t *testing.T) { + suite.Run(t, new(RunDiscoverTestSuite)) +} + +// --- standalone tests below: pure helpers with no shared fixture, so +// a suite would be ceremony without payoff. They stay flat and use +// assert.New / require.New per the testify house style. + +// TestCoalescePar verifies the precedence rule: an explicit --parallelism +// flag wins over the config value, and 0 falls through to the config. +func TestCoalescePar(t *testing.T) { + t.Parallel() + cases := []struct { + name string + flag int + cfg int + want int + }{ + {"flag wins when set", 4, 8, 4}, + {"flag zero falls back to cfg", 0, 8, 8}, + {"both zero stays zero", 0, 0, 0}, + {"negative flag treated as unset", -1, 8, 8}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, coalescePar(tc.flag, tc.cfg)) + }) + } +} + +// TestResolveSelectionExplicitTaskFlags returns exactly the requested set +// regardless of mode. +func TestResolveSelectionExplicitTaskFlags(t *testing.T) { + t.Parallel() + is := assert.New(t) + must := require.New(t) + + opts := runOptions{taskNames: []string{"lint", "test"}} + cfg := config.DefaultConfig() + out, err := resolveSelection(opts, config.OutputJSON, []string{"."}, cfg, versionInfo{}) + must.NoError(err) + is.False(out.done) + is.Equal(map[string]bool{"lint": true, "test": true}, out.selected) +} + +// TestResolveSelectionNonHumanReturnsNil exercises the fast-path: in +// machine-readable modes there is no interactive picker, so the runner +// should fall back to default_selected (signaled by selected == nil). +func TestResolveSelectionNonHumanReturnsNil(t *testing.T) { + t.Parallel() + is := assert.New(t) + must := require.New(t) + + opts := runOptions{} + cfg := config.DefaultConfig() + out, err := resolveSelection(opts, config.OutputJSON, []string{"."}, cfg, versionInfo{}) + must.NoError(err) + is.False(out.done) + is.Nil(out.selected) +} + +// TestResolveSelectionNoInteractiveReturnsNil ensures --no-interactive on the +// human path also short-circuits to "use defaults". +func TestResolveSelectionNoInteractiveReturnsNil(t *testing.T) { + t.Parallel() + is := assert.New(t) + must := require.New(t) + + opts := runOptions{noInteractive: true} + cfg := config.DefaultConfig() + out, err := resolveSelection(opts, config.OutputHuman, []string{"."}, cfg, versionInfo{}) + must.NoError(err) + is.False(out.done) + is.Nil(out.selected) +} + +// TestLoadRunConfigMissing surfaces a useful error when no config exists. +func TestLoadRunConfigMissing(t *testing.T) { + t.Parallel() + is := assert.New(t) + must := require.New(t) + + _, err := loadRunConfig(t.TempDir(), "") + must.Error(err) + is.Contains(err.Error(), "no .prenup.yaml found") + is.Contains(err.Error(), "prenup init") +} + +// TestLoadRunConfigExplicitPath honors an absolute path argument and ignores +// repo-root discovery. +func TestLoadRunConfigExplicitPath(t *testing.T) { + t.Parallel() + is := assert.New(t) + must := require.New(t) + + dir := t.TempDir() + path := filepath.Join(dir, "custom.yaml") + must.NoError(os.WriteFile(path, []byte("version: 1\ntasks:\n - name: t\n command: \"true\"\n"), 0o600)) + + cfg, err := loadRunConfig(dir, path) + must.NoError(err) + is.Equal(1, cfg.Version) + is.Len(cfg.Tasks, 1) +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..34dce0e --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,40 @@ +package cli + +import ( + "runtime/debug" + "sync" +) + +// Version is the release version, set at link time with -X. +// When unset, debug.ReadBuildInfo() is consulted (for `go install ...@v1.2.3`). +var Version = "dev" + +// resolvedVersion memoizes the version lookup so repeated calls (UI render, +// version check, summary) don't re-read build info on every access. Tests +// that mutate Version should call ResetVersionCacheForTest first. +var resolvedVersion = sync.OnceValue(func() string { + if Version != "dev" { + return Version + } + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + return Version +}) + +// ResolvedVersion returns the string displayed in UIs and used for update checks. +func ResolvedVersion() string { return resolvedVersion() } + +// ResetVersionCacheForTest clears the memoized version so tests can swap +// Version and observe the change. Not for production use. +func ResetVersionCacheForTest() { + resolvedVersion = sync.OnceValue(func() string { + if Version != "dev" { + return Version + } + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + return Version + }) +} diff --git a/internal/cli/versioncheck_test.go b/internal/cli/versioncheck_test.go new file mode 100644 index 0000000..1f2f561 --- /dev/null +++ b/internal/cli/versioncheck_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestMaybeVersionCheckRespectsCancelledContext is a smoke test that the +// version check returns promptly when the parent context is already +// canceled, instead of blocking on the network call until the inner 10s +// timeout fires. This guards against future regressions that would freeze +// `prenup run` for ten seconds when the user has hit Ctrl-C. +func TestMaybeVersionCheckRespectsCancelledContext(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before invocation; the HTTP call should fail immediately + + start := time.Now() + info := maybeVersionCheck(ctx) + elapsed := time.Since(start) + + assert.NotEmpty(t, info.version, "should still report the resolved version") + assert.Empty(t, info.notice, "no update notice when the network call failed") + // 2s is a very loose ceiling; in practice this returns sub-second. + assert.Less(t, elapsed, 2*time.Second, "must not block on the 10s inner timeout") +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..22967e6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,337 @@ +// Package config defines the .prenup.yaml schema, loading, defaults, and validation. +package config + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "gopkg.in/yaml.v3" +) + +// SchemaVersion is the config schema version this binary understands. It is a +// plain integer, independent of the prenup release version, and only +// increments on a breaking change to the config file format. Additive, +// non-breaking changes do not bump it. +const SchemaVersion = 1 + +// ConfigFilenames lists the filenames prenup looks for in the repository root, +// in order of preference. The first one found is used. +var ConfigFilenames = []string{".prenup.yaml", ".prenup.yml"} + +// OutputMode controls how prenup renders progress and results. +type OutputMode string + +const ( + // OutputAuto selects human for TTYs, markdown otherwise. + OutputAuto OutputMode = "auto" + // OutputHuman is the Bubble Tea interactive UI. + OutputHuman OutputMode = "human" + // OutputMarkdown is a plain-text streaming + final markdown digest mode. + OutputMarkdown OutputMode = "markdown" + // OutputJSON is an NDJSON event stream mode for agents. + OutputJSON OutputMode = "json" +) + +// Config is the parsed .prenup.yaml configuration. +type Config struct { + Version int `yaml:"version"` + ModuleMarkers []string `yaml:"module_markers,omitempty"` + Exclude []string `yaml:"exclude,omitempty"` + CleanWorktree *bool `yaml:"clean_worktree,omitempty"` + MaxParallelism int `yaml:"max_parallelism,omitempty"` + Output OutputMode `yaml:"output,omitempty"` + Tasks []Task `yaml:"tasks"` + + // Path is the absolute path the config was loaded from. Not serialized. + Path string `yaml:"-"` +} + +// Task is a single entry in the tasks list. +type Task struct { + Name string `yaml:"name"` + DefaultSelected bool `yaml:"default_selected,omitempty"` + Command string `yaml:"command"` + PerModule bool `yaml:"per_module,omitempty"` + WorkingDir string `yaml:"working_dir,omitempty"` + Paths []string `yaml:"paths,omitempty"` + PathsIgnore []string `yaml:"paths_ignore,omitempty"` + OutputPatterns []string `yaml:"output_patterns,omitempty"` + StageOutput bool `yaml:"stage_output,omitempty"` + Parallel *bool `yaml:"parallel,omitempty"` + CleanWorktree *bool `yaml:"clean_worktree,omitempty"` + Env map[string]string `yaml:"env,omitempty"` +} + +// CleanWorktreeEnabled returns the effective clean_worktree value for this task +// given the config-level default. +func (t Task) CleanWorktreeEnabled(cfgDefault bool) bool { + if t.CleanWorktree != nil { + return *t.CleanWorktree + } + return cfgDefault +} + +// ParallelEnabled returns whether per-module runs of this task may fan out. +// Defaults to true for per_module tasks, false otherwise. +func (t Task) ParallelEnabled() bool { + if t.Parallel != nil { + return *t.Parallel + } + return t.PerModule +} + +// DefaultConfig returns a Config populated with all defaults applied. +// Callers then overlay user-provided YAML on top. +func DefaultConfig() Config { + trueVal := true + return Config{ + Version: SchemaVersion, + ModuleMarkers: []string{"go.mod"}, + CleanWorktree: &trueVal, + MaxParallelism: 0, // 0 == NumCPU at runtime + Output: OutputAuto, + } +} + +// CleanWorktreeEnabled returns the effective config-level default for stashing. +func (c Config) CleanWorktreeEnabled() bool { + if c.CleanWorktree == nil { + return true + } + return *c.CleanWorktree +} + +// Find returns the absolute path to the first config file found under repoRoot. +// Returns an empty string and nil error if no config exists. +func Find(repoRoot string) (string, error) { + for _, name := range ConfigFilenames { + p := filepath.Join(repoRoot, name) + info, err := os.Stat(p) + if err == nil && !info.IsDir() { + return p, nil + } + if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("checking %s: %w", p, err) + } + } + return "", nil +} + +// Load parses the YAML file at path and returns a fully-defaulted, validated Config. +// The path must be inside repoRoot. +func Load(path, repoRoot string) (Config, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return Config{}, fmt.Errorf("resolving config path: %w", err) + } + absRepo, err := filepath.Abs(repoRoot) + if err != nil { + return Config{}, fmt.Errorf("resolving repo root: %w", err) + } + // Resolve symlinks on both sides before comparing. A purely + // lexical check would let a symlink that lives inside the repo + // but targets a file outside the repo slip through and be read, + // which would defeat the "config must be inside the repo" rule. + resolvedPath, err := filepath.EvalSymlinks(absPath) + if err != nil { + return Config{}, fmt.Errorf("resolving config path: %w", err) + } + resolvedRepo, err := filepath.EvalSymlinks(absRepo) + if err != nil { + return Config{}, fmt.Errorf("resolving repo root: %w", err) + } + rel, err := filepath.Rel(resolvedRepo, resolvedPath) + if err != nil { + return Config{}, fmt.Errorf("checking config path: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return Config{}, errors.New("config file path is outside the repository") + } + + // resolvedPath is validated above to live under the repository root. + data, err := os.ReadFile(resolvedPath) + if err != nil { + return Config{}, fmt.Errorf("reading config: %w", err) + } + + return Parse(data, resolvedPath) +} + +// Parse decodes YAML bytes into a Config, applies defaults, and validates. +// pathForErrors is used only in error messages. +func Parse(data []byte, pathForErrors string) (Config, error) { + // Start from a zero Config so we can detect a missing `version:` key; the + // user-provided YAML is then overlaid and defaults applied afterwards. + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + // Common footgun: `version: "2"` (string instead of int). + if hint := versionTypeHint(data); hint != "" { + return Config{}, fmt.Errorf("parsing %s: %s (underlying error: %w)", pathForErrors, hint, err) + } + return Config{}, fmt.Errorf("parsing %s: %w", pathForErrors, err) + } + + if cfg.Version == 0 { + return Config{}, fmt.Errorf(`config is missing "version: %d"`, SchemaVersion) + } + + // Apply defaults for fields YAML unmarshal would leave at zero. + defaults := DefaultConfig() + if cfg.CleanWorktree == nil { + cfg.CleanWorktree = defaults.CleanWorktree + } + if len(cfg.ModuleMarkers) == 0 { + cfg.ModuleMarkers = []string{"go.mod"} + } + if cfg.Output == "" { + cfg.Output = OutputAuto + } + cfg.Path = pathForErrors + + if err := Validate(cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +// Validate performs structural checks on cfg and returns a combined error. +func Validate(cfg Config) error { + errs := validateTopLevel(cfg) + seen := make(map[string]bool) + for i := range cfg.Tasks { + errs = append(errs, validateTask(&cfg.Tasks[i], i, seen)...) + } + if len(errs) == 0 { + return nil + } + return fmt.Errorf("invalid config %s:\n - %s", cfg.Path, strings.Join(errs, "\n - ")) +} + +// validateTopLevel checks all non-task fields. +func validateTopLevel(cfg Config) []string { + var errs []string + switch { + case cfg.Version == SchemaVersion: + // supported + case cfg.Version > SchemaVersion: + errs = append(errs, fmt.Sprintf( + "config version %d requires a newer version of prenup (this binary supports version %d); please upgrade prenup", + cfg.Version, SchemaVersion)) + default: + errs = append(errs, fmt.Sprintf("unsupported config version %d (expected %d)", cfg.Version, SchemaVersion)) + } + switch cfg.Output { + case OutputAuto, OutputHuman, OutputMarkdown, OutputJSON, "": + default: + errs = append(errs, fmt.Sprintf("unknown output mode %q (want auto, human, markdown, or json)", cfg.Output)) + } + if cfg.MaxParallelism < 0 { + errs = append(errs, fmt.Sprintf("max_parallelism must be >= 0, got %d", cfg.MaxParallelism)) + } + if len(cfg.Tasks) == 0 { + errs = append(errs, "no tasks defined") + } + for _, p := range cfg.Exclude { + if err := validatePattern(p); err != nil { + errs = append(errs, "exclude: "+err.Error()) + } + } + return errs +} + +// validateTask checks a single task entry. seen accumulates names across +// calls so duplicates can be flagged. +func validateTask(t *Task, idx int, seen map[string]bool) []string { + var errs []string + label := fmt.Sprintf("tasks[%d]", idx) + switch { + case t.Name == "": + errs = append(errs, label+": name is required") + case seen[t.Name]: + errs = append(errs, fmt.Sprintf("%s: duplicate task name %q", label, t.Name)) + default: + seen[t.Name] = true + } + if strings.TrimSpace(t.Command) == "" { + errs = append(errs, fmt.Sprintf("%s (%s): command is required", label, t.Name)) + } + if t.StageOutput && len(t.OutputPatterns) == 0 { + errs = append(errs, fmt.Sprintf("%s (%s): stage_output: true requires output_patterns", label, t.Name)) + } + errs = append(errs, validateTaskPatterns(label, t)...) + return errs +} + +// validateTaskPatterns runs validatePattern over every pattern field on t. +func validateTaskPatterns(label string, t *Task) []string { + var errs []string + for _, group := range []struct { + field string + patterns []string + }{ + {"paths", t.Paths}, + {"paths_ignore", t.PathsIgnore}, + {"output_patterns", t.OutputPatterns}, + } { + for _, p := range group.patterns { + if err := validatePattern(p); err != nil { + errs = append(errs, fmt.Sprintf("%s (%s) %s: %s", label, t.Name, group.field, err)) + } + } + } + return errs +} + +// validatePattern returns an error if the doublestar pattern is malformed. +func validatePattern(p string) error { + if _, err := doublestar.Match(p, ""); err != nil { + return fmt.Errorf("invalid pattern %q: %w", p, err) + } + return nil +} + +// versionTypeHint returns a friendly hint when YAML decoding fails because +// `version:` was a string instead of an int. Returns "" if the source does +// not contain that pattern. +func versionTypeHint(data []byte) string { + var probe struct { + Version yaml.Node `yaml:"version"` + } + if err := yaml.Unmarshal(data, &probe); err != nil { + return "" + } + if probe.Version.Kind == yaml.ScalarNode && probe.Version.Tag == "!!str" { + return `"version" must be an integer (use "version: 1", not "version: \"1\"")` + } + return "" +} + +// Marshal writes a config back to YAML with a stable layout, led by a +// discoverability header so anyone who encounters a .prenup.yaml can find out +// what it is. Any code path that rewrites a config must go through Marshal so +// the header is preserved. +func Marshal(cfg Config) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString(configHeader) + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(cfg); err != nil { + return nil, fmt.Errorf("encoding config: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("closing encoder: %w", err) + } + return buf.Bytes(), nil +} + +// configHeader is the comment block prepended to generated .prenup.yaml files. +const configHeader = `# .prenup.yaml — pre-commit checks managed by prenup. +# What is this? Prenup runs configurable checks before each commit, scoped to +# the parts of the repo that changed. Learn more and install: +# https://github.com/c2fo/prenup +` diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b39964b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,171 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseValid(t *testing.T) { + data := []byte(` +version: 1 +module_markers: + - go.mod +exclude: + - "**/*.yaml" +clean_worktree: false +max_parallelism: 4 +output: json +tasks: + - name: "Tests" + command: "go test ./..." + default_selected: true + per_module: true + paths: ["**/*.go"] + - name: "Docs" + command: "make docs" + stage_output: true + output_patterns: ["docs/**/*.md"] +`) + cfg, err := Parse(data, "/tmp/test.yaml") + require.NoError(t, err) + + assert.Equal(t, 1, cfg.Version) + assert.Equal(t, []string{"go.mod"}, cfg.ModuleMarkers) + assert.Equal(t, 4, cfg.MaxParallelism) + assert.Equal(t, OutputJSON, cfg.Output) + require.NotNil(t, cfg.CleanWorktree) + assert.False(t, *cfg.CleanWorktree) + assert.False(t, cfg.CleanWorktreeEnabled()) + + require.Len(t, cfg.Tasks, 2) + assert.True(t, cfg.Tasks[0].DefaultSelected) + assert.True(t, cfg.Tasks[0].PerModule) + assert.True(t, cfg.Tasks[0].ParallelEnabled(), "per-module task should default parallel=true") + assert.False(t, cfg.Tasks[1].ParallelEnabled(), "non-per-module task should default parallel=false") +} + +func TestParseMissingVersionRejected(t *testing.T) { + data := []byte(` +tasks: + - name: foo + command: "true" +`) + _, err := Parse(data, "/tmp/test.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), `missing "version: 1"`) +} + +func TestParseNewerVersionRejected(t *testing.T) { + data := []byte(` +version: 2 +tasks: + - name: foo + command: "true" +`) + _, err := Parse(data, "/tmp/test.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a newer version of prenup") +} + +func TestParseUnsupportedOlderVersionRejected(t *testing.T) { + data := []byte(` +version: -1 +tasks: + - name: foo + command: "true" +`) + _, err := Parse(data, "/tmp/test.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported config version") +} + +func TestValidateRejectsDuplicateTaskName(t *testing.T) { + data := []byte(` +version: 1 +tasks: + - name: foo + command: "true" + - name: foo + command: "true" +`) + _, err := Parse(data, "/tmp/test.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate task name") +} + +func TestValidateRequiresOutputPatternsForStageOutput(t *testing.T) { + data := []byte(` +version: 1 +tasks: + - name: foo + command: "true" + stage_output: true +`) + _, err := Parse(data, "/tmp/test.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "stage_output: true requires output_patterns") +} + +func TestValidateRejectsUnknownOutputMode(t *testing.T) { + data := []byte(` +version: 1 +output: flashy +tasks: + - name: foo + command: "true" +`) + _, err := Parse(data, "/tmp/test.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown output mode") +} + +func TestFindPrefersYamlOverYml(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".prenup.yaml"), []byte("version: 1\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".prenup.yml"), []byte("version: 1\n"), 0o600)) + + p, err := Find(dir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, ".prenup.yaml"), p) +} + +func TestLoadRejectsPathOutsideRepo(t *testing.T) { + repo := t.TempDir() + other := t.TempDir() + cfgPath := filepath.Join(other, ".prenup.yaml") + require.NoError(t, os.WriteFile(cfgPath, []byte("version: 1\ntasks: [{name: f, command: \"true\"}]\n"), 0o600)) + + _, err := Load(cfgPath, repo) + require.Error(t, err) + assert.Contains(t, err.Error(), "outside the repository") +} + +// TestLoadRejectsSymlinkEscapingRepo guards against a symlink that +// lives inside the repo but points at a config file outside the repo. +// A purely lexical check on the user-supplied path would let such a +// file be read; Load must EvalSymlinks both sides before comparing. +func TestLoadRejectsSymlinkEscapingRepo(t *testing.T) { + repo := t.TempDir() + other := t.TempDir() + + target := filepath.Join(other, ".prenup.yaml") + require.NoError(t, os.WriteFile(target, []byte("version: 1\ntasks: [{name: f, command: \"true\"}]\n"), 0o600)) + + link := filepath.Join(repo, ".prenup.yaml") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unsupported on this filesystem: %v", err) + } + + _, err := Load(link, repo) + require.Error(t, err) + assert.Contains(t, err.Error(), "outside the repository") +} + +func TestDefaultConfigStashOn(t *testing.T) { + cfg := DefaultConfig() + assert.True(t, cfg.CleanWorktreeEnabled()) +} diff --git a/internal/config/marshal_test.go b/internal/config/marshal_test.go new file mode 100644 index 0000000..8f0b6ad --- /dev/null +++ b/internal/config/marshal_test.go @@ -0,0 +1,105 @@ +package config + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMarshalRoundTrip verifies that a config emitted by Marshal parses back +// cleanly, is self-consistent, and always leads with the discoverability +// header. This pins the invariant that any config prenup writes advertises +// the project. +func TestMarshalRoundTrip(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.CleanWorktree = nil // let it inherit defaults on reload + cfg.Tasks = []Task{ + {Name: "test", Command: "go test ./...", DefaultSelected: true, PerModule: true}, + } + + out, err := Marshal(cfg) + require.NoError(t, err) + + got := string(out) + assert.True(t, strings.HasPrefix(got, "# .prenup.yaml"), + "marshal output must lead with the discoverability header, got: %q", got[:min(len(got), 40)]) + assert.Contains(t, got, "https://github.com/c2fo/prenup", + "header must advertise the project URL") + assert.Contains(t, got, "version: 1") + assert.Contains(t, got, "go test ./...") + + reloaded, err := Parse(out, "/tmp/out.yaml") + require.NoError(t, err) + assert.Equal(t, cfg.Tasks[0].Name, reloaded.Tasks[0].Name) + assert.Equal(t, cfg.Tasks[0].Command, reloaded.Tasks[0].Command) + assert.True(t, reloaded.CleanWorktreeEnabled(), "nil CleanWorktree should default to true on reload") +} + +// TestConfigCleanWorktreeEnabled exercises both branches of the +// Config-level default. +func TestConfigCleanWorktreeEnabled(t *testing.T) { + t.Parallel() + + tr, fl := true, false + cases := []struct { + name string + cfg Config + want bool + }{ + {name: "nil defaults to true", cfg: Config{}, want: true}, + {name: "explicit true", cfg: Config{CleanWorktree: &tr}, want: true}, + {name: "explicit false", cfg: Config{CleanWorktree: &fl}, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, tc.cfg.CleanWorktreeEnabled()) + }) + } +} + +// TestTaskCleanWorktreeEnabled exercises the Task-level override: +// a nil pointer inherits the config default, a set pointer wins. +func TestTaskCleanWorktreeEnabled(t *testing.T) { + t.Parallel() + + tr, fl := true, false + cases := []struct { + name string + task Task + cfgDefault bool + want bool + }{ + {name: "nil inherits cfg default (true)", task: Task{}, cfgDefault: true, want: true}, + {name: "nil inherits cfg default (false)", task: Task{}, cfgDefault: false, want: false}, + {name: "explicit true overrides false", task: Task{CleanWorktree: &tr}, cfgDefault: false, want: true}, + {name: "explicit false overrides true", task: Task{CleanWorktree: &fl}, cfgDefault: true, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, tc.task.CleanWorktreeEnabled(tc.cfgDefault)) + }) + } +} + +// TestVersionTypeHint fires when a user writes `version: "1"` (quoted +// string) instead of the integer form. The hint should call out the +// specific footgun to save round-trips through the YAML unmarshaler. +func TestVersionTypeHint(t *testing.T) { + t.Parallel() + + quoted := []byte(` +version: "1" +tasks: + - name: t + command: "true" +`) + _, err := Parse(quoted, "/tmp/quoted.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), `"version" must be an integer`) +} diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..7c778b0 --- /dev/null +++ b/internal/config/schema.go @@ -0,0 +1,12 @@ +package config + +import _ "embed" + +//go:embed schema.json +var embeddedSchema []byte + +// Schema returns the embedded JSON schema describing the config. +// The bytes are stable across calls; callers must not mutate them. +func Schema() []byte { + return embeddedSchema +} diff --git a/internal/config/schema.json b/internal/config/schema.json new file mode 100644 index 0000000..f1d6e96 --- /dev/null +++ b/internal/config/schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/c2fo/prenup/main/assets/prenup.schema.json", + "title": "Prenup configuration", + "$comment": "This file is the canonical source. assets/prenup.schema.json must stay byte-identical. When adding a version 2, replace `version.const: 1` with a `oneOf` over per-version schemas.", + "type": "object", + "required": ["version", "tasks"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "const": 1, + "description": "Config schema version. Must be 1." + }, + "module_markers": { + "type": "array", + "items": { "type": "string" }, + "description": "File names whose presence marks a module directory. Defaults to ['go.mod']." + }, + "exclude": { + "type": "array", + "items": { "type": "string" }, + "description": "Doublestar glob patterns of files to ignore for change detection." + }, + "clean_worktree": { + "type": "boolean", + "description": "Stash unstaged changes around task execution so checks see exactly the pending commit. Default true." + }, + "max_parallelism": { + "type": "integer", + "minimum": 0, + "description": "Max concurrent per-module task runs. 0 means NumCPU." + }, + "output": { + "type": "string", + "enum": ["auto", "human", "markdown", "json"], + "description": "Default output mode. Overridable via --output." + }, + "tasks": { + "type": "array", + "items": { "$ref": "#/$defs/task" } + } + }, + "$defs": { + "task": { + "type": "object", + "required": ["name", "command"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "default_selected": { "type": "boolean" }, + "command": { "type": "string", "minLength": 1 }, + "per_module": { "type": "boolean" }, + "working_dir": { "type": "string" }, + "paths": { "type": "array", "items": { "type": "string" } }, + "paths_ignore": { "type": "array", "items": { "type": "string" } }, + "output_patterns": { "type": "array", "items": { "type": "string" } }, + "stage_output": { "type": "boolean" }, + "parallel": { "type": "boolean" }, + "clean_worktree": { "type": "boolean" }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + } + } +} diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go new file mode 100644 index 0000000..bd7aa73 --- /dev/null +++ b/internal/config/schema_test.go @@ -0,0 +1,25 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSchemaAssetMatchesEmbedded guards against the two schema copies (the +// embedded one in this package and the public asset under ../../assets) +// drifting apart. They are duplicated so external consumers can fetch the +// canonical $id URL without pulling in the full Go module; the duplication +// is acceptable as long as a CI signal catches divergence immediately. +func TestSchemaAssetMatchesEmbedded(t *testing.T) { + t.Parallel() + wd, err := os.Getwd() + require.NoError(t, err) + assetPath := filepath.Join(wd, "..", "..", "assets", "prenup.schema.json") + asset, err := os.ReadFile(assetPath) //nolint:gosec // G304: path is built from a fixed filename relative to the test wd. + require.NoError(t, err, "read public asset schema") + require.Equal(t, string(asset), string(Schema()), + "assets/prenup.schema.json and internal/config/schema.json must be byte-identical; update both when the schema changes") +} diff --git a/internal/discover/changes.go b/internal/discover/changes.go new file mode 100644 index 0000000..70e4384 --- /dev/null +++ b/internal/discover/changes.go @@ -0,0 +1,98 @@ +// Package discover finds what changed in the working tree and which modules own +// those changes. It also applies the repo-level exclude filter and per-task +// path filters. +package discover + +import ( + "fmt" + + "github.com/bmatcuk/doublestar/v4" + + "github.com/c2fo/prenup/internal/git" +) + +// ChangedFiles returns deduplicated paths (relative to repo root) that are +// staged, unstaged, or untracked. Paths matching any excludePattern (a +// doublestar glob) are dropped. +func ChangedFiles(r *git.Runner, excludePatterns []string) ([]string, error) { + staged, err := r.StagedFiles() + if err != nil { + return nil, fmt.Errorf("staged files: %w", err) + } + unstaged, err := r.UnstagedFiles() + if err != nil { + return nil, fmt.Errorf("unstaged files: %w", err) + } + untracked, err := r.UntrackedFiles() + if err != nil { + return nil, fmt.Errorf("untracked files: %w", err) + } + + seen := make(map[string]struct{}) + all := make([]string, 0, len(staged)+len(unstaged)+len(untracked)) + for _, src := range [][]string{staged, unstaged, untracked} { + for _, f := range src { + if _, ok := seen[f]; ok { + continue + } + seen[f] = struct{}{} + all = append(all, f) + } + } + + if len(excludePatterns) == 0 { + return all, nil + } + + kept := make([]string, 0, len(all)) + for _, f := range all { + if Matches(excludePatterns, f) { + continue + } + kept = append(kept, f) + } + return kept, nil +} + +// Matches returns true when path matches any doublestar pattern. +// +// Pattern errors are intentionally swallowed: config validation should have +// caught them at load time, and a per-event log here would amount to one +// warning per file per pattern. Use ValidatePatterns at config-load to +// surface bad patterns early. +func Matches(patterns []string, path string) bool { + for _, p := range patterns { + if ok, err := doublestar.Match(p, path); err == nil && ok { + return true + } + } + return false +} + +// ValidatePatterns returns the first invalid pattern (if any) along with the +// underlying parser error. Used by config.Validate to fail loudly at load +// time instead of letting Matches silently miss matches. +func ValidatePatterns(patterns []string) error { + for _, p := range patterns { + if _, err := doublestar.Match(p, ""); err != nil { + return fmt.Errorf("invalid pattern %q: %w", p, err) + } + } + return nil +} + +// FilterByPaths returns only the files matching include (if non-empty) and +// not matching exclude. An empty include list includes everything. +func FilterByPaths(files, include, exclude []string) []string { + out := make([]string, 0, len(files)) + for _, f := range files { + if len(include) > 0 && !Matches(include, f) { + continue + } + if len(exclude) > 0 && Matches(exclude, f) { + continue + } + out = append(out, f) + } + return out +} diff --git a/internal/discover/changes_test.go b/internal/discover/changes_test.go new file mode 100644 index 0000000..22170b3 --- /dev/null +++ b/internal/discover/changes_test.go @@ -0,0 +1,115 @@ +package discover + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/prenup/internal/git" +) + +// TestValidatePatterns is a truth table for the pattern validator. Errors +// only bubble up for malformed doublestar syntax; unmatched-but-valid +// patterns are fine. +func TestValidatePatterns(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + patterns []string + wantErr string + }{ + {name: "nil", patterns: nil}, + {name: "empty", patterns: []string{}}, + {name: "valid patterns", patterns: []string{"**/*.go", "vendor/**", "!*.md"}}, + {name: "malformed bracket", patterns: []string{"[unclosed"}, wantErr: "invalid pattern"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidatePatterns(tc.patterns) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + assert.NoError(t, err) + }) + } +} + +// ChangedFilesTestSuite exercises ChangedFiles against a real git repo. +// The three cases share a non-trivial setup (git init + seed commit), so a +// suite pays for itself. +type ChangedFilesTestSuite struct { + suite.Suite + repo string + runner *git.Runner +} + +func (s *ChangedFilesTestSuite) SetupTest() { + dir := s.T().TempDir() + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + {"config", "commit.gpgsign", "false"}, + } { + cmd := exec.Command("git", args...) //nolint:gosec // G204: fixed binary, internal args. + cmd.Dir = dir + out, err := cmd.CombinedOutput() + s.Require().NoErrorf(err, "git %s: %s", strings.Join(args, " "), string(out)) + } + s.Require().NoError(os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi\n"), 0o600)) + for _, args := range [][]string{{"add", "README.md"}, {"commit", "-m", "init"}} { + cmd := exec.Command("git", args...) //nolint:gosec // G204: fixed binary, internal args. + cmd.Dir = dir + s.Require().NoError(cmd.Run()) + } + s.repo = dir + s.runner = git.New(dir) +} + +// TestReturnsUntrackedAndStaged: an untracked file plus a staged file both +// appear, deduplicated. +func (s *ChangedFilesTestSuite) TestReturnsUntrackedAndStaged() { + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, "a.go"), []byte("package a\n"), 0o600)) + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, "b.go"), []byte("package b\n"), 0o600)) + cmd := exec.Command("git", "add", "a.go") + cmd.Dir = s.repo + s.Require().NoError(cmd.Run()) + + got, err := ChangedFiles(s.runner, nil) + s.Require().NoError(err) + s.ElementsMatch([]string{"a.go", "b.go"}, got) +} + +// TestExcludePatternDropsFiles: exclude patterns filter out matching paths. +func (s *ChangedFilesTestSuite) TestExcludePatternDropsFiles() { + s.Require().NoError(os.MkdirAll(filepath.Join(s.repo, "vendor"), 0o750)) + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, "vendor", "dep.go"), []byte("package v\n"), 0o600)) + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, "keep.go"), []byte("package k\n"), 0o600)) + + got, err := ChangedFiles(s.runner, []string{"vendor/**"}) + s.Require().NoError(err) + s.NotContains(got, "vendor/dep.go", "vendor/** should be excluded") + s.Contains(got, "keep.go") +} + +// TestEmptyRepoReturnsEmpty: a clean worktree returns an empty slice, no error. +func (s *ChangedFilesTestSuite) TestEmptyRepoReturnsEmpty() { + got, err := ChangedFiles(s.runner, nil) + s.Require().NoError(err) + s.Empty(got) +} + +func TestChangedFilesSuite(t *testing.T) { + suite.Run(t, new(ChangedFilesTestSuite)) +} diff --git a/internal/discover/discover_test.go b/internal/discover/discover_test.go new file mode 100644 index 0000000..bd36d24 --- /dev/null +++ b/internal/discover/discover_test.go @@ -0,0 +1,54 @@ +package discover + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatches(t *testing.T) { + patterns := []string{"**/*.go", "docs/*.md"} + assert.True(t, Matches(patterns, "pkg/foo/bar.go")) + assert.True(t, Matches(patterns, "docs/readme.md")) + assert.False(t, Matches(patterns, "docs/sub/readme.md")) + assert.False(t, Matches(patterns, "README.txt")) +} + +func TestFilterByPaths(t *testing.T) { + files := []string{"a.go", "a_test.go", "docs/x.md", "README.txt"} + got := FilterByPaths(files, []string{"**/*.go"}, []string{"**/*_test.go"}) + assert.Equal(t, []string{"a.go"}, got) +} + +func TestFilterByPathsEmptyIncludeMeansAll(t *testing.T) { + files := []string{"a.go", "README.md"} + assert.Equal(t, files, FilterByPaths(files, nil, nil)) +} + +func TestModulesFindsNearestAncestor(t *testing.T) { + root := t.TempDir() + // Layout: + // /go.mod (root module) + // /services/auth/go.mod (nested module) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module r\n"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "services", "auth"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "services", "auth", "go.mod"), []byte("module a\n"), 0o600)) + + files := []string{ + "services/auth/handler.go", + "services/auth/internal/db/db.go", + "pkg/other/file.go", + } + mods := Modules(root, files, []string{"go.mod"}) + assert.Equal(t, []string{".", "services/auth"}, mods) +} + +func TestModulesSkipsFilesWithNoMarker(t *testing.T) { + root := t.TempDir() + // No go.mod anywhere. + mods := Modules(root, []string{"pkg/foo/bar.go"}, []string{"go.mod"}) + assert.Empty(t, mods) +} diff --git a/internal/discover/modules.go b/internal/discover/modules.go new file mode 100644 index 0000000..89c86d7 --- /dev/null +++ b/internal/discover/modules.go @@ -0,0 +1,57 @@ +package discover + +import ( + "os" + "path/filepath" + "sort" +) + +// Modules returns the deduplicated, sorted list of module directories (relative +// to repoRoot) that own the given changed files. A module is defined as the +// nearest ancestor directory of a changed file that contains any of the +// markerFiles (e.g. go.mod). +// +// Files that have no ancestor marker are skipped. +func Modules(repoRoot string, changedFiles, markerFiles []string) []string { + if len(markerFiles) == 0 { + markerFiles = []string{"go.mod"} + } + seen := make(map[string]struct{}) + for _, f := range changedFiles { + dir := filepath.Dir(f) + mod := findModuleRoot(repoRoot, dir, markerFiles) + if mod == "" { + continue + } + seen[mod] = struct{}{} + } + out := make([]string, 0, len(seen)) + for m := range seen { + out = append(out, m) + } + sort.Strings(out) + return out +} + +// findModuleRoot walks up from startDir looking for any markerFile. Returns +// the relative path from repoRoot, or "." if the marker lives at the root. +// Returns the empty string if no marker is found up to the repo root. +func findModuleRoot(repoRoot, startDir string, markerFiles []string) string { + current := startDir + for { + for _, m := range markerFiles { + candidate := filepath.Join(repoRoot, current, m) + if _, err := os.Stat(candidate); err == nil { + return current + } + } + if current == "." || current == string(filepath.Separator) { + return "" + } + parent := filepath.Dir(current) + if parent == current { + return "" + } + current = parent + } +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..d2c1e17 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,195 @@ +// Package git is a thin wrapper around the `git` command-line tool. It exposes +// only the operations prenup needs and keeps the wrapper easy to fake in tests. +package git + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" +) + +// Runner executes git commands against a specific working directory. A zero +// Runner uses the current working directory. +type Runner struct { + Dir string +} + +// New returns a Runner rooted at dir. If dir is empty, RepoRoot() callers will +// still resolve the repository relative to the current working directory. +func New(dir string) *Runner { + return &Runner{Dir: dir} +} + +// run executes git with the given args and returns stdout. stderr is captured +// and folded into the returned error on failure. +func (r *Runner) run(args ...string) (string, error) { + // args originate from internal callers building well-formed git command + // lines; the binary is hardcoded to "git". + cmd := exec.Command("git", args...) //nolint:gosec // G204: git binary is fixed; args are constructed internally. + if r.Dir != "" { + cmd.Dir = r.Dir + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) + } + return stdout.String(), nil +} + +// RepoRoot returns the absolute path of the git repository containing dir (or +// the current working directory when dir is empty). +func RepoRoot(dir string) (string, error) { + r := &Runner{Dir: dir} + out, err := r.run("rev-parse", "--show-toplevel") + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// StagedFiles returns paths, relative to the repo root, that have staged +// changes vs HEAD. +func (r *Runner) StagedFiles() ([]string, error) { + out, err := r.run("diff", "--name-only", "--cached", "HEAD") + if err != nil { + // Empty repo (no HEAD) is not an error; treat as "everything staged is new". + if strings.Contains(err.Error(), "unknown revision") || strings.Contains(err.Error(), "bad revision") { + out, err = r.run("diff", "--name-only", "--cached") + if err != nil { + return nil, err + } + } else { + return nil, err + } + } + return splitLines(out), nil +} + +// UnstagedFiles returns paths, relative to the repo root, with tracked but +// unstaged modifications. +func (r *Runner) UnstagedFiles() ([]string, error) { + out, err := r.run("diff", "--name-only") + if err != nil { + return nil, err + } + return splitLines(out), nil +} + +// UntrackedFiles returns paths, relative to the repo root, that are not +// tracked by git and not ignored by .gitignore. +func (r *Runner) UntrackedFiles() ([]string, error) { + out, err := r.run("ls-files", "--others", "--exclude-standard") + if err != nil { + return nil, err + } + return splitLines(out), nil +} + +// HasUnstagedChanges returns true when the worktree has unstaged modifications +// or untracked, non-ignored files. Used by stash-and-restore to decide whether +// any stashing is necessary. +func (r *Runner) HasUnstagedChanges() (bool, error) { + unstaged, err := r.UnstagedFiles() + if err != nil { + return false, err + } + if len(unstaged) > 0 { + return true, nil + } + untracked, err := r.UntrackedFiles() + if err != nil { + return false, err + } + return len(untracked) > 0, nil +} + +// TrackedFiles returns the set of files currently tracked by git, used as a +// baseline snapshot before running tasks that may generate new files. +func (r *Runner) TrackedFiles() (map[string]struct{}, error) { + out, err := r.run("ls-files") + if err != nil { + return nil, err + } + set := make(map[string]struct{}) + for _, line := range splitLines(out) { + set[line] = struct{}{} + } + return set, nil +} + +// PorcelainStatus returns the parsed output of +// `git status -s --porcelain=v1 --untracked-files=all` as a set of paths. Only +// the path portion is returned; status codes are discarded. +// +// `--untracked-files=all` is required: by default git collapses untracked +// directories to `dir/`, which would cause stage_output's per-file pattern +// matching to miss anything generated under a brand-new directory. +func (r *Runner) PorcelainStatus() ([]string, error) { + out, err := r.run("status", "-s", "--porcelain=v1", "--untracked-files=all") + if err != nil { + return nil, err + } + var files []string + for _, line := range splitLines(out) { + if len(line) < 4 { + continue + } + path := strings.TrimSpace(line[3:]) + // Rename and copy entries (status codes R / C) format the path + // as `old -> new`. Take only the destination path so callers + // can `git add` it without git treating the literal arrow as + // part of the filename. + if idx := strings.Index(path, " -> "); idx >= 0 { + path = path[idx+len(" -> "):] + } + files = append(files, path) + } + return files, nil +} + +// Add stages the named files. A nil or empty slice is a no-op. +func (r *Runner) Add(files []string) error { + if len(files) == 0 { + return nil + } + args := append([]string{"add", "--"}, files...) + if _, err := r.run(args...); err != nil { + return err + } + return nil +} + +// Version returns the `git --version` string, primarily for diagnostics. +func (r *Runner) Version() (string, error) { + out, err := r.run("--version") + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// splitLines splits s on newlines, trims carriage returns, and drops empties. +func splitLines(s string) []string { + raw := strings.Split(s, "\n") + out := make([]string, 0, len(raw)) + for _, line := range raw { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + out = append(out, line) + } + return out +} + +// ErrNoRepo is returned when git cannot find a repository rooted at the given +// directory. +var ErrNoRepo = errors.New("not a git repository") diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..ac8962e --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,181 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/suite" +) + +// GitTestSuite groups behavioral tests for the git wrapper. Every method +// runs against a freshly-initialized repo with one committed file +// (README.md), so tests are independent and can run in any order. +type GitTestSuite struct { + suite.Suite + repo string // absolute path to the temp git repo + r *Runner // wrapper under test, scoped to repo +} + +func (s *GitTestSuite) SetupTest() { + dir := s.T().TempDir() + s.runGit(dir, "init", "-b", "main") + s.runGit(dir, "config", "user.email", "test@example.com") + s.runGit(dir, "config", "user.name", "test") + // Disable GPG signing so commit succeeds in environments where the + // developer's global git config requires a signing key the test + // process cannot access. + s.runGit(dir, "config", "commit.gpgsign", "false") + + s.Require().NoError(os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi\n"), 0o600)) + s.runGit(dir, "add", "README.md") + s.runGit(dir, "commit", "-m", "initial") + + s.repo = dir + s.r = New(dir) +} + +// runGit runs `git args...` in dir and fails the test on non-zero exit. +// Using s.Require() means a setup failure aborts the test immediately +// rather than letting later assertions run against a half-built repo. +func (s *GitTestSuite) runGit(dir string, args ...string) { + s.T().Helper() + cmd := exec.Command("git", args...) //nolint:gosec // G204: git binary is fixed in tests. + cmd.Dir = dir + out, err := cmd.CombinedOutput() + s.Require().NoErrorf(err, "git %s: %s", strings.Join(args, " "), string(out)) +} + +// writeFile is a small helper used by tests that need to mutate the +// worktree before the assertion phase. +func (s *GitTestSuite) writeFile(name, content string) { + s.T().Helper() + s.Require().NoError(os.WriteFile(filepath.Join(s.repo, name), []byte(content), 0o600)) +} + +func (s *GitTestSuite) TestRepoRoot() { + got, err := RepoRoot(s.repo) + s.Require().NoError(err) + + // macOS temp dirs live under /var/folders which symlinks to + // /private/var/folders; resolve both sides before comparing so a + // symlink-aware mismatch doesn't break this test on darwin. + wantEval, _ := filepath.EvalSymlinks(s.repo) + gotEval, _ := filepath.EvalSymlinks(got) + s.Equal(wantEval, gotEval) +} + +func (s *GitTestSuite) TestChangedFileCategories() { + s.writeFile("staged.txt", "s") + s.runGit(s.repo, "add", "staged.txt") + s.writeFile("README.md", "changed\n") // unstaged modification + s.writeFile("new.txt", "n") // untracked + + staged, err := s.r.StagedFiles() + s.Require().NoError(err) + s.Equal([]string{"staged.txt"}, staged) + + unstaged, err := s.r.UnstagedFiles() + s.Require().NoError(err) + s.Equal([]string{"README.md"}, unstaged) + + untracked, err := s.r.UntrackedFiles() + s.Require().NoError(err) + s.Equal([]string{"new.txt"}, untracked) + + has, err := s.r.HasUnstagedChanges() + s.Require().NoError(err) + s.True(has) +} + +// TestStashPushPopRestoresDirtyWorktree pins the round-trip behavior +// prenup relies on: Push() must hide unstaged + untracked changes +// (leaving staged content for the hook to evaluate), and Pop() must +// put both categories back exactly as they were. +func (s *GitTestSuite) TestStashPushPopRestoresDirtyWorktree() { + s.writeFile("staged.txt", "staged content\n") + s.runGit(s.repo, "add", "staged.txt") + s.writeFile("README.md", "unstaged change\n") + s.writeFile("new.txt", "untracked\n") + + stash, err := s.r.Push() + s.Require().NoError(err) + + unstaged, err := s.r.UnstagedFiles() + s.Require().NoError(err) + s.Empty(unstaged, "unstaged changes should be stashed away") + + untracked, err := s.r.UntrackedFiles() + s.Require().NoError(err) + s.Empty(untracked, "untracked files should be stashed away") + + staged, err := s.r.StagedFiles() + s.Require().NoError(err) + s.Contains(staged, "staged.txt") + + s.Require().NoError(stash.Pop()) + + unstaged, err = s.r.UnstagedFiles() + s.Require().NoError(err) + s.Contains(unstaged, "README.md") + + untracked, err = s.r.UntrackedFiles() + s.Require().NoError(err) + s.Contains(untracked, "new.txt") +} + +// TestStashPushNoopOnCleanWorktree pins that the wrapper handles the +// nothing-to-stash case gracefully -- prenup runs on every commit, so +// a panic or error here would block clean-worktree commits. +func (s *GitTestSuite) TestStashPushNoopOnCleanWorktree() { + stash, err := s.r.Push() + s.Require().NoError(err) + s.Require().NotNil(stash) + s.NoError(stash.Pop(), "Pop on a no-op stash must be safe") +} + +func (s *GitTestSuite) TestAddFiles() { + s.writeFile("a.txt", "a") + s.Require().NoError(s.r.Add([]string{"a.txt"})) + + staged, err := s.r.StagedFiles() + s.Require().NoError(err) + s.Contains(staged, "a.txt") +} + +// TestPorcelainStatusReturnsRenameDestination pins the parsing rule +// for `git status --porcelain=v1` rename entries, which look like +// `R old -> new`. PorcelainStatus must return only `new` so callers +// like stageGenerated can pass it to `git add` without git treating +// the literal arrow as part of the filename. +func (s *GitTestSuite) TestPorcelainStatusReturnsRenameDestination() { + // Seed an extra committed file so we have something to rename. + s.writeFile("original.txt", "hello\n") + s.runGit(s.repo, "add", "original.txt") + s.runGit(s.repo, "commit", "-m", "add original") + + // Stage a rename. `git mv` records both the deletion of the old + // path and the addition of the new one; with rename detection + // (which `--porcelain=v1` uses by default for staged changes) + // status reports a single R entry. + s.runGit(s.repo, "mv", "original.txt", "renamed.txt") + + files, err := s.r.PorcelainStatus() + s.Require().NoError(err) + s.Contains(files, "renamed.txt", "rename destination must be returned") + s.NotContains(files, "original.txt -> renamed.txt", "raw arrow form must not leak through") + s.NotContains(files, "original.txt", "source path of a rename must not be reported as a separate entry") +} + +func (s *GitTestSuite) TestTrackedFilesSnapshot() { + tracked, err := s.r.TrackedFiles() + s.Require().NoError(err) + _, has := tracked["README.md"] + s.True(has, "the seed README must show up in TrackedFiles") +} + +func TestGitSuite(t *testing.T) { + suite.Run(t, new(GitTestSuite)) +} diff --git a/internal/git/stash.go b/internal/git/stash.go new file mode 100644 index 0000000..fd55022 --- /dev/null +++ b/internal/git/stash.go @@ -0,0 +1,94 @@ +package git + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" +) + +// StashMessagePrefix tags prenup-created stashes so we can identify and pop +// them even if the user creates other stashes in the meantime. +const StashMessagePrefix = "prenup:autostash" + +// Stash represents a prenup-managed stash that must be restored. +type Stash struct { + Ref string // e.g. "stash@{0}" at the time of creation + Message string + runner *Runner + active bool +} + +// Push stashes unstaged changes (keeping the index intact) so task execution +// runs against a worktree that matches the pending commit. Returns a Stash +// which the caller must Pop (typically via defer). +// +// If the worktree is clean, Push returns a zero-value Stash whose Pop is a +// no-op. Untracked files are included so they can't leak into task runs. +func (r *Runner) Push() (*Stash, error) { + dirty, err := r.HasUnstagedChanges() + if err != nil { + return nil, fmt.Errorf("detecting unstaged changes: %w", err) + } + if !dirty { + return &Stash{runner: r}, nil + } + + // Combine nanoseconds with a small random suffix so back-to-back stashes + // (e.g. tests, retries) cannot collide on the same message. + var nonce [4]byte + _, _ = rand.Read(nonce[:]) + msg := fmt.Sprintf("%s %d-%s", StashMessagePrefix, time.Now().UnixNano(), hex.EncodeToString(nonce[:])) + _, err = r.run("stash", "push", "--include-untracked", "--keep-index", "--message", msg) + if err != nil { + return nil, fmt.Errorf("creating stash: %w", err) + } + + ref, err := r.findStashRef(msg) + if err != nil { + return nil, err + } + return &Stash{Ref: ref, Message: msg, runner: r, active: true}, nil +} + +// Pop restores a stash previously created via Push. Safe to call on a zero +// Stash or one that has already been popped. +func (s *Stash) Pop() error { + if s == nil || !s.active { + return nil + } + // Use the recorded message to re-locate the stash, since running tasks + // may have added other stashes in between. + ref, err := s.runner.findStashRef(s.Message) + if err != nil { + return err + } + if ref == "" { + return fmt.Errorf("prenup stash %q not found when restoring", s.Message) + } + if _, err := s.runner.run("stash", "pop", ref); err != nil { + return fmt.Errorf("restoring stash: %w", err) + } + s.active = false + return nil +} + +// findStashRef walks `git stash list` and returns the ref matching msg, or +// the empty string if not found. +func (r *Runner) findStashRef(msg string) (string, error) { + out, err := r.run("stash", "list") + if err != nil { + return "", fmt.Errorf("listing stashes: %w", err) + } + for _, line := range splitLines(out) { + // Format: "stash@{N}: On branch: message" + if !strings.Contains(line, msg) { + continue + } + if idx := strings.Index(line, ":"); idx > 0 { + return line[:idx], nil + } + } + return "", nil +} diff --git a/internal/git/version_test.go b/internal/git/version_test.go new file mode 100644 index 0000000..40adbe4 --- /dev/null +++ b/internal/git/version_test.go @@ -0,0 +1,24 @@ +package git + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestVersion pins the diagnostic contract of Runner.Version: it must +// return a non-empty string that starts with "git version" (the git CLI +// output format). Skipped if the git binary isn't on PATH so the test +// still passes in minimal build environments. +func TestVersion(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available on this platform") + } + + got, err := New("").Version() + require.NoError(t, err) + assert.Contains(t, got, "git version") +} diff --git a/internal/hook/exists_error_test.go b/internal/hook/exists_error_test.go new file mode 100644 index 0000000..9fc256a --- /dev/null +++ b/internal/hook/exists_error_test.go @@ -0,0 +1,22 @@ +package hook + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestExistsError_Error pins the user-facing message. This is the string a +// user sees when a competing pre-commit hook blocks install; changing the +// wording would break the CLI's `--force`/`--replace`/`--chain` guidance. +func TestExistsError_Error(t *testing.T) { + t.Parallel() + + err := &ExistsError{Path: "/repo/.git/hooks/pre-commit"} + msg := err.Error() + + assert.Contains(t, msg, "/repo/.git/hooks/pre-commit") + assert.Contains(t, msg, "--force") + assert.Contains(t, msg, "--replace") + assert.Contains(t, msg, "--chain") +} diff --git a/internal/hook/install.go b/internal/hook/install.go new file mode 100644 index 0000000..0479995 --- /dev/null +++ b/internal/hook/install.go @@ -0,0 +1,142 @@ +// Package hook installs and uninstalls the .git/hooks/pre-commit entry. +package hook + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Mode chooses how install handles an existing hook. +type Mode int + +const ( + // ModeAbort refuses to overwrite; returns ExistsError. + ModeAbort Mode = iota + // ModeReplace backs up the existing hook and replaces it with prenup. + ModeReplace + // ModeChain saves the existing hook as pre-commit.local and invokes it + // from the prenup-managed wrapper before running prenup. + ModeChain + // ModeForce overwrites without backup. + ModeForce +) + +// ExistsError is returned by Install when a non-prenup hook already exists and +// Mode is ModeAbort. +type ExistsError struct{ Path string } + +func (e *ExistsError) Error() string { + return fmt.Sprintf("hook already exists at %s (use --force, --replace, or --chain)", e.Path) +} + +// PrenupMarker is written into the managed hook so uninstall can verify +// authorship before removing the file. +const PrenupMarker = "# prenup-managed-hook" + +// Install writes a pre-commit hook at repoRoot/.git/hooks/pre-commit that +// invokes the prenup binary found at binaryPath. mode controls collision +// handling. +func Install(repoRoot, binaryPath string, mode Mode) error { + hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit") + + // Git hooks live in .git/hooks (already restrictive, owned by the user). + // 0o750 satisfies gosec G301 while remaining traversable for the user. + if err := os.MkdirAll(filepath.Dir(hookPath), 0o750); err != nil { + return fmt.Errorf("ensuring hooks dir: %w", err) + } + + // hookPath is constructed from the validated repoRoot plus a fixed suffix. + existing, err := os.ReadFile(hookPath) //nolint:gosec // G304: hookPath is a constructed path under .git/hooks. + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading existing hook: %w", err) + } + + existedAndManaged := len(existing) > 0 && strings.Contains(string(existing), PrenupMarker) + + switch { + case len(existing) == 0 || existedAndManaged: + // Clear to write. + case mode == ModeAbort: + return &ExistsError{Path: hookPath} + case mode == ModeReplace: + backup := hookPath + ".prenup-backup" + if err := os.Rename(hookPath, backup); err != nil { + return fmt.Errorf("backing up existing hook: %w", err) + } + case mode == ModeChain: + localPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit.local") + if err := os.Rename(hookPath, localPath); err != nil { + return fmt.Errorf("saving existing hook as pre-commit.local: %w", err) + } + // Hooks must be executable; chmod ensures the owner+group can run it. + if err := os.Chmod(localPath, 0o750); err != nil { //nolint:gosec // G302: git hooks must be executable. + return fmt.Errorf("chmod pre-commit.local: %w", err) + } + case mode == ModeForce: + // Overwrite without backup. + } + + script := generateScript(binaryPath, mode == ModeChain) + // Git requires the hook to be executable; the owner+group can run it. + if err := os.WriteFile(hookPath, []byte(script), 0o750); err != nil { //nolint:gosec // G306: git hooks must be executable. + return fmt.Errorf("writing hook: %w", err) + } + return nil +} + +// generateScript returns the shell script body for the managed hook. +// +// `set -e` is intentional: if the chained pre-commit.local exits non-zero we +// want to fail-closed and block the commit before prenup even starts. The +// recorded prenup binary path is invoked with "$@" so any future Git hooks +// that do receive arguments forward them; today Git invokes pre-commit with +// none, so this is forward-compatible polish only. +func generateScript(binaryPath string, chain bool) string { + var b strings.Builder + b.WriteString("#!/usr/bin/env bash\n") + b.WriteString(PrenupMarker + "\n") + b.WriteString("# Do not edit this file by hand; regenerate via `prenup install`.\n") + b.WriteString("set -e\n") + if chain { + b.WriteString(`LOCAL="$(dirname "$0")/pre-commit.local"` + "\n") + // fail-closed: a failing local hook aborts the script before exec. + b.WriteString(`if [ -x "$LOCAL" ]; then "$LOCAL" "$@"; fi` + "\n") + } + fmt.Fprintf(&b, "exec %q \"$@\"\n", binaryPath) + return b.String() +} + +// Uninstall removes the managed hook at repoRoot. If a .prenup-backup exists, +// it is restored; otherwise the hook is simply deleted. Returns nil if no +// managed hook is installed. +func Uninstall(repoRoot string) error { + hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit") + data, err := os.ReadFile(hookPath) //nolint:gosec // G304: hookPath is constructed under .git/hooks. + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("reading hook: %w", err) + } + if !strings.Contains(string(data), PrenupMarker) { + return fmt.Errorf("hook at %s was not installed by prenup; leaving it alone", hookPath) + } + + backup := hookPath + ".prenup-backup" + if _, err := os.Stat(backup); err == nil { + if err := os.Rename(backup, hookPath); err != nil { + return fmt.Errorf("restoring backup: %w", err) + } + return nil + } + localPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit.local") + if _, err := os.Stat(localPath); err == nil { + if err := os.Rename(localPath, hookPath); err != nil { + return fmt.Errorf("restoring chained pre-commit.local: %w", err) + } + return nil + } + return os.Remove(hookPath) +} diff --git a/internal/hook/install_test.go b/internal/hook/install_test.go new file mode 100644 index 0000000..75bfc4b --- /dev/null +++ b/internal/hook/install_test.go @@ -0,0 +1,130 @@ +package hook + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" +) + +// HookInstallTestSuite groups behavioral tests for Install/Uninstall. +// Each method gets a fresh fake repo with .git/hooks/ pre-created so +// the code under test has somewhere to write; tests do not need a real +// git repo for these checks. +type HookInstallTestSuite struct { + suite.Suite + repo string // absolute path to the temp repo dir + hook string // absolute path to .git/hooks/pre-commit inside repo +} + +func (s *HookInstallTestSuite) SetupTest() { + dir := s.T().TempDir() + s.Require().NoError(os.MkdirAll(filepath.Join(dir, ".git", "hooks"), 0o750)) + s.repo = dir + s.hook = filepath.Join(dir, ".git", "hooks", "pre-commit") +} + +// writeHook drops a fake (non-prenup) executable hook script at the +// pre-commit path. Used by tests that exercise the +// existing-hook-handling modes. +func (s *HookInstallTestSuite) writeHook(content string) { + s.T().Helper() + //nolint:gosec // G306: simulating executable git hook in tests. + s.Require().NoError(os.WriteFile(s.hook, []byte(content), 0o750)) +} + +// readHook reads the managed pre-commit file. Wrapped so the //nolint +// gosec annotation does not have to repeat at every call site. +func (s *HookInstallTestSuite) readHook(path string) string { + s.T().Helper() + //nolint:gosec // G304: path is constructed under the test TempDir. + data, err := os.ReadFile(path) + s.Require().NoError(err) + return string(data) +} + +func (s *HookInstallTestSuite) TestInstallOnEmptyWritesManagedHook() { + s.Require().NoError(Install(s.repo, "/usr/local/bin/prenup", ModeAbort)) + + body := s.readHook(s.hook) + s.Contains(body, PrenupMarker) + s.Contains(body, `"/usr/local/bin/prenup"`) + + info, err := os.Stat(s.hook) + s.Require().NoError(err) + s.NotZero(info.Mode()&0o100, "hook must be executable") +} + +func (s *HookInstallTestSuite) TestInstallAbortsOnExistingNonPrenupHook() { + s.writeHook("#!/usr/bin/env bash\necho hi\n") + + err := Install(s.repo, "/usr/local/bin/prenup", ModeAbort) + var existsErr *ExistsError + s.Require().ErrorAs(err, &existsErr, "expected ExistsError, got %v", err) +} + +func (s *HookInstallTestSuite) TestInstallReplaceBacksUp() { + original := "#!/usr/bin/env bash\necho original\n" + s.writeHook(original) + + s.Require().NoError(Install(s.repo, "/usr/local/bin/prenup", ModeReplace)) + + s.Equal(original, s.readHook(s.hook+".prenup-backup")) + s.Contains(s.readHook(s.hook), PrenupMarker) +} + +func (s *HookInstallTestSuite) TestInstallChainKeepsOriginalAsLocal() { + original := "#!/usr/bin/env bash\necho original\n" + s.writeHook(original) + + s.Require().NoError(Install(s.repo, "/usr/local/bin/prenup", ModeChain)) + + s.Equal(original, s.readHook(filepath.Join(s.repo, ".git", "hooks", "pre-commit.local"))) + + managed := s.readHook(s.hook) + s.Contains(managed, PrenupMarker) + // In chain mode the managed hook must reference the saved-aside + // local hook so it can delegate to the user's original script. + s.Contains(managed, "pre-commit.local") +} + +// TestInstallReplacesManagedHookEvenInAbortMode pins that ModeAbort's +// "refuse to clobber" behavior does NOT apply when the existing hook +// is itself a prenup-managed hook -- otherwise upgrades and binary +// path changes would be impossible without a separate uninstall step. +func (s *HookInstallTestSuite) TestInstallReplacesManagedHookEvenInAbortMode() { + s.Require().NoError(Install(s.repo, "/old/prenup", ModeAbort)) + s.Require().NoError(Install(s.repo, "/new/prenup", ModeAbort)) + + body := s.readHook(s.hook) + s.Contains(body, "/new/prenup") + s.NotContains(body, "/old/prenup") +} + +func (s *HookInstallTestSuite) TestUninstallRestoresBackup() { + original := "#!/usr/bin/env bash\necho original\n" + s.writeHook(original) + s.Require().NoError(Install(s.repo, "/usr/local/bin/prenup", ModeReplace)) + + s.Require().NoError(Uninstall(s.repo)) + s.Equal(original, s.readHook(s.hook)) + _, err := os.Stat(s.hook + ".prenup-backup") + s.True(os.IsNotExist(err), "backup must be removed after restore") +} + +func (s *HookInstallTestSuite) TestUninstallRefusesUnmanagedHook() { + s.writeHook("#!/usr/bin/env bash\necho hi\n") + + err := Uninstall(s.repo) + s.Require().Error(err) + s.Contains(err.Error(), "not installed by prenup") +} + +func (s *HookInstallTestSuite) TestUninstallNoOpWhenMissing() { + s.Require().NoError(Uninstall(s.repo)) +} + +func TestHookInstallSuite(t *testing.T) { + suite.Run(t, new(HookInstallTestSuite)) +} diff --git a/internal/lock/flock_other.go b/internal/lock/flock_other.go new file mode 100644 index 0000000..c828223 --- /dev/null +++ b/internal/lock/flock_other.go @@ -0,0 +1,19 @@ +//go:build !unix + +package lock + +import "os" + +// This stub keeps the lock package (and therefore the whole module) +// compiling on non-unix platforms, where flock(2) is unavailable. Prenup's +// task execution already assumes a unix-like environment (tasks run via +// `bash -c`), so the cross-repo serialization guarantee is unix-only by +// design; here Acquire degrades to a no-op lock rather than failing to +// build. The package doc calls out that real advisory locking is unix-only. + +// flockNB is a no-op on non-unix platforms: it always "succeeds" so a +// single prenup run proceeds, but it provides no cross-process exclusion. +func flockNB(*os.File) error { return nil } + +// funlock is a no-op on non-unix platforms. +func funlock(*os.File) error { return nil } diff --git a/internal/lock/flock_unix.go b/internal/lock/flock_unix.go new file mode 100644 index 0000000..147f12d --- /dev/null +++ b/internal/lock/flock_unix.go @@ -0,0 +1,19 @@ +//go:build unix + +package lock + +import ( + "os" + "syscall" +) + +// flockNB attempts a non-blocking exclusive advisory lock. Returns +// syscall.EWOULDBLOCK if another process already holds the lock. +func flockNB(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + +// funlock releases a previously-acquired advisory lock. +func funlock(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/lock/helpers_test.go b/internal/lock/helpers_test.go new file mode 100644 index 0000000..88ceb84 --- /dev/null +++ b/internal/lock/helpers_test.go @@ -0,0 +1,99 @@ +package lock + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSplitLines is a truth table for the internal newline splitter. It +// intentionally keeps blank lines (unlike git.splitLines) so callers can +// preserve blank-line semantics if they want; it just splits on '\n'. +func TestSplitLines(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want []string + }{ + {name: "empty", in: "", want: nil}, + {name: "single line no newline", in: "hello", want: []string{"hello"}}, + {name: "single line with newline", in: "hello\n", want: []string{"hello"}}, + {name: "two lines", in: "a\nb", want: []string{"a", "b"}}, + {name: "trailing blank line preserved", in: "a\n\n", want: []string{"a", ""}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, splitLines(tc.in)) + }) + } +} + +// TestTrimSpaceAndIsSpace covers both helpers together since trimSpace's +// only interesting behavior is that it delegates to isSpace. +func TestTrimSpaceAndIsSpace(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want string + }{ + {name: "no whitespace", in: "abc", want: "abc"}, + {name: "leading and trailing spaces", in: " abc ", want: "abc"}, + {name: "tabs and CR", in: "\t\rabc\t\r", want: "abc"}, + {name: "all whitespace", in: " \t\n\r ", want: ""}, + {name: "empty", in: "", want: ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, trimSpace(tc.in)) + }) + } + + assert.True(t, isSpace(' ')) + assert.True(t, isSpace('\t')) + assert.True(t, isSpace('\r')) + assert.True(t, isSpace('\n')) + assert.False(t, isSpace('a')) + assert.False(t, isSpace(0)) +} + +// TestResolveGitFile covers the worktree/submodule support: a .git file +// (regular file, not directory) pointing to a real gitdir should resolve. +func TestResolveGitFile(t *testing.T) { + t.Parallel() + + t.Run("returns false when file is missing", func(t *testing.T) { + t.Parallel() + _, ok := resolveGitFile(filepath.Join(t.TempDir(), "nope")) + assert.False(t, ok) + }) + + t.Run("returns false when prefix is missing", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + f := filepath.Join(dir, ".git") + require.NoError(t, os.WriteFile(f, []byte("something else\n"), 0o600)) + _, ok := resolveGitFile(f) + assert.False(t, ok) + }) + + t.Run("returns trimmed gitdir when present", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + f := filepath.Join(dir, ".git") + require.NoError(t, os.WriteFile(f, []byte("gitdir: /some/where\n"), 0o600)) + got, ok := resolveGitFile(f) + require.True(t, ok) + assert.Equal(t, "/some/where", got) + }) +} diff --git a/internal/lock/lock.go b/internal/lock/lock.go new file mode 100644 index 0000000..fdfee6d --- /dev/null +++ b/internal/lock/lock.go @@ -0,0 +1,158 @@ +// Package lock provides a per-repository advisory lock so two concurrent +// `prenup run` invocations cannot race on the worktree. +// +// Some Git GUIs and IDE integrations invoke `git commit` more than once in +// quick succession; without coordination, two prenup runs can both stash, +// run tasks, and pop in interleaved order, corrupting `stage_output` and +// the user's stash list. The lock guarantees serialization at the repo +// scope. +// +// The implementation uses an OS-level advisory lock (flock(2) on Unix) on a +// file under .git/. Advisory locks are automatically released when the +// holding process dies, so there is no stale-PID file to clean up after a +// crash or kill -9. +package lock + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" +) + +// LockFileName is the basename of the lock file inside .git/. +const LockFileName = "prenup.lock" + +// ErrContended is returned by Acquire when another prenup run is already +// holding the lock. Callers should surface a friendly message and exit +// with a non-zero status rather than block. +var ErrContended = errors.New("another prenup run is already in progress for this repository") + +// Lock is a held advisory lock on a repository. Release with Close. +type Lock struct { + path string + f *os.File +} + +// Acquire takes a non-blocking exclusive advisory lock on +// repoRoot/.git/prenup.lock. It returns ErrContended when another process +// already holds the lock, and any other error if the file system itself is +// the problem. +// +// The returned Lock owns the file descriptor; the caller must call Close +// (typically via defer) to release the lock. Close does not delete the +// lock file (see Close for the rationale). +func Acquire(repoRoot string) (*Lock, error) { + gitDir := filepath.Join(repoRoot, ".git") + // Repos with separate gitdirs (worktrees, submodules) write a `.git` + // regular file pointing at the real dir; resolve it so the lock lives + // alongside the repo's actual git metadata. If `.git` doesn't exist + // yet, MkdirAll below will create it -- callers are responsible for + // having pointed us at a real repo root. + if info, err := os.Stat(gitDir); err == nil && !info.IsDir() { + if resolved, ok := resolveGitFile(gitDir); ok { + // A `gitdir:` target may be relative to the directory that + // contains the `.git` file (repoRoot). Anchor it there so + // MkdirAll/OpenFile don't operate relative to the current + // working directory. + if !filepath.IsAbs(resolved) { + resolved = filepath.Join(repoRoot, resolved) + } + gitDir = resolved + } + } + if err := os.MkdirAll(gitDir, 0o750); err != nil { + return nil, fmt.Errorf("ensuring lock directory: %w", err) + } + path := filepath.Join(gitDir, LockFileName) + // O_CREATE so first-run users don't need a pre-existing lock file. + // 0600: only the user running prenup needs to read/write it. + // G304 suppressed: path = repoRoot/.git/prenup.lock; basename is fixed. + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("opening lock file %s: %w", path, err) + } + if err := flockNB(f); err != nil { + _ = f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, ErrContended + } + return nil, fmt.Errorf("locking %s: %w", path, err) + } + return &Lock{path: path, f: f}, nil +} + +// Close releases the advisory lock and closes the underlying file. It is +// safe to call Close more than once; the second call is a no-op. Close does +// not delete the lock file -- the inode is harmless when unlocked, and +// keeping it avoids a TOCTOU race where another process opens the file +// between our unlink and unlock. +func (l *Lock) Close() error { + if l == nil || l.f == nil { + return nil + } + // Best-effort unlock; the kernel also releases when the fd closes. + _ = funlock(l.f) + err := l.f.Close() + l.f = nil + return err +} + +// Path returns the absolute path of the lock file. Useful for diagnostics. +func (l *Lock) Path() string { + if l == nil { + return "" + } + return l.path +} + +// resolveGitFile reads a `.git` regular file (used by worktrees and +// submodules) and returns the directory it points at. +func resolveGitFile(gitFile string) (string, bool) { + data, err := os.ReadFile(gitFile) //nolint:gosec // G304: callers pass a fixed .git path under repoRoot. + if err != nil { + return "", false + } + const prefix = "gitdir:" + for _, line := range splitLines(string(data)) { + if len(line) > len(prefix) && line[:len(prefix)] == prefix { + return trimSpace(line[len(prefix):]), true + } + } + return "", false +} + +func splitLines(s string) []string { + var out []string + start := 0 + for i := range len(s) { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + out = append(out, s[start:]) + } + return out +} + +func trimSpace(s string) string { + start, end := 0, len(s) + for start < end && isSpace(s[start]) { + start++ + } + for end > start && isSpace(s[end-1]) { + end-- + } + return s[start:end] +} + +func isSpace(b byte) bool { + switch b { + case ' ', '\t', '\r', '\n': + return true + } + return false +} diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go new file mode 100644 index 0000000..be46b41 --- /dev/null +++ b/internal/lock/lock_test.go @@ -0,0 +1,137 @@ +package lock + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// fakeRepo creates a directory containing an empty .git/ subdir, mimicking +// the layout Acquire expects. Returns the repo root. +func fakeRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0o750)) + return repo +} + +func TestAcquireReleaseRoundTrip(t *testing.T) { + t.Parallel() + repo := fakeRepo(t) + + l, err := Acquire(repo) + require.NoError(t, err) + require.NotNil(t, l) + require.FileExists(t, l.Path()) + require.Equal(t, filepath.Join(repo, ".git", LockFileName), l.Path()) + + require.NoError(t, l.Close()) + // Second Close is a no-op, must not panic or error. + require.NoError(t, l.Close()) +} + +func TestAcquireContendedReturnsErrContended(t *testing.T) { + t.Parallel() + repo := fakeRepo(t) + + first, err := Acquire(repo) + require.NoError(t, err) + defer func() { _ = first.Close() }() + + second, err := Acquire(repo) + require.Nil(t, second) + require.ErrorIs(t, err, ErrContended) +} + +func TestAcquireAfterReleaseSucceeds(t *testing.T) { + t.Parallel() + repo := fakeRepo(t) + + first, err := Acquire(repo) + require.NoError(t, err) + require.NoError(t, first.Close()) + + second, err := Acquire(repo) + require.NoError(t, err) + require.NotNil(t, second) + require.NoError(t, second.Close()) +} + +func TestAcquireCreatesGitDirIfMissing(t *testing.T) { + t.Parallel() + repo := t.TempDir() // no .git/ at all + + l, err := Acquire(repo) + require.NoError(t, err) + defer func() { _ = l.Close() }() + + info, statErr := os.Stat(filepath.Join(repo, ".git")) + require.NoError(t, statErr) + require.True(t, info.IsDir()) +} + +// TestAcquireResolvesRelativeGitdir covers the worktree/submodule layout +// where `.git` is a regular file whose `gitdir:` target is *relative* to +// the directory containing that file. Acquire must anchor the resolved path +// at repoRoot so the lock lands in the real git metadata dir rather than +// relative to the current working directory. +func TestAcquireResolvesRelativeGitdir(t *testing.T) { + t.Parallel() + repo := t.TempDir() + + // Real gitdir lives at /.real-git; the `.git` file points at it + // with a relative path, exactly as `git worktree add` writes it. + realGitDir := filepath.Join(repo, ".real-git") + require.NoError(t, os.MkdirAll(realGitDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(repo, ".git"), []byte("gitdir: .real-git\n"), 0o600)) + + l, err := Acquire(repo) + require.NoError(t, err) + defer func() { _ = l.Close() }() + + require.Equal(t, filepath.Join(realGitDir, LockFileName), l.Path()) + require.FileExists(t, l.Path()) +} + +func TestAcquireConcurrent(t *testing.T) { + t.Parallel() + repo := fakeRepo(t) + + const goroutines = 16 + var ( + wg sync.WaitGroup + mu sync.Mutex + successes int + contended int + other []error + ) + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + l, err := Acquire(repo) + mu.Lock() + defer mu.Unlock() + switch { + case err == nil: + successes++ + // Hold briefly so others observe contention. + _ = l.Close() + case errors.Is(err, ErrContended): + contended++ + default: + other = append(other, err) + } + }() + } + wg.Wait() + + require.Empty(t, other, "no unexpected errors") + require.GreaterOrEqual(t, successes, 1, "at least one acquirer must win") + require.Equal(t, goroutines, successes+contended, "every goroutine accounted for") +} diff --git a/internal/runner/events.go b/internal/runner/events.go new file mode 100644 index 0000000..de6b137 --- /dev/null +++ b/internal/runner/events.go @@ -0,0 +1,114 @@ +// Package runner executes selected tasks against the changed modules and emits +// a stream of events that the UI layers render (human TUI, markdown digest, +// NDJSON for agents). +package runner + +import "time" + +// EventKind names the shape of an Event. +type EventKind string + +// Event kinds emitted by the runner. Consumed by all UI sinks (human, markdown, +// json) and exposed verbatim on the JSON wire format. +const ( + EventRunStarted EventKind = "run_started" + EventTaskStarted EventKind = "task_started" + EventLine EventKind = "line" + EventTaskCompleted EventKind = "task_completed" + EventRunCompleted EventKind = "run_completed" + EventNotice EventKind = "notice" +) + +// Stream identifies which pipe an output line came from. +type Stream string + +// Stream values for EventLine events. +const ( + StreamStdout Stream = "stdout" + StreamStderr Stream = "stderr" +) + +// TaskStatus is the terminal status of a task run. +type TaskStatus string + +// TaskStatus values reported on EventTaskCompleted. +const ( + TaskStatusDone TaskStatus = "done" + TaskStatusFailed TaskStatus = "failed" + TaskStatusSkipped TaskStatus = "skipped" +) + +// Event is the unit the runner emits to subscribed UIs. +type Event struct { + Kind EventKind `json:"type"` + Time time.Time `json:"time,omitempty"` + + // Identifying fields (populated as relevant to Kind). + Version string `json:"version,omitempty"` + Task string `json:"task,omitempty"` + Module string `json:"module,omitempty"` + + // Task-start fields. Carry the resolved (post-template-expansion) + // command and working directory so a consumer reading a failure + // transcript has everything it needs to reproduce the run without + // also reading .prenup.yaml. + Command string `json:"command,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + + // Line-specific fields. + Stream Stream `json:"stream,omitempty"` + Text string `json:"text,omitempty"` + + // Task-completion fields. + Status TaskStatus `json:"status,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` + + // Run-level fields. + Modules []string `json:"modules,omitempty"` + Tasks []string `json:"tasks,omitempty"` + Succeeded int `json:"succeeded,omitempty"` + Failed int `json:"failed,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + + // RepoRoot is the absolute path of the git repository prenup was + // invoked from. Set on EventRunStarted so a JSON consumer can anchor + // every subsequent task's working_dir without inferring it from + // substring matches. + RepoRoot string `json:"repo_root,omitempty"` + + // FailedTasks lists the names of tasks that ended in TaskStatusFailed. + // Set on EventRunCompleted so a consumer with a long log can jump + // directly to the failures without rescanning every task_completed + // event. + FailedTasks []string `json:"failed_tasks,omitempty"` + + // Free-form human-readable message used by notices. + Message string `json:"message,omitempty"` +} + +// Sink consumes events. +// +// Implementations MUST be safe for concurrent Emit calls from multiple +// goroutines: parallel per-module task runs (see runner.runParallel) fan out +// goroutines that each call Emit on the same sink. Sinks that wrap shared +// state (writers, channels, maps) need their own synchronization. +// +// Close is called once, by the orchestrator that owns the sink, after the +// final Run-level event has been delivered. Implementations may use it to +// flush buffered output. Close need not be safe to call concurrently with +// Emit. +type Sink interface { + Emit(Event) + Close() error +} + +// DiscardSink drops every event. Useful as a default for callers that do not +// supply a Sink, and for tests. +type DiscardSink struct{} + +// Emit on a DiscardSink does nothing. +func (DiscardSink) Emit(Event) {} + +// Close on a DiscardSink does nothing. +func (DiscardSink) Close() error { return nil } diff --git a/internal/runner/exec.go b/internal/runner/exec.go new file mode 100644 index 0000000..9485dff --- /dev/null +++ b/internal/runner/exec.go @@ -0,0 +1,115 @@ +package runner + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "syscall" + "time" +) + +// Executor runs a shell command and streams stdout+stderr lines to a callback. +// It is an interface so tests can swap in a fake. +type Executor interface { + Run(ctx context.Context, command, workingDir string, env map[string]string, onLine func(Stream, string)) error +} + +// BashExecutor invokes `bash -c command`. +// +// On context cancellation it sends SIGTERM and gives the child up to +// gracefulShutdown to clean up before SIGKILL. Long-running tools (test +// runners, linters) often want to flush output or remove temp files; the +// default exec.CommandContext behavior of immediate SIGKILL would skip that. +type BashExecutor struct { + // GracefulShutdown caps how long to wait after cancel before the + // process is forcefully killed. Zero means use the default of 5s. + GracefulShutdown time.Duration +} + +// gracefulShutdownDefault is the wait between SIGTERM and SIGKILL when +// GracefulShutdown is unset. +const gracefulShutdownDefault = 5 * time.Second + +// Run executes command via bash -c. stdout and stderr are read concurrently +// and forwarded to onLine preserving the originating stream. +func (b BashExecutor) Run(ctx context.Context, command, workingDir string, env map[string]string, onLine func(Stream, string)) error { + cmd := exec.CommandContext(ctx, "bash", "-c", command) //nolint:gosec // command comes from user config + if workingDir != "" { + cmd.Dir = workingDir + } + if len(env) > 0 { + cmd.Env = append(os.Environ(), flattenEnv(env)...) + } + // On ctx cancel, ask politely first; SIGKILL after the grace period. + cmd.Cancel = func() error { + // SIGTERM may legitimately fail (process already gone); ignore. + _ = cmd.Process.Signal(syscall.SIGTERM) + return nil + } + grace := b.GracefulShutdown + if grace <= 0 { + grace = gracefulShutdownDefault + } + cmd.WaitDelay = grace + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting command: %w", err) + } + + var wg sync.WaitGroup + wg.Add(2) + go pumpLines(&wg, stdout, StreamStdout, onLine) + go pumpLines(&wg, stderr, StreamStderr, onLine) + wg.Wait() + + if err := cmd.Wait(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return fmt.Errorf("exit status %d", exitErr.ExitCode()) + } + return err + } + return nil +} + +// pumpLines reads from r line-by-line up to maxScannerLine. If a single line +// exceeds the buffer, the scanner stops; we surface that to the caller as a +// synthetic stderr-style notice on the same stream so users know their +// output was truncated. +func pumpLines(wg *sync.WaitGroup, r io.Reader, stream Stream, onLine func(Stream, string)) { + defer wg.Done() + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 64*1024), maxScannerLine) + for scanner.Scan() { + onLine(stream, scanner.Text()) + } + if err := scanner.Err(); err != nil { + onLine(stream, fmt.Sprintf("[prenup] output truncated: %v (line exceeded %d bytes)", err, maxScannerLine)) + } +} + +// maxScannerLine bounds a single line's length. 1 MB is generous for human +// output but small enough to keep memory bounded against pathological tools. +const maxScannerLine = 1024 * 1024 + +func flattenEnv(env map[string]string) []string { + out := make([]string, 0, len(env)) + for k, v := range env { + out = append(out, k+"="+v) + } + return out +} diff --git a/internal/runner/exec_test.go b/internal/runner/exec_test.go new file mode 100644 index 0000000..13bf478 --- /dev/null +++ b/internal/runner/exec_test.go @@ -0,0 +1,133 @@ +package runner + +import ( + "context" + "os/exec" + "sort" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFlattenEnv verifies the deterministic-shape contract: every entry is +// K=V and the set is exactly the input map. We sort before comparing since +// map iteration order isn't stable. +func TestFlattenEnv(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in map[string]string + want []string + }{ + {name: "empty map", in: map[string]string{}, want: []string{}}, + {name: "nil map", in: nil, want: []string{}}, + {name: "single entry", in: map[string]string{"X": "1"}, want: []string{"X=1"}}, + { + name: "multiple entries deterministic", + in: map[string]string{"A": "1", "B": "2", "C": "3"}, + want: []string{"A=1", "B=2", "C=3"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := flattenEnv(tc.in) + sort.Strings(got) + sort.Strings(tc.want) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestBashExecutor_Run_HappyPath exercises the full pipe/pump/wait flow: a +// short bash command emits stdout and stderr lines, both are streamed to +// onLine with the correct Stream, and Run returns nil on exit 0. +func TestBashExecutor_Run_HappyPath(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available on this platform") + } + + var ( + mu sync.Mutex + lines []struct { + stream Stream + text string + } + ) + onLine := func(s Stream, txt string) { + mu.Lock() + defer mu.Unlock() + lines = append(lines, struct { + stream Stream + text string + }{s, txt}) + } + + err := BashExecutor{}.Run(context.Background(), + `echo hello; echo boom 1>&2`, "", map[string]string{"PRENUP_TEST": "x"}, onLine) + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + require.Len(t, lines, 2) + + // Order between stdout and stderr isn't guaranteed (separate goroutines), + // so sort by text before asserting. + sort.Slice(lines, func(i, j int) bool { return lines[i].text < lines[j].text }) + assert.Equal(t, StreamStderr, lines[0].stream) + assert.Equal(t, "boom", lines[0].text) + assert.Equal(t, StreamStdout, lines[1].stream) + assert.Equal(t, "hello", lines[1].text) +} + +// TestBashExecutor_Run_NonZeroExit surfaces the exit status as an error +// prefixed with "exit status N". +func TestBashExecutor_Run_NonZeroExit(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available on this platform") + } + + err := BashExecutor{}.Run(context.Background(), "exit 7", "", nil, func(Stream, string) {}) + require.Error(t, err) + assert.Contains(t, err.Error(), "exit status 7") +} + +// TestBashExecutor_Run_ContextCancel_ReturnsPromptly asserts that canceling +// the parent context terminates a long-running command within the grace +// window instead of waiting on it forever. The Cancel/WaitDelay wiring is +// subtle enough that a regression here would show up as a hang under load. +func TestBashExecutor_Run_ContextCancel_ReturnsPromptly(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available on this platform") + } + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := BashExecutor{GracefulShutdown: 200 * time.Millisecond}. + Run(ctx, "sleep 30", "", nil, func(Stream, string) {}) + elapsed := time.Since(start) + + require.Error(t, err, "canceled command should return an error") + assert.Less(t, elapsed, 5*time.Second, "cancel path must not hang for the full sleep") +} + +// TestDiscardSink covers the trivial no-op sink's contract. +func TestDiscardSink(t *testing.T) { + t.Parallel() + var s DiscardSink + assert.NotPanics(t, func() { s.Emit(Event{Kind: EventRunStarted}) }) + assert.NoError(t, s.Close()) +} diff --git a/internal/runner/mocks/mocks.go b/internal/runner/mocks/mocks.go new file mode 100644 index 0000000..c78306e --- /dev/null +++ b/internal/runner/mocks/mocks.go @@ -0,0 +1,225 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/c2fo/prenup/internal/runner" + mock "github.com/stretchr/testify/mock" +) + +// NewSink creates a new instance of Sink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSink(t interface { + mock.TestingT + Cleanup(func()) +}) *Sink { + mock := &Sink{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// Sink is an autogenerated mock type for the Sink type +type Sink struct { + mock.Mock +} + +type Sink_Expecter struct { + mock *mock.Mock +} + +func (_m *Sink) EXPECT() *Sink_Expecter { + return &Sink_Expecter{mock: &_m.Mock} +} + +// Close provides a mock function for the type Sink +func (_mock *Sink) Close() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Close") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Sink_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type Sink_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *Sink_Expecter) Close() *Sink_Close_Call { + return &Sink_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *Sink_Close_Call) Run(run func()) *Sink_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Sink_Close_Call) Return(err error) *Sink_Close_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Sink_Close_Call) RunAndReturn(run func() error) *Sink_Close_Call { + _c.Call.Return(run) + return _c +} + +// Emit provides a mock function for the type Sink +func (_mock *Sink) Emit(event runner.Event) { + _mock.Called(event) + return +} + +// Sink_Emit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Emit' +type Sink_Emit_Call struct { + *mock.Call +} + +// Emit is a helper method to define mock.On call +// - event runner.Event +func (_e *Sink_Expecter) Emit(event interface{}) *Sink_Emit_Call { + return &Sink_Emit_Call{Call: _e.mock.On("Emit", event)} +} + +func (_c *Sink_Emit_Call) Run(run func(event runner.Event)) *Sink_Emit_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 runner.Event + if args[0] != nil { + arg0 = args[0].(runner.Event) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Sink_Emit_Call) Return() *Sink_Emit_Call { + _c.Call.Return() + return _c +} + +func (_c *Sink_Emit_Call) RunAndReturn(run func(event runner.Event)) *Sink_Emit_Call { + _c.Run(run) + return _c +} + +// NewExecutor creates a new instance of Executor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewExecutor(t interface { + mock.TestingT + Cleanup(func()) +}) *Executor { + mock := &Executor{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// Executor is an autogenerated mock type for the Executor type +type Executor struct { + mock.Mock +} + +type Executor_Expecter struct { + mock *mock.Mock +} + +func (_m *Executor) EXPECT() *Executor_Expecter { + return &Executor_Expecter{mock: &_m.Mock} +} + +// Run provides a mock function for the type Executor +func (_mock *Executor) Run(ctx context.Context, command string, workingDir string, env map[string]string, onLine func(runner.Stream, string)) error { + ret := _mock.Called(ctx, command, workingDir, env, onLine) + + if len(ret) == 0 { + panic("no return value specified for Run") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string, func(runner.Stream, string)) error); ok { + r0 = returnFunc(ctx, command, workingDir, env, onLine) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Executor_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' +type Executor_Run_Call struct { + *mock.Call +} + +// Run is a helper method to define mock.On call +// - ctx context.Context +// - command string +// - workingDir string +// - env map[string]string +// - onLine func(runner.Stream, string) +func (_e *Executor_Expecter) Run(ctx interface{}, command interface{}, workingDir interface{}, env interface{}, onLine interface{}) *Executor_Run_Call { + return &Executor_Run_Call{Call: _e.mock.On("Run", ctx, command, workingDir, env, onLine)} +} + +func (_c *Executor_Run_Call) Run(run func(ctx context.Context, command string, workingDir string, env map[string]string, onLine func(runner.Stream, string))) *Executor_Run_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 map[string]string + if args[3] != nil { + arg3 = args[3].(map[string]string) + } + var arg4 func(runner.Stream, string) + if args[4] != nil { + arg4 = args[4].(func(runner.Stream, string)) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *Executor_Run_Call) Return(err error) *Executor_Run_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Executor_Run_Call) RunAndReturn(run func(ctx context.Context, command string, workingDir string, env map[string]string, onLine func(runner.Stream, string)) error) *Executor_Run_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/runner/multisink.go b/internal/runner/multisink.go new file mode 100644 index 0000000..289b69d --- /dev/null +++ b/internal/runner/multisink.go @@ -0,0 +1,31 @@ +package runner + +// MultiSink fans every event out to multiple downstream sinks. Useful for +// combining the live TUI with a post-run summary printer. +type MultiSink struct { + sinks []Sink +} + +// NewMultiSink returns a Sink that delegates to each of the given sinks. +func NewMultiSink(sinks ...Sink) *MultiSink { + return &MultiSink{sinks: sinks} +} + +// Emit forwards ev to every sink. +func (m *MultiSink) Emit(ev Event) { + for _, s := range m.sinks { + s.Emit(ev) + } +} + +// Close closes every sink; the first error is returned and the rest are +// attempted regardless. +func (m *MultiSink) Close() error { + var firstErr error + for _, s := range m.sinks { + if err := s.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/internal/runner/multisink_test.go b/internal/runner/multisink_test.go new file mode 100644 index 0000000..20b95a1 --- /dev/null +++ b/internal/runner/multisink_test.go @@ -0,0 +1,54 @@ +package runner + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type recordingSink struct { + events []Event + closeErr error + closed bool +} + +func (r *recordingSink) Emit(ev Event) { r.events = append(r.events, ev) } +func (r *recordingSink) Close() error { r.closed = true; return r.closeErr } + +// TestMultiSinkEmitsToAll verifies the basic fan-out contract. +func TestMultiSinkEmitsToAll(t *testing.T) { + t.Parallel() + a, b := &recordingSink{}, &recordingSink{} + m := NewMultiSink(a, b) + m.Emit(Event{Kind: EventNotice, Message: "hi"}) + + assert.Len(t, a.events, 1) + assert.Len(t, b.events, 1) +} + +// TestMultiSinkCloseAggregatesErrors confirms that when one sink's Close +// fails, the other sinks are still closed (so resources don't leak) and the +// first error is what gets returned to the caller. +func TestMultiSinkCloseAggregatesErrors(t *testing.T) { + t.Parallel() + first := &recordingSink{closeErr: errors.New("first failed")} + second := &recordingSink{} + third := &recordingSink{closeErr: errors.New("third failed")} + + m := NewMultiSink(first, second, third) + err := m.Close() + + require.EqualError(t, err, "first failed", "first error must propagate") + assert.True(t, first.closed) + assert.True(t, second.closed, "second sink must be closed even if first errored") + assert.True(t, third.closed, "third sink must be closed regardless of earlier errors") +} + +// TestMultiSinkCloseAllSuccess returns nil when every sink closes cleanly. +func TestMultiSinkCloseAllSuccess(t *testing.T) { + t.Parallel() + m := NewMultiSink(&recordingSink{}, &recordingSink{}) + assert.NoError(t, m.Close()) +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..027421d --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,502 @@ +package runner + +import ( + "context" + "fmt" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/c2fo/prenup/internal/config" + "github.com/c2fo/prenup/internal/discover" + "github.com/c2fo/prenup/internal/git" +) + +// Plan describes what a run will do. It is computed up-front so it can be +// shown by `prenup plan` without executing anything. +type Plan struct { + RepoRoot string + Modules []string + ExcludedModules []string // modules filtered out by per-task paths; informational + Tasks []PlannedTask +} + +// PlannedTask is a task combined with its resolved module set. +type PlannedTask struct { + Task config.Task + Modules []string // empty slice means "run once with no module scope" + Selected bool + SkipReason string // populated when Selected is false or Modules is empty + CleanWorktree bool + Parallel bool +} + +// BuildPlan computes the plan without side effects. changedFiles and modules +// come from discover. +func BuildPlan(cfg config.Config, repoRoot string, changedFiles, modules []string, selected map[string]bool) Plan { + plan := Plan{ + RepoRoot: repoRoot, + Modules: modules, + } + + cleanDefault := cfg.CleanWorktreeEnabled() + + for i := range cfg.Tasks { + // Range by index + pointer to avoid copying the (potentially large) + // Task struct on every iteration; PlannedTask.Task takes its own + // copy below so callers can mutate the plan without affecting cfg. + t := &cfg.Tasks[i] + planned := PlannedTask{ + Task: *t, + Selected: true, + CleanWorktree: t.CleanWorktreeEnabled(cleanDefault), + Parallel: t.ParallelEnabled(), + } + + // Apply selection filter (nil means "take default_selected"). + if selected != nil { + if _, ok := selected[t.Name]; !ok { + planned.Selected = false + planned.SkipReason = "not selected" + } + } else if !t.DefaultSelected { + planned.Selected = false + planned.SkipReason = "not default_selected" + } + + // Apply per-task path filter to decide which files (and thus + // which modules) this task cares about. + taskFiles := discover.FilterByPaths(changedFiles, t.Paths, t.PathsIgnore) + if (len(t.Paths) > 0 || len(t.PathsIgnore) > 0) && len(taskFiles) == 0 { + planned.Selected = false + if planned.SkipReason == "" { + planned.SkipReason = "no files match task paths" + } + } + + if t.PerModule { + // Intersect the global module list with modules that own at + // least one taskFiles entry. When no path filter is set, + // taskFiles is the full file list, so the intersection equals + // the global module set. When path filters are set but there + // is no marker-derived module (e.g. tests using a synthetic + // module list), we still want to respect the task's filtered + // file set by keeping the global modules that "own" those + // files via prefix match. + mods := discover.Modules(repoRoot, taskFiles, cfg.ModuleMarkers) + if len(mods) == 0 && planned.Selected { + // Fall back: keep only global modules that have at least + // one taskFile under them. + mods = intersectModulesWithFiles(modules, taskFiles) + } + planned.Modules = mods + if planned.Selected && len(mods) == 0 { + planned.Selected = false + if planned.SkipReason == "" { + planned.SkipReason = "no modules matched" + } + } + } else { + // Non-per-module tasks run once without a module scope. + planned.Modules = []string{""} + } + + plan.Tasks = append(plan.Tasks, planned) + } + return plan +} + +// Options configures a Run invocation. +type Options struct { + Executor Executor + Git *git.Runner + Sink Sink + Version string + UpdateNotice string + MaxParallelism int // 0 == NumCPU + CleanWorktree bool // whether to stash unstaged changes around the run + DryRun bool // if true, emit events but do not execute commands +} + +// Result aggregates the outcome of a Run. +type Result struct { + Succeeded int + Failed int + ExitCode int + // FailedTasks is the names of tasks that ended in TaskStatusFailed, + // in selection order. Empty when Failed == 0. + FailedTasks []string +} + +// Run executes the plan's selected tasks and emits events to opts.Sink. +// +// Run never returns an error today; the (Result, error) shape is preserved so +// future failure modes (e.g. unrecoverable stash errors) can be surfaced +// without changing every caller. +func Run(ctx context.Context, plan Plan, opts Options) (Result, error) { + if opts.Sink == nil { + opts.Sink = DiscardSink{} + } + sink := opts.Sink + exec := opts.Executor + if exec == nil { + exec = BashExecutor{} + } + maxPar := opts.MaxParallelism + if maxPar <= 0 { + maxPar = runtime.NumCPU() + } + + emitRunStarted(sink, plan, opts) + + stash := beginStash(opts, sink) + defer endStash(stash, sink, opts.Git) + + var result Result + for i := range plan.Tasks { + pt := &plan.Tasks[i] + if !pt.Selected { + sink.Emit(Event{ + Kind: EventTaskCompleted, + Time: time.Now(), + Task: pt.Task.Name, + Status: TaskStatusSkipped, + Message: pt.SkipReason, + }) + continue + } + runOneTask(ctx, pt, plan.RepoRoot, opts, exec, sink, maxPar, &result) + } + + if result.Failed > 0 { + result.ExitCode = 1 + } + sink.Emit(Event{ + Kind: EventRunCompleted, + Time: time.Now(), + Succeeded: result.Succeeded, + Failed: result.Failed, + ExitCode: result.ExitCode, + FailedTasks: result.FailedTasks, + }) + return result, nil +} + +// emitRunStarted publishes the EventRunStarted event with selected task names. +func emitRunStarted(sink Sink, plan Plan, opts Options) { + taskNames := make([]string, 0, len(plan.Tasks)) + for i := range plan.Tasks { + if plan.Tasks[i].Selected { + taskNames = append(taskNames, plan.Tasks[i].Task.Name) + } + } + sink.Emit(Event{ + Kind: EventRunStarted, + Time: time.Now(), + Version: opts.Version, + Modules: plan.Modules, + Tasks: taskNames, + RepoRoot: plan.RepoRoot, + Message: opts.UpdateNotice, + }) +} + +// beginStash optionally stashes the worktree before the run; emits a notice on +// failure and returns nil so the run continues without stash protection. +func beginStash(opts Options, sink Sink) *git.Stash { + if !opts.CleanWorktree || opts.Git == nil || opts.DryRun { + return nil + } + s, err := opts.Git.Push() + if err != nil { + sink.Emit(Event{ + Kind: EventNotice, + Time: time.Now(), + Message: "stash failed; continuing without clean_worktree: " + err.Error(), + }) + return nil + } + return s +} + +// endStash pops a previously created stash, emitting a notice if restore +// fails. A failed pop typically means `stage_output` added files that +// conflicted with the user's own unstaged hunks; the original work is still +// recoverable from `git stash list`. +func endStash(stash *git.Stash, sink Sink, _ *git.Runner) { + if stash == nil { + return + } + if err := stash.Pop(); err != nil { + sink.Emit(Event{ + Kind: EventNotice, + Time: time.Now(), + Message: "failed to restore stash: " + err.Error() + + " (your unstaged changes are preserved; run `git stash list`, resolve any conflicts, then `git stash pop`)", + }) + } +} + +// snapshotTracked records the set of tracked files for a fresh "before" +// baseline. Used per-task so that stage_output only stages files that this +// task created, not files generated by an earlier task in the same run. +func snapshotTracked(opts Options) map[string]struct{} { + if opts.Git == nil || opts.DryRun { + return nil + } + snap, err := opts.Git.TrackedFiles() + if err != nil { + return nil + } + return snap +} + +// runOneTask executes a single planned task (possibly across modules) and +// updates result with success/failure counts. +func runOneTask(ctx context.Context, pt *PlannedTask, repoRoot string, opts Options, + exec Executor, sink Sink, maxPar int, result *Result) { + taskStart := time.Now() + + // Snapshot the tracked file set immediately before each task with + // stage_output so generated files from a previous task aren't + // mis-attributed here. + var beforeTracked map[string]struct{} + if pt.Task.StageOutput && opts.Git != nil && !opts.DryRun { + beforeTracked = snapshotTracked(opts) + } + + var taskFailed bool + if pt.Parallel && len(pt.Modules) > 1 && !opts.DryRun { + taskFailed = runParallel(ctx, pt, repoRoot, opts, exec, sink, maxPar) + } else { + taskFailed = runSequential(ctx, pt, repoRoot, opts, exec, sink) + } + + status := TaskStatusDone + if taskFailed { + status = TaskStatusFailed + result.Failed++ + result.FailedTasks = append(result.FailedTasks, pt.Task.Name) + } else { + result.Succeeded++ + } + sink.Emit(Event{ + Kind: EventTaskCompleted, + Time: time.Now(), + Task: pt.Task.Name, + Status: status, + DurationMs: time.Since(taskStart).Milliseconds(), + }) + + if !taskFailed && pt.Task.StageOutput && opts.Git != nil && !opts.DryRun { + if err := stageGenerated(opts.Git, pt.Task.OutputPatterns, beforeTracked, pt.Modules); err != nil { + sink.Emit(Event{ + Kind: EventNotice, + Time: time.Now(), + Task: pt.Task.Name, + Message: "staging output failed: " + err.Error(), + }) + } + } +} + +// runSequential executes pt's modules one by one, fail-fast on the first +// error. When fail-fast triggers, remaining modules are reported as skipped +// so consumers see a terminating event for every module that was queued. +// Returns true if any module failed. +func runSequential(ctx context.Context, pt *PlannedTask, repoRoot string, opts Options, exec Executor, sink Sink) bool { + for i, module := range pt.Modules { + if err := runOne(ctx, pt, module, repoRoot, opts, exec, sink); err != nil { + emitModuleSkips(sink, pt.Task.Name, pt.Modules[i+1:], "fail-fast: sibling module failed") + return true + } + } + return false +} + +// runParallel fans per-module runs out with bounded concurrency. The first +// failure short-circuits subsequent work (fail-fast is preserved); modules +// that never started or aborted before runOne report a skip notice so the +// event stream stays in balance with EventTaskStarted/EventTaskCompleted +// expectations downstream. Returns true if any module failed. +func runParallel(ctx context.Context, pt *PlannedTask, repoRoot string, opts Options, exec Executor, sink Sink, maxPar int) bool { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + sem := make(chan struct{}, maxPar) + var wg sync.WaitGroup + var mu sync.Mutex + var failed bool + skipped := make(map[string]bool, len(pt.Modules)) + + modules := make([]string, len(pt.Modules)) + copy(modules, pt.Modules) + sort.Strings(modules) // stable event ordering for tests + + for _, module := range modules { + mu.Lock() + if failed { + skipped[module] = true + mu.Unlock() + continue + } + mu.Unlock() + + wg.Add(1) + sem <- struct{}{} + go func(module string) { + defer wg.Done() + defer func() { <-sem }() + + // Re-check failure state after acquiring the semaphore so + // queued goroutines drop out as soon as a sibling fails. + mu.Lock() + abort := failed + if abort { + skipped[module] = true + } + mu.Unlock() + if abort { + return + } + + if err := runOne(ctx, pt, module, repoRoot, opts, exec, sink); err != nil { + mu.Lock() + failed = true + mu.Unlock() + cancel() + } + }(module) + } + wg.Wait() + if failed { + // Emit deterministic skip notices in module order. + var skippedList []string + for _, m := range modules { + if skipped[m] { + skippedList = append(skippedList, m) + } + } + emitModuleSkips(sink, pt.Task.Name, skippedList, "fail-fast: sibling module failed") + } + return failed +} + +// emitModuleSkips emits a skip-flavored notice per module so consumers know +// these per-module runs were aborted by fail-fast rather than silently +// dropped. Notices are used (rather than synthetic task_completed events) +// because EventTaskCompleted is reserved for the per-task aggregate. +func emitModuleSkips(sink Sink, task string, modules []string, reason string) { + for _, m := range modules { + sink.Emit(Event{ + Kind: EventNotice, + Time: time.Now(), + Task: task, + Module: m, + Message: reason, + }) + } +} + +// runOne executes pt on a single module, emitting task_started, line events, +// and either nothing (caller emits task_completed) or an error message. +func runOne(ctx context.Context, pt *PlannedTask, module, repoRoot string, opts Options, exec Executor, sink Sink) error { + vars := NewTemplateVars(repoRoot, module) + command := vars.Expand(pt.Task.Command) + + workingDir := pt.Task.WorkingDir + if workingDir != "" { + workingDir = vars.Expand(workingDir) + } else if pt.Task.PerModule && module != "" { + workingDir = vars.ModuleRoot + } else { + workingDir = vars.RepoRoot + } + + // Emit task_started after template expansion so the event carries the + // resolved command and working directory. Consumers reading a failure + // transcript can then reproduce the exact invocation without also + // reading .prenup.yaml or knowing how prenup expands templates. + sink.Emit(Event{ + Kind: EventTaskStarted, + Time: time.Now(), + Task: pt.Task.Name, + Module: module, + Command: command, + WorkingDir: workingDir, + }) + + if opts.DryRun { + sink.Emit(Event{ + Kind: EventLine, + Time: time.Now(), + Task: pt.Task.Name, + Module: module, + Stream: StreamStdout, + Text: fmt.Sprintf("[dry-run] would run: %s (in %s)", command, workingDir), + }) + return nil + } + + err := exec.Run(ctx, command, workingDir, pt.Task.Env, func(stream Stream, text string) { + sink.Emit(Event{ + Kind: EventLine, + Time: time.Now(), + Task: pt.Task.Name, + Module: module, + Stream: stream, + Text: text, + }) + }) + if err != nil { + // Prefix with [prenup] so consumers can distinguish prenup's own + // synthetic failure attribution from a stderr line the user's + // script happened to print (e.g. a test harness reporting on a + // child process). The Message field carries the unprefixed error + // for programmatic consumers. + sink.Emit(Event{ + Kind: EventLine, + Time: time.Now(), + Task: pt.Task.Name, + Module: module, + Stream: StreamStderr, + Text: "[prenup] command failed: " + err.Error(), + Message: err.Error(), + }) + } + return err +} + +// intersectModulesWithFiles returns the subset of modules that own at least +// one of files via a path-prefix match. Used as a fallback when the +// configured module_markers aren't present on disk (e.g. test scaffolding) +// but the caller still provides a plausible module list. +// +// "." is treated as the repo root, which owns every file. +func intersectModulesWithFiles(modules, files []string) []string { + out := make([]string, 0, len(modules)) + for _, m := range modules { + if m == "." { + out = append(out, m) + continue + } + for _, f := range files { + if hasPathPrefix(f, m) { + out = append(out, m) + break + } + } + } + return out +} + +// hasPathPrefix reports whether s is exactly prefix or sits below prefix in +// the path hierarchy. Operates on slash-separated paths (the form git uses). +func hasPathPrefix(s, prefix string) bool { + if prefix == "" || prefix == "." { + return true + } + return s == prefix || strings.HasPrefix(s, prefix+"/") +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..6b69430 --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,264 @@ +package runner_test + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/prenup/internal/config" + "github.com/c2fo/prenup/internal/runner" + "github.com/c2fo/prenup/internal/runner/mocks" +) + +// RunnerTestSuite groups end-to-end behavioral tests for the runner: its +// plan-building rules and its event-emission contract during execution. +// Tests are written against the public package (runner_test) so the +// suite exercises the same surface a downstream consumer would. +type RunnerTestSuite struct { + suite.Suite +} + +// recordingSink wraps a mockery-generated Sink mock with a permissive +// catch-all EXPECT() that captures every emitted event into a slice +// the test can assert against. The runner emits many events per run +// (started, line, completed for each task plus run-level bookends); +// strict per-event expectations would be brittle and would obscure +// the actual behavior under test. Tests that care about a specific +// event walk the captured slice instead. +type recordingSink struct { + mock *mocks.Sink + mu sync.Mutex + events []runner.Event +} + +func (s *RunnerTestSuite) newRecordingSink() *recordingSink { + rs := &recordingSink{mock: mocks.NewSink(s.T())} + rs.mock.EXPECT(). + Emit(mock.Anything). + Run(func(ev runner.Event) { + rs.mu.Lock() + defer rs.mu.Unlock() + rs.events = append(rs.events, ev) + }). + Return(). + Maybe() + rs.mock.EXPECT().Close().Return(nil).Maybe() + return rs +} + +func (rs *recordingSink) snapshot() []runner.Event { + rs.mu.Lock() + defer rs.mu.Unlock() + out := make([]runner.Event, len(rs.events)) + copy(out, rs.events) + return out +} + +// stubExecutor returns a mockery Executor pre-wired so any Run call +// returns runResult, optionally invoking the runner-supplied onLine +// callback with stdoutLine first. Tests that need different per-call +// behavior wire the EXPECT chain themselves. +func (s *RunnerTestSuite) stubExecutor(stdoutLine string, runResult error) (*mocks.Executor, *atomicCallLog) { + exec := mocks.NewExecutor(s.T()) + calls := &atomicCallLog{} + exec.EXPECT(). + Run(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + RunAndReturn(func(_ context.Context, command, workingDir string, env map[string]string, onLine func(runner.Stream, string)) error { + calls.add(command, workingDir, env) + if stdoutLine != "" { + onLine(runner.StreamStdout, stdoutLine) + } + return runResult + }). + Maybe() + return exec, calls +} + +// atomicCallLog records executor invocations from concurrent runner +// goroutines without forcing tests to manage their own mutex. +type atomicCallLog struct { + mu sync.Mutex + calls []executorCall +} + +type executorCall struct { + command string + workingDir string + env map[string]string +} + +func (l *atomicCallLog) add(command, workingDir string, env map[string]string) { + l.mu.Lock() + defer l.mu.Unlock() + l.calls = append(l.calls, executorCall{command: command, workingDir: workingDir, env: env}) +} + +func (l *atomicCallLog) len() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.calls) +} + +func (s *RunnerTestSuite) TestSequentialSuccess() { + cfg := config.Config{ + Version: 1, + ModuleMarkers: []string{"go.mod"}, + Tasks: []config.Task{ + {Name: "A", Command: "cmd-a", DefaultSelected: true, PerModule: true}, + {Name: "B", Command: "cmd-b", DefaultSelected: true, PerModule: true}, + }, + } + plan := runner.BuildPlan(cfg, "/repo", []string{"pkg/foo/x.go"}, []string{"pkg/foo"}, nil) + s.Require().Len(plan.Tasks, 2) + s.True(plan.Tasks[0].Selected) + + exec, _ := s.stubExecutor("", nil) + sink := s.newRecordingSink() + opts := runner.Options{ + Executor: exec, + Sink: sink.mock, + Version: "v2.0.0", + MaxParallelism: 1, + CleanWorktree: false, + } + result, err := runner.Run(context.Background(), plan, opts) + s.Require().NoError(err) + s.Equal(0, result.ExitCode) + s.Equal(2, result.Succeeded) + s.Equal(0, result.Failed) + + events := sink.snapshot() + s.Require().NotEmpty(events) + s.Equal(runner.EventRunStarted, events[0].Kind, "first event must be run_started") + s.Equal(runner.EventRunCompleted, events[len(events)-1].Kind, "last event must be run_completed") +} + +// TestFailFastWithinTaskAcrossModules pins two intertwined behaviors: +// 1. when the first module of a per-module task fails, the runner +// does not try the remaining modules of that task; and +// 2. the synthetic failure-attribution stderr line carries a +// [prenup] prefix in Text but keeps the unprefixed cause in +// Message, so a programmatic consumer can distinguish prenup's +// own attribution from a stderr line the user's command happened +// to emit. +func (s *RunnerTestSuite) TestFailFastWithinTaskAcrossModules() { + cfg := config.Config{ + Version: 1, + ModuleMarkers: []string{"go.mod"}, + Tasks: []config.Task{ + {Name: "Lint", Command: "lint", DefaultSelected: true, PerModule: true}, + }, + } + modules := []string{"a", "b", "c"} + plan := runner.BuildPlan(cfg, "/repo", []string{"a/x.go", "b/y.go", "c/z.go"}, modules, nil) + plan.Tasks[0].Modules = modules + + exec, calls := s.stubExecutor("", errors.New("boom")) + sink := s.newRecordingSink() + opts := runner.Options{Executor: exec, Sink: sink.mock, MaxParallelism: 1, CleanWorktree: false} + + result, err := runner.Run(context.Background(), plan, opts) + s.Require().NoError(err) + s.Equal(1, result.ExitCode) + s.Equal(1, result.Failed) + s.Equal(1, calls.len(), "fail-fast must stop after the first failing module") + + var foundFailureLine bool + // runner.Event is large (~320B); iterate by index per gocritic's + // rangeValCopy to avoid copying every event into the loop variable. + events := sink.snapshot() + for i := range events { + ev := &events[i] + if ev.Kind == runner.EventLine && ev.Stream == runner.StreamStderr && ev.Message == "boom" { + s.Equal("[prenup] command failed: boom", ev.Text, + "runner-synthesized failure line must be tagged with [prenup]") + foundFailureLine = true + break + } + } + s.True(foundFailureLine, "expected a synthetic failure-attribution line on EventLine/stderr") +} + +func (s *RunnerTestSuite) TestBuildPlanAppliesPathFilter() { + cfg := config.Config{ + Version: 1, + ModuleMarkers: []string{"go.mod"}, + Tasks: []config.Task{ + { + Name: "Go tests", Command: "go test", DefaultSelected: true, PerModule: true, + Paths: []string{"**/*.go"}, + }, + { + Name: "Docs", Command: "mkdocs", DefaultSelected: true, + Paths: []string{"docs/**/*.md"}, + }, + }, + } + plan := runner.BuildPlan(cfg, "/repo", []string{"pkg/foo/x.go", "README.txt"}, []string{"pkg/foo"}, nil) + s.True(plan.Tasks[0].Selected, "Go tests must be selected when a .go file changed") + s.False(plan.Tasks[1].Selected, "Docs must be skipped when no .md file changed") + s.Contains(plan.Tasks[1].SkipReason, "no files match task paths") +} + +func (s *RunnerTestSuite) TestBuildPlanSelectionMap() { + cfg := config.Config{ + Version: 1, + ModuleMarkers: []string{"go.mod"}, + Tasks: []config.Task{ + {Name: "A", Command: "a", DefaultSelected: true, PerModule: true}, + {Name: "B", Command: "b", DefaultSelected: true, PerModule: true}, + }, + } + plan := runner.BuildPlan(cfg, "/repo", []string{"m/x.go"}, []string{"m"}, + map[string]bool{"A": true}) + s.True(plan.Tasks[0].Selected, "A must respect explicit selection=true") + s.False(plan.Tasks[1].Selected, "B must respect explicit absence (no implicit selection)") +} + +// TestTemplateExpand is a pure-function check; no executor/sink needed. +// It uses table-driven cases to keep all template variables on display. +func (s *RunnerTestSuite) TestTemplateExpand() { + cases := []struct { + name string + repo string + module string + input string + want string + }{ + { + name: "all four vars", + repo: "/repo", + module: "services/auth", + input: "run {{.module_name}} in {{.module_root}} from {{.repo_root}} path {{.module_path}}", + want: "run auth in /repo/services/auth from /repo path services/auth", + }, + { + name: "module_name is the leaf of module_path", + repo: "/repo", + module: "a/b/c/leaf", + input: "{{.module_name}}", + want: "leaf", + }, + { + name: "no template vars passes through", + repo: "/repo", + module: "x", + input: "literal command", + want: "literal command", + }, + } + for _, tc := range cases { + s.Run(tc.name, func() { + v := runner.NewTemplateVars(tc.repo, tc.module) + s.Equal(tc.want, v.Expand(tc.input)) + }) + } +} + +func TestRunnerSuite(t *testing.T) { + suite.Run(t, new(RunnerTestSuite)) +} diff --git a/internal/runner/stage.go b/internal/runner/stage.go new file mode 100644 index 0000000..e8b52b7 --- /dev/null +++ b/internal/runner/stage.go @@ -0,0 +1,74 @@ +package runner + +import ( + "github.com/bmatcuk/doublestar/v4" + + "github.com/c2fo/prenup/internal/git" +) + +// stageGenerated stages files that newly appeared (vs beforeTracked), match +// any of the doublestar patterns, and (for per-module tasks) live under one +// of the task's modules. Pre-existing unstaged changes are never promoted. +// +// modules may be nil/empty (non per-module tasks) or contain "" / "." to mean +// "scope to the whole repo". A non-empty list of real module paths restricts +// staging to files that live under those modules so a task that ran in module +// A cannot accidentally stage files under module B. +func stageGenerated(gitRunner *git.Runner, patterns []string, beforeTracked map[string]struct{}, modules []string) error { + if len(patterns) == 0 { + return nil + } + after, err := gitRunner.PorcelainStatus() + if err != nil { + return err + } + + scoped := make([]string, 0, len(modules)) + for _, m := range modules { + if m == "" || m == "." { + // Wildcard scope: a single empty entry signals "match anywhere" + // to underModule below. + scoped = nil + break + } + scoped = append(scoped, m) + } + + var toStage []string + for _, f := range after { + if _, existed := beforeTracked[f]; existed { + continue + } + if !matchesAny(patterns, f) { + continue + } + if !underModules(f, scoped) { + continue + } + toStage = append(toStage, f) + } + return gitRunner.Add(toStage) +} + +// underModules returns true when f lives under one of modules. An empty +// modules slice means "match anywhere" (no per-module restriction). +func underModules(f string, modules []string) bool { + if len(modules) == 0 { + return true + } + for _, m := range modules { + if hasPathPrefix(f, m) { + return true + } + } + return false +} + +func matchesAny(patterns []string, path string) bool { + for _, p := range patterns { + if ok, err := doublestar.Match(p, path); err == nil && ok { + return true + } + } + return false +} diff --git a/internal/runner/stage_test.go b/internal/runner/stage_test.go new file mode 100644 index 0000000..a5c11a1 --- /dev/null +++ b/internal/runner/stage_test.go @@ -0,0 +1,114 @@ +package runner + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/c2fo/prenup/internal/git" +) + +// newStageRepo creates a fresh git repo with one committed README so we have +// a known "tracked before" baseline to compare against. +func newStageRepo(t *testing.T) (string, *git.Runner) { + t.Helper() + dir := t.TempDir() + mustGit(t, dir, "init", "-b", "main") + mustGit(t, dir, "config", "user.email", "test@example.com") + mustGit(t, dir, "config", "user.name", "test") + mustGit(t, dir, "config", "commit.gpgsign", "false") + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi\n"), 0o600)) + mustGit(t, dir, "add", "README.md") + mustGit(t, dir, "commit", "-m", "initial") + return dir, git.New(dir) +} + +func mustGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) //nolint:gosec // G204: git binary is fixed in tests. + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %s: %s", strings.Join(args, " "), string(out)) +} + +// TestStageGeneratedSkipsPreexistingFiles guarantees that stage_output never +// promotes a file that was already tracked before the task ran -- otherwise +// users' in-progress edits to existing generated files would be silently +// added to the index. +func TestStageGeneratedSkipsPreexistingFiles(t *testing.T) { + t.Parallel() + dir, r := newStageRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gen.txt"), []byte("v1\n"), 0o600)) + mustGit(t, dir, "add", "gen.txt") + mustGit(t, dir, "commit", "-m", "add gen") + + before, err := r.TrackedFiles() + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "gen.txt"), []byte("v2 unstaged\n"), 0o600)) + + require.NoError(t, stageGenerated(r, []string{"gen.txt"}, before, nil)) + + staged, err := r.StagedFiles() + require.NoError(t, err) + assert.Empty(t, staged, "pre-existing file should not be auto-staged") +} + +// TestStageGeneratedAddsNewMatchingFiles is the happy path: a brand-new file +// matching the patterns is staged. +func TestStageGeneratedAddsNewMatchingFiles(t *testing.T) { + t.Parallel() + dir, r := newStageRepo(t) + before, err := r.TrackedFiles() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "out.txt"), []byte("new\n"), 0o600)) + + require.NoError(t, stageGenerated(r, []string{"out.txt"}, before, nil)) + + staged, err := r.StagedFiles() + require.NoError(t, err) + assert.Equal(t, []string{"out.txt"}, staged) +} + +// TestStageGeneratedRespectsModuleScope ensures a per-module task that ran +// in module A cannot accidentally stage files generated in module B even +// when both happen to match the patterns. +func TestStageGeneratedRespectsModuleScope(t *testing.T) { + t.Parallel() + dir, r := newStageRepo(t) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "a"), 0o750)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "b"), 0o750)) + before, err := r.TrackedFiles() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "out.txt"), []byte("a\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b", "out.txt"), []byte("b\n"), 0o600)) + + require.NoError(t, stageGenerated(r, []string{"**/out.txt"}, before, []string{"a"})) + + staged, err := r.StagedFiles() + require.NoError(t, err) + assert.Equal(t, []string{"a/out.txt"}, staged) +} + +// TestStageGeneratedDotModuleStagesEverywhere covers the "wildcard" module +// scope (e.g. non per-module task synthesized to "."). +func TestStageGeneratedDotModuleStagesEverywhere(t *testing.T) { + t.Parallel() + dir, r := newStageRepo(t) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "x"), 0o750)) + before, err := r.TrackedFiles() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "top.txt"), []byte("t\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "x", "deep.txt"), []byte("d\n"), 0o600)) + + require.NoError(t, stageGenerated(r, []string{"**/*.txt"}, before, []string{"."})) + + staged, err := r.StagedFiles() + require.NoError(t, err) + assert.ElementsMatch(t, []string{"top.txt", "x/deep.txt"}, staged) +} diff --git a/internal/runner/template.go b/internal/runner/template.go new file mode 100644 index 0000000..0929b27 --- /dev/null +++ b/internal/runner/template.go @@ -0,0 +1,50 @@ +package runner + +import ( + "path/filepath" + "strings" +) + +// TemplateVars holds the values substituted into command and working_dir. +type TemplateVars struct { + RepoRoot string + ModuleRoot string + ModulePath string + ModuleName string +} + +// NewTemplateVars computes the template variables for module (a path relative +// to repoRoot). A module of "." yields the repo itself; for global tasks, call +// with module = "". +func NewTemplateVars(repoRoot, module string) TemplateVars { + if module == "" { + return TemplateVars{RepoRoot: repoRoot} + } + return TemplateVars{ + RepoRoot: repoRoot, + ModuleRoot: filepath.Join(repoRoot, module), + ModulePath: module, + ModuleName: filepath.Base(module), + } +} + +// Expand replaces the supported template variables in s. +// Supported: {{.repo_root}}, {{.module_root}}, {{.module_path}}, {{.module_name}}. +// Unknown variables are left untouched. +func (v TemplateVars) Expand(s string) string { + pairs := map[string]string{ + "{{.repo_root}}": v.RepoRoot, + "{{.module_root}}": v.ModuleRoot, + "{{.module_path}}": v.ModulePath, + "{{.module_name}}": v.ModuleName, + // Backward-compatible alias that v1 users may have in configs. + "{{.module}}": v.ModuleName, + } + for k, val := range pairs { + if val == "" { + continue + } + s = strings.ReplaceAll(s, k, val) + } + return s +} diff --git a/internal/ui/agent.go b/internal/ui/agent.go new file mode 100644 index 0000000..3f28d49 --- /dev/null +++ b/internal/ui/agent.go @@ -0,0 +1,72 @@ +package ui + +// This file holds the canonical strings prenup emits to AI agents and other +// non-human consumers so the markdown digest and the NDJSON stream stay in +// lockstep. Humans get the Bubble Tea TUI and the chrome it provides, so +// these strings are intentionally absent from the human renderer. +// +// When you change one of these constants, please also: +// - re-run `go test ./internal/ui/...` (golden assertions key off them) +// - update docs/SCHEMA.md if you change AgentHintSchema or the +// shape of the agent_hint event. + +// Tool is the project name as it should appear to consumers. Stable. +const Tool = "prenup" + +// HomepageURL points readers at human-facing docs. Stable. +const HomepageURL = "https://github.com/c2fo/prenup" + +// Description is a one-sentence "what is this" so an agent that has never +// heard of prenup can orient itself from a single line of output. +const Description = "Prenup is a Git pre-commit hook runner that executes " + + "configurable quality checks (tests, linters, generators) scoped to " + + "the modules touched by the in-flight commit." + +// HookContextNote tells consumers that this output came from a Git hook -- +// not from `git` itself -- so they don't misattribute failures to git. +const HookContextNote = "This output is produced by the prenup pre-commit " + + "hook, not by `git` itself. A non-zero exit means the hook blocked the " + + "commit; the commit was not created." + +// JSONHint advertises the structured output mode. Shown only in markdown. +const JSONHint = "For machine-readable output, run with " + + "`--output json` or set `PRENUP_OUTPUT=json` in the environment." + +// FailureBypassHint explains how to retry or bypass after a failed run. +// Used in the markdown digest's "Next steps" block and surfaced verbatim +// in the agent_hint event so JSON consumers see the same guidance. +const FailureBypassHint = "Re-run `git commit` after fixing the issue, or " + + "use `git commit --no-verify` to bypass the hook for a single commit. " + + "Configuration lives in `.prenup.yaml` at the repository root." + +// AgentHintSchema is the version of the agent_hint event payload. Bump +// when the shape of the strings or the surrounding event fields changes +// in a way that would surprise an existing consumer. +const AgentHintSchema = "1" + +// DevVersion is the literal Version string an unbuilt or `go install`-from- +// source binary reports. Anything else is taken to be a real semver-like +// release identifier. +const DevVersion = "dev" + +// VersionLabel turns a raw version token into a human- and agent-friendly +// label that makes the field's role obvious. A pattern-matching consumer +// scanning for `Prenup dev` would otherwise have to guess whether `dev` +// is a build identifier, a status, or part of a task name. Examples: +// +// "v2.0.1" -> "v2.0.1" +// "dev" -> "dev (development build, not a tagged release)" +// "" -> "unknown" +// +// Wire/JSON consumers receive the raw token in Event.Version unchanged; +// this helper only formats it for human-readable contexts. +func VersionLabel(version string) string { + switch version { + case "": + return "unknown" + case DevVersion: + return DevVersion + " (development build, not a tagged release)" + default: + return version + } +} diff --git a/internal/ui/agent_test.go b/internal/ui/agent_test.go new file mode 100644 index 0000000..3926afe --- /dev/null +++ b/internal/ui/agent_test.go @@ -0,0 +1,50 @@ +package ui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestVersionLabel is a truth table for the human-readable version formatter. +// The three branches (unknown / dev build / release) all show up verbatim in +// user-facing output and in the agent_hint event, so a wording regression +// here would leak into external contracts. +func TestVersionLabel(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + version string + want string + }{ + {name: "empty is unknown", version: "", want: "unknown"}, + {name: "dev gets development-build suffix", version: "dev", want: "dev (development build, not a tagged release)"}, + {name: "release passes through", version: "v1.2.3", want: "v1.2.3"}, + {name: "arbitrary token passes through", version: "abc123", want: "abc123"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, VersionLabel(tc.version)) + }) + } +} + +// TestAgentConstantsExposeStableStrings pins the exported strings that +// consumers may pattern-match against. These aren't secrets or long +// paragraphs of copy, but they are stable API surface as far as JSON +// consumers are concerned. +func TestAgentConstantsExposeStableStrings(t *testing.T) { + t.Parallel() + + assert.Equal(t, "prenup", Tool) + assert.Equal(t, "https://github.com/c2fo/prenup", HomepageURL) + assert.Equal(t, "dev", DevVersion) + assert.Equal(t, "1", AgentHintSchema) + assert.NotEmpty(t, Description) + assert.NotEmpty(t, HookContextNote) + assert.NotEmpty(t, JSONHint) + assert.NotEmpty(t, FailureBypassHint) +} diff --git a/internal/ui/human/runner_ui.go b/internal/ui/human/runner_ui.go new file mode 100644 index 0000000..277ea21 --- /dev/null +++ b/internal/ui/human/runner_ui.go @@ -0,0 +1,331 @@ +package human + +import ( + "fmt" + "sort" + "strings" + "sync" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/c2fo/prenup/internal/runner" +) + +// EventChannelSink is a runner.Sink that pushes every event onto a buffered +// channel. The runner UI consumes the channel via a tea.Cmd loop, which keeps +// rendering and event processing on the same goroutine and avoids relying on +// the program's internal Send queue for high-throughput line streams. +// +// Concurrency: +// - Emit may be called from any number of goroutines (parallel per-module +// task runs) at the same time. +// - Once Close has been called, every subsequent Emit returns immediately +// instead of panicking with "send on closed channel" or pinning the +// producer on a full buffer. The underlying event channel is intentionally +// never closed, because doing so would race with producers that are still +// in Emit; instead the consumer loop watches Done() to know when to quit. +// - Close is safe to call multiple times; only the first call has any +// effect. +type EventChannelSink struct { + ch chan runner.Event + done chan struct{} + closer sync.Once +} + +// NewEventChannelSink returns a sink with a generously-sized buffer so the +// runner goroutine rarely blocks waiting for the UI to render. +func NewEventChannelSink() *EventChannelSink { + return &EventChannelSink{ + ch: make(chan runner.Event, 1024), + done: make(chan struct{}), + } +} + +// Emit forwards ev to the consumer channel. After Close has been called the +// event is dropped silently. Late notices from deferred cleanup (stash pop, +// etc.) emitted after EventRunCompleted must not crash the process. +func (s *EventChannelSink) Emit(ev runner.Event) { + select { + case <-s.done: + return + default: + } + select { + case <-s.done: + case s.ch <- ev: + } +} + +// Close marks the sink as closed exactly once. In-flight Emit calls observe +// the closed state and drop their event; the consumer loop should select on +// both Channel() and Done() and exit when Done fires. +func (s *EventChannelSink) Close() error { + s.closer.Do(func() { close(s.done) }) + return nil +} + +// Channel exposes the underlying buffered event channel. +// +// The channel is intentionally never closed; consumers must also watch Done() +// to know when to terminate. Closing the channel would race with producer +// goroutines still inside Emit and could panic with "send on closed channel". +func (s *EventChannelSink) Channel() <-chan runner.Event { return s.ch } + +// Done returns a channel that is closed when Close is called. Consumers select +// on both Channel() and Done() to terminate cleanly. +func (s *EventChannelSink) Done() <-chan struct{} { return s.done } + +// eventMsg wraps a runner event for delivery to the Bubble Tea Update loop. +type eventMsg struct{ ev runner.Event } + +// channelClosedMsg signals the event channel was closed (run is fully done). +type channelClosedMsg struct{} + +// nextEventCmd returns a tea.Cmd that blocks waiting for one event from ch +// (or for done to fire, signaling shutdown), then re-issues itself in the +// model's Update so the loop continues. +func nextEventCmd(ch <-chan runner.Event, done <-chan struct{}) tea.Cmd { + return func() tea.Msg { + select { + case ev := <-ch: + return eventMsg{ev: ev} + case <-done: + // Drain any remaining buffered events so the final summary is + // captured before quitting. + select { + case ev := <-ch: + return eventMsg{ev: ev} + default: + return channelClosedMsg{} + } + } + } +} + +// taskUIState holds the per-task display data. +type taskUIState struct { + name string + status string // pending, running, done, error, skipped + modules map[string]string + duration int64 + note string +} + +// maxLogLines bounds the in-memory log buffer so streaming many thousands of +// lines does not turn viewport.SetContent into a quadratic operation. +const maxLogLines = 5000 + +// RunnerModel is the Bubble Tea model that renders runner events live. +type RunnerModel struct { + events <-chan runner.Event + done <-chan struct{} + version string + notice string + order []string + tasks map[string]*taskUIState + logLines []string + viewport viewport.Model + finished bool +} + +// NewRunnerModel constructs a RunnerModel that consumes events from sink. +// The model uses both the event channel and the sink's Done channel so it +// can shut down cleanly even when no terminating event arrives. +func NewRunnerModel(sink *EventChannelSink) *RunnerModel { + vp := viewport.New(80, 20) + vp.SetContent("Waiting for tasks...\n") + return &RunnerModel{ + events: sink.Channel(), + done: sink.Done(), + tasks: make(map[string]*taskUIState), + viewport: vp, + } +} + +// Init kicks off the event-pump command. Bubble Tea will continuously +// re-issue this command (via nextEventCmd) as long as events arrive. +func (m *RunnerModel) Init() tea.Cmd { return nextEventCmd(m.events, m.done) } + +// Update processes events and key/window messages. +func (m *RunnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if s := msg.String(); s == "ctrl+c" || s == "q" { + return m, tea.Quit + } + case tea.WindowSizeMsg: + if msg.Width > 0 { + m.viewport.Width = msg.Width + } + if msg.Height > 0 { + h := msg.Height - (len(m.tasks) + 8) + if h < 4 { + h = 4 + } + m.viewport.Height = h + } + case eventMsg: + m.applyEvent(msg.ev) + if msg.ev.Kind == runner.EventRunCompleted { + m.finished = true + } + return m, nextEventCmd(m.events, m.done) + case channelClosedMsg: + return m, tea.Quit + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *RunnerModel) applyEvent(ev runner.Event) { + switch ev.Kind { + case runner.EventRunStarted: + m.version = ev.Version + m.notice = ev.Message + for _, name := range ev.Tasks { + if _, ok := m.tasks[name]; !ok { + m.tasks[name] = &taskUIState{ + name: name, + status: "pending", + modules: make(map[string]string), + } + m.order = append(m.order, name) + } + } + case runner.EventTaskStarted: + t := m.ensureTask(ev.Task) + t.status = "running" + t.modules[ev.Module] = "running" + m.appendLog(fmt.Sprintf("> %s (%s)", ev.Task, ev.Module)) + case runner.EventLine: + line := ev.Text + if ev.Module != "" { + line = "[" + ev.Module + "] " + line + } + m.appendLog(line) + case runner.EventTaskCompleted: + t := m.ensureTask(ev.Task) + t.duration += ev.DurationMs + var moduleStatus string + switch ev.Status { + case runner.TaskStatusDone: + t.status = "done" + moduleStatus = "done" + case runner.TaskStatusFailed: + t.status = "error" + moduleStatus = "error" + case runner.TaskStatusSkipped: + t.status = "skipped" + moduleStatus = "skipped" + t.note = ev.Message + } + // Promote any modules left in "running" to the task's final status so + // the per-module display reflects completion. Modules that completed + // successfully but the task as a whole failed remain "running" until + // here; that's acceptable since fail-fast aborts the rest. + for mod, st := range t.modules { + if st == "running" { + t.modules[mod] = moduleStatus + } + } + case runner.EventNotice: + m.appendLog("notice: " + ev.Message) + } + m.viewport.SetContent(strings.Join(m.logLines, "\n")) + m.viewport.GotoBottom() +} + +// appendLog records line in the bounded log buffer. +func (m *RunnerModel) appendLog(line string) { + m.logLines = append(m.logLines, line) + if over := len(m.logLines) - maxLogLines; over > 0 { + // Drop the oldest 'over' lines. Keep the slice short to avoid the + // underlying array growing without bound. + m.logLines = append(m.logLines[:0], m.logLines[over:]...) + } +} + +func (m *RunnerModel) ensureTask(name string) *taskUIState { + t, ok := m.tasks[name] + if !ok { + t = &taskUIState{name: name, status: "pending", modules: make(map[string]string)} + m.tasks[name] = t + m.order = append(m.order, name) + } + return t +} + +// View renders the header, task checklist, and live log viewport. +func (m *RunnerModel) View() string { + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF00")) + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAA00")) + separatorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#444444")) + + var sb strings.Builder + sb.WriteString(headerStyle.Render("Prenup " + m.version)) + sb.WriteString("\n") + if m.notice != "" { + sb.WriteString(warnStyle.Render(m.notice)) + sb.WriteString("\n") + } + sb.WriteString("\n") + sb.WriteString(headerStyle.Render("Task Checklist:")) + sb.WriteString("\n\n") + + for _, name := range m.order { + t := m.tasks[name] + icon, color := statusIcon(t.status) + iconStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) + fmt.Fprintf(&sb, " %s %s", iconStyle.Render(icon), t.name) + if t.duration > 0 { + fmt.Fprintf(&sb, " (%dms)", t.duration) + } + if t.note != "" { + sb.WriteString(" -- " + t.note) + } + sb.WriteByte('\n') + // When a task ran across multiple modules, show their per-module + // status under the task line so users can see fan-out progress. + if len(t.modules) > 1 { + modNames := make([]string, 0, len(t.modules)) + for name := range t.modules { + if name == "" { + continue + } + modNames = append(modNames, name) + } + sort.Strings(modNames) + for _, mod := range modNames { + modIcon, modColor := statusIcon(t.modules[mod]) + modStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(modColor)) + fmt.Fprintf(&sb, " %s %s\n", modStyle.Render(modIcon), mod) + } + } + } + + sb.WriteString("\n") + sb.WriteString(separatorStyle.Render("----------------------------------------")) + sb.WriteString("\n") + sb.WriteString(headerStyle.Render("Output:")) + sb.WriteString("\n") + sb.WriteString(m.viewport.View()) + return sb.String() +} + +func statusIcon(status string) (string, string) { + switch status { + case "running": + return "*", "#FFFF00" + case "done": + return "v", "#00FF00" + case "error": + return "x", "#FF0000" + case "skipped": + return "-", "#888888" + default: + return "o", "#444444" + } +} diff --git a/internal/ui/human/selection.go b/internal/ui/human/selection.go new file mode 100644 index 0000000..79540ae --- /dev/null +++ b/internal/ui/human/selection.go @@ -0,0 +1,187 @@ +// Package human renders the interactive Bubble Tea UIs: task selection and +// runner output. It is selected automatically when stdout is a TTY. +package human + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/c2fo/prenup/internal/config" +) + +// ErrCanceled signals the user quit the selection UI; the caller should block +// the commit with a non-zero exit. +var ErrCanceled = errors.New("canceled by user") + +// SelectionInput configures the selection UI. +type SelectionInput struct { + Version string + Notice string // update warning, shown in yellow + Modules []string + Tasks []config.Task + DefaultOnly bool // when true, skip the UI and return default_selected tasks +} + +// SelectionResult indicates the user's choice. +type SelectionResult struct { + Selected map[string]bool // task name -> selected + Skipped bool // user hit 's': run nothing, commit proceeds +} + +// SelectTasks runs the Bubble Tea selection UI. Returns ErrCanceled when the +// user quits with q/ctrl+c (the caller should block the commit). +func SelectTasks(in SelectionInput) (SelectionResult, error) { + if in.DefaultOnly { + sel := map[string]bool{} + for i := range in.Tasks { + if in.Tasks[i].DefaultSelected { + sel[in.Tasks[i].Name] = true + } + } + return SelectionResult{Selected: sel}, nil + } + + model := newSelectionModel(in) + prog := tea.NewProgram(model, tea.WithAltScreen()) + finalModel, err := prog.Run() + if err != nil { + return SelectionResult{}, fmt.Errorf("selection UI: %w", err) + } + m := finalModel.(*selectionModel) + if m.canceled { + return SelectionResult{}, ErrCanceled + } + if m.skipped { + return SelectionResult{Skipped: true}, nil + } + + sel := make(map[string]bool, len(m.tasks)) + for _, t := range m.tasks { + if t.enabled { + sel[t.name] = true + } + } + return SelectionResult{Selected: sel}, nil +} + +type selectableTask struct { + name string + enabled bool +} + +func (s selectableTask) Title() string { + if s.enabled { + return "[x] " + s.name + } + return "[ ] " + s.name +} +func (selectableTask) Description() string { return "" } +func (s selectableTask) FilterValue() string { return s.name } + +type selectionModel struct { + version string + notice string + modules []string + tasks []selectableTask + list list.Model + canceled bool + skipped bool +} + +func newSelectionModel(in SelectionInput) *selectionModel { + items := make([]list.Item, len(in.Tasks)) + selected := make([]selectableTask, len(in.Tasks)) + for i := range in.Tasks { + t := &in.Tasks[i] + selected[i] = selectableTask{name: t.Name, enabled: t.DefaultSelected} + items[i] = selected[i] + } + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = false + delegate.SetSpacing(0) + + listHeight := len(in.Tasks) + 4 + l := list.New(items, delegate, 50, listHeight) + l.Title = "Select Prenup Tasks to Run" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.DisableQuitKeybindings() + + return &selectionModel{ + version: in.Version, + notice: in.Notice, + modules: in.Modules, + tasks: selected, + list: l, + } +} + +func (m *selectionModel) Init() tea.Cmd { return nil } + +func (m *selectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + chrome := 7 + len(m.modules) + if m.notice != "" { + chrome++ + } + h := msg.Height - chrome + if h < len(m.tasks)+4 { + h = len(m.tasks) + 4 + } + m.list.SetSize(msg.Width, h) + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.canceled = true + return m, tea.Quit + case "s": + m.skipped = true + return m, tea.Quit + case "enter": + return m, tea.Quit + case " ": + idx := m.list.Index() + if idx >= 0 && idx < len(m.tasks) { + m.tasks[idx].enabled = !m.tasks[idx].enabled + items := m.list.Items() + items[idx] = m.tasks[idx] + m.list.SetItems(items) + } + return m, nil + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *selectionModel) View() string { + header := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00FF00")). + Render("Prenup Interactive Pre-Commit " + m.version) + warn := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAA00")) + + var sb strings.Builder + sb.WriteString(header) + sb.WriteString("\n") + if m.notice != "" { + sb.WriteString(warn.Render(m.notice)) + sb.WriteString("\n") + } + sb.WriteString("\nChanged modules:\n") + for _, mod := range m.modules { + sb.WriteString(" - ") + sb.WriteString(mod) + sb.WriteString("\n") + } + sb.WriteString("\n") + sb.WriteString(m.list.View()) + sb.WriteString("\nup/down move, SPACE toggle, ENTER confirm, s skip (allow commit), q cancel (block commit)\n") + return sb.String() +} diff --git a/internal/ui/human/summary.go b/internal/ui/human/summary.go new file mode 100644 index 0000000..29ffd12 --- /dev/null +++ b/internal/ui/human/summary.go @@ -0,0 +1,136 @@ +package human + +import ( + "fmt" + "io" + "sync" + + "github.com/charmbracelet/lipgloss" + + "github.com/c2fo/prenup/internal/runner" +) + +// maxSummaryLogLines caps how many output lines SummarySink retains for the +// post-run "Prenup output" block. A noisy task (verbose test suite, chatty +// linter) could otherwise grow s.logs without bound during a single hook +// run. When the cap is exceeded we keep the most recent lines -- the tail is +// where failures and stack traces surface -- and note how many were dropped. +const maxSummaryLogLines = 5000 + +// SummarySink collects events and prints a human-readable post-run summary to +// stdout after the alt-screen UI exits. Used in combination with the +// RunnerSink TUI to preserve visibility in clients that hide the alt-screen. +type SummarySink struct { + mu sync.Mutex + w io.Writer + version string + notice string + logs []string + droppedLines int + taskOrder []string + seenTask map[string]bool + statuses map[string]runner.TaskStatus + durations map[string]int64 + notes map[string]string + summary runner.Event +} + +// NewSummarySink returns a SummarySink writing to w. +func NewSummarySink(w io.Writer) *SummarySink { + return &SummarySink{ + w: w, + seenTask: make(map[string]bool), + statuses: make(map[string]runner.TaskStatus), + durations: make(map[string]int64), + notes: make(map[string]string), + } +} + +// Emit records event data for later printing. +func (s *SummarySink) Emit(ev runner.Event) { + s.mu.Lock() + defer s.mu.Unlock() + switch ev.Kind { + case runner.EventRunStarted: + s.version = ev.Version + s.notice = ev.Message + case runner.EventLine: + s.appendLog(ev.Text) + case runner.EventTaskStarted: + if !s.seenTask[ev.Task] { + s.seenTask[ev.Task] = true + s.taskOrder = append(s.taskOrder, ev.Task) + } + case runner.EventTaskCompleted: + if !s.seenTask[ev.Task] { + s.seenTask[ev.Task] = true + s.taskOrder = append(s.taskOrder, ev.Task) + } + s.statuses[ev.Task] = ev.Status + s.durations[ev.Task] += ev.DurationMs + if ev.Message != "" { + s.notes[ev.Task] = ev.Message + } + case runner.EventRunCompleted: + s.summary = ev + } +} + +// appendLog records an output line, enforcing maxSummaryLogLines by +// dropping the oldest line once the cap is reached. Caller holds s.mu. +func (s *SummarySink) appendLog(line string) { + if len(s.logs) >= maxSummaryLogLines { + s.logs = s.logs[1:] + s.droppedLines++ + } + s.logs = append(s.logs, line) +} + +// Close flushes the summary to the writer. +func (s *SummarySink) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.render() + return nil +} + +// writef writes to s.w, intentionally discarding errors (summary prints to +// stdout; a failing writer is rare and would fail every subsequent call). +func (s *SummarySink) writef(format string, a ...any) { + _, _ = fmt.Fprintf(s.w, format, a...) +} + +func (s *SummarySink) writeln(a ...any) { + _, _ = fmt.Fprintln(s.w, a...) +} + +func (s *SummarySink) render() { + s.writef("\nprenup %s\n", s.version) + if s.notice != "" { + s.writef("%s\n", s.notice) + } + s.writef("\nPrenup output:\n----------------\n") + if s.droppedLines > 0 { + s.writef("... (%d earlier line(s) omitted to bound memory) ...\n", s.droppedLines) + } + for _, line := range s.logs { + s.writeln(line) + } + + s.writef("\nTask Summary:\n------------\n") + for _, name := range s.taskOrder { + symbol := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")).Render("v") + switch s.statuses[name] { + case runner.TaskStatusFailed: + symbol = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Render("x") + case runner.TaskStatusSkipped: + symbol = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Render("-") + } + extra := "" + if note := s.notes[name]; note != "" { + extra = " -- " + note + } + s.writef("%s %s (%dms)%s\n", symbol, name, s.durations[name], extra) + } + s.writef("\nSummary: %d succeeded, %d failed\n", s.summary.Succeeded, s.summary.Failed) +} diff --git a/internal/ui/human/summary_test.go b/internal/ui/human/summary_test.go new file mode 100644 index 0000000..46ac303 --- /dev/null +++ b/internal/ui/human/summary_test.go @@ -0,0 +1,58 @@ +package human + +import ( + "bytes" + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/c2fo/prenup/internal/runner" +) + +// TestSummarySinkBoundsLogs verifies the memory guard on the output buffer: +// once more than maxSummaryLogLines EventLine events arrive, only the most +// recent lines are retained and the rendered summary notes how many were +// dropped. +func TestSummarySinkBoundsLogs(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + s := NewSummarySink(&buf) + + const extra = 25 + total := maxSummaryLogLines + extra + for i := range total { + s.Emit(runner.Event{Kind: runner.EventLine, Text: fmt.Sprintf("line-%d", i)}) + } + + assert.Len(t, s.logs, maxSummaryLogLines, "retained lines must be capped") + assert.Equal(t, extra, s.droppedLines, "dropped count must track the overflow") + // Oldest lines are evicted; the tail is preserved. + assert.Equal(t, "line-"+strconv.Itoa(total-1), s.logs[len(s.logs)-1]) + assert.Equal(t, "line-"+strconv.Itoa(extra), s.logs[0]) + + require.NoError(t, s.Close()) + out := buf.String() + assert.Contains(t, out, "earlier line(s) omitted") + assert.Contains(t, out, "line-"+strconv.Itoa(total-1), "most recent line should render") + assert.NotContains(t, out, "line-0\n", "evicted oldest line should not render") +} + +// TestSummarySinkNoTruncationNotice confirms the truncation notice is absent +// when output stays under the cap. +func TestSummarySinkNoTruncationNotice(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + s := NewSummarySink(&buf) + s.Emit(runner.Event{Kind: runner.EventLine, Text: "only line"}) + require.NoError(t, s.Close()) + + out := buf.String() + assert.Equal(t, 0, s.droppedLines) + assert.NotContains(t, out, "omitted") + assert.Contains(t, out, "only line") +} diff --git a/internal/ui/jsonout/jsonout.go b/internal/ui/jsonout/jsonout.go new file mode 100644 index 0000000..cf9783c --- /dev/null +++ b/internal/ui/jsonout/jsonout.go @@ -0,0 +1,72 @@ +// Package jsonout is a runner.Sink that writes one JSON object per line. +// The schema is documented in docs/SCHEMA.md and is intended to be +// consumed by agents and scripting clients. +package jsonout + +import ( + "encoding/json" + "io" + "sync" + + "github.com/c2fo/prenup/internal/runner" + "github.com/c2fo/prenup/internal/ui" +) + +// Sink writes NDJSON events to w. +type Sink struct { + mu sync.Mutex + enc *json.Encoder +} + +// agentHint is the bootstrap NDJSON line emitted before any runner event. +// It carries the minimum self-describing context an agent needs to orient +// itself if it has no prior knowledge of prenup -- what the tool is, how +// to switch to/parse this stream, and what to do on failure. The schema +// version is bumped via ui.AgentHintSchema when the shape changes. +// +// Consumers that don't recognize the type field should skip the line and +// continue parsing as normal; the rest of the stream is unaffected. +type agentHint struct { + Type string `json:"type"` + Schema string `json:"schema"` + Tool string `json:"tool"` + Description string `json:"description"` + Homepage string `json:"homepage"` + HookContext string `json:"hook_context"` + BypassHint string `json:"bypass_hint"` + StreamFormat string `json:"stream_format"` + EventTypesNote string `json:"event_types_note"` +} + +// New constructs a Sink writing to w. It immediately writes a single +// `agent_hint` line so first-time consumers can identify the stream +// without prior knowledge of prenup. +func New(w io.Writer) *Sink { + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + s := &Sink{enc: enc} + _ = enc.Encode(agentHint{ + Type: "agent_hint", + Schema: ui.AgentHintSchema, + Tool: ui.Tool, + Description: ui.Description, + Homepage: ui.HomepageURL, + HookContext: ui.HookContextNote, + BypassHint: ui.FailureBypassHint, + StreamFormat: "ndjson", + EventTypesNote: "Subsequent lines are runner events: run_started, " + + "task_started, line, task_completed, run_completed, notice. " + + "See docs/SCHEMA.md.", + }) + return s +} + +// Emit writes ev as a single line of JSON. +func (s *Sink) Emit(ev runner.Event) { + s.mu.Lock() + defer s.mu.Unlock() + _ = s.enc.Encode(ev) +} + +// Close is a no-op; the underlying writer is owned by the caller. +func (s *Sink) Close() error { return nil } diff --git a/internal/ui/jsonout/jsonout_test.go b/internal/ui/jsonout/jsonout_test.go new file mode 100644 index 0000000..1e73395 --- /dev/null +++ b/internal/ui/jsonout/jsonout_test.go @@ -0,0 +1,126 @@ +package jsonout + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/prenup/internal/runner" +) + +// JSONOutTestSuite groups behavioral tests for the NDJSON sink: the +// agent_hint bootstrap line, the per-event line shapes, and the run-level +// failure index. Each method gets a fresh buffer + sink via SetupTest. +type JSONOutTestSuite struct { + suite.Suite + buf *bytes.Buffer + sink *Sink + now time.Time +} + +func (s *JSONOutTestSuite) SetupTest() { + s.buf = &bytes.Buffer{} + s.sink = New(s.buf) + s.now = time.Now() +} + +// decodeLines splits the stream on newlines and JSON-decodes each +// non-empty line into a generic map. Returns the decoded objects so +// callers can assert by index. +func (s *JSONOutTestSuite) decodeLines() []map[string]any { + raw := strings.Split(strings.TrimRight(s.buf.String(), "\n"), "\n") + out := make([]map[string]any, 0, len(raw)) + for _, line := range raw { + if line == "" { + continue + } + var m map[string]any + s.Require().NoError(json.Unmarshal([]byte(line), &m), "line must be valid JSON: %q", line) + out = append(out, m) + } + return out +} + +func (s *JSONOutTestSuite) TestEmitWritesOneLinePerEvent() { + s.sink.Emit(runner.Event{ + Kind: runner.EventRunStarted, Time: s.now, Version: "v2.0.0", + Tasks: []string{"Run tests"}, RepoRoot: "/tmp/repo", + }) + s.sink.Emit(runner.Event{ + Kind: runner.EventTaskStarted, Time: s.now, Task: "Run tests", Module: "pkg/foo", + Command: "go test ./...", WorkingDir: "/tmp/pkg/foo", + }) + s.sink.Emit(runner.Event{ + Kind: runner.EventLine, Time: s.now, Task: "Run tests", Module: "pkg/foo", + Stream: runner.StreamStdout, Text: "ok", + }) + s.sink.Emit(runner.Event{ + Kind: runner.EventTaskCompleted, Time: s.now, Task: "Run tests", + Status: runner.TaskStatusFailed, DurationMs: 12, + }) + s.sink.Emit(runner.Event{ + Kind: runner.EventRunCompleted, Time: s.now, + Succeeded: 0, Failed: 1, ExitCode: 1, + FailedTasks: []string{"Run tests"}, + }) + + lines := s.decodeLines() + // 1 agent_hint bootstrap + 5 runner events. + s.Require().Len(lines, 6) + + hint := lines[0] + s.Equal("agent_hint", hint["type"], "first line must be the self-describing bootstrap") + s.Equal("prenup", hint["tool"]) + s.NotEmpty(hint["description"]) + s.NotEmpty(hint["homepage"]) + s.NotEmpty(hint["schema"]) + + first := lines[1] + s.Equal("run_started", first["type"]) + // run_started must carry repo_root so a JSON consumer can anchor + // every subsequent task's working_dir without inferring it from + // substring matches. + s.Equal("/tmp/repo", first["repo_root"]) + + // task_started must carry the resolved command and working_dir so a + // JSON-only consumer can reproduce the invocation without parsing + // .prenup.yaml or knowing prenup's template-expansion rules. + started := lines[2] + s.Equal("task_started", started["type"]) + s.Equal("go test ./...", started["command"]) + s.Equal("/tmp/pkg/foo", started["working_dir"]) + + line := lines[3] + s.Equal("line", line["type"]) + s.Equal("stdout", line["stream"]) + s.Equal("ok", line["text"]) + + // run_completed must carry the failed_tasks list so a JSON consumer + // has an O(1) failure index without rescanning task_completed events. + completed := lines[5] + s.Equal("run_completed", completed["type"]) + failedAny, ok := completed["failed_tasks"].([]any) + s.Require().True(ok, "failed_tasks must be present and an array") + s.Require().Len(failedAny, 1) + s.Equal("Run tests", failedAny[0]) +} + +// TestAgentHintIsFirstLineEvenWithNoEvents asserts the bootstrap line +// is written eagerly at construction. A consumer that opens the stream +// and immediately blocks on read sees the orienting context before any +// runner activity. +func (s *JSONOutTestSuite) TestAgentHintIsFirstLineEvenWithNoEvents() { + s.Require().NotEmpty(s.buf.Bytes(), "agent_hint must be emitted on construction") + first := strings.SplitN(s.buf.String(), "\n", 2)[0] + var obj map[string]any + s.Require().NoError(json.Unmarshal([]byte(first), &obj)) + s.Equal("agent_hint", obj["type"]) +} + +func TestJSONOutSuite(t *testing.T) { + suite.Run(t, new(JSONOutTestSuite)) +} diff --git a/internal/ui/markdown/markdown.go b/internal/ui/markdown/markdown.go new file mode 100644 index 0000000..d7c7161 --- /dev/null +++ b/internal/ui/markdown/markdown.go @@ -0,0 +1,228 @@ +// Package markdown is a runner.Sink that streams a plain-text progress feed +// during execution and emits a final structured markdown digest at the end. +// It is selected automatically when stdout is not a TTY. +package markdown + +import ( + "fmt" + "io" + "strings" + "sync" + + "github.com/c2fo/prenup/internal/runner" + "github.com/c2fo/prenup/internal/ui" +) + +// Sink renders markdown output. +type Sink struct { + mu sync.Mutex + w io.Writer + + version string + notice string + modules []string + selected []string + taskLogs map[string][]string // task -> accumulated lines (truncated tail kept) + taskStatus map[string]runner.TaskStatus + taskTime map[string]int64 + taskMessage map[string]string + taskOrder []string + succeeded int + failed int + exitCode int + failedTasks []string +} + +// New constructs a markdown Sink writing to w. +func New(w io.Writer) *Sink { + return &Sink{ + w: w, + taskLogs: make(map[string][]string), + taskStatus: make(map[string]runner.TaskStatus), + taskTime: make(map[string]int64), + taskMessage: make(map[string]string), + } +} + +const maxFailureTail = 50 + +// writef writes a formatted line to s.w, ignoring transient write errors on +// stdout/pipes. A closed/failing writer would also fail every subsequent call, +// so surfacing each individual error adds no value. +func (s *Sink) writef(format string, a ...any) { + _, _ = fmt.Fprintf(s.w, format, a...) +} + +// writeln writes a line (or newline-only string) to s.w, ignoring errors. +func (s *Sink) writeln(a ...any) { + _, _ = fmt.Fprintln(s.w, a...) +} + +// Emit handles an event by streaming a short human line now and accumulating +// state for the final digest. +func (s *Sink) Emit(ev runner.Event) { + s.mu.Lock() + defer s.mu.Unlock() + + switch ev.Kind { + case runner.EventRunStarted: + s.handleRunStarted(ev) + case runner.EventTaskStarted: + s.handleTaskStarted(ev) + case runner.EventLine: + s.handleLine(ev) + case runner.EventTaskCompleted: + s.handleTaskCompleted(ev) + case runner.EventRunCompleted: + s.succeeded = ev.Succeeded + s.failed = ev.Failed + s.exitCode = ev.ExitCode + s.failedTasks = ev.FailedTasks + s.renderDigest() + case runner.EventNotice: + s.writef("Notice: %s\n", ev.Message) + } +} + +func (s *Sink) handleRunStarted(ev runner.Event) { + s.version = ev.Version + s.notice = ev.Message + s.modules = ev.Modules + s.selected = ev.Tasks + + // Self-describing preamble so an agent (or operator) that has never + // heard of prenup can identify the tool and understand what just + // happened from a single git-commit transcript. Humans see the TUI + // instead of this and so do not need it. + s.writef("[%s] %s\n", ui.Tool, ui.Description) + s.writef("[%s] Docs: %s\n", ui.Tool, ui.HomepageURL) + s.writef("[%s] %s\n", ui.Tool, ui.JSONHint) + s.writeln() + + s.writef("Prenup version: %s\n", ui.VersionLabel(s.version)) + if s.notice != "" { + s.writef("%s\n", s.notice) + } + if len(s.modules) > 0 { + s.writef("Modules: %s\n", strings.Join(s.modules, ", ")) + } + s.writef("Tasks: %s\n\n", strings.Join(s.selected, ", ")) +} + +func (s *Sink) handleTaskStarted(ev runner.Event) { + if _, ok := s.taskLogs[ev.Task]; !ok { + s.taskOrder = append(s.taskOrder, ev.Task) + s.taskLogs[ev.Task] = nil + } + if ev.Module != "" { + s.writef("> %s (%s)\n", ev.Task, ev.Module) + } else { + s.writef("> %s\n", ev.Task) + } + // Echo the resolved command so the live stream documents exactly what + // was about to run, and so the failure-tail digest carries enough to + // reproduce the invocation without reading .prenup.yaml. Routed + // through appendLog so it lands in the output_tail on failure. + if ev.Command != "" { + echo := "[prenup] $ " + ev.Command + if ev.WorkingDir != "" { + echo += " (cwd: " + ev.WorkingDir + ")" + } + s.writeln(echo) + s.appendLog(ev.Task, echo) + } +} + +// handleLine prefixes [module] in parallel runs so the digest tail is +// unambiguous about which module produced each output line. +func (s *Sink) handleLine(ev runner.Event) { + line := ev.Text + if ev.Module != "" && ev.Module != "." { + line = "[" + ev.Module + "] " + line + } + s.writeln(line) + s.appendLog(ev.Task, line) +} + +func (s *Sink) handleTaskCompleted(ev runner.Event) { + s.taskStatus[ev.Task] = ev.Status + s.taskTime[ev.Task] = ev.DurationMs + if ev.Message != "" { + s.taskMessage[ev.Task] = ev.Message + } + symbol := "OK" + switch ev.Status { + case runner.TaskStatusFailed: + symbol = "FAIL" + case runner.TaskStatusSkipped: + symbol = "SKIP" + } + s.writef("[%s] %s (%dms)\n\n", symbol, ev.Task, ev.DurationMs) +} + +// Close flushes any buffered state. The digest is emitted on EventRunCompleted, +// so Close is a no-op unless that event was missed. +func (s *Sink) Close() error { return nil } + +func (s *Sink) appendLog(task, line string) { + if task == "" { + return + } + logs := s.taskLogs[task] + logs = append(logs, line) + if len(logs) > maxFailureTail { + logs = logs[len(logs)-maxFailureTail:] + } + s.taskLogs[task] = logs +} + +func (s *Sink) renderDigest() { + s.writeln("---") + s.writef("## Prenup %s\n\n", ui.VersionLabel(s.version)) + if s.notice != "" { + s.writef("> %s\n\n", s.notice) + } + s.writef("**Summary:** %d succeeded, %d failed (exit %d)\n", s.succeeded, s.failed, s.exitCode) + // Surface failed task names directly under the summary so an agent + // scanning a long digest can jump straight to the failures without + // reading every per-task section. + if len(s.failedTasks) > 0 { + s.writef("**Failed tasks:** %s\n", strings.Join(s.failedTasks, ", ")) + } + s.writeln() + + for _, name := range s.taskOrder { + status := s.taskStatus[name] + s.writef("### Task: %s\n", name) + s.writef("- Status: `%s`\n", status) + s.writef("- Duration: %dms\n", s.taskTime[name]) + if msg := s.taskMessage[name]; msg != "" { + s.writef("- Note: %s\n", msg) + } + if status == runner.TaskStatusFailed { + tail := s.taskLogs[name] + if len(tail) > 0 { + s.writeln() + s.writeln("
Output tail") + s.writeln() + s.writeln("```") + for _, line := range tail { + s.writeln(line) + } + s.writeln("```") + s.writeln("
") + } + } + s.writeln() + } + + if s.failed > 0 { + s.writeln("### Next steps") + // Lead with the hook-context line so consumers understand the + // commit was blocked by prenup, not by `git` itself. + s.writef("- %s\n", ui.HookContextNote) + s.writeln("- Reproduce the failing task locally, then re-run `git commit`.") + s.writeln("- Use `prenup run --task \"\"` to rerun a specific task without committing.") + s.writef("- %s\n", ui.FailureBypassHint) + } +} diff --git a/internal/ui/markdown/markdown_test.go b/internal/ui/markdown/markdown_test.go new file mode 100644 index 0000000..8084ef4 --- /dev/null +++ b/internal/ui/markdown/markdown_test.go @@ -0,0 +1,207 @@ +package markdown + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/prenup/internal/runner" +) + +// MarkdownSinkTestSuite groups behavioral tests for the markdown sink: the +// agent-orienting preamble, the live event stream, and the structured digest +// emitted on EventRunCompleted. SetupTest gives every method a fresh +// buffer + sink so they can run in any order. +type MarkdownSinkTestSuite struct { + suite.Suite + buf *bytes.Buffer + sink *Sink + now time.Time +} + +func (s *MarkdownSinkTestSuite) SetupTest() { + s.buf = &bytes.Buffer{} + s.sink = New(s.buf) + s.now = time.Now() +} + +// emitSample drives a representative one-task run through the sink. +// failing controls whether the task ends in TaskStatusFailed (with a +// FAIL stderr line and a populated FailedTasks list on run_completed). +func (s *MarkdownSinkTestSuite) emitSample(failing bool) { + s.sink.Emit(runner.Event{ + Kind: runner.EventRunStarted, Time: s.now, Version: "v2.0.0", + Modules: []string{"pkg/foo"}, Tasks: []string{"Run tests"}, + RepoRoot: "/tmp/repo", + }) + s.sink.Emit(runner.Event{ + Kind: runner.EventTaskStarted, Time: s.now, Task: "Run tests", Module: "pkg/foo", + Command: "go test ./...", WorkingDir: "/tmp/pkg/foo", + }) + s.sink.Emit(runner.Event{ + Kind: runner.EventLine, Time: s.now, Task: "Run tests", Module: "pkg/foo", + Stream: runner.StreamStdout, Text: "ok pkg/foo", + }) + + status := runner.TaskStatusDone + exit := 0 + failed := 0 + var failedTasks []string + if failing { + s.sink.Emit(runner.Event{ + Kind: runner.EventLine, Time: s.now, Task: "Run tests", Module: "pkg/foo", + Stream: runner.StreamStderr, Text: "FAIL", + }) + status = runner.TaskStatusFailed + exit = 1 + failed = 1 + failedTasks = []string{"Run tests"} + } + s.sink.Emit(runner.Event{ + Kind: runner.EventTaskCompleted, Time: s.now, Task: "Run tests", + Status: status, DurationMs: 42, + }) + s.sink.Emit(runner.Event{ + Kind: runner.EventRunCompleted, Time: s.now, + Succeeded: 1 - failed, Failed: failed, ExitCode: exit, + FailedTasks: failedTasks, + }) +} + +// emitVersion renders a tiny stream with a custom version token so the +// version-label cases can exercise the labeling helper without rebuilding +// the whole fixture. +func (s *MarkdownSinkTestSuite) emitVersion(version string) { + s.sink.Emit(runner.Event{ + Kind: runner.EventRunStarted, Time: s.now, Version: version, + Modules: []string{"."}, Tasks: []string{"Echo"}, + }) + s.sink.Emit(runner.Event{Kind: runner.EventTaskStarted, Time: s.now, Task: "Echo"}) + s.sink.Emit(runner.Event{ + Kind: runner.EventTaskCompleted, Time: s.now, Task: "Echo", + Status: runner.TaskStatusDone, + }) + s.sink.Emit(runner.Event{Kind: runner.EventRunCompleted, Time: s.now, Succeeded: 1}) +} + +func (s *MarkdownSinkTestSuite) TestSuccessDigestIsQuiet() { + s.emitSample(false) + out := s.buf.String() + s.Contains(out, "Prenup version: v2.0.0") + s.Contains(out, "## Prenup v2.0.0") + s.Contains(out, "1 succeeded, 0 failed") + s.Contains(out, "### Task: Run tests") + s.Contains(out, "Status: `done`") + s.NotContains(out, "Next steps") + // Successful runs deliberately omit the "Failed tasks:" line; its + // absence is what tells a scanning agent that nothing went wrong. + s.NotContains(out, "Failed tasks:") +} + +func (s *MarkdownSinkTestSuite) TestPreambleIdentifiesTool() { + s.emitSample(false) + out := s.buf.String() + // Cold-start agent must be able to identify the tool, find docs, + // and discover the JSON output mode from the very first lines. + s.Contains(out, "[prenup]", "preamble must be tagged so it stands out from streamed task output") + s.Contains(out, "Git pre-commit hook", "preamble must explain what prenup is") + s.Contains(out, "Docs:", "preamble must point at human-facing docs") + s.Contains(out, "--output json", "preamble must advertise the structured output mode") +} + +// TestVersionLabel runs both the dev-build and release-version paths +// through the same fixture; the labeling rule is shared between the +// preamble and the digest header, so both must agree. +func (s *MarkdownSinkTestSuite) TestVersionLabel() { + cases := []struct { + name string + version string + wantPreamble string + wantHeader string + wantQualifier bool // true if the "(development build...)" parenthetical must appear + }{ + { + name: "dev build is qualified", + version: "dev", + wantPreamble: "Prenup version: dev (development build", + wantHeader: "## Prenup dev (development build", + wantQualifier: true, + }, + { + name: "release tag renders cleanly", + version: "v2.3.1", + wantPreamble: "Prenup version: v2.3.1\n", + wantHeader: "## Prenup v2.3.1\n", + wantQualifier: false, + }, + } + for _, tc := range cases { + s.Run(tc.name, func() { + // SetupTest does not run for child suite.Run cases, so reset by + // hand to keep the cases independent. + s.buf = &bytes.Buffer{} + s.sink = New(s.buf) + s.emitVersion(tc.version) + out := s.buf.String() + s.Contains(out, tc.wantPreamble) + s.Contains(out, tc.wantHeader) + if tc.wantQualifier { + s.Contains(out, "development build") + } else { + s.NotContains(out, "development build") + } + }) + } +} + +func (s *MarkdownSinkTestSuite) TestFailureDigestIncludesTailAndNextSteps() { + s.emitSample(true) + out := s.buf.String() + s.Contains(out, "Status: `failed`") + s.Contains(out, "FAIL") + s.Contains(out, "Next steps") + s.Contains(out, "
Output tail") + // Failure footer must clarify hook-vs-git attribution and bypass. + s.Contains(out, "not by `git`", "footer must disambiguate prenup vs git") + s.Contains(out, "--no-verify", "footer must document the bypass") + s.Contains(out, ".prenup.yaml", "footer must point at the config") +} + +// TestEchoesResolvedCommand keeps the agent-reproducibility guarantee +// honest: an agent reading the failure tail must see the literal shell +// command (and resolved cwd) prenup ran, not just the task name. +func (s *MarkdownSinkTestSuite) TestEchoesResolvedCommand() { + s.emitSample(true) + out := s.buf.String() + s.Contains(out, "[prenup] $ go test ./...", "command must be echoed in the live stream") + s.Contains(out, "(cwd: /tmp/pkg/foo)", "resolved working directory must be shown") + + tailIdx := strings.Index(out, "
Output tail") + s.Require().GreaterOrEqual(tailIdx, 0, "output tail block must exist") + tail := out[tailIdx:] + s.Contains(tail, "go test ./...", "command must be in the failure tail for reproducibility") +} + +// TestFailedTasksSummary keeps the at-a-glance failure index honest: +// the summary line must be followed by a "Failed tasks: ..." line +// listing the failing task names so an agent reading a long digest can +// jump directly to them. +func (s *MarkdownSinkTestSuite) TestFailedTasksSummary() { + s.emitSample(true) + out := s.buf.String() + s.Require().Contains(out, "**Failed tasks:** Run tests") + + summaryIdx := strings.Index(out, "**Summary:**") + failedIdx := strings.Index(out, "**Failed tasks:**") + s.Require().GreaterOrEqual(summaryIdx, 0) + s.Require().Greater(failedIdx, summaryIdx) + between := out[summaryIdx:failedIdx] + s.NotContains(between, "### Task:", "Failed tasks: must appear before any per-task section") +} + +func TestMarkdownSinkSuite(t *testing.T) { + suite.Run(t, new(MarkdownSinkTestSuite)) +} diff --git a/internal/ui/output.go b/internal/ui/output.go new file mode 100644 index 0000000..e6e75df --- /dev/null +++ b/internal/ui/output.go @@ -0,0 +1,37 @@ +// Package ui wires the runner's event stream into the user's chosen renderer +// (human TUI, markdown digest, or NDJSON stream). +package ui + +import ( + "os" + + "github.com/mattn/go-isatty" + + "github.com/c2fo/prenup/internal/config" +) + +// Resolve collapses "auto" to a concrete mode based on environment signals. +// Rules: +// - explicit override wins +// - NO_COLOR or TERM=dumb -> markdown +// - stdout not a TTY -> markdown +// - otherwise -> human +// +// "json" is only ever returned when explicitly requested. +func Resolve(requested config.OutputMode) config.OutputMode { + switch requested { + case config.OutputHuman, config.OutputMarkdown, config.OutputJSON: + return requested + } + if os.Getenv("NO_COLOR") != "" { + return config.OutputMarkdown + } + if term := os.Getenv("TERM"); term == "dumb" { + return config.OutputMarkdown + } + fd := os.Stdout.Fd() + if isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) { + return config.OutputHuman + } + return config.OutputMarkdown +} diff --git a/internal/versioncheck/versioncheck.go b/internal/versioncheck/versioncheck.go new file mode 100644 index 0000000..3eec48d --- /dev/null +++ b/internal/versioncheck/versioncheck.go @@ -0,0 +1,185 @@ +// Package versioncheck compares the running prenup binary version to the latest GitHub release. +package versioncheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +const ( + repoOwner = "c2fo" + repoName = "prenup" + tagPrefix = "v" + apiTimeout = 10 * time.Second + // per_page=100 is the GitHub API maximum. We do not paginate: the + // newest valid semver-tagged release is virtually always in the most + // recent 100 entries, and prenup would rather report "no release + // found" than fan out multiple HTTP calls on a pre-commit hook path. + releasesURL = "https://api.github.com/repos/%s/%s/releases?per_page=100" +) + +// Release represents a GitHub release. +type Release struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` +} + +// CheckResult contains the result of a version check. +type CheckResult struct { + CurrentVersion string + LatestVersion string + LatestURL string + IsOutdated bool + IsAhead bool // Running unreleased commits ahead of latest release + Error error +} + +// Check compares the current version against the latest GitHub release for prenup. +// If githubToken is non-empty, it is sent as a Bearer token (needed for private repos). +func Check(ctx context.Context, currentVersion, githubToken string) CheckResult { + apiURL := fmt.Sprintf(releasesURL, repoOwner, repoName) + return checkAgainstReleasesAPI(ctx, currentVersion, githubToken, apiURL) +} + +func checkAgainstReleasesAPI(ctx context.Context, currentVersion, githubToken, apiURL string) CheckResult { + result := CheckResult{ + CurrentVersion: currentVersion, + } + + if currentVersion == "dev" || currentVersion == "" { + result.Error = errors.New("running development build (version not set)") + return result + } + + latest, url, err := fetchLatestPrenupRelease(ctx, githubToken, apiURL) + if err != nil { + result.Error = fmt.Errorf("failed to check for updates: %w", err) + return result + } + + result.LatestVersion = latest + result.LatestURL = url + + current := normalizeVersion(currentVersion) + latestNorm := normalizeVersion(latest) + currentBase := extractBaseVersion(current) + + if !semver.IsValid(currentBase) || !semver.IsValid(latestNorm) { + result.Error = fmt.Errorf("invalid version format: current=%q, latest=%q", currentBase, latestNorm) + return result + } + + cmp := semver.Compare(currentBase, latestNorm) + switch { + case cmp < 0: + result.IsOutdated = true + case cmp == 0 && current != currentBase: + result.IsAhead = true + case cmp > 0: + result.IsAhead = true + } + + return result +} + +func fetchLatestPrenupRelease(ctx context.Context, githubToken, apiURL string) (version, url string, err error) { + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody) + if err != nil { + return "", "", err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + // GitHub's REST API requires a User-Agent; requests without one can be + // rejected with 403 and are harder to trace in server logs. + req.Header.Set("User-Agent", repoName) + if githubToken != "" { + req.Header.Set("Authorization", "Bearer "+githubToken) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusForbidden && resp.Header.Get("X-RateLimit-Remaining") == "0" { + return "", "", fmt.Errorf("GitHub API rate limit exceeded (%s)", + formatRateLimitReset(resp.Header.Get("X-RateLimit-Reset"))) + } + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var releases []Release + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", "", err + } + + var latestVer string + var latestURL string + for _, r := range releases { + if !strings.HasPrefix(r.TagName, tagPrefix) { + continue + } + ver := strings.TrimPrefix(r.TagName, tagPrefix) + normalized := normalizeVersion(ver) + if !semver.IsValid(normalized) { + continue + } + if latestVer == "" || semver.Compare(normalized, normalizeVersion(latestVer)) > 0 { + latestVer = ver + latestURL = r.HTMLURL + } + } + + if latestVer == "" { + return "", "", errors.New("no prenup releases found") + } + + return latestVer, latestURL, nil +} + +// formatRateLimitReset renders the GitHub X-RateLimit-Reset header (a Unix +// timestamp in seconds) as both a human-readable UTC time and a duration +// until reset. Falls back to the raw value on parse failure so a +// misformatted header never masks the underlying rate-limit condition. +func formatRateLimitReset(raw string) string { + secs, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return "reset time unavailable" + } + reset := time.Unix(secs, 0).UTC() + wait := time.Until(reset).Round(time.Second) + if wait < 0 { + wait = 0 + } + return fmt.Sprintf("resets in %s at %s", wait, reset.Format(time.RFC3339)) +} + +func normalizeVersion(v string) string { + v = strings.TrimSpace(v) + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + return v +} + +func extractBaseVersion(v string) string { + parts := strings.Split(v, "-") + if len(parts) >= 3 { + if len(parts[len(parts)-1]) > 1 && parts[len(parts)-1][0] == 'g' { + return parts[0] + } + } + return v +} diff --git a/internal/versioncheck/versioncheck_test.go b/internal/versioncheck/versioncheck_test.go new file mode 100644 index 0000000..a795397 --- /dev/null +++ b/internal/versioncheck/versioncheck_test.go @@ -0,0 +1,321 @@ +package versioncheck + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractBaseVersion(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "clean version", input: "v1.2.0", expected: "v1.2.0"}, + {name: "git describe with commits after tag", input: "v1.2.0-1-g71f9e6d", expected: "v1.2.0"}, + {name: "git describe with many commits", input: "v1.2.0-15-gabcdef1", expected: "v1.2.0"}, + {name: "actual prerelease (not git describe)", input: "v1.2.0-beta.1", expected: "v1.2.0-beta.1"}, + {name: "actual prerelease rc", input: "v1.2.0-rc1", expected: "v1.2.0-rc1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, extractBaseVersion(tt.input)) + }) + } +} + +func TestNormalizeVersion(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "already has v prefix", input: "v1.2.3", expected: "v1.2.3"}, + {name: "missing v prefix", input: "1.2.3", expected: "v1.2.3"}, + {name: "with whitespace", input: " v1.2.3 ", expected: "v1.2.3"}, + {name: "whitespace without v", input: " 1.2.3 ", expected: "v1.2.3"}, + {name: "prerelease version", input: "v1.2.3-beta.1", expected: "v1.2.3-beta.1"}, + {name: "empty string", input: "", expected: "v"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, normalizeVersion(tt.input)) + }) + } +} + +func TestCheck_DevBuild(t *testing.T) { + tests := []struct { + name string + version string + }{ + {name: "dev version", version: "dev"}, + {name: "empty version", version: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Check(context.Background(), tt.version, "") + + assert.Equal(t, tt.version, result.CurrentVersion) + assert.Empty(t, result.LatestVersion) + assert.Empty(t, result.LatestURL) + assert.False(t, result.IsOutdated) + require.Error(t, result.Error) + assert.Contains(t, result.Error.Error(), "development build") + }) + } +} + +func TestCheck_WithMockServer(t *testing.T) { + tests := []struct { + name string + currentVersion string + releases []Release + statusCode int + wantOutdated bool + wantAhead bool + wantError bool + errorContains string + }{ + { + name: "current version is latest", + currentVersion: "v1.2.0", + releases: []Release{ + {TagName: "v1.2.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.2.0"}, + {TagName: "v1.1.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.1.0"}, + }, + statusCode: http.StatusOK, + }, + { + name: "current version is outdated", + currentVersion: "v1.1.0", + releases: []Release{ + {TagName: "v1.2.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.2.0"}, + {TagName: "v1.1.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.1.0"}, + }, + statusCode: http.StatusOK, + wantOutdated: true, + }, + { + name: "current version without v prefix is outdated", + currentVersion: "1.1.0", + releases: []Release{ + {TagName: "v1.2.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.2.0"}, + }, + statusCode: http.StatusOK, + wantOutdated: true, + }, + { + name: "current version is newer than latest", + currentVersion: "v1.3.0", + releases: []Release{ + {TagName: "v1.2.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.2.0"}, + }, + statusCode: http.StatusOK, + wantAhead: true, + }, + { + name: "git-describe version with commits after latest tag is ahead", + currentVersion: "v1.2.0-1-g71f9e6d", + releases: []Release{ + {TagName: "v1.2.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.2.0"}, + }, + statusCode: http.StatusOK, + wantAhead: true, + }, + { + name: "git-describe version IS outdated when newer release exists", + currentVersion: "v1.1.0-5-g1234567", + releases: []Release{ + {TagName: "v1.2.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.2.0"}, + }, + statusCode: http.StatusOK, + wantOutdated: true, + }, + { + name: "patch version comparison", + currentVersion: "v1.2.0", + releases: []Release{ + {TagName: "v1.2.1", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.2.1"}, + }, + statusCode: http.StatusOK, + wantOutdated: true, + }, + { + name: "API returns error status", + currentVersion: "v1.2.0", + statusCode: http.StatusInternalServerError, + wantError: true, + errorContains: "status 500", + }, + { + name: "no prenup releases found", + currentVersion: "v1.2.0", + releases: []Release{ + {TagName: "rowtater/v1.0.0", HTMLURL: "https://github.com/c2fo/rowtater/releases/tag/v1.0.0"}, + }, + statusCode: http.StatusOK, + wantError: true, + errorContains: "no prenup releases found", + }, + { + name: "empty releases list", + currentVersion: "v1.2.0", + releases: []Release{}, + statusCode: http.StatusOK, + wantError: true, + errorContains: "no prenup releases found", + }, + { + name: "skips other tools to find prenup", + currentVersion: "v1.1.0", + releases: []Release{ + {TagName: "releasegen/v1.0.0", HTMLURL: "https://github.com/c2fo/releasegen/releases/tag/v1.0.0"}, + {TagName: "v1.2.0", HTMLURL: "https://github.com/c2fo/prenup/releases/tag/v1.2.0"}, + }, + statusCode: http.StatusOK, + wantOutdated: true, + }, + { + name: "selects highest semver regardless of API order", + currentVersion: "v1.2.0", + releases: []Release{ + {TagName: "v1.1.0", HTMLURL: "https://example.com/a"}, + {TagName: "v1.3.0", HTMLURL: "https://example.com/b"}, + {TagName: "v1.2.0", HTMLURL: "https://example.com/c"}, + }, + statusCode: http.StatusOK, + wantOutdated: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/vnd.github.v3+json", r.Header.Get("Accept")) + w.WriteHeader(tt.statusCode) + if tt.releases != nil { + _ = json.NewEncoder(w).Encode(tt.releases) + } + })) + defer server.Close() + + result := checkAgainstReleasesAPI(context.Background(), tt.currentVersion, "", server.URL) + + assert.Equal(t, tt.currentVersion, result.CurrentVersion) + assert.Equal(t, tt.wantOutdated, result.IsOutdated, "IsOutdated mismatch") + assert.Equal(t, tt.wantAhead, result.IsAhead, "IsAhead mismatch") + + if tt.wantError { + require.Error(t, result.Error) + if tt.errorContains != "" { + assert.Contains(t, result.Error.Error(), tt.errorContains) + } + } else { + require.NoError(t, result.Error) + assert.NotEmpty(t, result.LatestVersion) + assert.NotEmpty(t, result.LatestURL) + } + }) + } +} + +func TestCheck_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]Release{{TagName: "v1.0.0"}}) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := checkAgainstReleasesAPI(ctx, "v1.0.0", "", server.URL) + + require.Error(t, result.Error) + assert.Contains(t, result.Error.Error(), "context canceled") +} + +func TestCheck_WithGitHubToken(t *testing.T) { + var receivedToken string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedToken = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]Release{ + {TagName: "v1.0.0", HTMLURL: "https://example.com"}, + }) + })) + defer server.Close() + + result := checkAgainstReleasesAPI(context.Background(), "v1.0.0", "my-secret-token", server.URL) + + require.NoError(t, result.Error) + assert.Equal(t, "Bearer my-secret-token", receivedToken) +} + +func TestCheck_RateLimited(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", "1700000000") + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + result := checkAgainstReleasesAPI(context.Background(), "v1.0.0", "", server.URL) + + require.Error(t, result.Error) + msg := result.Error.Error() + assert.Contains(t, msg, "rate limit exceeded") + // The raw Unix timestamp 1700000000 is 2023-11-14T22:13:20Z; the + // formatter renders it as an RFC3339 UTC time so operators aren't left + // squinting at a Unix epoch. Assert against the human-readable form. + assert.Contains(t, msg, "2023-11-14T22:13:20Z") + assert.Contains(t, msg, "resets in") +} + +// TestFormatRateLimitReset covers the parse-fallback branch: a header the +// GitHub API doesn't set (or that we somehow received malformed) must not +// mask the underlying rate-limit condition with a strconv error. +func TestFormatRateLimitReset(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {name: "valid unix seconds", in: "1700000000", want: "2023-11-14T22:13:20Z"}, + {name: "empty header", in: "", want: "reset time unavailable"}, + {name: "non-numeric header", in: "later", want: "reset time unavailable"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatRateLimitReset(tc.in) + assert.Contains(t, got, tc.want) + }) + } +} + +func TestCheck_InvalidCurrentVersion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]Release{ + {TagName: "v1.0.0", HTMLURL: "https://example.com"}, + }) + })) + defer server.Close() + + result := checkAgainstReleasesAPI(context.Background(), "not-a-version", "", server.URL) + + require.Error(t, result.Error) + assert.Contains(t, result.Error.Error(), "invalid version format") +} diff --git a/prenup.example.yaml b/prenup.example.yaml new file mode 100644 index 0000000..8ed84b0 --- /dev/null +++ b/prenup.example.yaml @@ -0,0 +1,64 @@ +# .prenup.yaml — pre-commit checks managed by prenup. +# What is this? Prenup runs configurable checks before each commit, scoped to +# the parts of the repo that changed. Learn more and install: +# https://github.com/c2fo/prenup +# +# Place this file at the root of your git repository as `.prenup.yaml` (or +# `.prenup.yml`). Run `prenup install` once per repo to wire up the hook. +# +# See https://github.com/c2fo/prenup/blob/main/docs/SCHEMA.md for a full +# reference and https://github.com/c2fo/prenup/blob/main/assets/prenup.schema.json +# for an editor-friendly JSON schema. + +version: 1 + +# Files whose presence marks a "module". Defaults to [go.mod]. Configurable so +# polyglot repos can opt in to other languages. +module_markers: + - go.mod + +# Files matching any pattern here are ignored for change detection. Syntax is +# doublestar, so `**` crosses directory boundaries. +exclude: + - ".github/**" + - "**/*.yaml" + - "**/*.yml" + +# Stash unstaged changes around the run so tasks see exactly what will be +# committed. Override per-task below if needed. +clean_worktree: true + +# 0 means NumCPU. Applies to per-module fan-out within a single task. +max_parallelism: 0 + +# auto picks human for TTYs, markdown when piped. Override with --output. +output: auto + +tasks: + - name: "Run tests" + default_selected: true + command: "go test ./..." + per_module: true + paths: + - "**/*.go" + + - name: "Run golangci-lint" + default_selected: true + command: "golangci-lint run --max-same-issues 0 ./..." + per_module: true + paths: + - "**/*.go" + + - name: "go vet" + default_selected: true + command: "go vet ./..." + per_module: true + paths: + - "**/*.go" + + - name: "gofmt check" + default_selected: true + command: "test -z \"$(gofmt -l .)\"" + per_module: true + paths: + - "**/*.go" diff --git a/prenup.png b/prenup.png new file mode 100644 index 0000000..77a6278 Binary files /dev/null and b/prenup.png differ