@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +11882 -11126
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -3
  6. package/paste.config.json +54 -0
  7. package/scaffold/deploy.yml +101 -0
  8. package/scaffold/githooks/pre-push +114 -0
  9. package/scaffold/manifest.json +11 -0
  10. package/scaffold/storyboard.config.json +4 -1
  11. package/src/ActionMenuButton.svelte +12 -2
  12. package/src/CanvasCreateMenu.svelte +228 -10
  13. package/src/CanvasSnap.svelte +2 -0
  14. package/src/CoreUIBar.svelte +152 -3
  15. package/src/CreateMenuButton.svelte +4 -1
  16. package/src/InspectorPanel.svelte +2 -0
  17. package/src/PwaInstallBanner.svelte +124 -0
  18. package/src/autosync/server.js +99 -111
  19. package/src/autosync/server.test.js +0 -7
  20. package/src/canvas/collision.js +206 -0
  21. package/src/canvas/collision.test.js +271 -0
  22. package/src/canvas/deriveCanvasId.test.js +40 -0
  23. package/src/canvas/identity.js +107 -0
  24. package/src/canvas/identity.test.js +100 -0
  25. package/src/canvas/server.js +285 -31
  26. package/src/canvasConfig.js +56 -0
  27. package/src/canvasConfig.test.js +42 -0
  28. package/src/cli/canvasAdd.js +185 -0
  29. package/src/cli/canvasRead.js +208 -0
  30. package/src/cli/code.js +67 -0
  31. package/src/cli/create.js +339 -72
  32. package/src/cli/dev-helpers.js +53 -0
  33. package/src/cli/dev-helpers.test.js +53 -0
  34. package/src/cli/dev.js +245 -26
  35. package/src/cli/flags.js +174 -0
  36. package/src/cli/flags.test.js +155 -0
  37. package/src/cli/index.js +84 -13
  38. package/src/cli/intro.js +37 -0
  39. package/src/cli/proxy.js +127 -6
  40. package/src/cli/proxy.test.js +63 -0
  41. package/src/cli/schemas.js +200 -0
  42. package/src/cli/serverUrl.js +56 -0
  43. package/src/cli/setup.js +130 -20
  44. package/src/cli/snapshots.js +335 -0
  45. package/src/cli/updateVersion.js +54 -3
  46. package/src/configSchema.js +125 -0
  47. package/src/configSchema.test.js +68 -0
  48. package/src/index.js +5 -0
  49. package/src/inspector/highlighter.js +10 -2
  50. package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
  51. package/src/loader.js +21 -2
  52. package/src/loader.test.js +63 -1
  53. package/src/mobileViewport.js +57 -0
  54. package/src/mobileViewport.test.js +68 -0
  55. package/src/mountStoryboardCore.js +61 -7
  56. package/src/rename-watcher/config.json +23 -0
  57. package/src/rename-watcher/watcher.js +538 -0
  58. package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
  59. package/src/tools/handlers/flows.js +6 -7
  60. package/src/viewfinder.js +21 -9
  61. package/src/viewfinder.test.js +2 -2
  62. package/src/vite/server-plugin.js +150 -7
  63. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
  64. package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
  65. package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
  66. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  67. package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
  68. package/src/workshop/features/createStory/index.js +14 -0
  69. package/src/workshop/features/registry.js +2 -0
  70. package/src/worktree/port.js +57 -1
  71. package/src/worktree/port.test.js +91 -1
  72. package/toolbar.config.json +3 -3
  73. package/widgets.config.json +132 -27
@@ -14,6 +14,7 @@
14
14
  import * as DropdownMenu from './lib/components/ui/dropdown-menu/index.js'
15
15
  import * as Panel from './lib/components/ui/panel/index.js'
16
16
  import Icon from './svelte-plugin-ui/components/Icon.svelte'
17
+ import { isExcludedByRoute } from './commandActions.js'
17
18
  import type { Component } from 'svelte'
18
19
 
19
20
  interface CreateMenuFeature {
@@ -105,7 +106,9 @@
105
106
 
106
107
  <DropdownMenu.Content side="top" align="end" sideOffset={16} class="min-w-[180px]" style={menuWidth ? `width: ${menuWidth}` : ''}>
107
108
  {#each resolvedActions as action (action._key)}
108
- {#if action.type === 'header'}
109
+ {#if isExcludedByRoute(action)}
110
+ <!-- hidden by route -->
111
+ {:else if action.type === 'header'}
109
112
  <DropdownMenu.Label>{action.label}</DropdownMenu.Label>
110
113
  {:else if action.type === 'separator'}
111
114
  <DropdownMenu.Separator />
@@ -269,6 +269,7 @@
269
269
  highlightedHtml = hl.codeToHtml(sourceCode, {
270
270
  lang: getLang(sourcePath),
271
271
  theme: 'github-dark',
272
+ lineNumbers: false,
272
273
  decorations: matchedLine > 0
273
274
  ? [{ start: { line: matchedLine - 1, character: 0 }, end: { line: matchedLine - 1, character: Infinity }, properties: { class: 'highlighted-line' } }]
274
275
  : [],
@@ -364,6 +365,7 @@
364
365
  highlightedHtml = hl.codeToHtml(sourceCode, {
365
366
  lang: getLang(path),
366
367
  theme: 'github-dark',
368
+ lineNumbers: false,
367
369
  decorations: matchedLine > 0
368
370
  ? [{ start: { line: matchedLine - 1, character: 0 }, end: { line: matchedLine - 1, character: Infinity }, properties: { class: 'highlighted-line' } }]
369
371
  : [],
@@ -0,0 +1,124 @@
1
+ <!--
2
+ PwaInstallBanner — mobile-only "Add to Home Screen" prompt.
3
+
4
+ Listens for the `beforeinstallprompt` event (Chrome/Edge) and shows a
5
+ dismissible banner. Never shows on desktop or when already installed.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import { onMount, onDestroy } from 'svelte'
10
+ import { isMobile, isTouchDevice, subscribeToMobile } from './mobileViewport.js'
11
+
12
+ const DISMISS_KEY = 'sb-pwa-install-dismissed'
13
+
14
+ let showBanner = $state(false)
15
+ let deferredPrompt: any = null
16
+
17
+ function isStandalone(): boolean {
18
+ if (typeof window === 'undefined') return false
19
+ return (
20
+ window.matchMedia('(display-mode: standalone)').matches ||
21
+ (window.navigator as any).standalone === true
22
+ )
23
+ }
24
+
25
+ function isDismissed(): boolean {
26
+ try { return localStorage.getItem(DISMISS_KEY) === '1' } catch { return false }
27
+ }
28
+
29
+ function handleBeforeInstallPrompt(e: Event) {
30
+ e.preventDefault()
31
+ deferredPrompt = e
32
+ if (isMobile() && isTouchDevice() && !isStandalone() && !isDismissed()) {
33
+ showBanner = true
34
+ }
35
+ }
36
+
37
+ async function handleInstall() {
38
+ if (!deferredPrompt) return
39
+ deferredPrompt.prompt()
40
+ const { outcome } = await deferredPrompt.userChoice
41
+ deferredPrompt = null
42
+ showBanner = false
43
+ if (outcome === 'dismissed') {
44
+ try { localStorage.setItem(DISMISS_KEY, '1') } catch {}
45
+ }
46
+ }
47
+
48
+ function handleDismiss() {
49
+ showBanner = false
50
+ try { localStorage.setItem(DISMISS_KEY, '1') } catch {}
51
+ }
52
+
53
+ let unsubMobile: (() => void) | null = null
54
+
55
+ onMount(() => {
56
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
57
+ window.addEventListener('appinstalled', () => { showBanner = false })
58
+
59
+ unsubMobile = subscribeToMobile((mobile: boolean) => {
60
+ if (!mobile) showBanner = false
61
+ else if (deferredPrompt && isTouchDevice() && !isStandalone() && !isDismissed()) {
62
+ showBanner = true
63
+ }
64
+ })
65
+ })
66
+
67
+ onDestroy(() => {
68
+ window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
69
+ if (unsubMobile) unsubMobile()
70
+ })
71
+ </script>
72
+
73
+ {#if showBanner}
74
+ <div class="pwa-banner" role="alert">
75
+ <span class="pwa-banner-text">Add Storyboard to your home screen</span>
76
+ <button class="pwa-banner-btn install" onclick={handleInstall}>Install</button>
77
+ <button class="pwa-banner-btn dismiss" onclick={handleDismiss} aria-label="Dismiss">✕</button>
78
+ </div>
79
+ {/if}
80
+
81
+ <style>
82
+ .pwa-banner {
83
+ position: fixed;
84
+ bottom: 5rem;
85
+ left: 1rem;
86
+ right: 1rem;
87
+ z-index: 10000;
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 0.5rem;
91
+ padding: 0.75rem 1rem;
92
+ background: var(--bgColor-default, #0d1117);
93
+ color: var(--fgColor-default, #e6edf3);
94
+ border: 1px solid var(--borderColor-default, #30363d);
95
+ border-radius: 0.75rem;
96
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
97
+ font-size: 0.8125rem;
98
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
99
+ }
100
+
101
+ .pwa-banner-text {
102
+ flex: 1;
103
+ }
104
+
105
+ .pwa-banner-btn {
106
+ border: none;
107
+ border-radius: 0.375rem;
108
+ padding: 0.375rem 0.75rem;
109
+ font-size: 0.8125rem;
110
+ cursor: pointer;
111
+ font-weight: 500;
112
+ }
113
+
114
+ .pwa-banner-btn.install {
115
+ background: var(--button-primary-bgColor-rest, #238636);
116
+ color: #fff;
117
+ }
118
+
119
+ .pwa-banner-btn.dismiss {
120
+ background: transparent;
121
+ color: var(--fgColor-muted, #8d96a0);
122
+ padding: 0.375rem;
123
+ }
124
+ </style>
@@ -4,7 +4,7 @@
4
4
  * Dev-server middleware that provides git automation:
5
5
  * - List branches (excluding main/master)
6
6
  * - Enable/disable autosync per scope (canvas/prototype)
7
- * - Keep an isolated autosync worktree per target branch
7
+ * - Direct commit + push on the current branch (scoped files only)
8
8
  * - Push watcher: every 30s runs enabled scopes in relay sequence
9
9
  *
10
10
  * Routes (mounted at /_storyboard/autosync/):
@@ -16,8 +16,8 @@
16
16
  */
17
17
 
18
18
  import { execFileSync } from 'node:child_process'
19
- import { copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
20
- import { dirname, join, resolve, sep } from 'node:path'
19
+ import { existsSync } from 'node:fs'
20
+ import { join, resolve } from 'node:path'
21
21
 
22
22
  // ── Module-level watcher state (singleton, survives page reloads) ──
23
23
 
@@ -25,7 +25,6 @@ let schedulerInterval = null
25
25
  let schedulerTimeout = null
26
26
  let targetBranch = null
27
27
  let originalBranch = null
28
- let autosyncWorktreeRoot = null
29
28
  let lastSyncTime = null
30
29
  let lastError = null
31
30
  let syncing = false
@@ -37,7 +36,6 @@ let lastErrorByScope = { canvas: null, prototype: null }
37
36
 
38
37
  const SYNC_INTERVAL_MS = 30_000
39
38
  const PUSH_RETRY_LIMIT = 3
40
- const AUTOSYNC_WORKTREE_DIR = 'autosync-worktrees'
41
39
  const SCOPE_ORDER = ['canvas', 'prototype']
42
40
  const AUTOSYNC_SCOPES = new Set(SCOPE_ORDER)
43
41
 
@@ -70,78 +68,8 @@ function getBranches(root) {
70
68
  .filter((b) => b && b.toLowerCase() !== 'main' && b.toLowerCase() !== 'master')
71
69
  }
72
70
 
73
- function getGitCommonDir(root) {
74
- return resolve(root, git(['rev-parse', '--git-common-dir'], root))
75
- }
76
-
77
- export function getAutosyncWorktreeDirName(branch) {
78
- const safe = String(branch || 'branch')
79
- .toLowerCase()
80
- .replace(/[^a-z0-9._-]+/g, '--')
81
- .replace(/-+/g, '-')
82
- .replace(/^-|-$/g, '')
83
- return safe || 'branch'
84
- }
85
-
86
- function getAutosyncWorktreePath(root, branch) {
87
- return join(getGitCommonDir(root), AUTOSYNC_WORKTREE_DIR, getAutosyncWorktreeDirName(branch))
88
- }
89
-
90
- function removeAutosyncWorktree(root, worktreePath) {
91
- if (!worktreePath) return
92
- try {
93
- git(['worktree', 'remove', '--force', worktreePath], root)
94
- } catch {
95
- // Path may not be a registered worktree.
96
- }
97
- rmSync(worktreePath, { recursive: true, force: true })
98
- }
99
-
100
- function resetAutosyncWorktree(root, branch) {
101
- const worktreePath = getAutosyncWorktreePath(root, branch)
102
- removeAutosyncWorktree(root, worktreePath)
103
-
104
- mkdirSync(dirname(worktreePath), { recursive: true })
105
- const branchHead = git(['rev-parse', branch], root)
106
- git(['worktree', 'add', '--detach', worktreePath, branchHead], root)
107
-
108
- const worktreeHead = git(['rev-parse', 'HEAD'], worktreePath)
109
- if (worktreeHead !== branchHead) {
110
- throw new Error(`Autosync worktree init mismatch for ${branch}`)
111
- }
112
-
113
- autosyncWorktreeRoot = worktreePath
114
- return worktreePath
115
- }
116
-
117
- function clearAutosyncWorktree(root) {
118
- if (!autosyncWorktreeRoot) return
119
- removeAutosyncWorktree(root, autosyncWorktreeRoot)
120
- autosyncWorktreeRoot = null
121
- }
122
-
123
- function resolveScopedFilePath(root, file) {
124
- const absoluteRoot = resolve(root)
125
- const absoluteFile = resolve(absoluteRoot, file)
126
- if (absoluteFile !== absoluteRoot && !absoluteFile.startsWith(`${absoluteRoot}${sep}`)) {
127
- throw new Error(`Invalid scoped file path: ${file}`)
128
- }
129
- return absoluteFile
130
- }
131
-
132
- function syncScopedFilesToWorktree(sourceRoot, worktreeRoot, files) {
133
- for (const file of files) {
134
- const sourcePath = resolveScopedFilePath(sourceRoot, file)
135
- const targetPath = resolveScopedFilePath(worktreeRoot, file)
136
-
137
- if (!existsSync(sourcePath)) {
138
- rmSync(targetPath, { force: true })
139
- continue
140
- }
141
-
142
- mkdirSync(dirname(targetPath), { recursive: true })
143
- copyFileSync(sourcePath, targetPath)
144
- }
71
+ function getGitDir(root) {
72
+ return resolve(root, git(['rev-parse', '--git-dir'], root))
145
73
  }
146
74
 
147
75
  function hasScopedStagedChanges(root, files) {
@@ -152,15 +80,43 @@ function hasScopedStagedChanges(root, files) {
152
80
 
153
81
  function listChangedFiles(root) {
154
82
  const tracked = git(['diff', '--name-only'], root)
155
- const staged = git(['diff', '--name-only', '--cached'], root)
156
83
  const untracked = git(['ls-files', '--others', '--exclude-standard'], root)
157
- return [...tracked, ...staged, ...untracked]
84
+ return [tracked, untracked]
158
85
  .flatMap((raw) => raw.split('\n'))
159
86
  .map((file) => file.trim())
160
87
  .filter(Boolean)
161
88
  .filter((file, idx, arr) => arr.indexOf(file) === idx)
162
89
  }
163
90
 
91
+ // ── Repo-busy guards ──
92
+
93
+ /**
94
+ * Check if the repo is in a state where autosync should defer.
95
+ * Returns { busy: true, reason } if unsafe, { busy: false } otherwise.
96
+ */
97
+ export function isRepoBusy(root) {
98
+ const gitDir = getGitDir(root)
99
+
100
+ if (existsSync(join(gitDir, 'index.lock'))) {
101
+ return { busy: true, reason: 'index.lock exists — another git process is active' }
102
+ }
103
+ if (existsSync(join(gitDir, 'rebase-merge')) || existsSync(join(gitDir, 'rebase-apply'))) {
104
+ return { busy: true, reason: 'rebase in progress' }
105
+ }
106
+ if (existsSync(join(gitDir, 'MERGE_HEAD'))) {
107
+ return { busy: true, reason: 'merge in progress' }
108
+ }
109
+ if (existsSync(join(gitDir, 'CHERRY_PICK_HEAD'))) {
110
+ return { busy: true, reason: 'cherry-pick in progress' }
111
+ }
112
+
113
+ if (targetBranch && getCurrentBranch(root) !== targetBranch) {
114
+ return { busy: true, reason: `branch drift: expected ${targetBranch}, on ${getCurrentBranch(root)}` }
115
+ }
116
+
117
+ return { busy: false }
118
+ }
119
+
164
120
  export function normalizeAutosyncScope(scope) {
165
121
  return AUTOSYNC_SCOPES.has(scope) ? scope : 'canvas'
166
122
  }
@@ -190,15 +146,6 @@ function listScopedChangedFiles(root, scope) {
190
146
  return filterFilesForAutosyncScope(scope, listChangedFiles(root))
191
147
  }
192
148
 
193
- function remoteBranchExists(root, branch) {
194
- try {
195
- git(['ls-remote', '--exit-code', '--heads', 'origin', branch], root)
196
- return true
197
- } catch {
198
- return false
199
- }
200
- }
201
-
202
149
  function isValidBranch(name) {
203
150
  return typeof name === 'string' && BRANCH_NAME_RE.test(name) && name.length < 256
204
151
  }
@@ -259,6 +206,16 @@ function resetRuntimeState({ clearBranch = true } = {}) {
259
206
  }
260
207
  }
261
208
 
209
+ /** Undo a commit that was never pushed, leaving files staged then unstaged. */
210
+ function rollbackUnpushedCommit(root, scopedFiles) {
211
+ try {
212
+ git(['reset', '--soft', 'HEAD~1'], root)
213
+ git(['reset', '--', ...scopedFiles], root)
214
+ } catch {
215
+ // Best-effort rollback; if this fails the user's tree is still valid.
216
+ }
217
+ }
218
+
262
219
  function buildStatusPayload(root) {
263
220
  const singleScope = enabledScopes.canvas === enabledScopes.prototype
264
221
  ? null
@@ -283,7 +240,6 @@ function buildStatusPayload(root) {
283
240
 
284
241
  function stopAutosync(root, { clearBranch = true, clearErrors = false } = {}) {
285
242
  stopScheduler()
286
- clearAutosyncWorktree(root)
287
243
  resetRuntimeState({ clearBranch })
288
244
  if (clearErrors) {
289
245
  lastError = null
@@ -293,31 +249,42 @@ function stopAutosync(root, { clearBranch = true, clearErrors = false } = {}) {
293
249
 
294
250
  // ── Sync cycle ──
295
251
 
296
- /** Run one scoped sync against the isolated autosync worktree. */
252
+ /** Run one scoped sync stage, commit, and push scoped files directly. */
297
253
  function runSyncCycle(root, scope) {
298
254
  if (syncing) return false
299
255
  syncing = true
300
256
  syncingScope = scope
301
257
  let cycleSucceeded = false
258
+ let committed = false
259
+ let scopedFiles = []
302
260
 
303
261
  try {
304
262
  if (!targetBranch) {
305
263
  throw new Error('Autosync branch is not configured')
306
264
  }
307
- if (!autosyncWorktreeRoot) {
308
- throw new Error('Autosync worktree is not initialized')
265
+
266
+ // Guard: skip if repo is busy (index lock, rebase, merge, branch drift)
267
+ const busy = isRepoBusy(root)
268
+ if (busy.busy) {
269
+ cycleSucceeded = true // defer, not failure
270
+ return true
309
271
  }
310
272
 
311
- const scopedFiles = listScopedChangedFiles(root, scope)
273
+ scopedFiles = listScopedChangedFiles(root, scope)
312
274
  if (scopedFiles.length === 0) {
313
275
  cycleSucceeded = true
314
276
  return true
315
277
  }
316
278
 
317
- syncScopedFilesToWorktree(root, autosyncWorktreeRoot, scopedFiles)
318
- git(['add', '-A', '--', ...scopedFiles], autosyncWorktreeRoot)
279
+ // Guard: skip if scoped files already have user-staged changes
280
+ if (hasScopedStagedChanges(root, scopedFiles)) {
281
+ cycleSucceeded = true // defer, not failure
282
+ return true
283
+ }
284
+
285
+ git(['add', '-A', '--', ...scopedFiles], root)
319
286
 
320
- if (!hasScopedStagedChanges(autosyncWorktreeRoot, scopedFiles)) {
287
+ if (!hasScopedStagedChanges(root, scopedFiles)) {
321
288
  cycleSucceeded = true
322
289
  return true
323
290
  }
@@ -326,36 +293,51 @@ function runSyncCycle(root, scope) {
326
293
  const time = formatTime()
327
294
  git(
328
295
  ['commit', '-m', `[auto:${scope}] ${username} update at ${time}`, '--', ...scopedFiles],
329
- autosyncWorktreeRoot,
296
+ root,
330
297
  )
298
+ committed = true
331
299
 
332
300
  for (let attempt = 1; attempt <= PUSH_RETRY_LIMIT; attempt += 1) {
333
- if (remoteBranchExists(root, targetBranch)) {
334
- git(['fetch', 'origin', targetBranch], autosyncWorktreeRoot)
335
- git(['rebase', 'FETCH_HEAD'], autosyncWorktreeRoot)
301
+ // Re-check guards before push/rebase
302
+ const pushBusy = isRepoBusy(root)
303
+ if (pushBusy.busy) {
304
+ rollbackUnpushedCommit(root, scopedFiles)
305
+ committed = false
306
+ cycleSucceeded = true // defer
307
+ return true
336
308
  }
337
309
 
338
310
  try {
339
- git(['push', 'origin', `HEAD:refs/heads/${targetBranch}`], autosyncWorktreeRoot)
311
+ git(['push', 'origin', `HEAD:refs/heads/${targetBranch}`], root)
340
312
  cycleSucceeded = true
341
313
  break
342
314
  } catch (pushErr) {
343
315
  if (!isRetryablePushError(pushErr?.message) || attempt === PUSH_RETRY_LIMIT) {
344
316
  throw pushErr
345
317
  }
318
+
319
+ // Fetch and rebase with autostash to handle non-fast-forward
320
+ try {
321
+ git(['fetch', 'origin', targetBranch], root)
322
+ git(['rebase', '--autostash', 'FETCH_HEAD'], root)
323
+ } catch {
324
+ // Rebase failed — abort and defer
325
+ try { git(['rebase', '--abort'], root) } catch { /* no rebase in progress */ }
326
+ rollbackUnpushedCommit(root, scopedFiles)
327
+ committed = false
328
+ cycleSucceeded = true // defer, try again next cycle
329
+ return true
330
+ }
346
331
  }
347
332
  }
348
333
  } catch (err) {
349
334
  lastError = err.message || 'Sync failed'
350
335
  lastErrorByScope[scope] = lastError
351
- try {
352
- if (autosyncWorktreeRoot) {
353
- git(['rebase', '--abort'], autosyncWorktreeRoot)
354
- }
355
- } catch {
356
- // no rebase in progress
336
+
337
+ // Rollback the commit if we made one but never pushed
338
+ if (committed) {
339
+ rollbackUnpushedCommit(root, scopedFiles)
357
340
  }
358
- stopAutosync(root, { clearBranch: false })
359
341
  } finally {
360
342
  if (cycleSucceeded) {
361
343
  const nowIso = new Date().toISOString()
@@ -448,6 +430,14 @@ export function createAutosyncHandler({ root, sendJson }) {
448
430
  return
449
431
  }
450
432
 
433
+ const currentBranch = getCurrentBranch(root)
434
+ if (branch !== currentBranch) {
435
+ sendJson(res, 400, {
436
+ error: `Autosync requires you to be on the target branch. Current: ${currentBranch}, requested: ${branch}`,
437
+ })
438
+ return
439
+ }
440
+
451
441
  const normalizedScope = normalizeAutosyncScope(scope)
452
442
  const hadEnabledScopes = hasAnyScopeEnabled()
453
443
 
@@ -457,10 +447,8 @@ export function createAutosyncHandler({ root, sendJson }) {
457
447
  }
458
448
 
459
449
  if (!hadEnabledScopes) {
460
- originalBranch = getCurrentBranch(root)
450
+ originalBranch = currentBranch
461
451
  targetBranch = branch
462
- // Recreate from selected branch HEAD so autosync starts from current branch tip.
463
- resetAutosyncWorktree(root, branch)
464
452
  }
465
453
 
466
454
  enabledScopes[normalizedScope] = true
@@ -3,7 +3,6 @@ import {
3
3
  matchesAutosyncScope,
4
4
  filterFilesForAutosyncScope,
5
5
  isRetryablePushError,
6
- getAutosyncWorktreeDirName,
7
6
  } from './server.js'
8
7
 
9
8
  describe('autosync scope helpers', () => {
@@ -45,10 +44,4 @@ describe('autosync scope helpers', () => {
45
44
  expect(isRetryablePushError('fetch first')).toBe(true)
46
45
  expect(isRetryablePushError('some other git error')).toBe(false)
47
46
  })
48
-
49
- it('sanitizes autosync worktree dir names', () => {
50
- expect(getAutosyncWorktreeDirName('feature/my-branch')).toBe('feature-my-branch')
51
- expect(getAutosyncWorktreeDirName('3.11.0')).toBe('3.11.0')
52
- expect(getAutosyncWorktreeDirName('')).toBe('branch')
53
- })
54
47
  })