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/README.md +238 -16
- package/bin/git-workspace.js +63 -9
- package/completions/git-workspace.bash +26 -0
- package/docs/hooks.md +214 -0
- package/lib/add.js +55 -7
- package/lib/apply-pull.js +140 -0
- package/lib/checkpoint.js +208 -0
- package/lib/clone-repository.js +51 -0
- package/lib/commit-manifest.js +29 -0
- package/lib/completion.js +93 -0
- package/lib/git-network-environment.js +31 -0
- package/lib/hook-context.js +239 -0
- package/lib/hooks.js +87 -0
- package/lib/init.js +17 -4
- package/lib/manifest.js +102 -0
- package/lib/plan-pull.js +332 -0
- package/lib/pull-state.js +113 -0
- package/lib/pull.js +269 -0
- package/lib/push-manifest.js +53 -0
- package/lib/registry.js +23 -12
- package/lib/remove.js +27 -2
- package/lib/rename-root.js +182 -84
- package/lib/rename.js +92 -16
- package/package.json +3 -1
- package/test/add-offline.js +240 -0
- package/test/checkpoint.js +243 -0
- package/test/completion.js +180 -0
- package/test/fixtures/checkpoint.js +14 -0
- package/test/fixtures/completion.bash +28 -0
- package/test/helpers.js +60 -2
- package/test/hooks.js +451 -0
- package/test/lifecycle.js +393 -13
- package/test/manifest-push.js +135 -0
- package/test/manifest.js +170 -0
- package/test/pull.js +696 -0
|
@@ -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
|
+
})
|