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.
package/test/pull.js ADDED
@@ -0,0 +1,696 @@
1
+ import test from 'brittle'
2
+ import fs from 'bare-fs'
3
+ import path from 'bare-path'
4
+ import os from 'bare-os'
5
+ import setup from './helpers.js'
6
+ import { changes } from '../lib/plan-pull.js'
7
+
8
+ function machines(t, initial = ['owner/repo@jobs']) {
9
+ const context = setup(t)
10
+ const { ok, git, registry, home, upstream, cliAt } = context
11
+ for (const name of initial) ok('add', name)
12
+ const directory = path.dirname(registry)
13
+ git('clone', '--bare', directory, path.join(upstream, 'owner/workspace.git'))
14
+ const second = path.join(home, 'second')
15
+ fs.mkdirSync(second)
16
+ const cli = (...args) => cliAt(second, ...args)
17
+ const success = (...args) => {
18
+ const result = cli(...args)
19
+ if (result.status !== 0) throw new Error(result.stderr)
20
+ return result.stdout
21
+ }
22
+ success('init', 'owner/workspace')
23
+ for (const name of initial) success('add', name)
24
+ const local = path.join(second, 'workspace/owner/workspace')
25
+ const root = path.join(second, 'workspace/owner/repo')
26
+ const push = () => git('-C', directory, 'push', 'origin', 'main')
27
+ const head = () => git('-C', local, 'rev-parse', 'HEAD')
28
+ const applied = () => git('-C', local, 'rev-parse', 'refs/git-workspace/applied')
29
+ return { ...context, second, directory, cli, success, local, root, push, head, applied }
30
+ }
31
+
32
+ const repo = (id, name, worktrees = []) => ({ id, name, worktrees })
33
+ const manifest = (...repositories) => ({ version: 1, repositories })
34
+
35
+ function progress(directory) {
36
+ return JSON.parse(fs.readFileSync(path.join(directory, '.git/workspace-pull.json'), 'utf8'))
37
+ }
38
+
39
+ function interruptedPull(context, pattern, after = false) {
40
+ const { run, second } = context
41
+ const shim = path.join(second, 'git-shim')
42
+ fs.mkdirSync(shim)
43
+ const quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'"
44
+ const realGit = quote(run('which', ['git']).stdout.trim())
45
+ const interrupt = after
46
+ ? `${realGit} "$@"\nresult=$?\nif [ "$result" = 0 ]; then kill -KILL "$PPID"; fi\nexit "$result"`
47
+ : 'kill -KILL "$PPID"\nexit 99'
48
+ fs.writeFileSync(
49
+ path.join(shim, 'git'),
50
+ `#!/bin/sh\ncase "$*" in\n *${quote(pattern)}*)\n${interrupt}\n;;\nesac\nexec ${realGit} "$@"\n`,
51
+ { mode: 0o755 }
52
+ )
53
+ const runner = fs.realpathSync(new URL('./fixtures/cli.js', import.meta.url))
54
+ return run(os.execPath(), [runner, second, 'pull'], {
55
+ env: { PATH: shim + ':' + context.env.PATH }
56
+ })
57
+ }
58
+
59
+ test('pull plans IDs in dependency order, including root and branch renames', (t) => {
60
+ const previous = manifest(
61
+ repo('a', 'old/repo', [
62
+ { id: 'keep', branch: 'jobs' },
63
+ { id: 'remove', branch: 'retired' }
64
+ ]),
65
+ repo('b', 'owner/gone', [{ id: 'child', branch: 'child' }])
66
+ )
67
+ const desired = manifest(
68
+ repo('a', 'new/repo', [
69
+ { id: 'keep', branch: 'fixes' },
70
+ { id: 'new', branch: 'new' }
71
+ ]),
72
+ repo('c', 'owner/added', [{ id: 'added-child', branch: 'new' }])
73
+ )
74
+ const plan = changes(previous, desired)
75
+ t.alike(plan.blockers, [])
76
+ t.alike(
77
+ plan.actions.map(({ type, from, to }) => [type, from, to]),
78
+ [
79
+ ['remove', 'old/repo@retired', undefined],
80
+ ['remove', 'owner/gone@child', undefined],
81
+ ['remove', 'owner/gone', undefined],
82
+ ['rename', 'old/repo', 'new/repo'],
83
+ ['rename', 'new/repo@jobs', 'new/repo@fixes'],
84
+ ['add', undefined, 'owner/added'],
85
+ ['add', undefined, 'new/repo@new'],
86
+ ['add', undefined, 'owner/added@new']
87
+ ]
88
+ )
89
+ t.alike(changes(previous, previous).actions, [], 'unchanged IDs and names have no operations')
90
+ const rootOnly = changes(
91
+ previous,
92
+ manifest(repo('a', 'new/repo', previous.repositories[0].worktrees), previous.repositories[1])
93
+ )
94
+ t.is(rootOnly.actions.length, 1, 'root rename includes unchanged worktree names')
95
+ })
96
+
97
+ test('reused names are replacements and worktree identities cannot change parents or entry types', (t) => {
98
+ const before = manifest(repo('a', 'owner/repo', [{ id: 'w', branch: 'jobs' }]))
99
+ const replacement = changes(
100
+ before,
101
+ manifest(repo('b', 'owner/repo', [{ id: 'new-w', branch: 'jobs' }]))
102
+ )
103
+ t.alike(
104
+ replacement.actions.map(({ type }) => type),
105
+ ['remove', 'remove', 'add', 'add']
106
+ )
107
+ const moved = changes(
108
+ before,
109
+ manifest(repo('a', 'owner/repo'), repo('b', 'owner/new', [{ id: 'w', branch: 'jobs' }]))
110
+ )
111
+ t.ok(moved.blockers[0].includes('parent repository'))
112
+ const changedType = changes(before, manifest(repo('w', 'owner/new')))
113
+ t.ok(changedType.blockers[0].includes('entry type'))
114
+ })
115
+
116
+ test('two machines preview a root and branch rename without changing either checkout or applied state', (t) => {
117
+ const { ok, git, registry, root, local, second, cli, push, head, applied } = machines(t)
118
+ fs.writeFileSync(path.join(root, 'tracked.txt'), 'dirty root\n')
119
+ fs.writeFileSync(path.join(root + '@jobs', 'tracked.txt'), 'dirty worktree\n')
120
+ git('-C', root, 'add', 'tracked.txt')
121
+ const rootIndex = fs.readFileSync(path.join(root, '.git/index'))
122
+ const manifestIndex = fs.readFileSync(path.join(local, '.git/index'))
123
+ const before = head()
124
+ const checkpoint = applied()
125
+ const contents = fs.readFileSync(path.join(local, 'workspace.json'), 'utf8')
126
+ ok('rename', 'owner/repo', 'other/new')
127
+ ok('rename', 'other/new@jobs', 'other/new@fixes')
128
+ ok('add', 'other/new@feature/new')
129
+ push()
130
+ const result = cli('pull', '--dry-run')
131
+ t.is(result.status, 0, result.stderr)
132
+ t.ok(result.stdout.includes('Would fast-forward the manifest'))
133
+ t.ok(result.stdout.includes('rename owner/repo -> other/new'))
134
+ t.ok(result.stdout.includes('rename other/new@jobs -> other/new@fixes'))
135
+ t.ok(result.stdout.includes('add other/new@feature/new'))
136
+ t.absent(
137
+ result.stdout.includes('rename owner/repo@jobs'),
138
+ 'no redundant root-induced branch rename'
139
+ )
140
+ t.is(head(), before)
141
+ t.is(applied(), checkpoint)
142
+ t.is(fs.readFileSync(path.join(local, 'workspace.json'), 'utf8'), contents)
143
+ t.alike(fs.readFileSync(path.join(root, '.git/index')), rootIndex)
144
+ t.alike(fs.readFileSync(path.join(local, '.git/index')), manifestIndex)
145
+ t.is(fs.readFileSync(path.join(root, 'tracked.txt'), 'utf8'), 'dirty root\n')
146
+ t.is(fs.readFileSync(path.join(root + '@jobs', 'tracked.txt'), 'utf8'), 'dirty worktree\n')
147
+ t.absent(fs.existsSync(path.join(second, 'workspace/other')), 'no destination parents created')
148
+ t.is(git('-C', root, 'config', '--get', 'remote.origin.url'), 'git@github.com:owner/repo.git')
149
+ t.absent(
150
+ fs.existsSync(path.join(root, '.git/FETCH_HEAD')),
151
+ 'managed repositories are not fetched'
152
+ )
153
+ t.is(
154
+ git('-C', local, 'rev-parse', 'origin/main'),
155
+ git('-C', path.dirname(registry), 'rev-parse', 'HEAD'),
156
+ 'fetch updates remote metadata'
157
+ )
158
+ t.is(cli('pull', '--dry-run').stdout, result.stdout, 'repeated preview is stable')
159
+ })
160
+
161
+ test('clean removals are planned child first, while dirty removals report blockers and keep all files', (t) => {
162
+ const { ok, root, cli, push, head, applied } = machines(t)
163
+ ok('remove', 'owner/repo@jobs')
164
+ ok('remove', 'owner/repo')
165
+ push()
166
+ const before = head()
167
+ const checkpoint = applied()
168
+ const clean = cli('pull', '--dry-run')
169
+ t.is(clean.status, 0, clean.stderr)
170
+ t.ok(clean.stdout.indexOf('remove owner/repo@jobs') < clean.stdout.indexOf('remove owner/repo\n'))
171
+ fs.writeFileSync(path.join(root, 'untracked.txt'), 'keep root\n')
172
+ fs.writeFileSync(path.join(root + '@jobs', 'tracked.txt'), 'keep worktree\n')
173
+ const dirty = cli('pull', '--dry-run')
174
+ t.is(dirty.status, 1)
175
+ t.ok(dirty.stdout.includes('blocked: remove owner/repo@jobs: Local changes'))
176
+ t.ok(dirty.stdout.includes('blocked: remove owner/repo: Local changes'))
177
+ t.is(head(), before)
178
+ t.is(applied(), checkpoint)
179
+ t.is(fs.readFileSync(path.join(root, 'untracked.txt'), 'utf8'), 'keep root\n')
180
+ t.is(fs.readFileSync(path.join(root + '@jobs', 'tracked.txt'), 'utf8'), 'keep worktree\n')
181
+ })
182
+
183
+ test('root rename reports occupied destinations, locked worktrees and invalid parent paths', (t) => {
184
+ const { ok, root, second, cli, push, git } = machines(t)
185
+ ok('rename', 'owner/repo', 'other/new')
186
+ push()
187
+ const owner = path.join(second, 'workspace/other')
188
+ fs.mkdirSync(owner)
189
+ const destination = path.join(owner, 'new')
190
+ fs.symlinkSync(path.join(second, 'absent'), destination)
191
+ t.ok(cli('pull', '--dry-run').stdout.includes('Destination already exists'))
192
+ fs.unlinkSync(destination)
193
+ fs.rmdirSync(owner)
194
+ fs.writeFileSync(owner, 'blocking parent')
195
+ t.ok(cli('pull', '--dry-run').stdout.includes('Destination parent is not a directory'))
196
+ fs.unlinkSync(owner)
197
+ git('-C', root, 'worktree', 'lock', root + '@jobs')
198
+ t.ok(cli('pull', '--dry-run').stdout.includes('Worktree is locked'))
199
+ git('-C', root, 'worktree', 'unlock', root + '@jobs')
200
+ git('-C', root, 'remote', 'set-url', 'origin', 'git@github.com:other/new.git')
201
+ t.is(cli('pull', '--dry-run').status, 0, 'origin may already be renamed manually')
202
+ })
203
+
204
+ test('missing and mismatched checkouts block preview even when no manifest changes are needed', (t) => {
205
+ const { cli, root, git } = machines(t)
206
+ t.is(cli('pull', '--dry-run').status, 0)
207
+ fs.renameSync(root + '@jobs', root + '@missing')
208
+ const missing = cli('pull', '--dry-run')
209
+ t.is(missing.status, 1)
210
+ t.ok(missing.stdout.includes('Missing checkout: owner/repo@jobs'))
211
+ fs.renameSync(root + '@missing', root + '@jobs')
212
+ git('-C', root + '@jobs', 'switch', '-c', 'different')
213
+ t.ok(cli('pull', '--dry-run').stdout.includes('not on branch jobs'))
214
+ git('-C', root + '@jobs', 'switch', 'jobs')
215
+ git('-C', root, 'remote', 'set-url', 'origin', 'git@github.com:wrong/repo.git')
216
+ t.ok(cli('pull', '--dry-run').stdout.includes('unexpected origin'))
217
+ })
218
+
219
+ test('existing branch targets and outside linked worktrees block unsafe operations', (t) => {
220
+ const { ok, root, second, cli, push, git } = machines(t)
221
+ ok('rename', 'owner/repo@jobs', 'owner/repo@fixes')
222
+ push()
223
+ git('-C', root, 'branch', 'fixes')
224
+ t.ok(cli('pull', '--dry-run').stdout.includes('Branch fixes already exists'))
225
+ git('-C', root, 'branch', '-D', 'fixes')
226
+ ok('remove', 'owner/repo@fixes')
227
+ ok('remove', 'owner/repo')
228
+ push()
229
+ const outside = path.join(second, 'outside')
230
+ git('-C', root, 'worktree', 'add', '-b', 'outside', outside)
231
+ t.ok(cli('pull', '--dry-run').stdout.includes('Linked worktree would remain'))
232
+ })
233
+
234
+ test('manifest working edits, staged edits and detached HEAD are rejected before fetch', (t) => {
235
+ const { cli, local, git, head, applied } = machines(t)
236
+ const before = head()
237
+ const checkpoint = applied()
238
+ const file = path.join(local, 'workspace.json')
239
+ const contents = fs.readFileSync(file, 'utf8')
240
+ fs.writeFileSync(file, contents + '\n')
241
+ t.ok(cli('pull', '--dry-run').stderr.includes('uncommitted changes'))
242
+ git('-C', local, 'add', 'workspace.json')
243
+ fs.writeFileSync(file, contents)
244
+ t.ok(cli('pull', '--dry-run').stderr.includes('uncommitted changes'))
245
+ git('-C', local, 'restore', '--staged', 'workspace.json')
246
+ git('-C', local, 'checkout', '--detach')
247
+ t.ok(cli('pull', '--dry-run').stderr.includes('detached HEAD'))
248
+ t.absent(fs.existsSync(path.join(local, '.git/FETCH_HEAD')))
249
+ t.is(head(), before)
250
+ t.is(applied(), checkpoint)
251
+ })
252
+
253
+ test('pull uses local ahead state and refuses divergent histories without changing HEAD', (t) => {
254
+ const { cli, local, directory, git, push, head, applied } = machines(t)
255
+ git('-C', local, 'commit', '--allow-empty', '-m', 'Local work')
256
+ const before = head()
257
+ const checkpoint = applied()
258
+ const ahead = cli('pull', '--dry-run')
259
+ t.is(ahead.status, 0, ahead.stderr)
260
+ t.ok(ahead.stdout.includes('ahead of the remote'))
261
+ t.ok(ahead.stdout.includes('No layout changes planned'))
262
+ git('-C', directory, 'commit', '--allow-empty', '-m', 'Other machine work')
263
+ push()
264
+ const divergent = cli('pull', '--dry-run')
265
+ t.is(divergent.status, 1)
266
+ t.ok(divergent.stderr.includes('histories have diverged'))
267
+ t.is(head(), before)
268
+ t.is(applied(), checkpoint)
269
+ })
270
+
271
+ test('first machine setup plans additions and adopts matching local roots without recording a baseline', (t) => {
272
+ const context = setup(t)
273
+ const { ok, git, home, upstream, registry, cliAt, checkAt } = context
274
+ ok('add', 'owner/repo@jobs')
275
+ git('clone', '--bare', path.dirname(registry), path.join(upstream, 'owner/workspace.git'))
276
+ const second = path.join(home, 'fresh')
277
+ fs.mkdirSync(second)
278
+ t.is(cliAt(second, 'init', 'owner/workspace').status, 0)
279
+ const initial = cliAt(second, 'pull', '--dry-run')
280
+ t.is(initial.status, 0, initial.stderr)
281
+ t.ok(initial.stdout.includes('add owner/repo\nadd owner/repo@jobs'))
282
+ const root = path.join(second, 'workspace/owner/repo')
283
+ git('clone', path.join(upstream, 'owner/repo.git'), root)
284
+ git('-C', root, 'remote', 'set-url', 'origin', 'git@github.com:owner/repo.git')
285
+ const partial = cliAt(second, 'pull', '--dry-run')
286
+ t.is(partial.status, 0, partial.stderr)
287
+ t.ok(partial.stdout.includes('adopt owner/repo\nadd owner/repo@jobs'))
288
+ t.is(checkAt(second, '--show').stdout, 'No applied checkpoint\n')
289
+ t.absent(fs.existsSync(root + '@jobs'))
290
+ })
291
+
292
+ test('configured upstream is fetched and unsupported remote manifests never replace the local one', (t) => {
293
+ const { cli, local, directory, git, head, applied } = machines(t)
294
+ git('-C', directory, 'push', 'origin', 'main:team')
295
+ git('-C', local, 'config', 'branch.main.merge', 'refs/heads/team')
296
+ t.ok(cli('pull', '--dry-run').stdout.includes('Manifest: origin/team'))
297
+ const before = head()
298
+ const checkpoint = applied()
299
+ fs.writeFileSync(path.join(directory, 'workspace.json'), '{"version":2,"repositories":[]}\n')
300
+ git('-C', directory, 'add', 'workspace.json')
301
+ git('-C', directory, 'commit', '-m', 'Unsupported manifest')
302
+ git('-C', directory, 'push', 'origin', 'main:team')
303
+ const invalid = cli('pull', '--dry-run')
304
+ t.is(invalid.status, 1)
305
+ t.ok(invalid.stderr.includes('Invalid workspace manifest'))
306
+ t.is(head(), before)
307
+ t.is(applied(), checkpoint)
308
+ })
309
+
310
+ test('pull requires a reachable manifest remote and always releases its lock', (t) => {
311
+ const { cli, ok, registry, git } = setup(t)
312
+ const directory = path.dirname(registry)
313
+ t.is(cli('pull').status, 1)
314
+ const missing = cli('pull', '--dry-run')
315
+ t.is(missing.status, 1)
316
+ t.absent(fs.existsSync(path.join(directory, '.git/workspace.lock')))
317
+ git('clone', '--bare', directory, path.join(path.dirname(directory), 'remote.git'))
318
+ git(
319
+ '-C',
320
+ directory,
321
+ 'remote',
322
+ 'set-url',
323
+ 'origin',
324
+ path.join(path.dirname(directory), 'remote.git')
325
+ )
326
+ t.ok(
327
+ ok('pull', '--dry-run').includes('Manifest is up to date'),
328
+ 'origin/current-branch fallback works'
329
+ )
330
+ })
331
+
332
+ test('replacing a worktree name during a root rename is an addition after removal, never adoption', (t) => {
333
+ const { ok, cli, push } = machines(t)
334
+ ok('remove', 'owner/repo@jobs')
335
+ ok('rename', 'owner/repo', 'other/new')
336
+ ok('add', 'other/new@jobs')
337
+ push()
338
+ const result = cli('pull', '--dry-run')
339
+ t.is(result.status, 0, result.stderr)
340
+ t.ok(result.stdout.includes('remove owner/repo@jobs'))
341
+ t.ok(result.stdout.includes('rename owner/repo -> other/new'))
342
+ t.ok(result.stdout.includes('add other/new@jobs'))
343
+ t.absent(result.stdout.includes('adopt other/new@jobs'))
344
+ })
345
+
346
+ test('stale worktree records and branches checked out elsewhere are reported without repair', (t) => {
347
+ const { ok, root, cli, push, directory, git } = machines(t)
348
+ ok('remove', 'owner/repo@jobs')
349
+ const file = path.join(directory, 'workspace.json')
350
+ const desired = JSON.parse(fs.readFileSync(file, 'utf8'))
351
+ desired.repositories[0].worktrees.push({ id: 'main-test', branch: 'main' })
352
+ fs.writeFileSync(file, JSON.stringify(desired) + '\n')
353
+ git('-C', directory, 'add', 'workspace.json')
354
+ git('-C', directory, 'commit', '-m', 'Request main worktree')
355
+ push()
356
+ fs.renameSync(root + '@jobs', root + '@saved')
357
+ const result = cli('pull', '--dry-run')
358
+ t.is(result.status, 1)
359
+ t.ok(result.stdout.includes('Missing worktree still has a Git record'))
360
+ t.ok(result.stdout.includes('Branch main is already checked out'))
361
+ t.ok(fs.existsSync(root + '@saved'))
362
+ })
363
+
364
+ test('nested destinations, branch namespaces and invalid Git branch names are blocked', (t) => {
365
+ const { root, cli, push, directory, git } = machines(t)
366
+ const file = path.join(directory, 'workspace.json')
367
+ const desired = JSON.parse(fs.readFileSync(file, 'utf8'))
368
+ const update = (branch) => {
369
+ desired.repositories[0].worktrees[0].branch = branch
370
+ fs.writeFileSync(file, JSON.stringify(desired) + '\n')
371
+ git('-C', directory, 'add', 'workspace.json')
372
+ git('-C', directory, 'commit', '-m', `Request ${branch}`)
373
+ push()
374
+ return cli('pull', '--dry-run')
375
+ }
376
+ t.ok(update('jobs/new').stdout.includes('Destination is inside another worktree'))
377
+ git('-C', root, 'branch', 'feature')
378
+ t.ok(update('feature/new').stdout.includes('conflicts with existing branch feature'))
379
+ const invalid = update('invalid.lock')
380
+ t.is(invalid.status, 1)
381
+ t.ok(invalid.stdout.includes('not a valid branch name'))
382
+ t.ok(fs.existsSync(root + '@jobs'))
383
+ })
384
+
385
+ test('a remote manifest cannot remove the active manifest checkout', (t) => {
386
+ const { directory, local, cli, push, git, head } = machines(t, [
387
+ 'owner/repo@jobs',
388
+ 'owner/workspace'
389
+ ])
390
+ const before = head()
391
+ const file = path.join(directory, 'workspace.json')
392
+ const desired = JSON.parse(fs.readFileSync(file, 'utf8'))
393
+ desired.repositories = desired.repositories.filter(({ name }) => name !== 'owner/workspace')
394
+ fs.writeFileSync(file, JSON.stringify(desired) + '\n')
395
+ git('-C', directory, 'add', 'workspace.json')
396
+ git('-C', directory, 'commit', '-m', 'Remove manifest checkout entry')
397
+ push()
398
+ const result = cli('pull', '--dry-run')
399
+ t.is(result.status, 1)
400
+ t.ok(result.stdout.includes('Cannot remove or rename the active manifest repository'))
401
+ t.ok(fs.existsSync(local))
402
+ t.is(head(), before)
403
+ })
404
+
405
+ test('pull applies root and branch renames and additions, preserving dirty work and manifest history', (t) => {
406
+ const { ok, git, root, local, second, cli, push, head, applied, directory } = machines(t)
407
+ fs.writeFileSync(path.join(root, 'tracked.txt'), 'staged root\n')
408
+ git('-C', root, 'add', 'tracked.txt')
409
+ fs.writeFileSync(path.join(root + '@jobs', 'tracked.txt'), 'dirty worktree\n')
410
+ const index = fs.readFileSync(path.join(root, '.git/index'))
411
+ const worktreeHead = git('-C', root + '@jobs', 'rev-parse', 'HEAD')
412
+ ok('rename', 'owner/repo', 'other/new')
413
+ ok('rename', 'other/new@jobs', 'other/new@fixes')
414
+ ok('add', 'other/new@feature/new')
415
+ push()
416
+ const target = git('-C', directory, 'rev-parse', 'HEAD')
417
+ const result = cli('pull')
418
+ t.is(result.status, 0, result.stderr)
419
+ t.ok(result.stdout.includes('Fast-forwarded the manifest'))
420
+ t.is(head(), target, 'fast-forward creates no extra manifest commit')
421
+ t.is(applied(), target)
422
+ const renamed = path.join(second, 'workspace/other/new')
423
+ t.absent(fs.existsSync(root))
424
+ t.absent(fs.existsSync(root + '@jobs'))
425
+ t.alike(fs.readFileSync(path.join(renamed, '.git/index')), index)
426
+ t.is(fs.readFileSync(path.join(renamed + '@fixes', 'tracked.txt'), 'utf8'), 'dirty worktree\n')
427
+ t.is(git('-C', renamed + '@fixes', 'rev-parse', 'HEAD'), worktreeHead)
428
+ t.is(git('-C', renamed + '@fixes', 'symbolic-ref', '--short', 'HEAD'), 'fixes')
429
+ t.is(git('-C', renamed, 'config', 'remote.origin.url'), 'git@github.com:other/new.git')
430
+ t.ok(fs.existsSync(renamed + '@feature/new'))
431
+ t.absent(
432
+ fs.existsSync(path.join(renamed, '.git/FETCH_HEAD')),
433
+ 'existing repositories are not fetched'
434
+ )
435
+ t.is(git('-C', local, 'status', '--porcelain'), '')
436
+ t.absent(fs.existsSync(path.join(local, '.git/workspace-pull.json')))
437
+ t.absent(
438
+ git('-C', local, 'for-each-ref', '--format=%(refname)', 'refs/git-workspace/pull-target')
439
+ )
440
+ const repeat = cli('pull')
441
+ t.is(repeat.status, 0, repeat.stderr)
442
+ t.ok(repeat.stdout.includes('No layout changes planned'))
443
+ t.is(head(), target)
444
+ })
445
+
446
+ test('pull refuses blockers before fast-forwarding or saving progress, then performs clean removals', (t) => {
447
+ const { ok, root, local, cli, push, head, applied, git } = machines(t)
448
+ ok('remove', 'owner/repo@jobs')
449
+ ok('remove', 'owner/repo')
450
+ push()
451
+ const before = head()
452
+ fs.writeFileSync(path.join(root + '@jobs', 'tracked.txt'), 'keep\n')
453
+ t.is(cli('pull').status, 1)
454
+ t.is(head(), before)
455
+ t.is(applied(), before)
456
+ t.absent(fs.existsSync(path.join(local, '.git/workspace-pull.json')))
457
+ git('-C', root + '@jobs', 'restore', 'tracked.txt')
458
+ const result = cli('pull')
459
+ t.is(result.status, 0, result.stderr)
460
+ t.absent(fs.existsSync(root + '@jobs'))
461
+ t.absent(fs.existsSync(root))
462
+ t.is(applied(), head())
463
+ })
464
+
465
+ test('first pull adopts a matching root, creates its worktrees and retains the remote IDs', (t) => {
466
+ const { ok, git, home, upstream, registry, cliAt, checkAt } = setup(t)
467
+ ok('add', 'owner/repo@jobs')
468
+ git('clone', '--bare', path.dirname(registry), path.join(upstream, 'owner/workspace.git'))
469
+ const second = path.join(home, 'fresh')
470
+ fs.mkdirSync(second)
471
+ t.is(cliAt(second, 'init', 'owner/workspace').status, 0)
472
+ const root = path.join(second, 'workspace/owner/repo')
473
+ git('clone', path.join(upstream, 'owner/repo.git'), root)
474
+ git('-C', root, 'remote', 'set-url', 'origin', 'git@github.com:owner/repo.git')
475
+ fs.writeFileSync(path.join(root, 'untracked'), 'keep\n')
476
+ const result = cliAt(second, 'pull')
477
+ t.is(result.status, 0, result.stderr)
478
+ t.ok(result.stdout.includes('adopt owner/repo'))
479
+ t.ok(fs.existsSync(root + '@jobs'))
480
+ t.is(fs.readFileSync(path.join(root, 'untracked'), 'utf8'), 'keep\n')
481
+ const local = path.join(second, 'workspace/owner/workspace')
482
+ t.is(
483
+ fs.readFileSync(path.join(local, 'workspace.json'), 'utf8'),
484
+ fs.readFileSync(registry, 'utf8')
485
+ )
486
+ t.is(checkAt(second, '--show').stdout.trim(), git('-C', local, 'rev-parse', 'HEAD'))
487
+ })
488
+
489
+ test('pull replaces reused root and worktree names and verifies the new identities', (t) => {
490
+ const { ok, cli, root, push, head, applied, git, directory } = machines(t)
491
+ const previous = git('-C', root + '@jobs', 'rev-parse', 'HEAD')
492
+ git('-C', root + '@jobs', 'commit', '--allow-empty', '-m', 'Local old identity')
493
+ ok('remove', 'owner/repo@jobs')
494
+ ok('remove', 'owner/repo')
495
+ ok('add', 'owner/repo@jobs')
496
+ push()
497
+ const result = cli('pull')
498
+ t.is(result.status, 0, result.stderr)
499
+ t.is(
500
+ git('-C', root + '@jobs', 'rev-parse', 'HEAD'),
501
+ previous,
502
+ 'replacement is created from the requested repository'
503
+ )
504
+ t.is(applied(), head())
505
+ t.is(head(), git('-C', directory, 'rev-parse', 'HEAD'))
506
+ t.is(cli('pull').status, 0)
507
+ })
508
+
509
+ test('a failed clone resumes the saved target and blocks ordinary manifest mutations meanwhile', (t) => {
510
+ const { ok, cli, local, root, upstream, directory, git, push, head, applied } = machines(t)
511
+ const before = applied()
512
+ ok('rename', 'owner/repo@jobs', 'owner/repo@fixes')
513
+ const file = path.join(directory, 'workspace.json')
514
+ const desired = JSON.parse(fs.readFileSync(file, 'utf8'))
515
+ desired.repositories.push(
516
+ repo('missing-root', 'owner/missing', [{ id: 'missing-child', branch: 'new' }])
517
+ )
518
+ fs.writeFileSync(file, JSON.stringify(desired) + '\n')
519
+ git('-C', directory, 'add', 'workspace.json')
520
+ git('-C', directory, 'commit', '-m', 'Add unavailable repository')
521
+ push()
522
+ const target = git('-C', directory, 'rev-parse', 'HEAD')
523
+ const failed = cli('pull')
524
+ t.is(failed.status, 1)
525
+ t.ok(failed.stderr.includes('rerun git workspace pull'))
526
+ t.is(head(), target)
527
+ t.is(applied(), before)
528
+ t.is(progress(local).completed, 1)
529
+ t.is(progress(local).pending, true)
530
+ t.absent(
531
+ fs.existsSync(path.join(path.dirname(root), 'missing/.git')),
532
+ 'pull never initializes an empty replacement for an unavailable remote'
533
+ )
534
+ t.ok(fs.existsSync(root + '@fixes'))
535
+ for (const args of [
536
+ ['add', 'owner/repo'],
537
+ ['remove', 'owner/repo@fixes'],
538
+ ['rename', 'owner/repo', 'other/new'],
539
+ ['init', 'owner/workspace']
540
+ ]) {
541
+ t.ok(cli(...args).stderr.includes('interrupted workspace pull is pending'))
542
+ }
543
+ t.is(cli('list').status, 0)
544
+ t.is(cli('pull', '--dry-run').status, 0)
545
+ t.is(progress(local).completed, 1, 'preview does not advance progress')
546
+ git('clone', '--bare', root, path.join(upstream, 'owner/missing.git'))
547
+ git('-C', directory, 'commit', '--allow-empty', '-m', 'Later remote commit')
548
+ push()
549
+ const resumed = cli('pull')
550
+ t.is(resumed.status, 0, resumed.stderr)
551
+ t.ok(resumed.stdout.includes('Resuming saved pull'))
552
+ t.is(head(), target, 'retry completes the pinned target before fetching newer changes')
553
+ t.is(applied(), target)
554
+ t.is(cli('pull').status, 0)
555
+ t.is(head(), git('-C', directory, 'rev-parse', 'HEAD'))
556
+ })
557
+
558
+ test('pull resumes a branch rename that moved the worktree before a Git config failure', (t) => {
559
+ const { ok, root, local, cli, push, git, run, applied, head } = machines(t)
560
+ const before = applied()
561
+ ok('rename', 'owner/repo@jobs', 'owner/repo@fixes')
562
+ push()
563
+ git('-C', root, 'config', 'branch.jobs.remote', 'origin')
564
+ git('-C', root, 'config', 'branch.jobs.merge', 'refs/heads/jobs')
565
+ const lock = path.join(root, '.git/config.lock')
566
+ fs.writeFileSync(lock, '')
567
+ t.is(cli('pull').status, 1)
568
+ t.absent(fs.existsSync(root + '@jobs'))
569
+ t.ok(fs.existsSync(root + '@fixes'))
570
+ t.is(progress(local).pending, true)
571
+ t.is(applied(), before)
572
+ const preview = cli('pull', '--dry-run')
573
+ t.is(preview.status, 1)
574
+ t.ok(preview.stdout.includes('Interrupted rename needs repair'))
575
+ fs.unlinkSync(lock)
576
+ const result = cli('pull')
577
+ t.is(result.status, 0, result.stderr)
578
+ t.is(git('-C', root + '@fixes', 'symbolic-ref', '--short', 'HEAD'), 'fixes')
579
+ t.is(git('-C', root, 'config', 'branch.fixes.merge'), 'refs/heads/fixes')
580
+ t.is(run('git', ['-C', root, 'config', '--get-regexp', '^branch\\.jobs\\.']).status, 1)
581
+ t.is(applied(), head())
582
+ })
583
+
584
+ test('pull retries branch deletion after the worktree has already been removed', (t) => {
585
+ const { ok, root, local, cli, push, applied, head } = machines(t)
586
+ const before = applied()
587
+ ok('remove', 'owner/repo@jobs')
588
+ push()
589
+ const lock = path.join(root, '.git/refs/heads/jobs.lock')
590
+ fs.writeFileSync(lock, '')
591
+ t.is(cli('pull').status, 1)
592
+ t.absent(fs.existsSync(root + '@jobs'))
593
+ t.is(progress(local).completed, 0)
594
+ t.is(applied(), before)
595
+ fs.unlinkSync(lock)
596
+ const result = cli('pull')
597
+ t.is(result.status, 0, result.stderr)
598
+ t.is(applied(), head())
599
+ })
600
+
601
+ test('pull repairs a root rename after the process dies between directory moves and Git repair', (t) => {
602
+ const context = machines(t)
603
+ const { ok, root, second, local, cli, push, applied, head, git } = context
604
+ const before = applied()
605
+ fs.writeFileSync(path.join(root + '@jobs', 'tracked.txt'), 'keep dirty\n')
606
+ ok('rename', 'owner/repo', 'other/new')
607
+ ok('rename', 'other/new@jobs', 'other/new@fixes')
608
+ push()
609
+ interruptedPull(context, 'worktree repair')
610
+ const renamed = path.join(second, 'workspace/other/new')
611
+ t.absent(fs.existsSync(root))
612
+ t.ok(fs.existsSync(renamed + '@jobs'))
613
+ t.is(progress(local).pending, true)
614
+ t.is(applied(), before)
615
+ const result = cli('pull')
616
+ t.is(result.status, 0, result.stderr)
617
+ t.is(git('-C', renamed + '@fixes', 'symbolic-ref', '--short', 'HEAD'), 'fixes')
618
+ t.is(fs.readFileSync(path.join(renamed + '@fixes', 'tracked.txt'), 'utf8'), 'keep dirty\n')
619
+ t.is(git('-C', renamed, 'config', 'remote.origin.url'), 'git@github.com:other/new.git')
620
+ t.is(applied(), head())
621
+ })
622
+
623
+ test('pull never repeats removal when a replacement was created before the process died', (t) => {
624
+ const context = machines(t)
625
+ const { ok, root, second, local, cli, push, applied, head } = context
626
+ const before = applied()
627
+ ok('remove', 'owner/repo@jobs')
628
+ ok('rename', 'owner/repo', 'other/new')
629
+ ok('add', 'other/new@jobs')
630
+ push()
631
+ interruptedPull(context, 'worktree add', true)
632
+ const renamed = path.join(second, 'workspace/other/new')
633
+ t.is(progress(local).completed, 2)
634
+ t.is(progress(local).pending, true)
635
+ t.is(applied(), before)
636
+ t.absent(fs.existsSync(root))
637
+ fs.writeFileSync(path.join(renamed + '@jobs', 'untracked'), 'keep replacement\n')
638
+ const result = cli('pull')
639
+ t.is(result.status, 0, result.stderr)
640
+ t.is(fs.readFileSync(path.join(renamed + '@jobs', 'untracked'), 'utf8'), 'keep replacement\n')
641
+ t.is(applied(), head())
642
+ })
643
+
644
+ test('pull retries final checkpoint failure without replaying completed changes', (t) => {
645
+ const { ok, root, local, cli, push, applied, head } = machines(t)
646
+ const before = applied()
647
+ ok('remove', 'owner/repo@jobs')
648
+ ok('add', 'owner/repo@jobs')
649
+ push()
650
+ const lock = path.join(local, '.git/refs/git-workspace/applied.lock')
651
+ fs.writeFileSync(lock, '')
652
+ const failed = cli('pull')
653
+ t.is(failed.status, 1)
654
+ t.ok(failed.stderr.includes('applied.lock'))
655
+ t.is(progress(local).completed, 2)
656
+ t.is(progress(local).pending, false)
657
+ t.is(applied(), before)
658
+ fs.writeFileSync(path.join(root + '@jobs', 'untracked'), 'keep replacement\n')
659
+ fs.unlinkSync(lock)
660
+ const result = cli('pull')
661
+ t.is(result.status, 0, result.stderr)
662
+ t.is(fs.readFileSync(path.join(root + '@jobs', 'untracked'), 'utf8'), 'keep replacement\n')
663
+ t.is(applied(), head())
664
+ })
665
+
666
+ test('a crash after advancing the checkpoint only needs verification and progress cleanup', (t) => {
667
+ const context = machines(t)
668
+ const { ok, root, local, cli, push, applied, head } = context
669
+ ok('remove', 'owner/repo@jobs')
670
+ ok('add', 'owner/repo@jobs')
671
+ push()
672
+ interruptedPull(context, 'update-ref --no-deref refs/git-workspace/applied', true)
673
+ t.is(applied(), head(), 'checkpoint already advanced')
674
+ t.is(progress(local).completed, 2)
675
+ fs.writeFileSync(path.join(root + '@jobs', 'untracked'), 'keep replacement\n')
676
+ const result = cli('pull')
677
+ t.is(result.status, 0, result.stderr)
678
+ t.is(fs.readFileSync(path.join(root + '@jobs', 'untracked'), 'utf8'), 'keep replacement\n')
679
+ t.absent(fs.existsSync(path.join(local, '.git/workspace-pull.json')))
680
+ })
681
+
682
+ test('pull leaves live, unowned and symbolic locks intact', (t) => {
683
+ const { cli, local } = machines(t)
684
+ const lock = path.join(local, '.git/workspace.lock')
685
+ fs.mkdirSync(lock)
686
+ t.is(cli('pull').status, 1, 'ordinary command lock is not reclaimed')
687
+ const owner = path.join(lock, 'pull-owner')
688
+ fs.writeFileSync(owner, String(os.pid()))
689
+ t.is(cli('pull').status, 1, 'live pull lock is not reclaimed')
690
+ t.is(fs.readFileSync(owner, 'utf8'), String(os.pid()))
691
+ fs.unlinkSync(owner)
692
+ fs.rmdirSync(lock)
693
+ fs.symlinkSync(path.join(local, '.git'), lock)
694
+ t.is(cli('pull').status, 1)
695
+ t.ok(fs.lstatSync(lock).isSymbolicLink())
696
+ })