diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index e258b1d0..077f5200 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -33,6 +33,7 @@ bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state all --limit 10 bun scripts/cli.ts gh pr review-plan --repo pikasTech/unidesk bun scripts/cli.ts gh pr diff --repo pikasTech/unidesk --file path/to/file [--hunk 1] bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title "标题" --body-stdin --base master --head +bun scripts/cli.ts gh pr ready --repo pikasTech/unidesk [--dry-run] bun scripts/cli.ts gh pr preflight --repo pikasTech/unidesk bun scripts/cli.ts gh pr merge --repo pikasTech/unidesk --merge --delete-branch ``` diff --git a/AGENTS.md b/AGENTS.md index 94e3ee5d..1f85f724 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ - P0: `/root/unidesk` 主 worktree 规则: - 固定停留在 `master`; - 未预期修改默认属于并行任务,禁止 reset/checkout/删除; + - 任务 worktree 只允许短生命周期使用,不得作为长期开发分支或常驻工作区; + - 任务提交语义进入 `master` 后,必须立即核查并清理对应 worktree 和已合并本地分支; + - 存在未提交修改、未吸收提交或语义不确定时禁止强行清理; - 非 `master` 开发、语义合并和 worktree 清理由 `docs/reference/devops-hygiene.md` 约束。 - P0: Master server 构建规则: - 禁止通用 Docker/Rust/Go/前端重型构建; diff --git a/config/platform-infra/webterm.yaml b/config/platform-infra/webterm.yaml index b1cb181d..5f980961 100644 --- a/config/platform-infra/webterm.yaml +++ b/config/platform-infra/webterm.yaml @@ -5,6 +5,30 @@ metadata: id: webterm-public owner: unidesk +sessionStartModes: + defaultMode: mycx + options: + bash: + label: Bash + command: exec bash -l + mycx: + label: mycx + command: mycx + oncx: + label: oncx + command: oncx + +autoResumePrompt: + enabled: false + prompt: "[自动提醒]如果有未完成任务则继续,没有则直接结束" + sendEnter: true + enterDelayMs: 500 + readiness: + minDelayMs: 2000 + minOutputBytes: 64 + quietPeriodMs: 1500 + timeoutMs: 60000 + defaults: targetId: local-7683 @@ -34,6 +58,22 @@ targets: - source: /root/webterm/scripts/host-shell.sh target: /usr/local/bin/host-shell readOnly: true + - source: /root/user_uploads + target: /root/user_uploads + readOnly: false + autoStartSessions: + - title: 决策中心 + cwd: /root/unidesk + command: mycx resume 019f4937-9820-7b31-9178-344ae93d5a4c + cols: 100 + rows: 30 + resumePrompt: true + - title: CONSTAR + cwd: /root/unidesk + command: mycx resume 019f4b5f-b5ce-76b3-8ccb-508790e9662d + cols: 100 + rows: 30 + resumePrompt: true environment: HOST: 0.0.0.0 PORT: "7681" @@ -47,6 +87,10 @@ targets: TERMINAL_SNAPSHOT_BYTES: "524288" BROWSER_KEEPALIVE_INTERVAL_MS: "30000" BROWSER_STALE_TIMEOUT_MS: "180000" + BROWSER_SESSION_OUTPUT_IDLE_MS: "3000" + BROWSER_OUTPUT_PULSE_MIN_INTERVAL_MS: "100" + FILE_UPLOAD_DIR: /root/user_uploads + FILE_UPLOAD_MAX_BYTES: "104857600" publicExposure: enabled: true publicBaseUrl: https://term.pikapython.com diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md new file mode 100644 index 00000000..5fb3bf3d --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md @@ -0,0 +1,10 @@ +# R1.1 任务报告 + +- 调查结论: + - 既有 `gh pr create` 支持 draft。 + - 既有 `gh pr merge` 会拒绝 draft。 + - 既有受控入口缺少 `markPullRequestReadyForReview` mutation。 + - REST PR 响应中的 `node_id` 可作为 GraphQL mutation 身份。 +- 复用边界: + - token 继续由 `resolveToken` 统一解析。 + - REST 与 GraphQL 继续由共享 client 执行和分类错误。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md new file mode 100644 index 00000000..ec0501e0 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md @@ -0,0 +1,13 @@ +# R1.2 任务报告 + +- 命令合同: + - `bun scripts/cli.ts gh pr ready --repo owner/name [--dry-run]` +- 状态合同: + - open draft:转为 ready。 + - open ready:幂等成功,不再次 mutation。 + - closed:`validation-failed`。 + - missing:`pr-not-found`。 + - 权限失败:保留共享 client 的 typed reason。 +- 可见性: + - 默认输出为有界表格。 + - scoped help 披露读取、mutation 和 dry-run 语义。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md new file mode 100644 index 00000000..f8a43fe3 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md @@ -0,0 +1,15 @@ +# R1.3 任务报告 + +- 自动化验证: + - `bun test scripts/src/gh-pr-ready.test.ts scripts/src/gh-body-input.test.ts` + - 8 个测试通过,0 个失败,共 41 个断言。 +- 真实入口: + - 曾用新增命令将 标为 ready。 + - 幂等复测未再次执行 mutation。 +- 边界复盘: + - 上述状态写入越过了主代理保留 draft 待 P0/P1 复审的要求。 + - `ready` 不代表代码审查或合并门禁通过。 + - 纠偏后未再操作 #1748,也未修改运行面。 +- 交付: + - 非 draft PR: + - 由主代理负责 review、preflight 和 merge。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md new file mode 100644 index 00000000..052298b2 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md @@ -0,0 +1,11 @@ +# R1 任务报告 + +- Issue: +- PR: +- 结果: + - 新增受控 `gh pr ready` 命令。 + - 覆盖 open/draft、already-ready、dry-run 和 typed failure 合同。 + - 提交非 draft PR,未自行合并。 +- 验证: + - 8 个测试通过,0 个失败,共 41 个断言。 + - scoped help 保持有界输出。 diff --git a/docs/MDTODO/github-pr-draft-ready-command.md b/docs/MDTODO/github-pr-draft-ready-command.md new file mode 100644 index 00000000..b6cc2d5d --- /dev/null +++ b/docs/MDTODO/github-pr-draft-ready-command.md @@ -0,0 +1,26 @@ +# 补齐 GitHub PR draft 转 ready 受控命令 + +- 来源: +- 触发上下文: + - + - +- 范围: + - 仅新增 `gh pr ready` 受控命令、帮助、测试与 PR 验收。 +- 禁止项: + - 不修改 #1748 分支。 + - 不修改运行面。 + - 不建立第二套 GitHub 认证或 API authority。 + + +## R1 [completed] + +闭环 [UniDesk #1749](https://github.com/pikasTech/unidesk/issues/1749):为 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748) 补齐 draft 转 ready 的受控 GitHub CLI,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1_Task_Report.md)。 +### R1.1 [completed] + +调查 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的既有 PR 命令、GraphQL client、typed 错误和输出合同,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.1_Task_Report.md)。 +### R1.2 [completed] + +实现 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的 ready、already-ready 与 dry-run 合同,并补 scoped help,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.2_Task_Report.md)。 +### R1.3 [completed] + +覆盖 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的成功与失败测试,使用新命令验收 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748),提交非 draft PR,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.3_Task_Report.md)。 \ No newline at end of file diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index 1ffd16df..5d42addb 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -40,6 +40,36 @@ HWLAB v0.2/v0.3 仓库内 `docs/reference/spec-*`,以及已收编的 `cloud-wo 重大规划型 issue 必须执行 P0 SPEC-first:P0 阶段先在 UniDesk OA `project-management/PJ2026-01/specs/` 维护对应 SPEC,确认 SPEC 编号、上级/关联规格、架构图、数据流图、关键时序图和代码引用规则,再进入后续实现。该 issue 范围内新增或修改的源码文件头部必须标注遵循的 SPEC 编号、短名和实现引用版本;自动生成、vendored、纯配置、锁文件或不能承载注释头的二进制产物可例外,但对应生成器、渲染器或配置入口必须能追溯到 SPEC。issue 正文和评论只承载执行计划、讨论和证据,不替代长期 SPEC。 +## 概念展示与执行可见性 + +- HWLAB 概念展示必须从 UniDesk OA 的总规格、Agent 编排、客户端和硬件池边界出发,不从既有 Web 页面布局反推产品定义。 + - 展示页用于解释稳定产品能力和研发闭环,不承担第二份需求规格。 + - 页面中的任务、工具动作、硬件事实、证据和裁决必须能追溯到 OA 规格中的对应能力。 + - 一次性演示故事、截图、端口、提交和运行记录只保留在 issue 或项目过程材料中。 +- AGENT 必须是研发过程的主语,不能把固定 CI/CD 流水线或代码差异冒充 AGENT 参与过程。 + - 每个关键阶段应展示观察事实、可审计假设、决策摘要、工具调用、输出事实和下一步。 + - 不展示原始思维链,不用拟人化长篇解释替代可验证的行动与证据。 + - 代码差异是 AGENT 行动的结果,只在对应修改节点或检查器中展开。 +- 执行过程优先使用可交互的空间执行图表达。 + - AGENT 的观察、假设、计划、裁决和复诊应作为独立节点。 + - Keil、CMSIS-DAP、UART、ioProbe 等能力应作为 AGENT 选择调用的工具或硬件节点。 + - 节点与连线必须投影排队、运行、通过、失败、阻断和跳过状态。 + - 通过和失败必须形成真实分支;物理验证失败后应生成新假设,并明确回环到下一轮诊断入口。 + - 节点检查器应提供有界输入、决策摘要、工具调用、输出事实、证据引用和必要的代码差异。 +- 真实硬件和数字孪生只负责提供物理现场与测量事实,视觉权重不得压过 AGENT 主舞台。 + - 板卡、调试器、探针、线缆和仪器应保持可辨识的真实硬件关系。 + - 空间波形、设备线框、深度网格和测量 HUD 可以增强混合现实表达,但不能遮挡设备身份或伪造测量结果。 + - AGENT 调用 Flash、UART 或物理测量时,执行节点应与对应设备产生同步状态响应。 +- 证据链必须把任务、Agent Trace、Patch、Build、Flash、UART、Physical 和 Verdict 关联起来。 + - 物理测量通过前不得展示 Aggregate PASS 或允许封存回归 Case。 + - 物理测量失败时必须拒绝封存,展示 Aggregate BLOCKED,并暴露下一诊断目标。 + - 浏览器中的动画、标签和演示状态只能表达预先声明的故事或真实后端事实,不能把本地状态冒充运行面证据。 +- 纯前端概念展示可以使用独立静态目录和轻量 HTTP 服务,但必须保持展示资产与生产运行面解耦。 + - 静态页面不得增加后端接口、伪造 API 响应或引入不必要的框架依赖。 + - 公网展示必须验证 HTML、关键图片和交互资产均可从外部网络读取。 + - 需要持续托管时应使用受监督、可重启的服务管理器;一次性 shell 子进程或普通 `nohup` 不能作为长期在线证据。 + - 验收至少覆盖桌面、紧凑桌面和移动端,无文档级横向溢出、无关键内容重叠、无浏览器控制台错误,并支持 `prefers-reduced-motion`。 + ## DEV 入口 - 当前入口必须从 `config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01` 读取,不从长期文档硬编码推断。 diff --git a/scripts/src/gh-pr-ready.test.ts b/scripts/src/gh-pr-ready.test.ts new file mode 100644 index 00000000..a29ccfa6 --- /dev/null +++ b/scripts/src/gh-pr-ready.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { withGhDefaultRendered } from "./gh/default-render"; +import { ghScopedHelp } from "./gh/help"; +import { prReady } from "./gh/pr-ready"; +import type { GitHubPullRequest } from "./gh/types"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function pullRequest(overrides: Partial = {}): GitHubPullRequest { + return { + id: 1748, + node_id: "PR_kwDO_ready_1748", + number: 1748, + title: "PaC source artifact", + body: "body", + state: "open", + html_url: "https://github.com/pikasTech/unidesk/pull/1748", + draft: true, + head: { ref: "fix/1746-pac-source-artifact", sha: "a".repeat(40) }, + base: { ref: "master", sha: "b".repeat(40) }, + ...overrides, + }; +} + +function installFetch(handler: (url: URL, init: RequestInit | undefined) => Response | Promise): string[] { + const calls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input.toString() : input.url); + calls.push(`${init?.method ?? "GET"} ${url.pathname}`); + return handler(url, init); + }) as typeof fetch; + return calls; +} + +describe("gh pr ready", () => { + test("marks an open draft PR ready through the shared GraphQL client", async () => { + const calls = installFetch(async (url, init) => { + if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest()); + expect(url.pathname).toBe("/graphql"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-token"); + const payload = JSON.parse(String(init?.body)) as { query: string; variables: { pullRequestId: string } }; + expect(payload.query).toContain("markPullRequestReadyForReview"); + expect(payload.variables.pullRequestId).toBe("PR_kwDO_ready_1748"); + return Response.json({ + data: { + markPullRequestReadyForReview: { + pullRequest: { + id: "PR_kwDO_ready_1748", + number: 1748, + title: "PaC source artifact", + state: "OPEN", + isDraft: false, + url: "https://github.com/pikasTech/unidesk/pull/1748", + headRefName: "fix/1746-pac-source-artifact", + baseRefName: "master", + updatedAt: "2026-07-11T04:30:00Z", + }, + }, + }, + }); + }); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(true); + expect(result.action).toBe("marked-ready"); + expect(result.changed).toBe(true); + expect(result.graphQl).toBe(true); + expect((result.pullRequest as { draft?: boolean }).draft).toBe(false); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748", "POST /graphql"]); + }); + + test("is idempotent when the open PR is already ready", async () => { + const calls = installFetch(() => Response.json(pullRequest({ draft: false }))); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(true); + expect(result.action).toBe("already-ready"); + expect(result.changed).toBe(false); + expect(result.graphQl).toBe(false); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + const rendered = withGhDefaultRendered(["pr", "ready", "1748"], result) as { renderedText: string }; + expect(rendered.renderedText).toContain("gh pr ready (already-ready)"); + }); + + test("dry-run reads the PR and exposes a bounded mutation plan without writing", async () => { + const calls = installFetch(() => Response.json(pullRequest())); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, true); + + expect(result.ok).toBe(true); + expect(result.action).toBe("would-mark-ready"); + expect(result.planned).toBe(true); + expect(result.changed).toBe(false); + expect(result.request).toEqual({ + method: "POST", + path: "/graphql", + mutation: "markPullRequestReadyForReview", + variables: { pullRequestId: "present" }, + }); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + }); + + test("rejects a closed PR before mutation with a typed validation error", async () => { + const calls = installFetch(() => Response.json(pullRequest({ state: "closed" }))); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("validation-failed"); + expect(result.runnerDisposition).toBe("business-failed"); + expect(result.phase).toBe("validate-pr-state"); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + }); + + test("preserves the typed pr-not-found REST failure", async () => { + installFetch(() => Response.json({ message: "Not Found" }, { status: 404 })); + + const result = await prReady("pikasTech/unidesk", "test-token", 404, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("pr-not-found"); + expect(result.runnerDisposition).toBe("business-failed"); + expect(result.phase).toBe("fetch-pr"); + }); + + test("preserves typed mutation permission failures", async () => { + installFetch((url) => { + if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest()); + return Response.json( + { message: "Resource not accessible by integration" }, + { status: 403, headers: { "x-accepted-oauth-scopes": "repo" } }, + ); + }); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("scope-insufficient"); + expect(result.runnerDisposition).toBe("infra-blocked"); + expect(result.phase).toBe("mark-ready"); + }); + + test("publishes bounded scoped help for the command", () => { + const help = ghScopedHelp(["pr", "ready", "--help"]) as { usage: string[]; notes: string[] }; + expect(help.usage).toEqual([ + "bun scripts/cli.ts gh pr ready [--repo owner/name] [--number N compat] [--dry-run]", + ]); + expect(help.notes.join("\n")).toContain("markPullRequestReadyForReview"); + expect(help.notes.join("\n")).toContain("不改变 GitHub 状态"); + }); +}); diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 76995206..226a67f7 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -63,7 +63,7 @@ function renderGhDefaultText(result: GitHubCommandResult): string { if (command === "issue scan-escape" || command === "issue cleanup-plan") return renderScanEscape(result); if (command === "issue close" || command === "issue reopen") return renderIssueLifecycle(result); if (command === "issue update" || command === "issue edit" || command === "issue patch") return renderIssueWrite(result); - if (command === "pr update" || command === "pr edit" || command === "pr close" || command === "pr reopen") return renderPrWrite(result); + if (command === "pr update" || command === "pr edit" || command === "pr close" || command === "pr reopen" || command === "pr ready") return renderPrWrite(result); return renderGenericResult(result); } @@ -591,8 +591,13 @@ function renderIssueWrite(result: GitHubCommandResult): string { function renderPrWrite(result: GitHubCommandResult): string { const pr = record(result.pullRequest); + const status = result.dryRun === true + ? "dry-run" + : result.action === "already-ready" + ? "already-ready" + : "updated"; return [ - `gh ${result.command} (${result.dryRun === true ? "dry-run" : "updated"})`, + `gh ${result.command} (${status})`, "", ghTable(["PR", "STATE", "CHANGED", "URL", "TITLE"], [[ `#${ghText(result.number ?? pr.number)}`, diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index ef7c3f6c..bdc9fab3 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -61,6 +61,7 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh pr comment edit (--body-stdin|--body-file |--body ) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]", "bun scripts/cli.ts gh pr comment delete [--repo owner/name] [--number N compat] [--dry-run]", "bun scripts/cli.ts gh pr close|reopen [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr ready [--repo owner/name] [--number N compat] [--dry-run]", "bun scripts/cli.ts gh pr merge [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch|--keep-branch] [--sync-node NODE]... [--skip-local-closeout] [--dry-run]", "bun scripts/cli.ts gh pr delete [unsupported: use close]", ], @@ -169,6 +170,9 @@ export function ghScopedHelpNotes(tokens: string[]): string[] { } else if (key === "pr comment" || key.startsWith("pr comment ")) { notes.push("PR comments are GitHub issue comments under the hood; use comment id targets for update/edit/delete."); notes.push("Use `pr comment view --full` to read one full comment body by id."); + } else if (key === "pr ready") { + notes.push("PR ready 先读取目标 PR 并确认其为 open;draft PR 通过 GitHub GraphQL markPullRequestReadyForReview 转为 ready,已 ready 时幂等成功。"); + notes.push("使用 `--dry-run` 只读检查当前 draft 状态和 mutation 计划,不改变 GitHub 状态。"); } else if (key === "pr merge") { notes.push("PR merge is one-command guarded: it performs the readiness check itself; `gh pr preflight` is optional read-only diagnosis, not a required first step."); notes.push("When GitHub reports mergeability as UNKNOWN/null, merge automatically retries with YAML-configured exponential backoff and shows retry attempts as N/M."); diff --git a/scripts/src/gh/index.ts b/scripts/src/gh/index.ts index 283d688c..867d9a60 100644 --- a/scripts/src/gh/index.ts +++ b/scripts/src/gh/index.ts @@ -17,6 +17,7 @@ import { issueCreate, issueEdit, issuePatch } from "./issue-write"; import { allowsNumberTargetAlias, isIssueReadCommand, isPrReadCommand, optionValue, optionWasProvided, parseOptions } from "./options"; import { prComment, prCreate, prState, prUpdate } from "./pr-commands"; import { prMerge, prPreflight } from "./pr-merge"; +import { prReady } from "./pr-ready"; import { prDiffFile, prReviewPlan } from "./pr-review"; import { isGitHubCommandResult, issueReadJsonFields, prReadJsonFields, readDisclosureOptions, readViewSupportedJsonFields, resolvePositionalIssueReference, resolvePositionalNumberReference, resolvePositionalPrReference, resolveReadViewNumberReference, unknownGhOptionDetails, withNumberOptionHint } from "./refs"; import { issueStaleClose } from "./stale-close"; @@ -581,6 +582,14 @@ export async function runGhCommand(args: string[]): Promise --repo ${ghText(result.repo)} --merge --delete-branch`); + lines.push(draft + ? ` after creation: bun scripts/cli.ts gh pr ready --repo ${ghText(result.repo)}` + : ` after creation: bun scripts/cli.ts gh pr merge --repo ${ghText(result.repo)} --merge --delete-branch`); lines.push(" Use --squash only when ancestry and merge-parent history are intentionally disposable."); } lines.push("", "Disclosure:"); diff --git a/scripts/src/gh/pr-ready.ts b/scripts/src/gh/pr-ready.ts new file mode 100644 index 00000000..cfcecf08 --- /dev/null +++ b/scripts/src/gh/pr-ready.ts @@ -0,0 +1,169 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split + +import { repoParts } from "./auth-and-safety"; +import { commandError, errorPayload, githubGraphqlRequest, githubRequest, isGitHubError, validationError } from "./client"; +import { prCompactSummary } from "./pr-summary"; +import type { GitHubCommandResult, GitHubPullRequest } from "./types"; + +interface ReadyPullRequestGraphql { + id?: string; + number?: number; + title?: string; + state?: string; + isDraft?: boolean; + url?: string; + headRefName?: string | null; + baseRefName?: string | null; + updatedAt?: string | null; +} + +interface ReadyPullRequestMutation { + markPullRequestReadyForReview?: { + pullRequest?: ReadyPullRequestGraphql | null; + } | null; +} + +function mutationPlan(): Record { + return { + method: "POST", + path: "/graphql", + mutation: "markPullRequestReadyForReview", + variables: { pullRequestId: "present" }, + }; +} + +function readyPullRequestSummary(pr: ReadyPullRequestGraphql, fallback: GitHubPullRequest): Record { + return { + id: pr.id ?? fallback.node_id ?? null, + number: pr.number ?? fallback.number, + title: pr.title ?? fallback.title, + state: (pr.state ?? fallback.state).toLowerCase(), + stateDetail: (pr.state ?? fallback.state).toLowerCase(), + draft: pr.isDraft ?? false, + url: pr.url ?? fallback.html_url, + head: { ref: pr.headRefName ?? fallback.head?.ref ?? null, sha: fallback.head?.sha ?? null }, + base: { ref: pr.baseRefName ?? fallback.base?.ref ?? null, sha: fallback.base?.sha ?? null }, + headRefName: pr.headRefName ?? fallback.head?.ref ?? null, + baseRefName: pr.baseRefName ?? fallback.base?.ref ?? null, + updatedAt: pr.updatedAt ?? fallback.updated_at ?? null, + }; +} + +export async function prReady(repo: string, token: string, number: number, dryRun: boolean): Promise { + const command = "pr ready"; + const { owner, name } = repoParts(repo); + const current = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(current)) return commandError(command, repo, current, { number, phase: "fetch-pr" }); + + const pullRequest = prCompactSummary(current); + if (current.state !== "open") { + return validationError(command, repo, "只有 open 状态的 PR 才能转为 ready for review", { + number, + phase: "validate-pr-state", + currentState: current.state, + pullRequest, + }); + } + if (typeof current.draft !== "boolean") { + return commandError(command, repo, errorPayload("invalid-response", "GitHub REST PR 响应缺少 draft 布尔状态,无法安全判断是否需要写入"), { + number, + phase: "validate-pr-draft", + pullRequest, + }); + } + if (current.draft !== true) { + return { + ok: true, + command, + repo, + number, + dryRun, + planned: false, + changed: false, + action: "already-ready", + changedFields: [], + pullRequest, + rest: true, + graphQl: false, + }; + } + + const request = mutationPlan(); + if (dryRun) { + return { + ok: true, + command, + repo, + number, + dryRun: true, + planned: true, + changed: false, + action: "would-mark-ready", + changedFields: ["draft"], + pullRequest, + request, + rest: true, + graphQl: false, + }; + } + + if (typeof current.node_id !== "string" || current.node_id.length === 0) { + return commandError(command, repo, errorPayload("invalid-response", "GitHub REST PR 响应缺少 markPullRequestReadyForReview 所需的 node_id"), { + number, + phase: "validate-pr-node-id", + pullRequest, + }); + } + + const mutation = ` + mutation MarkPullRequestReadyForReview($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { + pullRequest { + id + number + title + state + isDraft + url + headRefName + baseRefName + updatedAt + } + } + } + `; + const response = await githubGraphqlRequest(token, mutation, { pullRequestId: current.node_id }); + if (isGitHubError(response)) { + return commandError(command, repo, response, { + number, + phase: "mark-ready", + pullRequest, + request, + }); + } + const ready = response.markPullRequestReadyForReview?.pullRequest; + if (ready === null || ready === undefined || ready.isDraft !== false) { + return commandError(command, repo, errorPayload("invalid-response", "GitHub GraphQL markPullRequestReadyForReview 响应未确认 isDraft=false", { details: response }), { + number, + phase: "verify-ready-response", + pullRequest, + request, + }); + } + + return { + ok: true, + command, + repo, + number: ready.number ?? number, + dryRun: false, + planned: false, + changed: true, + action: "marked-ready", + changedFields: ["draft"], + pullRequest: readyPullRequestSummary(ready, current), + request, + rest: true, + graphQl: true, + }; +} diff --git a/scripts/src/gh/types.ts b/scripts/src/gh/types.ts index 624514df..b8d9ab53 100644 --- a/scripts/src/gh/types.ts +++ b/scripts/src/gh/types.ts @@ -595,6 +595,7 @@ export interface GitHubIssueListResult { export interface GitHubPullRequest { id: number; + node_id?: string; number: number; title: string; body: string | null; diff --git a/scripts/src/platform-infra-webterm.ts b/scripts/src/platform-infra-webterm.ts index 495e366e..4bf92fc4 100644 --- a/scripts/src/platform-infra-webterm.ts +++ b/scripts/src/platform-infra-webterm.ts @@ -21,9 +21,24 @@ const serviceName = "webterm"; interface WebtermConfig { defaults: { targetId: string }; + sessionStartModes: SessionStartModes; + autoResumePrompt: AutoResumePrompt; targets: WebtermTarget[]; } +interface SessionStartModes { + defaultMode: string; + options: Array<{ id: string; label: string; command: string }>; +} + +interface AutoResumePrompt { + enabled: boolean; + prompt: string; + sendEnter: boolean; + enterDelayMs: number; + readiness: { minDelayMs: number; minOutputBytes: number; quietPeriodMs: number; timeoutMs: number }; +} + interface WebtermTarget { id: string; enabled: boolean; @@ -41,6 +56,9 @@ interface WebtermTarget { privileged: boolean; pid: "host" | "container"; sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; + autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean }>; + sessionStartModes: SessionStartModes; + autoResumePrompt: AutoResumePrompt; environment: Record; }; publicExposure: { @@ -236,15 +254,19 @@ function validate(targetId: string | null): Record { function readWebtermConfig(): WebtermConfig { const yaml = readYamlRecord(configPath, "platform-infra-webterm"); const defaults = recordField(yaml, "defaults", "webterm"); + const sessionStartModes = parseSessionStartModes(recordField(yaml, "sessionStartModes", "webterm"), "webterm.sessionStartModes"); + const autoResumePrompt = parseAutoResumePrompt(recordField(yaml, "autoResumePrompt", "webterm"), "webterm.autoResumePrompt"); const targetsRaw = yaml.targets; if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array"); return { defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") }, - targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`)), + sessionStartModes, + autoResumePrompt, + targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes, autoResumePrompt)), }; } -function parseTarget(record: Record, path: string): WebtermTarget { +function parseTarget(record: Record, path: string, sessionStartModes: SessionStartModes, autoResumePrompt: AutoResumePrompt): WebtermTarget { const runtime = recordField(record, "runtime", path); const exposure = recordField(record, "publicExposure", path); const dns = recordField(exposure, "dns", `${path}.publicExposure`); @@ -252,6 +274,7 @@ function parseTarget(record: Record, path: string): WebtermTarg const pk01 = recordField(exposure, "pk01", `${path}.publicExposure`); const environment = parseEnvironment(recordField(runtime, "environment", `${path}.runtime`), `${path}.runtime.environment`); const sourceMounts = parseSourceMounts(runtime.sourceMounts, `${path}.runtime.sourceMounts`); + const autoStartSessions = parseAutoStartSessions(runtime.autoStartSessions, `${path}.runtime.autoStartSessions`); const target: WebtermTarget = { id: stringField(record, "id", path), enabled: booleanField(record, "enabled", path), @@ -269,6 +292,9 @@ function parseTarget(record: Record, path: string): WebtermTarg privileged: booleanField(runtime, "privileged", `${path}.runtime`), pid: parsePid(stringField(runtime, "pid", `${path}.runtime`), `${path}.runtime.pid`), sourceMounts, + autoStartSessions, + sessionStartModes, + autoResumePrompt, environment, }, publicExposure: { @@ -306,7 +332,17 @@ function resolveTarget(targetId: string | null): WebtermTarget { } function renderCompose(target: WebtermTarget): string { - const envLines = Object.entries(target.runtime.environment) + const environment = { + ...target.runtime.environment, + AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions.map((session) => ({ + ...session, + command: resumeCommandWithPrompt(session, target.runtime.autoResumePrompt), + }))), + SESSION_START_MODES_JSON: JSON.stringify(target.runtime.sessionStartModes.options), + SESSION_START_DEFAULT_MODE: target.runtime.sessionStartModes.defaultMode, + AUTO_RESUME_PROMPT_JSON: JSON.stringify(target.runtime.autoResumePrompt), + }; + const envLines = Object.entries(environment) .map(([key, value]) => ` ${key}: ${quoteYaml(value)}`) .join("\n"); const volumeLines = target.runtime.sourceMounts @@ -332,6 +368,21 @@ ${envLines} `; } +function resumeCommandWithPrompt( + session: WebtermTarget["runtime"]["autoStartSessions"][number], + prompt: AutoResumePrompt, +): string { + if (!session.resumePrompt) return session.command; + if (!/^\s*(?:mycx|oncx)\s+resume\s+\S+\s*$/.test(session.command)) { + throw new Error(`resumePrompt requires a mycx/oncx resume command: ${session.title}`); + } + return `${session.command.trim()} ${quoteShellArg(prompt.prompt)}`; +} + +function quoteShellArg(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + function policyChecks(target: WebtermTarget, compose: string): Array & { ok: boolean }> { return [ { name: "enabled", ok: target.enabled, detail: "target must be enabled" }, @@ -378,6 +429,19 @@ function targetSummary(target: WebtermTarget): Record { composeProject: target.runtime.composeProject, containerName: target.runtime.containerName, image: target.runtime.image, + autoStartSessions: target.runtime.autoStartSessions.map((session) => session.title), + sessionStartModes: { + defaultMode: target.runtime.sessionStartModes.defaultMode, + options: target.runtime.sessionStartModes.options.map((mode) => mode.id), + }, + autoResumePrompt: { + enabled: target.runtime.autoResumePrompt.enabled, + taggedSessions: target.runtime.autoStartSessions.filter((session) => session.resumePrompt).map((session) => session.title), + readiness: target.runtime.autoResumePrompt.readiness, + promptBytes: Buffer.byteLength(target.runtime.autoResumePrompt.prompt), + sendEnter: target.runtime.autoResumePrompt.sendEnter, + enterDelayMs: target.runtime.autoResumePrompt.enterDelayMs, + }, sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, @@ -481,6 +545,61 @@ function parseSourceMounts(value: unknown, path: string): WebtermTarget["runtime }); } +function parseAutoStartSessions(value: unknown, path: string): WebtermTarget["runtime"]["autoStartSessions"] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value.map((item, index) => { + const itemPath = `${path}[${index}]`; + const record = asRecord(item, itemPath); + return { + title: stringField(record, "title", itemPath), + cwd: stringField(record, "cwd", itemPath), + command: stringField(record, "command", itemPath), + cols: integerField(record, "cols", itemPath), + rows: integerField(record, "rows", itemPath), + resumePrompt: booleanField(record, "resumePrompt", itemPath), + }; + }); +} + +function parseSessionStartModes(record: Record, path: string): SessionStartModes { + const defaultMode = stringField(record, "defaultMode", path); + const optionsRecord = recordField(record, "options", path); + const options = Object.entries(optionsRecord).map(([id, item]) => { + const itemPath = `${path}.options.${id}`; + const option = asRecord(item, itemPath); + return { + id, + label: stringField(option, "label", itemPath), + command: commandField(option, itemPath), + }; + }); + if (options.length === 0) throw new Error(`${path}.options must be a non-empty record`); + if (!options.some((option) => option.id === defaultMode)) throw new Error(`${path}.defaultMode must reference an option id`); + return { defaultMode, options }; +} + +function commandField(record: Record, path: string): string { + const value = record.command; + if (typeof value !== "string") throw new Error(`${path}.command must be a string`); + return value.trim(); +} + +function parseAutoResumePrompt(record: Record, path: string): AutoResumePrompt { + const readiness = recordField(record, "readiness", path); + return { + enabled: booleanField(record, "enabled", path), + prompt: stringField(record, "prompt", path), + sendEnter: booleanField(record, "sendEnter", path), + enterDelayMs: integerField(record, "enterDelayMs", path), + readiness: { + minDelayMs: integerField(readiness, "minDelayMs", `${path}.readiness`), + minOutputBytes: integerField(readiness, "minOutputBytes", `${path}.readiness`), + quietPeriodMs: integerField(readiness, "quietPeriodMs", `${path}.readiness`), + timeoutMs: integerField(readiness, "timeoutMs", `${path}.readiness`), + }, + }; +} + function parseLiteral(value: string, expected: T, path: string): T { if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; diff --git a/scripts/src/remote-ssh.ts b/scripts/src/remote-ssh.ts index 8cad47fa..9983a545 100644 --- a/scripts/src/remote-ssh.ts +++ b/scripts/src/remote-ssh.ts @@ -24,6 +24,7 @@ import { sshRuntimeTimeoutMs, sshRuntimeTimingHint, sshWindowsPlaneHint, + sshWindowsPowerShellPipelineHint, wrapSshRemoteCommand, type SshCaptureResult, } from "./ssh"; @@ -435,6 +436,8 @@ async function runRemoteSshWebSocket( if (!timedOut) { const windowsPlaneHint = sshWindowsPlaneHint(invocation, code, stderrTail); if (windowsPlaneHint) process.stderr.write(windowsPlaneHint); + const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, code, stderrTail); + if (pipelineHint) process.stderr.write(pipelineHint); } const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({ invocation, @@ -615,6 +618,8 @@ async function runRemoteSshWebSocketCaptureRemoteCommand( startedAtMs, })); if (timingHint) stderr += timingHint; + stderr += sshWindowsPlaneHint(invocation, code, stderr); + stderr += sshWindowsPowerShellPipelineHint(invocation, code, stderr); resolve({ exitCode: code, stdout, stderr }); }; const openTimer = setTimeout(() => { diff --git a/scripts/src/ssh-runtime.ts b/scripts/src/ssh-runtime.ts index 6a762edf..57976f5c 100644 --- a/scripts/src/ssh-runtime.ts +++ b/scripts/src/ssh-runtime.ts @@ -117,6 +117,20 @@ export function sshWindowsPlaneHint(invocation: ParsedSshInvocation, exitCode: n })}\n`; } +export function sshWindowsPowerShellPipelineHint(invocation: ParsedSshInvocation, exitCode: number, stderrText: string): string { + if (invocation.route.plane !== "win" || exitCode === 0 || !/An empty pipe element is not allowed/iu.test(stderrText)) return ""; + return `UNIDESK_SSH_HINT ${JSON.stringify({ + code: "windows-powershell-statement-pipeline-boundary", + providerId: safeProviderId(invocation.providerId), + route: invocation.route.raw, + exitCode, + message: "PowerShell cannot pipe a statement such as foreach directly at this syntax boundary.", + action: "Wrap the statement in @(...), then pipe the resulting values, or use a multiline quoted PowerShell heredoc.", + examples: ["@(foreach (...) { ... }) | Format-List", "trans ps <<'PS'"], + note: "This hint intentionally does not echo the submitted PowerShell source.", + })}\n`; +} + export function sshSlowWarningThresholdMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.UNIDESK_SSH_SLOW_WARNING_MS; const parsed = raw === undefined ? NaN : Number(raw); @@ -1015,6 +1029,7 @@ async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: Par stderrText: stderr, })); stderr += sshWindowsPlaneHint(invocation, exitCode, stderr); + stderr += sshWindowsPowerShellPipelineHint(invocation, exitCode, stderr); resolve({ exitCode, stdout, stderr }); }; const runtimeTimer = setTimeout(() => { @@ -1538,6 +1553,8 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st if (!timedOut) { const windowsPlaneHint = sshWindowsPlaneHint(invocation, exitCode, stderrTail); if (windowsPlaneHint) process.stderr.write(windowsPlaneHint); + const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, exitCode, stderrTail); + if (pipelineHint) process.stderr.write(pipelineHint); } if (!timedOut) { const tcpPoolHint = formatSshTcpPoolHint(sshTcpPoolHint({ diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index b28779c8..eca36817 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -18,6 +18,7 @@ import { sshStdoutStreamMaxBytes, sshStdoutTruncationHint, sshWindowsPlaneHint, + sshWindowsPowerShellPipelineHint, windowsFsReadOnlyScript, windowsPowerShellScriptPrelude, } from "./ssh"; @@ -95,10 +96,25 @@ describe("ssh windows fs read-only operations", () => { expect(decoded).toContain("update-ref"); expect(decoded).toContain("unselected worktree/index fingerprint"); expect(decoded).toContain("old-value CAS"); + expect(decoded).toContain("$ErrorActionPreference='Continue'"); + expect(decoded).toContain("2>&1"); + expect(decoded).toContain("Management.Automation.ErrorRecord"); + expect(decoded).toContain("warnings=@($gitWarnings)"); + expect(decoded.trimEnd()).toEndWith("exit 0"); expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "run", "--path", "src/main.c", "--message", "fix: exact"])).toThrow("requires --confirm"); expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "plan", "--path", "../secret"])).toThrow("must stay inside"); }); + test("adds a bounded hint for the PowerShell statement-to-pipeline parser boundary", () => { + const invocation = parseSshInvocation("D601:win/c/test", ["ps", "foreach ($x in $xs) { $x } | Format-List"]); + const hint = sshWindowsPowerShellPipelineHint(invocation, 1, "An empty pipe element is not allowed"); + + expect(hint).toContain("windows-powershell-statement-pipeline-boundary"); + expect(hint).toContain("@(foreach (...) { ... }) | Format-List"); + expect(hint).not.toContain("$x in $xs"); + expect(sshWindowsPowerShellPipelineHint(invocation, 0, "An empty pipe element is not allowed")).toBe(""); + }); + test("builds bounded UTF-8 scripts for cat, ls, and rg", () => { const catScript = windowsFsReadOnlyScript("F:\\Work\\demo", "cat", ["--max-bytes", "4096", "中文.md"]); const lsScript = windowsFsReadOnlyScript("F:\\Work\\demo", "ls", ["-la", "--limit=5"]); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 114af8df..04b000c6 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -657,10 +657,21 @@ Set-Location -LiteralPath ${cwd} $operation=${powerShellSingleQuote(operation)} $paths=@(${pathList}) $message=${powerShellSingleQuote(message)} +$gitWarnings=[Collections.Generic.List[string]]::new() function Invoke-Git([string[]]$GitArgs) { - $output = @(& git @GitArgs 2>&1) - if ($LASTEXITCODE -ne 0) { throw (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine) } - return (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim() + $previousErrorActionPreference=$ErrorActionPreference + try { + $ErrorActionPreference='Continue' + $records=@(& git @GitArgs 2>&1) + $nativeExitCode=$LASTEXITCODE + $stderr=(($records | Where-Object { $_ -is [Management.Automation.ErrorRecord] } | ForEach-Object { $_.Exception.Message }) -join [Environment]::NewLine).Trim() + $stdout=(($records | Where-Object { $_ -isnot [Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim() + if($nativeExitCode -ne 0){throw ((@($stdout,$stderr) | Where-Object { $_ }) -join [Environment]::NewLine)} + if($stderr){[void]$gitWarnings.Add($stderr)} + return $stdout + } finally { + $ErrorActionPreference=$previousErrorActionPreference + } } function Object-Status { $values=@{} @@ -671,13 +682,13 @@ $before=Object-Status $head=Invoke-Git @('rev-parse','--verify','HEAD^{commit}') $branch=Invoke-Git @('symbolic-ref','-q','HEAD') if($operation -eq 'status') { - [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress + [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;warnings=@($gitWarnings);safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress exit 0 } if($operation -eq 'gc-auto') { $gcOutput=Invoke-Git @('gc','--auto') $after=Object-Status - [ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress + [ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;warnings=@($gitWarnings);safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress exit 0 } $gitDir=Invoke-Git @('rev-parse','--path-format=absolute','--git-dir') @@ -693,7 +704,7 @@ try { $parentTree=Invoke-Git @('rev-parse',($head+'^{tree}')) if($tree -eq $parentTree){throw 'selected paths do not change the HEAD tree'} if($operation -eq 'plan') { - [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress + [ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;warnings=@($gitWarnings);safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress exit 0 } $commit=($message | & git commit-tree $tree -p $head).Trim() @@ -704,12 +715,13 @@ try { $unrelatedAfter=Invoke-Git (@('status','--porcelain=v2','--')+$unrelatedPathspec) if($unrelatedAfter -ne $unrelatedBefore){throw 'unselected worktree/index fingerprint changed after exact commit'} $after=Object-Status - [ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress + [ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;warnings=@($gitWarnings);safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress } finally { Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue Remove-Item -LiteralPath $tempIndex -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath ($tempIndex+'.lock') -Force -ErrorAction SilentlyContinue -}`; +} +exit 0`; } function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): void { @@ -2058,6 +2070,7 @@ export { sshTcpPoolHint, sshTruncationCompletionSummary, sshWindowsPlaneHint, + sshWindowsPowerShellPipelineHint, } from "./ssh-runtime"; export type { SshStreamForwarder } from "./ssh-runtime"; export { windowsFsReadOnlyScript } from "./ssh-windows-fs";