Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions internal/cli/peer_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,8 @@ the new owner and removes its scheduler, so two coordinators never run.`,
}

// peerBootstrapOptions translates the peer command tree's flags into the
// engine's bootstrap options. The profile is fixed here, which is why
// `--profile` is deliberately not read: `dot peer` only ever addresses the
// peer store.
// engine's bootstrap options. The profile is fixed here, which is why no
// flag selects it: `dot peer` only ever addresses the peer store.
func peerBootstrapOptions(cmd *cobra.Command) syncer.BootstrapOptions {
dryRun, _ := cmd.Flags().GetBool("dry-run")
verbose, _ := cmd.Flags().GetBool("verbose")
Expand Down Expand Up @@ -331,7 +330,7 @@ on a laptop.`,
p.Line("%s %d deletion(s) from here under %s on the peer", verb, res.QuarantinedOnPeer, dir)
}
p.Line("Conflicting edits keep both versions; list them with:")
p.Line(" dot sync conflicts --profile=%s", PeerProfile)
p.Line(" dot sync conflicts --store=%s", PeerProfile)
return nil
},
}
Expand Down
31 changes: 2 additions & 29 deletions internal/cli/sync_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,25 +96,9 @@ Run without a subcommand to print this help.`,
return cmd.Help()
},
SilenceUsage: true,
// --store and the deprecated --profile name the same thing; two
// different values must not silently pick one (#380).
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
if c.Flags().Changed("store") && c.Flags().Changed("profile") {
store, _ := c.Flags().GetString("store")
profile, _ := c.Flags().GetString("profile")
if store != profile {
return fmt.Errorf("--store %q and --profile %q name different stores; drop --profile, which is the deprecated name of --store", store, profile)
}
}
return nil
},
}
cmd.PersistentFlags().String("store", syncer.DefaultProfile,
"sync store under <workspace>/.dotfiles/<store>/; \"sync\" is the cloud mirror, \"peer\" the other Mac")
cmd.PersistentFlags().String("profile", "", "Deprecated: use --store")
// Hidden, not MarkDeprecated: cobra writes that notice to stdout, which
// would corrupt --json output; syncStoreFrom warns on stderr instead.
_ = cmd.PersistentFlags().MarkHidden("profile")
cmd.PersistentFlags().BoolP("verbose", "V", false, "Show rsync progress output")
Comment thread
entelecheia marked this conversation as resolved.
Resolved
cmd.AddCommand(
newSyncPullCmd(),
Expand Down Expand Up @@ -153,28 +137,17 @@ Run without a subcommand to print this help.`,
return cmd
}

// syncStoreFrom is --store, or the deprecated --profile when that was given.
func syncStoreFrom(cmd *cobra.Command) string {
if cmd.Flags().Changed("profile") {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: dot sync --profile is deprecated; use --store (it will be removed after one release)")
v, _ := cmd.Flags().GetString("profile")
return v
}
v, _ := cmd.Flags().GetString("store")
return v
}

// syncBootstrapOptions translates the sync command tree's flags into the
// engine's bootstrap options. Flag translation is cli's retained role; state
// loading, profile resolution and runner construction are the engine's
// (syncer.Bootstrap, D-07).
func syncBootstrapOptions(cmd *cobra.Command, readOnly bool) syncer.BootstrapOptions {
dryRun, _ := cmd.Flags().GetBool("dry-run")
profile := syncStoreFrom(cmd)
store, _ := cmd.Flags().GetString("store")
verbose, _ := cmd.Flags().GetBool("verbose")
filterMode, _ := cmd.Flags().GetString("filter-mode")
return syncer.BootstrapOptions{
Profile: profile,
Profile: store,
ReadOnly: readOnly,
DryRun: dryRun,
Verbose: verbose,
Expand Down
16 changes: 5 additions & 11 deletions internal/cli/sync_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,28 +361,22 @@ func TestSyncStatusSeparatesLiveConflictsFromBackups(t *testing.T) {
}
}

// --store names the sync store; --profile still works for one release, warns,
// and is gone from every command that is not dot sync (#380).
func TestSyncStoreFlagAndDeprecatedProfile(t *testing.T) {
// --store names the sync store; the deprecated --profile alias is gone (#394).
func TestSyncStoreFlag(t *testing.T) {
f := newSyncCLIFixture(t)
_ = f
out, errOut, err := runDotForTest("sync", "status", "--profile", "sync")
if err != nil || !strings.Contains(errOut, "use --store") || strings.Contains(out, "deprecated") {
t.Fatalf("deprecated --profile: err=%v stderr=%q stdout=%q", err, errOut, out)
}
if _, _, err := runDotForTest("sync", "status", "--store", "sync"); err != nil {
t.Fatalf("--store: %v", err)
}
if _, _, err := runDotForTest("sync", "status", "--profile", "sync"); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("sync status --profile: %v", err)
}
if _, _, err := runDotForTest("peer", "status", "--profile", "x"); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("peer status --profile: %v", err)
}
if _, _, err := runDotForTest("sync", "status", "--mode", "clean"); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("sync status --mode: %v", err)
}
// Two different stores through both names is refused, not silently resolved.
if _, _, err := runDotForTest("sync", "status", "--store", "sync", "--profile", "peer"); err == nil || !strings.Contains(err.Error(), "name different stores") {
t.Fatalf("conflicting --store/--profile: %v", err)
}
// Only the commands that read --profile take it.
for _, args := range [][]string{{"watchdog", "status", "--profile", "x"}, {"container", "status", "--profile", "x"}} {
if _, _, err := runDotForTest(args...); err == nil || !strings.Contains(err.Error(), "unknown flag") {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/sync_names_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
const nfdCLIPlanLimit = 20

// newSyncNamesCmd owns workspace filename maintenance below `dot sync names`.
// The parent sync command supplies the inherited --profile, --dry-run, and
// The parent sync command supplies the inherited --store, --dry-run, and
// --yes flags; keeping those flags inherited avoids a second source of truth.
func newSyncNamesCmd() *cobra.Command {
cmd := &cobra.Command{
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/sync_setup_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,19 @@ func runSyncResume(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
dryRun, _ := cmd.Flags().GetBool("dry-run")
if res.WasPaused {
p.Line("✓ sync resumed.")
} else {
p.Line("sync was not paused.")
}
for _, store := range res.MigratedStores {
if dryRun {
p.Line(" ~ would rewrite the installed scheduler unit to call `dot sync --store=%s`", store)
continue
}
p.Line("✓ rewrote the installed scheduler unit to call `dot sync --store=%s`", store)
}
if res.SchedulerOff {
p.Line("scheduler remains off — run `dot sync setup --push-interval=DUR` or `--pull-interval=DUR` to enable.")
return nil
Expand Down
24 changes: 24 additions & 0 deletions internal/cli/sync_status_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func runSyncStatus(cmd *cobra.Command, _ []string) error {
} else {
p.KV("Pull scheduler", "(off — `dot sync setup --pull-interval=DUR` to enable)")
}
reportStaleStoreUnits(p, sched)
if st.Propagation.Delete {
p.KV("Max delete", fmt.Sprintf("%d", st.MaxDelete))
}
Expand All @@ -134,6 +135,29 @@ func runSyncStatus(cmd *cobra.Command, _ []string) error {
return nil
}

// reportStaleStoreUnits names an installed unit that still calls
// `dot sync --profile=<store>`. This release rejects that flag, and neither
// `dot update` nor `dot apply` rewrites an installed unit, so the scheduled run
// fails on every tick while status would otherwise report it as running (#394).
// Status stays read-only: it prints the reload pair that rewrites the unit,
// which is the same idiom the spawn-failed recovery already names.
func reportStaleStoreUnits(p *Printer, sched *syncer.Scheduler) {
for _, unit := range []struct {
kind syncer.SchedulerKind
label string
}{
{syncer.SchedulerKindPush, "push"},
{syncer.SchedulerKindIntake, "pull"},
} {
store, stale := sched.StaleStoreUnit(unit.kind)
if !stale {
continue
}
command := fmt.Sprintf("dot sync --store=%s pause && dot sync --store=%s resume", store, store)
p.Warn("the installed %s unit still calls `dot sync --profile=%s`, which this release no longer accepts, so its scheduled runs fail; reload it with `%s`", unit.label, store, command)
}
}

// printConflictState shows live conflicts apart from retained backups (#264):
// live conflicts block a clean push, backups are copies that need nothing
// until they are pruned.
Expand Down
94 changes: 94 additions & 0 deletions internal/cli/sync_status_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,100 @@ func TestStatusCommandsReportRefusedConflictDirectory(t *testing.T) {
}
}

// installStaleStoreUnit writes the unit this platform installs for a
// non-default store, still calling the retired `--profile=` argument, and
// returns its path.
func installStaleStoreUnit(t *testing.T, home, store string) string {
t.Helper()
files := []struct{ path, body string }{
{
path: filepath.Join(home, "Library", "LaunchAgents", "com.dotfiles."+store+".plist"),
body: "<string>--profile=" + store + "</string>\n",
},
}
if runtime.GOOS != "darwin" {
files = []struct{ path, body string }{
{
path: filepath.Join(home, ".config", "systemd", "user", "dotfiles-"+store+".service"),
body: "ExecStart=%h/bin/dot sync push --profile=" + store + "\n",
},
{
path: filepath.Join(home, ".config", "systemd", "user", "dotfiles-"+store+".timer"),
body: "[Timer]\nOnUnitActiveSec=15m\n",
},
}
}
for _, file := range files {
writeCLITestFile(t, file.path, file.body)
}
return files[0].path
}

// stubSchedulerManager puts a service manager on PATH that reports the job as
// neither running nor loaded and accepts every lifecycle action.
func stubSchedulerManager(t *testing.T) {
t.Helper()
bin := t.TempDir()
body := "#!/bin/sh\n[ \"$1\" = print ] && exit 1\nexit 0\n"
for _, name := range []string{"launchctl", "systemctl"} {
if err := os.WriteFile(filepath.Join(bin, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
}

// A unit installed before #394 keeps invoking `dot sync push --profile=<store>`
// and fails with "unknown flag" on every tick, and nothing rewrites an
// installed unit. `dot sync status` is where an operator looks, so it names the
// unit and the reload pair; `dot sync resume` is the command that re-arms
// units, so it rewrites the argument before reloading.
func TestSyncReportsAUnitInstalledWithTheRetiredStoreFlag(t *testing.T) {
home, root := goldenSyncFixture(t)
stubSchedulerManager(t)
// A custom store is where `dot sync setup --store=<store>` may install a
// unit; `dot peer setup` owns the peer store's plist instead.
writeCLITestFile(t, filepath.Join(root, ".dotfiles", "research", "config.yaml"),
"target: local:"+filepath.Join(home, "gdrive-workspace", "work")+"\npropagation:\n create: true\n update: true\n delete: false\ninterval: 600\n")
unit := installStaleStoreUnit(t, home, "research")
before, err := os.ReadFile(unit)
if err != nil {
t.Fatal(err)
}

out, stderr, err := runDotForTest("sync", "status", "--store=research")
if err != nil {
t.Fatalf("status: %v, %s", err, stderr)
}
for _, wanted := range []string{"still calls `dot sync --profile=research`", "dot sync --store=research pause && dot sync --store=research resume"} {
if !strings.Contains(out+stderr, wanted) {
t.Fatalf("status missing %q: %s%s", wanted, out, stderr)
}
}
if after, err := os.ReadFile(unit); err != nil || string(after) != string(before) {
t.Fatalf("status rewrote the unit: %v", err)
}

out, stderr, err = runDotForTest("sync", "resume", "--store=research")
if err != nil {
t.Fatalf("resume: %v, %s", err, stderr)
}
if !strings.Contains(out+stderr, "rewrote the installed scheduler unit to call `dot sync --store=research`") {
t.Fatalf("resume did not report the rewrite: %s%s", out, stderr)
}
rewritten, err := os.ReadFile(unit)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(rewritten), "--profile=") || !strings.Contains(string(rewritten), "--store=research") {
t.Fatalf("unit still runs the retired flag:\n%s", rewritten)
}
out, stderr, err = runDotForTest("sync", "status", "--store=research")
if err != nil || strings.Contains(out+stderr, "still calls") {
t.Fatalf("status kept warning after the rewrite: %v, %s%s", err, out, stderr)
}
}

func TestSyncStatusReportsSpawnFailed(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("launchd status is macOS-specific")
Expand Down
2 changes: 1 addition & 1 deletion internal/syncer/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
// the same struct without inventing a flag set.
type BootstrapOptions struct {
// Profile names the sync store to resolve. Empty means the default
// profile, matching the `--profile` flag's own default.
// profile, matching the `--store` flag's own default.
Profile string

// ReadOnly resolves the profile without creating the local store,
Expand Down
2 changes: 1 addition & 1 deletion internal/syncer/name_normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ func nfdPushRefusal(marked bool, renames int, profile string) error {
return nil
}
return fmt.Errorf(
"found %d selected filename(s) requiring NFD migration; run `dot sync names normalize --profile=%s --dry-run`, then rerun with `--yes`",
"found %d selected filename(s) requiring NFD migration; run `dot sync names normalize --store=%s --dry-run`, then rerun with `--yes`",
renames, profile)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/syncer/owner.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,8 @@ func (e *OwnerMismatchError) Error() string {
"profile %q is owned by %q but this machine answers to %q; refusing to write.\n"+
" Two writers on one target corrupt it: each machine keeps its own baseline and\n"+
" delete propagation would make the runs undo each other.\n"+
" To move ownership here: dot sync owner --profile=%s --set-self\n"+
" To read instead of write: dot sync pull --profile=%s",
" To move ownership here: dot sync owner --store=%s --set-self\n"+
" To read instead of write: dot sync pull --store=%s",
e.Profile, e.Owner, e.Host, e.Profile, e.Profile)
}

Expand Down
6 changes: 3 additions & 3 deletions internal/syncer/peer_doctor_facts.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ func answersTo(names []string, name string) bool {
// its next preflight to refuse. TestDoctorNFDVerdictMatchesTheSync runs the
// real syncs for each case.
func nfdVerdict(s, o *PeerSideFacts, host, other string) (level, detail, fix string) {
fix = "on " + host + ": dot sync names normalize --profile=peer --yes"
fix = "on " + host + ": dot sync names normalize --store=peer --yes"
var quoted []string
for _, name := range s.NonNFDSample {
quoted = append(quoted, fmt.Sprintf("%q", name))
Expand Down Expand Up @@ -375,7 +375,7 @@ func topologyFix(local, peer *PeerSideFacts, here, there string) string {
if strings.ContainsAny(target, " \t'\"$\\`;&|()<>*?[]{}!#~") {
target = shellQuote(target)
}
fixes = append(fixes, fmt.Sprintf("on %s: dot sync target --profile=peer %s", s.host, target))
fixes = append(fixes, fmt.Sprintf("on %s: dot sync target --store=peer %s", s.host, target))
}
return strings.Join(fixes, "; ")
}
Expand Down Expand Up @@ -610,7 +610,7 @@ func evaluatePeerSides(local, peer *PeerSideFacts, here, there string) []DoctorC
if peer.NFDMarked {
marked, other = there, here
}
add("nfd", DoctorWarn, "the NFD marker is set on "+marked+" only", "on "+other+": dot sync names normalize --profile=peer --yes")
add("nfd", DoctorWarn, "the NFD marker is set on "+marked+" only", "on "+other+": dot sync names normalize --store=peer --yes")
}

// The fence checks topology first, on whichever Mac syncs, and dot peer
Expand Down
12 changes: 6 additions & 6 deletions internal/syncer/peer_doctor_facts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ func TestEvaluatePeerSides(t *testing.T) {
checks = evaluatePeerSides(local, peer, "m5x26", "m3x23")
for _, want := range []struct{ name, level, fix string }{
{"roles", DoctorFail, "dot peer adopt --owner <a name only the chosen Mac answers to> --epoch 3"},
{"nfd", DoctorFail, "on m3x23: dot sync names normalize --profile=peer"},
{"nfd", DoctorWarn, "on m3x23: dot sync names normalize --profile=peer"},
{"nfd", DoctorFail, "on m3x23: dot sync names normalize --store=peer"},
{"nfd", DoctorWarn, "on m3x23: dot sync names normalize --store=peer"},
{"rsync", DoctorFail, "on m3x23: brew install rsync"},
{"config", DoctorWarn, ""},
} {
Expand All @@ -72,7 +72,7 @@ func TestEvaluatePeerSides(t *testing.T) {
local, peer = doctorFacts()
peer.NFDMarked, peer.NonNFD = true, 3
checks = evaluatePeerSides(local, peer, "m5x26", "m3x23")
if c := checkFor(checks, "nfd", DoctorFail); c == nil || c.Fix != "on m3x23: dot sync names normalize --profile=peer --yes" {
if c := checkFor(checks, "nfd", DoctorFail); c == nil || c.Fix != "on m3x23: dot sync names normalize --store=peer --yes" {
t.Errorf("the other Mac's names under an unmarked coordinator: %+v", c)
}

Expand Down Expand Up @@ -111,7 +111,7 @@ func TestEvaluatePeerSides(t *testing.T) {
mutate func(local, peer *PeerSideFacts)
fix string
}{
{"unmarked coordinator with names", func(l, _ *PeerSideFacts) { l.NonNFD = 16 }, "on m5x26: dot sync names normalize --profile=peer --yes"},
{"unmarked coordinator with names", func(l, _ *PeerSideFacts) { l.NonNFD = 16 }, "on m5x26: dot sync names normalize --store=peer --yes"},
{"coordinator cannot plan", func(l, _ *PeerSideFacts) { l.NonNFDError = "permission denied" }, ""},
{"marked coordinator, peer cannot plan", func(l, p *PeerSideFacts) { l.NFDMarked, p.NFDMarked, p.NonNFDError = true, true, "collision" }, ""},
} {
Expand All @@ -130,7 +130,7 @@ func TestEvaluatePeerSides(t *testing.T) {
peer.WorkspacePath, peer.TargetPath, peer.TargetHost = "/Users/b/work", "/Users/a/elsewhere", "m5x26.ts.net"
// The fix names the Mac that is wrong and the target, and leaves the
// owner and epoch alone (dot peer init would claim the owner).
if c := checkFor(evaluatePeerSides(local, peer, "m5x26", "m3x23"), "topology", DoctorFail); c == nil || !strings.Contains(c.Detail, "does not point back") || c.Fix != "on m3x23: dot sync target --profile=peer ssh:m5x26.ts.net:/Users/a/work" {
if c := checkFor(evaluatePeerSides(local, peer, "m5x26", "m3x23"), "topology", DoctorFail); c == nil || !strings.Contains(c.Detail, "does not point back") || c.Fix != "on m3x23: dot sync target --store=peer ssh:m5x26.ts.net:/Users/a/work" {
t.Errorf("topology mismatch: %+v", c)
}
// Also with both Macs coordinating after a takeover: the fence refuses
Expand Down Expand Up @@ -459,7 +459,7 @@ func TestPeerInventoryNFDErrorNamesHostAndCommand(t *testing.T) {
t.Fatal(err)
}
_, err := PeerDiff(context.Background(), PeerDiffOptions{Config: cfg, Probe: peerScheduleRunner(false)})
if err == nil || !strings.Contains(err.Error(), "on fake-peer; run there: dot sync names normalize --profile=peer") {
if err == nil || !strings.Contains(err.Error(), "on fake-peer; run there: dot sync names normalize --store=peer") {
t.Fatalf("err = %v", err)
}
}
Expand Down
Loading
Loading