@dfosco/storyboard-core 4.2.6 → 4.2.8
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/package.json +1 -1
- package/scaffold/AGENTS.md +1 -1
- package/scaffold/skills/ship/SKILL.md +1 -1
- package/scaffold/skills/worktree/SKILL.md +11 -25
- package/src/canvas/terminal-config.js +2 -2
- package/src/cli/branch.js +256 -102
- package/src/cli/code.js +3 -3
- package/src/cli/dev.js +5 -5
- package/src/cli/index.js +3 -0
- package/src/cli/setup.js +53 -23
- package/src/server/index.js +1 -1
- package/src/worktree/port.js +20 -20
- package/src/worktree/port.test.js +21 -21
package/package.json
CHANGED
package/scaffold/AGENTS.md
CHANGED
|
@@ -66,7 +66,7 @@ The default location is in `.agents/plans`, but the user may ask for a specific
|
|
|
66
66
|
|
|
67
67
|
- **create** (`.agents/skills/create/SKILL.md`) — Walks through creating Storyboard assets: prototype, external prototype, flow, page, canvas, object, or record.
|
|
68
68
|
|
|
69
|
-
- **worktree** (`.agents/skills/worktree/SKILL.md`) — Creates a git worktree in
|
|
69
|
+
- **worktree** (`.agents/skills/worktree/SKILL.md`) — Creates a git worktree in `worktrees/<branch-name>` and switches into it.
|
|
70
70
|
|
|
71
71
|
- **tools** (`.agents/skills/tools/SKILL.md`) — Reference for creating toolbar tools: config schema, handlers, surfaces, and render types.
|
|
72
72
|
|
|
@@ -34,7 +34,7 @@ Invoke the **worktree** skill to create a git worktree for the feature branch.
|
|
|
34
34
|
- If the user provided an explicit branch name, use that instead.
|
|
35
35
|
- Use `ask_user` to confirm the branch name before creating the worktree.
|
|
36
36
|
|
|
37
|
-
After the worktree is created, all subsequent work happens inside
|
|
37
|
+
After the worktree is created, all subsequent work happens inside `worktrees/<branch-name>` **at the repository root** (use `git rev-parse --show-toplevel` to find the root). Never create worktrees nested inside other worktrees.
|
|
38
38
|
|
|
39
39
|
### Step 2: Plan the feature
|
|
40
40
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## What This Does
|
|
6
6
|
|
|
7
|
-
Creates a git worktree for a given branch name inside
|
|
7
|
+
Creates a git worktree for a given branch name inside `worktrees/` and switches into it.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -12,37 +12,23 @@ Creates a git worktree for a given branch name inside `.worktrees/` and switches
|
|
|
12
12
|
|
|
13
13
|
When the user asks for a worktree named `<branch-name>`:
|
|
14
14
|
|
|
15
|
-
### Step 0: Slugify the branch name
|
|
16
|
-
|
|
17
|
-
Sanitize the branch name to avoid filesystem and subdomain issues:
|
|
18
|
-
|
|
19
|
-
1. Convert to lowercase.
|
|
20
|
-
2. Replace dots (`.`), spaces, underscores, and other non-alphanumeric characters (except `-` and `/`) with hyphens.
|
|
21
|
-
3. Collapse consecutive hyphens into one.
|
|
22
|
-
4. Trim leading/trailing hyphens from each segment.
|
|
23
|
-
|
|
24
|
-
**Examples:**
|
|
25
|
-
- `feature.v2` → `feature-v2`
|
|
26
|
-
- `v3.11.0` → `v3-11-0`
|
|
27
|
-
- `my_cool.feature` → `my-cool-feature`
|
|
28
|
-
- `UPPER.Case` → `upper-case`
|
|
29
|
-
|
|
30
|
-
Use the slugified name for both the **branch name** and **worktree directory** throughout the rest of the workflow.
|
|
31
|
-
|
|
32
15
|
### Step 1: Create the worktree
|
|
33
16
|
|
|
34
17
|
If the branch already exists locally or on the remote:
|
|
35
18
|
|
|
36
19
|
```bash
|
|
37
|
-
git worktree add
|
|
20
|
+
git worktree add worktrees/<branch-name> <branch-name>
|
|
38
21
|
```
|
|
39
22
|
|
|
40
|
-
If the branch does NOT exist yet, create it from the current
|
|
23
|
+
If the branch does NOT exist yet, create it **from the current branch** (NOT from main):
|
|
41
24
|
|
|
42
25
|
```bash
|
|
43
|
-
git
|
|
26
|
+
CURRENT_BRANCH=$(git branch --show-current)
|
|
27
|
+
git worktree add worktrees/<branch-name> -b <branch-name> "$CURRENT_BRANCH"
|
|
44
28
|
```
|
|
45
29
|
|
|
30
|
+
> **⚠️ CRITICAL:** Always pass the current branch as the start-point. Without it, git defaults to HEAD of the main worktree, which may be a completely different branch.
|
|
31
|
+
|
|
46
32
|
### Step 2: Register a dev-server port
|
|
47
33
|
|
|
48
34
|
Assign a unique port for this worktree so multiple dev servers can run simultaneously.
|
|
@@ -59,12 +45,12 @@ Or if the project has `scripts/worktree-port.js`:
|
|
|
59
45
|
node scripts/worktree-port.js <branch-name>
|
|
60
46
|
```
|
|
61
47
|
|
|
62
|
-
This writes to
|
|
48
|
+
This writes to `worktrees/ports.json` (gitignored). The dev server (`npx storyboard-dev`) reads from this file automatically.
|
|
63
49
|
|
|
64
50
|
### Step 3: Change into the worktree directory
|
|
65
51
|
|
|
66
52
|
```bash
|
|
67
|
-
cd
|
|
53
|
+
cd worktrees/<branch-name>
|
|
68
54
|
```
|
|
69
55
|
|
|
70
56
|
All subsequent commands in the session should run from this directory.
|
|
@@ -99,9 +85,9 @@ The dev server automatically uses the port assigned in Step 2.
|
|
|
99
85
|
|
|
100
86
|
## Notes
|
|
101
87
|
|
|
102
|
-
- Worktrees live in
|
|
88
|
+
- Worktrees live in `worktrees/` at the repo root — this directory is already gitignored.
|
|
103
89
|
- The branch name comes from the user's request (e.g., "create worktree comments-redo" → branch is `comments-redo`).
|
|
104
90
|
- **Always slugify** the branch name (Step 0) before creating the worktree. Dots cause issues with subdomain routing and are replaced with hyphens.
|
|
105
91
|
- If the worktree already exists, inform the user and `cd` into it instead of recreating it.
|
|
106
92
|
- Port assignments are stable — once a worktree gets a port, it keeps it across restarts.
|
|
107
|
-
- To see all assigned ports, check
|
|
93
|
+
- To see all assigned ports, check `worktrees/ports.json`.
|
|
@@ -42,9 +42,9 @@ function readDevDomain() {
|
|
|
42
42
|
/** Detect worktree name */
|
|
43
43
|
function getWorktreeName() {
|
|
44
44
|
try {
|
|
45
|
-
// Check if we're in a
|
|
45
|
+
// Check if we're in a worktrees/ directory
|
|
46
46
|
const cwd = rootDir
|
|
47
|
-
const match = cwd.match(
|
|
47
|
+
const match = cwd.match(/worktrees\/([^/]+)/)
|
|
48
48
|
return match ? match[1] : 'main'
|
|
49
49
|
} catch { return 'main' }
|
|
50
50
|
}
|
package/src/cli/branch.js
CHANGED
|
@@ -2,18 +2,24 @@
|
|
|
2
2
|
* storyboard branch — Interactive guide to switch to a branch worktree.
|
|
3
3
|
*
|
|
4
4
|
* Deterministic flow (no AI):
|
|
5
|
-
* 1. Ask which branch to work on
|
|
6
|
-
* 2.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* 1. Ask which branch to work on (or accept --worktree flag)
|
|
6
|
+
* 2. If existing worktree:
|
|
7
|
+
* a. Stash uncommitted work in source (named stash)
|
|
8
|
+
* b. Ensure target is on the correct branch
|
|
9
|
+
* c. Apply source stash in target
|
|
10
|
+
* 3. If new worktree:
|
|
11
|
+
* a. Stash uncommitted work in source
|
|
12
|
+
* b. Create worktree (git worktree add + npm install)
|
|
13
|
+
* c. Pull --rebase from origin
|
|
14
|
+
* d. Apply source stash in target
|
|
15
|
+
* 4. Confirm to user
|
|
11
16
|
*
|
|
12
17
|
* Also available as post-setup prompt in setup.js.
|
|
13
18
|
*
|
|
14
19
|
* Usage:
|
|
15
|
-
* npx storyboard branch
|
|
16
|
-
* npx storyboard branch <name>
|
|
20
|
+
* npx storyboard branch # interactive
|
|
21
|
+
* npx storyboard branch <name> # positional, skip prompt
|
|
22
|
+
* npx storyboard branch --worktree=<name> # non-interactive flag
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
import * as p from '@clack/prompts'
|
|
@@ -22,8 +28,14 @@ import { existsSync } from 'fs'
|
|
|
22
28
|
import { resolve } from 'path'
|
|
23
29
|
import { repoRoot, worktreeDir, listWorktrees, getPort, detectWorktreeName } from '../worktree/port.js'
|
|
24
30
|
import { hasUncommittedChanges, localBranchExists } from './dev-helpers.js'
|
|
31
|
+
import { parseFlags } from './flags.js'
|
|
25
32
|
import { dim, green, bold, cyan } from './intro.js'
|
|
26
33
|
|
|
34
|
+
const flagSchema = {
|
|
35
|
+
worktree: { type: 'string', description: 'Target worktree/branch name (non-interactive)' },
|
|
36
|
+
cd: { type: 'boolean', default: false, description: 'Output shell-evaluable cd command (for eval)' },
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
/** Check if a remote branch exists on origin. */
|
|
28
40
|
function remoteBranchExists(name, cwd) {
|
|
29
41
|
try {
|
|
@@ -34,7 +46,7 @@ function remoteBranchExists(name, cwd) {
|
|
|
34
46
|
}
|
|
35
47
|
}
|
|
36
48
|
|
|
37
|
-
/** Get the current branch name. */
|
|
49
|
+
/** Get the current branch name in a given directory. */
|
|
38
50
|
function currentBranch(cwd) {
|
|
39
51
|
try {
|
|
40
52
|
return execFileSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf8' }).trim()
|
|
@@ -55,96 +67,151 @@ function isValidBranchName(name) {
|
|
|
55
67
|
return undefined
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
/** Build a timestamped stash message. */
|
|
71
|
+
function stashMessage(from, to) {
|
|
72
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
|
73
|
+
return `from-${from}-to-${to}-${ts}`
|
|
74
|
+
}
|
|
63
75
|
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Stash uncommitted changes (including untracked files) and return the stash ref SHA.
|
|
78
|
+
* Returns null if nothing was stashed.
|
|
79
|
+
*/
|
|
80
|
+
function stashChanges(cwd, message) {
|
|
81
|
+
if (!hasUncommittedChanges(cwd)) return null
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
p.log.step('Stashing uncommitted work…')
|
|
84
|
+
try {
|
|
85
|
+
execFileSync('git', ['stash', 'push', '-u', '-m', message], { cwd, stdio: 'pipe' })
|
|
86
|
+
// Capture the exact stash SHA so we can apply it by ref later
|
|
87
|
+
const sha = execFileSync('git', ['stash', 'list', '--format=%H', '-1'], { cwd, encoding: 'utf8' }).trim()
|
|
88
|
+
p.log.success(`Work stashed: ${dim(message)}`)
|
|
89
|
+
return sha
|
|
90
|
+
} catch {
|
|
91
|
+
p.log.warning('Could not stash changes — proceeding anyway')
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Apply a specific stash by SHA in the given directory.
|
|
98
|
+
* Uses apply (not pop) so the backup remains in the stash list.
|
|
99
|
+
*/
|
|
100
|
+
function applyStash(cwd, stashSha) {
|
|
101
|
+
try {
|
|
102
|
+
execFileSync('git', ['stash', 'apply', stashSha], { cwd, stdio: 'pipe' })
|
|
103
|
+
p.log.success('Previous work applied to this branch')
|
|
104
|
+
return true
|
|
105
|
+
} catch {
|
|
106
|
+
p.log.warning('Stash apply had conflicts — resolve them manually')
|
|
107
|
+
p.log.info(` Your work is safe in ${dim('git stash list')}`)
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Core logic for switching to an EXISTING worktree.
|
|
114
|
+
*
|
|
115
|
+
* 1. Stash source changes (if any)
|
|
116
|
+
* 2. Ensure target worktree is on the expected branch
|
|
117
|
+
* 3. Apply source stash in target
|
|
118
|
+
*/
|
|
119
|
+
function switchToExistingWorktree(targetBranch, { sourceDir, fromBranch }) {
|
|
120
|
+
const targetDir = worktreeDir(targetBranch)
|
|
121
|
+
|
|
122
|
+
// 1. Stash source changes
|
|
123
|
+
const sourceStashSha = stashChanges(sourceDir, stashMessage(fromBranch, targetBranch))
|
|
124
|
+
|
|
125
|
+
// 2. Ensure target is on the correct branch
|
|
126
|
+
const targetCurrentBranch = currentBranch(targetDir)
|
|
127
|
+
|
|
128
|
+
if (targetCurrentBranch !== targetBranch) {
|
|
129
|
+
// Target worktree is on a different branch — check if it's clean
|
|
130
|
+
if (hasUncommittedChanges(targetDir)) {
|
|
131
|
+
p.log.error(
|
|
132
|
+
`Worktree ${bold(targetBranch)} is on branch ${bold(targetCurrentBranch)} with uncommitted changes.`
|
|
133
|
+
)
|
|
134
|
+
p.log.info(` Clean up the worktree first:`)
|
|
135
|
+
p.log.info(` ${green('cd')} ${dim(`worktrees/${targetBranch}`)}`)
|
|
136
|
+
p.log.info(` ${dim('git stash')} ${dim('or')} ${dim('git commit')}`)
|
|
137
|
+
p.log.info(` ${dim(`git checkout ${targetBranch}`)}`)
|
|
138
|
+
if (sourceStashSha) {
|
|
139
|
+
p.log.info(`\n Your source stash is safe — apply it later with:`)
|
|
140
|
+
p.log.info(` ${dim(`git stash apply ${sourceStashSha.slice(0, 8)}`)}`)
|
|
85
141
|
}
|
|
86
|
-
p.
|
|
142
|
+
p.outro('')
|
|
143
|
+
process.exit(1)
|
|
87
144
|
}
|
|
88
145
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
146
|
+
// Clean worktree on wrong branch — switch it
|
|
147
|
+
p.log.step(`Switching worktree from ${bold(targetCurrentBranch)} to ${bold(targetBranch)}…`)
|
|
148
|
+
try {
|
|
149
|
+
execFileSync('git', ['checkout', targetBranch], { cwd: targetDir, stdio: 'pipe' })
|
|
150
|
+
p.log.success(`Now on branch ${bold(targetBranch)}`)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
p.log.error(`Could not switch branch: ${err.message || 'git checkout failed'}`)
|
|
153
|
+
if (sourceStashSha) {
|
|
154
|
+
p.log.info(` Your source stash is safe: ${dim(`git stash apply ${sourceStashSha.slice(0, 8)}`)}`)
|
|
155
|
+
}
|
|
156
|
+
p.outro('')
|
|
157
|
+
process.exit(1)
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
p.log.success(`Worktree ${bold(targetBranch)} is on the correct branch`)
|
|
161
|
+
}
|
|
94
162
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
163
|
+
// 3. Apply source stash in target
|
|
164
|
+
if (sourceStashSha) {
|
|
165
|
+
if (hasUncommittedChanges(targetDir)) {
|
|
166
|
+
p.log.warning(`Target worktree has uncommitted changes — skipping stash apply`)
|
|
167
|
+
p.log.info(` Apply your stash manually: ${dim(`git stash apply ${sourceStashSha.slice(0, 8)}`)}`)
|
|
168
|
+
} else {
|
|
169
|
+
applyStash(targetDir, sourceStashSha)
|
|
98
170
|
}
|
|
99
|
-
targetBranch = result.trim()
|
|
100
171
|
}
|
|
101
172
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 3. Stash uncommitted work
|
|
118
|
-
let didStash = false
|
|
119
|
-
const sourceDir = fromWorktree === 'main' ? root : worktreeDir(fromWorktree)
|
|
173
|
+
// 4. Summary
|
|
174
|
+
const lines = [
|
|
175
|
+
` Worktree ready: ${green(`worktrees/${targetBranch}`)}`,
|
|
176
|
+
]
|
|
177
|
+
if (sourceStashSha) {
|
|
178
|
+
lines.push(` Your uncommitted work has been safely moved`)
|
|
179
|
+
}
|
|
180
|
+
lines.push('')
|
|
181
|
+
lines.push(` ${green('cd')} ${dim(`worktrees/${targetBranch}`)}`)
|
|
182
|
+
lines.push(` ${green('npx storyboard dev')} ${dim('to start developing')}`)
|
|
183
|
+
lines.push('')
|
|
184
|
+
lines.push(` ${dim('Tip: auto-cd with')} ${green('eval "$(npx sb branch --cd)"')}`)
|
|
120
185
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
186
|
+
p.note(lines.join('\n'), `Branch ${bold(targetBranch)} is ready`)
|
|
187
|
+
p.outro('')
|
|
188
|
+
return targetDir
|
|
189
|
+
}
|
|
124
190
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Core logic for creating a NEW worktree.
|
|
193
|
+
*
|
|
194
|
+
* 1. Stash source changes
|
|
195
|
+
* 2. Resolve branch (local/remote/new)
|
|
196
|
+
* 3. Create worktree + npm install
|
|
197
|
+
* 4. Pull --rebase
|
|
198
|
+
* 5. Apply source stash
|
|
199
|
+
*/
|
|
200
|
+
function createNewWorktree(targetBranch, { sourceDir, fromBranch, root }) {
|
|
201
|
+
// 1. Stash source changes
|
|
202
|
+
const sourceStashSha = stashChanges(sourceDir, stashMessage(fromBranch, targetBranch))
|
|
134
203
|
|
|
135
|
-
//
|
|
204
|
+
// 2. Resolve branch
|
|
136
205
|
const hasLocal = localBranchExists(targetBranch, root)
|
|
137
206
|
const hasRemote = !hasLocal && remoteBranchExists(targetBranch, root)
|
|
138
207
|
const isNew = !hasLocal && !hasRemote
|
|
139
208
|
|
|
140
209
|
if (isNew) {
|
|
141
|
-
p.log.step(`Creating new branch ${bold(targetBranch)} from
|
|
210
|
+
p.log.step(`Creating new branch ${bold(targetBranch)} from ${bold(fromBranch)}`)
|
|
142
211
|
} else if (hasRemote) {
|
|
143
|
-
// Fetch the remote branch so git worktree add can use it
|
|
144
212
|
p.log.step(`Fetching ${bold(targetBranch)} from origin…`)
|
|
145
213
|
try {
|
|
146
214
|
execFileSync('git', ['fetch', 'origin', targetBranch], { cwd: root, stdio: 'pipe' })
|
|
147
|
-
// Create a local tracking branch
|
|
148
215
|
try {
|
|
149
216
|
execFileSync('git', ['branch', targetBranch, `origin/${targetBranch}`], { cwd: root, stdio: 'pipe' })
|
|
150
217
|
} catch { /* may already exist after fetch */ }
|
|
@@ -155,18 +222,19 @@ export async function runBranchGuide(branchArg) {
|
|
|
155
222
|
p.log.step(`Using existing branch ${bold(targetBranch)}`)
|
|
156
223
|
}
|
|
157
224
|
|
|
158
|
-
// Create the worktree
|
|
159
|
-
const targetDir = resolve(root, '
|
|
225
|
+
// 3. Create the worktree
|
|
226
|
+
const targetDir = resolve(root, 'worktrees', targetBranch)
|
|
160
227
|
const spin = p.spinner()
|
|
161
228
|
|
|
162
229
|
try {
|
|
230
|
+
// For new branches, use current branch as start-point (not main's HEAD)
|
|
163
231
|
const gitArgs = isNew
|
|
164
|
-
? ['worktree', 'add', targetDir, '-b', targetBranch]
|
|
232
|
+
? ['worktree', 'add', targetDir, '-b', targetBranch, fromBranch]
|
|
165
233
|
: ['worktree', 'add', targetDir, targetBranch]
|
|
166
234
|
|
|
167
|
-
spin.start(`Creating worktree
|
|
235
|
+
spin.start(`Creating worktree worktrees/${targetBranch}`)
|
|
168
236
|
execFileSync('git', gitArgs, { cwd: root, stdio: 'pipe' })
|
|
169
|
-
spin.stop(`Worktree created:
|
|
237
|
+
spin.stop(`Worktree created: worktrees/${targetBranch}`)
|
|
170
238
|
} catch (err) {
|
|
171
239
|
spin.stop('Failed to create worktree')
|
|
172
240
|
p.log.error(err.message || 'git worktree add failed')
|
|
@@ -186,7 +254,7 @@ export async function runBranchGuide(branchArg) {
|
|
|
186
254
|
// Assign a dev server port
|
|
187
255
|
getPort(targetBranch)
|
|
188
256
|
|
|
189
|
-
//
|
|
257
|
+
// 4. Pull --rebase from origin
|
|
190
258
|
if (!isNew) {
|
|
191
259
|
try {
|
|
192
260
|
spin.start('Pulling latest changes…')
|
|
@@ -197,36 +265,122 @@ export async function runBranchGuide(branchArg) {
|
|
|
197
265
|
}
|
|
198
266
|
}
|
|
199
267
|
|
|
200
|
-
//
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
// Apply (not pop) — keeps the backup in stash list
|
|
204
|
-
execFileSync('git', ['stash', 'apply'], { cwd: targetDir, stdio: 'pipe' })
|
|
205
|
-
p.log.success('Previous work applied to this branch')
|
|
206
|
-
} catch {
|
|
207
|
-
p.log.warning('Stash apply had conflicts — resolve them manually')
|
|
208
|
-
p.log.info(` Your work is safe in ${dim('git stash list')}`)
|
|
209
|
-
}
|
|
268
|
+
// 5. Apply source stash
|
|
269
|
+
if (sourceStashSha) {
|
|
270
|
+
applyStash(targetDir, sourceStashSha)
|
|
210
271
|
}
|
|
211
272
|
|
|
212
|
-
//
|
|
273
|
+
// 6. Summary
|
|
213
274
|
const lines = [
|
|
214
|
-
` Your branch is set up as a worktree in ${green(
|
|
275
|
+
` Your branch is set up as a worktree in ${green(`worktrees/${targetBranch}`)}`,
|
|
215
276
|
]
|
|
216
|
-
if (
|
|
277
|
+
if (sourceStashSha) {
|
|
217
278
|
lines.push(` Your uncommitted work has been safely moved`)
|
|
218
279
|
}
|
|
219
280
|
lines.push('')
|
|
220
|
-
lines.push(` ${green('cd')} ${dim(
|
|
281
|
+
lines.push(` ${green('cd')} ${dim(`worktrees/${targetBranch}`)}`)
|
|
221
282
|
lines.push(` ${green('npx storyboard dev')} ${dim('to start developing')}`)
|
|
222
283
|
lines.push('')
|
|
223
|
-
lines.push(` ${dim('Tip:
|
|
284
|
+
lines.push(` ${dim('Tip: auto-cd with')} ${green('eval "$(npx sb branch --cd)"')}`)
|
|
224
285
|
|
|
225
286
|
p.note(lines.join('\n'), `Branch ${bold(targetBranch)} is ready`)
|
|
226
287
|
p.outro('')
|
|
288
|
+
return targetDir
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Main ───
|
|
292
|
+
|
|
293
|
+
export async function runBranchGuide(branchArg) {
|
|
294
|
+
const root = repoRoot()
|
|
295
|
+
const existing = listWorktrees()
|
|
296
|
+
const fromWorktree = detectWorktreeName()
|
|
297
|
+
const sourceDir = fromWorktree === 'main' ? root : worktreeDir(fromWorktree)
|
|
298
|
+
const fromBranch = currentBranch(sourceDir)
|
|
299
|
+
|
|
300
|
+
// 1. Get branch name — select from existing or type a new one
|
|
301
|
+
let targetBranch = branchArg?.trim()
|
|
302
|
+
|
|
303
|
+
if (!targetBranch) {
|
|
304
|
+
if (existing.length > 0) {
|
|
305
|
+
// Build select options from existing worktrees + "new branch" option
|
|
306
|
+
const NEW_BRANCH = Symbol('new')
|
|
307
|
+
const options = [
|
|
308
|
+
...existing.map(name => ({ value: name, label: name })),
|
|
309
|
+
{ value: NEW_BRANCH, label: dim('Create a new branch…') },
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
const selected = await p.select({
|
|
313
|
+
message: 'Which branch do you want to work on?',
|
|
314
|
+
options,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
if (p.isCancel(selected)) {
|
|
318
|
+
p.cancel('Cancelled')
|
|
319
|
+
process.exit(0)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (selected === NEW_BRANCH) {
|
|
323
|
+
const newName = await p.text({
|
|
324
|
+
message: 'New branch name:',
|
|
325
|
+
placeholder: 'e.g. 4.3.0--my-feature',
|
|
326
|
+
validate: isValidBranchName,
|
|
327
|
+
})
|
|
328
|
+
if (p.isCancel(newName)) {
|
|
329
|
+
p.cancel('Cancelled')
|
|
330
|
+
process.exit(0)
|
|
331
|
+
}
|
|
332
|
+
targetBranch = newName.trim()
|
|
333
|
+
} else {
|
|
334
|
+
targetBranch = selected
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
// No existing worktrees — just ask for a name
|
|
338
|
+
const result = await p.text({
|
|
339
|
+
message: 'Branch name for new worktree:',
|
|
340
|
+
placeholder: 'e.g. 4.3.0--my-feature',
|
|
341
|
+
validate: isValidBranchName,
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
if (p.isCancel(result)) {
|
|
345
|
+
p.cancel('Cancelled')
|
|
346
|
+
process.exit(0)
|
|
347
|
+
}
|
|
348
|
+
targetBranch = result.trim()
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 2. Show equivalent non-interactive command (when user used TUI to select)
|
|
353
|
+
if (!branchArg) {
|
|
354
|
+
p.log.info(`${dim('Non-interactive:')} ${green(`npx sb branch --worktree=${targetBranch}`)}`)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 3. Route to existing or new worktree flow
|
|
358
|
+
const wtDir = worktreeDir(targetBranch)
|
|
359
|
+
if (existsSync(resolve(wtDir, '.git'))) {
|
|
360
|
+
return switchToExistingWorktree(targetBranch, { sourceDir, fromBranch })
|
|
361
|
+
} else {
|
|
362
|
+
return createNewWorktree(targetBranch, { sourceDir, fromBranch, root })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── Direct invocation ───
|
|
367
|
+
|
|
368
|
+
const { flags, positional } = parseFlags(process.argv.slice(3), flagSchema)
|
|
369
|
+
const branchArg = flags.worktree || positional[0] || undefined
|
|
370
|
+
|
|
371
|
+
// When --cd is set, redirect all TUI output (Clack) to stderr so that
|
|
372
|
+
// stdout contains only the shell-evaluable `cd <path>` command.
|
|
373
|
+
// Usage: eval "$(npx sb branch --worktree=<name> --cd)"
|
|
374
|
+
const realStdoutWrite = process.stdout.write.bind(process.stdout)
|
|
375
|
+
if (flags.cd) {
|
|
376
|
+
process.stdout.write = (chunk, encoding, callback) =>
|
|
377
|
+
process.stderr.write(chunk, encoding, callback)
|
|
227
378
|
}
|
|
228
379
|
|
|
229
|
-
// Direct invocation
|
|
230
|
-
const branchArg = process.argv[3] || undefined
|
|
231
380
|
p.intro('storyboard branch')
|
|
232
|
-
runBranchGuide(branchArg)
|
|
381
|
+
runBranchGuide(branchArg).then((targetDir) => {
|
|
382
|
+
if (flags.cd && targetDir) {
|
|
383
|
+
process.stdout.write = realStdoutWrite
|
|
384
|
+
realStdoutWrite(`cd ${JSON.stringify(targetDir)}\n`)
|
|
385
|
+
}
|
|
386
|
+
})
|
package/src/cli/code.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
* storyboard code # open current worktree or repo root
|
|
6
6
|
* storyboard code main # open repo root
|
|
7
|
-
* storyboard code <branch> # open
|
|
7
|
+
* storyboard code <branch> # open worktrees/<branch>/
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import * as p from '@clack/prompts'
|
|
@@ -33,7 +33,7 @@ if (!branch) {
|
|
|
33
33
|
const name = detectWorktreeName()
|
|
34
34
|
const dir = name === 'main' ? root : worktreeDir(name)
|
|
35
35
|
if (openInCode(dir)) {
|
|
36
|
-
p.outro(`Opened ${name === 'main' ? 'repo root' :
|
|
36
|
+
p.outro(`Opened ${name === 'main' ? 'repo root' : `worktrees/${name}/`}`)
|
|
37
37
|
} else {
|
|
38
38
|
p.log.error('Could not open VS Code. Is the `code` CLI installed?')
|
|
39
39
|
p.log.info('Run `npx storyboard setup` to install it, or open VS Code and run:')
|
|
@@ -59,7 +59,7 @@ if (!branch) {
|
|
|
59
59
|
process.exit(1)
|
|
60
60
|
}
|
|
61
61
|
if (openInCode(dir)) {
|
|
62
|
-
p.outro(`Opened
|
|
62
|
+
p.outro(`Opened worktrees/${branch}/`)
|
|
63
63
|
} else {
|
|
64
64
|
p.log.error('Could not open VS Code.')
|
|
65
65
|
process.exit(1)
|
package/src/cli/dev.js
CHANGED
|
@@ -60,13 +60,13 @@ function remoteBranchExists(name, cwd) {
|
|
|
60
60
|
* @returns {string} path to the new worktree directory
|
|
61
61
|
*/
|
|
62
62
|
function createWorktree(name, root, { newBranch = false } = {}) {
|
|
63
|
-
const targetDir = resolve(root, '
|
|
63
|
+
const targetDir = resolve(root, 'worktrees', name)
|
|
64
64
|
|
|
65
65
|
const gitArgs = newBranch
|
|
66
66
|
? ['worktree', 'add', targetDir, '-b', name]
|
|
67
67
|
: ['worktree', 'add', targetDir, name]
|
|
68
68
|
|
|
69
|
-
p.log.step(`Creating worktree:
|
|
69
|
+
p.log.step(`Creating worktree: worktrees/${name}`)
|
|
70
70
|
execFileSync('git', gitArgs, { cwd: root, stdio: 'inherit' })
|
|
71
71
|
|
|
72
72
|
p.log.step('Installing dependencies…')
|
|
@@ -112,7 +112,7 @@ async function resolveDevTarget(branchArg, { allowCreate = true } = {}) {
|
|
|
112
112
|
// No worktree exists — prompt the user to convert
|
|
113
113
|
p.log.warning(`Root is on branch "${branch}" instead of main.`)
|
|
114
114
|
const shouldConvert = await p.confirm({
|
|
115
|
-
message: `Convert "${branch}" to a worktree? (moves branch to
|
|
115
|
+
message: `Convert "${branch}" to a worktree? (moves branch to worktrees/${branch}/)`,
|
|
116
116
|
initialValue: true,
|
|
117
117
|
})
|
|
118
118
|
|
|
@@ -403,9 +403,9 @@ async function main() {
|
|
|
403
403
|
const { worktreeName, targetCwd, created } = await resolveDevTarget(branchArg, { allowCreate })
|
|
404
404
|
|
|
405
405
|
if (created) {
|
|
406
|
-
p.log.success(`Worktree ready:
|
|
406
|
+
p.log.success(`Worktree ready: worktrees/${worktreeName}`)
|
|
407
407
|
} else if (branchArg) {
|
|
408
|
-
p.log.info(`Using ${worktreeName === 'main' ? 'main repo' :
|
|
408
|
+
p.log.info(`Using ${worktreeName === 'main' ? 'main repo' : `worktrees/${worktreeName}`}`)
|
|
409
409
|
}
|
|
410
410
|
|
|
411
411
|
const domain = readDevDomain(targetCwd)
|
package/src/cli/index.js
CHANGED
|
@@ -93,8 +93,11 @@ function helpScreen(version) {
|
|
|
93
93
|
'',
|
|
94
94
|
` ${bold(cyan('Setup'))}`,
|
|
95
95
|
cmd('setup', 'Install deps, Caddy proxy, start proxy'),
|
|
96
|
+
cmd('setup --skip-branch', 'Non-interactive setup (skip branch prompt)'),
|
|
97
|
+
cmd('setup --branch=<name>', 'Setup + switch to a branch'),
|
|
96
98
|
cmd('branch', 'Switch to a branch (interactive worktree guide)'),
|
|
97
99
|
cmd('branch <name>', 'Switch to a specific branch directly'),
|
|
100
|
+
cmd('branch --worktree=<name>', 'Non-interactive branch switch'),
|
|
98
101
|
cmd('proxy start', 'Start or reload Caddy proxy'),
|
|
99
102
|
cmd('proxy close', 'Stop Caddy proxy'),
|
|
100
103
|
'',
|
package/src/cli/setup.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* storyboard setup — One-time setup for the Storyboard dev environment.
|
|
3
3
|
*
|
|
4
4
|
* Idempotent: safe to run multiple times, only does what's needed.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx storyboard setup # interactive
|
|
8
|
+
* npx storyboard setup --skip-branch # non-interactive, skip branch prompt
|
|
9
|
+
* npx storyboard setup --branch=<name> # non-interactive, switch to branch after setup
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
import * as p from '@clack/prompts'
|
|
@@ -9,7 +14,15 @@ import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync, symlin
|
|
|
9
14
|
import path from 'path'
|
|
10
15
|
import { execSync } from 'child_process'
|
|
11
16
|
import { generateCaddyfile, isCaddyInstalled, isCaddyRunning, startCaddy, reloadCaddy } from './proxy.js'
|
|
12
|
-
import { gettingStartedLines, dim, magenta, bold, yellow } from './intro.js'
|
|
17
|
+
import { gettingStartedLines, dim, magenta, bold, yellow, green } from './intro.js'
|
|
18
|
+
import { parseFlags } from './flags.js'
|
|
19
|
+
|
|
20
|
+
const flagSchema = {
|
|
21
|
+
'skip-branch': { type: 'boolean', default: false, description: 'Skip the branch prompt at the end' },
|
|
22
|
+
branch: { type: 'string', description: 'Switch to a branch after setup (non-interactive)' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { flags } = parseFlags(process.argv.slice(3), flagSchema)
|
|
13
26
|
|
|
14
27
|
/**
|
|
15
28
|
* Run a potentially slow task with a spinner that only appears after 500ms.
|
|
@@ -330,11 +343,16 @@ if (isCaddyInstalled()) {
|
|
|
330
343
|
p.log.info(dim('Skipping npm install — dev server is running (would cause restart)'))
|
|
331
344
|
p.log.info(dim('Run `npm install` manually after stopping the dev server if needed'))
|
|
332
345
|
} else {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
346
|
+
try {
|
|
347
|
+
await withSpin(
|
|
348
|
+
'Installing dependencies...',
|
|
349
|
+
'Dependencies installed',
|
|
350
|
+
() => { run('npm install', { stdio: 'ignore' }) }
|
|
351
|
+
)
|
|
352
|
+
} catch {
|
|
353
|
+
p.log.warning('npm install failed — run it manually to see details')
|
|
354
|
+
p.log.info(` ${dim('npm install')}`)
|
|
355
|
+
}
|
|
338
356
|
}
|
|
339
357
|
}
|
|
340
358
|
|
|
@@ -349,26 +367,38 @@ p.note(
|
|
|
349
367
|
|
|
350
368
|
// Offer branch guide
|
|
351
369
|
{
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
currentBranchName = execSync('git branch --show-current', { encoding: 'utf8' }).trim() || 'main'
|
|
355
|
-
} catch { /* empty */ }
|
|
356
|
-
|
|
357
|
-
const wantBranch = await p.select({
|
|
358
|
-
message: 'Want to work from a different branch?',
|
|
359
|
-
options: [
|
|
360
|
-
{ value: true, label: 'Yes (help me create it)' },
|
|
361
|
-
{ value: false, label: `No (stay on current branch ${bold(currentBranchName)})` },
|
|
362
|
-
],
|
|
363
|
-
initialValue: false,
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
if (!p.isCancel(wantBranch) && wantBranch) {
|
|
370
|
+
// Non-interactive: --branch=<name> or --skip-branch
|
|
371
|
+
if (flags.branch) {
|
|
367
372
|
const { runBranchGuide } = await import('./branch.js')
|
|
368
|
-
await runBranchGuide()
|
|
369
|
-
} else {
|
|
373
|
+
await runBranchGuide(flags.branch)
|
|
374
|
+
} else if (flags['skip-branch']) {
|
|
370
375
|
console.log()
|
|
371
376
|
console.log(mascot())
|
|
372
377
|
p.outro('')
|
|
378
|
+
} else {
|
|
379
|
+
// Interactive: ask the user
|
|
380
|
+
let currentBranchName = 'main'
|
|
381
|
+
try {
|
|
382
|
+
currentBranchName = execSync('git branch --show-current', { encoding: 'utf8' }).trim() || 'main'
|
|
383
|
+
} catch { /* empty */ }
|
|
384
|
+
|
|
385
|
+
const wantBranch = await p.select({
|
|
386
|
+
message: 'Want to work from a different branch?',
|
|
387
|
+
options: [
|
|
388
|
+
{ value: true, label: 'Yes (help me create it)' },
|
|
389
|
+
{ value: false, label: `No (stay on current branch ${bold(currentBranchName)})` },
|
|
390
|
+
],
|
|
391
|
+
initialValue: false,
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
if (!p.isCancel(wantBranch) && wantBranch) {
|
|
395
|
+
const { runBranchGuide } = await import('./branch.js')
|
|
396
|
+
await runBranchGuide()
|
|
397
|
+
} else {
|
|
398
|
+
console.log()
|
|
399
|
+
console.log(mascot())
|
|
400
|
+
p.log.info(`${dim('Non-interactive:')} ${green('npx sb setup --skip-branch')}`)
|
|
401
|
+
p.outro('')
|
|
402
|
+
}
|
|
373
403
|
}
|
|
374
404
|
}
|
package/src/server/index.js
CHANGED
|
@@ -89,7 +89,7 @@ async function parseBody(req) {
|
|
|
89
89
|
function resolveWorktreeCwd(branch) {
|
|
90
90
|
const root = repoRoot()
|
|
91
91
|
if (branch === 'main') return root
|
|
92
|
-
const wtDir = join(root, '
|
|
92
|
+
const wtDir = join(root, 'worktrees', branch)
|
|
93
93
|
if (existsSync(wtDir)) return wtDir
|
|
94
94
|
return null
|
|
95
95
|
}
|
package/src/worktree/port.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Worktree Port Registry
|
|
3
3
|
*
|
|
4
|
-
* Manages a JSON registry (
|
|
4
|
+
* Manages a JSON registry (worktrees/ports.json) that maps worktree names
|
|
5
5
|
* to unique dev-server ports. Main always gets 1234; worktrees get 1235+.
|
|
6
6
|
*
|
|
7
7
|
* This module is published as part of @dfosco/storyboard-core so client
|
|
@@ -19,10 +19,10 @@ import { findByWorktree } from './serverRegistry.js'
|
|
|
19
19
|
const BASE_PORT = 1234
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Resolve the path to
|
|
22
|
+
* Resolve the path to worktrees/ports.json.
|
|
23
23
|
*
|
|
24
24
|
* Derives the repo root from directory structure so it works whether
|
|
25
|
-
* running from the repo root or from inside
|
|
25
|
+
* running from the repo root or from inside worktrees/<name>/ — even
|
|
26
26
|
* when ports.json does not exist yet.
|
|
27
27
|
*
|
|
28
28
|
* @param {string} [cwd] — override working directory
|
|
@@ -31,34 +31,34 @@ const BASE_PORT = 1234
|
|
|
31
31
|
export function portsFilePath(cwd = process.cwd()) {
|
|
32
32
|
const realCwd = realpathSync(cwd)
|
|
33
33
|
|
|
34
|
-
// Check if we're inside
|
|
35
|
-
const worktreeMatch = realCwd.match(/^(.+)[/\\]
|
|
34
|
+
// Check if we're inside worktrees/<name>/
|
|
35
|
+
const worktreeMatch = realCwd.match(/^(.+)[/\\]worktrees[/\\][^/\\]+/)
|
|
36
36
|
if (worktreeMatch) {
|
|
37
|
-
return join(worktreeMatch[1], '
|
|
37
|
+
return join(worktreeMatch[1], 'worktrees', 'ports.json')
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// We're at the repo root (or somewhere else) — default location
|
|
41
|
-
return join(realCwd, '
|
|
41
|
+
return join(realCwd, 'worktrees', 'ports.json')
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Detect the worktree name from the current git context.
|
|
46
46
|
*
|
|
47
|
-
* Returns 'main' when not inside a
|
|
47
|
+
* Returns 'main' when not inside a worktrees/<name>/ directory.
|
|
48
48
|
*/
|
|
49
49
|
export function detectWorktreeName() {
|
|
50
50
|
try {
|
|
51
51
|
const topLevel = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
|
|
52
52
|
const realTop = realpathSync(topLevel)
|
|
53
53
|
|
|
54
|
-
// Check if we're inside a
|
|
55
|
-
if (realTop.includes('
|
|
54
|
+
// Check if we're inside a worktrees/<name> directory
|
|
55
|
+
if (realTop.includes('worktrees/') || realTop.includes('worktrees\\')) {
|
|
56
56
|
return basename(realTop)
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// Also check the cwd pattern
|
|
60
60
|
const realCwd = realpathSync(process.cwd())
|
|
61
|
-
const worktreeMatch = realCwd.match(
|
|
61
|
+
const worktreeMatch = realCwd.match(/worktrees[/\\]([^/\\]+)/)
|
|
62
62
|
if (worktreeMatch) return worktreeMatch[1]
|
|
63
63
|
|
|
64
64
|
// Not a worktree — check the current branch name
|
|
@@ -90,7 +90,7 @@ function isPortInUse(port) {
|
|
|
90
90
|
/**
|
|
91
91
|
* Get or assign a port for the given worktree name.
|
|
92
92
|
*
|
|
93
|
-
* Creates
|
|
93
|
+
* Creates worktrees/ports.json if it doesn't exist. Assigns ports
|
|
94
94
|
* starting at BASE_PORT+1 (1235) for non-main worktrees.
|
|
95
95
|
* If the previously assigned port was stolen by another process,
|
|
96
96
|
* reassigns to the next available port.
|
|
@@ -179,7 +179,7 @@ export function resolveRunningPort(worktreeName) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/**
|
|
182
|
-
* Resolve the port for a worktree from
|
|
182
|
+
* Resolve the port for a worktree from worktrees/ports.json
|
|
183
183
|
* without assigning a new one if missing.
|
|
184
184
|
*
|
|
185
185
|
* @param {string} worktreeName
|
|
@@ -216,9 +216,9 @@ export function slugify(name) {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* Resolve the repo root — the directory that contains
|
|
219
|
+
* Resolve the repo root — the directory that contains `worktrees/`.
|
|
220
220
|
*
|
|
221
|
-
* Works whether cwd is the repo root itself or inside
|
|
221
|
+
* Works whether cwd is the repo root itself or inside `worktrees/<name>/`.
|
|
222
222
|
*
|
|
223
223
|
* @param {string} [cwd]
|
|
224
224
|
* @returns {string} absolute path to repo root
|
|
@@ -226,7 +226,7 @@ export function slugify(name) {
|
|
|
226
226
|
export function repoRoot(cwd = process.cwd()) {
|
|
227
227
|
const realCwd = realpathSync(cwd)
|
|
228
228
|
|
|
229
|
-
const worktreeMatch = realCwd.match(/^(.+)[/\\]
|
|
229
|
+
const worktreeMatch = realCwd.match(/^(.+)[/\\]worktrees[/\\][^/\\]+/)
|
|
230
230
|
if (worktreeMatch) return worktreeMatch[1]
|
|
231
231
|
|
|
232
232
|
return realCwd
|
|
@@ -235,7 +235,7 @@ export function repoRoot(cwd = process.cwd()) {
|
|
|
235
235
|
/**
|
|
236
236
|
* Resolve the full path to a worktree directory.
|
|
237
237
|
*
|
|
238
|
-
* Returns repo root for 'main',
|
|
238
|
+
* Returns repo root for 'main', `worktrees/<name>` otherwise.
|
|
239
239
|
*
|
|
240
240
|
* @param {string} name — worktree name
|
|
241
241
|
* @param {string} [cwd]
|
|
@@ -244,11 +244,11 @@ export function repoRoot(cwd = process.cwd()) {
|
|
|
244
244
|
export function worktreeDir(name, cwd) {
|
|
245
245
|
const root = repoRoot(cwd)
|
|
246
246
|
if (name === 'main') return root
|
|
247
|
-
return join(root, '
|
|
247
|
+
return join(root, 'worktrees', name)
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
* List existing worktree directory names from
|
|
251
|
+
* List existing worktree directory names from `worktrees/`.
|
|
252
252
|
*
|
|
253
253
|
* Only returns directories that look like real worktrees (contain a `.git` file).
|
|
254
254
|
* Does not include 'main'.
|
|
@@ -258,7 +258,7 @@ export function worktreeDir(name, cwd) {
|
|
|
258
258
|
*/
|
|
259
259
|
export function listWorktrees(cwd) {
|
|
260
260
|
const root = repoRoot(cwd)
|
|
261
|
-
const worktreesDir = join(root, '
|
|
261
|
+
const worktreesDir = join(root, 'worktrees')
|
|
262
262
|
|
|
263
263
|
if (!existsSync(worktreesDir)) return []
|
|
264
264
|
|
|
@@ -43,27 +43,27 @@ describe('portsFilePath', () => {
|
|
|
43
43
|
rmSync(tempRoot, { recursive: true, force: true })
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
-
it('returns root
|
|
46
|
+
it('returns root worktrees/ports.json from repo root', () => {
|
|
47
47
|
const result = portsFilePath(tempRoot)
|
|
48
|
-
expect(result).toBe(join(tempRoot, '
|
|
48
|
+
expect(result).toBe(join(tempRoot, 'worktrees', 'ports.json'))
|
|
49
49
|
})
|
|
50
50
|
|
|
51
51
|
it('returns shared ports.json from inside a worktree dir', () => {
|
|
52
|
-
// Simulate
|
|
53
|
-
const worktreeDir = join(tempRoot, '
|
|
52
|
+
// Simulate worktrees/my-branch/
|
|
53
|
+
const worktreeDir = join(tempRoot, 'worktrees', 'my-branch')
|
|
54
54
|
mkdirSync(worktreeDir, { recursive: true })
|
|
55
55
|
const result = portsFilePath(worktreeDir)
|
|
56
|
-
expect(result).toBe(join(tempRoot, '
|
|
56
|
+
expect(result).toBe(join(tempRoot, 'worktrees', 'ports.json'))
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
it('returns shared ports.json from worktree even when ports.json does not exist', () => {
|
|
60
60
|
// This is the key bug fix — first run from a worktree with no ports.json
|
|
61
|
-
const worktreeDir = join(tempRoot, '
|
|
61
|
+
const worktreeDir = join(tempRoot, 'worktrees', 'first-run')
|
|
62
62
|
mkdirSync(worktreeDir, { recursive: true })
|
|
63
63
|
const result = portsFilePath(worktreeDir)
|
|
64
|
-
// Must point to root, NOT to
|
|
65
|
-
expect(result).toBe(join(tempRoot, '
|
|
66
|
-
expect(result).not.toContain('first-run
|
|
64
|
+
// Must point to root, NOT to worktrees/first-run/worktrees/ports.json
|
|
65
|
+
expect(result).toBe(join(tempRoot, 'worktrees', 'ports.json'))
|
|
66
|
+
expect(result).not.toContain('first-run/worktrees')
|
|
67
67
|
})
|
|
68
68
|
})
|
|
69
69
|
|
|
@@ -73,7 +73,7 @@ describe('getPort / resolvePort', () => {
|
|
|
73
73
|
|
|
74
74
|
beforeEach(() => {
|
|
75
75
|
tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-port-test-')))
|
|
76
|
-
mkdirSync(join(tempRoot, '
|
|
76
|
+
mkdirSync(join(tempRoot, 'worktrees'), { recursive: true })
|
|
77
77
|
originalCwd = process.cwd()
|
|
78
78
|
process.chdir(tempRoot)
|
|
79
79
|
})
|
|
@@ -106,7 +106,7 @@ describe('getPort / resolvePort', () => {
|
|
|
106
106
|
|
|
107
107
|
it('persists to ports.json', () => {
|
|
108
108
|
getPort('persisted')
|
|
109
|
-
const portsFile = join(tempRoot, '
|
|
109
|
+
const portsFile = join(tempRoot, 'worktrees', 'ports.json')
|
|
110
110
|
expect(existsSync(portsFile)).toBe(true)
|
|
111
111
|
const data = JSON.parse(readFileSync(portsFile, 'utf8'))
|
|
112
112
|
expect(data.persisted).toBe(1235)
|
|
@@ -123,7 +123,7 @@ describe('getPort / resolvePort', () => {
|
|
|
123
123
|
})
|
|
124
124
|
|
|
125
125
|
it('handles corrupted ports.json gracefully', () => {
|
|
126
|
-
const portsFile = join(tempRoot, '
|
|
126
|
+
const portsFile = join(tempRoot, 'worktrees', 'ports.json')
|
|
127
127
|
writeFileSync(portsFile, 'not valid json')
|
|
128
128
|
// Should not throw — starts fresh
|
|
129
129
|
const port = getPort('recovery')
|
|
@@ -136,7 +136,7 @@ describe('repoRoot', () => {
|
|
|
136
136
|
|
|
137
137
|
beforeEach(() => {
|
|
138
138
|
tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-root-test-')))
|
|
139
|
-
mkdirSync(join(tempRoot, '
|
|
139
|
+
mkdirSync(join(tempRoot, 'worktrees', 'my-branch'), { recursive: true })
|
|
140
140
|
})
|
|
141
141
|
|
|
142
142
|
afterEach(() => {
|
|
@@ -147,8 +147,8 @@ describe('repoRoot', () => {
|
|
|
147
147
|
expect(repoRoot(tempRoot)).toBe(tempRoot)
|
|
148
148
|
})
|
|
149
149
|
|
|
150
|
-
it('returns parent of
|
|
151
|
-
const wt = join(tempRoot, '
|
|
150
|
+
it('returns parent of worktrees when inside a worktree', () => {
|
|
151
|
+
const wt = join(tempRoot, 'worktrees', 'my-branch')
|
|
152
152
|
expect(repoRoot(wt)).toBe(tempRoot)
|
|
153
153
|
})
|
|
154
154
|
})
|
|
@@ -158,7 +158,7 @@ describe('worktreeDir', () => {
|
|
|
158
158
|
|
|
159
159
|
beforeEach(() => {
|
|
160
160
|
tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-wtdir-test-')))
|
|
161
|
-
mkdirSync(join(tempRoot, '
|
|
161
|
+
mkdirSync(join(tempRoot, 'worktrees'), { recursive: true })
|
|
162
162
|
})
|
|
163
163
|
|
|
164
164
|
afterEach(() => {
|
|
@@ -169,8 +169,8 @@ describe('worktreeDir', () => {
|
|
|
169
169
|
expect(worktreeDir('main', tempRoot)).toBe(tempRoot)
|
|
170
170
|
})
|
|
171
171
|
|
|
172
|
-
it('returns
|
|
173
|
-
expect(worktreeDir('my-feature', tempRoot)).toBe(join(tempRoot, '
|
|
172
|
+
it('returns worktrees/<name> for branches', () => {
|
|
173
|
+
expect(worktreeDir('my-feature', tempRoot)).toBe(join(tempRoot, 'worktrees', 'my-feature'))
|
|
174
174
|
})
|
|
175
175
|
})
|
|
176
176
|
|
|
@@ -185,12 +185,12 @@ describe('listWorktrees', () => {
|
|
|
185
185
|
rmSync(tempRoot, { recursive: true, force: true })
|
|
186
186
|
})
|
|
187
187
|
|
|
188
|
-
it('returns empty array when
|
|
188
|
+
it('returns empty array when worktrees does not exist', () => {
|
|
189
189
|
expect(listWorktrees(tempRoot)).toEqual([])
|
|
190
190
|
})
|
|
191
191
|
|
|
192
192
|
it('returns only directories with a .git file', () => {
|
|
193
|
-
const wtDir = join(tempRoot, '
|
|
193
|
+
const wtDir = join(tempRoot, 'worktrees')
|
|
194
194
|
mkdirSync(wtDir)
|
|
195
195
|
|
|
196
196
|
// Valid worktree — has .git file
|
|
@@ -208,7 +208,7 @@ describe('listWorktrees', () => {
|
|
|
208
208
|
})
|
|
209
209
|
|
|
210
210
|
it('returns multiple worktrees', () => {
|
|
211
|
-
const wtDir = join(tempRoot, '
|
|
211
|
+
const wtDir = join(tempRoot, 'worktrees')
|
|
212
212
|
mkdirSync(wtDir)
|
|
213
213
|
|
|
214
214
|
for (const name of ['alpha', 'beta', 'gamma']) {
|