Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Check out LF everywhere. Windows runners default to autocrlf=true, which
# rewrites every file to CRLF and makes `prettier --check` fail on all of them.
* text=auto eol=lf

# Sprites are width-sensitive art; never let a filter touch them.
src/ui/sprites/*.ts -text
*.gif binary
61 changes: 61 additions & 0 deletions .github/workflows/action-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Action smoke

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
smoke:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the pet reads commit history

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- id: pet
uses: ./
with:
version: local
card: gitgotchi-card.svg
cache: 'false' # a fresh egg every run keeps the assertions stable

- name: the action produced a live pet
shell: bash
env:
STAGE: ${{ steps.pet.outputs.stage }}
MOOD: ${{ steps.pet.outputs.mood }}
HEALTH: ${{ steps.pet.outputs.health }}
CARD: ${{ steps.pet.outputs.card-path }}
run: |
set -euo pipefail
[ -n "$STAGE" ] || { echo "no stage output"; exit 1; }
[ -n "$MOOD" ] || { echo "no mood output"; exit 1; }
case "$HEALTH" in ''|*[!0-9]*) echo "health not a number: '$HEALTH'"; exit 1 ;; esac
[ "$CARD" = "gitgotchi-card.svg" ] || { echo "card-path was '$CARD'"; exit 1; }
grep -q '<svg' gitgotchi-card.svg

- name: the repo was never written to
shell: bash
run: |
set -euo pipefail
git diff --exit-code
[ ! -d .gitgotchi ] || { echo ".gitgotchi/ appeared in the working tree"; exit 1; }
# The card we asked for is the only thing allowed to appear.
unexpected="$(git status --porcelain | grep -v 'gitgotchi-card.svg' || true)"
[ -z "$unexpected" ] || { echo "unexpected changes:"; echo "$unexpected"; exit 1; }
39 changes: 38 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
check:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
node: [20, 22]
runs-on: ${{ matrix.os }}
steps:
Expand All @@ -23,3 +26,37 @@ jobs:
- run: npm run typecheck
- run: npm run lint
- run: npm test

package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci

# What we publish must actually run. Install the tarball into a scratch
# project and drive the real bin, the way `npx gitgotchi` would.
- name: the packed tarball runs
run: |
set -euo pipefail
tarball="$PWD/$(npm pack --silent)"
scratch="$(mktemp -d)"
git init -q "$scratch/repo"
git -C "$scratch/repo" -c user.email=ci@example.com -c user.name=CI \
commit -q --allow-empty -m "feat: hello"

# `npm init --prefix` writes to the cwd's package.json, not the
# prefix — subshell into the scratch dir instead.
(cd "$scratch" && npm init -y >/dev/null && npm install "$tarball" >/dev/null)

cd "$scratch/repo"
bin="$scratch/node_modules/.bin/gitgotchi"
"$bin" --version
"$bin" --report "$scratch/pet.json" | tee "$scratch/out.txt"
grep -q health "$scratch/out.txt"
node -e 'const s=require(process.argv[1]); if (!s.vitals) { throw new Error("no vitals") }' \
"$scratch/pet.json"
test -z "$(git status --porcelain)" || { echo "the CLI dirtied the repo"; exit 1; }
38 changes: 38 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Release

on:
push:
tags: ['v*']

permissions:
contents: read

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
registry-url: https://registry.npmjs.org

- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm test

- name: the tag matches package.json
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME#v}"
pkg="$(node -p 'require("./package.json").version')"
[ "$tag" = "$pkg" ] || { echo "tag $tag != package.json $pkg"; exit 1; }

- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ node_modules/
dist/
coverage/
.gitgotchi/
gitgotchi-card.svg
*.tgz
*.log
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Full product/architecture spec: `PLAN.md`. Milestones: `docs/milestones/`. Work
1. **Zero-config first run.** `npx gitgotchi` in any git repo must produce a living creature in under 5 seconds using only local signals (git + grep). CI and coverage are progressive enhancements.
2. **Ambient, not nagging.** Never block, never exit non-zero because of repo state, never lecture. The pet's face is the only feedback mechanism.
3. **The creature is the UI.** Sprite + one flavor line first; numbers second.
4. **Local-first.** No accounts, no server, no telemetry. State lives in `.gitgotchi/state.json` (gitignored automatically).
4. **Local-first and read-only.** No accounts, no server, no telemetry. Gitgotchi never writes into the repo it observes — state lives outside the working tree (`GITGOTCHI_STATE_DIR`, else `$XDG_STATE_HOME/gitgotchi/`, else `~/.local/state/gitgotchi/`, `%LOCALAPPDATA%` on Windows), keyed by a hash of the repo path. Pre-0.2 pets at `.gitgotchi/state.json` are read once for migration and never written.
5. **One run, one check-in.** A single invocation ticks the pet exactly once; `--report` and `--card` are extra renderings of that tick, never extra ticks.

## Development process — TDD is mandatory

Expand Down
6 changes: 3 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ This document is a Claude Code-ready build plan: architecture, data model, and s
| Git access | `simple-git` | Battle-tested, promise-based |
| CI/GitHub data | `@octokit/rest` (optional, token via `gh auth token` fallback) | Progressive enhancement |
| Coverage parsing | Own parsers for `lcov.info` + `coverage-summary.json` | Two formats cover ~90% of JS/TS repos |
| Config/state | `zod`-validated JSON at `.gitgotchi/state.json` | Schema-first, safe migrations |
| Config/state | `zod`-validated JSON outside the repo (`GITGOTCHI_STATE_DIR` / `$XDG_STATE_HOME/gitgotchi/`) | Schema-first, safe migrations, never writes to the observed repo |
| Share card | `satori` (JSX → SVG) + optional `sharp` (SVG → PNG) | No headless browser |
| Tests | **Vitest** + ink-testing-library | Fast, TS-native |
| Lint/format | ESLint + Prettier | Standard |
Expand All @@ -50,7 +50,7 @@ src/
evolution.ts # lifetime stats -> Species/Stage (pure)
decay.ts # time-since-last-check decay (pure)
state/
store.ts # load/save/migrate .gitgotchi/state.json (zod)
store.ts # load/save/migrate state.json outside the repo (zod)
history.ts # ring buffer of last N snapshots
ui/
App.tsx # Ink root: layout, refresh loop
Expand Down Expand Up @@ -154,7 +154,7 @@ Pure functions only. Table-driven tests.
- **DoD:** 100% line coverage on `engine/` (it's pure — no excuse); running the engine twice on identical input is byte-identical.

### M3 — State store (½ day)
- `store.ts`: load → zod-validate → migrate → save to `.gitgotchi/state.json`; auto-append to `.gitignore` on first run (ask nothing).
- `store.ts`: load → zod-validate → migrate → save to a per-repo slot outside the working tree (`GITGOTCHI_STATE_DIR`, else `$XDG_STATE_HOME/gitgotchi/`, else `~/.local/state/gitgotchi/`). A pre-0.2 `.gitgotchi/state.json` is read once for migration and never written back.
- `history.ts`: ring buffer of 50 snapshots.
- Tests: missing file → newborn state; corrupt JSON → backup to `.bak` + newborn (never crash, never silently overwrite); schemaVersion bump path.
- **DoD:** kill -9 during save can't corrupt state (write-to-temp + atomic rename, tested).
Expand Down
99 changes: 95 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ npx gitgotchi
- [Life stages](#life-stages)
- [How it works](#how-it-works)
- [The share card](#the-share-card)
- [In GitHub Actions](#in-github-actions)
- [FAQ](#faq)
- [Development](#development)

Expand All @@ -43,6 +44,15 @@ npx gitgotchi rename Pixel
npx gitgotchi --json # machine-readable state, for status bars
```

One run is one check-in. `--report pet.json` and `--card pet.svg` are extra
renderings of that same check-in, so scripts can have the numbers, the picture,
and the human block without the pet ageing three times:

```bash
npx gitgotchi --report pet.json --card pet.svg
npx gitgotchi card -o - # SVG straight to stdout
```

Zero config. Runs in any git repo using only local signals (git + grep) in a few
seconds. CI and coverage are automatic bonuses when they're available.

Expand Down Expand Up @@ -117,14 +127,95 @@ mood and stage.
## The share card

`gitgotchi card` renders a 1200×630 SVG of your pet + stats — the thing you post.
It's deterministic (same state → identical bytes) and needs no browser. Add
`--png` to rasterize via `sharp` if you have it installed; otherwise you get the
SVG and a friendly note.
It needs no browser, and it's deterministic: the flavor line is seeded by the
pet's condition rather than the clock, so re-rendering an unchanged pet gives
byte-identical output. Add `--png` to rasterize via `sharp` if you have it
installed; otherwise you get the SVG and a friendly note.

## In GitHub Actions

The action checks in on your pet and puts it in the **job summary** — nothing is
committed, nothing is pushed, and the working tree is never written to.

```yaml
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the pet reads commit history
- uses: dvd90/gitgotchi@v1
```

That's the whole thing. `actions/cache` keeps the pet alive between runs, so it
actually grows up instead of hatching every time.

**Keep the card as a downloadable artifact:**

```yaml
- uses: dvd90/gitgotchi@v1
id: pet
with:
card: gitgotchi-card.svg
- uses: actions/upload-artifact@v4
with:
name: gitgotchi-card
path: ${{ steps.pet.outputs.card-path }}
```

**Commit the card to a branch** — opt in explicitly, since this is the one
workflow that writes:

```yaml
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: dvd90/gitgotchi@v1
with:
card: .github/gitgotchi-card.svg
- run: |
git config user.name "gitgotchi"
git config user.email "gitgotchi@users.noreply.github.com"
git add .github/gitgotchi-card.svg
git diff --staged --quiet || git commit -m "chore: update gitgotchi card"
git push
```

The card is seeded by the pet's condition, not by when you looked, so an
unchanged pet renders identical bytes and `git diff --staged --quiet` skips the
commit. The day counter still ticks, so expect roughly one commit a day rather
than one per push.

### Inputs

| Input | Default | What it does |
| -------------- | --------------------- | ------------------------------------------------------------- |
| `path` | `.` | Repository directory to check in on |
| `version` | `latest` | npm version to run; `local` builds the checked-out copy |
| `cache` | `true` | Persist the pet between runs via `actions/cache` |
| `summary` | `true` | Write the pet to the job summary |
| `card` | _(none)_ | Workspace-relative path for the card SVG |
| `github-token` | `${{ github.token }}` | Read Actions status; needs `actions: read`, pass `''` to skip |

### Outputs

`name`, `species`, `stage`, `mood`, `health`, `hunger`, `hygiene`, `social`,
`report` (path to the full state JSON), `card-path`.

```yaml
- if: steps.pet.outputs.mood == 'critical'
run: echo "the repo is not okay"
```

## FAQ

**Does it send my data anywhere?** No. No account, no server, no telemetry.
State lives in `.gitgotchi/state.json` (added to your `.gitignore` automatically).

**Does it touch my repo?** No. Gitgotchi only reads. Your pet lives outside the
working tree, under `$XDG_STATE_HOME/gitgotchi/` (`~/.local/state/gitgotchi/` by
default, `%LOCALAPPDATA%\gitgotchi\` on Windows), keyed by the repo's path. Set
`GITGOTCHI_STATE_DIR` to put it somewhere else. Pets from before 0.2 that live
in `.gitgotchi/state.json` are picked up automatically and move on the next
check-in; the old file is left alone and can be deleted.

**Can it block or fail my build?** Never, by design. It always exits 0 and never
lectures. Guilt is delivered exclusively through sad eyes.
Expand Down
Loading
Loading