8000 fix(snooze): let --snooze re-affirm a lapsed entry by k-sok · Pull Request #167 · habit-hooks/habit-hooks · GitHub
[go: up one dir, main page]

Skip to content

fix(snooze): let --snooze re-affirm a lapsed entry - #167

Closed
k-sok wants to merge 5 commits into
habit-hooks:mainfrom
k-sok:fix/163-snooze-reaffirm
Closed

k-sok wants to merge 5 commits into
habit-hooks:mainfrom
k-sok:fix/163-snooze-reaffirm

Conversation

@k-sok
@k-sok k-sok commented Sep 11, 2026
Copy link
Copy Markdown
Contributor

Closes #163. No new option — --snooze itself is fixed, as decided on the issue.

An entry was only a path, so a second --snooze wrote the same file again and nothing changed.
An entry now also stores the content of each file it covers:

[{"key": "requests", "anchors": {"src/a.py": "sha256:…", "src/b.py": "sha256:…"}}]

A snooze now stops working only if both of these are true:

  1. git says the file changed since the branch started, and
  2. the file no longer matches the stored content.

So running --snooze again clears the finding, until the next edit.

Both conditions are needed. The spec already promises that "work someone else lands on the base
ref afterwards never lapses a snooze you did not touch", and the case ### Work landed on the base ref afterwards lapses nothing checks it. If we compared only content, that case would fail.

Compatibility and details

An entry that stores nothing stays a plain string. An old index still loads, and is written back
unchanged. A project moves to the new format one --snooze at a time. --list prints plain keys
in both cases.

An entry stores one hash per file, not one per key, because a key can stand for several files
(deptry uses module names, knip uses export names). A hash for the key alone would cover a file
that nobody looked at.

--prune now also drops a file that the run no longer reports, not only a key. Its other rules are
unchanged.

Hashes ignore the difference between \r\n and \n. This is slightly more forgiving than
git diff, and it means a hash written on Windows still matches on Linux. I did not use
git hash-object, because --snooze has to work without a git repository.

Two things worth knowing before merging

1.5.0 cannot read an index that stores content. It raises SnoozeError and exits 2. This is
also true for an index that is only half migrated. So the upgrade works forwards but not backwards:
if one person on a team upgrades, the runs of everyone else fail, and CI fails too, until they
upgrade as well. The failure is loud and names the file, which is the right way to fail, but it is
still a breaking change in a minor version.

It also affects people who do not use this feature. --snooze stores content even without
--until-changed. A project that uses plain snooze gets the same break.

--snooze re-affirms everything you give it. If you run
habit-sensors --all | habit-snooze --snooze to add one key, it also re-affirms every other lapsed
entry in the pipe. It even stores a file that has only just started to report a key you snoozed
earlier. Nothing is printed, and --list looks the same before and after. Only the index file
shows it.

This follows directly from fixing --snooze instead of adding a flag. It is the best remaining
argument for the --reaffirm flag that was rejected. The spec case states it.

Three smaller limits, all known: the index is written on one line, so a re-affirmation shows up as
one changed line; two --snooze runs at the same time can still lose an update, as before; and a
smudge filter such as ident rewrites the file on commit, so the stored hash never matches again.

Tests

  • New acceptance case in docs/habit-snooze.spec.md: --snooze re-affirms a lapsed entry, and the
    next edit brings the finding back. It fails on main.
  • New unit tests for what an entry records, which snoozes a recording covers, what --prune keeps
    and reaps, and every shape the index now refuses.

uv run pytest tests/ docs/ → 821 passed, 5 skipped, on Python 3.11, the version CI pins.
ruff check and habit-hooks --all are both clean.

Changelog

I did not touch CHANGELOG.md. One sentence, if it is useful:

--snooze re-affirms a snooze whose file has changed, clearing the finding until the next
edit instead of writing the index back unchanged. Recording that needs a new entry shape, which
every --snooze writes — plain snooze too — so an index this version has written cannot be
read by 1.5.0.

k-sok and others added 5 commits September 12, 2026 00:15
A pure move: no message, exit code, signature or file format changes,
and no test asserts anything new. `anchor_file`, `finding_keys` and
`snoozed_anchors` read the findings contract, not the index file and not
the CLI, so neither module they sat beside was their home.

Preparation for habit-hooks#163: the lapse rule is expressed in exactly these terms
and is about to be built here, and the move keeps snooze.py clear of the
200-line gate for what follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The on-disk shape changes: an entry may now be {"key": …, "anchors": {…}}
as well as a bare key string, and an entry recording nothing is still
written bare, so an index written before this loads and rewrites
unchanged. `Entry` and `Anchors` name the two halves, so a signature says
which string is a key, which is a file and which is a digest.

Nothing records into `anchors` yet, so no command does anything new. What
does change is the refusal a malformed file earns, in four ways:
"expected a JSON list of string keys" becomes "…of snoozed entries"; a
new message names the shape an entry may take; a key written twice is
refused when the two disagree, since loading into a mapping keeps the
last and the order in the file would otherwise decide which recording
survives; and a JSON name written twice inside one object is refused
too, which `json` resolves last-wins in silence — an anchor pasted twice
with two digests would otherwise load as one of them. Entries that agree
still load, as they always did. The corrupt-index spec case moves with
the first of those.

Preparation for the fix in the next commit (habit-hooks#163).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A snooze lapsed when git said the anchor file had changed, and nothing a
re-run of --snooze could write made that answer differ: the entry was a
bare path, so the second write was byte-identical. Splitting the file or
excluding it permanently were the only ways out, and the second is the
exemption list the ratchet replaced.

An entry now records each anchor's content, and a snooze lapses only when
the file changed *and* no longer holds what was recorded. Re-affirming
therefore clears the finding until the next edit, while a branch that
never touched the file is still left alone by the git half — which
`### Work landed on the base ref afterwards lapses nothing` guards.

Recording opens an anchor as a path, so `anchor_file` now reads a
`details.file` that is not a non-empty string as absent, the way
`sensors.finding_paths` already reads it. That stage lets such an issue
through rather than failing its sensor, so one reaches here.

Only an anchor git called changed is hashed: the other half decides the
rest without it, and asking them all would read every snoozed file in the
project on a run where git flagged none. Closes habit-hooks#163.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An entry records one content per anchor file, and those go stale inside
an entry that is still live: a key reported through two files keeps both
recorded after one of them is deleted, and pruning by key alone would
leave the dead one there forever.

--prune reaps an anchor the same way it reaps a key, so it asks
`anchors_by_key` rather than the bare key list; `finding_keys` goes with
its last caller. The contract is unchanged — --prune must still be fed a
snooze-free whole-project run — and its refusal to empty a populated
index on a run that measured nothing is untouched. Follows habit-hooks#163.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec harness runs each case inside this checkout, so a case that
shells out to git without a ceiling is answered about habit-hooks itself,
and one whose `git init` fails commits into the reader's own repository.

Both git-backed sections in habit-snooze.spec.md get the step, at section
level so their subsections inherit it. CLAUDE.md's list of the cases that
still lack one shrinks to the two in habit-sensors.spec.md. Noticed while
working on habit-hooks#163, not part of that fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@devill
devill commented Sep 18, 2026 •
Copy link
Copy Markdown
Collaborator

Sorry for the slow response. The main reason was that the PR was rather large, and that made it hard to review. When I did get to it I noticed some underlying design issues that I wanted to fix instead, and make this fix a by product of fixing that deeper issue.

My alternative solution is now in main. Commit is here:
6da8e10

I referenced you on that commit as co-author. Thanks for the contribution!

84E5

@devill devill closed this Sep 18, 2026
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.

snooze-until-changed: a lapsed entry cannot be re-affirmed

2 participants

0