@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/LICENSE +21 -0
- package/README.md +246 -2
- package/README.zh.md +210 -0
- package/client.js +1689 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +10 -0
- package/index.js +78 -0
- package/lib/config.js +180 -0
- package/lib/dsh-go.js +674 -0
- package/lib/errors.js +67 -0
- package/lib/git.js +677 -0
- package/lib/github-api.js +390 -0
- package/lib/http.js +113 -0
- package/lib/service.js +974 -0
- package/lib/tools.js +102 -0
- package/lib/validate.js +197 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +125 -4
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
|
+
}
|