Skip to content

feat!: read-only GitHub Action, external state, and a product-quality pass - #8

Merged
dvd90 merged 4 commits into
mainfrom
feat/read-only-action-and-external-state
Aug 15, 2026
Merged

feat!: read-only GitHub Action, external state, and a product-quality pass#8
dvd90 merged 4 commits into
mainfrom
feat/read-only-action-and-external-state

Conversation

@dvd90

@dvd90 dvd90 commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Gitgotchi promised to be an ambient observer but wrote to the repo it was watching. This PR makes that promise true, then builds the GitHub Action that becomes possible once it is.

Breaking: pet state no longer lives in .gitgotchi/state.json. Existing pets migrate automatically on the next check-in.

161 tests (was 131). npm test && npm run typecheck && npm run lint all pass.


1. State moved out of the repo

save() used to write .gitgotchi/state.json and append to the user's .gitignore. That is a surprise write to a tracked file, and it makes the tool unusable in a read-only CI context.

State now resolves to GITGOTCHI_STATE_DIR$XDG_STATE_HOME/gitgotchi/~/.local/state/gitgotchi/ (%LOCALAPPDATA%\gitgotchi\ on Windows), in a per-repo slot named <basename>-<sha256(abs path)[0:12]> so two checkouts named api never collide. ensureGitignore is deleted.

A pre-0.2 .gitgotchi/state.json is read once for migration and never written back; the old file is left alone and can be deleted. hasState() checks both locations, so an upgraded pet does not get greeted as a fresh egg.

test/setup.ts points every test file at its own tmp state root — without it the suite would write into the developer's real state directory.

2. CLI bugs

Four real ones, each now covered by a test:

  • card rendered stale state. It called load() instead of collectAndAdvance(), so on a fresh clone you got a day-0 egg at health 70 no matter what the repo looked like.
  • messages.forEach(io.out) passed the index and array into console.log, printing Saved card.svg 0 [ 'Saved card.svg' ]. Found while regenerating the README image.
  • oneShot/watch bypassed the injected Io and wrote to console.log. This is why the project had no CLI integration tests — nothing was capturable. main() also takes repoPath now, so tests can drive it against a fixture repo.
  • Flags with missing values silently defaulted. card -o wrote to the default path; watch -i and watch -i soon fell back to 60s. Each gets one friendly line instead.

New test/cli.integration.test.ts drives main() end to end against real git fixtures, including an assertion that the working tree stays clean.

3. One run, one check-in

--report <path> and --card <path> are extra renderings of a single tick, not extra ticks:

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

This is what lets the Action produce a summary, outputs and a card from one check-in. Without it the Action would have aged the pet three times per run.

4. Input and path handling

safeName/stripControl (src/state/name.ts) drop control bytes, collapse whitespace and cap at 32 chars. Applied at the zod boundary — so a hand-edited state.json is covered too — and again in the card's esc().

A pet name is typed by the user, read back from disk, rendered into a terminal, embedded in a shared SVG, and written to $GITHUB_OUTPUT. A newline alone would corrupt that last one.

The Action rejects card paths that are absolute or contain .., and passes every input through env: rather than ${{ }} interpolation inside run:.

5. The card

Attribution: an npx gitgotchi install line bottom-left, <repo> · github.com/dvd90/gitgotchi bottom-right. A card gets shared away from its repo, so it carries its own provenance.

Determinism, for real. The flavor line was seeded from the last check-in timestamp, so every run produced different bytes — which would have made the commit-the-card workflow churn on every push. cardSeed() now seeds from the pet's condition (stage, mood, four vitals), so an unchanged pet renders byte-identical output.

The day counter still ticks, so the README says "roughly one commit a day", not "no noise".

6. The Action

Composite, read-only. Default behaviour is the job summary — no commits, no artifacts, no extra token scope, and the pet shows up on the run page:

- uses: actions/checkout@v4
  with: { fetch-depth: 0 }
- uses: dvd90/gitgotchi@v1

actions/cache keeps the pet alive between runs so it actually grows up. Inputs: path, version (local builds the checked-out copy), cache, summary, card, github-token. Outputs: name, species, stage, mood, the four vitals, report, card-path.

README documents both workflows you asked for — artifact-only via upload-artifact, and the opt-in SVG commit with its contents: write scope spelled out.

7. CI, cross-platform, release readiness

  • Windows added to the test matrix. test/helpers/repo.ts now passes SystemRoot/TEMP/ComSpec through, which git needs to start there. The collectors were already Windows-safe — collectTodos uses git ls-files and Node, not grep.
  • package job packs the tarball, installs it into a scratch project, drives the real node_modules/.bin/gitgotchi, and asserts the repo stays clean. Verified locally.
  • Action smoke test on Linux, macOS and Windows, asserting outputs are populated, the card is real SVG, and nothing but the card appears in the working tree.
  • release.yml on v* tags: full check, tag-matches-package.json guard, npm publish --provenance.
  • Version → 0.2.0.

Notes

npm init -y --prefix <dir> writes to the cwd's package.json, not the prefix. It polluted the root package.json while I was testing the pack flow; reverted, and the CI script subshells into the scratch dir instead.

This is a single commit rather than seven. src/cli.ts and src/ui/launch.tsx are touched by nearly every slice, so splitting would have produced intermediate commits that don't pass tests — which CLAUDE.md forbids. Happy to restructure into stacked PRs if you'd rather review it in pieces.

docs/gitgotchi-card.svg was updated by hand to add the footer, keeping its healthy stat values — regenerating it from this repo would have baked in a critical pet with zeroed vitals.

🤖 Generated with Claude Code

… pass

Gitgotchi promised to be an ambient observer but wrote to the repo it was
watching. This makes that promise true, then builds the GitHub Action that
becomes possible once it is.

BREAKING CHANGE: pet state no longer lives in `.gitgotchi/state.json`.

State moves outside the working tree
- `GITGOTCHI_STATE_DIR`, else `$XDG_STATE_HOME/gitgotchi/`, else
  `~/.local/state/gitgotchi/` (`%LOCALAPPDATA%\gitgotchi\` on Windows).
- Per-repo slot is `<basename>-<sha256(abs path)[0:12]>`, so two checkouts
  named `api` never collide.
- `ensureGitignore` is gone. A check-in never touches the user's `.gitignore`.
- Pre-0.2 pets are read once for migration and never written back.

CLI fixes
- `card` called `load()` and rendered stale state; it now performs a real
  check-in.
- `messages.forEach(io.out)` passed the index and array straight into
  `console.log`, printing `Saved card.svg 0 [ 'Saved card.svg' ]`.
- `oneShot`/`watch` bypassed the injected `Io` and wrote to `console.log`,
  which is why no CLI integration tests existed. They are threaded now.
- `-o` and `-i` with a missing or non-numeric value silently defaulted; each
  gets one friendly line instead.

One run, one check-in
- `--report <path>` and `--card <path>` render extra artifacts from the same
  tick, so a script can have the numbers, the picture and the human block
  without ageing the pet three times.
- `card -o -` writes the SVG to stdout.

Input handling
- `safeName`/`stripControl` scrub control bytes at the persistence boundary
  and again at the SVG boundary, so a pet name cannot carry an escape
  sequence into a terminal, a shared card, or a `$GITHUB_OUTPUT` line.

The card
- Seeded by the pet's condition instead of the last check-in timestamp, so an
  unchanged pet renders byte-identical output and the commit workflow stays
  quiet.
- Carries its own attribution and `npx gitgotchi` install line.

The Action
- Composite, read-only. Job summary by default, `actions/cache` for
  persistence between runs, nine outputs.
- Inputs reach bash through `env:`, never `${{ }}` interpolation inside
  `run:`. Card paths are rejected if absolute or containing `..`.

CI and release
- Windows added to the test matrix; git fixtures get the env it needs there.
- A `package` job packs the tarball, installs it into a scratch project and
  drives the real bin.
- Action smoke test on Linux, macOS and Windows.
- Tag-triggered release with npm provenance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Claude finished @dvd90's task in 2m 10s —— View job


Reviewing PR #8

  • Gather context (PR diff, CLAUDE.md, changed files)
  • Understand request (code review against CLAUDE.md conventions)
  • Review engine purity / collectors-never-throw rules
  • Review TDD evidence (tests-first) and coverage of new behavior
  • Review product principles (zero-config, ambient, local-first, no nagging)
  • Review state migration, path handling, and CLI fixes
  • Post final review findings

Review

This is a well-scoped milestone PR and it holds up against CLAUDE.md's rules:

  • Engine purity: src/engine/** is untouched by this diff — no risk introduced there.
  • Collectors never throw: no collector files touched either.
  • State is schema-first / atomic: src/state/store.ts keeps zod validation on load, .bak on corrupt/invalid input, and atomic temp-file+rename saves (src/state/store.ts:123-134), all still covered by tests (test/state/store.test.ts).
  • Local-first / read-only: moving state out of the repo and deleting ensureGitignore is exactly the kind of principle-restoring fix this project wants, and CLAUDE.md itself was updated to codify the new "local-first and read-only" and "one run, one check-in" principles — good, that keeps the doc authoritative.
  • TDD: test/cli.integration.test.ts and the expanded test/state/store.test.ts / test/state/name.test.ts clearly exercise the four described CLI bugs, the migration path, and the read-only guarantee (expect(await repo.git.status().then(s => s.isClean())).toBe(true)). Good boundary coverage (corrupt JSON, schema-invalid, v0 migration, missing-flag-value).
  • Sprites/flavor determinism: cardSeed reseeding from pet condition instead of timestamp is a real fix for the "commit-the-card" workflow noise, and there's a dedicated test asserting byte-identical output across runs.
  • Action security: action.yml passes inputs through env: rather than ${{ }} interpolation in run:, and rejects absolute/.. card paths — consistent with the PR description's security claims. GITHUB_OUTPUT writes for name are safe against newline-based output injection because PetStateSchema sanitizes name via safeName at the zod boundary (src/state/schemas.ts:38-41), so a hand-edited or freshly-typed name can never reach the shell/output file with a raw newline.

One real bug, low severity:

load() in src/state/store.ts:90-121 reads from legacyFile when the new-location file is missing (lines 100-105), but if that legacy content then fails to parse or fails schema validation, the backup call is backup(file, bak) (lines 111, 117) — i.e. it tries to copyFile from the new location, which doesn't exist yet, not from legacyFile, which is where the corrupt bytes actually are. The copyFile throws ENOENT and is swallowed by the "best effort" catch in backup() (lines 149-157), so a corrupt legacy .gitgotchi/state.json silently gets no .bak at all — a quiet violation of "Corrupt state → back up to .bak... never crash, never silently overwrite" for this one path. It doesn't corrupt or overwrite anything (a fresh newborn is still returned), but the promised backup silently doesn't happen. This case isn't covered by a test — store.test.ts only exercises corrupt/invalid JSON at the new location (test/state/store.test.ts:45-68), never a corrupt legacy file.

Fix would be tracking which path was actually read and backing up from there, e.g.:

const source = /* file or legacyFile, whichever readFile succeeded on */;
await backup(source, bak);

Fix this →

Minor nits (non-blocking):

  • docs/gitgotchi-card.svg being hand-edited rather than regenerated is called out honestly in the PR description — worth a follow-up issue to regenerate once there's a way to snapshot a "healthy" fixture pet, but fine for this PR.
  • Single commit for a 1078-line diff is a deviation from "one milestone per PR... keep PRs under ~400 lines," acknowledged in the PR description with a reasonable explanation (interdependent slices through cli.ts/launch.tsx). Given the milestone is cohesive (read-only state is a prerequisite for the Action), this seems acceptable, but flagging since it's a notable size/commit-count deviation from convention.

I wasn't able to run npm test/typecheck/lint myself in this sandboxed environment (no network access for npm ci/npx), so I relied on static reading plus a manual trace of the corrupt-legacy-file path — the PR description states all three pass locally.

Overall: solid, principle-restoring change. One small, low-severity backup-path bug worth a quick fix + test; everything else checks out against CLAUDE.md.

Both caught by the new CI jobs on their first run.

`prepublishOnly` runs on `npm publish` but not on `npm pack`, so the packed
tarball contained no `dist/` and its `bin` pointed at a file that was not
there. `prepack` runs on both.

Windows runners check out with `core.autocrlf=true`, which rewrites every file
to CRLF and makes `prettier --check` fail on all 73 of them. `.gitattributes`
pins the checkout to LF, leaving the width-sensitive sprite files untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Claude finished @dvd90's task in 1m 9s —— View job


Reviewing PR #8 (re-review after new commit)

  • Gather context (PR diff, CLAUDE.md, changed files)
  • Diff against previous review to see what changed
  • Re-review engine purity / collectors-never-throw rules
  • Re-review TDD evidence for new/changed behavior
  • Check whether the previously-flagged backup-path bug was addressed
  • Post final review findings

What changed since the last review

The new commit (305f6fa) only touches build/CI plumbing, not product code:

  • prepublishOnlyprepack in package.json, so npm pack (not just npm publish) runs the build — fixes a tarball shipped without dist/.
  • New .gitattributes pins checkout to LF (* text=auto eol=lf, with -text for the width-sensitive sprite files) so Windows runners with core.autocrlf=true don't fail prettier --check.

Both are sensible, minimal fixes for problems the new CI matrix caught on its own first run, and neither needs its own test (they're packaging/checkout config, not behavior). This doesn't change the substance of the earlier review — the rest of the 1086-line diff is identical to what was already reviewed.

Still holds from the previous pass

  • Engine purity / collectors-never-throw: neither src/engine/** nor src/collectors/** are touched by this PR — no risk introduced.
  • State is schema-first / atomic: src/state/store.ts keeps zod validation on load, .bak on corrupt input, and atomic temp-file+rename saves, backed by test/state/store.test.ts.
  • Local-first / read-only: moving state to GITGOTCHI_STATE_DIR/XDG paths and deleting ensureGitignore genuinely fixes the "ambient observer that wrote to the repo" problem described in the PR body, and the Action's env:-based input passing + rejection of absolute/.. card paths is sound.
  • TDD: test/cli.integration.test.ts and expanded test/state/store.test.ts / test/state/name.test.ts clearly cover the four described CLI bugs, migration, and the read-only guarantee (asserting a clean working tree after a run).
  • Determinism: cardSeed() reseeding from pet condition instead of timestamp is a real, tested fix for card-commit-workflow churn.

Still-open issue: legacy-file backup path (unchanged)

The bug flagged in the previous review is still present in src/state/store.ts:90-121 and was not part of this commit:

try {
  raw = await readFile(file, 'utf8');
} catch {
  try {
    raw = await readFile(legacyFile, 'utf8');   // source may be legacyFile
  } catch {
    return newbornState(key, clock);
  }
}
...
} catch {
  await backup(file, bak);   // always backs up `file`, even when `legacyFile` was the one actually read
  return newbornState(key, clock);
}

When the pre-0.2 .gitgotchi/state.json (legacyFile) is corrupt, file doesn't exist yet at the new location, so backup()'s copyFile(file, bak) throws ENOENT, which is swallowed by backup()'s best-effort catch. Result: a corrupt legacy state file silently gets no .bak, quietly violating "Corrupt state → back up to .bak... never crash, never silently overwrite." Not data-destructive (a fresh newborn is still returned) but the promised backup doesn't happen. test/state/store.test.ts still only covers corrupt/invalid JSON at the new location, not a corrupt legacy file — confirmed by re-reading the current test file (test/state/store.test.ts:158-183, which covers valid legacy reads but not corrupt ones).

Minimal fix: track which path was actually read and pass that into backup():

let raw: string;
let source = file;
try {
  raw = await readFile(file, 'utf8');
} catch {
  source = legacyFile;
  try {
    raw = await readFile(legacyFile, 'utf8');
  } catch {
    return newbornState(key, clock);
  }
}
...
await backup(source, bak);

plus a test seeding a corrupt/invalid .gitgotchi/state.json with no state at the new location, asserting bak is written.

Fix this →

Minor nits (non-blocking, unchanged)

  • docs/gitgotchi-card.svg hand-edited rather than regenerated — honestly disclosed in the PR description; fine for now, worth a follow-up once a "healthy" fixture pet can be snapshotted.
  • Single commit for the main 1086-line diff deviates from "one milestone per PR... under ~400 lines," acknowledged with a reasonable rationale (interdependent slices through cli.ts/launch.tsx). Acceptable given the milestone is cohesive.

I don't have npm ci/network access in this sandbox to run npm test/typecheck/lint myself; relied on static reading plus tracing the corrupt-legacy-file path against the actual current source.

Overall: still a solid, principle-restoring change with one small, low-severity, easy-to-fix backup-path bug carried over from the previous review — not addressed by the latest commit, which only fixed unrelated CI/build issues.

Both are test-side; the source was already correct on Windows.

`commands.test.ts` derived the expected folder name with
`join(repo).split('/').pop()`, which returns the whole path when the separator
is a backslash. `initPet` uses `basename()`, so the test now does too.

The first CLI integration test timed out at the 5s default. These tests drive
real git in real fixture repos, which takes seconds on a Windows runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Claude finished @dvd90's task in 46s —— View job


Reviewing PR #8 (re-review after synchronize)

  • Gather context (PR diff, CLAUDE.md, changed files)
  • Diff against previous review to see what changed since last commit
  • Re-review engine purity / collectors-never-throw rules
  • Re-review TDD evidence for new/changed behavior
  • Check whether previously-flagged backup-path bug was addressed
  • Post final review findings

What changed since the last review

The new commit (5873e79) is test-only, fixing two Windows-only CI failures:

  • test/commands.test.ts: the expected repo-folder-name was derived with join(repo).split('/').pop(), which breaks on Windows where the separator is \. Switched to basename(), matching what initPet already uses in src/commands.ts. Test-side bug, source was already correct — reasonable fix.
  • vitest.config.ts: raised testTimeout to 20s, since the CLI integration/collector tests drive real git in real fixture repos and that's slower on Windows runners than the 5s default.

Both are minimal, correctly-scoped fixes for problems the new Windows CI matrix (added in this same PR) caught. No product code touched, so this doesn't change the substance of the prior two reviews.

Still holds from previous passes

  • Engine purity / collectors-never-throw: neither src/engine/** nor src/collectors/** are touched anywhere in this PR.
  • State is schema-first / atomic: zod validation on load, .bak on corrupt input, atomic temp-file+rename saves — still backed by test/state/store.test.ts.
  • Local-first / read-only: moving state to GITGOTCHI_STATE_DIR/XDG paths and deleting ensureGitignore is the real fix promised in the PR description; the Action's env:-based input passing and rejection of absolute/.. card paths are sound.
  • TDD: test/cli.integration.test.ts and expanded test/state/store.test.ts / test/state/name.test.ts cover the four described CLI bugs, migration, and the read-only guarantee.
  • Determinism: cardSeed() reseeding from pet condition instead of timestamp is a real, tested fix for card-commit-workflow churn.

Still-open issue: legacy-file backup path (unaddressed, carried over)

Confirmed still present in src/state/store.ts:90-118 — not touched by this commit:

try {
  raw = await readFile(file, 'utf8');
} catch {
  try {
    raw = await readFile(legacyFile, 'utf8');   // source may be legacyFile
  } catch {
    return newbornState(key, clock);
  }
}
...
} catch {
  await backup(file, bak);   // always backs up `file`, even when `legacyFile` was actually read
  return newbornState(key, clock);
}

When the pre-0.2 .gitgotchi/state.json (legacyFile) is corrupt, file doesn't exist at the new location yet, so backup()'s copyFile(file, bak) throws ENOENT, silently swallowed by backup()'s best-effort catch (src/state/store.ts:149-157). Result: a corrupt legacy state file gets no .bak, quietly violating CLAUDE.md's "Corrupt state → back up to .bak... never crash, never silently overwrite." Not data-destructive (a fresh newborn is still returned), but the promised backup silently doesn't happen. test/state/store.test.ts (checked lines 158-208 in the current tree) still only covers corrupt/invalid JSON at the new location and valid legacy reads — never a corrupt legacy file.

Minimal fix: track which path was actually read and back up from there:

let raw: string;
let source = file;
try {
  raw = await readFile(file, 'utf8');
} catch {
  source = legacyFile;
  try {
    raw = await readFile(legacyFile, 'utf8');
  } catch {
    return newbornState(key, clock);
  }
}
...
await backup(source, bak);

plus a test seeding a corrupt/invalid .gitgotchi/state.json with no state at the new location, asserting a .bak is written.

Fix this →

Minor nits (non-blocking, unchanged)

  • docs/gitgotchi-card.svg hand-edited rather than regenerated — honestly disclosed in the PR description; fine for now.
  • Single commit for the main ~1086-line diff deviates from "one milestone per PR... under ~400 lines," acknowledged with a reasonable rationale. Acceptable given the milestone is cohesive.

I don't have npm ci/network access in this sandbox to run npm test/typecheck/lint myself; relied on static reading plus tracing the corrupt-legacy-file path against the current source.

Overall: still a solid, principle-restoring change. This synchronize only fixed unrelated Windows CI flakiness — the one low-severity backup-path bug from earlier reviews remains open.

`main()` imports its command modules on demand, so whichever integration test
ran first absorbed the cost of transforming the whole Ink/React tree. On a cold
Windows runner that was over 20s for a call whose siblings take ~1.2s. Paying
it in `beforeAll` puts the first test back in line with the rest (591ms local).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown

Claude finished @dvd90's task in 40s —— View job


Reviewing PR #8 (re-review after synchronize)

  • Gather context (PR diff, CLAUDE.md, changed files)
  • Diff against previous review to see what changed since last commit
  • Check whether previously-flagged backup-path bug was addressed
  • Re-review remaining CLAUDE.md conventions
  • Post final review findings

What changed since the last review

The new commit (3c91d66) is test-only, unrelated to product code:

  • test/cli.integration.test.ts: moves the cost of importing command modules (Ink/React tree transform) into a beforeAll warm-up, so the first integration test isn't the one that eats a 20s+ cold-import cost on Windows runners.
  • vitest.config.ts: minor config tweak to match.

Sensible, minimal fix for CI flakiness the new Windows matrix surfaced. Doesn't touch src/**, so nothing here changes the substance of the last two reviews.

Still holds from previous passes

  • Engine purity / collectors-never-throw: neither src/engine/** nor src/collectors/** are touched anywhere in this PR.
  • State is schema-first / atomic: src/state/store.ts keeps zod validation on load, .bak on corrupt input, atomic temp-file+rename saves, backed by test/state/store.test.ts.
  • Local-first / read-only: moving state to GITGOTCHI_STATE_DIR/XDG paths and deleting ensureGitignore is the real, principle-restoring fix described in the PR body; the Action's env:-based input passing and rejection of absolute/.. card paths are sound.
  • TDD: test/cli.integration.test.ts and expanded test/state/store.test.ts / test/state/name.test.ts cover the four described CLI bugs, migration, and the read-only guarantee (asserting a clean working tree after a run).
  • Determinism: cardSeed() reseeding from pet condition instead of timestamp is a real, tested fix for card-commit-workflow churn.

Still-open issue: legacy-file backup path (unaddressed across three reviews)

Confirmed still present in src/state/store.ts:90-118, unchanged by any of the last three commits:

try {
  raw = await readFile(file, 'utf8');
} catch {
  try {
    raw = await readFile(legacyFile, 'utf8');   // source may be legacyFile
  } catch {
    return newbornState(key, clock);
  }
}
...
} catch {
  await backup(file, bak);   // always backs up `file`, even when `legacyFile` was actually read
  return newbornState(key, clock);
}

When the pre-0.2 .gitgotchi/state.json (legacyFile) is corrupt, file doesn't exist at the new location yet, so backup()'s copyFile(file, bak) throws ENOENT, silently swallowed by backup()'s best-effort catch. Result: a corrupt legacy state file gets no .bak, quietly violating CLAUDE.md's "Corrupt state → back up to .bak... never crash, never silently overwrite." Not data-destructive (a fresh newborn is still returned), but the promised backup silently doesn't happen. Re-confirmed test/state/store.test.ts still has no test for this: the legacy-file tests (lines 158-199) cover a valid legacy read, a stale-vs-external-state precedence case, and hasState() on an untouched legacy file — never a corrupt legacy file.

Minimal fix: track which path was actually read and back up from there:

let raw: string;
let source = file;
try {
  raw = await readFile(file, 'utf8');
} catch {
  source = legacyFile;
  try {
    raw = await readFile(legacyFile, 'utf8');
  } catch {
    return newbornState(key, clock);
  }
}
...
await backup(source, bak);

plus a test seeding a corrupt/invalid .gitgotchi/state.json with no state at the new location, asserting a .bak is written.

Fix this →

Minor nits (non-blocking, unchanged)

  • docs/gitgotchi-card.svg hand-edited rather than regenerated — honestly disclosed in the PR description; fine for now.
  • Single commit for the main ~1086-line diff deviates from "one milestone per PR... under ~400 lines," acknowledged with a reasonable rationale. Acceptable given the milestone is cohesive.

I don't have npm ci/network access in this sandbox to run npm test/typecheck/lint myself; relied on static reading plus tracing the corrupt-legacy-file path against the current source.

Overall: still a solid, principle-restoring change. This synchronize only fixed unrelated Windows CI timing — the one low-severity backup-path bug from earlier reviews remains open and untouched.

@dvd90

dvd90 commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

CI follow-ups

The three new jobs caught four real problems on their first run. All fixed; all 11 checks now pass.

npm pack shipped a broken tarball. prepublishOnly runs on npm publish but not on npm pack, so the tarball contained no dist/ and its bin pointed at a file that wasn't there. npm publish would have worked, which is exactly why this went unnoticed — the package job existed for a day and found it. Now prepack, which runs on both.

Windows checked out CRLF. Runners default to core.autocrlf=true, so prettier --check failed on all 73 files. .gitattributes pins the checkout to LF and leaves the width-sensitive sprite files unfiltered.

A latent test bug. commands.test.ts derived the expected folder name with join(repo).split('/').pop(), which returns the whole path when the separator is a backslash. initPet uses basename(), so the test does too now. The source was already correct — the test had just never run on Windows.

A slow first test, not a slow suite. One integration test took over 20s on Windows while its siblings took ~1.2s. main() imports its command modules lazily, so whichever test ran first absorbed the transform cost of the whole Ink/React tree. Warming those imports in beforeAll put it back at 591ms rather than reaching for a bigger timeout to hide it.

@dvd90
dvd90 merged commit c856845 into main Aug 15, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant