@modesto_ma/dsh-guanxiang 0.2.0-beta.2 → 0.2.0-beta.4

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.
@@ -4,11 +4,13 @@
4
4
  * - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
5
5
  * 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
6
6
  * - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
7
- * 父提交,推送到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不推送已有
8
- * 分支或默认分支。
9
- * - 推送前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
10
- * 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才推送;
11
- * 没有审批服务时工具报错,不推送。
7
+ * 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<改动摘要前缀>`,从不提交到
8
+ * 已有分支或默认分支。
9
+ * - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
10
+ * GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
11
+ * - 提交前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
12
+ * 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才提交;
13
+ * 没有审批服务时工具报错,不提交。
12
14
  * - 子会话中不提供本工具。
13
15
  *
14
16
  * @module dsh-guanxiang/skill-update/tool
@@ -17,16 +19,18 @@
17
19
  import { randomBytes } from 'node:crypto'
18
20
  import { rename, rm, writeFile } from 'node:fs/promises'
19
21
  import { join, resolve } from 'node:path'
22
+ import type { CommitAuthor } from 'dsh-repo/contract'
20
23
  import { z } from 'zod'
21
24
  import { describeIssue } from '../config.ts'
22
25
  import { isDelegatedAgent } from '../dispatch/tool.ts'
23
26
  import type { ContentBlock, HostApproval, ToolDefinition, ToolRunContext } from '../host.ts'
24
- import type { GitRunner } from '../repos/git.ts'
27
+ import { messageOf, repoErrorCode, requireRepos } from '../repos/lookup.ts'
25
28
  import type { RepoRegistry } from '../repos/registry.ts'
26
29
  import type { AppManifest, EditableSettings } from '../settings/contract.ts'
27
- import { commitChange, prepareChange, pushForMergeRequest, remoteBranchExists } from './git.ts'
28
- import type { CommitAuthor, FileStat, PreparedChange } from './git.ts'
30
+ import { prepareChange } from './change.ts'
31
+ import type { FileStat, PreparedChange } from './change.ts'
29
32
  import { DIFF_FILE, FILES_DIR, PROPOSAL_DIR, ProposalError, readProposal, REASON_FILE } from './proposal.ts'
33
+ import type { ProposedFile } from './proposal.ts'
30
34
 
31
35
  /** Model-facing tool name. */
32
36
  export const PROPOSE_TOOL_NAME = 'guanxiang_propose_skill'
@@ -69,7 +73,7 @@ export interface ProposeValue {
69
73
  status: ProposeStatus
70
74
  app: string
71
75
  repo: string
72
- /** New branch the change was or would be pushed to. */
76
+ /** New branch the change was or would be committed to. */
73
77
  branch: string
74
78
  /** Branch the merge request targets. */
75
79
  target: string
@@ -77,6 +81,8 @@ export interface ProposeValue {
77
81
  /** Diff file in the task's run directory. */
78
82
  diffPath: string
79
83
  mergeRequestUrl?: string
84
+ /** Why the merge request was not created; only with `pushed`. */
85
+ mergeRequestError?: string
80
86
  }
81
87
 
82
88
  const argsSchema = z.object({
@@ -98,7 +104,7 @@ const PARAMETERS: Record<string, unknown> = {
98
104
  const FILE_STAT_SCHEMA = {
99
105
  type: 'object',
100
106
  additionalProperties: false,
101
- required: ['path'],
107
+ required: ['path', 'added', 'removed'],
102
108
  properties: { path: { type: 'string' }, added: { type: 'number' }, removed: { type: 'number' } },
103
109
  } as const
104
110
 
@@ -115,6 +121,7 @@ const OUTPUT_SCHEMA = {
115
121
  files: { type: 'array', items: FILE_STAT_SCHEMA },
116
122
  diffPath: { type: 'string' },
117
123
  mergeRequestUrl: { type: 'string' },
124
+ mergeRequestError: { type: 'string' },
118
125
  },
119
126
  } as const
120
127
 
@@ -124,7 +131,7 @@ const OUTPUT_SCHEMA = {
124
131
  * @returns one line.
125
132
  */
126
133
  export function describeFiles(files: readonly FileStat[]): string {
127
- return files.map(file => file.added === undefined ? `${file.path}(二进制)` : `${file.path}(+${file.added} −${file.removed ?? 0})`).join('、')
134
+ return files.map(file => `${file.path}(+${file.added} −${file.removed})`).join('、')
128
135
  }
129
136
 
130
137
  /**
@@ -135,12 +142,12 @@ export function describeFiles(files: readonly FileStat[]): string {
135
142
  export function renderProposeValue(value: ProposeValue): ContentBlock[] {
136
143
  const where = `${value.repo} 的分支 ${value.branch}(目标 ${value.target})`
137
144
  const text = {
138
- opened: `已推送到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ''}`,
139
- pushed: `已推送到 ${where},但服务器没有返回合并请求链接;请在 GitLab 上确认合并请求是否已创建。`,
140
- exists: `${where} 已存在,同一改动可能已经提交过,本次没有推送。`,
141
- rejected: '授权被拒绝,没有推送:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。',
142
- cancelled: '授权请求被撤回,没有推送。',
143
- unavailable: '当前没有可以回答授权请求的界面,没有推送。',
145
+ opened: `已提交到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ''}`,
146
+ pushed: `已提交到 ${where},但创建合并请求失败(${value.mergeRequestError ?? '未知原因'});请在代码托管平台上从该分支手动创建。`,
147
+ exists: `${where} 已存在,同一改动可能已经提交过,本次没有提交。`,
148
+ rejected: '授权被拒绝,没有提交:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。',
149
+ cancelled: '授权请求被撤回,没有提交。',
150
+ unavailable: '当前没有可以回答授权请求的界面,没有提交。',
144
151
  }[value.status]
145
152
  return [{ type: 'text', text: `${text}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}` }]
146
153
  }
@@ -149,15 +156,11 @@ export function renderProposeValue(value: ProposeValue): ContentBlock[] {
149
156
  export interface SkillUpdaterDeps {
150
157
  readonly registry: RepoRegistry
151
158
  settings(): EditableSettings
152
- readonly git: GitRunner
153
- readonly gitTimeoutMs: number
154
159
  readonly author?: CommitAuthor
155
160
  /** DSH's approval service, when the composition has one. */
156
161
  approval(): HostApproval | undefined
157
- }
158
-
159
- function messageOf(error: unknown): string {
160
- return error instanceof Error ? error.message : String(error)
162
+ /** What `ctx.get('dshRepos')` returns now. */
163
+ repos(): unknown
161
164
  }
162
165
 
163
166
  /**
@@ -184,7 +187,7 @@ function skillDirOf(app: AppManifest): string {
184
187
  export class SkillUpdater {
185
188
  private readonly tasks = new Map<string, DispatchedTask>()
186
189
 
187
- /** @param deps - registry, settings, git, and approval access. */
190
+ /** @param deps - registry, settings, approval, and `dshRepos` access. */
188
191
  constructor(private readonly deps: SkillUpdaterDeps) {}
189
192
 
190
193
  /** @returns whether proposals are enabled in the current settings. */
@@ -221,20 +224,17 @@ export class SkillUpdater {
221
224
  /**
222
225
  * Read the proposal, build its tree, and write the diff file.
223
226
  * @returns undefined when the task wrote no proposal.
224
- * @throws ProposalError or a git failure.
227
+ * @throws ProposalError, or an error when dsh-repo is unavailable or the mirror cannot be read.
225
228
  */
226
- private async prepare(task: DispatchedTask): Promise<{ reason: string; change: PreparedChange; target: string; diffPath: string } | undefined> {
229
+ private async prepare(task: DispatchedTask, signal?: AbortSignal): Promise<{ reason: string; files: readonly ProposedFile[]; change: PreparedChange; target: string; diffPath: string } | undefined> {
227
230
  const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates)
228
231
  if (proposal === undefined) return undefined
229
- const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
230
- const { change, target } = await this.deps.registry.withClone(task.url, async clone => ({
231
- change: await prepareChange(commands, clone.dir, task.commit, skillDirOf(task.app), proposal.files),
232
- target: clone.branch,
233
- }))
232
+ const target = this.deps.registry.branchOf(task.url)
233
+ const change = await prepareChange(requireRepos(this.deps.repos()), { url: task.url, base: task.commit, skillDir: skillDirOf(task.app) }, proposal.files, signal)
234
234
  if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`)
235
235
  const diffPath = join(task.workDir, DIFF_FILE)
236
236
  await replaceFile(diffPath, change.diff)
237
- return { reason: proposal.reason, change, target, diffPath }
237
+ return { reason: proposal.reason, files: proposal.files, change, target, diffPath }
238
238
  }
239
239
 
240
240
  /**
@@ -244,7 +244,7 @@ export class SkillUpdater {
244
244
  definition(): ToolDefinition<ProposeValue> {
245
245
  return {
246
246
  name: PROPOSE_TOOL_NAME,
247
- description: '观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后推送新分支并创建合并请求,不会直接改动默认分支。'
247
+ description: '观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后提交到新分支并创建合并请求,不会直接改动默认分支。'
248
248
  + '仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。',
249
249
  parameters: PARAMETERS,
250
250
  output: {
@@ -261,7 +261,7 @@ export class SkillUpdater {
261
261
  * @param rawArgs - model arguments.
262
262
  * @param exec - execution context of the calling agent.
263
263
  * @returns how the submission ended.
264
- * @throws when the arguments, task, proposal, or approval service are unusable, or git fails; nothing is pushed then.
264
+ * @throws when the arguments, task, proposal, approval service, or `dshRepos` service are unusable, or the commit fails; nothing is committed then.
265
265
  */
266
266
  async execute(rawArgs: unknown, exec: ToolRunContext): Promise<ProposeValue> {
267
267
  const parsed = argsSchema.safeParse(rawArgs)
@@ -275,22 +275,26 @@ export class SkillUpdater {
275
275
  if (task === undefined) throw new Error(`${parsed.data.workDir} 不是本次 DSH 运行中观象派发任务的运行目录;DSH 重启后需要重新分析`)
276
276
  if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`)
277
277
  const approval = this.deps.approval()
278
- if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时推送')
278
+ if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交')
279
+ const repos = requireRepos(this.deps.repos())
279
280
 
280
- const prepared = await this.prepare(task)
281
+ let prepared: Awaited<ReturnType<SkillUpdater['prepare']>>
282
+ try {
283
+ prepared = await this.prepare(task, exec.signal)
284
+ } catch (error) {
285
+ exec.signal.throwIfAborted()
286
+ throw error
287
+ }
281
288
  if (prepared === undefined) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`)
282
289
  const { change, target, diffPath, reason } = prepared
283
- const branch = `guanxiang/skill-${task.app.id}-${change.tree.slice(0, 10)}`
290
+ const branch = `guanxiang/skill-${task.app.id}-${change.digest.slice(0, 10)}`
284
291
  const base = { app: task.app.id, repo: task.url, branch, target, files: [...change.files], diffPath }
285
- const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
286
292
 
287
- if (await this.deps.registry.withClone(task.url, clone => remoteBranchExists(commands, clone.dir, branch, clone.env))) {
288
- return { ...base, status: 'exists' }
289
- }
293
+ if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== undefined) return { ...base, status: 'exists' }
290
294
 
291
295
  const preview = reason.replace(/\s+/g, ' ').slice(0, REASON_PREVIEW_CHARS)
292
- const zh = `观象将向 ${task.url} 推送新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`
293
- const en = `GuanXiang will push branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map(file => file.path).join(', ')}. Summary: ${summary}`
296
+ const zh = `观象将在 ${task.url} 提交新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`
297
+ const en = `GuanXiang will commit branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map(file => file.path).join(', ')}. Summary: ${summary}`
294
298
  const outcome = await approval.request({
295
299
  agent,
296
300
  toolName: PROPOSE_TOOL_NAME,
@@ -304,11 +308,32 @@ export class SkillUpdater {
304
308
 
305
309
  const message = `${summary}\n\n${reason}\n\nProposed-by: 观象 GuanXiang\nAnalyzed-commit: ${task.commit}\nSession: ${task.sessionId}\n`
306
310
  const description = `由观象在日志分析中提出(应用 ${task.app.id},分析提交 ${task.commit.slice(0, 12)},会话 ${task.sessionId})。理由:${reason.slice(0, REASON_DESCRIPTION_CHARS)}`
307
- const url = await this.deps.registry.withClone(task.url, async (clone) => {
308
- exec.signal.throwIfAborted()
309
- const commit = await commitChange(commands, clone.dir, change, message, this.deps.author)
310
- return await pushForMergeRequest(commands, clone.dir, commit, branch, { target, title: summary, description }, clone.env)
311
- })
312
- return url === undefined ? { ...base, status: 'pushed' } : { ...base, status: 'opened', mergeRequestUrl: url }
311
+ // Only files that differ from the analyzed commit; an unchanged one would be an empty update.
312
+ const changed = new Set(change.files.map(file => file.path))
313
+ const files = prepared.files
314
+ .map(file => ({ action: 'write' as const, path: `${skillDirOf(task.app)}/${file.path}`, content: file.content }))
315
+ .filter(file => changed.has(file.path))
316
+ exec.signal.throwIfAborted()
317
+ try {
318
+ await repos.commitFiles(task.url, {
319
+ branch,
320
+ baseSha: task.commit,
321
+ newBranch: true,
322
+ message,
323
+ files,
324
+ ...this.deps.author === undefined ? {} : { author: this.deps.author },
325
+ signal: exec.signal,
326
+ })
327
+ } catch (error) {
328
+ if (repoErrorCode(error) === 'conflict') return { ...base, status: 'exists' }
329
+ throw error
330
+ }
331
+ try {
332
+ const opened = await repos.createChange(task.url, { title: summary, body: description, sourceBranch: branch, targetBranch: target, signal: exec.signal })
333
+ return { ...base, status: 'opened', mergeRequestUrl: opened.webUrl }
334
+ } catch (error) {
335
+ // The branch exists now; report it so the user can open the request by hand.
336
+ return { ...base, status: 'pushed', mergeRequestError: messageOf(error) }
337
+ }
313
338
  }
314
339
  }
@@ -1,104 +0,0 @@
1
- /**
2
- * HTTPS 访问令牌:按地址前缀为仓库挑选令牌配置,并生成只对一次 git 进程生效的
3
- * 凭据助手配置。令牌经子进程环境变量交给 git,不出现在命令行参数、仓库的
4
- * `.git/config` 或 git 输出里;只有服务器要求认证时 git 才向助手取令牌,
5
- * 本机为同一主机配置的其他凭据助手在这次调用中不参与。
6
- *
7
- * @module dsh-guanxiang/repos/credentials
8
- */
9
-
10
- import type { GitCredentialSpec } from '../settings/contract.ts'
11
-
12
- export { TOKEN_ENV_PATTERN } from '../settings/contract.ts'
13
-
14
- /** Environment variables the helper reads; set only on git child processes. */
15
- const USERNAME_VARIABLE = 'GUANXIANG_GIT_USERNAME'
16
- const TOKEN_VARIABLE = 'GUANXIANG_GIT_TOKEN'
17
-
18
- /** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
19
- const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`
20
-
21
- /** Longest accepted token; personal access tokens are far shorter. */
22
- export const MAX_TOKEN_LENGTH = 4096
23
-
24
- /**
25
- * Normalize an access-token address prefix to `scheme://host[:port][/path]`
26
- * with a lowercase host and no trailing slash.
27
- * @param value - candidate prefix.
28
- * @returns the normalized prefix, or undefined when it is not an `https`/`http` URL without credentials, query, or fragment.
29
- */
30
- export function normalizeCredentialUrl(value: string): string | undefined {
31
- let url: URL
32
- try {
33
- url = new URL(value)
34
- } catch {
35
- // Not a URL: refused.
36
- return undefined
37
- }
38
- if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined
39
- if (url.host === '' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') return undefined
40
- return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '')}`
41
- }
42
-
43
- /**
44
- * Whether a token value can be handed to git: non-empty, bounded, and free of
45
- * control characters, which would break the credential helper protocol.
46
- * @param value - candidate token.
47
- * @returns whether it is accepted.
48
- */
49
- export function isAcceptedToken(value: string): boolean {
50
- return value.length > 0 && value.length <= MAX_TOKEN_LENGTH && !/[\u0000-\u001f\u007f]/.test(value)
51
- }
52
-
53
- /**
54
- * The access-token configuration for a repository address: the entry whose
55
- * prefix has the same scheme and host and the longest path that contains the
56
- * repository path. scp-like addresses, SSH URLs, and local paths never match.
57
- * @param url - repository address.
58
- * @param specs - configured access tokens with normalized prefixes.
59
- * @returns the matching entry, if any.
60
- */
61
- export function credentialFor(url: string, specs: readonly GitCredentialSpec[]): GitCredentialSpec | undefined {
62
- let repo: URL
63
- try {
64
- repo = new URL(url)
65
- } catch {
66
- // scp-like addresses and local paths use other credentials.
67
- return undefined
68
- }
69
- let best: { spec: GitCredentialSpec; length: number } | undefined
70
- for (const spec of specs) {
71
- const prefix = new URL(spec.url)
72
- if (prefix.protocol !== repo.protocol || prefix.host !== repo.host) continue
73
- const path = prefix.pathname.replace(/\/+$/, '')
74
- if (path !== '' && repo.pathname !== path && !repo.pathname.startsWith(`${path}/`)) continue
75
- if (best === undefined || path.length > best.length) best = { spec, length: path.length }
76
- }
77
- return best?.spec
78
- }
79
-
80
- /**
81
- * Environment that makes one git process answer the credential request for
82
- * the token's host with the given token. Existing `GIT_CONFIG_COUNT` entries
83
- * are kept; the helper list for that host is reset first so a stale password
84
- * from another helper cannot shadow the token.
85
- * @param spec - matching access-token configuration.
86
- * @param token - token value.
87
- * @param env - environment of the git process before this call.
88
- * @returns variables to add to the git process environment.
89
- */
90
- export function credentialEnvironment(spec: GitCredentialSpec, token: string, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
91
- const prefix = new URL(spec.url)
92
- const key = `credential.${prefix.protocol}//${prefix.host}.helper`
93
- const parsed = Number(env.GIT_CONFIG_COUNT ?? '0')
94
- const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0
95
- return {
96
- GIT_CONFIG_COUNT: String(base + 2),
97
- [`GIT_CONFIG_KEY_${base}`]: key,
98
- [`GIT_CONFIG_VALUE_${base}`]: '',
99
- [`GIT_CONFIG_KEY_${base + 1}`]: key,
100
- [`GIT_CONFIG_VALUE_${base + 1}`]: HELPER,
101
- [USERNAME_VARIABLE]: spec.username,
102
- [TOKEN_VARIABLE]: token,
103
- }
104
- }
package/src/repos/git.ts DELETED
@@ -1,74 +0,0 @@
1
- /**
2
- * 本机 git 命令的调用封装:沿用本机已有凭据(SSH key 或 credential helper),
3
- * 或由调用方经环境变量提供的访问令牌;禁止交互式提示,避免后台拉取卡在
4
- * 密码输入上。
5
- *
6
- * @module dsh-guanxiang/repos/git
7
- */
8
-
9
- import { execFile } from 'node:child_process'
10
-
11
- /** A failed git command with its combined output. */
12
- export class GitError extends Error {
13
- /**
14
- * @param args - git arguments that failed.
15
- * @param output - trimmed stderr (or stdout when stderr is empty).
16
- */
17
- constructor(readonly args: readonly string[], readonly output: string) {
18
- super(`git ${args.join(' ')} 失败:${output === '' ? '无输出' : output}`)
19
- }
20
- }
21
-
22
- /** Options of one git command. */
23
- export interface GitRunOptions {
24
- cwd?: string
25
- timeoutMs: number
26
- /** Variables added to the environment of this command only (credentials for network commands). */
27
- env?: Readonly<Record<string, string>>
28
- /** Text written to the command's standard input. */
29
- input?: string
30
- /** `all` resolves, or reports on failure, stdout followed by stderr, for commands such as `push --porcelain` that split their report. */
31
- output?: 'stdout' | 'all'
32
- }
33
-
34
- /** Runs one git command and resolves its stdout (or all output, per {@link GitRunOptions.output}). */
35
- export type GitRunner = (args: readonly string[], options: GitRunOptions) => Promise<string>
36
-
37
- /**
38
- * Environment for non-interactive git: no terminal prompts, and SSH in batch
39
- * mode unless the user configured their own SSH command.
40
- * @param env - base environment.
41
- * @returns environment for git child processes.
42
- */
43
- export function gitEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
44
- const next: NodeJS.ProcessEnv = { ...env, GIT_TERMINAL_PROMPT: '0' }
45
- if (env.GIT_SSH_COMMAND === undefined && env.GIT_SSH === undefined) next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
46
- return next
47
- }
48
-
49
- /**
50
- * Default runner backed by the `git` executable on PATH.
51
- * @param args - git arguments.
52
- * @param options - working directory, timeout, extra environment, input, and output selection.
53
- * @returns stdout, or stdout followed by stderr.
54
- */
55
- export const runGit: GitRunner = (args, options) => new Promise((resolve, reject) => {
56
- const child = execFile('git', [...args], {
57
- cwd: options.cwd,
58
- env: { ...gitEnvironment(), ...options.env },
59
- timeout: options.timeoutMs,
60
- maxBuffer: 16 * 1024 * 1024,
61
- windowsHide: true,
62
- }, (error, stdout, stderr) => {
63
- if (error === null) {
64
- resolve(options.output === 'all' ? `${stdout}${stderr}` : stdout)
65
- return
66
- }
67
- const output = (options.output === 'all' || stderr.trim() === '' ? `${stdout}${stderr}` : stderr).trim()
68
- const killed = (error as { killed?: boolean }).killed === true
69
- reject(new GitError(args, killed ? `超时(${options.timeoutMs} ms)后被终止${output === '' ? '' : `:${output}`}` : output || error.message))
70
- })
71
- // A command that exits without reading its input fails through its exit status, not EPIPE.
72
- child.stdin?.on('error', () => {})
73
- child.stdin?.end(options.input)
74
- })
@@ -1,157 +0,0 @@
1
- /**
2
- * 在拉取仓库中用 git 底层命令生成 SKILL 修改的提交并推送:用临时索引文件
3
- * 在分析时的提交上替换文件、写出树对象,不改动任何检出、快照或本地分支;
4
- * 推送时用 GitLab push options 顺带创建合并请求。
5
- *
6
- * @module dsh-guanxiang/skill-update/git
7
- */
8
-
9
- import { mkdtemp, rm } from 'node:fs/promises'
10
- import { tmpdir } from 'node:os'
11
- import { join } from 'node:path'
12
- import type { GitRunner } from '../repos/git.ts'
13
- import type { ProposedFile } from './proposal.ts'
14
-
15
- /** Line counts of one changed file. */
16
- export interface FileStat {
17
- /** Path relative to the repository root. */
18
- readonly path: string
19
- /** Added lines; undefined for a binary change. */
20
- readonly added?: number
21
- /** Removed lines; undefined for a binary change. */
22
- readonly removed?: number
23
- }
24
-
25
- /** A proposal turned into a tree object on top of its base commit. */
26
- export interface PreparedChange {
27
- readonly base: string
28
- readonly tree: string
29
- readonly files: readonly FileStat[]
30
- /** Unified diff from the base commit to {@link tree}, limited to the skill directory. */
31
- readonly diff: string
32
- }
33
-
34
- /** git runner and the timeout of one command. */
35
- export interface GitCommands {
36
- readonly git: GitRunner
37
- readonly timeoutMs: number
38
- }
39
-
40
- /** Author and committer of a commit. */
41
- export interface CommitAuthor {
42
- readonly name: string
43
- readonly email: string
44
- }
45
-
46
- /** Merge request fields sent as GitLab push options. */
47
- export interface MergeRequestOptions {
48
- readonly target: string
49
- readonly title: string
50
- readonly description: string
51
- }
52
-
53
- const DIFF_FLAGS = ['--no-color', '--no-ext-diff', '--no-renames']
54
-
55
- /**
56
- * Write the proposed files over the skill directory of `base` into a new tree.
57
- * @param commands - git runner and timeout.
58
- * @param dir - repository directory whose object database receives the objects.
59
- * @param base - commit the analysis ran on.
60
- * @param skillDir - skill directory relative to the repository root, `/`-separated.
61
- * @param files - proposed files relative to `skillDir`.
62
- * @returns the tree, per-file line counts, and the diff; `files` is empty when nothing changes.
63
- * @throws when a proposed path replaces a symbolic link or a directory, or a git command fails.
64
- */
65
- export async function prepareChange(commands: GitCommands, dir: string, base: string, skillDir: string, files: readonly ProposedFile[]): Promise<PreparedChange> {
66
- const { git, timeoutMs } = commands
67
- const scratch = await mkdtemp(join(tmpdir(), 'guanxiang-index-'))
68
- try {
69
- const env = { GIT_INDEX_FILE: join(scratch, 'index') }
70
- await git(['read-tree', base], { cwd: dir, timeoutMs, env })
71
- const listing = await git(['ls-tree', '-r', '-t', '-z', base, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
72
- const modes = new Map<string, string>()
73
- for (const record of listing.split('\0')) {
74
- const tab = record.indexOf('\t')
75
- if (tab < 0) continue
76
- modes.set(record.slice(tab + 1), record.slice(0, record.indexOf(' ')))
77
- }
78
- for (const file of files) {
79
- const path = `${skillDir}/${file.path}`
80
- const existing = modes.get(path)
81
- if (existing === '120000') throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`)
82
- if (existing === '040000') throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`)
83
- const blob = (await git(['hash-object', '-w', '--stdin'], { cwd: dir, timeoutMs, input: file.content })).trim()
84
- await git(['update-index', '--add', '--cacheinfo', `${existing === '100755' ? '100755' : '100644'},${blob},${path}`], { cwd: dir, timeoutMs, env })
85
- }
86
- const tree = (await git(['write-tree'], { cwd: dir, timeoutMs, env })).trim()
87
- const numstat = await git(['diff', ...DIFF_FLAGS, '--numstat', base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
88
- const stats = numstat.split('\n').filter(line => line !== '').map((line): FileStat => {
89
- const [added = '-', removed = '-', ...path] = line.split('\t')
90
- return { path: path.join('\t'), ...added === '-' ? {} : { added: Number(added) }, ...removed === '-' ? {} : { removed: Number(removed) } }
91
- })
92
- const diff = stats.length === 0 ? '' : await git(['diff', ...DIFF_FLAGS, base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
93
- return { base, tree, files: stats, diff }
94
- } finally {
95
- await rm(scratch, { recursive: true, force: true })
96
- }
97
- }
98
-
99
- /**
100
- * Whether the remote already has a branch.
101
- * @param commands - git runner and timeout.
102
- * @param dir - clone directory.
103
- * @param branch - branch name.
104
- * @param env - network environment of the clone's address.
105
- * @returns whether `refs/heads/<branch>` exists on `origin`.
106
- */
107
- export async function remoteBranchExists(commands: GitCommands, dir: string, branch: string, env: Readonly<Record<string, string>>): Promise<boolean> {
108
- const output = await commands.git(['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env })
109
- return output.trim() !== ''
110
- }
111
-
112
- /**
113
- * Commit a prepared tree on its base commit without touching any ref.
114
- * @param commands - git runner and timeout.
115
- * @param dir - clone directory.
116
- * @param change - prepared tree and its base.
117
- * @param message - full commit message.
118
- * @param author - explicit identity; omitted uses the host's git configuration.
119
- * @returns the new commit id.
120
- */
121
- export async function commitChange(commands: GitCommands, dir: string, change: PreparedChange, message: string, author: CommitAuthor | undefined): Promise<string> {
122
- const env = author === undefined
123
- ? undefined
124
- : { GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email, GIT_COMMITTER_NAME: author.name, GIT_COMMITTER_EMAIL: author.email }
125
- const output = await commands.git(['commit-tree', change.tree, '-p', change.base, '-F', '-'], { cwd: dir, timeoutMs: commands.timeoutMs, input: message, ...env === undefined ? {} : { env } })
126
- return output.trim()
127
- }
128
-
129
- /** Push options cannot carry line breaks; collapse every whitespace run. */
130
- function oneLine(text: string): string {
131
- return text.replace(/\s+/g, ' ').trim()
132
- }
133
-
134
- /**
135
- * Push a commit to a new remote branch and ask GitLab to open a merge request
136
- * for it (`merge_request.*` push options). The push carries an empty lease on
137
- * the branch, so it fails instead of updating a branch that appeared after
138
- * {@link remoteBranchExists} was checked. A server that does not support push
139
- * options rejects the push.
140
- * @param commands - git runner and timeout.
141
- * @param dir - clone directory.
142
- * @param commit - commit to push.
143
- * @param branch - new branch name.
144
- * @param request - merge request target and text.
145
- * @param env - network environment of the clone's address.
146
- * @returns the merge request URL the server printed, if any.
147
- */
148
- export async function pushForMergeRequest(commands: GitCommands, dir: string, commit: string, branch: string, request: MergeRequestOptions, env: Readonly<Record<string, string>>): Promise<string | undefined> {
149
- const options = [
150
- 'merge_request.create',
151
- `merge_request.target=${request.target}`,
152
- `merge_request.title=${oneLine(request.title)}`,
153
- `merge_request.description=${oneLine(request.description)}`,
154
- ].flatMap(option => ['-o', option])
155
- const output = await commands.git(['push', '--porcelain', `--force-with-lease=refs/heads/${branch}:`, ...options, 'origin', `${commit}:refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env, output: 'all' })
156
- return /https?:\/\/\S+\/-\/merge_requests\/\d+/.exec(output)?.[0]
157
- }