Skip to content

Commit 8a33a01

Browse files
acmoreclaude
andauthored
feat(sync): per-path sync direction (spec.sync.paths[].direction: bi|up|down) (#155)
* feat(sync): per-path sync direction (spec.sync.paths[].direction: bi|up|down) A single bidirectional folder carries both inputs (code, local->pod) and outputs (results, pod->local), so a bad local write can clobber pod-generated data. Finer per-subpath direction inside one folder is impossible in Syncthing (.stignore lives in the directory; per-path folder types were rejected upstream), so direction is a per-mapping (per-folder) setting. Config: spec.sync.paths entries now accept a structured form alongside the compact "local:remote" string (which stays fully backward compatible and marshals back unchanged): paths: - local: . remote: /workspace direction: down # bi (default) | up | down up = local sendonly / pod receiveonly (code push-only; nothing on the pod can alter local files). down = pod sendonly / local receiveonly (the pod is the authority; a local write can never clobber pod results). The schema is per-path from day one so lifting the current single-mapping limit later will not change config shape. Plumbing: syncengine.Pair carries the resolved direction; `okdev sync --mode` defaults to the configured path's direction with an explicit flag winning; `okdev up` starts sync in that direction and skips the two-phase bootstrap for directional folders (one side is already sendonly, so the final folder types are safe from the first start). Direction participates in the sync config hash (explicit bi hashes like the legacy default, so upgrades do not restart existing sessions). `up --reset-workspace` and `sync reset-remote` are rejected under "down": they reseed the remote from local, which would sync back as deletions over the pod's data. `status --details` shows the direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(e2e): cover per-path sync direction semantics in kind smoke Four scenarios on the live smoke session: rewriting the sync path entry to the structured form with direction "up" takes effect on the next `okdev up` (config-hash driven restart, verified via status --details); up-direction still pushes local edits while pod-side writes never flow back; down-direction returns pod-generated results while a stray local truncation cannot clobber the pod's copy (the reported data-loss incident, reproduced and asserted harmless); and `up --reset-workspace` is rejected under "down" with the remote file left intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5755e5e commit 8a33a01

16 files changed

Lines changed: 463 additions & 43 deletions

docs/command-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ agents can react without launching a diagnostic chain on every blip:
304304
### `okdev sync [--mode up|down|bi] [--foreground] [--reset] [--dry-run]`
305305

306306
- Advanced command. Starts detached background sync by default; use `--foreground` for sync debugging, or explicit one-way sync (`up`/`down`).
307+
- `--mode` defaults to the configured path's `direction` (`spec.sync.paths[].direction`, falling back to `bi`); an explicit flag overrides it for that invocation. See the config manifest for choosing a persistent direction — `down` makes the pod the authority so local writes can never clobber pod-generated results.
307308
- For default `--mode bi`, no-op when background sync is already active for the session.
308309
- `--background`: explicitly request detached background mode.
309310
- `--reset`: check local-to-hub sync and mesh receiver health, then reset only what is broken. Skips the local sync teardown when the primary sync is already healthy. For sessions with mesh receivers, probes each receiver and re-runs mesh setup only when broken or disconnected receivers are found.

docs/config-manifest.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,8 @@ spec:
233233
| Field | Type | Default | Description |
234234
|-------|------|---------|-------------|
235235
| `engine` | `string` | — | Sync engine (currently only `syncthing`) |
236-
| `paths` | `[]string` | — | Mappings in `local:remote` format (max 1 entry) |
236+
| `paths` | `[]entry` | — | Sync mappings (max 1 entry). Each entry is either the compact string `local:remote`, or a mapping `{local, remote, direction}` with an optional per-path `direction` |
237+
| `paths[].direction` | `string` | `bi` | Sync direction for this mapping's folder: `bi` (bidirectional), `up` (local is the authority: local sendonly, pod receiveonly), `down` (pod is the authority: pod sendonly, local receiveonly) |
237238
| `syncthing.version` | `string` | `v1.29.7` | Local Syncthing binary version |
238239
| `syncthing.autoInstall` | `bool` | `true` | Auto-install local Syncthing |
239240
| `syncthing.image` | `string` | `ghcr.io/acmore/okdev:<version>` | Sidecar image (fallback: `edge`) |
@@ -242,7 +243,27 @@ spec:
242243
| `syncthing.compression` | `bool` | `false` | Use Syncthing `always` compression for peer connections instead of the default `metadata` mode |
243244
| `syncthing.versioningDays` | `int` | `30` | Keep files overwritten or deleted by sync in `.stversions` for this many days (staggered versioning, both sides); `0` disables |
244245

245-
**Validation:** `engine` must be `syncthing`; each `paths[]` entry must be `local:remote`; `versioningDays` must be `>= 0`.
246+
**Validation:** `engine` must be `syncthing`; each `paths[]` entry must have non-empty local and remote; `direction` must be `bi`, `up`, or `down`; `versioningDays` must be `>= 0`.
247+
248+
**Sync direction.** `direction` applies to a mapping's whole synced folder — finer per-subpath direction inside one folder is impossible with Syncthing (ignore rules live in the directory itself; per-path folder types were rejected upstream). Pick the direction by what the folder carries:
249+
250+
- `bi` (default): source code you edit on both sides. Either side can overwrite the other, so **keep generated outputs (results, checkpoints, logs) out of the synced path** — write them to a non-synced location and fetch with `okdev cp` or `okdev jobs logs`. Versioning (`.stversions/`) is the safety net, not the design.
251+
- `up`: the folder is a code drop. Nothing the pod writes can ever touch local files; pod-side writes into the folder are flagged by Syncthing but not propagated.
252+
- `down`: the folder carries pod-generated results. A stray local write (for example an empty file from a failed redirect) can never clobber the pod's data. `okdev up --reset-workspace` and `okdev sync reset-remote` are rejected in this direction because they reseed the remote from local.
253+
254+
An explicit `okdev sync --mode` overrides the configured direction for that invocation. Changing `direction` takes effect on the next `okdev up` (it restarts sync with the new folder types). Mesh receiver pods are always receiveonly regardless of direction.
255+
256+
```yaml
257+
# compact form (direction defaults to bi):
258+
paths:
259+
- .:/workspace
260+
261+
# structured form with an explicit direction:
262+
paths:
263+
- local: .
264+
remote: /workspace
265+
direction: up
266+
```
246267

247268
Local ignore rules come from the synced workspace's `.stignore`. `okdev init` writes a starter `.stignore` for built-in templates, and `okdev up` creates one with default patterns if the local sync root does not already have one. Editing `.stignore` takes effect automatically as Syncthing notices the change, but it does not remove files that were already synced to the remote workspace. For faster initial syncs, consider ignoring large generated build outputs or local test artifacts such as `debug/`, `release/`, caches, and dataset directories when they do not need to exist remotely.
248269

internal/cli/status_details.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ type detailedStatusSSH struct {
114114

115115
type detailedStatusSync struct {
116116
Engine string `json:"engine"`
117+
Direction string `json:"direction,omitempty"`
117118
Health string `json:"health,omitempty"`
118119
HealthDetail string `json:"healthDetail,omitempty"`
119120
ConfiguredPaths []string `json:"configuredPaths,omitempty"`
@@ -420,8 +421,13 @@ func buildDetailedSync(sessionName string, cfg *config.DevEnvironment, cfgPath s
420421
if engine == "" {
421422
engine = "syncthing"
422423
}
424+
direction := ""
425+
if pairs, err := syncengine.ParsePairs(cfg.Spec.Sync.Paths, cfg.EffectiveWorkspaceMountPath(cfgPath)); err == nil && len(pairs) > 0 {
426+
direction = pairs[0].Direction
427+
}
423428
detail := detailedStatusSync{
424429
Engine: engine,
430+
Direction: direction,
425431
ConfiguredPaths: summarizeConfiguredSyncPaths(cfg, cfgPath),
426432
}
427433
if engine != "syncthing" {
@@ -593,6 +599,9 @@ func printDetailedStatus(w io.Writer, detail detailedStatus) {
593599

594600
fmt.Fprintln(w, "\nSync:")
595601
fmt.Fprintf(w, "- engine: %s\n", detail.Sync.Engine)
602+
if detail.Sync.Direction != "" {
603+
fmt.Fprintf(w, "- direction: %s (%s)\n", detail.Sync.Direction, modeSymbol(detail.Sync.Direction))
604+
}
596605
if detail.Sync.BackgroundStatus != "" {
597606
status := detail.Sync.BackgroundStatus
598607
if detail.Sync.BackgroundPID > 0 {

internal/cli/status_details_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func TestGatherDetailedStatusIncludesDiagnostics(t *testing.T) {
6565
if err := os.WriteFile(conflictPath, []byte("conflict"), 0o644); err != nil {
6666
t.Fatalf("write conflict file: %v", err)
6767
}
68-
cfg.Spec.Sync.Paths = []string{syncRoot + ":/workspace"}
68+
cfg.Spec.Sync.Paths = []config.SyncPathSpec{{Local: syncRoot, Remote: "/workspace"}}
6969
cfg.Spec.Ports = []config.PortMapping{{Name: "http", Local: 8080, Remote: 80}}
7070
cfg.Spec.Agents = []config.AgentSpec{{Name: "codex", Auth: &config.AgentAuth{LocalPath: "~/.codex/auth.json"}}}
7171

@@ -347,6 +347,7 @@ func TestPrintDetailedStatusIncludesSections(t *testing.T) {
347347
},
348348
Sync: detailedStatusSync{
349349
Engine: "syncthing",
350+
Direction: "down",
350351
BackgroundStatus: "running",
351352
BackgroundPID: 123,
352353
ConfiguredPaths: []string{"/tmp/repo -> /workspace"},
@@ -391,6 +392,7 @@ func TestPrintDetailedStatusIncludesSections(t *testing.T) {
391392
"SSH:",
392393
"managed forward: running",
393394
"Sync:",
395+
"direction: down (<-)",
394396
"background: running (pid 123)",
395397
"conflicts: 2",
396398
"./a.sync-conflict-1",

internal/cli/sync.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"syscall"
1717
"time"
1818

19+
"github.com/acmore/okdev/internal/config"
1920
"github.com/acmore/okdev/internal/kube"
2021
"github.com/acmore/okdev/internal/logx"
2122
"github.com/acmore/okdev/internal/session"
@@ -88,6 +89,10 @@ func newSyncCmd(opts *Options) *cobra.Command {
8889
if err != nil {
8990
return err
9091
}
92+
mode = resolveSyncMode(cmd.Flags().Changed("mode"), mode, pairs[0].Direction)
93+
if err := validateSyncMode(mode); err != nil {
94+
return err
95+
}
9196
if dryRun {
9297
fmt.Fprintf(cmd.OutOrStdout(), "DRY RUN: sync session=%s namespace=%s engine=%s mode=%s\n", cc.sessionName, cc.namespace, engine, mode)
9398
fmt.Fprintf(cmd.OutOrStdout(), "- paths: %v\n", pairs)
@@ -166,7 +171,7 @@ func newSyncCmd(opts *Options) *cobra.Command {
166171
},
167172
}
168173

169-
cmd.Flags().StringVar(&mode, "mode", "bi", "Sync mode: up|down|bi")
174+
cmd.Flags().StringVar(&mode, "mode", "bi", "Sync mode: up|down|bi (default: spec.sync.direction, or bi)")
170175
cmd.Flags().BoolVar(&background, "background", true, "Run syncthing sync as a detached background process")
171176
cmd.Flags().BoolVar(&foreground, "foreground", false, "Run syncthing sync in the foreground for troubleshooting")
172177
cmd.Flags().BoolVar(&reset, "reset", false, "Check sync health and reset broken components")
@@ -179,6 +184,26 @@ func newSyncCmd(opts *Options) *cobra.Command {
179184
return cmd
180185
}
181186

187+
// resolveSyncMode picks the effective sync mode: an explicit --mode always
188+
// wins; otherwise the configured spec.sync.direction applies.
189+
func resolveSyncMode(flagSet bool, flagMode, configDirection string) string {
190+
if flagSet {
191+
return flagMode
192+
}
193+
return configDirection
194+
}
195+
196+
// validateSyncMode accepts the documented modes plus the internal
197+
// "two-phase" bootstrap mode that `okdev up` spawns for fresh sessions.
198+
func validateSyncMode(mode string) error {
199+
switch mode {
200+
case "up", "down", "bi", "two-phase":
201+
return nil
202+
default:
203+
return fmt.Errorf("invalid sync mode %q: must be one of up, down, bi", mode)
204+
}
205+
}
206+
182207
func newSyncResetRemoteCmd(opts *Options) *cobra.Command {
183208
return &cobra.Command{
184209
Use: "reset-remote",
@@ -204,6 +229,12 @@ synced workspace subtree is cleared.`,
204229
if len(pairs) != 1 {
205230
return fmt.Errorf("reset-remote requires exactly one sync path mapping, got %d", len(pairs))
206231
}
232+
if pairs[0].Direction == config.SyncDirectionDown {
233+
// reset-remote clears the remote and reseeds it from local;
234+
// with direction "down" the pod is the authority and local
235+
// is receiveonly — the wipe would sync back as deletions.
236+
return fmt.Errorf("sync reset-remote conflicts with sync direction \"down\" (the pod is the sync authority)")
237+
}
207238

208239
target, err := resolveTargetRef(cmd.Context(), cc.opts, cc.cfg, cc.namespace, cc.sessionName, cc.kube)
209240
if err != nil {

internal/cli/sync_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,47 @@ func TestSyncthingSessionConfigHashTracksSyncSettings(t *testing.T) {
417417
}
418418
}
419419

420+
func TestSyncthingSessionConfigHashTracksDirection(t *testing.T) {
421+
cfg := &config.DevEnvironment{}
422+
cfg.SetDefaults()
423+
base := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace")
424+
425+
// Explicit "bi" hashes identically to the legacy empty value so
426+
// upgrading okdev (or spelling out the default) does not force a sync
427+
// restart on existing sessions.
428+
cfg.Spec.Sync.Paths = []config.SyncPathSpec{{Local: ".", Remote: "/workspace", Direction: "bi"}}
429+
if got := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace"); got != base {
430+
t.Fatal("explicit bi direction must hash like the legacy default")
431+
}
432+
433+
cfg.Spec.Sync.Paths = []config.SyncPathSpec{{Local: ".", Remote: "/workspace", Direction: "down"}}
434+
if got := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace"); got == base {
435+
t.Fatal("expected direction change to affect sync config hash")
436+
}
437+
}
438+
439+
func TestResolveSyncMode(t *testing.T) {
440+
// Explicit --mode wins over the configured direction.
441+
if got := resolveSyncMode(true, "up", "down"); got != "up" {
442+
t.Fatalf("explicit flag must win, got %q", got)
443+
}
444+
// Without the flag, spec.sync.direction applies.
445+
if got := resolveSyncMode(false, "bi", "down"); got != "down" {
446+
t.Fatalf("config direction must apply, got %q", got)
447+
}
448+
}
449+
450+
func TestValidateSyncMode(t *testing.T) {
451+
for _, ok := range []string{"up", "down", "bi", "two-phase"} {
452+
if err := validateSyncMode(ok); err != nil {
453+
t.Fatalf("mode %q should validate, got %v", ok, err)
454+
}
455+
}
456+
if err := validateSyncMode("push"); err == nil {
457+
t.Fatal("expected invalid mode to be rejected")
458+
}
459+
}
460+
420461
func TestProcStatIsZombie(t *testing.T) {
421462
if !procStatIsZombie([]byte("123 (okdev) Z 1 2 3")) {
422463
t.Fatal("expected zombie proc stat to be detected")

internal/cli/syncthing.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2465,9 +2465,14 @@ func resetRemoteWorkspace(ctx context.Context, k interface {
24652465
}
24662466

24672467
type syncthingSessionConfigState struct {
2468-
Engine string `json:"engine"`
2469-
LocalPath string `json:"localPath"`
2470-
RemotePath string `json:"remotePath"`
2468+
Engine string `json:"engine"`
2469+
LocalPath string `json:"localPath"`
2470+
RemotePath string `json:"remotePath"`
2471+
// Direction is part of the hash so editing spec.sync.direction makes
2472+
// the next `okdev up` restart sync with the new folder types. The
2473+
// default "bi" is hashed as the empty legacy value to avoid a
2474+
// restart on upgrade for existing sessions.
2475+
Direction string `json:"direction,omitempty"`
24712476
WatcherDelaySeconds int `json:"watcherDelaySeconds,omitempty"`
24722477
RescanIntervalSeconds int `json:"rescanIntervalSeconds,omitempty"`
24732478
RelaysEnabled bool `json:"relaysEnabled,omitempty"`
@@ -2479,10 +2484,15 @@ func syncthingSessionConfigHash(cfg *config.DevEnvironment, localPath, remotePat
24792484
if cfg == nil {
24802485
return ""
24812486
}
2487+
direction := ""
2488+
if pairs, err := syncengine.ParsePairs(cfg.Spec.Sync.Paths, remotePath); err == nil && len(pairs) > 0 && pairs[0].Direction != config.SyncDirectionBi {
2489+
direction = pairs[0].Direction
2490+
}
24822491
state := syncthingSessionConfigState{
24832492
Engine: strings.TrimSpace(cfg.Spec.Sync.Engine),
24842493
LocalPath: localPath,
24852494
RemotePath: remotePath,
2495+
Direction: direction,
24862496
WatcherDelaySeconds: cfg.Spec.Sync.Syncthing.WatcherDelaySeconds,
24872497
RescanIntervalSeconds: cfg.Spec.Sync.Syncthing.RescanIntervalSeconds,
24882498
RelaysEnabled: cfg.Spec.Sync.Syncthing.RelaysEnabled,

internal/cli/up.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ func upValidate(cmd *cobra.Command, opts *Options, flags upOptions) (*upState, e
175175
if err != nil {
176176
return nil, err
177177
}
178+
if flags.resetWorkspace && len(syncPairs) > 0 && syncPairs[0].Direction == config.SyncDirectionDown {
179+
// reset-workspace clears the remote and reseeds it from local; with
180+
// direction "down" the pod is the authority and local is
181+
// receiveonly, so the combination would wipe the remote and then
182+
// pull the deletions back over the local files.
183+
return nil, fmt.Errorf("--reset-workspace conflicts with sync direction \"down\" (the pod is the sync authority); remove the flag or switch direction")
184+
}
178185
runtime, err := sessionRuntime(cc.cfg, cc.cfgPath, cc.sessionName, workloadName, labels, annotations, cc.cfg.Spec.PodTemplate.Spec, volumes, enableTmux, resolvePreStopCommand(cc.cfg, cc.cfgPath))
179186
if err != nil {
180187
return nil, err
@@ -1092,12 +1099,18 @@ func upSetupSync(state *upState, target workload.TargetRef) (string, string, str
10921099
}
10931100
configChanged = changed
10941101
}
1102+
direction := config.SyncDirectionBi
1103+
if len(state.syncPairs) > 0 {
1104+
direction = state.syncPairs[0].Direction
1105+
}
10951106
active := syncthingSessionActive(state.command.sessionName)
10961107
restartRequired := state.flags.resetWorkspace || configChanged || !active
1097-
startMode = syncStartMode(state.flags.resetWorkspace, targetReset, hasConfigState, configChanged, active)
1108+
startMode = syncStartMode(direction, state.flags.resetWorkspace, targetReset, hasConfigState, configChanged, active)
10981109
// Signal waitForInitialSync to probe the remote folder type for an
1099-
// incomplete bootstrap, reusing the port-forward it already opens.
1100-
bootstrapResume = len(state.syncPairs) == 1 && hasConfigState && !state.flags.resetWorkspace && !targetReset
1110+
// incomplete bootstrap, reusing the port-forward it already opens. Only
1111+
// meaningful for bi: directional folders never run the two-phase
1112+
// bootstrap, so there is nothing to resume.
1113+
bootstrapResume = len(state.syncPairs) == 1 && hasConfigState && !state.flags.resetWorkspace && !targetReset && direction == config.SyncDirectionBi
11011114
if restartRequired {
11021115
if err := refreshSyncthingSessionProcesses(state.command.sessionName); err != nil {
11031116
return "", "", "", false, fmt.Errorf("refresh local syncthing session state: %w", err)
@@ -1832,7 +1845,15 @@ func modeSymbol(mode string) string {
18321845
}
18331846
}
18341847

1835-
func syncStartMode(resetWorkspace, targetReset, hasConfigState, configChanged, active bool) string {
1848+
func syncStartMode(direction string, resetWorkspace, targetReset, hasConfigState, configChanged, active bool) string {
1849+
// A directional folder never needs the two-phase bootstrap: that
1850+
// protocol exists to protect the remote from the local side's initial
1851+
// emptiness before flipping both sides to sendreceive. With up/down one
1852+
// side is already sendonly, so the final folder types are safe from the
1853+
// first start.
1854+
if direction == config.SyncDirectionUp || direction == config.SyncDirectionDown {
1855+
return direction
1856+
}
18361857
switch {
18371858
case resetWorkspace:
18381859
return "two-phase"

internal/cli/up_ui_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,23 +119,31 @@ func TestSyncHelpers(t *testing.T) {
119119
func TestSyncStartMode(t *testing.T) {
120120
cases := []struct {
121121
name string
122+
direction string
122123
resetWorkspace bool
123124
targetReset bool
124125
hasConfigState bool
125126
configChanged bool
126127
active bool
127128
want string
128129
}{
129-
{name: "first bootstrap", hasConfigState: false, active: false, want: "two-phase"},
130-
{name: "explicit reset", resetWorkspace: true, hasConfigState: true, configChanged: true, active: true, want: "two-phase"},
131-
{name: "target recreated", targetReset: true, hasConfigState: true, active: false, want: "two-phase"},
132-
{name: "config changed on active session", hasConfigState: true, configChanged: true, active: true, want: "bi"},
133-
{name: "daemon recovery", hasConfigState: true, active: false, want: "bi"},
134-
{name: "steady state", hasConfigState: true, active: true, want: "bi"},
130+
{name: "first bootstrap", direction: "bi", hasConfigState: false, active: false, want: "two-phase"},
131+
{name: "explicit reset", direction: "bi", resetWorkspace: true, hasConfigState: true, configChanged: true, active: true, want: "two-phase"},
132+
{name: "target recreated", direction: "bi", targetReset: true, hasConfigState: true, active: false, want: "two-phase"},
133+
{name: "config changed on active session", direction: "bi", hasConfigState: true, configChanged: true, active: true, want: "bi"},
134+
{name: "daemon recovery", direction: "bi", hasConfigState: true, active: false, want: "bi"},
135+
{name: "steady state", direction: "bi", hasConfigState: true, active: true, want: "bi"},
136+
// Directional folders skip the two-phase bootstrap entirely: one
137+
// side is already sendonly, so the final types are safe from the
138+
// first start.
139+
{name: "up direction fresh", direction: "up", hasConfigState: false, active: false, want: "up"},
140+
{name: "up direction target reset", direction: "up", targetReset: true, hasConfigState: true, active: false, want: "up"},
141+
{name: "down direction fresh", direction: "down", hasConfigState: false, active: false, want: "down"},
142+
{name: "down direction steady", direction: "down", hasConfigState: true, active: true, want: "down"},
135143
}
136144

137145
for _, tc := range cases {
138-
if got := syncStartMode(tc.resetWorkspace, tc.targetReset, tc.hasConfigState, tc.configChanged, tc.active); got != tc.want {
146+
if got := syncStartMode(tc.direction, tc.resetWorkspace, tc.targetReset, tc.hasConfigState, tc.configChanged, tc.active); got != tc.want {
139147
t.Fatalf("%s: got %q want %q", tc.name, got, tc.want)
140148
}
141149
}

0 commit comments

Comments
 (0)