@xiaoming_z/dsh-git 0.0.0-stage → 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 +998 -2
- package/client.js +3133 -0
- package/cordis.patch.yml +29 -0
- package/docs/composer-branch-chip.png +0 -0
- package/index.js +1320 -0
- package/package.json +44 -4
- package/test/browser/README.md +39 -0
- package/test/browser/drive.mjs +122 -0
- package/test/browser/harness.js +271 -0
- package/test/browser/index.html +41 -0
- package/test/browser/setup.mjs +119 -0
- package/test/client.test.mjs +1258 -0
- package/test/host.test.mjs +817 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-half end-to-end test.
|
|
3
|
+
*
|
|
4
|
+
* Builds its own fixture repository (including a real merge conflict), mounts
|
|
5
|
+
* the plugin over a fake `ctx`, and drives the real route handler with fake
|
|
6
|
+
* `req`/`res` objects. Run: `node test/host.test.mjs`
|
|
7
|
+
*/
|
|
8
|
+
import { apply, splitHunks, parseNumstat, parseNameStatus } from '../index.js'
|
|
9
|
+
import { execFileSync } from 'node:child_process'
|
|
10
|
+
import { mkdtempSync, writeFileSync, realpathSync, statSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { createServer } from 'node:http'
|
|
14
|
+
|
|
15
|
+
// ---- hermetic fixture -------------------------------------------------------
|
|
16
|
+
// realpath matters: on macOS /var is a symlink into /private, and the allowlist
|
|
17
|
+
// comparison canonicalizes both sides.
|
|
18
|
+
const repo = realpathSync(mkdtempSync(join(tmpdir(), 'gitfix-')))
|
|
19
|
+
const git = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' })
|
|
20
|
+
git('init', '-q', '-b', 'main')
|
|
21
|
+
git('config', 'user.email', 't@e.com')
|
|
22
|
+
git('config', 'user.name', 'T')
|
|
23
|
+
writeFileSync(join(repo, 'a.txt'), 'base\n')
|
|
24
|
+
writeFileSync(join(repo, 'b.txt'), 'bee\n')
|
|
25
|
+
git('add', '-A')
|
|
26
|
+
git('commit', '-qm', 'base')
|
|
27
|
+
git('checkout', '-qb', 'feature')
|
|
28
|
+
writeFileSync(join(repo, 'a.txt'), 'feature side\n')
|
|
29
|
+
git('commit', '-qam', 'feature change')
|
|
30
|
+
git('checkout', '-q', 'main')
|
|
31
|
+
writeFileSync(join(repo, 'a.txt'), 'main side\n')
|
|
32
|
+
git('commit', '-qam', 'main change')
|
|
33
|
+
try {
|
|
34
|
+
git('merge', 'feature')
|
|
35
|
+
} catch {
|
|
36
|
+
/* the conflict IS the fixture */
|
|
37
|
+
}
|
|
38
|
+
writeFileSync(join(repo, 'untracked.txt'), 'u\n')
|
|
39
|
+
// A remote-tracking ref and its symbolic HEAD, so the branch split is exercised
|
|
40
|
+
// on both shapes: `origin/HEAD` is a pointer, not a branch, and must be dropped.
|
|
41
|
+
git('remote', 'add', 'origin', '/nonexistent-remote')
|
|
42
|
+
git('update-ref', 'refs/remotes/origin/main', 'HEAD')
|
|
43
|
+
try {
|
|
44
|
+
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
|
|
45
|
+
} catch {
|
|
46
|
+
/* older git: the symbolic HEAD simply will not exist */
|
|
47
|
+
}
|
|
48
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
49
|
+
|
|
50
|
+
// ---- mount ------------------------------------------------------------------
|
|
51
|
+
const routes = []
|
|
52
|
+
const ctx = {
|
|
53
|
+
effect: (fn) => fn(),
|
|
54
|
+
webServer: {
|
|
55
|
+
register: (route) => {
|
|
56
|
+
routes.push(route)
|
|
57
|
+
return () => {}
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
logger: { warn: () => {}, info: () => {} },
|
|
61
|
+
}
|
|
62
|
+
apply(ctx, { allowedRoots: [repo] })
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Drive the mounted route handler.
|
|
66
|
+
* @param path - request URL.
|
|
67
|
+
* @param options - method, JSON body, and extra headers.
|
|
68
|
+
* @returns the decoded response.
|
|
69
|
+
*/
|
|
70
|
+
function call(path, { method = 'GET', body, headers = {} } = {}, routeList = routes) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))]
|
|
73
|
+
const req = {
|
|
74
|
+
method,
|
|
75
|
+
url: path,
|
|
76
|
+
headers,
|
|
77
|
+
on(event, fn) {
|
|
78
|
+
if (event === 'data') for (const chunk of chunks) queueMicrotask(() => fn(chunk))
|
|
79
|
+
if (event === 'end') queueMicrotask(() => queueMicrotask(() => fn()))
|
|
80
|
+
return this
|
|
81
|
+
},
|
|
82
|
+
destroy() {},
|
|
83
|
+
}
|
|
84
|
+
const res = {
|
|
85
|
+
status: null,
|
|
86
|
+
writeHead(status) {
|
|
87
|
+
this.status = status
|
|
88
|
+
},
|
|
89
|
+
end(payload) {
|
|
90
|
+
try {
|
|
91
|
+
resolve({ status: this.status, body: JSON.parse(payload) })
|
|
92
|
+
} catch (error) {
|
|
93
|
+
reject(error)
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
routeList[0].handler(req, res)
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const q = encodeURIComponent(repo)
|
|
102
|
+
let failures = 0
|
|
103
|
+
const check = (label, cond, extra = '') => {
|
|
104
|
+
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
|
|
105
|
+
if (!cond) failures += 1
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log('routes:', routes.map((route) => `${route.kind} ${route.path}`).join(', '))
|
|
109
|
+
|
|
110
|
+
let response = await call('/git-api/health')
|
|
111
|
+
check('health', response.status === 200 && response.body.ok)
|
|
112
|
+
|
|
113
|
+
response = await call(`/git-api/status?root=${q}`)
|
|
114
|
+
const initial = response.body.data
|
|
115
|
+
check(
|
|
116
|
+
'status exposes branch + conflicted file',
|
|
117
|
+
response.status === 200 && initial.branch.head === 'main' && initial.conflicted.length === 1,
|
|
118
|
+
`head=${initial.branch.head} conflicted=${initial.conflicted.map((file) => file.path)}`,
|
|
119
|
+
)
|
|
120
|
+
check(
|
|
121
|
+
'untracked entries carry the same field set as every other kind',
|
|
122
|
+
initial.untracked.length === 1 &&
|
|
123
|
+
initial.untracked[0].origPath === null &&
|
|
124
|
+
'index' in initial.untracked[0] &&
|
|
125
|
+
'worktree' in initial.untracked[0],
|
|
126
|
+
JSON.stringify(initial.untracked[0]),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
response = await call('/git-api/status')
|
|
130
|
+
check(
|
|
131
|
+
'omitted root falls back to defaultRoot (cwd) and the allowlist rejects it',
|
|
132
|
+
response.status === 400 && /outside allowedRoots/.test(response.body.error),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
response = await call(`/git-api/status?root=${encodeURIComponent('/etc')}`)
|
|
136
|
+
check('allowlist rejects /etc', response.status === 400 && /outside allowedRoots/.test(response.body.error))
|
|
137
|
+
|
|
138
|
+
response = await call(`/git-api/status?root=${encodeURIComponent('/nope/nope')}`)
|
|
139
|
+
check('nonexistent path rejected', response.status === 400 && /ENOENT/.test(response.body.error))
|
|
140
|
+
|
|
141
|
+
response = await call(`/git-api/log?root=${q}&limit=50`)
|
|
142
|
+
const commits = response.body.data.commits
|
|
143
|
+
check(
|
|
144
|
+
'log parses every commit with no leading-newline damage',
|
|
145
|
+
response.status === 200 && commits.length === 2 && commits.every((commit) => /^[0-9a-f]{40}$/.test(commit.hash)),
|
|
146
|
+
`${commits.length} commits: ${commits.map((commit) => commit.subject).join(' | ')}`,
|
|
147
|
+
)
|
|
148
|
+
check(
|
|
149
|
+
'log keeps parents (graph needs them)',
|
|
150
|
+
commits.every((commit) => Array.isArray(commit.parents)),
|
|
151
|
+
`parents: ${commits.map((commit) => commit.parents.length).join(',')}`,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
response = await call(`/git-api/branches?root=${q}`)
|
|
155
|
+
const branchData = response.body.data ?? {}
|
|
156
|
+
check(
|
|
157
|
+
'branches splits local from remote and marks HEAD',
|
|
158
|
+
response.status === 200 &&
|
|
159
|
+
branchData.head === 'main' &&
|
|
160
|
+
(branchData.local ?? []).some((branch) => branch.current && branch.name === 'main') &&
|
|
161
|
+
(branchData.local ?? []).some((branch) => branch.name === 'feature'),
|
|
162
|
+
JSON.stringify(response.body).slice(0, 200),
|
|
163
|
+
)
|
|
164
|
+
check(
|
|
165
|
+
'remote-tracking refs are split by remote, and the symbolic origin/HEAD is dropped',
|
|
166
|
+
(branchData.remote ?? []).length === 1 &&
|
|
167
|
+
branchData.remote[0].remote === 'origin' &&
|
|
168
|
+
branchData.remote[0].branch === 'main' &&
|
|
169
|
+
!(branchData.remote ?? []).some((branch) => branch.name.endsWith('/HEAD')),
|
|
170
|
+
JSON.stringify(branchData.remote),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// Walking a branch other than HEAD is what makes the branch column clickable.
|
|
174
|
+
response = await call(`/git-api/log?root=${q}&ref=feature`)
|
|
175
|
+
check(
|
|
176
|
+
'log accepts a ref and walks that branch',
|
|
177
|
+
response.status === 200 && response.body.data.ref === 'feature' && response.body.data.commits[0].subject === 'feature change',
|
|
178
|
+
`ref=${response.body.data?.ref} head=${response.body.data?.commits?.[0]?.subject}`,
|
|
179
|
+
)
|
|
180
|
+
response = await call(`/git-api/log?root=${q}&ref=--all`)
|
|
181
|
+
check(
|
|
182
|
+
'a ref that looks like an option is rejected rather than reaching git',
|
|
183
|
+
response.status === 200 && response.body.data.ref === '--all' && response.body.data.commits[0].subject === 'main change',
|
|
184
|
+
'fell back to HEAD',
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
response = await call(`/git-api/diff?root=${q}&file=a.txt`)
|
|
188
|
+
check(
|
|
189
|
+
'unstaged diff returns conflict markers for the conflicted file',
|
|
190
|
+
response.status === 200 && /<<<<<<<|=======/.test(response.body.data.text),
|
|
191
|
+
`${response.body.data.text.length} chars`,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
response = await call(`/git-api/diff?root=${q}&staged=1`)
|
|
195
|
+
check('staged diff accepted', response.status === 200 && response.body.data.staged === true)
|
|
196
|
+
|
|
197
|
+
// ---- commit detail (the click-a-commit interaction) -------------------------
|
|
198
|
+
response = await call(`/git-api/commit?root=${q}&hash=HEAD`)
|
|
199
|
+
const detail = response.body.data
|
|
200
|
+
check(
|
|
201
|
+
'commit detail returns the header',
|
|
202
|
+
response.status === 200 && detail.commit.subject === 'main change' && /^[0-9a-f]{40}$/.test(detail.commit.hash),
|
|
203
|
+
`subject=${detail.commit.subject}`,
|
|
204
|
+
)
|
|
205
|
+
check(
|
|
206
|
+
'commit detail lists changed files with status',
|
|
207
|
+
detail.files.length === 1 && detail.files[0].status === 'M' && detail.files[0].path === 'a.txt',
|
|
208
|
+
JSON.stringify(detail.files),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
response = await call(`/git-api/commit?root=${q}&hash=HEAD~1`)
|
|
212
|
+
check(
|
|
213
|
+
'commit detail parses a multi-file commit',
|
|
214
|
+
response.status === 200 && response.body.data.files.length === 2,
|
|
215
|
+
JSON.stringify(response.body.data.files.map((file) => file.status + ':' + file.path)),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
response = await call(`/git-api/show?root=${q}&hash=HEAD`)
|
|
219
|
+
check(
|
|
220
|
+
'show returns the patch for a commit',
|
|
221
|
+
response.status === 200 && /main side/.test(response.body.data.text) && /^\+main side/m.test(response.body.data.text),
|
|
222
|
+
`${response.body.data.text.length} chars`,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
response = await call(`/git-api/show?root=${q}&hash=HEAD&file=a.txt`)
|
|
226
|
+
check('show accepts a pathspec', response.status === 200 && response.body.data.text.length > 0)
|
|
227
|
+
|
|
228
|
+
response = await call('/git-api/bogus')
|
|
229
|
+
check('unknown GET route -> 404', response.status === 404)
|
|
230
|
+
|
|
231
|
+
// ---- write protection -------------------------------------------------------
|
|
232
|
+
response = await call('/git-api/stage', { method: 'POST', body: { root: repo, files: ['a.txt'] } })
|
|
233
|
+
check(
|
|
234
|
+
'a write without the custom header is refused (cross-site protection)',
|
|
235
|
+
response.status === 400 && /cross-site/.test(response.body.error),
|
|
236
|
+
response.body.error,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
response = await call('/git-api/stage', { method: 'GET' })
|
|
240
|
+
check('GET on a write route -> 404 (no such read route)', response.status === 404)
|
|
241
|
+
|
|
242
|
+
response = await call('/git-api/health', { method: 'DELETE' })
|
|
243
|
+
check('unsupported method -> 405', response.status === 405)
|
|
244
|
+
|
|
245
|
+
const write = { headers: { 'x-dsh-git': '1' } }
|
|
246
|
+
|
|
247
|
+
response = await call('/git-api/stage', {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
...write,
|
|
250
|
+
body: { root: repo, files: ['untracked.txt'] },
|
|
251
|
+
})
|
|
252
|
+
check('stage accepts a write with the header', response.status === 200 && response.body.ok === true, response.body.error)
|
|
253
|
+
|
|
254
|
+
response = await call(`/git-api/status?root=${q}`)
|
|
255
|
+
check(
|
|
256
|
+
'the write invalidated the cache immediately (no watcher wait)',
|
|
257
|
+
response.body.data.staged.some((file) => file.path === 'untracked.txt'),
|
|
258
|
+
`staged: ${response.body.data.staged.map((file) => file.path).join(',')}`,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
response = await call('/git-api/unstage', {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
...write,
|
|
264
|
+
body: { root: repo, files: ['untracked.txt'] },
|
|
265
|
+
})
|
|
266
|
+
check('unstage accepts a write with the header', response.status === 200, response.body.error)
|
|
267
|
+
|
|
268
|
+
response = await call(`/git-api/status?root=${q}`)
|
|
269
|
+
check(
|
|
270
|
+
'unstage put the new file back to untracked',
|
|
271
|
+
response.body.data.untracked.some((file) => file.path === 'untracked.txt') &&
|
|
272
|
+
!response.body.data.staged.some((file) => file.path === 'untracked.txt'),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
response = await call('/git-api/stage', { method: 'POST', ...write, body: { root: repo, files: [] } })
|
|
276
|
+
check('an empty file list is refused', response.status === 400 && /non-empty/.test(response.body.error))
|
|
277
|
+
|
|
278
|
+
response = await call('/git-api/commit', { method: 'POST', ...write, body: { root: repo, message: ' ' } })
|
|
279
|
+
check('an empty commit message is refused', response.status === 400 && /must not be empty/.test(response.body.error))
|
|
280
|
+
|
|
281
|
+
// resolve the conflict and commit through the API
|
|
282
|
+
response = await call('/git-api/stage', { method: 'POST', ...write, body: { root: repo, files: ['a.txt'] } })
|
|
283
|
+
check('stage resolves the conflicted path', response.status === 200, response.body.error)
|
|
284
|
+
|
|
285
|
+
response = await call('/git-api/commit', {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
...write,
|
|
288
|
+
body: { root: repo, message: 'resolve the merge' },
|
|
289
|
+
})
|
|
290
|
+
check(
|
|
291
|
+
'commit through the API returns the new short hash',
|
|
292
|
+
response.status === 200 && /^[0-9a-f]{7,}$/.test(response.body.data.hash ?? ''),
|
|
293
|
+
`hash=${response.body.data?.hash} ${response.body.error ?? ''}`,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
response = await call(`/git-api/log?root=${q}&limit=5`)
|
|
297
|
+
check(
|
|
298
|
+
'the new commit is visible immediately',
|
|
299
|
+
response.body.data.commits[0].subject === 'resolve the merge',
|
|
300
|
+
`head=${response.body.data.commits[0].subject}`,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
response = await call(`/git-api/commit?root=${q}&hash=HEAD`)
|
|
304
|
+
check(
|
|
305
|
+
'a merge commit lists the conflicting file',
|
|
306
|
+
response.status === 200 && response.body.data.files.some((file) => file.path === 'a.txt'),
|
|
307
|
+
JSON.stringify(response.body.data.files),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
response = await call('/git-api/commit', { method: 'POST', ...write, body: { root: repo, message: 'nothing staged' } })
|
|
311
|
+
check(
|
|
312
|
+
'committing with nothing staged surfaces the git diagnostic',
|
|
313
|
+
// git prints this one on stdout, not stderr — the reason the error path
|
|
314
|
+
// checks both streams.
|
|
315
|
+
response.status === 400 && /nothing (?:added to |to )?commit|no changes/i.test(response.body.error),
|
|
316
|
+
response.body.error,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
// ---- untracked files --------------------------------------------------------
|
|
320
|
+
response = await call(`/git-api/diff?root=${q}&untracked=1&file=untracked.txt`)
|
|
321
|
+
check(
|
|
322
|
+
'an untracked file gets a synthesised all-added diff',
|
|
323
|
+
response.status === 200 && /^\+u$/m.test(response.body.data.text) && response.body.data.untracked === true,
|
|
324
|
+
response.body.error ?? `${response.body.data?.text?.length} chars`,
|
|
325
|
+
)
|
|
326
|
+
response = await call(`/git-api/diff?root=${q}&untracked=1&file=nope.txt`)
|
|
327
|
+
check('an untracked diff for a missing path is refused', response.status === 400 && /does not exist/.test(response.body.error), response.body.error)
|
|
328
|
+
response = await call(`/git-api/diff?root=${q}&untracked=1&file=../../etc/hosts`)
|
|
329
|
+
check('an untracked diff cannot escape the repository', response.status === 400 && /outside the repository/.test(response.body.error), response.body.error)
|
|
330
|
+
|
|
331
|
+
// A CJK filename must come back readable, not as git's default octal escapes.
|
|
332
|
+
writeFileSync(join(repo, '中文 文件名.txt'), '内容\n')
|
|
333
|
+
response = await call(`/git-api/diff?root=${q}&untracked=1&file=${encodeURIComponent('中文 文件名.txt')}`)
|
|
334
|
+
check(
|
|
335
|
+
'a non-ASCII path is not octal-escaped in the diff header',
|
|
336
|
+
response.status === 200 && response.body.data.text.includes('中文 文件名.txt') && !/\\3[0-7][0-7]/.test(response.body.data.text),
|
|
337
|
+
response.body.data?.text?.split('\n')[0]?.slice(0, 90),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
// ---- hunk-level staging -----------------------------------------------------
|
|
341
|
+
const hunkLines = Array.from({ length: 40 }, (_, i) => `line ${i}`)
|
|
342
|
+
writeFileSync(join(repo, 'hunks.txt'), hunkLines.join('\n') + '\n')
|
|
343
|
+
git('add', 'hunks.txt')
|
|
344
|
+
git('commit', '-qm', 'add hunks file')
|
|
345
|
+
hunkLines[2] = 'line 2 CHANGED'
|
|
346
|
+
hunkLines[30] = 'line 30 CHANGED'
|
|
347
|
+
writeFileSync(join(repo, 'hunks.txt'), hunkLines.join('\n') + '\n')
|
|
348
|
+
|
|
349
|
+
response = await call(`/git-api/diff?root=${q}&file=hunks.txt`)
|
|
350
|
+
const split = splitHunks(response.body.data.text)
|
|
351
|
+
check('two separated edits produce two hunks', split.hunks.length === 2, `${split.hunks.length} hunks`)
|
|
352
|
+
|
|
353
|
+
response = await call('/git-api/hunks', {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
...write,
|
|
356
|
+
body: { root: repo, file: 'hunks.txt', hunks: [0] },
|
|
357
|
+
})
|
|
358
|
+
check('staging a single hunk succeeds', response.status === 200, response.body.error)
|
|
359
|
+
|
|
360
|
+
const stagedHunks = (await call(`/git-api/diff?root=${q}&file=hunks.txt&staged=1`)).body.data.text
|
|
361
|
+
check(
|
|
362
|
+
'only the selected hunk reached the index',
|
|
363
|
+
/line 2 CHANGED/.test(stagedHunks) && !/line 30 CHANGED/.test(stagedHunks),
|
|
364
|
+
stagedHunks.split('\n').filter((l) => l.startsWith('+')).join(' | '),
|
|
365
|
+
)
|
|
366
|
+
const leftOver = (await call(`/git-api/diff?root=${q}&file=hunks.txt`)).body.data.text
|
|
367
|
+
check(
|
|
368
|
+
'the unselected hunk is still only in the worktree',
|
|
369
|
+
/line 30 CHANGED/.test(leftOver) && !/line 2 CHANGED/.test(leftOver),
|
|
370
|
+
leftOver.split('\n').filter((l) => l.startsWith('+')).join(' | '),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
response = await call('/git-api/hunks', {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
...write,
|
|
376
|
+
body: { root: repo, file: 'hunks.txt', hunks: [0], staged: true },
|
|
377
|
+
})
|
|
378
|
+
check('unstaging that hunk succeeds', response.status === 200, response.body.error)
|
|
379
|
+
check(
|
|
380
|
+
'the index is clean again for that file',
|
|
381
|
+
(await call(`/git-api/diff?root=${q}&file=hunks.txt&staged=1`)).body.data.text.trim() === '',
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
response = await call('/git-api/hunks', {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
...write,
|
|
387
|
+
body: { root: repo, file: 'hunks.txt', hunks: [7] },
|
|
388
|
+
})
|
|
389
|
+
check('an out-of-range hunk index is refused', response.status === 400 && /out of range/.test(response.body.error), response.body.error)
|
|
390
|
+
|
|
391
|
+
response = await call('/git-api/hunks', { method: 'POST', ...write, body: { root: repo, file: 'hunks.txt', hunks: [] } })
|
|
392
|
+
check('selecting no hunks is refused', response.status === 400 && /no hunks selected/.test(response.body.error), response.body.error)
|
|
393
|
+
|
|
394
|
+
writeFileSync(join(repo, 'hunks.txt'), hunkLines.map((l) => l.replace(' CHANGED', '')).join('\n') + '\n')
|
|
395
|
+
|
|
396
|
+
// ---- checkout and branch creation -------------------------------------------
|
|
397
|
+
response = await call('/git-api/checkout', { method: 'POST', ...write, body: { root: repo, ref: 'feature' } })
|
|
398
|
+
check('checkout switches to an existing branch', response.status === 200, response.body.error)
|
|
399
|
+
response = await call(`/git-api/status?root=${q}`)
|
|
400
|
+
check('the status reflects the new branch', response.body.data.branch.head === 'feature', response.body.data.branch.head)
|
|
401
|
+
|
|
402
|
+
response = await call('/git-api/checkout', {
|
|
403
|
+
method: 'POST',
|
|
404
|
+
...write,
|
|
405
|
+
body: { root: repo, ref: 'HEAD', create: 'topic/one' },
|
|
406
|
+
})
|
|
407
|
+
check('a new branch can be created from a start point', response.status === 200, response.body.error)
|
|
408
|
+
response = await call(`/git-api/branches?root=${q}`)
|
|
409
|
+
check(
|
|
410
|
+
'the new branch exists and is the current one',
|
|
411
|
+
response.body.data.head === 'topic/one' &&
|
|
412
|
+
response.body.data.local.some((branch) => branch.name === 'topic/one' && branch.current),
|
|
413
|
+
`head=${response.body.data.head}`,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
response = await call('/git-api/checkout', { method: 'POST', ...write, body: { root: repo, ref: 'HEAD', create: '-x' } })
|
|
417
|
+
check('a branch name that looks like an option is refused', response.status === 400 && /must not start with/.test(response.body.error), response.body.error)
|
|
418
|
+
|
|
419
|
+
response = await call('/git-api/checkout', { method: 'POST', ...write, body: { root: repo, ref: 'HEAD', create: 'bad..name' } })
|
|
420
|
+
check('git itself rejects an invalid branch name', response.status === 400 && /not a valid branch name/.test(response.body.error), response.body.error)
|
|
421
|
+
|
|
422
|
+
response = await call('/git-api/checkout', { method: 'POST', ...write, body: { root: repo, ref: '--force' } })
|
|
423
|
+
check('a ref that looks like an option is refused', response.status === 400 && /must not start with/.test(response.body.error), response.body.error)
|
|
424
|
+
|
|
425
|
+
response = await call('/git-api/checkout', { method: 'POST', ...write, body: { root: repo, ref: '' } })
|
|
426
|
+
check('an empty ref is refused', response.status === 400 && /must not be empty/.test(response.body.error), response.body.error)
|
|
427
|
+
|
|
428
|
+
response = await call('/git-api/checkout', { method: 'POST', body: { root: repo, ref: 'main' } })
|
|
429
|
+
check('checkout is a write and needs the header', response.status === 400 && /cross-site/.test(response.body.error), response.body.error)
|
|
430
|
+
|
|
431
|
+
// Back to main so the fixture's later assertions still hold.
|
|
432
|
+
response = await call('/git-api/checkout', { method: 'POST', ...write, body: { root: repo, ref: 'main' } })
|
|
433
|
+
check('checkout back to main', response.status === 200 && response.body.ok, response.body.error)
|
|
434
|
+
|
|
435
|
+
// ---- pull ------------------------------------------------------------------
|
|
436
|
+
// A local clone of a local repository: `git pull` runs offline against a real
|
|
437
|
+
// remote, so the fetch/merge path is exercised without a network.
|
|
438
|
+
const originRepo = realpathSync(mkdtempSync(join(tmpdir(), 'gitorigin-')))
|
|
439
|
+
const gitOrigin = (...args) => execFileSync('git', args, { cwd: originRepo, encoding: 'utf8' })
|
|
440
|
+
gitOrigin('init', '-q', '-b', 'main')
|
|
441
|
+
gitOrigin('config', 'user.email', 't@e.com')
|
|
442
|
+
gitOrigin('config', 'user.name', 'T')
|
|
443
|
+
writeFileSync(join(originRepo, 'f.txt'), 'one\n')
|
|
444
|
+
gitOrigin('add', '-A')
|
|
445
|
+
gitOrigin('commit', '-qm', 'origin first')
|
|
446
|
+
// A non-bare origin refuses a push to its checked-out branch; the fixture only
|
|
447
|
+
// needs the ref to move.
|
|
448
|
+
gitOrigin('config', 'receive.denyCurrentBranch', 'ignore')
|
|
449
|
+
|
|
450
|
+
const cloneRepo = realpathSync(mkdtempSync(join(tmpdir(), 'gitclone-')))
|
|
451
|
+
execFileSync('git', ['clone', '-q', originRepo, cloneRepo], { encoding: 'utf8' })
|
|
452
|
+
const gitClone = (...args) => execFileSync('git', args, { cwd: cloneRepo, encoding: 'utf8' })
|
|
453
|
+
const cloneBefore = gitClone('rev-parse', 'HEAD').trim()
|
|
454
|
+
|
|
455
|
+
const pullRoutes = (() => {
|
|
456
|
+
const localRoutes = []
|
|
457
|
+
apply(
|
|
458
|
+
{
|
|
459
|
+
effect: (fn) => fn(),
|
|
460
|
+
webServer: { register: (route) => { localRoutes.push(route); return () => {} } },
|
|
461
|
+
logger: { warn: () => {}, info: () => {} },
|
|
462
|
+
get: () => undefined,
|
|
463
|
+
},
|
|
464
|
+
{ allowedRoots: [cloneRepo] },
|
|
465
|
+
)
|
|
466
|
+
return localRoutes
|
|
467
|
+
})()
|
|
468
|
+
|
|
469
|
+
response = await call('/git-api/pull', { method: 'POST', body: { root: cloneRepo } }, pullRoutes)
|
|
470
|
+
check('pull is a write and needs the header', response.status === 400 && /cross-site/.test(response.body.error), response.body.error)
|
|
471
|
+
|
|
472
|
+
response = await call('/git-api/pull', { method: 'POST', ...write, body: { root: cloneRepo } }, pullRoutes)
|
|
473
|
+
check(
|
|
474
|
+
'pulling with nothing new reports no change',
|
|
475
|
+
response.status === 200 && response.body.data.changed === false,
|
|
476
|
+
JSON.stringify(response.body.data ?? response.body.error),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
// Advance the origin, then pull it into the clone.
|
|
480
|
+
writeFileSync(join(originRepo, 'f.txt'), 'one\ntwo\n')
|
|
481
|
+
gitOrigin('commit', '-qam', 'origin second')
|
|
482
|
+
|
|
483
|
+
response = await call('/git-api/pull', { method: 'POST', ...write, body: { root: cloneRepo } }, pullRoutes)
|
|
484
|
+
check(
|
|
485
|
+
'pulling new commits advances HEAD and reports the move',
|
|
486
|
+
response.status === 200 &&
|
|
487
|
+
response.body.data.changed === true &&
|
|
488
|
+
response.body.data.before === cloneBefore.slice(0, 7) &&
|
|
489
|
+
response.body.data.after === gitClone('rev-parse', 'HEAD').trim().slice(0, 7),
|
|
490
|
+
`${response.body.data?.before} -> ${response.body.data?.after}`,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
response = await call(`/git-api/status?root=${encodeURIComponent(cloneRepo)}`, {}, pullRoutes)
|
|
494
|
+
check('the worktree really received the change', response.body.data.branch.head === 'main')
|
|
495
|
+
|
|
496
|
+
response = await call(
|
|
497
|
+
'/git-api/pull',
|
|
498
|
+
{ method: 'POST', ...write, body: { root: cloneRepo, strategy: '--force' } },
|
|
499
|
+
pullRoutes,
|
|
500
|
+
)
|
|
501
|
+
check(
|
|
502
|
+
'an unknown strategy is refused rather than reaching git',
|
|
503
|
+
response.status === 400 && /unknown pull strategy/.test(response.body.error),
|
|
504
|
+
response.body.error,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
response = await call(
|
|
508
|
+
'/git-api/pull',
|
|
509
|
+
{ method: 'POST', ...write, body: { root: cloneRepo, strategy: 'rebase' } },
|
|
510
|
+
pullRoutes,
|
|
511
|
+
)
|
|
512
|
+
check('an enumerated strategy is accepted', response.status === 200 && response.body.data.strategy === 'rebase', response.body.error)
|
|
513
|
+
|
|
514
|
+
// A remote that demands credentials must fail immediately. Without
|
|
515
|
+
// GIT_TERMINAL_PROMPT=0 git would wait for input it can never receive, and the
|
|
516
|
+
// only thing that would end the request is the 180s network timeout.
|
|
517
|
+
const authServer = createServer((request, response) => {
|
|
518
|
+
response.writeHead(401, { 'www-authenticate': 'Basic realm="test"' })
|
|
519
|
+
response.end('auth required')
|
|
520
|
+
})
|
|
521
|
+
await new Promise((resolve) => authServer.listen(0, '127.0.0.1', resolve))
|
|
522
|
+
const authPort = authServer.address().port
|
|
523
|
+
try {
|
|
524
|
+
gitClone('remote', 'add', 'auth', `http://127.0.0.1:${authPort}/repo.git`)
|
|
525
|
+
gitClone('config', 'branch.main.remote', 'auth')
|
|
526
|
+
gitClone('config', 'branch.main.merge', 'refs/heads/main')
|
|
527
|
+
const startedAt = Date.now()
|
|
528
|
+
response = await call('/git-api/pull', { method: 'POST', ...write, body: { root: cloneRepo } }, pullRoutes)
|
|
529
|
+
const elapsedMs = Date.now() - startedAt
|
|
530
|
+
check(
|
|
531
|
+
'a credential prompt fails fast instead of hanging until the timeout',
|
|
532
|
+
response.status === 400 && elapsedMs < 20_000 && /prompts disabled|Authentication failed|could not read/i.test(response.body.error),
|
|
533
|
+
`${elapsedMs}ms — ${(response.body.error ?? '').split('\n')[0]}`,
|
|
534
|
+
)
|
|
535
|
+
} finally {
|
|
536
|
+
authServer.close()
|
|
537
|
+
gitClone('config', 'branch.main.remote', 'origin')
|
|
538
|
+
gitClone('config', 'branch.main.merge', 'refs/heads/main')
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---- push -------------------------------------------------------------------
|
|
542
|
+
response = await call('/git-api/push', { method: 'POST', body: { root: cloneRepo } }, pullRoutes)
|
|
543
|
+
check('push is a write and needs the header', response.status === 400 && /cross-site/.test(response.body.error), response.body.error)
|
|
544
|
+
|
|
545
|
+
writeFileSync(join(cloneRepo, 'f.txt'), 'one\ntwo\nlocal\n')
|
|
546
|
+
gitClone('commit', '-qam', 'local commit')
|
|
547
|
+
response = await call('/git-api/push', { method: 'POST', ...write, body: { root: cloneRepo } }, pullRoutes)
|
|
548
|
+
check(
|
|
549
|
+
'pushing a local commit moves the upstream and reports the change',
|
|
550
|
+
response.status === 200 && response.body.data.changed === true,
|
|
551
|
+
JSON.stringify(response.body.data ?? response.body.error),
|
|
552
|
+
)
|
|
553
|
+
check('the remote really received it', gitOrigin('log', '-1', '--format=%s').trim() === 'local commit')
|
|
554
|
+
|
|
555
|
+
response = await call('/git-api/push', { method: 'POST', ...write, body: { root: cloneRepo } }, pullRoutes)
|
|
556
|
+
check(
|
|
557
|
+
'pushing with nothing new reports no change',
|
|
558
|
+
response.status === 200 && response.body.data.changed === false,
|
|
559
|
+
`changed=${response.body.data?.changed} output=${JSON.stringify(response.body.data?.output?.trim())}`,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
// A branch with no upstream cannot be pushed until it is published.
|
|
563
|
+
gitClone('checkout', '-q', '-b', 'topic/push')
|
|
564
|
+
writeFileSync(join(cloneRepo, 'g.txt'), 'g\n')
|
|
565
|
+
gitClone('add', '-A')
|
|
566
|
+
gitClone('commit', '-qm', 'topic commit')
|
|
567
|
+
response = await call('/git-api/push', { method: 'POST', ...write, body: { root: cloneRepo } }, pullRoutes)
|
|
568
|
+
check(
|
|
569
|
+
'a branch without an upstream fails with git\'s own guidance',
|
|
570
|
+
response.status === 400 && /no upstream branch|set-upstream/i.test(response.body.error),
|
|
571
|
+
(response.body.error ?? '').split('\n')[0],
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
response = await call(
|
|
575
|
+
'/git-api/push',
|
|
576
|
+
{ method: 'POST', ...write, body: { root: cloneRepo, setUpstream: true } },
|
|
577
|
+
pullRoutes,
|
|
578
|
+
)
|
|
579
|
+
check(
|
|
580
|
+
'publishing a branch sets its upstream',
|
|
581
|
+
response.status === 200 && response.body.data.published === true && response.body.data.changed === true,
|
|
582
|
+
JSON.stringify(response.body.data ?? response.body.error),
|
|
583
|
+
)
|
|
584
|
+
check(
|
|
585
|
+
'the branch now tracks the remote',
|
|
586
|
+
gitClone('rev-parse', '--abbrev-ref', '@{upstream}').trim() === 'origin/topic/push',
|
|
587
|
+
gitClone('rev-parse', '--abbrev-ref', '@{upstream}').trim(),
|
|
588
|
+
)
|
|
589
|
+
check('the remote received the new branch', gitOrigin('branch', '--format=%(refname:short)').includes('topic/push'))
|
|
590
|
+
|
|
591
|
+
// A detached HEAD has no branch to push.
|
|
592
|
+
gitClone('checkout', '-q', '--detach')
|
|
593
|
+
response = await call('/git-api/push', { method: 'POST', ...write, body: { root: cloneRepo } }, pullRoutes)
|
|
594
|
+
check('a detached HEAD is refused', response.status === 400 && /detached/.test(response.body.error), response.body.error)
|
|
595
|
+
gitClone('checkout', '-q', 'main')
|
|
596
|
+
|
|
597
|
+
// ---- review since a marker --------------------------------------------------
|
|
598
|
+
// Two commits plus an uncommitted edit, so the review has to cover both.
|
|
599
|
+
git('checkout', '-q', 'main')
|
|
600
|
+
const reviewMarker = git('rev-parse', 'HEAD').trim()
|
|
601
|
+
writeFileSync(join(repo, 'a.txt'), 'review one\n')
|
|
602
|
+
git('add', 'a.txt')
|
|
603
|
+
git('commit', '-qm', 'reviewed commit one')
|
|
604
|
+
writeFileSync(join(repo, 'rev.txt'), 'r\n')
|
|
605
|
+
git('add', 'rev.txt')
|
|
606
|
+
git('commit', '-qm', 'reviewed commit two')
|
|
607
|
+
writeFileSync(join(repo, 'a.txt'), 'review one\nUNCOMMITTED\n')
|
|
608
|
+
|
|
609
|
+
response = await call(`/git-api/review?root=${q}&since=${reviewMarker}`)
|
|
610
|
+
const review = response.body.data
|
|
611
|
+
check('review resolves against a marker', response.status === 200, response.body.error)
|
|
612
|
+
check('review reports the commits since the marker', review.commits.length === 2, `${review.commits.length} commits`)
|
|
613
|
+
check(
|
|
614
|
+
'review counts committed and uncommitted files together',
|
|
615
|
+
review.files.length === 2 && review.files.some((file) => file.path === 'rev.txt') && review.files.some((file) => file.path === 'a.txt'),
|
|
616
|
+
JSON.stringify(review.files.map((file) => `${file.status}:${file.path}`)),
|
|
617
|
+
)
|
|
618
|
+
check(
|
|
619
|
+
'review totals add up',
|
|
620
|
+
review.totals.files === 2 && review.totals.added >= 2,
|
|
621
|
+
JSON.stringify(review.totals),
|
|
622
|
+
)
|
|
623
|
+
check('review echoes the head it observed', /^[0-9a-f]{7,}$/.test(review.head), review.head)
|
|
624
|
+
|
|
625
|
+
response = await call(`/git-api/diff?root=${q}&file=a.txt&since=${reviewMarker}`)
|
|
626
|
+
check(
|
|
627
|
+
'a single file can be diffed against the marker',
|
|
628
|
+
response.status === 200 && response.body.data.text.includes('UNCOMMITTED'),
|
|
629
|
+
`${response.body.data?.text?.length} chars`,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
response = await call(`/git-api/review?root=${q}`)
|
|
633
|
+
check('review without a marker is refused', response.status === 400 && /since marker/.test(response.body.error), response.body.error)
|
|
634
|
+
response = await call(`/git-api/review?root=${q}&since=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`)
|
|
635
|
+
check(
|
|
636
|
+
'a marker that no longer resolves is reported rather than looking empty',
|
|
637
|
+
response.status === 400 && /unknown revision|bad object|Not a valid object|Needed a single revision/i.test(response.body.error),
|
|
638
|
+
(response.body.error ?? '').split('\n')[0],
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
// The parsers are the fiddly part; pin their shapes directly.
|
|
642
|
+
check(
|
|
643
|
+
'numstat parses an ordinary record',
|
|
644
|
+
JSON.stringify(parseNumstat('3\t1\tsrc/a.js\0')) === JSON.stringify([{ path: 'src/a.js', origPath: null, added: 3, deleted: 1, binary: false }]),
|
|
645
|
+
)
|
|
646
|
+
check(
|
|
647
|
+
'numstat parses a rename (empty path field, then both paths)',
|
|
648
|
+
JSON.stringify(parseNumstat('1\t1\t\0old.js\0new.js\0')) ===
|
|
649
|
+
JSON.stringify([{ path: 'new.js', origPath: 'old.js', added: 1, deleted: 1, binary: false }]),
|
|
650
|
+
)
|
|
651
|
+
check(
|
|
652
|
+
'numstat marks a binary file',
|
|
653
|
+
JSON.stringify(parseNumstat('-\t-\tlogo.png\0')) ===
|
|
654
|
+
JSON.stringify([{ path: 'logo.png', origPath: null, added: null, deleted: null, binary: true }]),
|
|
655
|
+
)
|
|
656
|
+
check(
|
|
657
|
+
'numstat keeps a path that itself contains a tab',
|
|
658
|
+
parseNumstat('1\t0\tweird\tname.txt\0')[0].path === 'weird\tname.txt',
|
|
659
|
+
parseNumstat('1\t0\tweird\tname.txt\0')[0].path,
|
|
660
|
+
)
|
|
661
|
+
check(
|
|
662
|
+
'name-status parses a rename',
|
|
663
|
+
parseNameStatus('R100\0old.js\0new.js\0').get('new.js').origPath === 'old.js',
|
|
664
|
+
)
|
|
665
|
+
check('name-status parses a plain change', parseNameStatus('M\0a.js\0').get('a.js').status === 'M')
|
|
666
|
+
|
|
667
|
+
// Leave a clean worktree: later assertions measure unstaged changes.
|
|
668
|
+
git('checkout', '--', 'a.txt')
|
|
669
|
+
|
|
670
|
+
// ---- registered workspaces extend the allowlist ------------------------------
|
|
671
|
+
// The panel follows the session's repository, which is usually outside
|
|
672
|
+
// `allowedRoots`. Rather than widening that to every path, the host also trusts
|
|
673
|
+
// directories the harness itself has registered as workspaces.
|
|
674
|
+
const otherRepo = realpathSync(mkdtempSync(join(tmpdir(), 'gitws-')))
|
|
675
|
+
const gitOther = (...args) => execFileSync('git', args, { cwd: otherRepo, encoding: 'utf8' })
|
|
676
|
+
gitOther('init', '-q', '-b', 'main')
|
|
677
|
+
gitOther('config', 'user.email', 't@e.com')
|
|
678
|
+
gitOther('config', 'user.name', 'T')
|
|
679
|
+
writeFileSync(join(otherRepo, 'x.txt'), 'x\n')
|
|
680
|
+
gitOther('add', '-A')
|
|
681
|
+
gitOther('commit', '-qm', 'base')
|
|
682
|
+
|
|
683
|
+
response = await call(`/git-api/status?root=${encodeURIComponent(otherRepo)}`)
|
|
684
|
+
check(
|
|
685
|
+
'a repository outside allowedRoots is still refused without a workspace entry',
|
|
686
|
+
response.status === 400 && /not a known workspace/.test(response.body.error),
|
|
687
|
+
response.body.error,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
/** Mount a second instance with its own route table and a workspace registry. */
|
|
691
|
+
function mountWith(registry) {
|
|
692
|
+
const localRoutes = []
|
|
693
|
+
const localCtx = {
|
|
694
|
+
effect: (fn) => fn(),
|
|
695
|
+
webServer: {
|
|
696
|
+
register: (route) => {
|
|
697
|
+
localRoutes.push(route)
|
|
698
|
+
return () => {}
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
logger: { warn: () => {}, info: () => {} },
|
|
702
|
+
get: (name) => (name === 'workspaceRegistry' ? registry : undefined),
|
|
703
|
+
}
|
|
704
|
+
apply(localCtx, { allowedRoots: ['/definitely-not-a-repo'] })
|
|
705
|
+
return localRoutes
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const withRegistry = mountWith({ list: () => [{ path: otherRepo }] })
|
|
709
|
+
response = await call(`/git-api/status?root=${encodeURIComponent(otherRepo)}`, {}, withRegistry)
|
|
710
|
+
check(
|
|
711
|
+
'a registered workspace is trusted even when allowedRoots excludes it',
|
|
712
|
+
response.status === 200 && response.body.data.branch.head === 'main',
|
|
713
|
+
response.body.error ?? `head=${response.body.data?.branch?.head}`,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
const wrongNameRegistry = (() => {
|
|
717
|
+
const localRoutes = []
|
|
718
|
+
apply(
|
|
719
|
+
{
|
|
720
|
+
effect: (fn) => fn(),
|
|
721
|
+
webServer: { register: (route) => { localRoutes.push(route); return () => {} } },
|
|
722
|
+
logger: { warn: () => {}, info: () => {} },
|
|
723
|
+
get: (name) => (name === 'workspace' ? { list: () => [{ path: otherRepo }] } : undefined),
|
|
724
|
+
},
|
|
725
|
+
{ allowedRoots: ['/definitely-not-a-repo'] },
|
|
726
|
+
)
|
|
727
|
+
return localRoutes
|
|
728
|
+
})()
|
|
729
|
+
response = await call(`/git-api/status?root=${encodeURIComponent(otherRepo)}`, {}, wrongNameRegistry)
|
|
730
|
+
check(
|
|
731
|
+
'the registry is looked up under its real service name',
|
|
732
|
+
response.status === 400,
|
|
733
|
+
'a registry exposed only under a guessed name must not be consulted',
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
const withoutRegistry = mountWith(undefined)
|
|
737
|
+
response = await call(`/git-api/status?root=${encodeURIComponent(otherRepo)}`, {}, withoutRegistry)
|
|
738
|
+
check(
|
|
739
|
+
'no registry means the workspace entry is simply absent',
|
|
740
|
+
response.status === 400,
|
|
741
|
+
response.body.error,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
const throwingRegistry = mountWith({ list: () => { throw new Error('registry unavailable') } })
|
|
745
|
+
response = await call(`/git-api/status?root=${encodeURIComponent(otherRepo)}`, {}, throwingRegistry)
|
|
746
|
+
check(
|
|
747
|
+
'a throwing workspace registry does not take the route down',
|
|
748
|
+
response.status === 400 && /not a known workspace/.test(response.body.error),
|
|
749
|
+
response.body.error,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
// ---- watcher-driven invalidation (out-of-band writes) -----------------------
|
|
753
|
+
const before = (await call(`/git-api/status?root=${q}`)).body.data
|
|
754
|
+
writeFileSync(join(repo, 'a.txt'), 'changed outside the panel\n')
|
|
755
|
+
await sleep(400)
|
|
756
|
+
const after = (await call(`/git-api/status?root=${q}`)).body.data
|
|
757
|
+
check(
|
|
758
|
+
'watcher drops the cached status after an out-of-band worktree write',
|
|
759
|
+
before.unstaged.length === 0 && after.unstaged.length === 1,
|
|
760
|
+
`unstaged ${before.unstaged.length}->${after.unstaged.length}`,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
// ---- a linked worktree keeps its git directory in a FILE --------------------
|
|
764
|
+
// `<worktree>/.git` is a one-line pointer to `<repo>/.git/worktrees/<name>`, and
|
|
765
|
+
// it never changes. Watching THAT file means a checkout in the worktree goes
|
|
766
|
+
// unnoticed and the cache keeps serving the old branch forever — which is
|
|
767
|
+
// exactly what the composer chip reads.
|
|
768
|
+
const linkedRoot = realpathSync(mkdtempSync(join(tmpdir(), 'gitwt-')))
|
|
769
|
+
const linked = join(linkedRoot, 'wt')
|
|
770
|
+
execFileSync('git', ['worktree', 'add', '-q', linked, '-b', 'linked-work'], { cwd: repo })
|
|
771
|
+
check(
|
|
772
|
+
'the fixture really is a linked worktree',
|
|
773
|
+
statSync(join(linked, '.git')).isFile(),
|
|
774
|
+
'a linked worktree keeps .git as a file',
|
|
775
|
+
)
|
|
776
|
+
const linkedRoutes = []
|
|
777
|
+
apply(
|
|
778
|
+
{
|
|
779
|
+
effect: (fn) => fn(),
|
|
780
|
+
webServer: { register: (route) => { linkedRoutes.push(route); return () => {} } },
|
|
781
|
+
logger: { warn: () => {}, info: () => {} },
|
|
782
|
+
},
|
|
783
|
+
{ allowedRoots: [linkedRoot] },
|
|
784
|
+
)
|
|
785
|
+
const linkedRoute = linkedRoutes.find((route) => route.path === '/git-api')
|
|
786
|
+
const callLinked = (path) =>
|
|
787
|
+
new Promise((resolve) => {
|
|
788
|
+
const req = {
|
|
789
|
+
method: 'GET',
|
|
790
|
+
url: path,
|
|
791
|
+
headers: {},
|
|
792
|
+
on(event, fn) {
|
|
793
|
+
if (event === 'end') queueMicrotask(() => fn())
|
|
794
|
+
return this
|
|
795
|
+
},
|
|
796
|
+
}
|
|
797
|
+
const res = { writeHead() {}, end(body) { resolve({ status: 200, body: JSON.parse(body) }) } }
|
|
798
|
+
linkedRoute.handler(req, res)
|
|
799
|
+
})
|
|
800
|
+
const linkedQ = encodeURIComponent(linked)
|
|
801
|
+
const linkedBefore = (await callLinked(`/git-api/branches?root=${linkedQ}`)).body.data
|
|
802
|
+
execFileSync('git', ['checkout', '-q', '-b', 'linked-other'], { cwd: linked })
|
|
803
|
+
await sleep(400)
|
|
804
|
+
const linkedAfter = (await callLinked(`/git-api/branches?root=${linkedQ}`)).body.data
|
|
805
|
+
check(
|
|
806
|
+
'a linked worktree reports its branch before the switch',
|
|
807
|
+
linkedBefore.head === 'linked-work',
|
|
808
|
+
String(linkedBefore.head),
|
|
809
|
+
)
|
|
810
|
+
check(
|
|
811
|
+
'a checkout inside a linked worktree drops the cache',
|
|
812
|
+
linkedAfter.head === 'linked-other',
|
|
813
|
+
String(linkedAfter.head),
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`)
|
|
817
|
+
process.exit(failures === 0 ? 0 : 1)
|