@ban-sky/dsh-github 0.0.0-stage → 0.4.2

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/lib/git.js ADDED
@@ -0,0 +1,677 @@
1
+ /**
2
+ * Git operations for the plugin.
3
+ *
4
+ * All network operations go through `ctx.subprocess` with:
5
+ * - the token handed to `git` only through the child environment, read by an
6
+ * inline `!shell` credential helper (never argv, never a remote URL, never
7
+ * git config, nothing written to disk);
8
+ * - `GIT_TERMINAL_PROMPT=0` so git can never block on an interactive prompt;
9
+ * - `-c credential.helper=` first, so no globally configured credential store
10
+ * is consulted or written;
11
+ * - a hard timeout and the caller's abort signal.
12
+ *
13
+ * Local-only reads never receive the token environment at all.
14
+ *
15
+ * @module dsh-github/lib/git
16
+ */
17
+
18
+ import { lstat, mkdir, readdir, realpath, rm } from 'node:fs/promises'
19
+ import { dirname, join } from 'node:path'
20
+ import { GithubError } from './errors.js'
21
+ import { assertInside, isInside, parseFullName } from './validate.js'
22
+
23
+ /**
24
+ * Inline credential helper. Git runs it through `sh -c` for HTTPS
25
+ * authentication; the token is read from the child environment set by
26
+ * {@link runGit}, so it never appears in argv or on disk.
27
+ *
28
+ * The helper is written as a `!shell` snippet because `/tmp` is commonly
29
+ * mounted noexec, which would break execution of a `GIT_ASKPASS` script. It
30
+ * only answers `get`, so nothing is ever stored.
31
+ */
32
+ const CREDENTIAL_HELPER =
33
+ '!f() { [ "$1" = get ] && { printf "%s\\n" "username=x-access-token" "password=$DSH_GITHUB_TOKEN"; }; }; f'
34
+
35
+ /** Default timeout for a local git command (ms). */
36
+ const LOCAL_TIMEOUT_MS = 30000
37
+ /** Timeout for a network git command (ms). */
38
+ const NETWORK_TIMEOUT_MS = 180000
39
+
40
+ /**
41
+ * Result of one git invocation.
42
+ *
43
+ * @typedef {object} GitResult
44
+ * @property {number | null} exitCode - Process exit code (`null` when killed).
45
+ * @property {string} stdout - Collected stdout.
46
+ * @property {string} stderr - Collected stderr.
47
+ */
48
+
49
+ /**
50
+ * Run git with the plugin's safety flags.
51
+ *
52
+ * @param {{subprocess: any}} ctx - Host context carrying `subprocess`.
53
+ * @param {string[]} argv - Arguments after `git`.
54
+ * @param {object} [options] - Run options.
55
+ * @param {string} [options.cwd] - Working directory.
56
+ * @param {string} [options.token] - Token that enables the credential helper.
57
+ * @param {AbortSignal} [options.signal] - Caller cancellation.
58
+ * @param {number} [options.timeoutMs] - Hard timeout.
59
+ * @returns {Promise<GitResult>}
60
+ */
61
+ export async function runGit(ctx, argv, options = {}) {
62
+ const { cwd, token, signal, timeoutMs = LOCAL_TIMEOUT_MS } = options
63
+ const withToken = typeof token === 'string' && token !== ''
64
+ /** @type {Record<string, string>} */
65
+ const env = { GIT_TERMINAL_PROMPT: '0' }
66
+ if (withToken) env.DSH_GITHUB_TOKEN = token
67
+ // Clear every configured helper (no keychain, no ~/.git-credentials) and, when
68
+ // a token is present, answer the HTTPS prompt from the environment only.
69
+ const credentialArgs = withToken
70
+ ? ['-c', 'credential.helper=', '-c', `credential.helper=${CREDENTIAL_HELPER}`]
71
+ : ['-c', 'credential.helper=']
72
+
73
+ const timeoutController = new AbortController()
74
+ const timer = setTimeout(() => timeoutController.abort(new Error('git timed out')), timeoutMs)
75
+ const combined = signal === undefined ? timeoutController.signal : AbortSignal.any([signal, timeoutController.signal])
76
+
77
+ try {
78
+ const handle = ctx.subprocess.spawn({
79
+ argv: ['git', ...credentialArgs, ...argv],
80
+ // The subprocess service requires an explicit cwd; every git command here
81
+ // addresses its repository explicitly, so the host's cwd is only a floor.
82
+ cwd: cwd ?? process.cwd(),
83
+ stdio: {
84
+ stdin: 'ignore',
85
+ stdout: { maxBytes: 1 << 20 },
86
+ stderr: { maxBytes: 1 << 20 },
87
+ },
88
+ graceMs: 5000,
89
+ signal: combined,
90
+ env,
91
+ })
92
+ const outcome = await handle.done
93
+ return {
94
+ exitCode: typeof outcome?.exitCode === 'number' ? outcome.exitCode : null,
95
+ stdout: handle.collected?.stdout?.readFrom(0).text ?? '',
96
+ stderr: handle.collected?.stderr?.readFrom(0).text ?? '',
97
+ }
98
+ } catch (error) {
99
+ if (timeoutController.signal.aborted || signal?.aborted === true) {
100
+ throw new GithubError('git-timeout', 'The git operation timed out or was cancelled.')
101
+ }
102
+ const message = error instanceof Error ? error.message : String(error)
103
+ if (/ENOENT|not found|spawn/i.test(message)) {
104
+ throw new GithubError('git-missing', `Could not run git: ${message}`, {}, { cause: error })
105
+ }
106
+ throw new GithubError('git-failed', `Could not run git: ${message}`, {}, { cause: error })
107
+ } finally {
108
+ clearTimeout(timer)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Build a GithubError from a failed git result.
114
+ *
115
+ * @param {GitResult} result - Failed invocation.
116
+ * @param {string} what - What was being attempted.
117
+ * @returns {GithubError}
118
+ */
119
+ function gitFailure(result, what) {
120
+ const detail = `${result.stderr}\n${result.stdout}`.trim().slice(-2000)
121
+ return new GithubError(
122
+ 'git-failed',
123
+ detail === '' ? `${what} failed (exit ${result.exitCode ?? 'unknown'}).` : `${what} failed: ${detail}`,
124
+ { exitCode: result.exitCode },
125
+ )
126
+ }
127
+
128
+ /**
129
+ * Parse an `https://github.com/owner/name(.git)` remote URL.
130
+ *
131
+ * @param {string} value - Remote URL.
132
+ * @returns {{owner: string, name: string, url: string, hasCredentials: boolean} | null}
133
+ */
134
+ export function parseGithubRemote(value) {
135
+ const raw = typeof value === 'string' ? value.trim() : ''
136
+ if (raw === '') return null
137
+ let parsed
138
+ try {
139
+ parsed = new URL(raw)
140
+ } catch {
141
+ return null
142
+ }
143
+ if (parsed.protocol !== 'https:') return null
144
+ if (parsed.hostname.toLowerCase() !== 'github.com') return null
145
+ let path = parsed.pathname.replace(/^\/+/, '').replace(/\/+$/, '')
146
+ if (path.endsWith('.git')) path = path.slice(0, -4)
147
+ const parts = path.split('/')
148
+ if (parts.length !== 2) return null
149
+ const [owner, name] = parts
150
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner)) return null
151
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) return null
152
+ return {
153
+ owner,
154
+ name,
155
+ url: `https://github.com/${owner}/${name}.git`,
156
+ hasCredentials: parsed.username !== '' || parsed.password !== '',
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Strip userinfo from a URL for display.
162
+ *
163
+ * @param {string} value - Raw URL.
164
+ * @returns {string}
165
+ */
166
+ export function displayRemote(value) {
167
+ return String(value)
168
+ .replace(/^(https?:\/\/)[^/@]+@/i, '$1')
169
+ .replace(/^(git@|ssh:\/\/[^/@]+@)/i, 'ssh:')
170
+ }
171
+
172
+ /**
173
+ * Read `remote.origin.url`.
174
+ *
175
+ * @param {{subprocess: any}} ctx - Host context.
176
+ * @param {string} path - Repository path.
177
+ * @param {AbortSignal} [signal] - Cancellation.
178
+ * @returns {Promise<string>}
179
+ */
180
+ async function readRemote(ctx, path, signal) {
181
+ const result = await runGit(ctx, ['-C', path, 'config', '--get', 'remote.origin.url'], { signal })
182
+ return result.exitCode === 0 ? result.stdout.trim() : ''
183
+ }
184
+
185
+ /**
186
+ * Refuse network operations on remotes we cannot verify.
187
+ *
188
+ * @param {{subprocess: any}} ctx - Host context.
189
+ * @param {string} path - Repository path.
190
+ * @param {AbortSignal} [signal] - Cancellation.
191
+ * @returns {Promise<{owner: string, name: string, url: string}>}
192
+ */
193
+ async function assertGithubRemote(ctx, path, signal) {
194
+ const remote = await readRemote(ctx, path, signal)
195
+ if (remote === '') {
196
+ throw new GithubError('no-remote', 'This repository has no "origin" remote.')
197
+ }
198
+ const parsed = parseGithubRemote(remote)
199
+ if (parsed === null) {
200
+ throw new GithubError(
201
+ 'unsupported-remote',
202
+ 'Only https://github.com/<owner>/<repo> remotes are supported for pull and push.',
203
+ )
204
+ }
205
+ if (parsed.hasCredentials) {
206
+ throw new GithubError(
207
+ 'remote-has-credentials',
208
+ 'The origin URL embeds a credential. Remove it with `git remote set-url origin https://github.com/<owner>/<repo>.git`, then retry.',
209
+ )
210
+ }
211
+ return { owner: parsed.owner, name: parsed.name, url: parsed.url }
212
+ }
213
+
214
+ /**
215
+ * Parse the first line of `git status --porcelain=v1 --branch`.
216
+ *
217
+ * @param {string} header - Line starting with `## `.
218
+ * @returns {{branch: string, detached: boolean, upstream: string, ahead: number, behind: number}}
219
+ */
220
+ function parseStatusHeader(header) {
221
+ const text = header.replace(/^##\s?/, '')
222
+ const noCommits = /^No commits yet on (.+)$/.exec(text)
223
+ if (noCommits !== null) {
224
+ return { branch: noCommits[1], detached: false, upstream: '', ahead: 0, behind: 0 }
225
+ }
226
+ if (/^HEAD \(no branch\)/.test(text)) {
227
+ return { branch: 'HEAD', detached: true, upstream: '', ahead: 0, behind: 0 }
228
+ }
229
+ const split = text.split('...')
230
+ const branch = split[0] ?? ''
231
+ const right = split.length > 1 ? split.slice(1).join('...') : ''
232
+ const trackMatch = /^(\S+?)(?:\s+\[(.+)\])?$/.exec(right)
233
+ const upstream = trackMatch?.[1] ?? ''
234
+ const track = trackMatch?.[2] ?? ''
235
+ const aheadMatch = /ahead (\d+)/.exec(track)
236
+ const behindMatch = /behind (\d+)/.exec(track)
237
+ return {
238
+ branch,
239
+ detached: false,
240
+ upstream,
241
+ ahead: aheadMatch === null ? 0 : Number(aheadMatch[1]),
242
+ behind: behindMatch === null ? 0 : Number(behindMatch[1]),
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Read the last commit of a repository.
248
+ *
249
+ * @param {{subprocess: any}} ctx - Host context.
250
+ * @param {string} path - Repository path.
251
+ * @param {AbortSignal} [signal] - Cancellation.
252
+ * @returns {Promise<{shortHash: string, subject: string, author: string, date: string} | null>}
253
+ */
254
+ async function readLastCommit(ctx, path, signal) {
255
+ const result = await runGit(ctx, ['-C', path, 'log', '-1', '--format=%h%x1f%s%x1f%an%x1f%cI'], {
256
+ signal,
257
+ timeoutMs: LOCAL_TIMEOUT_MS,
258
+ })
259
+ if (result.exitCode !== 0) return null
260
+ const [shortHash = '', subject = '', author = '', date = ''] = result.stdout.trim().split('\u001f')
261
+ return { shortHash, subject, author, date }
262
+ }
263
+
264
+ /**
265
+ * Describe one repository: branch, cleanliness, ahead/behind, last commit and
266
+ * the sanitized origin remote.
267
+ *
268
+ * @param {{subprocess: any}} ctx - Host context.
269
+ * @param {object} input - Repository to inspect.
270
+ * @param {string} input.path - Absolute repository path.
271
+ * @param {AbortSignal} [input.signal] - Cancellation.
272
+ * @returns {Promise<object>}
273
+ */
274
+ export async function readStatus(ctx, { path, signal }) {
275
+ const [statusResult, lastCommit, remote] = await Promise.all([
276
+ runGit(ctx, ['-C', path, 'status', '--porcelain=v1', '--branch'], { signal }),
277
+ readLastCommit(ctx, path, signal),
278
+ readRemote(ctx, path, signal),
279
+ ])
280
+ if (statusResult.exitCode !== 0) throw gitFailure(statusResult, 'reading repository status')
281
+ const lines = statusResult.stdout.split('\n').filter((line) => line !== '')
282
+ const header = parseStatusHeader(lines[0] ?? '## ')
283
+ const entries = lines.slice(1)
284
+ const parsedRemote = parseGithubRemote(remote)
285
+ return {
286
+ path,
287
+ branch: header.branch,
288
+ detached: header.detached,
289
+ upstream: header.upstream,
290
+ ahead: header.ahead,
291
+ behind: header.behind,
292
+ clean: entries.length === 0,
293
+ changeCount: entries.length,
294
+ changes: entries.slice(0, 100),
295
+ lastCommit,
296
+ remote:
297
+ remote === ''
298
+ ? null
299
+ : parsedRemote === null
300
+ ? { owner: '', name: '', url: displayRemote(remote), unsupported: true }
301
+ : { owner: parsedRemote.owner, name: parsedRemote.name, url: parsedRemote.url },
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Prepare `<root>/<owner>/<name>` for a clone, refusing symlinked segments and
307
+ * non-empty targets.
308
+ *
309
+ * @param {string} root - Existing clone root.
310
+ * @param {string} owner - Validated owner.
311
+ * @param {string} name - Validated repository name.
312
+ * @returns {Promise<string>} Absolute directory that is ready to receive the clone.
313
+ */
314
+ export async function prepareCloneTarget(root, owner, name) {
315
+ const realRoot = await realpath(root)
316
+ let current = realRoot
317
+ for (const part of [owner, name]) {
318
+ const next = join(current, part)
319
+ const info = await lstat(next).catch(() => null)
320
+ if (info === null) {
321
+ await mkdir(next, { mode: 0o755 })
322
+ } else if (info.isSymbolicLink()) {
323
+ throw new GithubError('unsafe-path', 'Refusing to clone through a symlinked directory.')
324
+ } else if (!info.isDirectory()) {
325
+ throw new GithubError('target-exists', `A non-directory already exists at ${next}.`)
326
+ }
327
+ current = await realpath(next)
328
+ if (!isInside(realRoot, current)) {
329
+ throw new GithubError('unsafe-path', 'Refusing to clone outside the configured clone root.')
330
+ }
331
+ }
332
+ const entries = await readdir(current)
333
+ if (entries.length > 0) {
334
+ throw new GithubError('target-exists', 'The clone target already exists and is not empty.')
335
+ }
336
+ return current
337
+ }
338
+
339
+ /**
340
+ * Clone `https://github.com/<owner>/<name>.git` into `<root>/<owner>/<name>`.
341
+ *
342
+ * @param {{subprocess: any}} ctx - Host context.
343
+ * @param {object} input - Clone input.
344
+ * @param {string} input.token - Access token.
345
+ * @param {string} input.fullName - `owner/name`.
346
+ * @param {string} input.root - Clone root.
347
+ * @param {AbortSignal} [input.signal] - Cancellation.
348
+ * @returns {Promise<string>} Absolute path of the new clone.
349
+ */
350
+ export async function cloneRepository(ctx, { token, fullName, root, signal }) {
351
+ const { owner, name } = parseFullName(fullName)
352
+ await mkdir(root, { recursive: true })
353
+ const target = await prepareCloneTarget(root, owner, name)
354
+ const url = `https://github.com/${owner}/${name}.git`
355
+ const result = await runGit(ctx, ['clone', '--quiet', '--', url, target], {
356
+ cwd: dirname(target),
357
+ token,
358
+ signal,
359
+ timeoutMs: NETWORK_TIMEOUT_MS,
360
+ })
361
+ if (result.exitCode !== 0) {
362
+ await rm(target, { recursive: true, force: true }).catch(() => {})
363
+ const text = `${result.stderr}\n${result.stdout}`
364
+ if (/Authentication failed|could not read Username|invalid username or password/i.test(text)) {
365
+ throw new GithubError('token-invalid', 'GitHub rejected the credentials. Sign in again.')
366
+ }
367
+ if (/not found|does not exist|Repository not found/i.test(text)) {
368
+ throw new GithubError('not-found', 'The repository was not found (or the token has no access).')
369
+ }
370
+ throw gitFailure(result, 'clone failed')
371
+ }
372
+ const realTarget = await realpath(target)
373
+ assertInside(await realpath(root), realTarget)
374
+ return realTarget
375
+ }
376
+
377
+ /**
378
+ * Fast-forward pull of the current branch.
379
+ *
380
+ * @param {{subprocess: any}} ctx - Host context.
381
+ * @param {object} input - Pull input.
382
+ * @param {string} input.path - Repository path.
383
+ * @param {string} input.token - Access token.
384
+ * @param {AbortSignal} [input.signal] - Cancellation.
385
+ * @returns {Promise<object>} Fresh status.
386
+ */
387
+ export async function pullRepository(ctx, { path, token, signal }) {
388
+ await assertGithubRemote(ctx, path, signal)
389
+ const result = await runGit(ctx, ['-C', path, 'pull', '--ff-only', '--no-rebase', '--quiet'], {
390
+ token,
391
+ signal,
392
+ timeoutMs: NETWORK_TIMEOUT_MS,
393
+ })
394
+ if (result.exitCode !== 0) {
395
+ const text = `${result.stdout}\n${result.stderr}`
396
+ if (/no tracking information|no upstream|no such remote/i.test(text)) {
397
+ throw new GithubError('no-upstream', 'The current branch has no upstream branch to pull from.')
398
+ }
399
+ if (/not possible to fast-forward|diverging|diverged|need to specify how to reconcile/i.test(text)) {
400
+ throw new GithubError('not-fast-forward', 'Local and remote history diverged; this plugin only fast-forwards.')
401
+ }
402
+ if (/would be overwritten|local changes/i.test(text)) {
403
+ throw new GithubError('dirty-worktree', 'Local changes would be overwritten. Commit or stash them first.')
404
+ }
405
+ if (/Authentication failed|could not read Username|invalid username or password/i.test(text)) {
406
+ throw new GithubError('token-invalid', 'GitHub rejected the credentials. Sign in again.')
407
+ }
408
+ throw gitFailure(result, 'pull failed')
409
+ }
410
+ return readStatus(ctx, { path, signal })
411
+ }
412
+
413
+ /**
414
+ * Stage changes and create one commit.
415
+ *
416
+ * @param {{subprocess: any}} ctx - Host context.
417
+ * @param {object} input - Commit input.
418
+ * @param {string} input.path - Repository path.
419
+ * @param {string} input.message - Commit message.
420
+ * @param {string[] | undefined} input.files - Repository-relative paths; all changes when absent.
421
+ * @param {AbortSignal} [input.signal] - Cancellation.
422
+ * @returns {Promise<object | null>} The new commit.
423
+ */
424
+ export async function commitChanges(ctx, { path, message, files, signal }) {
425
+ const add =
426
+ files === undefined
427
+ ? await runGit(ctx, ['-C', path, 'add', '-A'], { signal })
428
+ : await runGit(ctx, ['-C', path, 'add', '--', ...files], { signal })
429
+ if (add.exitCode !== 0) throw gitFailure(add, 'staging changes')
430
+
431
+ const commit = await runGit(ctx, ['-C', path, 'commit', '-m', message], { signal })
432
+ if (commit.exitCode !== 0) {
433
+ const text = `${commit.stdout}\n${commit.stderr}`
434
+ if (/nothing to commit|no changes added to commit|nothing added to commit/i.test(text)) {
435
+ throw new GithubError('nothing-to-commit', 'There are no changes to commit.')
436
+ }
437
+ if (/please tell me who you are|author identity unknown|unable to auto-detect email address/i.test(text)) {
438
+ throw new GithubError(
439
+ 'identity-missing',
440
+ 'Git has no committer identity. Configure `git config --global user.name "..."` and `git config --global user.email "..."` first.',
441
+ )
442
+ }
443
+ throw gitFailure(commit, 'commit')
444
+ }
445
+ return readLastCommit(ctx, path, signal)
446
+ }
447
+
448
+ /**
449
+ * True when git refused a push because the token lacks the `workflow` scope.
450
+ *
451
+ * GitHub answers such a push with "refusing to allow an OAuth App to create or
452
+ * update workflow `.github/workflows/x.yml` without `workflow` scope", and the
453
+ * line is also tagged `[remote rejected]`, so this test must run before the
454
+ * generic rejection test.
455
+ *
456
+ * @param {string} text - Combined stdout and stderr of a failed `git push`.
457
+ * @returns {boolean}
458
+ */
459
+ export function isWorkflowScopeError(text) {
460
+ return /workflow/i.test(text) && /scope/i.test(text)
461
+ }
462
+
463
+ /**
464
+ * Push the current branch to `origin`.
465
+ *
466
+ * @param {{subprocess: any}} ctx - Host context.
467
+ * @param {object} input - Push input.
468
+ * @param {string} input.path - Repository path.
469
+ * @param {string} input.token - Access token.
470
+ * @param {AbortSignal} [input.signal] - Cancellation.
471
+ * @returns {Promise<{branch: string, upstream: string}>}
472
+ */
473
+ export async function pushRepository(ctx, { path, token, signal }) {
474
+ await assertGithubRemote(ctx, path, signal)
475
+ const head = await runGit(ctx, ['-C', path, 'symbolic-ref', '--short', '-q', 'HEAD'], { signal })
476
+ if (head.exitCode !== 0 || head.stdout.trim() === '') {
477
+ throw new GithubError('detached-head', 'HEAD is detached; check out a branch before pushing.')
478
+ }
479
+ const branch = head.stdout.trim()
480
+ const upstream = await runGit(ctx, ['-C', path, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { signal })
481
+ const hasUpstream = upstream.exitCode === 0 && upstream.stdout.trim() !== ''
482
+ const argv = hasUpstream
483
+ ? ['-C', path, 'push', '--quiet']
484
+ : ['-C', path, 'push', '--quiet', '--set-upstream', 'origin', branch]
485
+ const result = await runGit(ctx, argv, { token, signal, timeoutMs: NETWORK_TIMEOUT_MS })
486
+ if (result.exitCode !== 0) {
487
+ const text = `${result.stdout}\n${result.stderr}`
488
+ if (isWorkflowScopeError(text)) {
489
+ throw new GithubError(
490
+ 'workflow-scope-missing',
491
+ 'GitHub refused the push because the token has no `workflow` scope. Sign in again to grant it, then push again.',
492
+ )
493
+ }
494
+ if (/non-fast-forward|fetch first|rejected/i.test(text)) {
495
+ throw new GithubError('push-rejected', 'The push was rejected because the remote has newer commits. Pull first.')
496
+ }
497
+ if (/Authentication failed|could not read Username|invalid username or password|Permission denied/i.test(text)) {
498
+ throw new GithubError('token-invalid', 'GitHub rejected the credentials. Sign in again.')
499
+ }
500
+ throw gitFailure(result, 'push failed')
501
+ }
502
+ return { branch, upstream: hasUpstream ? upstream.stdout.trim() : `origin/${branch}` }
503
+ }
504
+
505
+ /**
506
+ * Resolve the work-tree root of a directory, or `null` when it is not inside a
507
+ * git repository.
508
+ *
509
+ * @param {{subprocess: any}} ctx - Host context.
510
+ * @param {string} path - Directory to probe.
511
+ * @param {AbortSignal} [signal] - Cancellation.
512
+ * @returns {Promise<string | null>} Real path of the work-tree root.
513
+ */
514
+ export async function repositoryTopLevel(ctx, path, signal) {
515
+ const result = await runGit(ctx, ['-C', path, 'rev-parse', '--show-toplevel'], { signal })
516
+ if (result.exitCode !== 0) return null
517
+ const top = result.stdout.trim()
518
+ if (top === '') return null
519
+ return (await realpath(top).catch(() => null)) ?? top
520
+ }
521
+
522
+ /**
523
+ * Prepare `<baseDir>/<dirName>` for a clone without leaving the base directory.
524
+ *
525
+ * @param {string} baseDir - Existing directory that receives the clone.
526
+ * @param {string} dirName - Validated single path segment.
527
+ * @returns {Promise<{base: string, target: string}>}
528
+ */
529
+ export async function prepareFlatTarget(baseDir, dirName) {
530
+ const realBase = await realpath(baseDir).catch(() => null)
531
+ if (realBase === null) throw new GithubError('not-found', 'The session directory does not exist any more.')
532
+ const info = await lstat(realBase).catch(() => null)
533
+ if (info === null || !info.isDirectory()) {
534
+ throw new GithubError('invalid-path', 'The session directory is not a directory.')
535
+ }
536
+ if (dirName.includes('/') || dirName.includes('\\') || dirName === '.' || dirName === '..') {
537
+ throw new GithubError('invalid-repo-name', 'A repository name may not contain path separators.')
538
+ }
539
+ const target = join(realBase, dirName)
540
+ const existing = await lstat(target).catch(() => null)
541
+ if (existing !== null && existing.isSymbolicLink()) {
542
+ throw new GithubError('unsafe-path', 'Refusing to clone through a symlinked directory.')
543
+ }
544
+ if (existing !== null && (!existing.isDirectory() || (await readdir(target)).length > 0)) {
545
+ throw new GithubError('target-exists', 'The clone target already exists and is not empty.')
546
+ }
547
+ return { base: realBase, target }
548
+ }
549
+
550
+ /**
551
+ * Clone `https://github.com/<owner>/<name>.git` into `<baseDir>/<name>`.
552
+ *
553
+ * @param {{subprocess: any}} ctx - Host context.
554
+ * @param {object} input - Clone input.
555
+ * @param {string} input.token - Access token.
556
+ * @param {string} input.fullName - `owner/name`.
557
+ * @param {string} input.baseDir - Directory that receives the clone.
558
+ * @param {AbortSignal} [input.signal] - Cancellation.
559
+ * @returns {Promise<string>} Absolute path of the new clone.
560
+ */
561
+ export async function cloneIntoDirectory(ctx, { token, fullName, baseDir, signal }) {
562
+ const { owner, name } = parseFullName(fullName)
563
+ const { base, target } = await prepareFlatTarget(baseDir, name)
564
+ const url = `https://github.com/${owner}/${name}.git`
565
+ const result = await runGit(ctx, ['clone', '--quiet', '--', url, target], {
566
+ cwd: base,
567
+ token,
568
+ signal,
569
+ timeoutMs: NETWORK_TIMEOUT_MS,
570
+ })
571
+ if (result.exitCode !== 0) {
572
+ await rm(target, { recursive: true, force: true }).catch(() => {})
573
+ const text = `${result.stderr}\n${result.stdout}`
574
+ if (/Authentication failed|could not read Username|invalid username or password/i.test(text)) {
575
+ throw new GithubError('token-invalid', 'GitHub rejected the credentials. Sign in again.')
576
+ }
577
+ if (/not found|does not exist|Repository not found/i.test(text)) {
578
+ throw new GithubError('not-found', 'The repository was not found (or the token has no access).')
579
+ }
580
+ throw gitFailure(result, 'clone failed')
581
+ }
582
+ const realTarget = await realpath(target)
583
+ assertInside(base, realTarget)
584
+ return realTarget
585
+ }
586
+
587
+ /**
588
+ * Create a repository in place (idempotent when one already exists).
589
+ *
590
+ * @param {{subprocess: any}} ctx - Host context.
591
+ * @param {string} path - Directory to initialise.
592
+ * @param {AbortSignal} [signal] - Cancellation.
593
+ * @returns {Promise<void>}
594
+ */
595
+ export async function initRepository(ctx, path, signal) {
596
+ const result = await runGit(ctx, ['-C', path, 'init', '--quiet', '-b', 'main'], { signal })
597
+ if (result.exitCode !== 0) throw gitFailure(result, 'git init')
598
+ }
599
+
600
+ /**
601
+ * Point `origin` at a GitHub URL, refusing to overwrite a different remote.
602
+ *
603
+ * @param {{subprocess: any}} ctx - Host context.
604
+ * @param {object} input - Input.
605
+ * @param {string} input.path - Repository path.
606
+ * @param {string} input.url - Expected `https://github.com/<owner>/<name>.git` URL.
607
+ * @param {AbortSignal} [input.signal] - Cancellation.
608
+ * @returns {Promise<{created: boolean}>}
609
+ */
610
+ export async function ensureOrigin(ctx, { path, url, signal }) {
611
+ const current = await runGit(ctx, ['-C', path, 'config', '--get', 'remote.origin.url'], { signal })
612
+ const existing = current.exitCode === 0 ? current.stdout.trim() : ''
613
+ if (existing !== '') {
614
+ if (existing === url) return { created: false }
615
+ throw new GithubError(
616
+ 'remote-exists',
617
+ 'This repository already has a different "origin" remote. Remove it before publishing.',
618
+ )
619
+ }
620
+ const result = await runGit(ctx, ['-C', path, 'remote', 'add', 'origin', url], { signal })
621
+ if (result.exitCode !== 0) throw gitFailure(result, 'adding the origin remote')
622
+ return { created: true }
623
+ }
624
+
625
+ /**
626
+ * Stage everything and create a commit, allowing an empty commit.
627
+ *
628
+ * @param {{subprocess: any}} ctx - Host context.
629
+ * @param {object} input - Input.
630
+ * @param {string} input.path - Repository path.
631
+ * @param {string} input.message - Commit message.
632
+ * @param {AbortSignal} [input.signal] - Cancellation.
633
+ * @returns {Promise<object | null>} The new commit.
634
+ */
635
+ export async function commitAll(ctx, { path, message, signal }) {
636
+ const add = await runGit(ctx, ['-C', path, 'add', '-A'], { signal })
637
+ if (add.exitCode !== 0) throw gitFailure(add, 'staging changes')
638
+ const commit = await runGit(ctx, ['-C', path, 'commit', '--allow-empty', '-m', message], { signal })
639
+ if (commit.exitCode !== 0) {
640
+ const text = `${commit.stdout}\n${commit.stderr}`
641
+ if (/please tell me who you are|author identity unknown|unable to auto-detect email address/i.test(text)) {
642
+ throw new GithubError(
643
+ 'identity-missing',
644
+ 'Git has no committer identity. Configure `git config --global user.name "..."` and `git config --global user.email "..."` first.',
645
+ )
646
+ }
647
+ throw gitFailure(commit, 'commit')
648
+ }
649
+ return readLastCommit(ctx, path, signal)
650
+ }
651
+
652
+ /**
653
+ * Fetch `origin` and report the refreshed status.
654
+ *
655
+ * @param {{subprocess: any}} ctx - Host context.
656
+ * @param {object} input - Input.
657
+ * @param {string} input.path - Repository path.
658
+ * @param {string} input.token - Access token.
659
+ * @param {AbortSignal} [input.signal] - Cancellation.
660
+ * @returns {Promise<object>} Fresh status.
661
+ */
662
+ export async function fetchRepository(ctx, { path, token, signal }) {
663
+ await assertGithubRemote(ctx, path, signal)
664
+ const result = await runGit(ctx, ['-C', path, 'fetch', '--prune', '--quiet', 'origin'], {
665
+ token,
666
+ signal,
667
+ timeoutMs: NETWORK_TIMEOUT_MS,
668
+ })
669
+ if (result.exitCode !== 0) {
670
+ const text = `${result.stdout}\n${result.stderr}`
671
+ if (/Authentication failed|could not read Username|invalid username or password/i.test(text)) {
672
+ throw new GithubError('token-invalid', 'GitHub rejected the credentials. Sign in again.')
673
+ }
674
+ throw gitFailure(result, 'fetch failed')
675
+ }
676
+ return readStatus(ctx, { path, signal })
677
+ }