git-workspace-bare 0.1.1 → 0.3.0

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,332 @@
1
+ import fs from 'bare-fs'
2
+ import path from 'bare-path'
3
+ import { spawnSync } from 'bare-subprocess'
4
+ import { validateManifest } from './manifest.js'
5
+ import { verifyCheckouts } from './checkpoint.js'
6
+ import repositoryPath from './repository-path.js'
7
+
8
+ // Plan dependencies before consulting the filesystem: remove children first,
9
+ // rename roots before their branches, and add parents before children.
10
+ export function changes(previous, desired) {
11
+ validateManifest(previous)
12
+ validateManifest(desired)
13
+ const before = entries(previous)
14
+ const after = entries(desired)
15
+ const actions = []
16
+ const blockers = []
17
+ for (const entry of before.values()) {
18
+ const next = after.get(entry.id)
19
+ if (next && (next.kind !== entry.kind || next.root !== entry.root)) {
20
+ blockers.push(`Identity ${entry.id} changes entry type or parent repository`)
21
+ continue
22
+ }
23
+ if (!next) actions.push({ type: 'remove', ...entry, from: entry.name })
24
+ else if (entry.kind === 'repository' && entry.name !== next.name) {
25
+ actions.push({ type: 'rename', ...entry, from: entry.name, to: next.name })
26
+ } else if (entry.kind === 'worktree' && entry.branch !== next.branch) {
27
+ const root = after.get(entry.root)
28
+ actions.push({
29
+ type: 'rename',
30
+ ...entry,
31
+ source: entry.name,
32
+ from: `${root.name}@${entry.branch}`,
33
+ to: next.name
34
+ })
35
+ }
36
+ }
37
+ for (const entry of after.values()) {
38
+ if (!before.has(entry.id)) actions.push({ type: 'add', ...entry, to: entry.name })
39
+ }
40
+ const order = (action) => {
41
+ const child = action.kind === 'worktree'
42
+ if (action.type === 'remove') return child ? 0 : 1
43
+ if (action.type === 'rename') return child ? 3 : 2
44
+ return child ? 5 : 4
45
+ }
46
+ actions.sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name))
47
+ return { actions, blockers }
48
+ }
49
+
50
+ export default function planPull(previous, desired, manifestDirectory) {
51
+ const plan = changes(previous, desired)
52
+ if (plan.blockers.length) return plan
53
+ const before = entries(previous)
54
+ const removals = new Set(plan.actions.filter((a) => a.type === 'remove').map((a) => a.name))
55
+ const removedPaths = new Set([...removals].map(repositoryPath))
56
+ const roots = new Map()
57
+ const rootRenames = new Map(
58
+ plan.actions.filter((a) => a.type === 'rename' && a.kind === 'repository').map((a) => [a.id, a])
59
+ )
60
+ const attempt = (label, fn) => {
61
+ try {
62
+ fn()
63
+ } catch (err) {
64
+ plan.blockers.push(`${label}: ${err.message}`)
65
+ }
66
+ }
67
+ const verify = (repository, worktrees = [], retired = new Set()) =>
68
+ verifyCheckouts({ repositories: [{ ...repository, worktrees }] }, retired, rootRenames)
69
+
70
+ for (const repository of desired.repositories) {
71
+ for (const worktree of repository.worktrees) {
72
+ attempt(`${repository.name}@${worktree.branch}`, () => {
73
+ git(manifestDirectory, ['check-ref-format', '--branch', worktree.branch])
74
+ if (repository.worktrees.some((other) => worktree.branch.startsWith(other.branch + '/'))) {
75
+ throw new Error('Branch name conflicts with another desired branch prefix')
76
+ }
77
+ })
78
+ }
79
+ }
80
+
81
+ for (const repository of previous.repositories) {
82
+ const directory = repositoryPath(repository.name)
83
+ if (!exists(directory) && removals.has(repository.name)) {
84
+ roots.set(repository.id, { name: repository.name, directory, listed: [] })
85
+ continue
86
+ }
87
+ attempt(repository.name, () => {
88
+ verify(repository)
89
+ const listed = worktrees(directory)
90
+ roots.set(repository.id, { name: repository.name, directory, listed })
91
+ })
92
+ for (const worktree of repository.worktrees) {
93
+ const name = `${repository.name}@${worktree.branch}`
94
+ if (!exists(repositoryPath(name)) && removals.has(name)) continue
95
+ attempt(name, () => verify(repository, [worktree]))
96
+ }
97
+ }
98
+
99
+ // Existing untracked destinations may be adopted only on initial/add setup.
100
+ // A reused path with an old ID is removed and recreated, never silently adopted.
101
+ for (const repository of desired.repositories) {
102
+ if (before.has(repository.id)) continue
103
+ const directory = repositoryPath(repository.name)
104
+ if (!exists(directory) || removals.has(repository.name)) continue
105
+ attempt(repository.name, () => {
106
+ verify(repository)
107
+ roots.set(repository.id, { name: repository.name, directory, listed: worktrees(directory) })
108
+ })
109
+ }
110
+
111
+ for (const action of plan.actions) {
112
+ attempt(`${action.type} ${action.from || action.to}`, () => {
113
+ const root = roots.get(action.kind === 'repository' ? action.id : action.root)
114
+ const physicalSource = action.source || action.from
115
+ if (action.type === 'remove') {
116
+ const directory = repositoryPath(action.from)
117
+ if (exists(directory)) {
118
+ protectedManifest(directory, manifestDirectory)
119
+ if (fs.lstatSync(directory).isSymbolicLink()) {
120
+ throw new Error('Checkout is a symbolic link')
121
+ }
122
+ if (
123
+ git(directory, [
124
+ 'status',
125
+ '--porcelain',
126
+ '--untracked-files=all',
127
+ '--ignore-submodules=none'
128
+ ])
129
+ ) {
130
+ throw new Error('Local changes or untracked files would be removed')
131
+ }
132
+ }
133
+ if (root) {
134
+ if (action.kind === 'repository') {
135
+ for (const entry of root.listed.slice(1)) {
136
+ const name = [...before.values()].find(
137
+ (item) => repositoryPath(item.name) === entry.directory
138
+ )?.name
139
+ if (!name || !removals.has(name)) {
140
+ throw new Error(`Linked worktree would remain: ${entry.directory}`)
141
+ }
142
+ }
143
+ } else {
144
+ const entry = root.listed.find((entry) => entry.directory === directory)
145
+ if (entry?.locked) throw new Error('Worktree is locked')
146
+ if (entry && !exists(directory)) {
147
+ throw new Error('Missing worktree still has a Git record; repair or prune it first')
148
+ }
149
+ }
150
+ }
151
+ return
152
+ }
153
+
154
+ if (action.type === 'rename') {
155
+ const source = repositoryPath(physicalSource)
156
+ if (!exists(source)) throw new Error(`Missing checkout: ${physicalSource}`)
157
+ protectedManifest(source, manifestDirectory)
158
+ if (action.kind === 'repository' && root) {
159
+ for (const entry of root.listed.slice(1)) {
160
+ if (removedPaths.has(entry.directory)) continue
161
+ if (!exists(entry.directory)) {
162
+ throw new Error(`Missing linked worktree: ${entry.directory}`)
163
+ }
164
+ if (
165
+ fs.realpathSync(
166
+ git(entry.directory, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
167
+ ) !== path.join(fs.realpathSync(root.directory), '.git')
168
+ ) {
169
+ throw new Error(`Linked worktree needs repair: ${entry.directory}`)
170
+ }
171
+ if (entry.directory.startsWith(root.directory + '@')) {
172
+ if (entry.locked) throw new Error(`Worktree is locked: ${entry.directory}`)
173
+ if (fs.lstatSync(entry.directory).isSymbolicLink()) {
174
+ throw new Error(`Worktree is a symbolic link: ${entry.directory}`)
175
+ }
176
+ vacant(repositoryPath(action.to) + entry.directory.slice(root.directory.length))
177
+ }
178
+ if (
179
+ git(entry.directory, ['ls-files', '--stage', '-z'])
180
+ .split('\0')
181
+ .some((item) => item.startsWith('160000 '))
182
+ ) {
183
+ throw new Error(`Linked worktree contains submodules: ${entry.directory}`)
184
+ }
185
+ }
186
+ } else if (root) {
187
+ const entry = root.listed.find((entry) => entry.directory === source)
188
+ if (entry?.locked) throw new Error('Worktree is locked')
189
+ const branch = action.to.split('@')[1]
190
+ branchTarget(root, branch, action.branch)
191
+ outsideWorktrees(repositoryPath(`${root.name}@${branch}`))
192
+ }
193
+ vacant(repositoryPath(action.to))
194
+ return
195
+ }
196
+
197
+ const destination = repositoryPath(action.to)
198
+ // A root rename changes the base path before a branch can be added.
199
+ const physical =
200
+ action.kind === 'worktree' && rootRenames.has(action.root)
201
+ ? repositoryPath(`${rootRenames.get(action.root).from}@${action.branch}`)
202
+ : destination
203
+ checkParents(destination)
204
+ if (exists(destination) && destination !== physical) vacant(destination)
205
+ if (exists(physical) && !removedPaths.has(physical)) {
206
+ const repository = desired.repositories.find(
207
+ (repo) => repo.id === (action.root || action.id)
208
+ )
209
+ const localRepository = root ? { ...repository, name: root.name } : repository
210
+ verify(localRepository, action.kind === 'worktree' ? [{ branch: action.branch }] : [])
211
+ action.present = true
212
+ } else if (action.kind === 'worktree' && root) {
213
+ branchTarget(root, action.branch)
214
+ const checkedOut = root.listed.find((entry) => entry.branch === action.branch)
215
+ if (checkedOut) {
216
+ const name = `${root.name}@${action.branch}`
217
+ if (!removals.has(name) || checkedOut.directory !== repositoryPath(name)) {
218
+ throw new Error(
219
+ `Branch ${action.branch} is already checked out at ${checkedOut.directory}`
220
+ )
221
+ }
222
+ }
223
+ }
224
+ outsideWorktrees(physical)
225
+ })
226
+ }
227
+ return { actions: plan.actions, blockers: [...new Set(plan.blockers)] }
228
+
229
+ function vacant(directory) {
230
+ if (exists(directory)) {
231
+ if (!removedPaths.has(directory)) throw new Error(`Destination already exists: ${directory}`)
232
+ }
233
+ checkParents(directory)
234
+ }
235
+
236
+ function checkParents(directory) {
237
+ // Catch files and dangling symlinks in parent components without creating them.
238
+ let parent = path.dirname(directory)
239
+ while (!exists(parent)) parent = path.dirname(parent)
240
+ if (!fs.statSync(parent).isDirectory()) {
241
+ throw new Error(`Destination parent is not a directory: ${parent}`)
242
+ }
243
+ }
244
+
245
+ function outsideWorktrees(directory) {
246
+ // An absent nested destination can still sit inside another worktree.
247
+ for (const entry of roots.values()) {
248
+ for (const worktree of entry.listed.slice(1)) {
249
+ if (
250
+ directory.startsWith(worktree.directory + path.sep) &&
251
+ !removedPaths.has(worktree.directory)
252
+ ) {
253
+ throw new Error(`Destination is inside another worktree: ${worktree.directory}`)
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ function branchTarget(root, target, source = null) {
260
+ const refs = git(root.directory, [
261
+ 'for-each-ref',
262
+ '--format=%(refname:strip=2)',
263
+ 'refs/heads/'
264
+ ]).split('\n')
265
+ for (const branch of refs) {
266
+ if (!branch || branch === source || removals.has(`${root.name}@${branch}`)) continue
267
+ if (source && branch === target) throw new Error(`Branch ${target} already exists`)
268
+ if (target.startsWith(branch + '/') || branch.startsWith(target + '/')) {
269
+ throw new Error(`Branch ${target} conflicts with existing branch ${branch}`)
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ function entries(manifest) {
276
+ return new Map(
277
+ manifest.repositories.flatMap((repository) => [
278
+ [repository.id, { id: repository.id, kind: 'repository', root: null, name: repository.name }],
279
+ ...repository.worktrees.map((worktree) => [
280
+ worktree.id,
281
+ {
282
+ id: worktree.id,
283
+ kind: 'worktree',
284
+ root: repository.id,
285
+ branch: worktree.branch,
286
+ name: `${repository.name}@${worktree.branch}`
287
+ }
288
+ ])
289
+ ])
290
+ )
291
+ }
292
+
293
+ function protectedManifest(directory, manifestDirectory) {
294
+ if (fs.realpathSync(directory) === fs.realpathSync(manifestDirectory)) {
295
+ throw new Error('Cannot remove or rename the active manifest repository')
296
+ }
297
+ }
298
+
299
+ function worktrees(directory) {
300
+ return git(directory, ['worktree', 'list', '--porcelain', '-z'])
301
+ .split('\0\0')
302
+ .filter(Boolean)
303
+ .map((record) => {
304
+ const fields = record.split('\0')
305
+ return {
306
+ directory: fields[0].slice('worktree '.length),
307
+ branch: fields
308
+ .find((field) => field.startsWith('branch refs/heads/'))
309
+ ?.slice('branch refs/heads/'.length),
310
+ locked: fields.some((field) => field === 'locked' || field.startsWith('locked '))
311
+ }
312
+ })
313
+ }
314
+
315
+ function exists(directory) {
316
+ try {
317
+ fs.lstatSync(directory)
318
+ return true
319
+ } catch (err) {
320
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
321
+ return false
322
+ }
323
+ }
324
+
325
+ function git(directory, args) {
326
+ const result = spawnSync('git', ['--no-optional-locks', '-C', directory, ...args])
327
+ if (result.error) throw result.error
328
+ if (result.status !== 0) {
329
+ throw new Error(result.stderr.toString().trim() || 'Git inspection failed')
330
+ }
331
+ return result.stdout.toString().replace(/\r?\n$/, '')
332
+ }
@@ -0,0 +1,113 @@
1
+ import fs from 'bare-fs'
2
+ import path from 'bare-path'
3
+ import os from 'bare-os'
4
+ import { manifestDirectory } from './registry.js'
5
+
6
+ export const targetRef = 'refs/git-workspace/pull-target'
7
+
8
+ export function lockPull(directory) {
9
+ const lock = path.join(directory, '.git/workspace.lock')
10
+ const owner = path.join(lock, 'pull-owner')
11
+ try {
12
+ fs.mkdirSync(lock)
13
+ } catch (err) {
14
+ if (err.code !== 'EEXIST') throw err
15
+ // Only reclaim a dead pull's lock; ordinary command locks are left alone.
16
+ if (!fs.lstatSync(lock).isDirectory() || fs.lstatSync(lock).isSymbolicLink()) throw err
17
+ let pid
18
+ try {
19
+ if (!fs.lstatSync(owner).isFile()) throw err
20
+ pid = Number(fs.readFileSync(owner, 'utf8'))
21
+ } catch {
22
+ throw err
23
+ }
24
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw err
25
+ try {
26
+ os.kill(pid, 0)
27
+ throw err
28
+ } catch (dead) {
29
+ if (dead.code !== 'ESRCH') throw dead
30
+ }
31
+ // Serialize stale-lock reclaimers and recheck ownership: another retry may
32
+ // have replaced the lock since we first read its PID.
33
+ const claiming = path.join(lock, 'pull-recovery')
34
+ fs.mkdirSync(claiming)
35
+ const retired = lock + `.retired-${os.pid()}`
36
+ try {
37
+ if (Number(fs.readFileSync(owner, 'utf8')) !== pid) throw err
38
+ try {
39
+ os.kill(pid, 0)
40
+ throw err
41
+ } catch (dead) {
42
+ if (dead.code !== 'ESRCH') throw dead
43
+ }
44
+ fs.renameSync(lock, retired)
45
+ } catch (failed) {
46
+ fs.rmdirSync(claiming)
47
+ throw failed
48
+ }
49
+ fs.rmSync(retired, { recursive: true })
50
+ fs.mkdirSync(lock)
51
+ }
52
+ try {
53
+ fs.writeFileSync(owner, String(os.pid()), { flag: 'wx' })
54
+ } catch (err) {
55
+ fs.rmdirSync(lock)
56
+ throw err
57
+ }
58
+ return () => {
59
+ fs.unlinkSync(owner)
60
+ fs.rmdirSync(lock)
61
+ }
62
+ }
63
+
64
+ export function statePath(directory) {
65
+ return path.join(directory, '.git/workspace-pull.json')
66
+ }
67
+
68
+ export function assertNoPendingPull(directory = manifestDirectory()) {
69
+ try {
70
+ fs.lstatSync(statePath(directory))
71
+ } catch (err) {
72
+ if (err.code === 'ENOENT') return
73
+ throw err
74
+ }
75
+ throw new Error(
76
+ 'An interrupted workspace pull is pending; rerun git workspace pull before making workspace changes'
77
+ )
78
+ }
79
+
80
+ export function readPullState(directory) {
81
+ const file = statePath(directory)
82
+ try {
83
+ if (!fs.lstatSync(file).isFile()) throw new Error('Pull progress must be a regular file')
84
+ } catch (err) {
85
+ if (err.code === 'ENOENT') return null
86
+ throw err
87
+ }
88
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'))
89
+ const hash = (value) => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)
90
+ if (
91
+ !state ||
92
+ state.version !== 1 ||
93
+ !hash(state.head) ||
94
+ !hash(state.target) ||
95
+ (state.applied !== null && !hash(state.applied)) ||
96
+ !Number.isSafeInteger(state.completed) ||
97
+ state.completed < 0 ||
98
+ typeof state.pending !== 'boolean' ||
99
+ typeof state.localBranch !== 'string' ||
100
+ typeof state.remote !== 'string' ||
101
+ typeof state.merge !== 'string'
102
+ ) {
103
+ throw new Error('Invalid local pull progress; inspect .git/workspace-pull.json')
104
+ }
105
+ return state
106
+ }
107
+
108
+ export function writePullState(directory, state) {
109
+ const file = statePath(directory)
110
+ const temporary = file + '.tmp'
111
+ fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
112
+ fs.renameSync(temporary, file)
113
+ }