Pull atomically and refuse to overwrite uncommitted local changes¶
Date: 2026-07-02
forest pull handed rclone the final workspace path (copyto straight onto
the file). Two failure modes followed, both flagged by
docs/research/dud-rclone-lessons.md (§4, §7): a pull killed mid-transfer
could leave truncated garbage at the final path, and a routine pull silently
destroyed local edits the user had not pushed anywhere. Dud's posture — the
workspace is user property; fail and make the user fix it — is the model.
Pull now downloads every file to a same-directory temporary name
(.<name>.<random>.forest-tmp) and os.replaces it onto the final path
only after the transfer succeeds, mirroring fsutil.atomic_write. The
destination file is always either its previous bytes or the complete new
bytes; stray temps are cleaned up on failure and excluded from sync file
listings so they can never be pushed.
Before transferring, each pull target that already exists locally is checked
against the last recorded sync entry for that unit: a file not covered by
any recorded snapshot (existing-but-untracked), a size mismatch, or — when
content hashes are recorded (ADR 0017) — a hash mismatch marks the unit
dirty. A dirty unit refuses with
ERROR <unit> local file has uncommitted changes: <file> (use --force to overwrite)
and exits nonzero; pull --force overrides. The check also runs under
--dry-run, so PLAN output predicts the real outcome. A fresh clone (no
local files) transfers normally; a clean re-pull may skip under ADR 0023.
Consequences¶
- Kill -9 mid-pull leaves old-or-new bytes, never truncated files; re-pull converges.
- Local edits — including same-size ones — are never silently overwritten;
recovering the remote version is an explicit
pull --force. - Entries recorded before this change carry no snapshot for pulls (and no
hashes at all), so the first pull over such files refuses conservatively;
one
--force(or a push) heals the entry to the new evidence-carrying shape. - Pull pays a same-directory rename per file; no extra copy (temp lives on the same filesystem).
- Contract locked in
tests/test_pull_core_lock.py(VAL-PULL-039..043).