Skip to content

Phase 5: sync & freshness — wt sync, opportunistic fetch, staleness#14

Merged
loganthomas merged 18 commits into
devfrom
phase-5-sync-freshness
Jul 25, 2026
Merged

Phase 5: sync & freshness — wt sync, opportunistic fetch, staleness#14
loganthomas merged 18 commits into
devfrom
phase-5-sync-freshness

Conversation

@loganthomas

Copy link
Copy Markdown
Owner

Implements Phase 5 (Sync & freshness): the base stays current without a daemon.

What lands

  • wt sync [--all] — fetch the base's remote, fast-forward the local base (ff-only; a diverged or busy base is reported and left untouched), and report how far each tree trails it. --all re-parks every idle pool slot onto the new tip under its own lease, gated by the same refresh hash as a claim; claimed slots (and their work) are untouched.
  • Opportunistic fetch on wt new / wt claim — a base older than staleness_hours (default 24) is fetched once with a one-line notice, pointing at wt sync when the local base now trails upstream. The fetch only updates remote-tracking refs — it never fast-forwards a branch or moves a working tree. --no-fetch opts out; an offline failure degrades to a warning.
  • wt ls shows the base fetch age on stderr, best-effort and silent until wt has a fetch on record — it never touches the network.
  • staleness_hours config key, last_fetch state file, and a new internal/freshness policy package.

Design notes

  • --all follows git-town's current-vs-all convention: bare wt sync is the fast, safe command; the pool-wide re-park is the deliberate opt-in.
  • Opportunistic fetch is non-invasive by design (D7): fetch + notice, never an automatic fast-forward.
  • The deferred claim-path reset optimization stays deferred — perf-only with a real clean -ffd behavior delta and no benchmark yet justifying it. Recorded in PLAN.md.

Tests

TDD throughout: unit tests for freshness, state, config, and the gitx fetch/ff primitives; testscript e2e (sync.txtar with a local origin fixture advanced by the test — asserts slots re-park and a slot carrying user commits is untouched; fetch.txtar for the opportunistic fetch, --no-fetch, and the ls note). Full suite green under -race; golangci-lint v2 clean.

A small pure package owns the two decisions the freshness feature
shares across commands: whether a recorded fetch has aged past the
window, and how to say that age to a human. Time is injected so the
display side (ls) and the decision side (new, claim, sync) run the
same rule and cannot drift. pool ls's claim-age formatter is folded
onto freshness.Age so there is one coarse formatter, not two.
last_fetch is a repo-wide fact, so it is a single RFC3339 file at
the state root rather than a per-tree one. A missing or unparseable
stamp reads as never-fetched, the same fail-open rule the refresh
hash follows: the worst consequence is one extra fetch, always safe.
staleness_hours bounds how old the last base fetch may get before
new/claim refetch and ls marks it stale (default applied at the
use-site via DefaultStalenessHours, so a loaded config still mirrors
its files and a repo can narrow a global window). It round-trips
through Save so a read-modify-write like pool resize cannot drop it,
and negative values are rejected.
Fetch updates a remote's tracking refs without touching a working
tree, so it is safe to run opportunistically. Upstream/UpstreamRemote
resolve where a branch tracks, MergeFFOnly fast-forwards a
checked-out base (refusing anything but a pure fast-forward), and
FetchLocalFF does the same for a base checked out nowhere. All are
ff-only: a diverged branch is rejected, never rewritten.
When the base has aged past staleness_hours, wt new and wt claim
fetch it once with a one-line notice and, when the local base now
trails upstream, point at wt sync. The fetch only updates
remote-tracking refs — it never fast-forwards a branch or moves a
working tree — so the branch created is exactly what the user
expects; --no-fetch opts out, and an offline failure degrades to a
warning rather than blocking work. wt ls gains a best-effort
staleness note on stderr, silent until wt has a fetch on record so
it never touches the network or disturbs the machine listing.
wt sync fetches the base's remote, fast-forwards the local base
(reporting and skipping anything that is not a pure fast-forward),
and lists how far each tree trails it — never rewriting a branch
that carries user commits. --all adds the heavier step of re-parking
every idle slot onto the new base under its own lease, gated by the
same refresh hash as a claim, so a claimed slot's work is untouched
and a racing claim cannot collide. The behind-report runs last, so
re-parked slots read as current rather than trailing the base they
were just parked on.
Adds a slot-lifecycle diagram and a "staying fresh" section to
docs/pool-mode.md, a launchd recipe for scheduled wt sync --all to
docs/recipes.md, the staleness_hours reference and last_fetch state
entry to docs/configuration.md, wt sync to the README and agents
tables, and records Phase 5 in PLAN.md with the two news fragments.
A diverged or busy base printed a notice but returned success, so a
scheduled or scripted `wt sync` could not tell a completed sync from
a stuck one. fastForwardBase now returns a precondition error (exit
3, D13) for a base it could not advance; the behind-report still
prints, and the base is never rewritten.
A failed state-dir write no longer aborts the work the fetch was
for: `wt sync` still fast-forwards and reports, and the opportunistic
path still prints the behind-upstream pointer. The stamp only costs
one extra fetch next time if it is missed (LastFetch fails open), so
a write hiccup degrades to a note rather than a hard failure.
A free, detached slot could still hold uncommitted scratch, and the
shipped launchd recipe runs `wt sync --all` unattended — so a
reset-every-idle-slot policy would silently discard work in progress.
Re-park now refuses a dirty slot with a skip notice (tolerating wt's
own planted copies), and only announces a slot it actually re-parked.
staleness_hours = 0 is indistinguishable from unset and resolves to
the default, not to always-fetch; say so and point at wt sync to
force one. Note that the opportunistic fetch targets the base's own
configured remote, honoring git config exactly as a manual fetch.
The zero-means-unset default lived in a cli helper one layer above
the field it completes. StalenessWindow() co-locates it with
StalenessHours and DefaultStalenessHours in config, so every consumer
resolves the effective window the same way.
maybeFetchBase spawned a git subprocess (UpstreamRemote) before
reading the local fetch stamp, so every wt new and wt claim paid for
it even when the base was fetched moments ago — the common case on
both hot paths. Evaluate the cheap staleness gate first and only look
up the remote when a fetch is actually warranted.
The two consecutive upstream lookups repeated an identical
tracks-no-remote message and early return; fold them into one guard.
The fetch-stamp record collapses from a warnFetchRecord call in each
arm of an if/else into a single chained-error call.
Two reductions in the re-park and fast-forward paths, no behavior
change:

- Remove the lockless lease.Get pre-check before Acquire. Acquire is
  the authoritative gate (live and unreadable leases read as held,
  only provably dead ones are stolen, all under its flock), so the
  pre-read only duplicated that logic and raced it.
- Inline the single-use ffInPlace helper into fastForwardBase. The
  merge-vs-local-fetch choice is one step of the one fast-forward
  the function performs, not a separate 5-arg concern.
The local-ref fast-forward path (used by wt sync when the base
branch is checked out in no worktree, e.g. the main checkout on a
feature branch) had no coverage. Advance an origin, then ff a
non-checked-out local main through FetchLocalFF and assert it lands.
Extracting freshness.Age left humanAge as a single-caller pass-through
whose doc comment restated freshness.Age's own rationale. Inline it at
the pool ls call site so the coarse-age rule lives in exactly one
place.
@loganthomas
loganthomas merged commit d97c6f6 into dev Jul 25, 2026
4 checks passed
@loganthomas
loganthomas deleted the phase-5-sync-freshness branch July 25, 2026 00:54
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.

1 participant