git-workspace-bare 0.1.1 → 0.3.1

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.
@@ -0,0 +1,51 @@
1
+ import fs from 'bare-fs'
2
+ import { spawnSync } from 'bare-subprocess'
3
+ import networkEnvironment from './git-network-environment.js'
4
+
5
+ export default function cloneRepository(repository, directory, { initialize = false } = {}) {
6
+ const remote = `git@github.com:${repository}.git`
7
+ const clone = spawnSync('git', ['clone', remote, directory], {
8
+ stdio: 'inherit',
9
+ ...(initialize ? { env: networkEnvironment() } : {})
10
+ })
11
+ if (clone.error) throw clone.error
12
+ // An interrupted command is not a request to create a different repository.
13
+ if (clone.signal) throw new Error(`Git clone was interrupted by signal ${clone.signal}`)
14
+ if (clone.status === 0) return
15
+ if (!initialize) throw new Error(`Could not clone ${repository}`)
16
+ if (!emptyDestination(directory)) {
17
+ const err = new Error(
18
+ `Could not clone ${repository}; destination is not an empty directory: ${directory}`
19
+ )
20
+ err.hint = 'the destination was preserved; inspect the failed clone before retrying'
21
+ throw err
22
+ }
23
+
24
+ git(['init', directory])
25
+ git(['-C', directory, 'remote', 'add', 'origin', remote])
26
+ console.error(
27
+ `warning: could not clone ${repository}; initialized an empty local repository with origin configured`
28
+ )
29
+ console.error(
30
+ 'hint: create the GitHub repository or synchronize with its existing history when ready; no project commits were pushed'
31
+ )
32
+ }
33
+
34
+ export function emptyDestination(directory) {
35
+ let entry
36
+ try {
37
+ entry = fs.lstatSync(directory)
38
+ } catch (err) {
39
+ if (err.code === 'ENOENT') return true
40
+ throw err
41
+ }
42
+ return entry.isDirectory() && !entry.isSymbolicLink() && fs.readdirSync(directory).length === 0
43
+ }
44
+
45
+ function git(args) {
46
+ const result = spawnSync('git', args)
47
+ if (result.error) throw result.error
48
+ if (result.signal || result.status !== 0) {
49
+ throw new Error(result.stderr?.toString().trim() || 'Could not initialize local repository')
50
+ }
51
+ }
@@ -0,0 +1,29 @@
1
+ import { spawnSync } from 'bare-subprocess'
2
+ import pushManifest from './push-manifest.js'
3
+
4
+ export default function commitManifest(directory, message) {
5
+ git(directory, ['add', '--', 'workspace.json'])
6
+ const diff = spawnSync('git', [
7
+ '-C',
8
+ directory,
9
+ 'diff',
10
+ '--cached',
11
+ '--quiet',
12
+ '--',
13
+ 'workspace.json'
14
+ ])
15
+ if (diff.error) throw diff.error
16
+ if (diff.status !== 0 && diff.status !== 1) {
17
+ throw new Error(diff.stderr?.toString().trim() || 'Could not inspect manifest changes')
18
+ }
19
+ if (diff.status === 1) {
20
+ git(directory, ['commit', '--only', '-m', message, '--', 'workspace.json'])
21
+ }
22
+ pushManifest(directory)
23
+ }
24
+
25
+ function git(directory, args) {
26
+ const result = spawnSync('git', ['-C', directory, ...args])
27
+ if (result.error) throw result.error
28
+ if (result.status !== 0) throw new Error(result.stderr?.toString().trim() || 'Git command failed')
29
+ }
@@ -0,0 +1,93 @@
1
+ import { spawnSync } from 'bare-subprocess'
2
+ import { readManifest } from './registry.js'
3
+ import { manifestNames } from './manifest.js'
4
+ import repositoryPath from './repository-path.js'
5
+
6
+ // The last word is the partial word at the cursor, including an empty word.
7
+ // Never parse this request as a command: completing a mutation must only read.
8
+ export default function completion(words, definition) {
9
+ const current = words.at(-1) || ''
10
+ const previous = words.slice(0, -1)
11
+ let command = definition
12
+ let name = null
13
+ const args = []
14
+ let positional = false
15
+
16
+ for (const word of previous) {
17
+ if (!positional && word === '--') {
18
+ positional = true
19
+ } else if (!positional && word.startsWith('-')) {
20
+ // Current flags are booleans; help/version terminate the invocation.
21
+ if (['-h', '--help', '--version'].includes(word)) return []
22
+ const flags = Object.keys(command.flags).flatMap((flag) => flag.split('|'))
23
+ if (!flags.includes(word)) return []
24
+ } else if (name === null) {
25
+ if (!Object.hasOwn(definition.commands, word)) return []
26
+ name = word
27
+ command = definition.commands[name]
28
+ } else {
29
+ args.push(word)
30
+ }
31
+ }
32
+
33
+ let candidates = []
34
+ if (!positional && current.startsWith('-')) {
35
+ candidates = Object.keys(command.flags).flatMap((flag) => flag.split('|'))
36
+ } else if (name === null) {
37
+ candidates = Object.keys(definition.commands)
38
+ } else if (name === 'hook' && args.length === 0) {
39
+ candidates = Object.keys(command.commands)
40
+ } else if (name === 'completion' && args.length === 0) {
41
+ candidates = ['bash']
42
+ } else if (name === 'rename' && args.length === 1) {
43
+ // The target is a new name. Offer the required prefix, not existing targets.
44
+ if (args[0].includes('@')) candidates = [args[0].split('@')[0] + '@']
45
+ } else if (args.length === 0 && ['add', 'remove', 'rename'].includes(name)) {
46
+ try {
47
+ const manifest = readManifest()
48
+ if (name !== 'add') {
49
+ candidates = manifestNames(manifest)
50
+ } else if (current.includes('@')) {
51
+ const root = current.split('@')[0]
52
+ if (manifest.repositories.some(({ name }) => name === root)) {
53
+ candidates = branches(root).map((branch) => `${root}@${branch}`)
54
+ }
55
+ } else {
56
+ candidates = manifest.repositories.map(({ name }) => name)
57
+ }
58
+ } catch {
59
+ // Tab must stay quiet for an uninitialized, missing, or invalid workspace.
60
+ return []
61
+ }
62
+ }
63
+
64
+ return [...new Set(candidates)].filter((candidate) => candidate.startsWith(current)).sort()
65
+ }
66
+
67
+ function branches(repository) {
68
+ const result = spawnSync('git', [
69
+ '--no-optional-locks',
70
+ '-C',
71
+ repositoryPath(repository),
72
+ 'for-each-ref',
73
+ '--format=%(refname) %(symref)',
74
+ 'refs/heads/',
75
+ 'refs/remotes/origin/'
76
+ ])
77
+ if (result.error || result.status !== 0) return []
78
+ return result.stdout
79
+ .toString()
80
+ .split('\n')
81
+ .flatMap((line) => {
82
+ const [ref, symbolic] = line.trim().split(' ')
83
+ if (!ref || symbolic) return []
84
+ const branch = ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '')
85
+ try {
86
+ // Only suggest branch names that the workspace CLI accepts.
87
+ repositoryPath(`${repository}@${branch}`)
88
+ return [branch]
89
+ } catch {
90
+ return []
91
+ }
92
+ })
93
+ }
@@ -0,0 +1,31 @@
1
+ import env from 'bare-env'
2
+ import { spawnSync } from 'bare-subprocess'
3
+
4
+ export default function networkEnvironment(directory) {
5
+ const environment = { ...env, GIT_TERMINAL_PROMPT: '0' }
6
+ // Preserve custom SSH programs. Supply bounded, noninteractive connection
7
+ // defaults only for ordinary OpenSSH, including offline manifest pushes.
8
+ if (
9
+ env.GIT_SSH_COMMAND !== undefined ||
10
+ env.GIT_SSH !== undefined ||
11
+ config(directory, 'core.sshCommand') !== null
12
+ ) {
13
+ return environment
14
+ }
15
+ const variant = env.GIT_SSH_VARIANT || config(directory, 'ssh.variant')
16
+ if (variant && !['ssh', 'auto'].includes(variant)) return environment
17
+ environment.GIT_SSH_COMMAND = 'ssh -o ConnectTimeout=5 -o ConnectionAttempts=1 -o BatchMode=yes'
18
+ return environment
19
+ }
20
+
21
+ function config(directory, key) {
22
+ const args = directory ? ['-C', directory] : []
23
+ const result = spawnSync('git', [...args, 'config', '--get', key])
24
+ if (result.error) throw result.error
25
+ if (result.signal) throw new Error(`Git config was interrupted by signal ${result.signal}`)
26
+ if (result.status === 1) return null
27
+ if (result.status !== 0) {
28
+ throw new Error(result.stderr?.toString().trim() || 'Could not read Git config')
29
+ }
30
+ return result.stdout.toString().replace(/\r?\n$/, '')
31
+ }
@@ -0,0 +1,239 @@
1
+ import fs from 'bare-fs'
2
+ import os from 'bare-os'
3
+ import path from 'bare-path'
4
+ import { randomUUID } from 'bare-crypto'
5
+ import { spawnSync } from 'bare-subprocess'
6
+ import { readManifest, registryPath } from './registry.js'
7
+ import repositoryPath from './repository-path.js'
8
+ import Hooks from './hooks.js'
9
+
10
+ // Hook metadata is observational: failure to collect it must not change the command outcome.
11
+ export function observe(fn) {
12
+ try {
13
+ return fn()
14
+ } catch (err) {
15
+ console.error(`warning: could not prepare post hook context: ${err.message}`)
16
+ return null
17
+ }
18
+ }
19
+
20
+ export function withPostHooks(command, input, options, fn) {
21
+ for (const name of Object.values(input)) repositoryPath(name)
22
+ const hooks = new Hooks(options)
23
+ const operation = hooks.has(command)
24
+ ? observe(() => beginOperation(command, input, readManifest()))
25
+ : null
26
+ let error = null
27
+ try {
28
+ return fn()
29
+ } catch (err) {
30
+ error = err
31
+ throw err
32
+ } finally {
33
+ if (operation) hooks.run(observe(() => finishOperation(operation, readManifest(), error)))
34
+ }
35
+ }
36
+
37
+ export function beginOperation(
38
+ command,
39
+ input,
40
+ manifest,
41
+ { source = 'cli', known = manifest } = {}
42
+ ) {
43
+ const names = [input.repository || input.from]
44
+ if (command === 'add' && input.repository.includes('@')) {
45
+ names.unshift(input.repository.split('@')[0])
46
+ }
47
+ if (command === 'rename' && !input.from.includes('@')) {
48
+ const root = manifest.repositories.find((repo) => repo.name === input.from)
49
+ for (const entry of root?.worktrees || []) names.push(`${input.from}@${entry.branch}`)
50
+ // Root renames also move unregistered linked worktrees in the workspace layout.
51
+ const prefix = repositoryPath(input.from) + '@'
52
+ const linked = git(repositoryPath(input.from), ['worktree', 'list', '--porcelain', '-z'])
53
+ for (const field of (linked || '').split('\0')) {
54
+ if (field.startsWith('worktree ' + prefix)) {
55
+ names.push(input.from + field.slice('worktree '.length + prefix.length - 1))
56
+ }
57
+ }
58
+ }
59
+ const targets = [...new Set(names)].map((from) => {
60
+ const to = command === 'rename' ? input.to + from.slice(input.from.length) : from
61
+ return {
62
+ from,
63
+ to,
64
+ destinationExisted: exists(checkoutPath(to)),
65
+ before: checkout(from, manifest, identity(from, known))
66
+ }
67
+ })
68
+ return {
69
+ context: {
70
+ version: 1,
71
+ command,
72
+ phase: 'post',
73
+ operation: { id: randomUUID(), source, resumed: false },
74
+ workspace: { root: path.join(os.homedir(), 'workspace'), manifest: registryPath() },
75
+ input
76
+ },
77
+ targets
78
+ }
79
+ }
80
+
81
+ export function finishOperation(operation, manifest, error = null) {
82
+ const { context, targets } = operation
83
+ const checkouts = targets.map(({ from, to, destinationExisted, before }) => {
84
+ // On a failed rename, an occupied destination is not the checkout we moved.
85
+ const moved =
86
+ from !== to && !destinationExisted && !exists(checkoutPath(from)) && exists(checkoutPath(to))
87
+ const name = moved ? to : from
88
+ const after = checkout(name, manifest, before)
89
+ return { before, after }
90
+ })
91
+ const changed = checkouts.some(
92
+ ({ before, after }) => JSON.stringify(before) !== JSON.stringify(after)
93
+ )
94
+ return {
95
+ ...context,
96
+ result: {
97
+ status: error ? (changed ? 'partial' : 'error') : 'ok',
98
+ data: { changed, checkouts },
99
+ error: error ? { message: error.message, code: error.code || null } : null
100
+ }
101
+ }
102
+ }
103
+
104
+ export function validateHookProgress(state, actions) {
105
+ const validContext = (context, action) => {
106
+ const input =
107
+ action?.type === 'rename'
108
+ ? { from: action.from, to: action.to }
109
+ : { repository: action?.from || action?.to }
110
+ return (
111
+ action &&
112
+ context?.version === 1 &&
113
+ context.phase === 'post' &&
114
+ context.command === action.type &&
115
+ JSON.stringify(context.input) === JSON.stringify(input) &&
116
+ typeof context.operation?.id === 'string' &&
117
+ /^[a-z0-9-]+$/i.test(context.operation.id) &&
118
+ context.operation.source === 'pull' &&
119
+ typeof context.operation.resumed === 'boolean' &&
120
+ context.workspace?.root === path.join(os.homedir(), 'workspace') &&
121
+ context.workspace.manifest === registryPath()
122
+ )
123
+ }
124
+ const validCheckout = (entry) => {
125
+ if (!entry || typeof entry.name !== 'string') return false
126
+ return (
127
+ entry.path === checkoutPath(entry.name) &&
128
+ ['repository', 'worktree'].includes(entry.kind) &&
129
+ (entry.id === null || typeof entry.id === 'string') &&
130
+ typeof entry.exists === 'boolean' &&
131
+ typeof entry.registered === 'boolean' &&
132
+ typeof entry.repository?.name === 'string' &&
133
+ entry.repository.path === repositoryPath(entry.repository.name) &&
134
+ (entry.repository.id === null || typeof entry.repository.id === 'string')
135
+ )
136
+ }
137
+ const active = state.hookOperation
138
+ const post = state.postHook
139
+ let valid = true
140
+ try {
141
+ if (active) {
142
+ const action = actions[state.completed]
143
+ valid =
144
+ state.pending &&
145
+ validContext(active.context, action) &&
146
+ Array.isArray(active.targets) &&
147
+ active.targets.length > 0 &&
148
+ active.targets.every((target) => {
149
+ const from = action.from || action.to
150
+ const expected =
151
+ action.type === 'rename' ? action.to + target.from.slice(from.length) : target.from
152
+ return (
153
+ typeof target.from === 'string' &&
154
+ typeof target.to === 'string' &&
155
+ (target.from === from ||
156
+ (!from.includes('@') && target.from.startsWith(from + '@')) ||
157
+ (action.type === 'add' && target.from === from.split('@')[0])) &&
158
+ target.to === expected &&
159
+ typeof target.destinationExisted === 'boolean' &&
160
+ validCheckout(target.before) &&
161
+ target.before.name === target.from
162
+ )
163
+ })
164
+ }
165
+ if (post) {
166
+ valid =
167
+ valid &&
168
+ validContext(post, actions[state.pending ? state.completed : state.completed - 1]) &&
169
+ (state.pending
170
+ ? ['partial', 'error'].includes(post.result?.status)
171
+ : post.result?.status === 'ok') &&
172
+ (!active || active.context.operation.id === post.operation.id) &&
173
+ typeof post.result?.data?.changed === 'boolean' &&
174
+ Array.isArray(post.result.data.checkouts) &&
175
+ post.result.data.checkouts.length > 0 &&
176
+ post.result.data.checkouts.every(
177
+ ({ before, after }) => validCheckout(before) && validCheckout(after)
178
+ )
179
+ }
180
+ } catch {
181
+ valid = false
182
+ }
183
+ if (!valid) throw new Error('Invalid hook progress; inspect .git/workspace-pull.json')
184
+ }
185
+
186
+ function identity(name, manifest) {
187
+ const [base] = name.split('@')
188
+ const branch = name.includes('@') ? name.slice(base.length + 1) : null
189
+ const root = manifest.repositories.find((repo) => repo.name === base)
190
+ const entry = branch !== null ? root?.worktrees.find((entry) => entry.branch === branch) : root
191
+ return { id: entry?.id || null, repository: { id: root?.id || null } }
192
+ }
193
+
194
+ function checkout(name, manifest, known) {
195
+ const [base] = name.split('@')
196
+ const requested = name.includes('@') ? name.slice(base.length + 1) : null
197
+ const directory = checkoutPath(name)
198
+ const root = repositoryPath(base)
199
+ const entry = identity(name, manifest)
200
+ const present = exists(directory)
201
+ return {
202
+ id: entry.id || known.id,
203
+ kind: requested !== null ? 'worktree' : 'repository',
204
+ name,
205
+ path: directory,
206
+ repository: { id: entry.repository.id || known.repository.id, name: base, path: root },
207
+ exists: present,
208
+ registered: entry.id !== null,
209
+ branch: present ? git(directory, ['symbolic-ref', '--quiet', '--short', 'HEAD']) : null,
210
+ // Retain the requested local ref even when the worktree directory has disappeared.
211
+ ref: requested ? git(root, ['rev-parse', '--verify', `refs/heads/${requested}`]) : null,
212
+ origin: requested !== null ? null : git(root, ['config', '--get', 'remote.origin.url'])
213
+ }
214
+ }
215
+
216
+ function checkoutPath(name) {
217
+ // A root rename can also move unregistered worktrees made with ordinary Git.
218
+ // Their suffixes may use characters the CLI does not accept as input names.
219
+ const [base] = name.split('@')
220
+ const directory = repositoryPath(base) + name.slice(base.length)
221
+ if (path.normalize(directory) !== directory) throw new Error('Invalid checkout path')
222
+ return directory
223
+ }
224
+
225
+ function exists(directory) {
226
+ try {
227
+ fs.lstatSync(directory)
228
+ return true
229
+ } catch (err) {
230
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
231
+ return false
232
+ }
233
+ }
234
+
235
+ function git(directory, args) {
236
+ const result = spawnSync('git', ['--no-optional-locks', '-C', directory, ...args])
237
+ if (result.error) throw result.error
238
+ return result.status === 0 ? result.stdout.toString().replace(/\r?\n$/, '') : null
239
+ }
package/lib/hooks.js ADDED
@@ -0,0 +1,87 @@
1
+ import fs from 'bare-fs'
2
+ import path from 'bare-path'
3
+ import os from 'bare-os'
4
+ import env from 'bare-env'
5
+ import { spawnSync } from 'bare-subprocess'
6
+
7
+ export function discoverHooks() {
8
+ const found = new Map()
9
+ for (const directory of (env.PATH || '').split(path.delimiter)) {
10
+ let names
11
+ try {
12
+ names = fs.readdirSync(directory || '.')
13
+ } catch {
14
+ continue
15
+ }
16
+ for (const executable of names) {
17
+ const match = /^git-workspace-hook-(add|remove|rename)-post-([a-z0-9][a-z0-9_-]*)$/.exec(
18
+ executable
19
+ )
20
+ if (!match || found.has(executable)) continue
21
+ const file = path.resolve(directory || '.', executable)
22
+ try {
23
+ if (!fs.statSync(file).isFile()) continue
24
+ fs.accessSync(file, fs.constants.X_OK)
25
+ } catch {
26
+ continue
27
+ }
28
+ found.set(executable, { command: match[1], name: match[2], executable, path: file })
29
+ }
30
+ }
31
+ return [...found.values()].sort((a, b) => (a.executable < b.executable ? -1 : 1))
32
+ }
33
+
34
+ export default class Hooks {
35
+ constructor({ noHooks = false } = {}) {
36
+ this.entries = noHooks || env.GIT_WORKSPACE_HOOK ? [] : discoverHooks()
37
+ }
38
+
39
+ has(command) {
40
+ return this.entries.some((hook) => hook.command === command)
41
+ }
42
+
43
+ run(context) {
44
+ if (!context) return
45
+ for (const hook of this.entries.filter((hook) => hook.command === context.command)) {
46
+ let input
47
+ try {
48
+ input = openInput(context)
49
+ const result = spawnSync(hook.path, [context.command, 'post'], {
50
+ cwd: context.workspace.root,
51
+ env: { ...env, GIT_WORKSPACE_HOOK: '1' },
52
+ stdio: [input === 0 ? 'inherit' : input, 2, 2]
53
+ })
54
+ if (result.error) throw result.error
55
+ if (result.signal || result.status !== 0) {
56
+ throw new Error(
57
+ result.signal ? `signal ${result.signal}` : `exit status ${result.status}`
58
+ )
59
+ }
60
+ } catch (err) {
61
+ console.error(`warning: hook ${hook.executable} failed: ${err.message}`)
62
+ } finally {
63
+ if (input !== undefined) fs.closeSync(input)
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ function openInput(context) {
70
+ // bare-subprocess 6.x does not write/close spawnSync's input pipe. An unlinked
71
+ // read-only file descriptor provides the same stdin protocol, including EOF,
72
+ // without pipe limits or temporary files left behind if a hook kills the CLI.
73
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'git-workspace-hook-'))
74
+ const file = path.join(directory, 'input.json')
75
+ let input
76
+ try {
77
+ fs.writeFileSync(file, JSON.stringify(context) + '\n', { mode: 0o600 })
78
+ input = fs.openSync(file, 'r')
79
+ fs.unlinkSync(file)
80
+ fs.rmdirSync(directory)
81
+ return input
82
+ } catch (err) {
83
+ if (input !== undefined) fs.closeSync(input)
84
+ fs.rmSync(directory, { recursive: true, force: true })
85
+ throw err
86
+ }
87
+ }
package/lib/init.js CHANGED
@@ -2,10 +2,15 @@ import fs from 'bare-fs'
2
2
  import path from 'bare-path'
3
3
  import { spawnSync } from 'bare-subprocess'
4
4
  import repositoryPath from './repository-path.js'
5
- import { configPath, manifestDirectory, readRegistry } from './registry.js'
5
+ import { configPath, manifestDirectory, readManifest } from './registry.js'
6
+ import commitManifest from './commit-manifest.js'
7
+ import { emptyManifest } from './manifest.js'
8
+ import { refreshCheckpoint } from './checkpoint.js'
9
+ import { assertNoPendingPull } from './pull-state.js'
6
10
 
7
11
  export default function init(repository) {
8
12
  const directory = repositoryPath(repository)
13
+ assertNoPendingPull(directory)
9
14
  if (repository.includes('@')) throw new Error('Expected a manifest repository in owner/repo form')
10
15
 
11
16
  if (fs.existsSync(configPath()) && manifestDirectory() !== directory) {
@@ -60,10 +65,17 @@ export default function init(repository) {
60
65
  hasManifest = false
61
66
  }
62
67
  if (!hasManifest) {
63
- fs.writeFileSync(manifest, '[]\n')
64
- } else {
65
- readRegistry(manifest)
68
+ fs.writeFileSync(manifest, JSON.stringify(emptyManifest(), null, 2) + '\n')
69
+ try {
70
+ commitManifest(directory, 'Initialize workspace manifest')
71
+ } catch (err) {
72
+ err.message = `Created ${manifest}, but could not commit it: ${err.message}`
73
+ err.hint =
74
+ 'resolve the Git error, commit only workspace.json in the manifest repository, then rerun init'
75
+ throw err
76
+ }
66
77
  }
78
+ if (hasManifest) readManifest(manifest)
67
79
  fs.mkdirSync(path.dirname(configPath()), { recursive: true })
68
80
  const lock = configPath() + '.lock'
69
81
  fs.mkdirSync(lock)
@@ -75,6 +87,7 @@ export default function init(repository) {
75
87
  if (fs.existsSync(pending)) fs.unlinkSync(pending)
76
88
  fs.rmdirSync(lock)
77
89
  }
90
+ refreshCheckpoint(directory)
78
91
  }
79
92
 
80
93
  function git(args, options) {
@@ -0,0 +1,102 @@
1
+ import { randomUUID } from 'bare-crypto'
2
+ import repositoryPath from './repository-path.js'
3
+
4
+ export function emptyManifest() {
5
+ return { version: 1, repositories: [] }
6
+ }
7
+
8
+ export function manifestNames(manifest) {
9
+ validateManifest(manifest)
10
+ return manifest.repositories.flatMap(({ name, worktrees }) => [
11
+ name,
12
+ ...worktrees.map(({ branch }) => `${name}@${branch}`)
13
+ ])
14
+ }
15
+
16
+ export function validateManifest(manifest) {
17
+ if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.repositories)) {
18
+ throw new Error('Invalid workspace manifest: expected version 1 and a repositories array')
19
+ }
20
+ const ids = new Set()
21
+ const names = []
22
+ const checkId = (entry) => {
23
+ if (!entry || typeof entry.id !== 'string' || !/^[a-z0-9_-]+$/i.test(entry.id)) {
24
+ throw new Error('Invalid workspace manifest: every repository and worktree needs an ID')
25
+ }
26
+ if (ids.has(entry.id)) throw new Error(`Duplicate manifest ID: ${entry.id}`)
27
+ ids.add(entry.id)
28
+ }
29
+ for (const repository of manifest.repositories) {
30
+ checkId(repository)
31
+ if (
32
+ typeof repository.name !== 'string' ||
33
+ repository.name.includes('@') ||
34
+ !Array.isArray(repository.worktrees)
35
+ ) {
36
+ throw new Error('Invalid manifest repository: expected owner/repo and a worktrees array')
37
+ }
38
+ names.push(repository.name)
39
+ for (const worktree of repository.worktrees) {
40
+ checkId(worktree)
41
+ if (typeof worktree.branch !== 'string' || !worktree.branch) {
42
+ throw new Error('Invalid manifest worktree: expected a branch')
43
+ }
44
+ names.push(`${repository.name}@${worktree.branch}`)
45
+ }
46
+ }
47
+ validateNames(names)
48
+ }
49
+
50
+ function validateNames(names) {
51
+ const unique = new Set()
52
+ for (const name of names) {
53
+ if (typeof name !== 'string') {
54
+ throw new Error('Invalid manifest entry: expected a repository name')
55
+ }
56
+ repositoryPath(name)
57
+ if (unique.has(name)) throw new Error(`Duplicate manifest name: ${name}`)
58
+ unique.add(name)
59
+ }
60
+ for (const name of names) {
61
+ const [root, branch] = name.split('@')
62
+ if (branch && !unique.has(root)) {
63
+ throw new Error(`Manifest worktree ${name} needs a root entry for ${root}`)
64
+ }
65
+ }
66
+ }
67
+
68
+ export function updateManifest(previous, names, rename) {
69
+ validateManifest(previous)
70
+ validateNames(names)
71
+ const renamed = (name) => {
72
+ if (!rename) return name
73
+ if (name === rename.from) return rename.to
74
+ if (!rename.from.includes('@') && name.startsWith(rename.from + '@')) {
75
+ return rename.to + name.slice(rename.from.length)
76
+ }
77
+ return name
78
+ }
79
+ const repositories = new Map()
80
+ const worktrees = new Map()
81
+ for (const repository of previous.repositories) {
82
+ repositories.set(renamed(repository.name), repository)
83
+ for (const worktree of repository.worktrees) {
84
+ worktrees.set(renamed(`${repository.name}@${worktree.branch}`), worktree)
85
+ }
86
+ }
87
+ const next = emptyManifest()
88
+ const roots = new Map()
89
+ for (const name of names.filter((name) => !name.includes('@'))) {
90
+ const existing = repositories.get(name)
91
+ const entry = { id: existing?.id || randomUUID(), name, worktrees: [] }
92
+ next.repositories.push(entry)
93
+ roots.set(name, entry)
94
+ }
95
+ for (const name of names.filter((name) => name.includes('@'))) {
96
+ const [root, branch] = name.split('@')
97
+ const existing = worktrees.get(name)
98
+ roots.get(root).worktrees.push({ id: existing?.id || randomUUID(), branch })
99
+ }
100
+ validateManifest(next)
101
+ return next
102
+ }