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,14 @@
1
+ import os from 'bare-os'
2
+ import process from 'bare-process'
3
+
4
+ const home = Bare.argv.splice(2, 1)[0]
5
+ os.homedir = () => home
6
+ const { default: checkpoint, readCheckpoint } = await import('../../lib/checkpoint.js')
7
+ try {
8
+ console.log(
9
+ Bare.argv.includes('--show') ? readCheckpoint() || 'No applied checkpoint' : checkpoint()
10
+ )
11
+ } catch (err) {
12
+ console.error(err.message)
13
+ process.exitCode = 1
14
+ }
@@ -0,0 +1,28 @@
1
+ # Drive the same completion functions that Bash invokes on Tab.
2
+ completion_bin=$1
3
+ git_completion=$2
4
+ shift 2
5
+ export PATH="$completion_bin:$PATH"
6
+
7
+ if [ -n "$git_completion" ]; then
8
+ source "$git_completion" || exit 1
9
+ git_before=$(complete -p git)
10
+ fi
11
+ completion_script=$(command git-workspace completion bash) || exit 1
12
+ eval "$completion_script" || exit 1
13
+
14
+ COMP_WORDS=("$@")
15
+ COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))
16
+ COMP_LINE="${COMP_WORDS[*]}"
17
+ COMP_POINT=${#COMP_LINE}
18
+ COMP_TYPE=9
19
+ COMPREPLY=()
20
+ if [ "${COMP_WORDS[0]}" = git ]; then
21
+ [ "$(complete -p git)" = "$git_before" ] || exit 1
22
+ __git_func_wrap __git_main
23
+ else
24
+ _git_workspace_direct
25
+ fi
26
+ for candidate in "${COMPREPLY[@]}"; do
27
+ printf '%s\n' "$candidate"
28
+ done
package/test/helpers.js CHANGED
@@ -15,19 +15,58 @@ export default function setup(t, { initialize = true } = {}) {
15
15
  fs.mkdirSync(path.join(temporary, 'hooks'))
16
16
  fs.writeFileSync(config, '')
17
17
 
18
+ // A private PATH prevents tests from running hooks installed on the developer's machine.
19
+ const bin = path.join(temporary, 'bin')
20
+ fs.mkdirSync(bin)
21
+ const gitPath = spawnSync('which', ['git']).stdout.toString().trim()
22
+ fs.symlinkSync(gitPath, path.join(bin, 'git'))
23
+ fs.symlinkSync(os.execPath(), path.join(bin, 'bare'))
24
+ for (const name of [
25
+ 'bash',
26
+ 'sh',
27
+ 'env',
28
+ 'which',
29
+ 'cat',
30
+ 'dirname',
31
+ 'basename',
32
+ 'sed',
33
+ 'tr',
34
+ 'sort',
35
+ 'cut',
36
+ 'grep',
37
+ 'head',
38
+ 'mkdir',
39
+ 'rm',
40
+ 'uname',
41
+ 'pwd',
42
+ 'perl',
43
+ 'sleep',
44
+ 'touch',
45
+ 'wc',
46
+ 'awk',
47
+ 'ln'
48
+ ]) {
49
+ const executable = ['/bin', '/usr/bin']
50
+ .map((directory) => path.join(directory, name))
51
+ .find((file) => fs.existsSync(file))
52
+ if (executable) fs.symlinkSync(executable, path.join(bin, name))
53
+ }
54
+
18
55
  const env = { ...process.env }
19
56
  for (const key of Object.keys(env)) {
20
57
  if (key.startsWith('GIT_')) delete env[key]
21
58
  }
22
59
  Object.assign(env, {
60
+ PATH: bin,
61
+ GIT_WORKSPACE_HOOK: '',
23
62
  GIT_CONFIG_NOSYSTEM: '1',
24
63
  GIT_CONFIG_GLOBAL: config,
25
64
  GIT_ALLOW_PROTOCOL: 'file',
26
65
  GIT_TERMINAL_PROMPT: '0'
27
66
  })
28
67
 
29
- function run(file, args) {
30
- const result = spawnSync(file, args, { cwd: temporary, env })
68
+ function run(file, args, { env: overrides = {} } = {}) {
69
+ const result = spawnSync(file, args, { cwd: temporary, env: { ...env, ...overrides } })
31
70
  if (result.error) throw result.error
32
71
  return {
33
72
  status: result.status,
@@ -68,11 +107,30 @@ export default function setup(t, { initialize = true } = {}) {
68
107
  return result.stdout
69
108
  }
70
109
 
110
+ const checkpointRunner = fs.realpathSync(new URL('./fixtures/checkpoint.js', import.meta.url))
111
+ function checkAt(directory, ...args) {
112
+ return run(os.execPath(), [checkpointRunner, directory, ...args])
113
+ }
114
+ function check(...args) {
115
+ return checkAt(home, ...args)
116
+ }
117
+ function checked(...args) {
118
+ const result = check(...args)
119
+ if (result.status !== 0) throw new Error(result.stderr)
120
+ return result.stdout
121
+ }
122
+
71
123
  if (initialize) ok('init', 'owner/workspace')
72
124
 
73
125
  return {
126
+ bin,
127
+ env,
128
+ run,
74
129
  cli,
75
130
  cliAt,
131
+ check,
132
+ checkAt,
133
+ checked,
76
134
  ok,
77
135
  git,
78
136
  home,
package/test/hooks.js ADDED
@@ -0,0 +1,451 @@
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
+
7
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"
8
+ const runner = fs.realpathSync(new URL('./fixtures/cli.js', import.meta.url))
9
+ const logPath = (context) => path.join(context.home, 'hook.jsonl')
10
+ const logs = (context) =>
11
+ fs.existsSync(logPath(context))
12
+ ? fs.readFileSync(logPath(context), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
13
+ : []
14
+
15
+ function install(context, command, name = 'capture', body = '') {
16
+ const file = path.join(context.bin, `git-workspace-hook-${command}-post-${name}`)
17
+ fs.writeFileSync(file, `#!/bin/sh\ncat >> ${quote(logPath(context))}\n${body}\n`, { mode: 0o755 })
18
+ return file
19
+ }
20
+
21
+ test('hooks are discovered before init, using executable PATH entries and deterministic order', (t) => {
22
+ const context = setup(t, { initialize: false })
23
+ const { home, bin, env, run, ok } = context
24
+ const first = install(context, 'add', 'zebra')
25
+ const second = install(context, 'add', 'alpha')
26
+ const linked = path.join(bin, 'git-workspace-hook-remove-post-linked')
27
+ fs.symlinkSync(second, linked)
28
+ const ignored = install(context, 'rename', 'ignored')
29
+ fs.chmodSync(ignored, 0o644)
30
+ fs.mkdirSync(path.join(bin, 'git-workspace-hook-add-post-directory'))
31
+ install(context, 'fetch', 'future')
32
+ fs.symlinkSync(path.join(home, 'missing'), path.join(bin, 'git-workspace-hook-add-post-broken'))
33
+ const other = path.join(home, 'other bin')
34
+ fs.mkdirSync(other)
35
+ fs.writeFileSync(path.join(other, path.basename(first)), '#!/bin/sh\nexit 99\n', { mode: 0o755 })
36
+ const fallback = path.join(other, path.basename(ignored))
37
+ fs.writeFileSync(fallback, '#!/bin/sh\nexit 99\n', { mode: 0o755 })
38
+ const result = run(os.execPath(), [runner, home, 'hook', 'list'], {
39
+ env: { PATH: bin + ':' + other }
40
+ })
41
+ t.is(result.status, 0)
42
+ t.is(result.stderr, '')
43
+ t.is(
44
+ result.stdout,
45
+ `add/post\talpha\t${second}\nadd/post\tzebra\t${first}\nremove/post\tlinked\t${linked}\nrename/post\tignored\t${fallback}\n`
46
+ )
47
+ t.is(env.PATH, bin, 'the fixture has an isolated PATH')
48
+ t.is(logs(context).length, 0, 'listing never executes hooks')
49
+ t.ok(ok('hook', 'list', '-h').includes('List available post hooks'))
50
+ })
51
+
52
+ test('one add post hook includes implicit root, stable IDs, argv, cwd and committed state', (t) => {
53
+ const context = setup(t)
54
+ const { ok, git, root, registry, home } = context
55
+ const metadata = path.join(home, 'metadata')
56
+ const directory = path.dirname(registry)
57
+ install(
58
+ context,
59
+ 'add',
60
+ 'capture',
61
+ `printf '%s\\n' "$1" "$2" "$PWD" "$GIT_WORKSPACE_HOOK" > ${quote(metadata)}\ngit -C ${quote(directory)} rev-parse HEAD refs/git-workspace/applied >> ${quote(metadata)}\necho hook-output\necho hook-error >&2`
62
+ )
63
+ const result = context.cli('add', 'owner/repo@jobs')
64
+ t.is(result.status, 0, result.stderr)
65
+ t.ok(result.stderr.includes('hook-output\nhook-error\n'), 'both hook streams go to stderr')
66
+ t.absent(result.stdout.includes('hook-output'))
67
+ const [payload] = logs(context)
68
+ t.is(logs(context).length, 1, 'implicit root does not dispatch another add')
69
+ t.is(payload.version, 1)
70
+ t.is(payload.command, 'add')
71
+ t.is(payload.phase, 'post')
72
+ t.is(payload.operation.source, 'cli')
73
+ t.is(payload.operation.resumed, false)
74
+ t.ok(payload.operation.id)
75
+ t.alike(payload.workspace, { root: path.join(home, 'workspace'), manifest: registry })
76
+ t.alike(payload.input, { repository: 'owner/repo@jobs' })
77
+ t.is(payload.result.status, 'ok')
78
+ t.is(payload.result.error, null)
79
+ const entries = JSON.parse(fs.readFileSync(registry, 'utf8')).repositories
80
+ const [parent, child] = payload.result.data.checkouts
81
+ t.is(parent.before.exists, false)
82
+ t.is(parent.after.id, entries[0].id)
83
+ t.is(parent.after.path, root)
84
+ t.is(child.after.id, entries[0].worktrees[0].id)
85
+ t.is(child.after.repository.id, entries[0].id)
86
+ t.is(child.after.branch, 'jobs')
87
+ t.is(child.after.exists, true)
88
+ t.is(child.after.registered, true)
89
+ const head = git('-C', directory, 'rev-parse', 'HEAD')
90
+ t.is(
91
+ fs.readFileSync(metadata, 'utf8'),
92
+ `add\npost\n${path.join(home, 'workspace')}\n1\n${head}\n${head}\n`
93
+ )
94
+ ok('add', 'owner/repo@jobs')
95
+ const repeated = logs(context)[1]
96
+ t.is(repeated.result.data.changed, false, 'no-op adds still deliver hooks')
97
+ t.unlike(repeated.operation.id, payload.operation.id, 'each CLI invocation has a new ID')
98
+ })
99
+
100
+ test('rename and remove preserve old identities, including root-linked unregistered worktrees', (t) => {
101
+ const context = setup(t)
102
+ const { ok, git, root } = context
103
+ ok('add', 'owner/repo@jobs')
104
+ git('-C', root, 'worktree', 'add', '-b', 'extra', root + '@extra')
105
+ git('-C', root, 'worktree', 'add', '-b', 'extra+native', root + '@extra+native')
106
+ install(context, 'rename')
107
+ install(context, 'remove')
108
+ ok('rename', 'owner/repo@jobs', 'owner/repo@fixes')
109
+ ok('rename', 'owner/repo', 'other/new')
110
+ const [branch, parent] = logs(context)
111
+ const moved = branch.result.data.checkouts[0]
112
+ t.is(moved.before.branch, 'jobs')
113
+ t.is(moved.after.branch, 'fixes')
114
+ t.is(moved.before.id, moved.after.id)
115
+ t.is(moved.after.name, 'owner/repo@fixes')
116
+ const children = parent.result.data.checkouts
117
+ t.alike(
118
+ children.map(({ after }) => after.name),
119
+ ['other/new', 'other/new@fixes', 'other/new@extra', 'other/new@extra+native']
120
+ )
121
+ t.is(children[0].after.origin, 'git@github.com:other/new.git')
122
+ t.is(children[1].before.id, children[1].after.id)
123
+ t.is(children[2].after.id, null, 'unregistered worktrees have no fabricated ID')
124
+ t.is(children[2].after.registered, false)
125
+ t.is(children[3].after.branch, 'extra+native', 'ordinary Git worktree names are preserved')
126
+ t.is(children[3].after.id, null)
127
+ ok('remove', 'other/new@fixes')
128
+ const removed = logs(context)[2].result.data.checkouts[0]
129
+ t.is(removed.before.id, moved.before.id)
130
+ t.is(removed.after.id, removed.before.id)
131
+ t.is(removed.before.exists, true)
132
+ t.is(removed.after.exists, false)
133
+ t.is(removed.after.registered, false)
134
+ t.is(removed.after.ref, null)
135
+ t.is(removed.before.branch, 'fixes')
136
+ })
137
+
138
+ test('hook errors warn, continue in order and preserve command status; --no-hooks bypasses delivery', (t) => {
139
+ const context = setup(t)
140
+ const { cli, ok, bin, root } = context
141
+ ok('add', 'owner/repo')
142
+ install(context, 'add', 'alpha', 'echo alpha; exit 7')
143
+ const broken = path.join(bin, 'git-workspace-hook-add-post-middle')
144
+ fs.writeFileSync(broken, '#!/missing-hook-interpreter\n', { mode: 0o755 })
145
+ install(context, 'add', 'signal', 'kill -TERM "$$"')
146
+ install(context, 'add', 'zebra', 'echo zebra')
147
+ const result = cli('add', 'owner/repo')
148
+ t.is(result.status, 0)
149
+ t.is(result.stdout, '')
150
+ t.ok(result.stderr.includes('exit status 7'))
151
+ t.ok(result.stderr.includes('hook git-workspace-hook-add-post-middle failed'))
152
+ t.ok(result.stderr.includes('hook git-workspace-hook-add-post-signal failed: signal'))
153
+ t.ok(result.stderr.indexOf('alpha') < result.stderr.indexOf('zebra'))
154
+ t.is(logs(context).length, 3)
155
+ ok('add', '--no-hooks', 'owner/repo@skip')
156
+ install(context, 'remove')
157
+ install(context, 'rename')
158
+ ok('rename', '--no-hooks', 'owner/repo@skip', 'owner/repo@skipped')
159
+ ok('remove', '--no-hooks', 'owner/repo@skipped')
160
+ t.is(logs(context).length, 3)
161
+ ok('add', '-h')
162
+ ok('--complete', 'add', '')
163
+ t.is(cli('add', '../invalid').status, 1)
164
+ t.is(logs(context).length, 3, 'help, completion and invalid names do not dispatch')
165
+ fs.writeFileSync(path.join(root, 'dirty'), 'local')
166
+ const failed = cli('remove', 'owner/repo')
167
+ t.is(failed.status, 1)
168
+ t.is(logs(context).at(-1).result.status, 'error')
169
+ t.is(logs(context).at(-1).result.data.changed, false)
170
+ })
171
+
172
+ test('a hook invoking another workspace command does not recursively invoke hooks', (t) => {
173
+ const context = setup(t)
174
+ install(
175
+ context,
176
+ 'add',
177
+ 'nested',
178
+ `${quote(os.execPath())} ${quote(runner)} ${quote(context.home)} add owner/repo`
179
+ )
180
+ t.is(context.cli('add', 'owner/repo').status, 0)
181
+ t.is(logs(context).length, 1)
182
+ })
183
+
184
+ test('post hooks report implicit-root and manifest-commit partial failures', (t) => {
185
+ const context = setup(t)
186
+ const { cli, ok, registry, root } = context
187
+ install(context, 'add')
188
+ t.is(cli('add', 'owner/repo@main').status, 1, 'main is already checked out by the new root')
189
+ const failed = logs(context)[0]
190
+ t.is(failed.result.status, 'partial')
191
+ t.is(failed.result.data.checkouts[0].after.exists, true)
192
+ t.is(failed.result.data.checkouts[0].after.registered, true)
193
+ t.is(failed.result.data.checkouts[1].after.exists, false)
194
+ ok('add', '--no-hooks', 'owner/repo')
195
+ const lock = path.join(path.dirname(registry), '.git/index.lock')
196
+ fs.writeFileSync(lock, '')
197
+ t.is(cli('add', 'owner/repo@jobs').status, 1)
198
+ const commit = logs(context)[1]
199
+ t.is(commit.result.status, 'partial')
200
+ t.ok(commit.result.error.message.includes('could not commit the manifest'))
201
+ t.is(commit.result.data.checkouts[1].after.exists, true)
202
+ t.is(commit.result.data.checkouts[1].after.registered, true)
203
+ t.ok(fs.existsSync(root + '@jobs'))
204
+ })
205
+
206
+ test('partial remove and branch rename contexts describe actual remaining paths and refs', (t) => {
207
+ const context = setup(t)
208
+ const { cli, ok, root } = context
209
+ ok('add', 'owner/repo@jobs')
210
+ install(context, 'remove')
211
+ const lock = path.join(root, '.git/refs/heads/jobs.lock')
212
+ fs.writeFileSync(lock, '')
213
+ t.is(cli('remove', 'owner/repo@jobs').status, 1)
214
+ const removed = logs(context)[0]
215
+ t.is(removed.result.status, 'partial')
216
+ const after = removed.result.data.checkouts[0].after
217
+ t.is(after.exists, false)
218
+ t.is(after.registered, true)
219
+ t.ok(after.ref, 'branch still exists after worktree removal')
220
+ fs.unlinkSync(lock)
221
+ ok('remove', '--no-hooks', 'owner/repo@jobs')
222
+ ok('add', 'owner/repo@jobs')
223
+ install(context, 'rename')
224
+ fs.writeFileSync(path.join(root, '.git/config.lock'), '')
225
+ t.is(cli('rename', 'owner/repo@jobs', 'owner/repo@fixes').status, 1)
226
+ const renamed = logs(context)[1]
227
+ t.is(renamed.result.status, 'partial')
228
+ const moved = renamed.result.data.checkouts[0]
229
+ t.is(moved.before.name, 'owner/repo@jobs')
230
+ t.is(moved.after.name, 'owner/repo@fixes')
231
+ t.is(moved.after.branch, 'fixes')
232
+ t.is(moved.after.registered, false, 'manifest retains the old name on failure')
233
+ t.is(moved.before.id, moved.after.id)
234
+ })
235
+
236
+ function machines(t) {
237
+ const context = setup(t)
238
+ const { ok, git, registry, upstream, home } = context
239
+ ok('add', 'owner/repo@jobs')
240
+ const directory = path.dirname(registry)
241
+ git('clone', '--bare', directory, path.join(upstream, 'owner/workspace.git'))
242
+ const second = path.join(home, 'second')
243
+ fs.mkdirSync(second)
244
+ const cli = (...args) => context.cliAt(second, ...args)
245
+ for (const args of [
246
+ ['init', 'owner/workspace'],
247
+ ['add', 'owner/repo@jobs']
248
+ ]) {
249
+ const result = cli(...args)
250
+ if (result.status !== 0) throw new Error(result.stderr)
251
+ }
252
+ return {
253
+ ...context,
254
+ second,
255
+ cli,
256
+ local: path.join(second, 'workspace/owner/workspace'),
257
+ root: path.join(second, 'workspace/owner/repo'),
258
+ push: () => git('-C', directory, 'push', 'origin', 'main')
259
+ }
260
+ }
261
+
262
+ test('pull dispatches each logical operation with stable IDs and never hooks a dry run', (t) => {
263
+ const context = machines(t)
264
+ const { ok, cli, push, local } = context
265
+ ok('rename', 'owner/repo@jobs', 'owner/repo@fixes')
266
+ ok('add', 'owner/repo@new')
267
+ push()
268
+ for (const command of ['add', 'remove', 'rename']) install(context, command)
269
+ t.is(cli('pull', '--dry-run').status, 0)
270
+ t.is(logs(context).length, 0)
271
+ const result = cli('pull')
272
+ t.is(result.status, 0, result.stderr)
273
+ const [renamed, added] = logs(context)
274
+ t.is(logs(context).length, 2)
275
+ t.is(renamed.command, 'rename')
276
+ t.is(added.command, 'add')
277
+ for (const payload of logs(context)) {
278
+ t.is(payload.operation.source, 'pull')
279
+ t.is(payload.operation.resumed, false)
280
+ t.is(payload.result.status, 'ok')
281
+ t.is(payload.workspace.manifest, path.join(local, 'workspace.json'))
282
+ }
283
+ const entries = JSON.parse(fs.readFileSync(path.join(local, 'workspace.json'), 'utf8'))
284
+ .repositories[0]
285
+ t.is(
286
+ added.result.data.checkouts[1].after.id,
287
+ entries.worktrees.find((entry) => entry.branch === 'new').id
288
+ )
289
+ t.is(renamed.result.data.checkouts[0].before.id, renamed.result.data.checkouts[0].after.id)
290
+ ok('remove', '--no-hooks', 'owner/repo@new')
291
+ push()
292
+ t.is(cli('pull', '--no-hooks').status, 0)
293
+ t.is(logs(context).length, 2)
294
+ })
295
+
296
+ for (const skip of [false, true]) {
297
+ test(`pull preserves completed removal and pending hook across a crash${skip ? ' with --no-hooks recovery' : ''}`, (t) => {
298
+ const context = machines(t)
299
+ const { ok, cli, push, local, root, home, git } = context
300
+ ok('remove', 'owner/repo@jobs')
301
+ push()
302
+ const marker = path.join(home, 'delivered')
303
+ install(
304
+ context,
305
+ 'remove',
306
+ 'crash',
307
+ `if [ ! -e ${quote(marker)} ]; then touch ${quote(marker)}; kill -KILL "$PPID"; fi`
308
+ )
309
+ t.unlike(cli('pull').status, 0)
310
+ const progress = path.join(local, '.git/workspace-pull.json')
311
+ const state = JSON.parse(fs.readFileSync(progress, 'utf8'))
312
+ t.is(state.completed, 1)
313
+ t.is(state.pending, false)
314
+ t.ok(state.postHook)
315
+ t.absent(fs.existsSync(root + '@jobs'))
316
+ const bytes = fs.readFileSync(progress, 'utf8')
317
+ t.is(cli('pull', '--dry-run').status, 0)
318
+ t.is(fs.readFileSync(progress, 'utf8'), bytes, 'dry-run preserves pending delivery')
319
+ t.is(logs(context).length, 1)
320
+ const resumed = cli('pull', ...(skip ? ['--no-hooks'] : []))
321
+ t.is(resumed.status, 0, resumed.stderr)
322
+ t.is(logs(context).length, skip ? 1 : 2)
323
+ if (!skip) {
324
+ const [first, retry] = logs(context)
325
+ t.is(retry.operation.id, first.operation.id)
326
+ t.is(retry.operation.resumed, true)
327
+ t.alike(retry.result, first.result, 'recovery delivers the saved removal snapshot')
328
+ }
329
+ t.absent(fs.existsSync(progress))
330
+ t.is(
331
+ git('-C', local, 'rev-parse', 'refs/git-workspace/applied'),
332
+ git('-C', local, 'rev-parse', 'HEAD')
333
+ )
334
+ })
335
+ }
336
+
337
+ test('pull retains operation identity and original paths when recovering a partial rename', (t) => {
338
+ const context = machines(t)
339
+ const { ok, cli, push, root, local } = context
340
+ ok('rename', 'owner/repo@jobs', 'owner/repo@fixes')
341
+ push()
342
+ install(context, 'rename')
343
+ const lock = path.join(root, '.git/config.lock')
344
+ fs.writeFileSync(lock, '')
345
+ t.is(cli('pull').status, 1)
346
+ const failed = logs(context)[0]
347
+ t.is(failed.result.status, 'partial')
348
+ t.is(failed.result.data.checkouts[0].after.name, 'owner/repo@fixes')
349
+ fs.unlinkSync(lock)
350
+ const resumed = cli('pull')
351
+ t.is(resumed.status, 0, resumed.stderr)
352
+ const completed = logs(context)[1]
353
+ t.is(completed.operation.id, failed.operation.id)
354
+ t.is(completed.operation.resumed, true)
355
+ t.is(completed.result.status, 'ok')
356
+ t.is(completed.result.data.checkouts[0].before.name, 'owner/repo@jobs')
357
+ t.is(completed.result.data.checkouts[0].after.registered, true)
358
+ t.absent(fs.existsSync(path.join(local, '.git/workspace-pull.json')))
359
+ })
360
+
361
+ test('pull recovers an interrupted root move before post context was saved', (t) => {
362
+ const context = machines(t)
363
+ const { ok, cli, push, local, second, root, run, home, env } = context
364
+ ok('rename', 'owner/repo', 'other/new')
365
+ push()
366
+ install(context, 'rename')
367
+ const shim = path.join(home, 'git-shim')
368
+ fs.mkdirSync(shim)
369
+ const realGit = fs.realpathSync(path.join(context.bin, 'git'))
370
+ fs.writeFileSync(
371
+ path.join(shim, 'git'),
372
+ `#!/bin/sh\ncase "$*" in\n *'worktree repair'*) kill -KILL "$PPID"; exit 99;;\nesac\nexec ${quote(realGit)} "$@"\n`,
373
+ { mode: 0o755 }
374
+ )
375
+ const interrupted = run(os.execPath(), [runner, second, 'pull'], {
376
+ env: { PATH: shim + ':' + env.PATH }
377
+ })
378
+ t.unlike(interrupted.status, 0)
379
+ const progress = path.join(local, '.git/workspace-pull.json')
380
+ const state = JSON.parse(fs.readFileSync(progress, 'utf8'))
381
+ t.is(state.completed, 0)
382
+ t.is(state.pending, true)
383
+ t.absent(state.postHook)
384
+ t.is(logs(context).length, 0)
385
+ t.absent(fs.existsSync(root))
386
+ const resumed = cli('pull')
387
+ t.is(resumed.status, 0, resumed.stderr)
388
+ const [payload] = logs(context)
389
+ t.is(logs(context).length, 1)
390
+ t.is(payload.operation.id, state.hookOperation.context.operation.id)
391
+ t.is(payload.operation.resumed, true)
392
+ t.is(payload.result.status, 'ok')
393
+ t.alike(
394
+ payload.result.data.checkouts.map(({ before, after }) => [before.name, after.name]),
395
+ [
396
+ ['owner/repo', 'other/new'],
397
+ ['owner/repo@jobs', 'other/new@jobs']
398
+ ]
399
+ )
400
+ t.is(payload.result.data.checkouts[1].after.exists, true)
401
+ t.is(payload.result.data.checkouts[1].after.branch, 'jobs')
402
+ })
403
+
404
+ test('failed pull hooks count as delivered and do not block later operations or the checkpoint', (t) => {
405
+ const context = machines(t)
406
+ const { ok, cli, push, local, git } = context
407
+ ok('remove', 'owner/repo@jobs')
408
+ ok('add', 'owner/repo@new')
409
+ push()
410
+ install(context, 'remove', 'failure', 'exit 7')
411
+ install(context, 'add')
412
+ const result = cli('pull')
413
+ t.is(result.status, 0, result.stderr)
414
+ t.ok(result.stderr.includes('exit status 7'))
415
+ t.alike(
416
+ logs(context).map(({ command }) => command),
417
+ ['remove', 'add']
418
+ )
419
+ t.absent(fs.existsSync(path.join(local, '.git/workspace-pull.json')))
420
+ t.is(
421
+ git('-C', local, 'rev-parse', 'refs/git-workspace/applied'),
422
+ git('-C', local, 'rev-parse', 'HEAD')
423
+ )
424
+ })
425
+
426
+ test('invalid saved hook context is refused without delivering it or replaying checkouts', (t) => {
427
+ const context = machines(t)
428
+ const { ok, cli, push, local, root, home } = context
429
+ ok('remove', 'owner/repo@jobs')
430
+ push()
431
+ const marker = path.join(home, 'once')
432
+ install(
433
+ context,
434
+ 'remove',
435
+ 'crash',
436
+ `if [ ! -e ${quote(marker)} ]; then touch ${quote(marker)}; kill -KILL "$PPID"; fi`
437
+ )
438
+ t.unlike(cli('pull').status, 0)
439
+ const progress = path.join(local, '.git/workspace-pull.json')
440
+ const state = JSON.parse(fs.readFileSync(progress, 'utf8'))
441
+ const original = JSON.stringify(state)
442
+ state.postHook.input.repository = 'other/repo'
443
+ fs.writeFileSync(progress, JSON.stringify(state))
444
+ const refused = cli('pull')
445
+ t.is(refused.status, 1)
446
+ t.ok(refused.stderr.includes('Invalid hook progress'))
447
+ t.is(logs(context).length, 1)
448
+ t.absent(fs.existsSync(root + '@jobs'))
449
+ fs.writeFileSync(progress, original)
450
+ t.is(cli('pull').status, 0)
451
+ })