diff --git a/.agents/skills/grill-me/SKILL.md b/.agents/skills/grill-me/SKILL.md new file mode 100644 index 0000000..6945ebc --- /dev/null +++ b/.agents/skills/grill-me/SKILL.md @@ -0,0 +1,12 @@ +--- +name: grill-me +description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +--- + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time. + +If a question can be answered by exploring the codebase, explore the codebase instead. + +Once we reach shared understanding, record the decisions and their reasoning as an ADR under `docs/adr/` (filename: `NNNN-.md`, sequential number). \ No newline at end of file diff --git a/.agents/skills/update-design/SKILL.md b/.agents/skills/update-design/SKILL.md new file mode 100644 index 0000000..a264716 --- /dev/null +++ b/.agents/skills/update-design/SKILL.md @@ -0,0 +1,81 @@ +--- +name: update-design +description: Review or update repository design documents, compare them with the actual codebase, and surface concrete gaps before implementation proceeds. Use when working on docs/design, ADRs, architecture notes, or when a plan depends on design quality. +--- + +# update-design + +Review design documents against the current repository state and improve them before implementation depends on them. + +## Phase 1: Gather Context + +Read only what is relevant: + +1. `docs/design/*.md`, `docs/adr/*.md`, and architecture notes under `docs/` +2. `docs/development-memo.md` when present +3. the source directories touched by the design +4. build manifests and CI workflows that constrain implementation +5. `AGENT.md` + +If design docs do not exist, report that first and switch to proposing a minimal design outline derived from the current memo and README. + +## Phase 2: Evaluate the Design + +Score each relevant document across these categories: + +1. module and ownership boundaries +2. data flow and control flow +3. interfaces, types, and configuration surface +4. failure modes, validation, and observability +5. testing and rollout strategy + +Use a 100-point scale. Treat 90+ as ready for implementation. + +## Phase 3: Check Against the Code + +Confirm whether the design matches reality: + +1. documented modules exist +2. names and paths match the codebase +3. described behaviors are implemented or clearly marked as planned +4. validation steps match the actual tooling + +Call out both directions: + +- documented but not implemented +- implemented but undocumented + +## Phase 4: Produce Improvements + +Return a concise report in this shape: + +```markdown +## Design Review + +### +| Category | Score | +|---|---:| +| Module boundaries | XX | +| Data flow | XX | +| Interfaces | XX | +| Failure modes | XX | +| Testing and rollout | XX | + +Average: XX.X + +Issues: +- ... + +Recommended edits: +- ... + +Code mismatch: +- ... +``` + +## Editing Rules + +- keep design docs implementation-oriented +- prefer concrete file paths, types, commands, and invariants +- do not restate obvious code when a short reference is enough +- if a design decision matters later, suggest an ADR under `docs/adr/` diff --git a/.agents/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md new file mode 100644 index 0000000..a40b4db --- /dev/null +++ b/.agents/skills/update-docs/SKILL.md @@ -0,0 +1,79 @@ +--- +name: update-docs +description: Synchronize README, docs, and AGENT guidance with the actual codebase and tooling. Use when implementation changed commands, behavior, configuration, architecture, or project structure. +--- + +# update-docs + +Update repository documentation to match the current implementation. + +## Phase 1: Inspect the Current Source of Truth + +Read the smallest set of files that define behavior: + +1. entry points and changed source files +2. build and dependency manifests +3. CI workflows +4. `README.md`, `docs/`, `AGENT.md` +5. `docs/development-memo.md` when present + +Do not update docs from memory. Derive every command and path from the repository. + +## Phase 2: Update the Right Document + +Use this split: + +- `README.md`: how to install, run, configure, and use the project +- `docs/`: design rationale, ADRs, requirements, internal notes +- `AGENT.md`: repo-specific instructions for Codex + +For GitReal specifically, keep `README.md` and `docs/development-memo.md` aligned on: + +1. executable name `git-real` +2. user-facing command `git real` +3. safety model around `arm`, backups, and rescue +4. planned Go-based architecture + +Common updates: + +1. build, test, lint, format, or release commands +2. config file names and option descriptions +3. module layout and entry points +4. examples and sample outputs +5. workflow or validation requirements + +## Phase 3: Consistency Check + +Verify consistency across docs: + +1. file paths +2. command names +3. option flags +4. module names +5. stated prerequisites + +If the repo has no established command for something, say that plainly instead of inventing one. + +## Phase 4: Report the Update + +Summarize in this form: + +```markdown +## Documentation Update + +Updated: +- : + +Open gaps: +- ... + +Remaining assumptions: +- ... +``` + +## Editing Rules + +- keep README user-facing and concise +- keep developer guidance close to the code it explains +- prefer exact commands over prose-only instructions +- remove stale claims instead of softening them diff --git a/.agents/skills/update-plan/SKILL.md b/.agents/skills/update-plan/SKILL.md new file mode 100644 index 0000000..7e23250 --- /dev/null +++ b/.agents/skills/update-plan/SKILL.md @@ -0,0 +1,68 @@ +--- +name: update-plan +description: Stress-test an implementation plan against the current codebase, design docs, and repo constraints before presenting it. Use for substantial plans, phased work, refactors, or multi-file features. +--- + +# update-plan + +Run this immediately before presenting a substantial implementation plan. + +## Phase 1: Gather Inputs + +Collect the minimum relevant context: + +1. the current draft plan +2. `AGENT.md` +3. related design docs, ADRs, requirements, roadmap items, issue notes, and `docs/development-memo.md` +4. the source files and manifests the plan touches + +## Phase 2: Validate the Plan + +Check the plan for: + +1. correct assumptions about the current codebase +2. independently verifiable steps +3. separation of structural cleanup from behavior changes +4. explicit validation for each risky step +5. impact on docs, config, CI, and tests + +## Phase 3: Find Gaps + +Flag these issues by priority: + +- `P0`: plan contradicts code or requirements +- `P1`: step ordering, missing validation, or unclear ownership +- `P2`: useful follow-up work or documentation debt + +## Phase 4: Rewrite the Plan + +Return the improved plan with: + +1. ordered steps +2. affected files or directories +3. validation per step +4. risks or dependencies +5. missing design or documentation prerequisites + +Use this output shape: + +```markdown +## Plan Check + +Issues: +- P0: ... +- P1: ... +- P2: ... + +Revised plan: +1. ... +2. ... +3. ... +``` + +## Plan Quality Bar + +- each step should change one coherent thing +- each step should have an observable completion signal +- broad refactors must justify why they cannot be split +- if design is too vague, route back through `update-design` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bdf2ad7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - run: go mod download + - run: make check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c90c71e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build-release: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + archive_ext: tar.gz + binary_name: git-real + - goos: darwin + goarch: arm64 + archive_ext: tar.gz + binary_name: git-real + - goos: linux + goarch: amd64 + archive_ext: tar.gz + binary_name: git-real + - goos: linux + goarch: arm64 + archive_ext: tar.gz + binary_name: git-real + - goos: windows + goarch: amd64 + archive_ext: zip + binary_name: git-real.exe + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - run: go mod download + + - name: Build archive + run: | + set -euo pipefail + artifact_name="git-real_${{ matrix.goos }}_${{ matrix.goarch }}" + archive_name="${artifact_name}.${{ matrix.archive_ext }}" + + mkdir -p "dist/${artifact_name}" + + GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" go build -o "dist/${artifact_name}/${{ matrix.binary_name }}" ./cmd/git-real + + if [ "${{ matrix.archive_ext }}" = "zip" ]; then + zip -j "dist/${archive_name}" "dist/${artifact_name}/${{ matrix.binary_name }}" + else + tar -C dist -czf "dist/${archive_name}" "${artifact_name}" + fi + + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/*.* + if-no-files-found: error + + publish-release: + runs-on: ubuntu-latest + needs: build-release + + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + pattern: release-* + merge-multiple: true + + - name: Generate checksums + run: | + set -euo pipefail + sha256sum dist/* > dist/SHA256SUMS + + - uses: softprops/action-gh-release@v2 + with: + files: dist/* diff --git a/.gitignore b/.gitignore index 872d5f6..e02649c 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,8 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Go +.cache/ +coverage.out +/git-real diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..e7c4157 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,151 @@ +# AGENT.md + +This repository uses a repo-local Codex guide and skills. + +## Scope + +This file defines the default working rules for Codex in this repository. +Repo-local skills live under `skills/`. + +Note: the reference repository uses `.agents/skills/`, but this workspace mounts hidden top-level agent directories read-only. Keep the same skill content here under `skills/`. + +## Project Overview + +GitReal is a Git subcommand distributed as a `git-real` executable and invoked by users as `git real`. + +The current product shape from `README.md` and `docs/development-memo.md` is: + +- 2-minute push challenge triggered by notification timing +- default dry-run behavior +- destructive mode enabled only by explicit `git real arm` +- backup and recovery through `refs/gitreal/backups/...` +- planned implementation language: Go + +Current MVP command set: + +```text +git real init +git real status +git real once +git real start +git real arm +git real disarm +git real rescue list +git real rescue restore +``` + +## Current Source Of Truth + +Until the Go implementation exists, treat these as the primary project documents: + +- `README.md` +- `docs/development-memo.md` +- `AGENT.md` + +Keep those three aligned. + +## Working Baseline + +1. Inspect the repository before making assumptions about language, framework, build system, or test runner. +2. Prefer the commands and conventions already present in the repo over introducing new tooling. +3. Keep changes small, explicit, and easy to validate. +4. Update related docs when behavior, commands, or project structure change. + +## Discovery Order + +Before making changes, check the files that define how the project works: + +- `README.md` +- `docs/development-memo.md` +- CI workflows under `.github/workflows/` +- language/build manifests such as `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `build.zig`, `Makefile` +- source entry points and top-level docs under `docs/` + +If those files do not exist, say so explicitly and proceed with the smallest reasonable assumption. + +## Completion Requirements + +Do not consider work complete until you have run the narrowest relevant validation available in the repository. + +Examples: + +- existing test command +- existing formatter or linter +- existing typecheck or build command + +For this repository today: + +- if only docs changed, verify `README.md`, `docs/development-memo.md`, and `AGENT.md` stay consistent +- if Go scaffolding exists, prefer the repo-native commands first +- once `go.mod` and `cmd/git-real` exist, the expected baseline is `go test ./...` and `go build ./cmd/git-real` + +If the repository does not yet define runnable validation commands, report that clearly instead of inventing a fake completion signal. + +## Engineering Approach + +### TDD When Practical + +When the repo already has tests or a clear place for them: + +1. write or update a failing test +2. implement the minimum change +3. refactor without changing behavior + +### Tidy First + +Separate structural cleanup from behavioral changes where practical. + +- reduce nesting with guard clauses +- remove dead code when encountered +- extract helpers when they clarify intent +- normalize similar code paths +- keep comments short and only where code is not self-evident + +### Iteration Size + +Split work into the smallest meaningful increment and finish that increment completely before moving on. + +## Planned Architecture + +Use the current memo as the default implementation direction unless newer code or docs replace it: + +- `cmd/git-real/main.go` for CLI entry +- `internal/git/` for Git command wrappers +- `internal/challenge/` for timing and challenge flow +- `internal/notify/` for desktop notifications +- `internal/config/` for Git config access +- `internal/daemon/` for foreground scheduler and later background support + +Prefer invoking Git commands over reading `.git` internals directly. + +## Documentation Rules + +- `README.md`: user-facing usage, install, and operation +- `docs/`: design notes, ADRs, requirements, implementation details +- `AGENT.md`: repo-specific instructions for Codex + +When a command, workflow, or architecture decision changes, update the relevant document in the same task when possible. + +For this repo in particular: + +- user-facing command behavior belongs in `README.md` +- command rationale and architecture notes belong in `docs/development-memo.md` +- Codex workflow rules belong in `AGENT.md` + +## Planning Rules + +When asked for a plan: + +1. ground the plan in the current repo state +2. identify missing design or requirement inputs +3. keep steps independently verifiable +4. note risks, dependencies, and affected files + +Run the `update-plan` skill before finalizing a substantial plan. + +## Available Repo Skills + +- `skills/update-design/` +- `skills/update-docs/` +- `skills/update-plan/` +- `skills/grill-me/` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a269174 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md + +See [AGENT.md](./AGENT.md) for the canonical Codex instructions for this repository. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..db95d9b --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +GO ?= go +COVERAGE_THRESHOLD ?= 95 +GOFMT_TARGETS := cmd internal +CACHE_DIR ?= $(CURDIR)/.cache + +export GOCACHE ?= $(CACHE_DIR)/go-build +export GOMODCACHE ?= $(CACHE_DIR)/gomod +export GOPATH ?= $(CACHE_DIR)/gopath +export XDG_CACHE_HOME ?= $(CACHE_DIR)/xdg + +.PHONY: build fmt fmt-check lint typecheck deadcode test coverage check + +build: + $(GO) build -o git-real ./cmd/git-real + +fmt: + $(GO) fmt ./... + +fmt-check: + files="$$(find $(GOFMT_TARGETS) -name '*.go' -print)"; \ + if [ -z "$$files" ]; then \ + exit 0; \ + fi; \ + unformatted="$$(gofmt -l $$files)"; \ + if [ -n "$$unformatted" ]; then \ + printf '%s\n' "$$unformatted"; \ + exit 1; \ + fi + +lint: + $(GO) vet ./... + $(GO) tool staticcheck ./... + +typecheck: + $(GO) test -run '^$$' ./... + +deadcode: + $(GO) tool deadcode ./cmd/git-real + +test: + $(GO) test ./... + +coverage: + COVERAGE_THRESHOLD=$(COVERAGE_THRESHOLD) bash ./scripts/check-coverage.sh + +check: fmt-check lint typecheck deadcode coverage diff --git a/README.md b/README.md new file mode 100644 index 0000000..e168dbf --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# GitReal + +GitReal is a Git subcommand that turns "I should push later" into a deadline. + +When a challenge fires, you have 2 minutes to push your local commits. If you miss the window, GitReal can reset your branch back to its upstream state. By default, it stays in dry-run mode, so you can try the workflow before allowing destructive behavior. + +The distributed binary is named `git-real`, and Git exposes it to users as: + +```bash +git real +``` + +## Why You Would Use It + +- You want a push habit, not just a reminder. +- You want the pressure of a timer without giving up recovery options. +- You want to try it safely before enabling real resets. + +## Quick Start + +Install: + +```bash +go install github.com/watany-dev/gitreal/cmd/git-real@latest +``` + +Try it in a repository: + +```bash +git real init +git real status +git real once +``` + +`git real once`, `git real start`, `git real arm`, and `git real disarm` require `git real init` first. `git real status` and `git real rescue ...` are still available before initialization. + +If you want GitReal to run continuously in the foreground: + +```bash +git real start +``` + +## What Happens In Practice + +1. Run `git real init` once per repository. +2. GitReal stores repo-local config and starts in dry-run mode. +3. A challenge checks whether your current branch is ahead of its upstream. +4. If you push in time, nothing happens. +5. If you miss the deadline: + +- in dry-run mode, GitReal only tells you what it would have reset +- in armed mode, GitReal backs up `HEAD` and resets the branch to `@{u}` + +## Safety First + +GitReal is intentionally conservative: + +- Default mode is dry-run. +- Destructive behavior requires an explicit `git real arm`. +- Before any reset, GitReal stores the current `HEAD` under `refs/gitreal/backups/...`. +- `git real rescue restore ` also backs up the current `HEAD` before restoring a backup. +- Dirty worktree changes are stashed and then restored when possible. + +If you want real enforcement for the current repository: + +```bash +git real arm +``` + +To go back to safe mode: + +```bash +git real disarm +``` + +## Recovery + +List available backups: + +```bash +git real rescue list +``` + +Restore one: + +```bash +git real rescue restore +``` + +## Commands + +```bash +git real init +git real status +git real once [--grace-seconds=120] +git real start [--grace-seconds=120] +git real arm +git real disarm +git real rescue list +git real rescue restore +``` + +Command intent: + +- `git real init`: enable GitReal for the current repository and write default config +- `git real status`: show current repo state, upstream, and ahead count +- `git real once`: run one challenge immediately +- `git real start`: stay in the foreground and schedule hourly random challenges over time +- `git real arm`: allow real resets for missed deadlines +- `git real disarm`: return to dry-run mode +- `git real rescue ...`: inspect and restore backup refs + +## Current Limits + +This beta currently expects: + +- the current branch has an upstream branch +- the repository is not in detached `HEAD` +- desktop notifications may fail and fall back to terminal output +- `git real start` is the current scheduler entrypoint +- `git real daemon` is not implemented yet + +## Configuration + +GitReal stores settings in Git config: + +```bash +git config --local gitreal.enabled true +git config --local gitreal.armed false +git config --local gitreal.graceSeconds 120 +``` + +Current keys: + +- `gitreal.enabled` +- `gitreal.armed` +- `gitreal.graceSeconds` + +## Build From Source + +```bash +go build -o git-real ./cmd/git-real +``` + +## Development + +Project checks: + +```bash +go mod download +make fmt +make test +make check +``` + +`make check` runs formatting, linting, type-check compilation, dead-code detection, and the coverage gate. + +## Releases + +Current beta distribution targets: + +- `go install` +- GitHub Releases for macOS, Linux, and Windows + +Release archives are published as: + +- `git-real_darwin_amd64.tar.gz` +- `git-real_darwin_arm64.tar.gz` +- `git-real_linux_amd64.tar.gz` +- `git-real_linux_arm64.tar.gz` +- `git-real_windows_amd64.zip` +- `SHA256SUMS` + +## More Detail + +Design notes and implementation rationale live in [docs/development-memo.md](/workspaces/gitreal/docs/development-memo.md). diff --git a/cmd/git-real/e2e_test.go b/cmd/git-real/e2e_test.go new file mode 100644 index 0000000..f59846b --- /dev/null +++ b/cmd/git-real/e2e_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestMainEndToEnd(t *testing.T) { + workDir := t.TempDir() + originDir := filepath.Join(workDir, "origin.git") + repoDir := filepath.Join(workDir, "repo") + + runGit(t, workDir, "init", "--bare", originDir) + runGit(t, workDir, "clone", originDir, repoDir) + runGit(t, repoDir, "checkout", "-b", "main") + runGit(t, repoDir, "config", "user.name", "GitReal Test") + runGit(t, repoDir, "config", "user.email", "test@example.com") + + writeFile(t, filepath.Join(repoDir, "file.txt"), "base\n") + runGit(t, repoDir, "add", "file.txt") + runGit(t, repoDir, "commit", "-m", "base") + runGit(t, repoDir, "push", "-u", "origin", "HEAD") + + stdout, stderr, exitCode := runMain(t, repoDir, "init") + if exitCode != 0 { + t.Fatalf("init exitCode = %d, stderr = %q", exitCode, stderr) + } + if !strings.Contains(stdout, "GitReal initialized for:") { + t.Fatalf("stdout = %q, want init output", stdout) + } + + stdout, stderr, exitCode = runMain(t, repoDir, "status") + if exitCode != 0 { + t.Fatalf("status exitCode = %d, stderr = %q", exitCode, stderr) + } + for _, want := range []string{"enabled: true", "armed: false", "upstream: origin/main", "ahead: 0"} { + if !strings.Contains(stdout, want) { + t.Fatalf("status stdout = %q, want substring %q", stdout, want) + } + } + + writeFile(t, filepath.Join(repoDir, "file.txt"), "base\nlocal\n") + runGit(t, repoDir, "commit", "-am", "local") + + _, stderr, exitCode = runMain(t, repoDir, "once", "--grace-seconds=1") + if exitCode != 0 { + t.Fatalf("once dry-run exitCode = %d, stderr = %q", exitCode, stderr) + } + if ahead := strings.TrimSpace(runGitOutput(t, repoDir, "rev-list", "--count", "@{u}..HEAD")); ahead != "1" { + t.Fatalf("ahead after dry-run = %q, want 1", ahead) + } + + _, stderr, exitCode = runMain(t, repoDir, "arm") + if exitCode != 0 { + t.Fatalf("arm exitCode = %d, stderr = %q", exitCode, stderr) + } + + stdout, stderr, exitCode = runMain(t, repoDir, "once", "--grace-seconds=1") + if exitCode != 0 { + t.Fatalf("once armed exitCode = %d, stderr = %q", exitCode, stderr) + } + if !strings.Contains(stdout, "backup ref: refs/gitreal/backups/main/") { + t.Fatalf("stdout = %q, want backup ref", stdout) + } + if ahead := strings.TrimSpace(runGitOutput(t, repoDir, "rev-list", "--count", "@{u}..HEAD")); ahead != "0" { + t.Fatalf("ahead after penalty = %q, want 0", ahead) + } + + stdout, stderr, exitCode = runMain(t, repoDir, "rescue", "list") + if exitCode != 0 { + t.Fatalf("rescue list exitCode = %d, stderr = %q", exitCode, stderr) + } + backupRef := firstNonEmptyLine(stdout) + if !strings.HasPrefix(backupRef, "refs/gitreal/backups/main/") { + t.Fatalf("backupRef = %q, want GitReal backup ref", backupRef) + } + + stdout, stderr, exitCode = runMain(t, repoDir, "rescue", "restore", backupRef) + if exitCode != 0 { + t.Fatalf("rescue restore exitCode = %d, stderr = %q", exitCode, stderr) + } + if !strings.Contains(stdout, "previous HEAD backed up to: refs/gitreal/backups/main/") { + t.Fatalf("stdout = %q, want current HEAD backup output", stdout) + } + if ahead := strings.TrimSpace(runGitOutput(t, repoDir, "rev-list", "--count", "@{u}..HEAD")); ahead != "1" { + t.Fatalf("ahead after rescue restore = %q, want 1", ahead) + } +} + +func runMain(t *testing.T, dir string, args ...string) (string, string, int) { + t.Helper() + + previousDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error = %v", dir, err) + } + defer func() { + if err := os.Chdir(previousDir); err != nil { + t.Fatalf("restore Chdir(%q) error = %v", previousDir, err) + } + }() + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + exitCode := Main(args, stdout, stderr) + return stdout.String(), stderr.String(), exitCode +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=GitReal Test", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=GitReal Test", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v: %s", args, err, output) + } +} + +func runGitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v: %s", args, err, output) + } + return string(output) +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } +} + +func firstNonEmptyLine(text string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} diff --git a/cmd/git-real/main.go b/cmd/git-real/main.go new file mode 100644 index 0000000..072931b --- /dev/null +++ b/cmd/git-real/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "io" + "os" + + "github.com/watany-dev/gitreal/internal/cli" +) + +func Main(args []string, stdout, stderr io.Writer) int { + return cli.Run(args, stdout, stderr) +} + +func main() { + os.Exit(Main(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/cmd/git-real/main_test.go b/cmd/git-real/main_test.go new file mode 100644 index 0000000..4c6cc18 --- /dev/null +++ b/cmd/git-real/main_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestMain(t *testing.T) { + t.Parallel() + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + exitCode := Main([]string{"help"}, stdout, stderr) + if exitCode != 0 { + t.Fatalf("Main() exitCode = %d, want 0", exitCode) + } + + if got := stdout.String(); !strings.Contains(got, "git real once") { + t.Fatalf("stdout = %q, want help output", got) + } + + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } +} diff --git a/docs/development-memo.md b/docs/development-memo.md new file mode 100644 index 0000000..f53e9e6 --- /dev/null +++ b/docs/development-memo.md @@ -0,0 +1,771 @@ +# GitReal Development Memo + +最終更新: 2026-05-01 + +## 結論 + +- 実行ファイル名は `git-real` にする。 +- ユーザー向けコマンドは `git real`。 +- 実装言語は Go を採用する。 + +Git は `git ` が core Git command でない場合、`PATH` 上の `git-` を探して実行し、後続引数をそのまま渡す。したがって `git-real` という実行ファイルを配布すれば、ユーザーは以下のように実行できる。 + +```bash +git real +git real start +git real once +git real status +git real rescue +``` + +## Go を選ぶ理由 + +GitReal は以下が中心になる。 + +- Git plumbing を呼ぶ CLI +- 通知 +- タイマー常駐 +- クロスプラットフォーム配布 + +この用途では Go の単一バイナリ配布が強い。Python はユーザー環境の Python バージョンや依存関係が面倒になりやすい。Rust も有力だが、この用途では Go の方が実装速度と配布のバランスがよい。Node は Git ユーザー向け CLI としてはランタイム依存がやや重い。 + +## 想定ディレクトリ構成 + +```text +git-real/ + go.mod + cmd/ + git-real/ + main.go + internal/ + cli/ + app.go + git/ + git.go + challenge/ + grace.go + notify/ + notify.go + README.md +``` + +現状の実装は `cmd/git-real/main.go` から `internal/cli` を呼び、Git 実行は `internal/git`、通知は `internal/notify` に分離している。`config` と `daemon` は将来拡張用の想定で、MVP ではまだ導入していない。 + +## ユーザー体験 + +```bash +# インストール +go install github.com/watany-dev/gitreal/cmd/git-real@latest + +# 対象 repo で初期化 +git real init + +# dry-run で開始 +git real start + +# 本当に reset する危険モードを有効化 +git real arm + +# 今すぐ1回だけ発火 +git real once + +# 退避された commit を確認 +git real rescue list + +# 復旧 +git real rescue restore +``` + +`git real once`、`git real start`、`git real arm`、`git real disarm` は `git real init` 後にのみ動かす。未初期化でも `git real status` と `git real rescue ...` は使える。 + +現状の制約も明示しておく。 + +- challenge 実行には upstream branch が必要 +- detached HEAD は非対応 +- 通知は best-effort で、使えない環境では標準出力へのフォールバックになる +- `git real daemon` はまだ未実装 + +## 設定方針 + +設定は Git config に入れる。 + +```bash +git config --local gitreal.enabled true +git config --local gitreal.armed false +git config --local gitreal.graceSeconds 120 +``` + +`gitreal.*` のような独自 namespace を使う。Git config は repository local の `.git/config`、global の `~/.gitconfig`、system-wide の config を扱えるため、この方針が自然。 + +## Hook 方針 + +Hook だけで作るのは不向き。Git hooks は `commit`、`push`、`merge` など Git 実行タイミングに反応する仕組みであり、ランダム時刻に通知する scheduler ではない。 + +- `git real once`: その場でチャレンジを 1 回実行 +- `git real start`: Public Beta 時点の常駐入口。フォアグラウンドで常駐し、毎時ランダムな時刻に通知 +- `git real daemon`: `launchd` / `systemd` / Windows Task Scheduler から起動される本番用 +- `git real init`: repo local config に `gitreal.enabled=true` を書く +- `git real arm`: `gitreal.armed=true` を書く +- `git real rescue`: `refs/gitreal/backups/...` に退避した `HEAD` を一覧・復旧する + +MVP では hook は使わない。後から追加するなら `post-commit` で「未 push commit ができたので GitReal 対象になった」と通知する程度に留める。 + +## Git 状態判定 + +Go から `.git` ディレクトリを直接読むより、Git の状態判定はすべて `git` コマンド経由にする。 + +```bash +git rev-parse --show-toplevel +git symbolic-ref --quiet --short HEAD +git rev-parse --abbrev-ref --symbolic-full-name @{u} +git fetch --quiet --prune +git rev-list --count @{u}..HEAD +``` + +`@{u}..HEAD` が `0` なら push 済み、`1` 以上なら upstream より ahead。締切後に `fetch` してから判定すれば、通常の `git push` による remote 反映を確認できる。 + +## 処罰処理 + +```bash +git update-ref refs/gitreal/backups// HEAD +git stash push --include-untracked --message "gitreal preserve worktree ..." +git reset --hard @{u} +git stash pop +``` + +この順序にする理由は、`reset --hard @{u}` でブランチ先端を upstream に戻す前に、元の `HEAD` を `refs/gitreal/backups/...` に退避するため。これにより「ローカルコミットがなかったことになる」体験を出しつつ、事故時は `git real rescue restore` で戻せる。 + +## Rescue restore 処理 + +`git real rescue restore ` も破壊的操作なので、restore 前に現在の `HEAD` を `refs/gitreal/backups/...` に退避する。worktree が dirty な場合は stash してから restore し、restore 後に stash pop を試みる。 + +## MVP コマンド + +```text +git real init +git real status +git real once +git real start +git real arm +git real disarm +git real rescue list +git real rescue restore +``` + +危険モードは必ず `git real arm` を明示的に要求する。デフォルトは dry-run。 + +## Public Beta 配布方針 + +- 正式なベータ導線は `go install` と GitHub Releases に絞る +- Release artifact は macOS / Linux / Windows 向けに出す +- Release には `SHA256SUMS` を含める +- `git real daemon` と Homebrew tap は次フェーズの課題として残す + +## 初期プロトタイプ + +初期の単一ファイル案は以下。現在の repository 実装はこの責務を `internal/cli`、`internal/git`、`internal/notify` に分割している。 + +```go +package main + +import ( + "errors" + "flag" + "fmt" + "math/rand" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +type GitRealError struct { + Message string +} + +func (e GitRealError) Error() string { + return e.Message +} + +type Git struct { + Repo string +} + +func main() { + exitCode := run(os.Args) + os.Exit(exitCode) +} + +func run(args []string) int { + if len(args) < 2 { + printHelp() + return 0 + } + + command := args[1] + + switch command { + case "init": + return commandInit(args[2:]) + case "status": + return commandStatus(args[2:]) + case "once": + return commandOnce(args[2:]) + case "start": + return commandStart(args[2:]) + case "arm": + return commandArm(args[2:]) + case "disarm": + return commandDisarm(args[2:]) + case "rescue": + return commandRescue(args[2:]) + case "help", "-h", "--help": + printHelp() + return 0 + default: + fmt.Fprintf(os.Stderr, "git-real: unknown command: %s\n", command) + printHelp() + return 2 + } +} + +func printHelp() { + fmt.Println(`git-real - BeReal-inspired punishment CLI for Git + +Usage: + git real init + git real status + git real once [--grace-seconds=120] + git real start [--grace-seconds=120] + git real arm + git real disarm + git real rescue list + git real rescue restore + +Git invokes this binary as "git real" when the executable is named "git-real" and is on PATH.`) +} + +func commandInit(args []string) int { + repo, err := discoverRepo(".") + if err != nil { + return fail(err) + } + + g := Git{Repo: repo} + + if err := g.Run("config", "--local", "gitreal.enabled", "true"); err != nil { + return fail(err) + } + if err := g.Run("config", "--local", "gitreal.armed", "false"); err != nil { + return fail(err) + } + if err := g.Run("config", "--local", "gitreal.graceSeconds", "120"); err != nil { + return fail(err) + } + + fmt.Println("GitReal initialized for:") + fmt.Println(repo) + fmt.Println("Mode: dry-run") + fmt.Println("Run: git real once") + return 0 +} + +func commandStatus(args []string) int { + repo, err := discoverRepo(".") + if err != nil { + return fail(err) + } + + g := Git{Repo: repo} + + branch, err := g.CurrentBranch() + if err != nil { + return fail(err) + } + + upstream, err := g.Upstream() + if err != nil { + return fail(err) + } + + _ = g.FetchQuiet() + + ahead, err := g.AheadCount() + if err != nil { + return fail(err) + } + + armed := g.ConfigBool("gitreal.armed", false) + + fmt.Printf("repo: %s\n", repo) + fmt.Printf("branch: %s\n", branch) + fmt.Printf("upstream: %s\n", upstream) + fmt.Printf("ahead: %d\n", ahead) + fmt.Printf("armed: %t\n", armed) + + return 0 +} + +func commandOnce(args []string) int { + fs := flag.NewFlagSet("once", flag.ContinueOnError) + graceSeconds := fs.Int("grace-seconds", 120, "seconds before punishment") + if err := fs.Parse(args); err != nil { + return 2 + } + + repo, err := discoverRepo(".") + if err != nil { + return fail(err) + } + + g := Git{Repo: repo} + armed := g.ConfigBool("gitreal.armed", false) + + if err := runChallenge(g, *graceSeconds, armed); err != nil { + return fail(err) + } + + return 0 +} + +func commandStart(args []string) int { + fs := flag.NewFlagSet("start", flag.ContinueOnError) + graceSeconds := fs.Int("grace-seconds", 120, "seconds before punishment") + if err := fs.Parse(args); err != nil { + return 2 + } + + repo, err := discoverRepo(".") + if err != nil { + return fail(err) + } + + g := Git{Repo: repo} + armed := g.ConfigBool("gitreal.armed", false) + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + next := nextRandomSlot(time.Now(), rng) + + fmt.Printf("GitReal started for %s\n", repo) + fmt.Printf("next challenge: %s\n", next.Format(time.RFC3339)) + + for { + sleepUntil(next) + + if err := runChallenge(g, *graceSeconds, armed); err != nil { + fmt.Fprintf(os.Stderr, "git-real: %s\n", err.Error()) + } + + next = nextRandomSlot(time.Now().Add(time.Hour), rng) + fmt.Printf("next challenge: %s\n", next.Format(time.RFC3339)) + } +} + +func commandArm(args []string) int { + repo, err := discoverRepo(".") + if err != nil { + return fail(err) + } + + g := Git{Repo: repo} + + if err := g.Run("config", "--local", "gitreal.armed", "true"); err != nil { + return fail(err) + } + + fmt.Println("GitReal is now armed for this repository.") + return 0 +} + +func commandDisarm(args []string) int { + repo, err := discoverRepo(".") + if err != nil { + return fail(err) + } + + g := Git{Repo: repo} + + if err := g.Run("config", "--local", "gitreal.armed", "false"); err != nil { + return fail(err) + } + + fmt.Println("GitReal is now in dry-run mode for this repository.") + return 0 +} + +func commandRescue(args []string) int { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: git real rescue list | git real rescue restore ") + return 2 + } + + repo, err := discoverRepo(".") + if err != nil { + return fail(err) + } + + g := Git{Repo: repo} + + switch args[0] { + case "list": + out, err := g.Output("for-each-ref", "refs/gitreal/backups", "--format=%(refname)") + if err != nil { + return fail(err) + } + text := strings.TrimSpace(out) + if text == "" { + fmt.Println("No GitReal backup refs found.") + return 0 + } + fmt.Println(text) + return 0 + + case "restore": + if len(args) != 2 { + fmt.Fprintln(os.Stderr, "usage: git real rescue restore ") + return 2 + } + backupRef := args[1] + if !strings.HasPrefix(backupRef, "refs/gitreal/backups/") { + fmt.Fprintln(os.Stderr, "ref must start with refs/gitreal/backups/") + return 2 + } + if err := g.Run("reset", "--hard", backupRef); err != nil { + return fail(err) + } + fmt.Printf("Restored: %s\n", backupRef) + return 0 + + default: + fmt.Fprintf(os.Stderr, "unknown rescue command: %s\n", args[0]) + return 2 + } +} + +func runChallenge(g Git, graceSeconds int, armed bool) error { + branch, err := g.CurrentBranch() + if err != nil { + return err + } + + upstream, err := g.Upstream() + if err != nil { + return err + } + + _ = g.FetchQuiet() + + ahead, err := g.AheadCount() + if err != nil { + return err + } + + deadline := time.Now().Add(time.Duration(graceSeconds) * time.Second) + + message := fmt.Sprintf("%s has %d unpushed commit(s). Push before %s.", branch, ahead, deadline.Format("15:04:05")) + notify("GitReal", message) + + fmt.Printf("repo: %s\n", g.Repo) + fmt.Printf("branch: %s\n", branch) + fmt.Printf("upstream: %s\n", upstream) + fmt.Printf("ahead: %d\n", ahead) + fmt.Printf("deadline: %s\n", deadline.Format(time.RFC3339)) + + sleepUntil(deadline) + + if err := g.FetchQuiet(); err != nil { + notify("GitReal", "fetch failed; punishment skipped for safety.") + return nil + } + + aheadAfter, err := g.AheadCount() + if err != nil { + return err + } + + if aheadAfter == 0 { + notify("GitReal", "Push confirmed. You are GitReal.") + return nil + } + + if !armed { + notify("GitReal dry-run", fmt.Sprintf("%d commit(s) would be reset.", aheadAfter)) + fmt.Printf("dry-run: would reset %d commit(s) to @{u}\n", aheadAfter) + return nil + } + + backupRef, err := g.BackupHead(branch) + if err != nil { + return err + } + + stashed, err := g.StashDirtyWorktree(backupRef) + if err != nil { + return err + } + + if err := g.Run("reset", "--hard", "@{u}"); err != nil { + return err + } + + if stashed { + if err := g.Run("stash", "pop"); err != nil { + fmt.Println("stash pop failed; your stash remains available via git stash list") + } + } + + notify("GitReal", fmt.Sprintf("Local commits made unreal. Backup: %s", backupRef)) + fmt.Printf("backup ref: %s\n", backupRef) + fmt.Printf("restore: git real rescue restore %s\n", backupRef) + + return nil +} + +func discoverRepo(path string) (string, error) { + cmd := exec.Command("git", "-C", path, "rev-parse", "--show-toplevel") + out, err := cmd.CombinedOutput() + if err != nil { + return "", GitRealError{Message: "not inside a Git repository"} + } + return strings.TrimSpace(string(out)), nil +} + +func (g Git) Run(args ...string) error { + _, err := g.Output(args...) + return err +} + +func (g Git) Output(args ...string) (string, error) { + fullArgs := append([]string{"-C", g.Repo}, args...) + cmd := exec.Command("git", fullArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", GitRealError{ + Message: fmt.Sprintf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(out))), + } + } + return string(out), nil +} + +func (g Git) CurrentBranch() (string, error) { + out, err := g.Output("symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil { + return "", GitRealError{Message: "detached HEAD is not supported"} + } + return strings.TrimSpace(out), nil +} + +func (g Git) Upstream() (string, error) { + out, err := g.Output("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + if err != nil { + return "", GitRealError{Message: "no upstream configured; run: git push -u origin HEAD"} + } + return strings.TrimSpace(out), nil +} + +func (g Git) FetchQuiet() error { + return g.Run("fetch", "--quiet", "--prune") +} + +func (g Git) AheadCount() (int, error) { + out, err := g.Output("rev-list", "--count", "@{u}..HEAD") + if err != nil { + return 0, err + } + value := strings.TrimSpace(out) + if value == "" { + return 0, nil + } + count, err := strconv.Atoi(value) + if err != nil { + return 0, err + } + return count, nil +} + +func (g Git) BackupHead(branch string) (string, error) { + safeBranch := strings.ReplaceAll(branch, string(filepath.Separator), "-") + safeBranch = strings.ReplaceAll(safeBranch, "/", "-") + + timestamp := time.Now().UTC().Format("20060102T150405Z") + ref := fmt.Sprintf("refs/gitreal/backups/%s/%s", safeBranch, timestamp) + + if err := g.Run("update-ref", ref, "HEAD"); err != nil { + return "", err + } + + return ref, nil +} + +func (g Git) StashDirtyWorktree(backupRef string) (bool, error) { + out, err := g.Output("status", "--porcelain=v1", "-z") + if err != nil { + return false, err + } + + if out == "" { + return false, nil + } + + message := fmt.Sprintf("gitreal preserve worktree before penalty %s", backupRef) + + if err := g.Run("stash", "push", "--include-untracked", "--message", message); err != nil { + return false, err + } + + return true, nil +} + +func (g Git) ConfigBool(key string, fallback bool) bool { + out, err := g.Output("config", "--bool", "--get", key) + if err != nil { + return fallback + } + + value := strings.ToLower(strings.TrimSpace(out)) + + switch value { + case "true", "yes", "on", "1": + return true + case "false", "no", "off", "0": + return false + default: + return fallback + } +} + +func nextRandomSlot(base time.Time, rng *rand.Rand) time.Time { + hourStart := base.Truncate(time.Hour) + offset := time.Duration(rng.Intn(3600)) * time.Second + candidate := hourStart.Add(offset) + + if !candidate.After(time.Now()) { + candidate = hourStart.Add(time.Hour).Add(time.Duration(rng.Intn(3600)) * time.Second) + } + + return candidate +} + +func sleepUntil(target time.Time) { + for { + remaining := time.Until(target) + if remaining <= 0 { + return + } + if remaining > 30*time.Second { + time.Sleep(30 * time.Second) + } else { + time.Sleep(remaining) + } + } +} + +func notify(title string, message string) { + fmt.Printf("\a%s: %s\n", title, message) + + switch runtime.GOOS { + case "darwin": + escapedTitle := strings.ReplaceAll(title, `"`, `\"`) + escapedMessage := strings.ReplaceAll(message, `"`, `\"`) + script := fmt.Sprintf(`display notification "%s" with title "%s"`, escapedMessage, escapedTitle) + _ = exec.Command("osascript", "-e", script).Run() + + case "linux": + if commandExists("notify-send") { + _ = exec.Command("notify-send", title, message).Run() + } + + case "windows": + if commandExists("powershell") { + _ = exec.Command("powershell", "-NoProfile", "-Command", "[console]::beep(880,300)").Run() + } + } +} + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func fail(err error) int { + if err == nil { + return 0 + } + + var gitRealError GitRealError + if errors.As(err, &gitRealError) { + fmt.Fprintf(os.Stderr, "git-real: %s\n", gitRealError.Message) + return 1 + } + + fmt.Fprintf(os.Stderr, "git-real: %s\n", err.Error()) + return 1 +} +``` + +## 初期ビルド + +```bash +go mod init github.com/yourname/git-real +go build -o git-real ./cmd/git-real +``` + +## 動作確認 + +```bash +mkdir -p ~/bin +cp git-real ~/bin/git-real +export PATH="$HOME/bin:$PATH" + +cd /path/to/repo +git real init +git real status +git real once --grace-seconds=10 +``` + +`git real` が動くこと自体が、Git サブコマンドとして配布できている証拠になる。 + +## 配布方針 + +段階的に進める。 + +1. GitHub Releases に OS 別バイナリを置く +2. Homebrew tap を出す +3. `go install` を整備する + +アーカイブ名の例: + +```text +git-real_Darwin_arm64.tar.gz +git-real_Darwin_x86_64.tar.gz +git-real_Linux_x86_64.tar.gz +git-real_Windows_x86_64.zip +``` + +想定コマンド: + +```bash +brew install yourname/tap/git-real +go install github.com/yourname/git-real/cmd/git-real@latest +``` + +将来的には Scoop、Winget、AUR を追加候補とするが、初期は Homebrew + GitHub Releases + `go install` で十分。 + +## README に必ず書くこと + +- タイトルは `GitReal` +- タグラインは次を使う + +```text +BeReal, but for Git. +When the notification hits, you have 2 minutes to push. +Miss it, and your local commits become unreal. +``` + +- デフォルトは dry-run +- 危険モードは `git real arm` が必要 +- コミットは `refs/gitreal/backups/...` に退避される +- 復旧方法を明記する diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..24ff849 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/watany-dev/gitreal + +go 1.26 + +tool ( + golang.org/x/tools/cmd/deadcode + honnef.co/go/tools/cmd/staticcheck +) + +require ( + github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/tools v0.44.0 // indirect + honnef.co/go/tools v0.7.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ebf561e --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= diff --git a/internal/challenge/grace.go b/internal/challenge/grace.go new file mode 100644 index 0000000..4e9a87f --- /dev/null +++ b/internal/challenge/grace.go @@ -0,0 +1,18 @@ +package challenge + +const ( + DefaultGraceSeconds = 120 + MinGraceSeconds = 1 + MaxGraceSeconds = 3600 +) + +func NormalizeGraceSeconds(value int) int { + switch { + case value < MinGraceSeconds: + return MinGraceSeconds + case value > MaxGraceSeconds: + return MaxGraceSeconds + default: + return value + } +} diff --git a/internal/challenge/grace_test.go b/internal/challenge/grace_test.go new file mode 100644 index 0000000..761a10a --- /dev/null +++ b/internal/challenge/grace_test.go @@ -0,0 +1,51 @@ +package challenge + +import ( + "testing" + "testing/quick" +) + +func TestNormalizeGraceSeconds(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input int + want int + }{ + {name: "below minimum", input: -10, want: MinGraceSeconds}, + {name: "minimum", input: MinGraceSeconds, want: MinGraceSeconds}, + {name: "default", input: DefaultGraceSeconds, want: DefaultGraceSeconds}, + {name: "maximum", input: MaxGraceSeconds, want: MaxGraceSeconds}, + {name: "above maximum", input: MaxGraceSeconds + 1, want: MaxGraceSeconds}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := NormalizeGraceSeconds(tc.input); got != tc.want { + t.Fatalf("NormalizeGraceSeconds(%d) = %d, want %d", tc.input, got, tc.want) + } + }) + } +} + +func TestNormalizeGraceSecondsProperties(t *testing.T) { + t.Parallel() + + if err := quick.Check(func(input int) bool { + got := NormalizeGraceSeconds(input) + return got >= MinGraceSeconds && got <= MaxGraceSeconds + }, nil); err != nil { + t.Fatalf("range property failed: %v", err) + } + + if err := quick.Check(func(input int) bool { + got := NormalizeGraceSeconds(input) + return NormalizeGraceSeconds(got) == got + }, nil); err != nil { + t.Fatalf("idempotency property failed: %v", err) + } +} diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..760bf49 --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,502 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "math/rand" + "strconv" + "strings" + "time" + + "github.com/watany-dev/gitreal/internal/challenge" + ggit "github.com/watany-dev/gitreal/internal/git" + "github.com/watany-dev/gitreal/internal/notify" +) + +type repository interface { + Root() string + SetConfigBool(key string, value bool) error + SetConfigInt(key string, value int) error + ConfigBool(key string, fallback bool) bool + ConfigInt(key string, fallback int) int + CurrentBranch() (string, error) + Upstream() (string, error) + FetchQuiet() error + AheadCount() (int, error) + BackupHead(branch string, now time.Time) (string, error) + StashDirtyWorktree(message string) (bool, error) + StashPop() error + ResetHard(ref string) error + RescueRefs() ([]string, error) +} + +type app struct { + discoverRepo func(path string) (repository, error) + now func() time.Time + sleep func(time.Duration) + sendNotification func(title, message string) error + rng *rand.Rand + stdout io.Writer + stderr io.Writer + startIterations int +} + +func Run(args []string, stdout, stderr io.Writer) int { + return newApp(stdout, stderr).run(args) +} + +func newApp(stdout, stderr io.Writer) *app { + return &app{ + discoverRepo: func(path string) (repository, error) { + return ggit.Discover(path) + }, + now: time.Now, + sleep: time.Sleep, + sendNotification: notify.Send, + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + stdout: stdout, + stderr: stderr, + } +} + +func (a *app) run(args []string) int { + if len(args) == 0 { + printHelp(a.stdout) + return 0 + } + + switch args[0] { + case "help", "-h", "--help": + printHelp(a.stdout) + return 0 + case "init": + return a.commandInit() + case "status": + return a.commandStatus() + case "once": + return a.commandOnce(args[1:]) + case "start": + return a.commandStart(args[1:]) + case "arm": + return a.commandArm() + case "disarm": + return a.commandDisarm() + case "rescue": + return a.commandRescue(args[1:]) + default: + fmt.Fprintf(a.stderr, "git-real: unknown command: %s\n", args[0]) + printHelp(a.stderr) + return 2 + } +} + +func (a *app) commandInit() int { + repo, err := a.discoverRepo(".") + if err != nil { + return a.fail(err) + } + + if err := repo.SetConfigBool("gitreal.enabled", true); err != nil { + return a.fail(err) + } + + if err := repo.SetConfigBool("gitreal.armed", false); err != nil { + return a.fail(err) + } + + if err := repo.SetConfigInt("gitreal.graceSeconds", challenge.DefaultGraceSeconds); err != nil { + return a.fail(err) + } + + fmt.Fprintf(a.stdout, "GitReal initialized for: %s\n", repo.Root()) + fmt.Fprintln(a.stdout, "Mode: dry-run") + fmt.Fprintln(a.stdout, "Run: git real once") + return 0 +} + +func (a *app) commandStatus() int { + repo, err := a.discoverRepo(".") + if err != nil { + return a.fail(err) + } + + branch := "" + if value, err := repo.CurrentBranch(); err == nil { + branch = value + } + + upstream := "" + aheadText := "unknown" + if value, err := repo.Upstream(); err == nil { + upstream = value + _ = repo.FetchQuiet() + if ahead, err := repo.AheadCount(); err == nil { + aheadText = strconv.Itoa(ahead) + } + } + + fmt.Fprintf(a.stdout, "repo: %s\n", repo.Root()) + fmt.Fprintf(a.stdout, "enabled: %t\n", repo.ConfigBool("gitreal.enabled", false)) + fmt.Fprintf(a.stdout, "armed: %t\n", repo.ConfigBool("gitreal.armed", false)) + fmt.Fprintf(a.stdout, "grace-seconds: %d\n", challenge.NormalizeGraceSeconds(repo.ConfigInt("gitreal.graceSeconds", challenge.DefaultGraceSeconds))) + fmt.Fprintf(a.stdout, "branch: %s\n", branch) + fmt.Fprintf(a.stdout, "upstream: %s\n", upstream) + fmt.Fprintf(a.stdout, "ahead: %s\n", aheadText) + return 0 +} + +func (a *app) commandOnce(args []string) int { + repo, err := a.discoverRepo(".") + if err != nil { + return a.fail(err) + } + + graceSeconds, err := resolveGraceSeconds(args, repo, a.stderr) + if err != nil { + return 2 + } + + if err := a.requireInitialized(repo); err != nil { + return a.fail(err) + } + + if err := a.runChallenge(repo, graceSeconds, repo.ConfigBool("gitreal.armed", false)); err != nil { + return a.fail(err) + } + + return 0 +} + +func (a *app) commandStart(args []string) int { + repo, err := a.discoverRepo(".") + if err != nil { + return a.fail(err) + } + + graceSeconds, err := resolveGraceSeconds(args, repo, a.stderr) + if err != nil { + return 2 + } + + if err := a.requireInitialized(repo); err != nil { + return a.fail(err) + } + + return a.runStart(repo, graceSeconds, a.startIterations) +} + +func (a *app) runStart(repo repository, graceSeconds int, iterations int) int { + base := a.now() + fmt.Fprintf(a.stdout, "GitReal started for %s\n", repo.Root()) + + completed := 0 + for iterations <= 0 || completed < iterations { + next := nextRandomSlot(base, a.rng) + fmt.Fprintf(a.stdout, "next challenge: %s\n", next.Format(time.RFC3339)) + a.sleepUntil(next) + + if err := a.runChallenge(repo, graceSeconds, repo.ConfigBool("gitreal.armed", false)); err != nil { + fmt.Fprintf(a.stderr, "git-real: %v\n", err) + } + + base = next.Add(time.Hour) + completed++ + } + + return 0 +} + +func (a *app) commandArm() int { + repo, err := a.discoverRepo(".") + if err != nil { + return a.fail(err) + } + + if err := a.requireInitialized(repo); err != nil { + return a.fail(err) + } + + if err := repo.SetConfigBool("gitreal.armed", true); err != nil { + return a.fail(err) + } + + fmt.Fprintln(a.stdout, "GitReal is now armed for this repository.") + return 0 +} + +func (a *app) commandDisarm() int { + repo, err := a.discoverRepo(".") + if err != nil { + return a.fail(err) + } + + if err := a.requireInitialized(repo); err != nil { + return a.fail(err) + } + + if err := repo.SetConfigBool("gitreal.armed", false); err != nil { + return a.fail(err) + } + + fmt.Fprintln(a.stdout, "GitReal is now in dry-run mode for this repository.") + return 0 +} + +func (a *app) commandRescue(args []string) int { + if len(args) == 0 { + fmt.Fprintln(a.stderr, "git-real rescue: expected subcommand list or restore ") + return 2 + } + + repo, err := a.discoverRepo(".") + if err != nil { + return a.fail(err) + } + + switch args[0] { + case "list": + if len(args) != 1 { + fmt.Fprintln(a.stderr, "git-real rescue list: unexpected arguments") + return 2 + } + + refs, err := repo.RescueRefs() + if err != nil { + return a.fail(err) + } + + if len(refs) == 0 { + fmt.Fprintln(a.stdout, "No GitReal backup refs found.") + return 0 + } + + fmt.Fprintln(a.stdout, strings.Join(refs, "\n")) + return 0 + case "restore": + if len(args) != 2 { + fmt.Fprintln(a.stderr, "git-real rescue restore: expected exactly one backup ref") + return 2 + } + + backupRef := args[1] + if !ggit.IsBackupRef(backupRef) { + fmt.Fprintln(a.stderr, "git-real rescue restore: ref must start with refs/gitreal/backups/") + return 2 + } + + return a.restoreBackupRef(repo, backupRef) + default: + fmt.Fprintf(a.stderr, "git-real rescue: unknown subcommand: %s\n", args[0]) + return 2 + } +} + +func (a *app) restoreBackupRef(repo repository, backupRef string) int { + branch, err := repo.CurrentBranch() + if err != nil { + return a.fail(err) + } + + currentBackupRef, err := repo.BackupHead(branch, a.now()) + if err != nil { + return a.fail(err) + } + + stashMessage := fmt.Sprintf("gitreal preserve worktree before rescue restore %s", currentBackupRef) + stashed, err := repo.StashDirtyWorktree(stashMessage) + if err != nil { + return a.fail(err) + } + + if err := repo.ResetHard(backupRef); err != nil { + return a.fail(err) + } + + if stashed { + if err := repo.StashPop(); err != nil { + fmt.Fprintln(a.stdout, "stash pop failed; your stash remains available via git stash list") + } + } + + fmt.Fprintf(a.stdout, "Current branch reset to backup ref: %s\n", backupRef) + fmt.Fprintf(a.stdout, "previous HEAD backed up to: %s\n", currentBackupRef) + return 0 +} + +func (a *app) runChallenge(repo repository, graceSeconds int, armed bool) error { + branch, err := repo.CurrentBranch() + if err != nil { + return err + } + + upstream, err := repo.Upstream() + if err != nil { + return err + } + + if err := repo.FetchQuiet(); err != nil { + fmt.Fprintf(a.stdout, "preflight fetch failed; continuing with last known upstream state: %v\n", err) + } + + ahead, err := repo.AheadCount() + if err != nil { + return err + } + + fmt.Fprintf(a.stdout, "repo: %s\n", repo.Root()) + fmt.Fprintf(a.stdout, "branch: %s\n", branch) + fmt.Fprintf(a.stdout, "upstream: %s\n", upstream) + fmt.Fprintf(a.stdout, "ahead: %d\n", ahead) + + if ahead == 0 { + a.notify("GitReal", "No unpushed commits. Nothing to do.") + fmt.Fprintln(a.stdout, "nothing to do: no unpushed commits") + return nil + } + + deadline := a.now().Add(time.Duration(graceSeconds) * time.Second) + fmt.Fprintf(a.stdout, "deadline: %s\n", deadline.Format(time.RFC3339)) + a.notify("GitReal", fmt.Sprintf("%s has %d unpushed commit(s). Push before %s.", branch, ahead, deadline.Format("15:04:05"))) + + a.sleepUntil(deadline) + + if err := repo.FetchQuiet(); err != nil { + a.notify("GitReal", "fetch failed; punishment skipped for safety.") + fmt.Fprintln(a.stdout, "fetch failed after deadline; punishment skipped for safety") + return nil + } + + aheadAfter, err := repo.AheadCount() + if err != nil { + return err + } + + if aheadAfter == 0 { + a.notify("GitReal", "Push confirmed. You are GitReal.") + fmt.Fprintln(a.stdout, "push confirmed") + return nil + } + + if !armed { + a.notify("GitReal dry-run", fmt.Sprintf("%d commit(s) would be reset.", aheadAfter)) + fmt.Fprintf(a.stdout, "dry-run: would reset %d commit(s) to @{u}\n", aheadAfter) + return nil + } + + backupRef, err := repo.BackupHead(branch, a.now()) + if err != nil { + return err + } + + stashMessage := fmt.Sprintf("gitreal preserve worktree before penalty %s", backupRef) + stashed, err := repo.StashDirtyWorktree(stashMessage) + if err != nil { + return err + } + + if err := repo.ResetHard("@{u}"); err != nil { + return err + } + + if stashed { + if err := repo.StashPop(); err != nil { + fmt.Fprintln(a.stdout, "stash pop failed; your stash remains available via git stash list") + } + } + + a.notify("GitReal", fmt.Sprintf("Local commits made unreal. Backup: %s", backupRef)) + fmt.Fprintf(a.stdout, "backup ref: %s\n", backupRef) + fmt.Fprintf(a.stdout, "restore: git real rescue restore %s\n", backupRef) + return nil +} + +func (a *app) notify(title, message string) { + if err := a.sendNotification(title, message); err != nil { + fmt.Fprintf(a.stdout, "notification: %s: %s\n", title, message) + } +} + +func (a *app) sleepUntil(target time.Time) { + duration := target.Sub(a.now()) + if duration > 0 { + a.sleep(duration) + } +} + +func resolveGraceSeconds(args []string, repo repository, stderr io.Writer) (int, error) { + graceSeconds, explicit, err := parseGraceSeconds(args, stderr) + if err != nil { + return 0, err + } + + if explicit { + return graceSeconds, nil + } + + return challenge.NormalizeGraceSeconds(repo.ConfigInt("gitreal.graceSeconds", challenge.DefaultGraceSeconds)), nil +} + +func parseGraceSeconds(args []string, stderr io.Writer) (int, bool, error) { + fs := flag.NewFlagSet("git-real", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + graceSeconds := fs.Int("grace-seconds", challenge.DefaultGraceSeconds, "challenge window in seconds") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "git-real: %v\n", err) + return 0, false, err + } + + if fs.NArg() != 0 { + err := fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) + fmt.Fprintf(stderr, "git-real: %v\n", err) + return 0, false, err + } + + explicit := false + fs.Visit(func(flag *flag.Flag) { + if flag.Name == "grace-seconds" { + explicit = true + } + }) + + return challenge.NormalizeGraceSeconds(*graceSeconds), explicit, nil +} + +func nextRandomSlot(base time.Time, rng *rand.Rand) time.Time { + windowStart := base.Truncate(time.Hour) + offset := time.Duration(rng.Intn(3600)) * time.Second + slot := windowStart.Add(offset) + if !slot.After(base) { + slot = windowStart.Add(time.Hour + time.Duration(rng.Intn(3600))*time.Second) + } + + return slot +} + +func printHelp(w io.Writer) { + fmt.Fprintln(w, `git-real - BeReal-inspired punishment CLI for Git + +Usage: + git real init + git real status + git real once [--grace-seconds=120] + git real start [--grace-seconds=120] + git real arm + git real disarm + git real rescue list + git real rescue restore `) +} + +func (a *app) fail(err error) int { + fmt.Fprintf(a.stderr, "git-real: %v\n", err) + return 1 +} + +func (a *app) requireInitialized(repo repository) error { + if repo.ConfigBool("gitreal.enabled", false) { + return nil + } + + return fmt.Errorf("repository is not initialized for GitReal; run: git real init") +} diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go new file mode 100644 index 0000000..c96b4f6 --- /dev/null +++ b/internal/cli/app_test.go @@ -0,0 +1,944 @@ +package cli + +import ( + "bytes" + "errors" + "math/rand" + "strings" + "testing" + "time" +) + +type fakeRepo struct { + root string + configBools map[string]bool + configInts map[string]int + currentBranch string + currentBranchErr error + upstream string + upstreamErr error + aheadCounts []int + aheadErr error + fetchErrors []error + backupRef string + backupErr error + stashDirty bool + stashErr error + stashPopErr error + rescueRefs []string + rescueErr error + resetErr error + setBoolErr error + setIntErr error + + setBoolCalls map[string]bool + setIntCalls map[string]int + backupCalls []string + resetCalls []string + fetchCalls int + stashMessages []string +} + +func (f *fakeRepo) Root() string { return f.root } + +func (f *fakeRepo) SetConfigBool(key string, value bool) error { + if f.setBoolErr != nil { + return f.setBoolErr + } + if f.setBoolCalls == nil { + f.setBoolCalls = map[string]bool{} + } + f.setBoolCalls[key] = value + if f.configBools == nil { + f.configBools = map[string]bool{} + } + f.configBools[key] = value + return nil +} + +func (f *fakeRepo) SetConfigInt(key string, value int) error { + if f.setIntErr != nil { + return f.setIntErr + } + if f.setIntCalls == nil { + f.setIntCalls = map[string]int{} + } + f.setIntCalls[key] = value + if f.configInts == nil { + f.configInts = map[string]int{} + } + f.configInts[key] = value + return nil +} + +func (f *fakeRepo) ConfigBool(key string, fallback bool) bool { + if value, ok := f.configBools[key]; ok { + return value + } + return fallback +} + +func (f *fakeRepo) ConfigInt(key string, fallback int) int { + if value, ok := f.configInts[key]; ok { + return value + } + return fallback +} + +func (f *fakeRepo) CurrentBranch() (string, error) { + if f.currentBranchErr != nil { + return "", f.currentBranchErr + } + return f.currentBranch, nil +} + +func (f *fakeRepo) Upstream() (string, error) { + if f.upstreamErr != nil { + return "", f.upstreamErr + } + return f.upstream, nil +} + +func (f *fakeRepo) FetchQuiet() error { + index := f.fetchCalls + f.fetchCalls++ + if index < len(f.fetchErrors) { + return f.fetchErrors[index] + } + return nil +} + +func (f *fakeRepo) AheadCount() (int, error) { + if f.aheadErr != nil { + return 0, f.aheadErr + } + if len(f.aheadCounts) == 0 { + return 0, nil + } + value := f.aheadCounts[0] + if len(f.aheadCounts) > 1 { + f.aheadCounts = f.aheadCounts[1:] + } + return value, nil +} + +func (f *fakeRepo) BackupHead(branch string, now time.Time) (string, error) { + f.backupCalls = append(f.backupCalls, branch) + if f.backupErr != nil { + return "", f.backupErr + } + if f.backupRef != "" { + return f.backupRef, nil + } + return "refs/gitreal/backups/" + branch + "/" + now.UTC().Format("20060102T150405Z"), nil +} + +func (f *fakeRepo) StashDirtyWorktree(message string) (bool, error) { + f.stashMessages = append(f.stashMessages, message) + if f.stashErr != nil { + return false, f.stashErr + } + return f.stashDirty, nil +} + +func (f *fakeRepo) StashPop() error { + return f.stashPopErr +} + +func (f *fakeRepo) ResetHard(ref string) error { + f.resetCalls = append(f.resetCalls, ref) + return f.resetErr +} + +func (f *fakeRepo) RescueRefs() ([]string, error) { + if f.rescueErr != nil { + return nil, f.rescueErr + } + return f.rescueRefs, nil +} + +type fakeClock struct { + current time.Time +} + +func (f *fakeClock) now() time.Time { + return f.current +} + +func (f *fakeClock) sleep(duration time.Duration) { + f.current = f.current.Add(duration) +} + +func newTestApp(repo repository) (*app, *bytes.Buffer, *bytes.Buffer, *fakeClock, *[]string) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + clock := &fakeClock{ + current: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC), + } + notifications := []string{} + + testApp := &app{ + discoverRepo: func(path string) (repository, error) { + return repo, nil + }, + now: clock.now, + sleep: clock.sleep, + sendNotification: func(title, message string) error { + notifications = append(notifications, title+": "+message) + return nil + }, + rng: rand.New(rand.NewSource(1)), + stdout: stdout, + stderr: stderr, + } + + return testApp, stdout, stderr, clock, ¬ifications +} + +func TestTopLevelRunAndNewApp(t *testing.T) { + t.Parallel() + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + if got := Run([]string{"help"}, stdout, stderr); got != 0 { + t.Fatalf("Run(help) = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "git real once") { + t.Fatalf("stdout = %q, want help output", stdout.String()) + } + + app := newApp(stdout, stderr) + if app == nil || app.rng == nil || app.now == nil || app.sleep == nil { + t.Fatalf("newApp() returned incomplete app: %#v", app) + } + + if _, err := app.discoverRepo("."); err != nil { + t.Fatalf("discoverRepo(.) error = %v", err) + } +} + +func TestRunHelpAndUnknown(t *testing.T) { + t.Parallel() + + app, stdout, stderr, _, _ := newTestApp(&fakeRepo{}) + + if got := app.run(nil); got != 0 { + t.Fatalf("run(nil) = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "Usage:") { + t.Fatalf("stdout = %q, want help output", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + if got := app.run([]string{"wat"}); got != 2 { + t.Fatalf("run(unknown) = %d, want 2", got) + } + if !strings.Contains(stderr.String(), "unknown command: wat") { + t.Fatalf("stderr = %q, want unknown command", stderr.String()) + } +} + +func TestInitArmDisarmAndStatus(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{}, + configInts: map[string]int{}, + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{2}, + } + app, stdout, _, _, _ := newTestApp(repo) + + if got := app.run([]string{"init"}); got != 0 { + t.Fatalf("init exit code = %d, want 0", got) + } + if !repo.setBoolCalls["gitreal.enabled"] || repo.setBoolCalls["gitreal.armed"] { + t.Fatalf("init config writes = %#v", repo.setBoolCalls) + } + if repo.setIntCalls["gitreal.graceSeconds"] != 120 { + t.Fatalf("graceSeconds = %d, want 120", repo.setIntCalls["gitreal.graceSeconds"]) + } + + stdout.Reset() + if got := app.run([]string{"arm"}); got != 0 { + t.Fatalf("arm exit code = %d, want 0", got) + } + if !repo.configBools["gitreal.armed"] { + t.Fatalf("armed config = false, want true") + } + + stdout.Reset() + if got := app.run([]string{"disarm"}); got != 0 { + t.Fatalf("disarm exit code = %d, want 0", got) + } + if repo.configBools["gitreal.armed"] { + t.Fatalf("armed config = true, want false") + } + + stdout.Reset() + if got := app.run([]string{"status"}); got != 0 { + t.Fatalf("status exit code = %d, want 0", got) + } + + statusOutput := stdout.String() + for _, want := range []string{ + "repo: /tmp/repo", + "enabled: true", + "armed: false", + "grace-seconds: 120", + "branch: main", + "upstream: origin/main", + "ahead: 2", + } { + if !strings.Contains(statusOutput, want) { + t.Fatalf("status output = %q, want substring %q", statusOutput, want) + } + } +} + +func TestStatusWithoutUpstream(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": false, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 90}, + currentBranch: "main", + upstreamErr: errors.New("missing"), + } + app, stdout, _, _, _ := newTestApp(repo) + + if got := app.run([]string{"status"}); got != 0 { + t.Fatalf("status exit code = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "upstream: ") || !strings.Contains(stdout.String(), "ahead: unknown") { + t.Fatalf("stdout = %q, want missing upstream markers", stdout.String()) + } +} + +func TestInitIsIdempotentAndResetsMode(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": true}, + configInts: map[string]int{"gitreal.graceSeconds": 30}, + } + app, _, _, _, _ := newTestApp(repo) + + if got := app.run([]string{"init"}); got != 0 { + t.Fatalf("first init exit code = %d, want 0", got) + } + if got := app.run([]string{"init"}); got != 0 { + t.Fatalf("second init exit code = %d, want 0", got) + } + if !repo.configBools["gitreal.enabled"] { + t.Fatalf("enabled config = false, want true") + } + if repo.configBools["gitreal.armed"] { + t.Fatalf("armed config = true, want false") + } + if repo.configInts["gitreal.graceSeconds"] != 120 { + t.Fatalf("graceSeconds = %d, want 120", repo.configInts["gitreal.graceSeconds"]) + } +} + +func TestResolveGraceSeconds(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + configInts: map[string]int{"gitreal.graceSeconds": 30}, + } + stderr := new(bytes.Buffer) + + got, err := resolveGraceSeconds(nil, repo, stderr) + if err != nil || got != 30 { + t.Fatalf("resolveGraceSeconds(nil) = %d, %v, want 30, nil", got, err) + } + + got, err = resolveGraceSeconds([]string{"--grace-seconds=7200"}, repo, stderr) + if err != nil || got != 3600 { + t.Fatalf("resolveGraceSeconds(explicit) = %d, %v, want 3600, nil", got, err) + } + + if _, err := resolveGraceSeconds([]string{"--grace-seconds=nope"}, repo, stderr); err == nil { + t.Fatalf("resolveGraceSeconds(invalid) error = nil, want non-nil") + } + if _, err := resolveGraceSeconds([]string{"extra"}, repo, stderr); err == nil { + t.Fatalf("resolveGraceSeconds(extra) error = nil, want non-nil") + } +} + +func TestCommandOnceViaRun(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 15}, + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{0}, + } + app, stdout, _, _, _ := newTestApp(repo) + + if got := app.run([]string{"once"}); got != 0 { + t.Fatalf("once exit code = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "nothing to do") { + t.Fatalf("stdout = %q, want once output", stdout.String()) + } +} + +func TestCommandParseFailures(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 15}, + currentBranch: "main", + upstream: "origin/main", + } + app, _, stderr, _, _ := newTestApp(repo) + + if got := app.run([]string{"once", "--grace-seconds=nope"}); got != 2 { + t.Fatalf("once invalid exit code = %d, want 2", got) + } + if !strings.Contains(stderr.String(), "invalid value") { + t.Fatalf("stderr = %q, want parse error", stderr.String()) + } + + stderr.Reset() + if got := app.run([]string{"start", "extra"}); got != 2 { + t.Fatalf("start invalid exit code = %d, want 2", got) + } +} + +func TestRunChallengeNoUnpushedCommits(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{0}, + } + app, stdout, _, _, notifications := newTestApp(repo) + + if err := app.runChallenge(repo, 120, false); err != nil { + t.Fatalf("runChallenge() error = %v", err) + } + if repo.fetchCalls != 1 { + t.Fatalf("fetchCalls = %d, want 1", repo.fetchCalls) + } + if !strings.Contains(stdout.String(), "nothing to do: no unpushed commits") { + t.Fatalf("stdout = %q, want nothing to do", stdout.String()) + } + if len(*notifications) != 1 || !strings.Contains((*notifications)[0], "No unpushed commits") { + t.Fatalf("notifications = %v, want no-unpushed message", *notifications) + } +} + +func TestRunChallengeDryRun(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{2, 2}, + } + app, stdout, _, clock, notifications := newTestApp(repo) + + if err := app.runChallenge(repo, 120, false); err != nil { + t.Fatalf("runChallenge() error = %v", err) + } + if !strings.Contains(stdout.String(), "dry-run: would reset 2 commit(s) to @{u}") { + t.Fatalf("stdout = %q, want dry-run message", stdout.String()) + } + if clock.current != time.Date(2026, 5, 1, 12, 2, 0, 0, time.UTC) { + t.Fatalf("clock.current = %s, want 2 minutes later", clock.current) + } + if len(*notifications) != 2 { + t.Fatalf("notifications = %v, want 2 notifications", *notifications) + } +} + +func TestRunChallengePushConfirmed(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{1, 0}, + } + app, stdout, _, _, notifications := newTestApp(repo) + + if err := app.runChallenge(repo, 30, false); err != nil { + t.Fatalf("runChallenge() error = %v", err) + } + if !strings.Contains(stdout.String(), "push confirmed") { + t.Fatalf("stdout = %q, want push confirmed", stdout.String()) + } + if len(*notifications) != 2 || !strings.Contains((*notifications)[1], "Push confirmed") { + t.Fatalf("notifications = %v, want push confirmed", *notifications) + } +} + +func TestRunChallengePreflightFetchFailureContinues(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{1, 0}, + fetchErrors: []error{errors.New("fetch failed")}, + } + app, stdout, _, _, _ := newTestApp(repo) + + if err := app.runChallenge(repo, 30, false); err != nil { + t.Fatalf("runChallenge() error = %v", err) + } + if !strings.Contains(stdout.String(), "preflight fetch failed; continuing with last known upstream state: fetch failed") { + t.Fatalf("stdout = %q, want preflight fetch warning", stdout.String()) + } + if !strings.Contains(stdout.String(), "push confirmed") { + t.Fatalf("stdout = %q, want push confirmed", stdout.String()) + } +} + +func TestRunChallengeFetchFailureSkipsPunishment(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{1}, + fetchErrors: []error{nil, errors.New("fetch failed")}, + } + app, stdout, _, _, _ := newTestApp(repo) + + if err := app.runChallenge(repo, 30, true); err != nil { + t.Fatalf("runChallenge() error = %v", err) + } + if len(repo.resetCalls) != 0 { + t.Fatalf("resetCalls = %v, want none", repo.resetCalls) + } + if !strings.Contains(stdout.String(), "punishment skipped for safety") { + t.Fatalf("stdout = %q, want skip message", stdout.String()) + } +} + +func TestRunChallengeArmed(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{2, 2}, + backupRef: "refs/gitreal/backups/main/20260501T120200Z", + stashDirty: true, + } + app, stdout, _, _, notifications := newTestApp(repo) + + if err := app.runChallenge(repo, 120, true); err != nil { + t.Fatalf("runChallenge() error = %v", err) + } + if len(repo.resetCalls) != 1 || repo.resetCalls[0] != "@{u}" { + t.Fatalf("resetCalls = %v, want [@{u}]", repo.resetCalls) + } + if len(repo.stashMessages) != 1 || !strings.Contains(repo.stashMessages[0], repo.backupRef) { + t.Fatalf("stashMessages = %v, want backup ref", repo.stashMessages) + } + if !strings.Contains(stdout.String(), "restore: git real rescue restore "+repo.backupRef) { + t.Fatalf("stdout = %q, want restore message", stdout.String()) + } + if len(*notifications) != 2 || !strings.Contains((*notifications)[1], "Local commits made unreal") { + t.Fatalf("notifications = %v, want punishment message", *notifications) + } +} + +func TestRunChallengeStashPopFailure(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{1, 1}, + backupRef: "refs/gitreal/backups/main/1", + stashDirty: true, + stashPopErr: errors.New("conflict"), + } + app, stdout, _, _, _ := newTestApp(repo) + + if err := app.runChallenge(repo, 1, true); err != nil { + t.Fatalf("runChallenge() error = %v", err) + } + if !strings.Contains(stdout.String(), "stash pop failed") { + t.Fatalf("stdout = %q, want stash pop warning", stdout.String()) + } +} + +func TestRunChallengeErrors(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + repo *fakeRepo + }{ + { + name: "branch error", + repo: &fakeRepo{currentBranchErr: errors.New("detached")}, + }, + { + name: "upstream error", + repo: &fakeRepo{currentBranch: "main", upstreamErr: errors.New("no upstream")}, + }, + { + name: "ahead error", + repo: &fakeRepo{currentBranch: "main", upstream: "origin/main", aheadErr: errors.New("boom")}, + }, + { + name: "backup error", + repo: &fakeRepo{currentBranch: "main", upstream: "origin/main", aheadCounts: []int{1, 1}, backupErr: errors.New("boom")}, + }, + { + name: "stash error", + repo: &fakeRepo{currentBranch: "main", upstream: "origin/main", aheadCounts: []int{1, 1}, backupRef: "refs/gitreal/backups/main/1", stashErr: errors.New("boom")}, + }, + { + name: "reset error", + repo: &fakeRepo{currentBranch: "main", upstream: "origin/main", aheadCounts: []int{1, 1}, backupRef: "refs/gitreal/backups/main/1", resetErr: errors.New("boom")}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + app, _, _, _, _ := newTestApp(tc.repo) + if err := app.runChallenge(tc.repo, 1, true); err == nil { + t.Fatalf("runChallenge() error = nil, want non-nil") + } + }) + } +} + +func TestNotifyFallback(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{} + app, stdout, _, _, _ := newTestApp(repo) + app.sendNotification = func(title, message string) error { + return errors.New("unsupported") + } + + app.notify("GitReal", "hello") + if !strings.Contains(stdout.String(), "notification: GitReal: hello") { + t.Fatalf("stdout = %q, want notification fallback", stdout.String()) + } +} + +func TestRescueCommands(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + currentBranch: "main", + backupRef: "refs/gitreal/backups/main/current", + rescueRefs: []string{ + "refs/gitreal/backups/main/1", + "refs/gitreal/backups/main/2", + }, + } + app, stdout, stderr, _, _ := newTestApp(repo) + + if got := app.run([]string{"rescue", "list"}); got != 0 { + t.Fatalf("rescue list exit code = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "refs/gitreal/backups/main/1") { + t.Fatalf("stdout = %q, want rescue refs", stdout.String()) + } + + stdout.Reset() + if got := app.run([]string{"rescue", "restore", "refs/gitreal/backups/main/1"}); got != 0 { + t.Fatalf("rescue restore exit code = %d, want 0", got) + } + if len(repo.resetCalls) != 1 || repo.resetCalls[0] != "refs/gitreal/backups/main/1" { + t.Fatalf("resetCalls = %v, want restore ref", repo.resetCalls) + } + if !strings.Contains(stdout.String(), "Current branch reset to backup ref: refs/gitreal/backups/main/1") { + t.Fatalf("stdout = %q, want restore success message", stdout.String()) + } + if !strings.Contains(stdout.String(), "previous HEAD backed up to: "+repo.backupRef) { + t.Fatalf("stdout = %q, want current HEAD backup message", stdout.String()) + } + if len(repo.backupCalls) != 1 || repo.backupCalls[0] != "main" { + t.Fatalf("backupCalls = %v, want current branch backup", repo.backupCalls) + } + + stdout.Reset() + stderr.Reset() + if got := app.run([]string{"rescue", "restore", "refs/heads/main"}); got != 2 { + t.Fatalf("rescue restore invalid exit code = %d, want 2", got) + } + if !strings.Contains(stderr.String(), "ref must start with refs/gitreal/backups/") { + t.Fatalf("stderr = %q, want backup prefix error", stderr.String()) + } +} + +func TestRescueRestoreStashPopFailure(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + currentBranch: "main", + backupRef: "refs/gitreal/backups/main/current", + stashDirty: true, + stashPopErr: errors.New("conflict"), + } + app, stdout, _, _, _ := newTestApp(repo) + + if got := app.run([]string{"rescue", "restore", "refs/gitreal/backups/main/1"}); got != 0 { + t.Fatalf("rescue restore exit code = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "stash pop failed") { + t.Fatalf("stdout = %q, want stash pop warning", stdout.String()) + } + if len(repo.stashMessages) != 1 || !strings.Contains(repo.stashMessages[0], repo.backupRef) { + t.Fatalf("stashMessages = %v, want backup ref in stash message", repo.stashMessages) + } +} + +func TestRescueCommandErrors(t *testing.T) { + t.Parallel() + + app, _, stderr, _, _ := newTestApp(&fakeRepo{}) + if got := app.run([]string{"rescue"}); got != 2 { + t.Fatalf("rescue exit code = %d, want 2", got) + } + if !strings.Contains(stderr.String(), "expected subcommand list or restore ") { + t.Fatalf("stderr = %q, want rescue usage", stderr.String()) + } + + stderr.Reset() + if got := app.run([]string{"rescue", "wat"}); got != 2 { + t.Fatalf("rescue unknown exit code = %d, want 2", got) + } + if !strings.Contains(stderr.String(), "unknown subcommand: wat") { + t.Fatalf("stderr = %q, want unknown subcommand", stderr.String()) + } + + stderr.Reset() + if got := app.run([]string{"rescue", "list", "extra"}); got != 2 { + t.Fatalf("rescue list exit code = %d, want 2", got) + } + + stderr.Reset() + if got := app.run([]string{"rescue", "restore"}); got != 2 { + t.Fatalf("rescue restore exit code = %d, want 2", got) + } +} + +func TestCommandStartSingleIteration(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 45}, + currentBranch: "main", + upstream: "origin/main", + aheadCounts: []int{0}, + } + app, stdout, _, _, _ := newTestApp(repo) + app.startIterations = 1 + + if got := app.run([]string{"start"}); got != 0 { + t.Fatalf("start exit code = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "GitReal started for /tmp/repo") || !strings.Contains(stdout.String(), "next challenge:") { + t.Fatalf("stdout = %q, want start output", stdout.String()) + } +} + +func TestCommandStartHandlesChallengeError(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": true, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 45}, + currentBranchErr: errors.New("detached"), + } + app, _, stderr, _, _ := newTestApp(repo) + app.startIterations = 1 + + if got := app.run([]string{"start"}); got != 0 { + t.Fatalf("start exit code = %d, want 0", got) + } + if !strings.Contains(stderr.String(), "detached") { + t.Fatalf("stderr = %q, want challenge error", stderr.String()) + } +} + +func TestNextRandomSlot(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(1)) + base := time.Date(2026, 5, 1, 12, 15, 0, 0, time.UTC) + slot := nextRandomSlot(base, rng) + + if !slot.After(base) { + t.Fatalf("nextRandomSlot() = %s, want after %s", slot, base) + } + if slot.After(base.Add(2 * time.Hour)) { + t.Fatalf("nextRandomSlot() = %s, want within two hours", slot) + } + + base = time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + slot = nextRandomSlot(base, rand.New(rand.NewSource(2))) + if !slot.After(base) { + t.Fatalf("nextRandomSlot() with hour boundary = %s, want after %s", slot, base) + } +} + +func TestCommandFailures(t *testing.T) { + t.Parallel() + + discoverErr := errors.New("not inside a Git repository") + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + app := &app{ + discoverRepo: func(path string) (repository, error) { + return nil, discoverErr + }, + now: time.Now, + sleep: time.Sleep, + sendNotification: func(title, message string) error { return nil }, + rng: rand.New(rand.NewSource(1)), + stdout: stdout, + stderr: stderr, + } + + for _, args := range [][]string{{"init"}, {"status"}, {"once"}, {"start"}, {"arm"}, {"disarm"}, {"rescue", "list"}} { + stdout.Reset() + stderr.Reset() + if got := app.run(args); got != 1 { + t.Fatalf("run(%v) = %d, want 1", args, got) + } + if !strings.Contains(stderr.String(), "not inside a Git repository") { + t.Fatalf("stderr = %q, want discover error", stderr.String()) + } + } +} + +func TestCommandsRequireInitialization(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + args []string + }{ + {name: "once", args: []string{"once"}}, + {name: "start", args: []string{"start"}}, + {name: "arm", args: []string{"arm"}}, + {name: "disarm", args: []string{"disarm"}}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": false, "gitreal.armed": false}, + } + app, _, stderr, _, _ := newTestApp(repo) + + if got := app.run(tc.args); got != 1 { + t.Fatalf("run(%v) = %d, want 1", tc.args, got) + } + if !strings.Contains(stderr.String(), "repository is not initialized for GitReal; run: git real init") { + t.Fatalf("stderr = %q, want initialization guidance", stderr.String()) + } + }) + } +} + +func TestStatusAndRescueWorkWithoutInitialization(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{ + root: "/tmp/repo", + configBools: map[string]bool{"gitreal.enabled": false, "gitreal.armed": false}, + configInts: map[string]int{"gitreal.graceSeconds": 120}, + currentBranch: "main", + upstreamErr: errors.New("missing"), + rescueRefs: []string{"refs/gitreal/backups/main/1"}, + } + app, stdout, stderr, _, _ := newTestApp(repo) + + if got := app.run([]string{"status"}); got != 0 { + t.Fatalf("status exit code = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "enabled: false") { + t.Fatalf("stdout = %q, want disabled status", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + if got := app.run([]string{"rescue", "list"}); got != 0 { + t.Fatalf("rescue list exit code = %d, want 0", got) + } + if !strings.Contains(stdout.String(), "refs/gitreal/backups/main/1") { + t.Fatalf("stdout = %q, want rescue refs", stdout.String()) + } +} + +func TestConfigWriteFailures(t *testing.T) { + t.Parallel() + + repo := &fakeRepo{setBoolErr: errors.New("boom")} + app, _, stderr, _, _ := newTestApp(repo) + if got := app.run([]string{"init"}); got != 1 { + t.Fatalf("init exit code = %d, want 1", got) + } + if !strings.Contains(stderr.String(), "boom") { + t.Fatalf("stderr = %q, want config error", stderr.String()) + } + + repo = &fakeRepo{setIntErr: errors.New("boom")} + app, _, stderr, _, _ = newTestApp(repo) + if got := app.commandInit(); got != 1 { + t.Fatalf("commandInit exit code = %d, want 1", got) + } + if !strings.Contains(stderr.String(), "boom") { + t.Fatalf("stderr = %q, want config error", stderr.String()) + } + + repo = &fakeRepo{ + configBools: map[string]bool{"gitreal.enabled": true}, + setBoolErr: errors.New("boom"), + } + app, _, stderr, _, _ = newTestApp(repo) + if got := app.run([]string{"arm"}); got != 1 { + t.Fatalf("arm exit code = %d, want 1", got) + } + if !strings.Contains(stderr.String(), "boom") { + t.Fatalf("stderr = %q, want config error", stderr.String()) + } + stderr.Reset() + if got := app.run([]string{"disarm"}); got != 1 { + t.Fatalf("disarm exit code = %d, want 1", got) + } + if !strings.Contains(stderr.String(), "boom") { + t.Fatalf("stderr = %q, want config error", stderr.String()) + } +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..49fc419 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,205 @@ +package git + +import ( + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +const backupRefPrefix = "refs/gitreal/backups/" + +type runner interface { + run(dir string, args ...string) (string, error) +} + +type commandRunner struct{} + +func (commandRunner) run(dir string, args ...string) (string, error) { + fullArgs := append([]string{"-C", dir}, args...) + cmd := exec.Command("git", fullArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(out))) + } + + return string(out), nil +} + +type Repository struct { + root string + runner runner +} + +func Discover(path string) (*Repository, error) { + return discover(path, commandRunner{}) +} + +func discover(path string, r runner) (*Repository, error) { + out, err := r.run(path, "rev-parse", "--show-toplevel") + if err != nil { + return nil, fmt.Errorf("not inside a Git repository") + } + + return &Repository{ + root: strings.TrimSpace(out), + runner: r, + }, nil +} + +func (r *Repository) Root() string { + return r.root +} + +func (r *Repository) SetConfigBool(key string, value bool) error { + boolValue := "false" + if value { + boolValue = "true" + } + + _, err := r.run("config", "--local", key, boolValue) + return err +} + +func (r *Repository) SetConfigInt(key string, value int) error { + _, err := r.run("config", "--local", key, strconv.Itoa(value)) + return err +} + +func (r *Repository) ConfigBool(key string, fallback bool) bool { + out, err := r.run("config", "--bool", "--get", key) + if err != nil { + return fallback + } + + switch strings.ToLower(strings.TrimSpace(out)) { + case "true", "yes", "on", "1": + return true + case "false", "no", "off", "0": + return false + default: + return fallback + } +} + +func (r *Repository) ConfigInt(key string, fallback int) int { + out, err := r.run("config", "--int", "--get", key) + if err != nil { + return fallback + } + + value, err := strconv.Atoi(strings.TrimSpace(out)) + if err != nil { + return fallback + } + + return value +} + +func (r *Repository) CurrentBranch() (string, error) { + out, err := r.run("symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil { + return "", fmt.Errorf("detached HEAD is not supported") + } + + return strings.TrimSpace(out), nil +} + +func (r *Repository) Upstream() (string, error) { + out, err := r.run("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + if err != nil { + return "", fmt.Errorf("no upstream configured; run: git push -u origin HEAD") + } + + return strings.TrimSpace(out), nil +} + +func (r *Repository) FetchQuiet() error { + _, err := r.run("fetch", "--quiet", "--prune") + return err +} + +func (r *Repository) AheadCount() (int, error) { + out, err := r.run("rev-list", "--count", "@{u}..HEAD") + if err != nil { + return 0, err + } + + value := strings.TrimSpace(out) + if value == "" { + return 0, nil + } + + count, err := strconv.Atoi(value) + if err != nil { + return 0, err + } + + return count, nil +} + +func (r *Repository) BackupHead(branch string, now time.Time) (string, error) { + safeBranch := strings.ReplaceAll(branch, string(filepath.Separator), "-") + safeBranch = strings.ReplaceAll(safeBranch, "/", "-") + + timestamp := fmt.Sprintf("%s-%09d", now.UTC().Format("20060102T150405Z"), now.UTC().Nanosecond()) + backupRef := fmt.Sprintf("%s%s/%s", backupRefPrefix, safeBranch, timestamp) + _, err := r.run("update-ref", backupRef, "HEAD") + if err != nil { + return "", err + } + + return backupRef, nil +} + +func (r *Repository) StashDirtyWorktree(message string) (bool, error) { + out, err := r.run("status", "--porcelain=v1", "-z") + if err != nil { + return false, err + } + + if out == "" { + return false, nil + } + + _, err = r.run("stash", "push", "--include-untracked", "--message", message) + if err != nil { + return false, err + } + + return true, nil +} + +func (r *Repository) StashPop() error { + _, err := r.run("stash", "pop") + return err +} + +func (r *Repository) ResetHard(ref string) error { + _, err := r.run("reset", "--hard", ref) + return err +} + +func (r *Repository) RescueRefs() ([]string, error) { + out, err := r.run("for-each-ref", backupRefPrefix, "--format=%(refname)") + if err != nil { + return nil, err + } + + text := strings.TrimSpace(out) + if text == "" { + return nil, nil + } + + return strings.Split(text, "\n"), nil +} + +func IsBackupRef(ref string) bool { + return strings.HasPrefix(ref, backupRefPrefix) +} + +func (r *Repository) run(args ...string) (string, error) { + return r.runner.run(r.root, args...) +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..bc52a95 --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,509 @@ +package git + +import ( + "errors" + "os" + "os/exec" + "testing" + "time" +) + +type fakeRunner struct { + responses map[string]fakeResponse + calls []string +} + +type fakeResponse struct { + output string + err error +} + +func (f *fakeRunner) run(dir string, args ...string) (string, error) { + key := dir + "|" + joinArgs(args) + f.calls = append(f.calls, key) + + response, ok := f.responses[key] + if !ok { + return "", errors.New("unexpected command") + } + + return response.output, response.err +} + +func joinArgs(args []string) string { + result := "" + for i, arg := range args { + if i > 0 { + result += "\x00" + } + result += arg + } + return result +} + +func TestDiscover(t *testing.T) { + t.Parallel() + + runner := &fakeRunner{ + responses: map[string]fakeResponse{ + ".|rev-parse\x00--show-toplevel": { + output: "/tmp/repo\n", + }, + }, + } + + repo, err := discover(".", runner) + if err != nil { + t.Fatalf("discover() error = %v", err) + } + + if got := repo.Root(); got != "/tmp/repo" { + t.Fatalf("Root() = %q, want /tmp/repo", got) + } +} + +func TestDiscoverOutsideRepository(t *testing.T) { + t.Parallel() + + runner := &fakeRunner{ + responses: map[string]fakeResponse{ + ".|rev-parse\x00--show-toplevel": { + err: errors.New("boom"), + }, + }, + } + + _, err := discover(".", runner) + if err == nil || err.Error() != "not inside a Git repository" { + t.Fatalf("discover() error = %v, want not inside a Git repository", err) + } +} + +func TestConfigAccessors(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|config\x00--bool\x00--get\x00gitreal.enabled": { + output: "true\n", + }, + "/tmp/repo|config\x00--bool\x00--get\x00gitreal.armed": { + output: "off\n", + }, + "/tmp/repo|config\x00--bool\x00--get\x00gitreal.unknown": { + output: "wat\n", + }, + "/tmp/repo|config\x00--bool\x00--get\x00gitreal.missing": { + err: errors.New("missing"), + }, + "/tmp/repo|config\x00--int\x00--get\x00gitreal.graceSeconds": { + output: "90\n", + }, + "/tmp/repo|config\x00--int\x00--get\x00gitreal.bad": { + output: "abc\n", + }, + "/tmp/repo|config\x00--int\x00--get\x00gitreal.missingInt": { + err: errors.New("missing"), + }, + }, + }, + } + + if !repo.ConfigBool("gitreal.enabled", false) { + t.Fatalf("ConfigBool(enabled) = false, want true") + } + if repo.ConfigBool("gitreal.armed", true) { + t.Fatalf("ConfigBool(armed) = true, want false") + } + if !repo.ConfigBool("gitreal.unknown", true) { + t.Fatalf("ConfigBool(unknown) = false, want fallback true") + } + if repo.ConfigBool("gitreal.missing", false) { + t.Fatalf("ConfigBool(missing) = true, want fallback false") + } + + if got := repo.ConfigInt("gitreal.graceSeconds", 120); got != 90 { + t.Fatalf("ConfigInt(graceSeconds) = %d, want 90", got) + } + if got := repo.ConfigInt("gitreal.bad", 120); got != 120 { + t.Fatalf("ConfigInt(bad) = %d, want fallback 120", got) + } + if got := repo.ConfigInt("gitreal.missingInt", 45); got != 45 { + t.Fatalf("ConfigInt(missingInt) = %d, want fallback 45", got) + } +} + +func TestSetConfig(t *testing.T) { + t.Parallel() + + runner := &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|config\x00--local\x00gitreal.enabled\x00true": {}, + "/tmp/repo|config\x00--local\x00gitreal.enabled\x00false": {}, + "/tmp/repo|config\x00--local\x00gitreal.graceSeconds\x0045": {}, + "/tmp/repo|config\x00--local\x00gitreal.graceSeconds\x00120": {}, + }, + } + repo := &Repository{root: "/tmp/repo", runner: runner} + + if err := repo.SetConfigBool("gitreal.enabled", true); err != nil { + t.Fatalf("SetConfigBool(true) error = %v", err) + } + if err := repo.SetConfigBool("gitreal.enabled", false); err != nil { + t.Fatalf("SetConfigBool(false) error = %v", err) + } + if err := repo.SetConfigInt("gitreal.graceSeconds", 45); err != nil { + t.Fatalf("SetConfigInt(45) error = %v", err) + } + if err := repo.SetConfigInt("gitreal.graceSeconds", 120); err != nil { + t.Fatalf("SetConfigInt(120) error = %v", err) + } +} + +func TestBranchAndUpstream(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|symbolic-ref\x00--quiet\x00--short\x00HEAD": { + output: "main\n", + }, + "/tmp/repo|rev-parse\x00--abbrev-ref\x00--symbolic-full-name\x00@{u}": { + output: "origin/main\n", + }, + }, + }, + } + + branch, err := repo.CurrentBranch() + if err != nil || branch != "main" { + t.Fatalf("CurrentBranch() = %q, %v", branch, err) + } + + upstream, err := repo.Upstream() + if err != nil || upstream != "origin/main" { + t.Fatalf("Upstream() = %q, %v", upstream, err) + } +} + +func TestBranchAndUpstreamErrors(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|symbolic-ref\x00--quiet\x00--short\x00HEAD": { + err: errors.New("detached"), + }, + "/tmp/repo|rev-parse\x00--abbrev-ref\x00--symbolic-full-name\x00@{u}": { + err: errors.New("missing"), + }, + }, + }, + } + + if _, err := repo.CurrentBranch(); err == nil || err.Error() != "detached HEAD is not supported" { + t.Fatalf("CurrentBranch() error = %v", err) + } + + if _, err := repo.Upstream(); err == nil || err.Error() != "no upstream configured; run: git push -u origin HEAD" { + t.Fatalf("Upstream() error = %v", err) + } +} + +func TestAheadCount(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|rev-list\x00--count\x00@{u}..HEAD": { + output: "3\n", + }, + }, + }, + } + + got, err := repo.AheadCount() + if err != nil || got != 3 { + t.Fatalf("AheadCount() = %d, %v", got, err) + } +} + +func TestAheadCountEmptyAndInvalid(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|rev-list\x00--count\x00@{u}..HEAD": {}, + }, + }, + } + + got, err := repo.AheadCount() + if err != nil || got != 0 { + t.Fatalf("AheadCount() = %d, %v, want 0, nil", got, err) + } + + repo = &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|rev-list\x00--count\x00@{u}..HEAD": { + output: "abc\n", + }, + }, + }, + } + + if _, err := repo.AheadCount(); err == nil { + t.Fatalf("AheadCount() error = nil, want non-nil") + } +} + +func TestBackupHead(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + runner := &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|update-ref\x00refs/gitreal/backups/feature-test/20260501T120000Z-000000000\x00HEAD": {}, + "/tmp/repo|update-ref\x00refs/gitreal/backups/main/20260501T120000Z-000000000\x00HEAD": { + err: errors.New("boom"), + }, + }, + } + repo := &Repository{root: "/tmp/repo", runner: runner} + + ref, err := repo.BackupHead("feature/test", now) + if err != nil { + t.Fatalf("BackupHead() error = %v", err) + } + + want := "refs/gitreal/backups/feature-test/20260501T120000Z-000000000" + if ref != want { + t.Fatalf("BackupHead() = %q, want %q", ref, want) + } + + if _, err := repo.BackupHead("main", now); err == nil { + t.Fatalf("BackupHead() error = nil, want non-nil") + } +} + +func TestStashDirtyWorktree(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|status\x00--porcelain=v1\x00-z": { + output: " M file.txt\x00", + }, + "/tmp/repo|stash\x00push\x00--include-untracked\x00--message\x00save": {}, + }, + }, + } + + stashed, err := repo.StashDirtyWorktree("save") + if err != nil { + t.Fatalf("StashDirtyWorktree() error = %v", err) + } + if !stashed { + t.Fatalf("StashDirtyWorktree() = false, want true") + } +} + +func TestStashDirtyWorktreeEdgeCases(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|status\x00--porcelain=v1\x00-z": {}, + }, + }, + } + + stashed, err := repo.StashDirtyWorktree("save") + if err != nil { + t.Fatalf("StashDirtyWorktree(clean) error = %v", err) + } + if stashed { + t.Fatalf("StashDirtyWorktree(clean) = true, want false") + } + + repo = &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|status\x00--porcelain=v1\x00-z": { + err: errors.New("boom"), + }, + }, + }, + } + + if _, err := repo.StashDirtyWorktree("save"); err == nil { + t.Fatalf("StashDirtyWorktree(status) error = nil, want non-nil") + } + + repo = &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|status\x00--porcelain=v1\x00-z": { + output: " M file.txt\x00", + }, + "/tmp/repo|stash\x00push\x00--include-untracked\x00--message\x00save": { + err: errors.New("boom"), + }, + }, + }, + } + + if _, err := repo.StashDirtyWorktree("save"); err == nil { + t.Fatalf("StashDirtyWorktree(stash) error = nil, want non-nil") + } +} + +func TestResetRescueAndHelpers(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|reset\x00--hard\x00refs/gitreal/backups/main/1": {}, + "/tmp/repo|stash\x00pop": {}, + "/tmp/repo|fetch\x00--quiet\x00--prune": {}, + "/tmp/repo|for-each-ref\x00refs/gitreal/backups/\x00--format=%(refname)": { + output: "refs/gitreal/backups/main/1\nrefs/gitreal/backups/main/2\n", + }, + }, + }, + } + + if err := repo.ResetHard("refs/gitreal/backups/main/1"); err != nil { + t.Fatalf("ResetHard() error = %v", err) + } + if err := repo.StashPop(); err != nil { + t.Fatalf("StashPop() error = %v", err) + } + if err := repo.FetchQuiet(); err != nil { + t.Fatalf("FetchQuiet() error = %v", err) + } + + refs, err := repo.RescueRefs() + if err != nil { + t.Fatalf("RescueRefs() error = %v", err) + } + if len(refs) != 2 { + t.Fatalf("len(RescueRefs()) = %d, want 2", len(refs)) + } + if !IsBackupRef(refs[0]) || IsBackupRef("refs/heads/main") { + t.Fatalf("IsBackupRef() returned unexpected result") + } +} + +func TestRescueRefsEmptyAndError(t *testing.T) { + t.Parallel() + + repo := &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|for-each-ref\x00refs/gitreal/backups/\x00--format=%(refname)": {}, + }, + }, + } + + refs, err := repo.RescueRefs() + if err != nil { + t.Fatalf("RescueRefs() error = %v", err) + } + if refs != nil { + t.Fatalf("RescueRefs() = %v, want nil", refs) + } + + repo = &Repository{ + root: "/tmp/repo", + runner: &fakeRunner{ + responses: map[string]fakeResponse{ + "/tmp/repo|for-each-ref\x00refs/gitreal/backups/\x00--format=%(refname)": { + err: errors.New("boom"), + }, + }, + }, + } + + if _, err := repo.RescueRefs(); err == nil { + t.Fatalf("RescueRefs() error = nil, want non-nil") + } +} + +func TestDiscoverIntegration(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + runGit(t, tempDir, "init") + + repo, err := Discover(tempDir) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if repo.Root() != tempDir { + t.Fatalf("Root() = %q, want %q", repo.Root(), tempDir) + } + + if err := repo.SetConfigBool("gitreal.enabled", true); err != nil { + t.Fatalf("SetConfigBool() error = %v", err) + } + if err := repo.SetConfigInt("gitreal.graceSeconds", 15); err != nil { + t.Fatalf("SetConfigInt() error = %v", err) + } + if !repo.ConfigBool("gitreal.enabled", false) { + t.Fatalf("ConfigBool(enabled) = false, want true") + } + if got := repo.ConfigInt("gitreal.graceSeconds", 120); got != 15 { + t.Fatalf("ConfigInt(graceSeconds) = %d, want 15", got) + } +} + +func TestCommandRunnerRunError(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + runner := commandRunner{} + + _, err := runner.run(dir, "status") + if err == nil { + t.Fatalf("run() error = nil, want non-nil") + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + fullArgs := append([]string{"-C", dir}, args...) + cmd := exec.Command("git", fullArgs...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test User", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test User", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v: %s", args, err, output) + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..a362d81 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,64 @@ +package notify + +import ( + "bytes" + "encoding/xml" + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" +) + +var currentGOOS = runtime.GOOS + +var runCommand = func(name string, args ...string) error { + cmd := exec.Command(name, args...) + return cmd.Run() +} + +func Send(title, message string) error { + return send(currentGOOS, title, message, runCommand) +} + +func send(goos, title, message string, run func(name string, args ...string) error) error { + name, args, ok := command(goos, title, message) + if !ok { + return fmt.Errorf("notifications are not supported on %s", goos) + } + + return run(name, args...) +} + +func command(goos, title, message string) (string, []string, bool) { + switch goos { + case "darwin": + script := fmt.Sprintf("display notification %s with title %s", strconv.Quote(message), strconv.Quote(title)) + return "osascript", []string{"-e", script}, true + case "linux": + return "notify-send", []string{title, message}, true + case "windows": + template := fmt.Sprintf( + `%s%s`, + xmlEscape(title), + xmlEscape(message), + ) + script := fmt.Sprintf(`[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null; $template = '%s'; $xml = New-Object Windows.Data.Xml.Dom.XmlDocument; $xml.LoadXml($template); $toast = [Windows.UI.Notifications.ToastNotification]::new($xml); $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("git-real"); $notifier.Show($toast)`, powerShellSingleQuote(template)) + return "powershell", []string{"-NoProfile", "-Command", script}, true + default: + return "", nil, false + } +} + +func powerShellSingleQuote(value string) string { + return strings.ReplaceAll(value, `'`, `''`) +} + +func xmlEscape(value string) string { + var escaped bytes.Buffer + if err := xml.EscapeText(&escaped, []byte(value)); err != nil { + return value + } + + return escaped.String() +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..b7b0289 --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,136 @@ +package notify + +import ( + "errors" + "strings" + "testing" +) + +func TestCommand(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + goos string + wantCmd string + wantOK bool + }{ + {name: "darwin", goos: "darwin", wantCmd: "osascript", wantOK: true}, + {name: "linux", goos: "linux", wantCmd: "notify-send", wantOK: true}, + {name: "windows", goos: "windows", wantCmd: "powershell", wantOK: true}, + {name: "unsupported", goos: "plan9", wantOK: false}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotCmd, gotArgs, gotOK := command(tc.goos, "GitReal", "test") + if gotOK != tc.wantOK { + t.Fatalf("command(%q) ok = %t, want %t", tc.goos, gotOK, tc.wantOK) + } + + if gotCmd != tc.wantCmd { + t.Fatalf("command(%q) cmd = %q, want %q", tc.goos, gotCmd, tc.wantCmd) + } + + if tc.wantOK && len(gotArgs) == 0 { + t.Fatalf("command(%q) args = %v, want non-empty", tc.goos, gotArgs) + } + }) + } +} + +func TestSend(t *testing.T) { + t.Parallel() + + called := false + err := send("linux", "GitReal", "test", func(name string, args ...string) error { + called = true + if name != "notify-send" { + t.Fatalf("runner name = %q, want notify-send", name) + } + if len(args) != 2 { + t.Fatalf("runner args = %v, want 2 args", args) + } + return nil + }) + if err != nil { + t.Fatalf("send() error = %v", err) + } + if !called { + t.Fatalf("runner was not called") + } +} + +func TestSendUnsupportedPlatform(t *testing.T) { + t.Parallel() + + err := send("plan9", "GitReal", "test", func(name string, args ...string) error { + t.Fatalf("runner should not be called") + return nil + }) + if err == nil { + t.Fatalf("send() error = nil, want non-nil") + } +} + +func TestSendRunnerError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("boom") + err := send("linux", "GitReal", "test", func(name string, args ...string) error { + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("send() error = %v, want %v", err, wantErr) + } +} + +func TestSendUsesPackageVariables(t *testing.T) { + t.Parallel() + + previousGOOS := currentGOOS + previousRunner := runCommand + t.Cleanup(func() { + currentGOOS = previousGOOS + runCommand = previousRunner + }) + + currentGOOS = "linux" + called := false + runCommand = func(name string, args ...string) error { + called = true + return nil + } + + if err := Send("GitReal", "test"); err != nil { + t.Fatalf("Send() error = %v", err) + } + if !called { + t.Fatalf("Send() did not invoke runCommand") + } +} + +func TestCommandEscapesWindowsToastText(t *testing.T) { + t.Parallel() + + _, args, ok := command("windows", `O'Hare `, "line 1 & line 2") + if !ok { + t.Fatalf("command(windows) ok = false, want true") + } + if len(args) != 3 { + t.Fatalf("command(windows) args = %v, want 3 args", args) + } + + script := args[2] + for _, want := range []string{ + `O'Hare <title>`, + `line 1 & line 2`, + } { + if !strings.Contains(script, want) { + t.Fatalf("script = %q, want substring %q", script, want) + } + } +} diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..de4c8f6 --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +threshold="${COVERAGE_THRESHOLD:-95}" +profile="${1:-coverage.out}" + +go test -coverprofile="${profile}" ./... + +total="$(go tool cover -func="${profile}" | awk '/^total:/ {gsub("%", "", $3); print $3}')" + +awk -v actual="${total}" -v threshold="${threshold}" ' +BEGIN { + if (actual + 0 < threshold + 0) { + printf("coverage %.1f%% is below threshold %.1f%%\n", actual, threshold) + exit 1 + } + + printf("coverage %.1f%% meets threshold %.1f%%\n", actual, threshold) +}'