git-workspace-bare 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +279 -17
- 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/man/man1/git-workspace.1 +240 -0
- package/package.json +6 -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
package/test/lifecycle.js
CHANGED
|
@@ -2,18 +2,214 @@ import test from 'brittle'
|
|
|
2
2
|
import fs from 'bare-fs'
|
|
3
3
|
import path from 'bare-path'
|
|
4
4
|
import setup from './helpers.js'
|
|
5
|
+
import './manifest.js'
|
|
6
|
+
import './checkpoint.js'
|
|
7
|
+
import './completion.js'
|
|
8
|
+
import './pull.js'
|
|
9
|
+
import './hooks.js'
|
|
10
|
+
import './add-offline.js'
|
|
11
|
+
import './manifest-push.js'
|
|
12
|
+
|
|
13
|
+
function manifestEntries(json) {
|
|
14
|
+
const manifest = JSON.parse(json)
|
|
15
|
+
return manifest.repositories.flatMap(({ name, worktrees }) => [
|
|
16
|
+
name,
|
|
17
|
+
...worktrees.map(({ branch }) => `${name}@${branch}`)
|
|
18
|
+
])
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
test('clone, list, and remove a root repository', (t) => {
|
|
7
|
-
const { ok, root, registry } = setup(t)
|
|
22
|
+
const { ok, git, root, registry } = setup(t)
|
|
23
|
+
const directory = path.dirname(registry)
|
|
8
24
|
t.is(ok('list'), '', 'empty manifest is empty')
|
|
9
25
|
ok('add', 'owner/repo')
|
|
10
26
|
t.ok(fs.existsSync(path.join(root, '.git')), 'repository cloned')
|
|
11
27
|
t.is(ok('list'), 'owner/repo\n', 'prints repository name outside the checkout')
|
|
28
|
+
t.is(git('-C', directory, 'log', '-1', '--format=%s'), 'Add owner/repo')
|
|
29
|
+
t.alike(manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')), ['owner/repo'])
|
|
30
|
+
t.is(git('-C', directory, 'status', '--porcelain'), '', 'manifest committed')
|
|
31
|
+
const head = git('-C', directory, 'rev-parse', 'HEAD')
|
|
12
32
|
ok('add', 'owner/repo')
|
|
13
|
-
t.
|
|
33
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), head, 'repeat add makes no commit')
|
|
34
|
+
t.alike(
|
|
35
|
+
manifestEntries(fs.readFileSync(registry, 'utf8')),
|
|
36
|
+
['owner/repo'],
|
|
37
|
+
'repeat add is idempotent'
|
|
38
|
+
)
|
|
14
39
|
ok('remove', 'owner/repo')
|
|
15
40
|
t.absent(fs.existsSync(root), 'checkout deleted')
|
|
16
41
|
t.is(ok('list'), '', 'registry empty after removal')
|
|
42
|
+
t.is(git('-C', directory, 'log', '-1', '--format=%s'), 'Remove owner/repo')
|
|
43
|
+
t.alike(manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')), [])
|
|
44
|
+
t.is(git('-C', directory, 'status', '--porcelain'), '', 'removal committed')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('remove commits a branch removal and preserves unrelated staged work and pushes the manifest', (t) => {
|
|
48
|
+
const { ok, git, registry, upstream } = setup(t)
|
|
49
|
+
const directory = path.dirname(registry)
|
|
50
|
+
ok('add', 'owner/repo@jobs')
|
|
51
|
+
const remote = path.join(upstream, 'owner/workspace.git')
|
|
52
|
+
git('clone', '--bare', directory, remote)
|
|
53
|
+
const other = path.join(directory, 'other.txt')
|
|
54
|
+
fs.writeFileSync(other, 'staged\n')
|
|
55
|
+
git('-C', directory, 'add', 'other.txt')
|
|
56
|
+
fs.writeFileSync(other, 'unstaged\n')
|
|
57
|
+
const staged = git('-C', directory, 'diff', '--cached')
|
|
58
|
+
const unstaged = git('-C', directory, 'diff')
|
|
59
|
+
ok('remove', 'owner/repo@jobs')
|
|
60
|
+
t.is(git('-C', directory, 'log', '-1', '--format=%s'), 'Remove owner/repo@jobs')
|
|
61
|
+
t.alike(manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')), ['owner/repo'])
|
|
62
|
+
t.is(git('-C', directory, 'diff', '--cached'), staged, 'staged work preserved')
|
|
63
|
+
t.is(git('-C', directory, 'diff'), unstaged, 'unstaged work preserved')
|
|
64
|
+
t.is(
|
|
65
|
+
git('--git-dir', remote, 'rev-parse', 'HEAD'),
|
|
66
|
+
git('-C', directory, 'rev-parse', 'HEAD'),
|
|
67
|
+
'manifest pushed'
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
for (const repository of ['owner/repo', 'owner/repo@jobs']) {
|
|
72
|
+
test(`remove reports commit failure after deleting ${repository}`, (t) => {
|
|
73
|
+
const { cli, ok, git, registry, root } = setup(t)
|
|
74
|
+
const directory = path.dirname(registry)
|
|
75
|
+
ok('add', repository)
|
|
76
|
+
const head = git('-C', directory, 'rev-parse', 'HEAD')
|
|
77
|
+
const hooks = path.join(directory, '.git/hooks')
|
|
78
|
+
git('-C', directory, 'config', 'core.hooksPath', hooks)
|
|
79
|
+
const hook = path.join(hooks, 'pre-commit')
|
|
80
|
+
fs.writeFileSync(hook, '#!/bin/sh\necho "test hook rejected commit" >&2\nexit 1\n')
|
|
81
|
+
fs.chmodSync(hook, 0o755)
|
|
82
|
+
const result = cli('remove', repository)
|
|
83
|
+
t.is(result.status, 1)
|
|
84
|
+
t.ok(result.stderr.includes('test hook rejected commit'))
|
|
85
|
+
t.ok(result.stderr.includes('checkout is already removed'))
|
|
86
|
+
t.ok(result.stderr.includes('commit only workspace.json'))
|
|
87
|
+
t.absent(fs.existsSync(repository.includes('@') ? root + '@jobs' : root))
|
|
88
|
+
const expected = repository.includes('@') ? ['owner/repo'] : []
|
|
89
|
+
t.alike(manifestEntries(fs.readFileSync(registry, 'utf8')), expected, 'removal saved')
|
|
90
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), head, 'no commit on failure')
|
|
91
|
+
fs.unlinkSync(hook)
|
|
92
|
+
git('-C', directory, 'commit', '--only', '-m', `Remove ${repository}`, '--', 'workspace.json')
|
|
93
|
+
t.alike(
|
|
94
|
+
manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')),
|
|
95
|
+
expected,
|
|
96
|
+
'manual recovery works'
|
|
97
|
+
)
|
|
98
|
+
const recovered = git('-C', directory, 'rev-parse', 'HEAD')
|
|
99
|
+
t.is(cli('remove', repository).status, 1, 'missing unregistered checkout remains an error')
|
|
100
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), recovered, 'repeat removal makes no commit')
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('remove skips commits when deleting an unregistered checkout leaves the manifest unchanged', (t) => {
|
|
105
|
+
const { ok, git, registry, root, upstream } = setup(t)
|
|
106
|
+
const directory = path.dirname(registry)
|
|
107
|
+
git('clone', path.join(upstream, 'owner/repo.git'), root)
|
|
108
|
+
git('-C', root, 'remote', 'set-url', 'origin', 'git@github.com:owner/repo.git')
|
|
109
|
+
const head = git('-C', directory, 'rev-parse', 'HEAD')
|
|
110
|
+
ok('remove', 'owner/repo')
|
|
111
|
+
t.absent(fs.existsSync(root))
|
|
112
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), head, 'no empty commit')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('remove commits unregistering a missing checkout', (t) => {
|
|
116
|
+
const { ok, git, registry, root } = setup(t)
|
|
117
|
+
ok('add', 'owner/repo')
|
|
118
|
+
fs.renameSync(root, root + '-moved')
|
|
119
|
+
ok('remove', 'owner/repo')
|
|
120
|
+
t.alike(manifestEntries(git('-C', path.dirname(registry), 'show', 'HEAD:workspace.json')), [])
|
|
121
|
+
t.ok(fs.existsSync(root + '-moved'), 'relocated checkout untouched')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('add commits root and branch registrations together and preserves unrelated work', (t) => {
|
|
125
|
+
const { ok, git, registry, upstream } = setup(t)
|
|
126
|
+
const directory = path.dirname(registry)
|
|
127
|
+
const remote = path.join(upstream, 'owner/workspace.git')
|
|
128
|
+
git('clone', '--bare', directory, remote)
|
|
129
|
+
const other = path.join(directory, 'other.txt')
|
|
130
|
+
fs.writeFileSync(other, 'staged\n')
|
|
131
|
+
git('-C', directory, 'add', 'other.txt')
|
|
132
|
+
fs.writeFileSync(other, 'unstaged\n')
|
|
133
|
+
const staged = git('-C', directory, 'diff', '--cached')
|
|
134
|
+
const unstaged = git('-C', directory, 'diff')
|
|
135
|
+
ok('add', 'owner/repo@feature/jobs')
|
|
136
|
+
t.is(
|
|
137
|
+
git('-C', directory, 'rev-list', '--count', 'HEAD'),
|
|
138
|
+
'2',
|
|
139
|
+
'one commit for both registrations'
|
|
140
|
+
)
|
|
141
|
+
t.is(git('-C', directory, 'log', '-1', '--format=%s'), 'Add owner/repo@feature/jobs')
|
|
142
|
+
t.alike(manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')), [
|
|
143
|
+
'owner/repo',
|
|
144
|
+
'owner/repo@feature/jobs'
|
|
145
|
+
])
|
|
146
|
+
t.is(git('-C', directory, 'diff', '--cached'), staged, 'staged work preserved')
|
|
147
|
+
t.is(git('-C', directory, 'diff'), unstaged, 'unstaged work preserved')
|
|
148
|
+
t.is(
|
|
149
|
+
git('--git-dir', remote, 'rev-parse', 'HEAD'),
|
|
150
|
+
git('-C', directory, 'rev-parse', 'HEAD'),
|
|
151
|
+
'manifest pushed'
|
|
152
|
+
)
|
|
153
|
+
ok('add', 'owner/repo@feature/jobs')
|
|
154
|
+
t.is(
|
|
155
|
+
git('-C', directory, 'rev-list', '--count', 'HEAD'),
|
|
156
|
+
'2',
|
|
157
|
+
'repeat branch add makes no commit'
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('add commits pending manifest edits only after successful registration', (t) => {
|
|
162
|
+
const { cli, ok, git, registry } = setup(t)
|
|
163
|
+
const directory = path.dirname(registry)
|
|
164
|
+
ok('add', 'owner/repo')
|
|
165
|
+
const head = git('-C', directory, 'rev-parse', 'HEAD')
|
|
166
|
+
const entries = ['owner/repo', 'other/saved']
|
|
167
|
+
const edited = JSON.parse(fs.readFileSync(registry, 'utf8'))
|
|
168
|
+
edited.repositories.push({ id: 'saved-repo', name: 'other/saved', worktrees: [] })
|
|
169
|
+
fs.writeFileSync(registry, JSON.stringify(edited) + '\n')
|
|
170
|
+
fs.mkdirSync(path.join(path.dirname(registry), '../missing'), { recursive: true })
|
|
171
|
+
fs.writeFileSync(path.join(path.dirname(registry), '../missing/keep.txt'), 'existing work\n')
|
|
172
|
+
t.is(cli('add', 'owner/missing').status, 1, 'occupied non-Git destination fails validation')
|
|
173
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), head, 'failed add does not commit pending edits')
|
|
174
|
+
ok('add', 'owner/repo')
|
|
175
|
+
t.alike(manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')), entries)
|
|
176
|
+
t.is(git('-C', directory, 'status', '--porcelain'), '', 'pending manifest edits committed')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('add retries a failed manifest commit without duplicate registrations', (t) => {
|
|
180
|
+
const { cli, ok, git, registry, root } = setup(t)
|
|
181
|
+
const directory = path.dirname(registry)
|
|
182
|
+
const hooks = path.join(directory, '.git/hooks')
|
|
183
|
+
git('-C', directory, 'config', 'core.hooksPath', hooks)
|
|
184
|
+
const hook = path.join(hooks, 'pre-commit')
|
|
185
|
+
fs.writeFileSync(hook, '#!/bin/sh\necho "test hook rejected commit" >&2\nexit 1\n')
|
|
186
|
+
fs.chmodSync(hook, 0o755)
|
|
187
|
+
const result = cli('add', 'owner/repo@jobs')
|
|
188
|
+
t.is(result.status, 1)
|
|
189
|
+
t.ok(result.stderr.includes('test hook rejected commit'))
|
|
190
|
+
t.ok(result.stderr.includes('rerun git workspace add owner/repo@jobs'))
|
|
191
|
+
t.ok(fs.existsSync(root + '@jobs'), 'checkout preserved')
|
|
192
|
+
t.alike(manifestEntries(fs.readFileSync(registry, 'utf8')), ['owner/repo', 'owner/repo@jobs'])
|
|
193
|
+
t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), '1', 'no commit on failure')
|
|
194
|
+
fs.unlinkSync(hook)
|
|
195
|
+
ok('add', 'owner/repo@jobs')
|
|
196
|
+
t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), '2')
|
|
197
|
+
t.alike(manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')), [
|
|
198
|
+
'owner/repo',
|
|
199
|
+
'owner/repo@jobs'
|
|
200
|
+
])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('failed branch creation leaves the root registered but uncommitted until retry', (t) => {
|
|
204
|
+
const { cli, ok, git, registry, root } = setup(t)
|
|
205
|
+
const directory = path.dirname(registry)
|
|
206
|
+
fs.mkdirSync(root + '@jobs', { recursive: true })
|
|
207
|
+
t.is(cli('add', 'owner/repo@jobs').status, 1)
|
|
208
|
+
t.alike(manifestEntries(fs.readFileSync(registry, 'utf8')), ['owner/repo'])
|
|
209
|
+
t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), '1')
|
|
210
|
+
fs.rmdirSync(root + '@jobs')
|
|
211
|
+
ok('add', 'owner/repo@jobs')
|
|
212
|
+
t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), '2')
|
|
17
213
|
})
|
|
18
214
|
|
|
19
215
|
test('nested branches and empty parent cleanup', (t) => {
|
|
@@ -36,9 +232,10 @@ test('nested branches and empty parent cleanup', (t) => {
|
|
|
36
232
|
})
|
|
37
233
|
|
|
38
234
|
test('root removal refuses linked worktrees', (t) => {
|
|
39
|
-
const { cli, ok, root, registry } = setup(t)
|
|
235
|
+
const { cli, ok, git, root, registry } = setup(t)
|
|
40
236
|
ok('add', 'owner/repo@jobs')
|
|
41
237
|
const before = fs.readFileSync(registry, 'utf8')
|
|
238
|
+
const head = git('-C', path.dirname(registry), 'rev-parse', 'HEAD')
|
|
42
239
|
const result = cli('remove', 'owner/repo')
|
|
43
240
|
t.is(result.status, 1)
|
|
44
241
|
t.is(result.stdout, '')
|
|
@@ -49,6 +246,11 @@ test('root removal refuses linked worktrees', (t) => {
|
|
|
49
246
|
t.ok(fs.existsSync(root), 'root preserved')
|
|
50
247
|
t.ok(fs.existsSync(root + '@jobs'), 'linked worktree preserved')
|
|
51
248
|
t.is(fs.readFileSync(registry, 'utf8'), before, 'registry unchanged')
|
|
249
|
+
t.is(
|
|
250
|
+
git('-C', path.dirname(registry), 'rev-parse', 'HEAD'),
|
|
251
|
+
head,
|
|
252
|
+
'refused removal makes no commit'
|
|
253
|
+
)
|
|
52
254
|
})
|
|
53
255
|
|
|
54
256
|
test('dirty worktrees and roots survive failed removal', (t) => {
|
|
@@ -99,6 +301,88 @@ test('worktrees are removed along with whichever branch they hold', (t) => {
|
|
|
99
301
|
t.is(ok('list'), 'owner/repo\n')
|
|
100
302
|
})
|
|
101
303
|
|
|
304
|
+
for (const rootRename of [false, true]) {
|
|
305
|
+
const from = rootRename ? 'owner/repo' : 'owner/repo@jobs'
|
|
306
|
+
const to = rootRename ? 'other/new' : 'owner/repo@fixes'
|
|
307
|
+
const expected = rootRename ? ['other/new', 'other/new@jobs'] : ['owner/repo', 'owner/repo@fixes']
|
|
308
|
+
|
|
309
|
+
test(`rename commits ${from} to ${to} without committing other files and pushes the manifest`, (t) => {
|
|
310
|
+
const { cli, ok, git, registry, upstream } = setup(t)
|
|
311
|
+
const directory = path.dirname(registry)
|
|
312
|
+
ok('add', 'owner/repo@jobs')
|
|
313
|
+
const remote = path.join(upstream, 'owner/workspace.git')
|
|
314
|
+
git('clone', '--bare', directory, remote)
|
|
315
|
+
const other = path.join(directory, 'other.txt')
|
|
316
|
+
fs.writeFileSync(other, 'staged\n')
|
|
317
|
+
git('-C', directory, 'add', 'other.txt')
|
|
318
|
+
fs.writeFileSync(other, 'unstaged\n')
|
|
319
|
+
const staged = git('-C', directory, 'diff', '--cached')
|
|
320
|
+
const unstaged = git('-C', directory, 'diff')
|
|
321
|
+
ok('rename', from, to)
|
|
322
|
+
t.is(git('-C', directory, 'log', '-1', '--format=%s'), `Rename ${from} to ${to}`)
|
|
323
|
+
t.alike(manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')), expected)
|
|
324
|
+
t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), '3', 'one rename commit')
|
|
325
|
+
t.is(git('-C', directory, 'diff', '--cached'), staged, 'staged work preserved')
|
|
326
|
+
t.is(git('-C', directory, 'diff'), unstaged, 'unstaged work preserved')
|
|
327
|
+
t.is(
|
|
328
|
+
git('--git-dir', remote, 'rev-parse', 'HEAD'),
|
|
329
|
+
git('-C', directory, 'rev-parse', 'HEAD'),
|
|
330
|
+
'manifest pushed'
|
|
331
|
+
)
|
|
332
|
+
const head = git('-C', directory, 'rev-parse', 'HEAD')
|
|
333
|
+
t.is(cli('rename', to, to).status, 1, 'same-name rename refused')
|
|
334
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), head, 'refusal creates no commit')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test(`rename keeps completed ${from} to ${to} after commit failure`, (t) => {
|
|
338
|
+
const { cli, ok, git, home, root, registry } = setup(t)
|
|
339
|
+
const directory = path.dirname(registry)
|
|
340
|
+
ok('add', 'owner/repo@jobs')
|
|
341
|
+
const head = git('-C', directory, 'rev-parse', 'HEAD')
|
|
342
|
+
const hooks = path.join(directory, '.git/hooks')
|
|
343
|
+
git('-C', directory, 'config', 'core.hooksPath', hooks)
|
|
344
|
+
const hook = path.join(hooks, 'pre-commit')
|
|
345
|
+
fs.writeFileSync(hook, '#!/bin/sh\necho "test hook rejected commit" >&2\nexit 1\n')
|
|
346
|
+
fs.chmodSync(hook, 0o755)
|
|
347
|
+
const result = cli('rename', from, to)
|
|
348
|
+
t.is(result.status, 1)
|
|
349
|
+
t.ok(result.stderr.includes('test hook rejected commit'))
|
|
350
|
+
t.ok(result.stderr.includes('rename is already complete'))
|
|
351
|
+
t.ok(result.stderr.includes('commit only workspace.json'))
|
|
352
|
+
t.alike(manifestEntries(fs.readFileSync(registry, 'utf8')), expected)
|
|
353
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), head, 'no commit on failure')
|
|
354
|
+
t.absent(fs.existsSync(rootRename ? root : root + '@jobs'), 'old path gone')
|
|
355
|
+
const renamedRoot = rootRename ? path.join(home, 'workspace/other/new') : root
|
|
356
|
+
const branchPath = rootRename ? renamedRoot + '@jobs' : root + '@fixes'
|
|
357
|
+
t.is(
|
|
358
|
+
git('-C', branchPath, 'rev-parse', '--path-format=absolute', '--git-common-dir'),
|
|
359
|
+
path.join(renamedRoot, '.git'),
|
|
360
|
+
'worktree links remain repaired'
|
|
361
|
+
)
|
|
362
|
+
t.is(git('-C', branchPath, 'symbolic-ref', '--short', 'HEAD'), rootRename ? 'jobs' : 'fixes')
|
|
363
|
+
t.is(
|
|
364
|
+
git('-C', renamedRoot, 'config', '--get', 'remote.origin.url'),
|
|
365
|
+
rootRename ? 'git@github.com:other/new.git' : 'git@github.com:owner/repo.git'
|
|
366
|
+
)
|
|
367
|
+
fs.unlinkSync(hook)
|
|
368
|
+
git(
|
|
369
|
+
'-C',
|
|
370
|
+
directory,
|
|
371
|
+
'commit',
|
|
372
|
+
'--only',
|
|
373
|
+
'-m',
|
|
374
|
+
`Rename ${from} to ${to}`,
|
|
375
|
+
'--',
|
|
376
|
+
'workspace.json'
|
|
377
|
+
)
|
|
378
|
+
t.alike(
|
|
379
|
+
manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')),
|
|
380
|
+
expected,
|
|
381
|
+
'manual recovery works'
|
|
382
|
+
)
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
102
386
|
test('root rename moves dirty checkouts, repairs links, and updates origin locally', (t) => {
|
|
103
387
|
const { ok, git, home, root, upstream } = setup(t)
|
|
104
388
|
ok('add', 'owner/repo@feature/jobs')
|
|
@@ -393,7 +677,7 @@ test('commands require init and ignore old registry files', (t) => {
|
|
|
393
677
|
t.absent(fs.existsSync(root), 'add does not clone before initialization')
|
|
394
678
|
})
|
|
395
679
|
|
|
396
|
-
test('init
|
|
680
|
+
test('init commits a new local manifest without importing old registries when the remote is unavailable', (t) => {
|
|
397
681
|
const { cli, ok, git, home, upstream, registry } = setup(t, { initialize: false })
|
|
398
682
|
fs.mkdirSync(path.join(home, '.git-workspace'))
|
|
399
683
|
fs.writeFileSync(path.join(home, '.git-workspace/worktrees.json'), '["/old/workspace/repo"]')
|
|
@@ -405,8 +689,10 @@ test('init creates a local manifest without importing old registries or committi
|
|
|
405
689
|
git('-C', directory, 'config', '--get', 'remote.origin.url'),
|
|
406
690
|
'git@github.com:owner/workspace.git'
|
|
407
691
|
)
|
|
408
|
-
t.alike(
|
|
409
|
-
t.is(git('-C', directory, 'status', '--porcelain'), '
|
|
692
|
+
t.alike(manifestEntries(fs.readFileSync(registry, 'utf8')), [], 'new manifest starts empty')
|
|
693
|
+
t.is(git('-C', directory, 'status', '--porcelain'), '', 'manifest committed')
|
|
694
|
+
t.is(git('-C', directory, 'log', '-1', '--format=%s'), 'Initialize workspace manifest')
|
|
695
|
+
t.alike(manifestEntries(git('-C', directory, 'show', 'HEAD:workspace.json')), [])
|
|
410
696
|
t.alike(
|
|
411
697
|
JSON.parse(fs.readFileSync(path.join(home, '.config/git-workspace/config.json'), 'utf8')),
|
|
412
698
|
{ manifestRepository: directory },
|
|
@@ -414,23 +700,120 @@ test('init creates a local manifest without importing old registries or committi
|
|
|
414
700
|
)
|
|
415
701
|
t.absent(fs.existsSync(path.join(upstream, 'owner/workspace.git')), 'no remote created')
|
|
416
702
|
ok('add', 'owner/repo@feature/jobs')
|
|
417
|
-
t.alike(
|
|
703
|
+
t.alike(manifestEntries(fs.readFileSync(registry, 'utf8')), [
|
|
704
|
+
'owner/repo',
|
|
705
|
+
'owner/repo@feature/jobs'
|
|
706
|
+
])
|
|
418
707
|
ok('remove', 'owner/repo@feature/jobs')
|
|
708
|
+
const beforeInit = git('-C', directory, 'rev-parse', 'HEAD')
|
|
709
|
+
const edited = fs.readFileSync(registry, 'utf8') + '\n'
|
|
710
|
+
fs.writeFileSync(registry, edited)
|
|
419
711
|
ok('init', 'owner/workspace')
|
|
712
|
+
t.is(fs.readFileSync(registry, 'utf8'), edited, 'manual edit preserved')
|
|
713
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), beforeInit, 'repeat init does not commit edits')
|
|
420
714
|
t.alike(
|
|
421
|
-
|
|
715
|
+
manifestEntries(fs.readFileSync(registry, 'utf8')),
|
|
422
716
|
['owner/repo'],
|
|
423
717
|
'repeat init preserves edits'
|
|
424
718
|
)
|
|
425
719
|
t.is(cli('init', 'owner/other').status, 1, 'different remote refused')
|
|
426
720
|
})
|
|
427
721
|
|
|
722
|
+
for (const existingHistory of [false, true]) {
|
|
723
|
+
test(`init commits only the manifest with existing history: ${existingHistory}`, (t) => {
|
|
724
|
+
const { ok, git, registry } = setup(t, { initialize: false })
|
|
725
|
+
const directory = path.dirname(registry)
|
|
726
|
+
git('init', directory)
|
|
727
|
+
git('-C', directory, 'remote', 'add', 'origin', 'git@github.com:owner/workspace.git')
|
|
728
|
+
const other = path.join(directory, 'other.txt')
|
|
729
|
+
if (existingHistory) {
|
|
730
|
+
fs.writeFileSync(other, 'original\n')
|
|
731
|
+
git('-C', directory, 'add', 'other.txt')
|
|
732
|
+
git('-C', directory, 'commit', '-m', 'Existing history')
|
|
733
|
+
}
|
|
734
|
+
fs.writeFileSync(other, 'staged\n')
|
|
735
|
+
git('-C', directory, 'add', 'other.txt')
|
|
736
|
+
fs.writeFileSync(other, 'unstaged\n')
|
|
737
|
+
const staged = git('-C', directory, 'diff', '--cached')
|
|
738
|
+
const unstaged = git('-C', directory, 'diff')
|
|
739
|
+
ok('init', 'owner/workspace')
|
|
740
|
+
t.is(git('-C', directory, 'diff', '--cached'), staged, 'staged changes preserved')
|
|
741
|
+
t.is(git('-C', directory, 'diff'), unstaged, 'unstaged changes preserved')
|
|
742
|
+
t.is(git('-C', directory, 'show', '--format=', '--name-only', 'HEAD'), 'workspace.json')
|
|
743
|
+
t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), existingHistory ? '2' : '1')
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
test('init preserves an existing untracked manifest without committing', (t) => {
|
|
748
|
+
const { ok, git, registry } = setup(t, { initialize: false })
|
|
749
|
+
const directory = path.dirname(registry)
|
|
750
|
+
git('init', directory)
|
|
751
|
+
git('-C', directory, 'remote', 'add', 'origin', 'git@github.com:owner/workspace.git')
|
|
752
|
+
fs.writeFileSync(
|
|
753
|
+
registry,
|
|
754
|
+
JSON.stringify({
|
|
755
|
+
version: 1,
|
|
756
|
+
repositories: [{ id: 'existing', name: 'owner/repo', worktrees: [] }]
|
|
757
|
+
})
|
|
758
|
+
)
|
|
759
|
+
ok('init', 'owner/workspace')
|
|
760
|
+
t.is(git('-C', directory, 'status', '--porcelain'), '?? workspace.json')
|
|
761
|
+
t.is(ok('list'), 'owner/repo\n')
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
test('init reports commit failures and preserves the manifest for recovery', (t) => {
|
|
765
|
+
const { cli, ok, git, home, registry } = setup(t, { initialize: false })
|
|
766
|
+
const directory = path.dirname(registry)
|
|
767
|
+
git('init', directory)
|
|
768
|
+
git('-C', directory, 'remote', 'add', 'origin', 'git@github.com:owner/workspace.git')
|
|
769
|
+
const hooks = path.join(directory, '.git/hooks')
|
|
770
|
+
git('-C', directory, 'config', 'core.hooksPath', hooks)
|
|
771
|
+
const hook = path.join(hooks, 'pre-commit')
|
|
772
|
+
fs.writeFileSync(hook, '#!/bin/sh\necho "test hook rejected commit" >&2\nexit 1\n')
|
|
773
|
+
fs.chmodSync(hook, 0o755)
|
|
774
|
+
const result = cli('init', 'owner/workspace')
|
|
775
|
+
t.is(result.status, 1)
|
|
776
|
+
t.ok(result.stderr.includes('test hook rejected commit'))
|
|
777
|
+
t.ok(result.stderr.includes('commit only workspace.json'))
|
|
778
|
+
t.alike(manifestEntries(fs.readFileSync(registry, 'utf8')), [])
|
|
779
|
+
t.absent(
|
|
780
|
+
fs.existsSync(path.join(home, '.config/git-workspace/config.json')),
|
|
781
|
+
'not activated on failure'
|
|
782
|
+
)
|
|
783
|
+
fs.unlinkSync(hook)
|
|
784
|
+
git(
|
|
785
|
+
'-C',
|
|
786
|
+
directory,
|
|
787
|
+
'commit',
|
|
788
|
+
'--only',
|
|
789
|
+
'-m',
|
|
790
|
+
'Initialize workspace manifest',
|
|
791
|
+
'--',
|
|
792
|
+
'workspace.json'
|
|
793
|
+
)
|
|
794
|
+
ok('init', 'owner/workspace')
|
|
795
|
+
t.is(ok('list'), '')
|
|
796
|
+
t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), '1', 'no duplicate commit')
|
|
797
|
+
})
|
|
798
|
+
|
|
428
799
|
test('existing manifests are portable and not applied during init', (t) => {
|
|
429
800
|
const { cliAt, ok, git, home, upstream, registry } = setup(t, { initialize: false })
|
|
430
801
|
const source = path.join(home, 'manifest-source')
|
|
431
802
|
git('init', source)
|
|
432
803
|
const entries = ['owner/repo', 'owner/repo@feature/jobs']
|
|
433
|
-
fs.writeFileSync(
|
|
804
|
+
fs.writeFileSync(
|
|
805
|
+
path.join(source, 'workspace.json'),
|
|
806
|
+
JSON.stringify({
|
|
807
|
+
version: 1,
|
|
808
|
+
repositories: [
|
|
809
|
+
{
|
|
810
|
+
id: 'fixture-repo',
|
|
811
|
+
name: 'owner/repo',
|
|
812
|
+
worktrees: [{ id: 'fixture-worktree', branch: 'feature/jobs' }]
|
|
813
|
+
}
|
|
814
|
+
]
|
|
815
|
+
})
|
|
816
|
+
)
|
|
434
817
|
git('-C', source, 'add', 'workspace.json')
|
|
435
818
|
git('-C', source, 'commit', '-m', 'Manifest fixture')
|
|
436
819
|
git('clone', '--bare', source, path.join(upstream, 'owner/workspace.git'))
|
|
@@ -466,11 +849,8 @@ test('invalid manifests fail without being overwritten', (t) => {
|
|
|
466
849
|
})
|
|
467
850
|
|
|
468
851
|
test('active manifest repository cannot be removed', (t) => {
|
|
469
|
-
const { cli, ok,
|
|
852
|
+
const { cli, ok, registry } = setup(t)
|
|
470
853
|
ok('add', 'owner/workspace')
|
|
471
|
-
const directory = path.dirname(registry)
|
|
472
|
-
git('-C', directory, 'add', 'workspace.json')
|
|
473
|
-
git('-C', directory, 'commit', '-m', 'Manifest')
|
|
474
854
|
const before = fs.readFileSync(registry, 'utf8')
|
|
475
855
|
const result = cli('remove', 'owner/workspace')
|
|
476
856
|
t.is(result.status, 1)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import test from 'brittle'
|
|
2
|
+
import fs from 'bare-fs'
|
|
3
|
+
import path from 'bare-path'
|
|
4
|
+
import setup from './helpers.js'
|
|
5
|
+
|
|
6
|
+
function remote(t) {
|
|
7
|
+
const context = setup(t)
|
|
8
|
+
const directory = path.dirname(context.registry)
|
|
9
|
+
const upstream = path.join(context.upstream, 'owner/workspace.git')
|
|
10
|
+
context.git('clone', '--bare', directory, upstream)
|
|
11
|
+
return { ...context, directory, remote: upstream }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('init pushes its first manifest commit to an empty remote and establishes tracking', (t) => {
|
|
15
|
+
const { cli, git, upstream, registry } = setup(t, { initialize: false })
|
|
16
|
+
const destination = path.join(upstream, 'owner/workspace.git')
|
|
17
|
+
git('init', '--bare', destination)
|
|
18
|
+
const result = cli('init', 'owner/workspace')
|
|
19
|
+
t.is(result.status, 0, result.stderr)
|
|
20
|
+
t.absent(result.stderr.includes('could not push'))
|
|
21
|
+
const directory = path.dirname(registry)
|
|
22
|
+
t.is(
|
|
23
|
+
git('--git-dir', destination, 'rev-parse', 'main'),
|
|
24
|
+
git('-C', directory, 'rev-parse', 'HEAD')
|
|
25
|
+
)
|
|
26
|
+
t.is(git('-C', directory, 'rev-parse', '--abbrev-ref', '@{upstream}'), 'origin/main')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('failed push retains local results, checkpoint and hook delivery; repeated add retries without a commit', (t) => {
|
|
30
|
+
const { cli, git, directory, registry, bin, remote: destination } = remote(t)
|
|
31
|
+
const head = git('-C', directory, 'rev-parse', 'HEAD')
|
|
32
|
+
const hooks = path.join(destination, 'hooks')
|
|
33
|
+
git('--git-dir', destination, 'config', 'core.hooksPath', hooks)
|
|
34
|
+
const reject = path.join(hooks, 'pre-receive')
|
|
35
|
+
fs.writeFileSync(reject, '#!/bin/sh\necho push-rejected >&2\nexit 1\n', { mode: 0o755 })
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
path.join(bin, 'git-workspace-hook-add-post-check'),
|
|
38
|
+
'#!/bin/sh\ncat >/dev/null\necho post-hook-ran >&2\n',
|
|
39
|
+
{ mode: 0o755 }
|
|
40
|
+
)
|
|
41
|
+
const added = cli('add', 'owner/repo@jobs')
|
|
42
|
+
t.is(added.status, 0, added.stderr)
|
|
43
|
+
t.ok(added.stderr.includes('could not push workspace manifest'))
|
|
44
|
+
t.ok(added.stderr.includes('push-rejected'))
|
|
45
|
+
t.ok(added.stderr.includes('changes are saved locally'))
|
|
46
|
+
t.ok(added.stderr.includes('post-hook-ran'))
|
|
47
|
+
const committed = git('-C', directory, 'rev-parse', 'HEAD')
|
|
48
|
+
t.unlike(committed, head)
|
|
49
|
+
t.is(git('-C', directory, 'rev-parse', 'refs/git-workspace/applied'), committed)
|
|
50
|
+
t.is(git('--git-dir', destination, 'rev-parse', 'main'), head)
|
|
51
|
+
t.is(JSON.parse(fs.readFileSync(registry, 'utf8')).repositories[0].worktrees.length, 1)
|
|
52
|
+
fs.unlinkSync(reject)
|
|
53
|
+
const retried = cli('add', 'owner/repo@jobs')
|
|
54
|
+
t.is(retried.status, 0, retried.stderr)
|
|
55
|
+
t.absent(retried.stderr.includes('could not push'))
|
|
56
|
+
t.is(git('-C', directory, 'rev-parse', 'HEAD'), committed, 'retry makes no extra commit')
|
|
57
|
+
t.is(git('--git-dir', destination, 'rev-parse', 'main'), committed)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('push follows a differently named upstream without publishing other branches, tags or checkout commits', (t) => {
|
|
61
|
+
const { ok, git, root, directory, upstream, remote: destination } = remote(t)
|
|
62
|
+
const original = git('--git-dir', destination, 'rev-parse', 'main')
|
|
63
|
+
git('-C', directory, 'push', 'origin', 'main:team')
|
|
64
|
+
git('-C', directory, 'config', 'branch.main.remote', 'origin')
|
|
65
|
+
git('-C', directory, 'config', 'branch.main.merge', 'refs/heads/team')
|
|
66
|
+
git('-C', directory, 'branch', 'unrelated')
|
|
67
|
+
git('-C', directory, 'tag', '-a', 'private-tag', '-m', 'Keep local')
|
|
68
|
+
git('-C', directory, 'config', 'push.followTags', 'true')
|
|
69
|
+
git('-C', directory, 'config', 'push.default', 'matching')
|
|
70
|
+
const checkoutRemote = path.join(upstream, 'owner/repo.git')
|
|
71
|
+
const checkoutHead = git('--git-dir', checkoutRemote, 'rev-parse', 'main')
|
|
72
|
+
ok('add', 'owner/repo')
|
|
73
|
+
git('-C', root, 'commit', '--allow-empty', '-m', 'Local project commit')
|
|
74
|
+
ok('add', 'owner/repo@jobs')
|
|
75
|
+
t.is(
|
|
76
|
+
git('--git-dir', destination, 'rev-parse', 'team'),
|
|
77
|
+
git('-C', directory, 'rev-parse', 'HEAD')
|
|
78
|
+
)
|
|
79
|
+
t.is(
|
|
80
|
+
git('--git-dir', destination, 'rev-parse', 'main'),
|
|
81
|
+
original,
|
|
82
|
+
'original branch stays unchanged'
|
|
83
|
+
)
|
|
84
|
+
t.is(
|
|
85
|
+
git(
|
|
86
|
+
'--git-dir',
|
|
87
|
+
destination,
|
|
88
|
+
'for-each-ref',
|
|
89
|
+
'--format=%(refname)',
|
|
90
|
+
'refs/tags',
|
|
91
|
+
'refs/heads/unrelated'
|
|
92
|
+
),
|
|
93
|
+
''
|
|
94
|
+
)
|
|
95
|
+
t.is(
|
|
96
|
+
git('--git-dir', checkoutRemote, 'rev-parse', 'main'),
|
|
97
|
+
checkoutHead,
|
|
98
|
+
'project commits stay local'
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('non-fast-forward pushes warn without force pushing or merging remote changes', (t) => {
|
|
103
|
+
const { cli, git, directory, home, remote: destination } = remote(t)
|
|
104
|
+
const other = path.join(home, 'other-manifest')
|
|
105
|
+
git('clone', destination, other)
|
|
106
|
+
fs.appendFileSync(path.join(other, 'workspace.json'), '\n')
|
|
107
|
+
git('-C', other, 'commit', '-am', 'Remote change')
|
|
108
|
+
git('-C', other, 'push', 'origin', 'main')
|
|
109
|
+
const remoteHead = git('--git-dir', destination, 'rev-parse', 'main')
|
|
110
|
+
const result = cli('add', 'owner/repo')
|
|
111
|
+
t.is(result.status, 0, result.stderr)
|
|
112
|
+
t.ok(result.stderr.includes('could not push workspace manifest'))
|
|
113
|
+
t.is(git('--git-dir', destination, 'rev-parse', 'main'), remoteHead)
|
|
114
|
+
t.unlike(git('-C', directory, 'rev-parse', 'HEAD'), remoteHead)
|
|
115
|
+
t.is(git('-C', directory, 'log', '-1', '--format=%s'), 'Add owner/repo')
|
|
116
|
+
t.is(git('-C', directory, 'rev-list', '--count', 'HEAD'), '2', 'no automatic merge')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('commit failures never push, and detached manifests warn rather than publishing an arbitrary branch', (t) => {
|
|
120
|
+
const { cli, git, directory, remote: destination } = remote(t)
|
|
121
|
+
const head = git('--git-dir', destination, 'rev-parse', 'main')
|
|
122
|
+
const hooks = path.join(directory, '.git/hooks')
|
|
123
|
+
git('-C', directory, 'config', 'core.hooksPath', hooks)
|
|
124
|
+
const reject = path.join(hooks, 'pre-commit')
|
|
125
|
+
fs.writeFileSync(reject, '#!/bin/sh\nexit 1\n', { mode: 0o755 })
|
|
126
|
+
t.is(cli('add', 'owner/repo').status, 1)
|
|
127
|
+
t.is(git('--git-dir', destination, 'rev-parse', 'main'), head)
|
|
128
|
+
fs.unlinkSync(reject)
|
|
129
|
+
git('-C', directory, 'checkout', '--detach')
|
|
130
|
+
const result = cli('add', 'owner/repo')
|
|
131
|
+
t.is(result.status, 0, result.stderr)
|
|
132
|
+
t.ok(result.stderr.includes('could not push workspace manifest'))
|
|
133
|
+
t.ok(result.stderr.includes('detached HEAD'))
|
|
134
|
+
t.is(git('--git-dir', destination, 'rev-parse', 'main'), head)
|
|
135
|
+
})
|