Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c63b683
docs(feature): brief the config key-skew fix
chess-seventh Jul 30, 2026
84dda4a
fix(config): report unrecognised ini keys instead of hiding them
chess-seventh Jul 30, 2026
bbf6c0a
refactor(config): resolve the ini path once and retain it
chess-seventh Jul 30, 2026
0df0b42
fix(config): name the file and key when a required key is missing
chess-seventh Jul 30, 2026
3b9fc1a
fix(diary): honour the configured time format in the commit row
chess-seventh Jul 30, 2026
a8a8bd5
test(config): cover key skew at the binary boundary
chess-seventh Jul 30, 2026
8844b6a
test(config): add the real-commit hook gate
chess-seventh Jul 30, 2026
146ea55
docs(readme): document how unrecognised ini keys are handled
chess-seventh Jul 30, 2026
a7ae31e
docs(readme): correct how an excluded repo is matched
chess-seventh Jul 30, 2026
49d689b
test(config): cover the paths the key work left untested
chess-seventh Jul 30, 2026
545748d
docs(feature): record the two faults the root-cause pass added
chess-seventh Jul 30, 2026
1b5ff82
docs(readme): correct which hook stage runs the saver
chess-seventh Jul 30, 2026
5ec4fa5
docs(readme): fix the required-key count and two stale lines
chess-seventh Jul 30, 2026
df42520
fix(config): reject a time format chrono cannot render
chess-seventh Jul 30, 2026
a19d1b9
docs(config): note that a blank or unrenderable value is fatal too
chess-seventh Jul 30, 2026
05173bf
fix(config): restore the doc comment the format check displaced
chess-seventh Jul 30, 2026
e778289
test(config): drive the unrenderable format through the real hook
chess-seventh Jul 30, 2026
4936e52
docs(readme): document the third fatal config path
chess-seventh Jul 30, 2026
c905d35
test(config): make the wrote-nothing assertion actually bite
chess-seventh Jul 30, 2026
5423cea
test(config): serialise the tests that share the config env var
chess-seventh Jul 30, 2026
3a37b1a
docs(config): stop the control test claiming more than it checks
chess-seventh Jul 30, 2026
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
60 changes: 53 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,11 @@ and appends it to a dated diary entry in your Wiki directory.

## Usage 🛞

Simply commit as usual. The pre-commit hook will:
Simply commit as usual. The hooks will:

1. Run linters (`clippy`, `rustfmt`, etc.) inside the Nix shell
2. Invoke Rusty Commit Saver to log the commit
1. Run linters (`clippy`, `rustfmt`, etc.) inside the Nix shell — **pre-commit**
2. Invoke Rusty Commit Saver to log the commit — **post-commit**, once the
commit exists, which is why nothing this tool does can cost you a commit

If you prefer manual invocation:

Expand Down Expand Up @@ -119,16 +120,18 @@ commit_path = Diaries/Commits
commit_date_path = %Y/%m-%B/%F.md
commit_datetime = %H:%M:%S

# Optional: repositories to skip, by working-directory name (comma-separated).
# Optional: repositories to skip, by canonical repo name (comma-separated).
# A commit made in one of these repos writes nothing to the diary.
[exclude]
repos = claude-src
```

The `[exclude]` section is optional. Each entry is matched, case-sensitively,
against the committing repository's working-directory name (e.g. `claude-src`
for a repo checked out at `~/src/claude-src`) — so it holds no matter which
subdirectory the commit is made from.
against the committing repository's **canonical name** — taken from its `origin`
remote URL (`…/claude-src.git` → `claude-src`), falling back to the
working-directory name for a repo with no usable `origin`. Because the origin is
the same from every checkout, one entry covers the main clone and every git
worktree of that repo, from any subdirectory.

`[obsidian]` and `[templates]` are required; a config missing either one is
fatal. Any **other** section is ignored, with a line on stderr naming it, never
Expand All @@ -139,6 +142,49 @@ exactly what adding `[exclude]` did to every checkout older than 4.17.0.
The stderr line matters: a misspelt section (`[excludes]`) is ignored too, so
without it your exclusions would silently stop applying.

Keys work the same way, for the same reason:

- A key this binary does not understand is **ignored and named on stderr**
(`ignoring unrecognised config keys [templates] commit_datetimes`). It used to
be ignored in complete silence, so a typo applied nothing and said nothing.
- The four keys in `[obsidian]` and `[templates]` are **required**, and so is a
non-empty value for each — `commit_path =` counts as missing. Without them
there is no destination to write to, and a hook that quietly journals nothing
looks exactly like a quiet day, so this one stays fatal. (`[exclude] repos` is
optional, like its section.)
- The two `[templates]` values must be formats `chrono` can actually render,
and that is checked when the config is read. A bad specifier used to surface
from inside the writer as `a formatting trait implementation returned an
error`, naming nothing, after an empty diary file had already been created.
- The fatal message names the config file, the key and its section, plus any
unrecognised key in that same section, since a misspelt `commit_paths` is the
usual reason `commit_path` is missing:

```text
rusty-commit-saver: /home/you/.config/rusty-commit-saver/rusty-commit-saver.ini:
missing required key 'commit_path' in section [obsidian];
unrecognised in [obsidian]: commit_paths
```

None of this can cost you a commit: the tool runs as a **post-commit** hook, and
git ignores that hook's exit status. A config fault costs you the diary entry
and prints on stderr; the commit itself always stands.

### Checking hook behaviour by hand

`tests/hook-gate.sh` drives a real commit through a real post-commit hook, in a
throwaway repo and vault, and prints what a human would see:

```bash
cargo build
./tests/hook-gate.sh good # journals, says nothing
./tests/hook-gate.sh unknown-key # journals, names the key on stderr
./tests/hook-gate.sh missing-key # journals nothing, names file + key
./tests/hook-gate.sh blank-key # same, for a key with an empty value
./tests/hook-gate.sh bad-format # same, for a format chrono cannot render
./tests/hook-gate.sh unknown-section # journals, names the section
```

---

## Roadmap & Improvements 📈
Expand Down
127 changes: 127 additions & 0 deletions docs/feature/L76-config-key-skew/brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Bug-fix brief - L76 config key skew

> Lean nWave motion, standard-rigor gate. Second axis of the outage L66 closed:
> 4.17.3 made an unrecognised config SECTION non-fatal, but the KEYS were left
> as they were. Root cause known up front; this brief records the defect, the
> decision, the gate, and the acceptance scenarios that drive
> RED -> GREEN -> COMMIT.

## Defect

One INI file at `~/.config/rusty-commit-saver/rusty-commit-saver.ini` is shared
by every checkout on the machine, while the binary reading it is pinned
per-repo and per-home-manager-generation. Config and binary therefore drift
apart by design, and the config parser has two axes of skew: sections and keys.
Only sections were made tolerant.

Measured on 4.17.3, by driving real commits through a real post-commit hook
(`git init` repo, `core.hooksPath` pointing at a hook that execs the binary):

| config fault | what the user sees | journalled |
|---|---|---|
| unrecognised section | one stderr line naming the section | yes |
| unrecognised key | **nothing at all** | yes |
| missing required key | `thread 'main' panicked at src/config.rs:862:14: Could not get commit_path from config` + backtrace note | no |

Both key rows are wrong, and they compound:

1. **An unrecognised key is swallowed in total silence.** `configparser` simply
never returns a key nobody asks for, and there is no key-level equivalent of
`KNOWN_SECTIONS` (`src/config.rs:617`). A `commit_datetimes` typo, or a key
renamed by a newer release, applies nothing and says nothing.
2. **A required key that is absent panics with a message that does not name the
config file.** Four sites hard-`.expect()` on the `Option`:
`src/config.rs:738-747` (`commit_datetime`), `819-830` (`commit_date_path`),
`859-885` (`commit_path`), `920-950` (`root_path_dir`). The panic text names
the key but not the file, and `retrieve_config_file_path()`
(`src/config.rs:1050`) returns the file *contents*, so the resolved path is
not retained anywhere for an error message to use.

Together they make a rename the same outage as L66 through a different door:
the new key is ignored silently (1), the old key is missing, so the run panics
(2) - and the message points at a key rather than at the file to edit.

## Decision: fatal, but say why

A post-commit hook cannot abort a commit - git ignores its exit status, which
the gate below verifies (`git commit` exits 0 and the commit exists in every
fault case). So the real cost of a config fault is stderr noise plus a
silently missing diary entry, never a lost commit.

Given that, the policy is:

- **Unrecognised key -> warn and continue.** Same reasoning as the section fix:
a config written for a newer release must not brick an older binary.
- **Missing required key -> still fatal**, because without it there is no
destination to write to. Degrading to "journal nothing, exit 0" would make a
broken config indistinguishable from a quiet day, and the diary would stop
for weeks unnoticed. The fix is the message, not the severity: name the
resolved config file and the exact `[section] key`.
- Both warnings go to **stderr as well as the log**, because the hook runs
without `RUST_LOG` and `env_logger` caps the level at Error there - a
log-only warning is invisible in practice.

Decided by Franci, 2026-07-30, over "never fatal, always degrade" and "fatal
only when there is no destination".

Two findings from the root-cause pass widened that decision, both reproduced
before acting on them:

- **A blank value defeats a presence check.** `commit_path =` satisfied
`Option::is_some`, exited 0, and journalled into the vault *root* instead of
the configured folder - a wrong-location write with no error at all. An
empty or whitespace value therefore counts as missing. A test that blessed
the same hole for `root_path_dir` (empty vault root resolving to `/`) is
withdrawn deliberately rather than worked around.
- **`commit_datetime` was required and never read.** The TIME column was
hardcoded to `%H:%M:%S`, so a typo in that key could abort a run over a
value nothing consumed - fatal-and-ignored, which no policy can defend.
Franci's call: wire it up, so the key means what it says. The live config
already carries `%H:%M:%S`, so no diary output changes.

## Fix

- New `KNOWN_KEYS` table beside `KNOWN_SECTIONS` (`src/config.rs`), listing what
each known section understands.
- New `unrecognised_keys()` returning the sorted `[section] key` list, and
`report_unrecognised_keys()` warning it to log + stderr, called from
`set_obsidian_vars()`.
- `GlobalVars` gains `config_path`, set in `set_all()` from the resolved path,
so an error can name the file. `get_ini_file_at()` / `read_config_file()`
split out for that; `get_ini_file()` and `retrieve_config_file_path()` keep
their signatures and behaviour.
- New `require_key()` replaces the four `.expect()` calls with one fatal path
whose message names the file, the key, and the unrecognised keys of that same
section; it rejects a blank value as missing.
- `[templates] commit_datetime` is threaded from `main()` through
`run_commit_saver()` and `append_entry_to_diary()` to the row builder in
`src/vim_commit.rs`, which had the format hardcoded.

## Gate (acceptance scenarios)

1. A key the binary does not know, in a section it does -> named on stderr,
run continues, commit still journalled. *(the silent half)*
2. A required key absent -> run aborts with a message naming the resolved
config file and `[section] key`, no bare "Could not get X from config".
*(the unhelpful half)*
3. An unrecognised **section** -> still warns and continues (4.17.3 behaviour
must not regress).
4. A good config -> no warning at all on stderr, entry journalled as before.
5. A required key present but **blank** -> treated as missing, same message,
nothing journalled. Previously: exit 0 and a diary in the wrong directory.
6. The configured time format reaches the diary row, instead of the hardcoded
one.

Verified by unit tests in `src/config.rs`, plus - and this is the point of the
lane - the real-hook gate: a genuine `git commit` in a throwaway repo whose
`core.hooksPath` runs the built binary against each config above, asserting the
stderr text, the commit's exit status, and whether a diary file appeared.
Script: `tests/hook-gate.sh`. Full gate: `devenv shell -- pre-check`.

## Deploy (Franci)

Merge to master -> the release workflow bumps + tags from the conventional
`fix:` commit; then `up-hm` deploys the new binary. The machine's global
post-commit hook still pins 4.17.0, which is unaffected by the current config
(it knows `[obsidian]`, `[templates]` and `[exclude]`) - verified, no live
outage waiting on this deploy.
Loading
Loading