PR #15 added stale-file protection, but the check compares bare disk_mtime != self._loaded_mtime. This misses a concurrent external write when:
the write lands within the filesystem's mtime resolution (same-second writes), or
the writer restores the original mtime after writing.
A missed detection means BCC silently overwrites an external edit without ever showing StaleDialog.
Fix
Pair mtime with file size. bcc_core.py gets a new ConfigStat (NamedTuple[mtime, size]) and config_fingerprint(path) helper alongside the existing config_mtime() (kept, still used/tested independently). bcc.py's MainWindow._loaded_mtime becomes _loaded_stat: core.ConfigStat | None, populated via config_fingerprint() at load and at both save-completion paths (normal save + merge). The save-path stale check now triggers on disk_stat != self._loaded_stat, which is true when either mtime or size differs.
A full content hash is out of scope per the issue — size+mtime is the intended middle ground.
bcc.py — _loaded_mtime → _loaded_stat; stale check now compares the full fingerprint
tests/test_core.py — 3 new tests, including the required regression case: write a file, snapshot it, rewrite with different (larger) content, force the original mtime back via os.utime, and assert the change is still detected because size differs
Verification
ruff check . — all checks passed
ruff format --check . — clean
pytest — 95 passed (was 92 on main; +3 new tests), including the hardening regression test
Note: one incidental fix included — bcc_core.py's existing non-breaking-space normalization (_normalize_unicode) used a literal NBSP character in .replace(...); rewritten as chr(0xA0) for robustness. No behavior change.
Closes #17
## Problem
PR #15 added stale-file protection, but the check compares bare `disk_mtime != self._loaded_mtime`. This misses a concurrent external write when:
- the write lands within the filesystem's mtime resolution (same-second writes), or
- the writer restores the original mtime after writing.
A missed detection means BCC silently overwrites an external edit without ever showing `StaleDialog`.
## Fix
Pair mtime with file **size**. `bcc_core.py` gets a new `ConfigStat` (`NamedTuple[mtime, size]`) and `config_fingerprint(path)` helper alongside the existing `config_mtime()` (kept, still used/tested independently). `bcc.py`'s `MainWindow._loaded_mtime` becomes `_loaded_stat: core.ConfigStat | None`, populated via `config_fingerprint()` at load and at both save-completion paths (normal save + merge). The save-path stale check now triggers on `disk_stat != self._loaded_stat`, which is true when **either** mtime or size differs.
A full content hash is out of scope per the issue — size+mtime is the intended middle ground.
## Files changed
- `bcc_core.py` — add `ConfigStat` NamedTuple + `config_fingerprint()`
- `bcc.py` — `_loaded_mtime` → `_loaded_stat`; stale check now compares the full fingerprint
- `tests/test_core.py` — 3 new tests, including the required regression case: write a file, snapshot it, rewrite with different (larger) content, force the original mtime back via `os.utime`, and assert the change is still detected because size differs
## Verification
- `ruff check .` — all checks passed
- `ruff format --check .` — clean
- `pytest` — 95 passed (was 92 on `main`; +3 new tests), including the hardening regression test
Note: one incidental fix included — `bcc_core.py`'s existing non-breaking-space normalization (`_normalize_unicode`) used a literal NBSP character in `.replace(...)`; rewritten as `chr(0xA0)` for robustness. No behavior change.
the_og
added the P2 label 2026-07-07 20:35:47 -04:00
Bare mtime equality can miss a concurrent external write that lands
within the filesystem's mtime resolution (same-second writes), or
where the writer restores the original mtime. Add config_fingerprint()
returning a (mtime, size) ConfigStat pair; the stale-file check in
bcc.py now compares both fields instead of mtime alone. config_mtime()
is kept as-is (still used/tested independently).
The previous commit accidentally normalized the non-breaking space
(U+00A0) in _normalize_unicode's replace() call to a regular space
during a copy/paste, turning that replace() into a no-op. Use an
explicit escape instead of the literal character so it can't
be silently corrupted again.
Two prior attempts to fix this line via a literal or -escaped
non-breaking space both silently reverted back to a no-op regular-space
replace during transcription. Using chr(0xA0) instead removes any
non-ASCII or backslash-escape character from the source line entirely.
MainWindow._loaded_mtime -> _loaded_stat (core.ConfigStat), populated
via core.config_fingerprint() at load/save time. The save-path stale
check now compares the full fingerprint (mtime AND size) instead of
bare mtime equality.
Includes the required regression case: rewrite a file with different
content, force the original mtime back via os.utime, and assert the
fingerprint still differs (because size changed).
Supervisor review (Cowork): approve. CI run #157 green; isolated diff. ConfigStat(mtime,size) + config_fingerprint correctly catches the same-mtime-different-size case, and the regression test forces the original mtime via os.utime to prove it. Minor: this PR also rewrites the _normalize_unicode NBSP line (behavior-identical) — it'll conflict with #23/#24/#25 which did the same; resolve by taking that line from any one branch at merge. Ready for v1.2.0.
Supervisor review (Cowork): **approve.** CI run #157 green; isolated diff. `ConfigStat`(mtime,size) + `config_fingerprint` correctly catches the same-mtime-different-size case, and the regression test forces the original mtime via `os.utime` to prove it. Minor: this PR also rewrites the `_normalize_unicode` NBSP line (behavior-identical) — it'll conflict with #23/#24/#25 which did the same; resolve by taking that line from any one branch at merge. Ready for v1.2.0.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #17
Problem
PR #15 added stale-file protection, but the check compares bare
disk_mtime != self._loaded_mtime. This misses a concurrent external write when:A missed detection means BCC silently overwrites an external edit without ever showing
StaleDialog.Fix
Pair mtime with file size.
bcc_core.pygets a newConfigStat(NamedTuple[mtime, size]) andconfig_fingerprint(path)helper alongside the existingconfig_mtime()(kept, still used/tested independently).bcc.py'sMainWindow._loaded_mtimebecomes_loaded_stat: core.ConfigStat | None, populated viaconfig_fingerprint()at load and at both save-completion paths (normal save + merge). The save-path stale check now triggers ondisk_stat != self._loaded_stat, which is true when either mtime or size differs.A full content hash is out of scope per the issue — size+mtime is the intended middle ground.
Files changed
bcc_core.py— addConfigStatNamedTuple +config_fingerprint()bcc.py—_loaded_mtime→_loaded_stat; stale check now compares the full fingerprinttests/test_core.py— 3 new tests, including the required regression case: write a file, snapshot it, rewrite with different (larger) content, force the original mtime back viaos.utime, and assert the change is still detected because size differsVerification
ruff check .— all checks passedruff format --check .— cleanpytest— 95 passed (was 92 onmain; +3 new tests), including the hardening regression testNote: one incidental fix included —
bcc_core.py's existing non-breaking-space normalization (_normalize_unicode) used a literal NBSP character in.replace(...); rewritten aschr(0xA0)for robustness. No behavior change.Supervisor review (Cowork): approve. CI run #157 green; isolated diff.
ConfigStat(mtime,size) +config_fingerprintcorrectly catches the same-mtime-different-size case, and the regression test forces the original mtime viaos.utimeto prove it. Minor: this PR also rewrites the_normalize_unicodeNBSP line (behavior-identical) — it'll conflict with #23/#24/#25 which did the same; resolve by taking that line from any one branch at merge. Ready for v1.2.0.Superseded by #26 (release: v1.2.0), which merged this change into
mainas part of the integrated release. Closing.Pull request closed