Skip to content

chore(sync): stop reading the retired mirror_path key - #409

Merged
entelecheia merged 3 commits into
mainfrom
chore/394-mirror-path
Nov 9, 2026
Merged

entelecheia merged 3 commits into
mainfrom
chore/394-mirror-path

Conversation

@entelecheia

@entelecheia entelecheia commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Part of #394 (row 10).

What changes

ResolveConfig no longer falls back to mirror_path: the workspace store's target
decides the destination, else the detected cloud default. That was the last reader of the
key, so LocalConfig.MirrorPath, UserGsyncState.MirrorPath and their writers are gone:

  • dot sync target writes the workspace store alone. Under --home it now writes the
    target home's store; before, only global state was home-aware, and the destination for
    a new workspace came from that state.
  • dot sync configure --target no longer mirrors the path into the store's old key.
  • SeedFromGlobal seeds a new store with an empty target, so the detected cloud default
    applies until someone sets one.

A file that still carries the key is not edited quietly. Both write choke points
(saveStateAt, SaveLocalConfig) say once what happened to it and name
dot sync target, then clear the flag so a second save in the same run is silent.
keepUnknownConfigKeys has to exempt the retired key, or every save would carry it back
and the note would be a lie.

A destination that would move without being asked is refused

Removing the fallback alone would move a store that never set target: its destination
becomes the detected default even when the store, or the global state it was seeded from,
still names another folder in mirror_path. Push and pull both propagate creates, updates
and deletes, so dot sync push on such a machine could mirror into a tree nobody chose and
delete there, with only a save-time note to say so later.

So the key is read in one place still, and only to refuse. When a store's target is empty
and its mirror_path (else the global state's) is not, the transfer entries (Push,
PullDirect, Intake, PullTracked, PlanPush, Fetch) compare the stored folder with
the destination this config resolved and stop before a transfer when they differ:

  • Fetch is included because it copies mirror to workspace: a destination that moved
    unasked would refill the workspace from a tree nobody picked and overwrite what is there.
  • symlinks are followed on both sides, so a folder spelled two ways stays one tree (the
    peer Mac's ~/Dropbox is a symlink into ~/Library/CloudStorage/Dropbox). A path that
    does not resolve is compared textually, which leans towards refusing.
  • the error names the file that still sets the key, the stored folder, the folder sync
    would use instead, and both dot sync target fixes.
  • the refusal sits in the transfer entries, not in ResolveConfig, because the fix the
    error names is dot sync target, which resolves and writes a config of its own.

Nothing routes through the key: a folder that matches proceeds with the default already
resolved, and a differing one never reaches a transfer.

The refusal is not one-shot

The refusal's only evidence is the retired value, and every routine save rewrites the file
that holds it, while target is still empty: dot sync pause/resume (the reload this
change tells users to run), dot sync owner, dot sync setup, dot sync shared all go
through SaveLocalConfig, and dot apply or dot reconfigure rewrite global state. A
save that dropped the key there would hand the next push the detected default, which is
exactly the redirect above.

So a retired value is treated as evidence, not a setting. A save carries it through
unchanged, and says nothing, while it could still move a destination:

  • store: target is empty and the value is not the same directory as the detected default.
  • global state: the value is not the same directory as that home's detected default.

Once the value names the folder resolution would use it decides nothing, so that save drops
it and prints the note. Both decisions use the symlink-aware comparison, and a layout built
without a home carries the value (the safe side of a doubt the save cannot settle).
--home writes judge against the target home's default, never the invoking user's.

Evidence: neither Mac depends on it

mirror_path still exists on both machines, and on neither does any store's destination
change without it: every live store sets target, which is read first.

This Mac:

  • ~/.config/dotfiles/config.yaml line 35: mirror_path: ~/Dropbox/work under
    modules.gdrive_sync, and no target key. That is the shape the carry rule protects.
  • <workspace>/.dotfiles/sync/config.yaml: target: local:~/Dropbox/work/ plus
    mirror_path: ~/Dropbox/work/.
  • <workspace>/.dotfiles/peer/config.yaml: target: ssh:... only.

The peer Mac, over ssh:

  • the same three shapes; its sync store target is written without a trailing slash.
  • a newly seeded store still lands on the same tree. This Mac has ~/Dropbox/secrets, so
    the default returns ~/Dropbox/work. The peer has
    ~/Library/CloudStorage/Dropbox/secrets (its ~/Dropbox is a symlink to that root), so
    the default returns ~/Library/CloudStorage/Dropbox/work, the same physical tree.

Verification

  • dot admit -- env GOMAXPROCS=2 go test -p 2 -parallel 2 ./internal/config ./internal/syncer ./internal/cli ./internal/fileutil passes.
  • dot admit -- make lint: 0 issues.
  • Dropping the key: a store carrying target and mirror_path loads, saves without the
    key and prints the note once; a stamped state file whose value names the default does the
    same; a store with only mirror_path resolves the detected default.
  • Refusing a move: a store naming another folder refuses, and the error names the stored
    folder and both fixes; the same folder through a symlinked cloud root does not refuse; a
    store whose global state names a folder refuses through that source; Fetch refuses and
    writes nothing; a store that sets target is unaffected.
  • Carrying the evidence: a store with no target and another folder in mirror_path saves
    through dot sync pause with the key still in the file and no note, and PlanPush on the
    next resolve still refuses; the same store with the default value in mirror_path drops
    the key and prints the note; a state file with another folder carries through a save.
  • The comparison itself is unit tested in internal/fileutil: the same folder through a
    symlink, a trailing separator, and two spellings of one missing path compare equal; a
    missing side and two different missing paths do not.
  • Each of the four tests guarding the new behavior was proved to bite by disabling its
    mechanism and watching it fail: with the carry disabled the save prints a drop note and
    PlanPush stops refusing; with the Fetch entry disabled the fetch runs.

Release note: mirror_path is no longer read for the sync destination; dot sync target sets it.

ResolveConfig fell back to mirror_path in the workspace store and then in
global state before the detected cloud default. The destination is the
store's target now, so the fallback goes, with LocalConfig.MirrorPath,
UserGsyncState.MirrorPath and every writer of them: `dot sync target`
writes the store alone, and under --home it writes that home's store.

A store or state file that still sets the old key is not edited quietly.
Both write choke points announce the key once and name `dot sync target`,
and keepUnknownConfigKeys exempts it, or the next save would carry it back
forever.

Part of #394.
Copilot AI balanced review requested due to automatic review settings November 9, 2026 13:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T13:20:09.002554Z 631f61f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

A store with no `target` resolves the detected cloud default, so a machine
that still names another folder in `mirror_path` would start pushing
(create+update+delete) and pulling against a tree nobody chose, with only a
save-time note to say so later. The transfer entries now compare the retired
value with the destination this config resolved, following symlinks on both
sides, and stop when they differ: the error names the stored folder and
`dot sync target <that folder>`. A same-directory match (the peer Mac's
~/Dropbox is a symlink into ~/Library/CloudStorage) proceeds untouched.

Resolution deliberately does not refuse: `dot sync target`, the fix the
error names, resolves a config of its own and would otherwise fail too.

Part of #394.
The refusal that came with removing the `mirror_path` fallback had one
piece of evidence, the retired value itself, and every routine save
rewrote the file holding it while `target` was still empty: `dot sync
pause`, `resume`, `owner`, `setup` and `shared` all save the store, and
`dot apply` or `dot reconfigure` save global state. After one of those the
store resolved the detected default, the refusal had nothing left to read,
and the next push mirrored, and deleted, in a folder nobody picked.

A retired value is evidence now, not a setting. A save writes it back
unchanged, and says nothing, while it could still move a destination; it
drops it with the existing note only once the value names the folder
resolution would use. A store save judges `target` first and then the
detected default; a state save judges that home's default, so `--home`
writes are decided against the target home. A layout built without a home
carries, which is the safe side of a doubt the save cannot settle.

`dot sync fetch` copies mirror to workspace, so a destination that moved
unasked would refill the workspace from a tree nobody picked and
overwrite what is there. It joins the transfer entries that refuse.

Also fixes the spelling of the retired state key: detection read
`modules.gsync.mirror_path`, and neither the struct tag nor any real file
has that block (`gdrive_sync`), so the live machine's key was never
detected and the passing test only proved the wrong spelling.
@entelecheia
entelecheia merged commit 6acddc0 into main Nov 9, 2026
14 checks passed
@entelecheia
entelecheia deleted the chore/394-mirror-path branch November 9, 2026 19:08
entelecheia added a commit that referenced this pull request Nov 9, 2026
Covers #399, #400, #401, #402, #404, #405, #407 and #409. #406 (spec
docs) and #408 (test isolation) are left out per the release-note rules.

Claude-Session: https://claude.ai/code/session_01Vb48Vt5iGMfc7MJWPTCWCL
entelecheia added a commit that referenced this pull request Nov 9, 2026
* chore(release): add v2.81.0 release notes

Covers #399, #400, #401, #402, #404, #405, #407 and #409. #406 (spec
docs) and #408 (test isolation) are left out per the release-note rules.

Claude-Session: https://claude.ai/code/session_01Vb48Vt5iGMfc7MJWPTCWCL

* chore(release): v2.81.0 notes, apply review findings

Name the resume reload fix (#411), the peer pair upgrade, the exact anchor
scope, the dot sync target change and the dot admit wait.

Claude-Session: https://claude.ai/code/session_01Vb48Vt5iGMfc7MJWPTCWCL
entelecheia added a commit that referenced this pull request Nov 10, 2026
* fix(sync): reload the scheduler unit on resume

`dot sync resume` rewrote a unit installed before #394 to `--store`, but
neither service manager reads a rewritten unit file until it is told to.
launchd keeps running the command line it cached when `load` finds the job
already loaded (which a failing job usually is), and systemd keeps the
ExecStart it read until `daemon-reload`. So the migration alone healed
nothing, and the `pause && resume` recovery hint that `dot sync status`
prints for a spawn-failed job did not work on either platform.

ResumeKind now unloads the plist before loading it on macOS, ignoring the
unload error the way InstallKind does, and runs a user-scoped
`daemon-reload` before starting the timer on Linux. The lifecycle test helper
pins the two-step resume, so a resume that skips the reload fails the tests
that record service-manager argv.

`dot sync target` no longer says a local target is recorded in the global
user state for future workspaces: since #409 the target is written only to
this workspace's store, and a workspace without one uses the detected cloud
folder.

Part of #394.

* test(sync): expect only the command that fails in a failed resume

The lifecycle row that injects a failure asserted every resume command for
the kind. On Linux that is wrong: a failed `daemon-reload` returns before the
timer is started, so `start` never runs. macOS differs only because launchd's
unload error is ignored, which puts the failure on the load.

The expectation now stops at the failing command on the platforms whose resume
returns there, which is what the two implementations do. Product code is
unchanged; the row is the only thing that was wrong, and only the ubuntu unit
job can execute it.

* test(sync): count daemon-reload as a lifecycle action

The installed-kind check treats a row that recorded no lifecycle action as a
vacuous fixture. The Linux resume row under an injected failure records
`--user daemon-reload` alone, which the counter did not recognise, so the
comparison passed and the row still failed on the counter (ubuntu unit CI).

The prefix is now counted, and a small table test pins the counter on both
platforms so this Mac notices an unrecognised command next time instead of
the ubuntu job.

* style(sync): spell recognize the US way in a comment

The repo's misspell linter runs with locale US, so the comment I added over
TestSchedulerLifecycleActionCount failed `make lint` on one word. Comment text
only.
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.

2 participants