Merge remote-tracking branch 'origin/master' into fix/1726-pac-source-artifact-sync
This commit is contained in:
@@ -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> = {}): 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<Response>): 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 <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
]);
|
||||
expect(help.notes.join("\n")).toContain("markPullRequestReadyForReview");
|
||||
expect(help.notes.join("\n")).toContain("不改变 GitHub 状态");
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -61,6 +61,7 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh pr comment edit <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]",
|
||||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr ready <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr merge <number> [--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 <number> [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 <commentId> --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.");
|
||||
|
||||
+10
-1
@@ -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<GitHubCommandResult
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(prState(resolved.repo, token, resolved.number, sub === "close" ? "closed" : "open", false), resolved);
|
||||
}
|
||||
if (sub === "ready") {
|
||||
const resolved = resolvePositionalPrReference(args, 2, "pr ready", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(resolved.repo, "pr ready", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr ready", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(prReady(resolved.repo, token, resolved.number, options.dryRun), resolved);
|
||||
}
|
||||
if (sub === "merge") {
|
||||
const resolved = resolvePositionalPrReference(args, 2, "pr merge", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
@@ -590,7 +599,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
return withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved);
|
||||
}
|
||||
if (sub !== "list" && !isPrReadCommand(sub)) {
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/update/edit/delete, and unsupported delete.");
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, ready, merge, comment create/update/edit/delete, and unsupported delete.");
|
||||
}
|
||||
if (sub === "read" || sub === "view") {
|
||||
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
|
||||
|
||||
@@ -165,7 +165,7 @@ export function allowsNumberTargetAlias(top: string | undefined, sub: string | u
|
||||
return false;
|
||||
}
|
||||
if (top === "pr") {
|
||||
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "review-plan" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true;
|
||||
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "review-plan" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "ready" || sub === "merge" || sub === "delete") return true;
|
||||
if (sub === "comment") return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -113,13 +113,14 @@ export function renderPrCreateTable(result: GitHubCommandResult): string {
|
||||
const title = ghText(pr.title ?? request.title ?? result.title);
|
||||
const base = ghText(isRecord(pr.base) ? pr.base.ref : request.base ?? result.base);
|
||||
const head = ghText(isRecord(pr.head) ? pr.head.ref : request.head ?? result.head);
|
||||
const draft = request.draft === true || pr.draft === true || result.draft === true;
|
||||
const status = result.dryRun === true ? "dry-run" : "created";
|
||||
const rows = [[
|
||||
number === "-" ? "-" : `#${number}`,
|
||||
status,
|
||||
base,
|
||||
head,
|
||||
ghText(request.draft ?? result.draft),
|
||||
ghText(draft),
|
||||
ghShort(title, 80),
|
||||
]];
|
||||
const lines = [
|
||||
@@ -133,12 +134,15 @@ export function renderPrCreateTable(result: GitHubCommandResult): string {
|
||||
"Next:",
|
||||
];
|
||||
if (status === "created" && number !== "-") {
|
||||
lines.push(` bun scripts/cli.ts gh pr merge ${number} --repo ${ghText(result.repo)} --merge --delete-branch`);
|
||||
if (draft) lines.push(` bun scripts/cli.ts gh pr ready ${number} --repo ${ghText(result.repo)}`);
|
||||
else lines.push(` bun scripts/cli.ts gh pr merge ${number} --repo ${ghText(result.repo)} --merge --delete-branch`);
|
||||
lines.push(` bun scripts/cli.ts gh pr preflight ${number} --repo ${ghText(result.repo)} --full`);
|
||||
lines.push(" Use --squash only when ancestry and merge-parent history are intentionally disposable.");
|
||||
} else {
|
||||
lines.push(" rerun without --dry-run to create the PR");
|
||||
lines.push(` after creation: bun scripts/cli.ts gh pr merge <pr-number> --repo ${ghText(result.repo)} --merge --delete-branch`);
|
||||
lines.push(draft
|
||||
? ` after creation: bun scripts/cli.ts gh pr ready <pr-number> --repo ${ghText(result.repo)}`
|
||||
: ` after creation: bun scripts/cli.ts gh pr merge <pr-number> --repo ${ghText(result.repo)} --merge --delete-branch`);
|
||||
lines.push(" Use --squash only when ancestry and merge-parent history are intentionally disposable.");
|
||||
}
|
||||
lines.push("", "Disclosure:");
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return {
|
||||
method: "POST",
|
||||
path: "/graphql",
|
||||
mutation: "markPullRequestReadyForReview",
|
||||
variables: { pullRequestId: "present" },
|
||||
};
|
||||
}
|
||||
|
||||
function readyPullRequestSummary(pr: ReadyPullRequestGraphql, fallback: GitHubPullRequest): Record<string, unknown> {
|
||||
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<GitHubCommandResult> {
|
||||
const command = "pr ready";
|
||||
const { owner, name } = repoParts(repo);
|
||||
const current = await githubRequest<GitHubPullRequest>(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<ReadyPullRequestMutation>(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,
|
||||
};
|
||||
}
|
||||
@@ -595,6 +595,7 @@ export interface GitHubIssueListResult {
|
||||
|
||||
export interface GitHubPullRequest {
|
||||
id: number;
|
||||
node_id?: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
publicExposure: {
|
||||
@@ -236,15 +254,19 @@ function validate(targetId: string | null): Record<string, unknown> {
|
||||
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<string, unknown>, path: string): WebtermTarget {
|
||||
function parseTarget(record: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<Record<string, unknown> & { ok: boolean }> {
|
||||
return [
|
||||
{ name: "enabled", ok: target.enabled, detail: "target must be enabled" },
|
||||
@@ -378,6 +429,19 @@ function targetSummary(target: WebtermTarget): Record<string, unknown> {
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<T extends string>(value: string, expected: T, path: string): T {
|
||||
if (value !== expected) throw new Error(`${path} must be ${expected}`);
|
||||
return expected;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 <route> 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({
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
+21
-8
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user