Skip to content

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).