@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- 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
|
|
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>
|
package/src/autosync/server.js
CHANGED
|
@@ -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
|
-
* -
|
|
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 {
|
|
20
|
-
import {
|
|
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
|
|
74
|
-
return resolve(root, git(['rev-parse', '--git-
|
|
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 [
|
|
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
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
273
|
+
scopedFiles = listScopedChangedFiles(root, scope)
|
|
312
274
|
if (scopedFiles.length === 0) {
|
|
313
275
|
cycleSucceeded = true
|
|
314
276
|
return true
|
|
315
277
|
}
|
|
316
278
|
|
|
317
|
-
|
|
318
|
-
|
|
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(
|
|
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
|
-
|
|
296
|
+
root,
|
|
330
297
|
)
|
|
298
|
+
committed = true
|
|
331
299
|
|
|
332
300
|
for (let attempt = 1; attempt <= PUSH_RETRY_LIMIT; attempt += 1) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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}`],
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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 =
|
|
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
|
})
|