mergestorm 0.3.63 → 0.3.66

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.
Files changed (79) hide show
  1. package/README.md +30 -10
  2. package/dist/api.d.ts +68 -11
  3. package/dist/api.js +206 -8
  4. package/dist/automation-catalog.d.ts +17 -3
  5. package/dist/automation-catalog.js +4 -1
  6. package/dist/cli.js +21 -19
  7. package/dist/client/index.d.ts +6 -4
  8. package/dist/client/index.js +6 -4
  9. package/dist/commands/account.d.ts +3 -0
  10. package/dist/commands/account.js +75 -0
  11. package/dist/commands/browse.d.ts +1 -9
  12. package/dist/commands/browse.js +10 -8
  13. package/dist/commands/dismiss.d.ts +0 -2
  14. package/dist/commands/dismiss.js +2 -2
  15. package/dist/commands/help.js +3 -2
  16. package/dist/commands/issue.d.ts +24 -6
  17. package/dist/commands/issue.js +153 -31
  18. package/dist/commands/login.d.ts +2 -1
  19. package/dist/commands/login.js +50 -14
  20. package/dist/commands/logout.js +6 -7
  21. package/dist/commands/memory.d.ts +29 -0
  22. package/dist/commands/memory.js +154 -0
  23. package/dist/commands/registry.d.ts +1 -0
  24. package/dist/commands/registry.js +52 -11
  25. package/dist/commands/review-client.d.ts +0 -3
  26. package/dist/commands/review-client.js +3 -3
  27. package/dist/commands/review.d.ts +1 -1
  28. package/dist/commands/review.js +1 -1
  29. package/dist/commands/settings.d.ts +3 -4
  30. package/dist/commands/settings.js +51 -5
  31. package/dist/commands/stack.d.ts +20 -5
  32. package/dist/commands/stack.js +106 -7
  33. package/dist/commands/whoami.js +14 -10
  34. package/dist/config.d.ts +56 -2
  35. package/dist/config.js +271 -13
  36. package/dist/errors.d.ts +1 -1
  37. package/dist/gh.d.ts +0 -4
  38. package/dist/gh.js +4 -4
  39. package/dist/git.d.ts +1 -1
  40. package/dist/git.js +2 -2
  41. package/dist/memory-api.d.ts +74 -0
  42. package/dist/memory-api.js +69 -0
  43. package/dist/node-version.d.ts +0 -2
  44. package/dist/node-version.js +1 -1
  45. package/dist/pr-body.d.ts +4 -0
  46. package/dist/pr-body.js +15 -0
  47. package/dist/repo-identity.d.ts +2 -1
  48. package/dist/repo-identity.js +8 -4
  49. package/dist/server-publish.d.ts +5 -26
  50. package/dist/server-publish.js +47 -40
  51. package/dist/stack-blockers.d.ts +15 -36
  52. package/dist/stack-blockers.js +69 -33
  53. package/dist/stack-dto.d.ts +4 -0
  54. package/dist/stack-dto.js +15 -2
  55. package/dist/stack-meta.d.ts +2 -2
  56. package/dist/stack-meta.js +1 -1
  57. package/dist/stack-watch-loop.d.ts +2 -13
  58. package/dist/stack-watch-loop.js +35 -14
  59. package/dist/stack-watch.d.ts +2 -1
  60. package/dist/stack-watch.js +6 -2
  61. package/dist/submit-pr-base.d.ts +0 -1
  62. package/dist/submit-pr-base.js +1 -1
  63. package/dist/ui/ansi.d.ts +0 -10
  64. package/dist/ui/ansi.js +2 -2
  65. package/dist/ui/banner.d.ts +3 -1
  66. package/dist/ui/banner.js +13 -3
  67. package/dist/ui/box.d.ts +0 -2
  68. package/dist/ui/box.js +1 -1
  69. package/dist/ui/prompt.d.ts +0 -11
  70. package/dist/ui/prompt.js +7 -7
  71. package/dist/ui/tabs.d.ts +2 -2
  72. package/dist/ui/tabs.js +1 -1
  73. package/dist/ui/width.d.ts +0 -9
  74. package/dist/ui/width.js +3 -3
  75. package/dist/web-api.d.ts +17 -0
  76. package/dist/web-api.js +30 -0
  77. package/package.json +3 -1
  78. package/skill/SKILL.md +4 -0
  79. package/skill/mergestorm-bounce-watch/SKILL.md +1 -1
package/dist/gh.d.ts CHANGED
@@ -10,7 +10,6 @@ export type GhResult = {
10
10
  };
11
11
  export type GhRunner = (args: string[], input?: string) => GhResult;
12
12
  export type GithubApi = "graphql" | "rest";
13
- export declare const GITHUB_API_ENV = "MERGESTORM_GITHUB_API";
14
13
  export declare function githubApiFromEnv(env?: NodeJS.ProcessEnv): GithubApi;
15
14
  export declare function isGraphqlEndpointBlocked(text: string): boolean;
16
15
  export declare class GraphqlBlockedError extends CommandError {
@@ -34,7 +33,6 @@ export declare function findOpenPrNumber(owner: string, repo: string, headBranch
34
33
  api?: GithubApi;
35
34
  gh?: GhRunner;
36
35
  }): number | null;
37
- export declare function findOpenPrNumberViaRest(owner: string, repo: string, headBranch: string, gh: GhRunner): number | null;
38
36
  export type NewPr = {
39
37
  base: string;
40
38
  head: string;
@@ -63,13 +61,11 @@ type GraphqlSpawn = (command: string, args: string[], options: {
63
61
  encoding: "utf8";
64
62
  input: string;
65
63
  }) => GraphqlSpawnResult;
66
- export declare const CREATE_PR_BATCH_SIZE = 5;
67
64
  export declare function ghGraphql(request: GraphqlRequest, cwd?: string, spawn?: GraphqlSpawn): GraphqlResponse;
68
65
  export declare function chunkCreatePrs<T>(items: readonly T[], size?: number): T[][];
69
66
  export declare function buildCreatePrsMutation(repositoryId: string, prs: readonly NewPr[]): GraphqlRequest;
70
67
  export declare function fetchRepositoryId(owner: string, repo: string, graphql: GraphqlRunner): string;
71
68
  export declare function createPrsOnRepository(repositoryId: string, prs: readonly NewPr[], graphql: GraphqlRunner): number[];
72
- export declare function createPrsViaRest(owner: string, repo: string, prs: readonly NewPr[], gh: GhRunner, opened?: readonly number[]): number[];
73
69
  export declare function createPrs(input: {
74
70
  owner: string;
75
71
  repo: string;
package/dist/gh.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { CommandError } from "./errors.js";
3
- export const GITHUB_API_ENV = "MERGESTORM_GITHUB_API";
3
+ const GITHUB_API_ENV = "MERGESTORM_GITHUB_API";
4
4
  export function githubApiFromEnv(env = process.env) {
5
5
  return env[GITHUB_API_ENV]?.trim().toLowerCase() === "rest" ? "rest" : "graphql";
6
6
  }
@@ -83,7 +83,7 @@ function findOpenPrNumberViaGraphql(owner, repo, headBranch, gh) {
83
83
  }
84
84
  return firstPrNumber(res.stdout);
85
85
  }
86
- export function findOpenPrNumberViaRest(owner, repo, headBranch, gh) {
86
+ function findOpenPrNumberViaRest(owner, repo, headBranch, gh) {
87
87
  const res = gh([
88
88
  "api",
89
89
  "-X",
@@ -111,7 +111,7 @@ function firstPrNumber(stdout) {
111
111
  throw new CommandError(`Failed to parse PR list response: ${e instanceof Error ? e.message : String(e)}`);
112
112
  }
113
113
  }
114
- export const CREATE_PR_BATCH_SIZE = 5;
114
+ const CREATE_PR_BATCH_SIZE = 5;
115
115
  export function ghGraphql(request, cwd = process.cwd(), spawn = spawnSync) {
116
116
  const result = spawn("gh", ["api", "graphql", "--input", "-"], {
117
117
  cwd,
@@ -231,7 +231,7 @@ function restErrorText(res) {
231
231
  return body.message.trim();
232
232
  return res.stderr;
233
233
  }
234
- export function createPrsViaRest(owner, repo, prs, gh, opened = []) {
234
+ function createPrsViaRest(owner, repo, prs, gh, opened = []) {
235
235
  const numbers = [...opened];
236
236
  for (const pr of prs.slice(numbers.length)) {
237
237
  const res = gh(["api", "-X", "POST", `repos/${owner}/${repo}/pulls`, "--input", "-"], JSON.stringify({ title: pr.title, head: pr.head, base: pr.base, body: pr.body }));
package/dist/git.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare function git(args: string[], cwd?: string): string;
1
+ export declare function git(args: string[], cwd?: string, env?: NodeJS.ProcessEnv): string;
2
2
  /** Parse `origin` remote into GitHub owner/repo when possible. */
3
3
  export declare function parseGithubOriginRepo(cwd?: string): {
4
4
  owner: string;
package/dist/git.js CHANGED
@@ -2,9 +2,9 @@ import { spawnSync } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { readFile, realpath, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
- export function git(args, cwd = process.cwd()) {
5
+ export function git(args, cwd = process.cwd(), env) {
6
6
  try {
7
- const result = spawnSync("git", args, { cwd, encoding: "utf8" });
7
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", env });
8
8
  if (result.status !== 0) {
9
9
  throw new Error(result.stderr?.trim() || `git ${args.join(" ")} failed`);
10
10
  }
@@ -0,0 +1,74 @@
1
+ import { type Config } from "./config.js";
2
+ export declare const MEMORY_MODES: readonly ["off", "on_request", "all"];
3
+ export declare const MEMORY_SCOPES: readonly ["user", "repo"];
4
+ export declare const MEMORY_KINDS: readonly ["rule", "style", "preference", "fact", "project"];
5
+ export declare const MEMORY_SEARCH_LIMIT_MAX = 20;
6
+ export type MemoryMode = (typeof MEMORY_MODES)[number];
7
+ export type MemoryScope = (typeof MEMORY_SCOPES)[number];
8
+ export type MemoryKind = (typeof MEMORY_KINDS)[number];
9
+ export type MemoryItem = {
10
+ id: string;
11
+ scope: MemoryScope;
12
+ repo: string | null;
13
+ kind: MemoryKind;
14
+ text: string;
15
+ source: string | null;
16
+ source_url: string | null;
17
+ created_at: string;
18
+ updated_at: string;
19
+ };
20
+ export type MemoryList = {
21
+ memory_mode: MemoryMode;
22
+ items: MemoryItem[];
23
+ };
24
+ export type MemorySearch = {
25
+ memory_mode: MemoryMode;
26
+ rules: MemoryItem[];
27
+ items: MemoryItem[];
28
+ total: number;
29
+ };
30
+ export type MemorySaved = {
31
+ id: string;
32
+ merged: boolean;
33
+ };
34
+ export type MemoryUpdated = {
35
+ id: string;
36
+ text: string;
37
+ kind?: MemoryKind;
38
+ };
39
+ export declare function memoryChatId(value?: string | null | undefined): string | null;
40
+ export declare function isMemoryMode(value: unknown): value is MemoryMode;
41
+ export declare function isMemoryKind(value: unknown): value is MemoryKind;
42
+ export declare function isMemoryScope(value: unknown): value is MemoryScope;
43
+ export declare function listMemories(filter?: {
44
+ scope?: MemoryScope;
45
+ repo?: string;
46
+ kind?: MemoryKind;
47
+ }, cfg?: Config): Promise<MemoryList>;
48
+ export declare function searchMemories(input: {
49
+ query: string;
50
+ repo?: string;
51
+ limit?: number;
52
+ chatId?: string | null;
53
+ }, cfg?: Config): Promise<MemorySearch>;
54
+ export declare function saveMemory(input: {
55
+ text: string;
56
+ kind: MemoryKind;
57
+ repo?: string | null;
58
+ chatId?: string | null;
59
+ keepBoth?: boolean;
60
+ }, cfg?: Config): Promise<MemorySaved>;
61
+ export declare function updateMemory(id: string, change: {
62
+ text: string;
63
+ kind?: MemoryKind;
64
+ }, cfg?: Config): Promise<MemoryUpdated>;
65
+ export declare function deleteMemory(id: string, cfg?: Config): Promise<{
66
+ id: string;
67
+ deleted: true;
68
+ }>;
69
+ export declare function getMemoryMode(cfg?: Config): Promise<{
70
+ memory_mode: MemoryMode;
71
+ }>;
72
+ export declare function setMemoryMode(mode: MemoryMode, cfg?: Config): Promise<{
73
+ memory_mode: MemoryMode;
74
+ }>;
@@ -0,0 +1,69 @@
1
+ import { apiFetch } from "./api.js";
2
+ import { loadConfig } from "./config.js";
3
+ import { CommandError, REVIEW_EXIT, rateLimitedMessage } from "./errors.js";
4
+ export const MEMORY_MODES = ["off", "on_request", "all"];
5
+ export const MEMORY_SCOPES = ["user", "repo"];
6
+ export const MEMORY_KINDS = ["rule", "style", "preference", "fact", "project"];
7
+ export const MEMORY_SEARCH_LIMIT_MAX = 20;
8
+ const CHAT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ export function memoryChatId(value = process.env.MERGESTORM_CHAT_ID) {
10
+ const trimmed = value?.trim() ?? "";
11
+ return CHAT_ID.test(trimmed) ? trimmed.toLowerCase() : null;
12
+ }
13
+ export function isMemoryMode(value) {
14
+ return typeof value === "string" && MEMORY_MODES.includes(value);
15
+ }
16
+ export function isMemoryKind(value) {
17
+ return typeof value === "string" && MEMORY_KINDS.includes(value);
18
+ }
19
+ export function isMemoryScope(value) {
20
+ return typeof value === "string" && MEMORY_SCOPES.includes(value);
21
+ }
22
+ const KNOWN_CODES = new Set(["invalid_input", "not_found", "memory_off", "memory_full", "memory_similar", "memory_unavailable", "payload_too_large"]);
23
+ function memoryFailure(status, body, retryAfterSeconds, action) {
24
+ const row = (body && typeof body === "object" ? body : {});
25
+ if (status === 429 || row.error === "rate_limited") {
26
+ return new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", retryAfterSeconds !== undefined ? { retryAfterSeconds } : undefined);
27
+ }
28
+ const message = typeof row.message === "string" && row.message ? row.message : null;
29
+ const code = typeof row.error === "string" && KNOWN_CODES.has(row.error) ? row.error : undefined;
30
+ if (status === 404 && !code)
31
+ return new CommandError("This Mergestorm server does not manage memories yet.", 1, "not_found");
32
+ return new CommandError(message ?? `${action} (HTTP ${status})`, 1, code);
33
+ }
34
+ async function call(cfg, route, init, ok, action) {
35
+ const { status, body, retryAfterSeconds } = await apiFetch(cfg ?? (await loadConfig()), route, init);
36
+ if (!ok.includes(status))
37
+ throw memoryFailure(status, body, retryAfterSeconds, action);
38
+ return body;
39
+ }
40
+ function query(params) {
41
+ const search = new URLSearchParams();
42
+ for (const [key, value] of Object.entries(params))
43
+ if (value !== undefined && value !== null && value !== "")
44
+ search.set(key, String(value));
45
+ const text = search.toString();
46
+ return text ? `?${text}` : "";
47
+ }
48
+ export function listMemories(filter = {}, cfg) {
49
+ return call(cfg, `/api/v1/memories${query(filter)}`, {}, [200], "Could not list memories");
50
+ }
51
+ export function searchMemories(input, cfg) {
52
+ return call(cfg, `/api/v1/memories/search${query({ query: input.query, repo: input.repo, limit: input.limit, chat_id: input.chatId })}`, {}, [200], "Could not search memories");
53
+ }
54
+ export function saveMemory(input, cfg) {
55
+ const json = { text: input.text, kind: input.kind, ...(input.repo ? { repo: input.repo } : {}), ...(input.chatId ? { chat_id: input.chatId } : {}), ...(input.keepBoth ? { keep_both: true } : {}) };
56
+ return call(cfg, "/api/v1/memories", { method: "POST", json }, [200, 201], "Could not save the memory");
57
+ }
58
+ export function updateMemory(id, change, cfg) {
59
+ return call(cfg, `/api/v1/memories/${encodeURIComponent(id)}`, { method: "PATCH", json: change }, [200], "Could not update the memory");
60
+ }
61
+ export function deleteMemory(id, cfg) {
62
+ return call(cfg, `/api/v1/memories/${encodeURIComponent(id)}`, { method: "DELETE" }, [200], "Could not delete the memory");
63
+ }
64
+ export function getMemoryMode(cfg) {
65
+ return call(cfg, "/api/v1/memories/mode", {}, [200], "Could not read the memory mode");
66
+ }
67
+ export function setMemoryMode(mode, cfg) {
68
+ return call(cfg, "/api/v1/memories/mode", { method: "PUT", json: { memory_mode: mode } }, [200], "Could not set the memory mode");
69
+ }
@@ -1,5 +1,3 @@
1
- /** Mergestorm CLI requires Node 22+ (matches package.json engines). */
2
- export declare const MIN_NODE_MAJOR = 22;
3
1
  /** Parse major version from a Node version string (e.g. "22.22.3" → 22). */
4
2
  export declare function nodeMajor(version?: string): number;
5
3
  /**
@@ -1,5 +1,5 @@
1
1
  /** Mergestorm CLI requires Node 22+ (matches package.json engines). */
2
- export const MIN_NODE_MAJOR = 22;
2
+ const MIN_NODE_MAJOR = 22;
3
3
  /** Parse major version from a Node version string (e.g. "22.22.3" → 22). */
4
4
  export function nodeMajor(version = process.versions.node) {
5
5
  const major = Number.parseInt(version.split(".")[0] ?? "", 10);
package/dist/pr-body.d.ts CHANGED
@@ -6,3 +6,7 @@
6
6
  * Turn `git log -1 --format=%B` output into a non-empty PR description.
7
7
  */
8
8
  export declare function buildPrBodyFromCommit(fullMessage: string): string;
9
+ export declare function linkedIssueNumbers(repo: {
10
+ owner: string;
11
+ repo: string;
12
+ }, ...texts: string[]): number[];
package/dist/pr-body.js CHANGED
@@ -49,6 +49,21 @@ export function buildPrBodyFromCommit(fullMessage) {
49
49
  }
50
50
  return parts.join("\n") + "\n";
51
51
  }
52
+ const LINKED_ISSUE_RE = /(?<![\w/#.-])(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s*:?\s+(?:([\w.-]+)\/([\w.-]+))?#(\d+)\b/gi;
53
+ export function linkedIssueNumbers(repo, ...texts) {
54
+ const numbers = new Set();
55
+ const same = `${repo.owner}/${repo.repo}`.toLowerCase();
56
+ for (const text of texts) {
57
+ for (const match of text.matchAll(LINKED_ISSUE_RE)) {
58
+ if (match[1] && `${match[1]}/${match[2]}`.toLowerCase() !== same)
59
+ continue;
60
+ const n = Number(match[3]);
61
+ if (Number.isSafeInteger(n) && n > 0)
62
+ numbers.add(n);
63
+ }
64
+ }
65
+ return [...numbers].sort((a, b) => a - b);
66
+ }
52
67
  function uniqueTrailers(trailers) {
53
68
  const seen = new Set();
54
69
  const out = [];
@@ -1,3 +1,5 @@
1
+ /** Classify git "not a repo" failures once at the boundary; callers match on code. */
2
+ export declare function isNotARepoGitError(detail: string): boolean;
1
3
  export declare function gitTopLevel(cwd?: string): string;
2
4
  /**
3
5
  * Each worktree has its own authoring state.
@@ -5,5 +7,4 @@ export declare function gitTopLevel(cwd?: string): string;
5
7
  */
6
8
  export declare function stableRepoId(cwd?: string): string;
7
9
  export declare function cliStacksRoot(): string;
8
- export declare function repoStateDir(cwd?: string, stacksRoot?: string): string;
9
10
  export declare function stackMetaPath(cwd?: string, stacksRoot?: string): string;
@@ -5,16 +5,20 @@ import { mergestormHome } from "./config.js";
5
5
  import { CommandError } from "./errors.js";
6
6
  import { git } from "./git.js";
7
7
  /** Classify git "not a repo" failures once at the boundary; callers match on code. */
8
+ export function isNotARepoGitError(detail) {
9
+ return /not a git repository|gitfile does not point to a valid repository/i.test(detail);
10
+ }
11
+ const classifierEnv = { ...process.env, LC_ALL: "C" };
8
12
  function rethrowIfNotARepo(err) {
9
13
  const detail = err instanceof Error ? err.message : String(err);
10
- if (/not a git repository/i.test(detail)) {
14
+ if (isNotARepoGitError(detail)) {
11
15
  throw new CommandError(detail, 1, "not_a_repo");
12
16
  }
13
17
  throw err;
14
18
  }
15
19
  export function gitTopLevel(cwd = process.cwd()) {
16
20
  try {
17
- return realpathSync(git(["rev-parse", "--show-toplevel"], cwd).trim());
21
+ return realpathSync(git(["rev-parse", "--show-toplevel"], cwd, classifierEnv).trim());
18
22
  }
19
23
  catch (err) {
20
24
  rethrowIfNotARepo(err);
@@ -27,7 +31,7 @@ export function gitTopLevel(cwd = process.cwd()) {
27
31
  export function stableRepoId(cwd = process.cwd()) {
28
32
  let raw;
29
33
  try {
30
- raw = git(["rev-parse", "--git-dir"], cwd).trim();
34
+ raw = git(["rev-parse", "--git-dir"], cwd, classifierEnv).trim();
31
35
  }
32
36
  catch (err) {
33
37
  rethrowIfNotARepo(err);
@@ -41,7 +45,7 @@ export function stableRepoId(cwd = process.cwd()) {
41
45
  export function cliStacksRoot() {
42
46
  return path.join(mergestormHome(), "stacks");
43
47
  }
44
- export function repoStateDir(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
48
+ function repoStateDir(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
45
49
  return path.join(stacksRoot, stableRepoId(cwd));
46
50
  }
47
51
  export function stackMetaPath(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
@@ -1,33 +1,18 @@
1
- import { getStackPublication, submitStackPublication, type StackPublication, type StackPublicationChange, type StackPublicationCommit, type StackPublicationIdentity, type StackPublicationStatus } from "./api.js";
1
+ import { getStackPublication, submitStackPublication, type StackPublication, type StackPublicationCommit, type StackPublicationStatus } from "./api.js";
2
2
  import { loadConfig, type Config } from "./config.js";
3
3
  import { discoverTrunk } from "./git-stack.js";
4
- export declare const SERVER_PUBLISH_MAX_COMMITS = 20;
5
- export declare const SERVER_PUBLISH_MAX_CHANGES = 500;
6
4
  export declare const SERVER_PUBLISH_MAX_FILE_BYTES: number;
7
- export declare const SERVER_PUBLISH_MAX_TOTAL_BYTES: number;
8
- export declare const SERVER_PUBLISH_MAX_COMMIT_BYTES: number;
9
- export declare const SERVER_PUBLISH_FILE_MODES: readonly ["100644", "100755", "120000"];
10
- export declare const SERVER_PUBLISH_HISTORY_SCAN = 500;
11
- export type GitRead = (args: string[], cwd: string) => Buffer;
5
+ export type GitRead = (args: string[], cwd: string) => Buffer | Promise<Buffer>;
6
+ export declare const READ_ONLY_GIT: Set<string>;
12
7
  export declare function readGit(args: string[], cwd: string): Buffer;
13
- export declare function readCommitHeader(read: GitRead, sha: string, cwd: string): {
14
- tree: string;
15
- parents: string[];
16
- author: StackPublicationIdentity;
17
- committer: StackPublicationIdentity;
18
- message: string;
19
- };
20
- export declare function readCommitChanges(read: GitRead, parent: string, sha: string, cwd: string, budget: {
21
- changes: number;
22
- bytes: number;
23
- }): StackPublicationChange[];
24
- export declare function readPublicationCommits(read: GitRead, from: string, to: string, cwd: string): StackPublicationCommit[];
8
+ export declare function readPublicationCommits(read: GitRead, from: string, to: string, cwd: string): Promise<StackPublicationCommit[]>;
25
9
  export type ServerPublishInput = {
26
10
  cfg: Config;
27
11
  cwd: string;
28
12
  owner: string;
29
13
  repo: string;
30
14
  branch: string;
15
+ localRef?: string;
31
16
  baseBranch: string;
32
17
  localBaseRef?: string;
33
18
  remoteBaseSha?: string;
@@ -71,12 +56,6 @@ export declare function assertExplicitStacking(input: {
71
56
  stackCommand: string;
72
57
  trunkCommand: string;
73
58
  }): void;
74
- export declare function workingBranchTarget(input: WorkingBranchInput): {
75
- cwd: string;
76
- owner: string;
77
- repo: string;
78
- branch: string;
79
- };
80
59
  export declare function publishWorkingBranch(input: WorkingBranchInput, deps?: WorkingBranchDeps): Promise<ServerPublishResult & {
81
60
  stacked: boolean;
82
61
  }>;
@@ -1,37 +1,41 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { getStackPublication, submitStackPublication, } from "./api.js";
3
3
  import { loadConfig } from "./config.js";
4
- import { CommandError } from "./errors.js";
4
+ import { CommandError, isCommandErrorCode } from "./errors.js";
5
5
  import { parseGithubOriginRepo } from "./git.js";
6
6
  import { currentBranch, discoverTrunk, tipCommitMessage, tipCommitSubject } from "./git-stack.js";
7
7
  import { buildPrBodyFromCommit } from "./pr-body.js";
8
- export const SERVER_PUBLISH_MAX_COMMITS = 20;
9
- export const SERVER_PUBLISH_MAX_CHANGES = 500;
8
+ const SERVER_PUBLISH_MAX_COMMITS = 20;
9
+ const SERVER_PUBLISH_MAX_CHANGES = 500;
10
10
  export const SERVER_PUBLISH_MAX_FILE_BYTES = 2 * 1024 * 1024;
11
- export const SERVER_PUBLISH_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
12
- export const SERVER_PUBLISH_MAX_COMMIT_BYTES = 1024 * 1024;
13
- export const SERVER_PUBLISH_FILE_MODES = ["100644", "100755", "120000"];
14
- export const SERVER_PUBLISH_HISTORY_SCAN = 500;
15
- const READ_ONLY_GIT = new Set(["cat-file", "diff-tree", "merge-base", "rev-list", "rev-parse", "symbolic-ref"]);
11
+ const SERVER_PUBLISH_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
12
+ const SERVER_PUBLISH_MAX_COMMIT_BYTES = 1024 * 1024;
13
+ const SERVER_PUBLISH_FILE_MODES = ["100644", "100755", "120000"];
14
+ const SERVER_PUBLISH_HISTORY_SCAN = 500;
15
+ export const READ_ONLY_GIT = new Set(["cat-file", "diff-tree", "merge-base", "rev-list", "rev-parse", "symbolic-ref"]);
16
16
  export function readGit(args, cwd) {
17
17
  if (!READ_ONLY_GIT.has(args[0] ?? "")) {
18
18
  throw new Error(`server-side publication only reads the repository; refusing git ${args[0] ?? ""}`);
19
19
  }
20
20
  const result = spawnSync("git", args, { cwd, maxBuffer: 256 * 1024 * 1024 });
21
+ if (result.error)
22
+ throw result.error;
21
23
  if (result.status !== 0) {
22
- throw new Error(result.stderr?.toString("utf8").trim() || `git ${args.join(" ")} failed`);
24
+ throw new CommandError(result.stderr?.toString("utf8").trim() || `git ${args.join(" ")} failed`, 1, "git_failed");
23
25
  }
24
26
  return result.stdout;
25
27
  }
26
- function text(read, args, cwd) {
27
- return read(args, cwd).toString("utf8").trim();
28
+ async function text(read, args, cwd) {
29
+ return (await read(args, cwd)).toString("utf8").trim();
28
30
  }
29
- function resolves(read, ref, cwd) {
31
+ async function resolves(read, ref, cwd) {
30
32
  try {
31
- return text(read, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd) || null;
33
+ return await text(read, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd) || null;
32
34
  }
33
- catch {
34
- return null;
35
+ catch (err) {
36
+ if (isCommandErrorCode(err, "git_failed"))
37
+ return null;
38
+ throw err;
35
39
  }
36
40
  }
37
41
  function isoWithOffset(epochSeconds, offset) {
@@ -52,12 +56,12 @@ function parseIdentity(line, sha) {
52
56
  }
53
57
  return { name: match[1].trim() ? match[1] : match[2], email: match[2], date };
54
58
  }
55
- export function readCommitHeader(read, sha, cwd) {
56
- const commitBytes = Number(text(read, ["cat-file", "-s", sha], cwd));
59
+ async function readCommitHeader(read, sha, cwd) {
60
+ const commitBytes = Number(await text(read, ["cat-file", "-s", sha], cwd));
57
61
  if (commitBytes > SERVER_PUBLISH_MAX_COMMIT_BYTES) {
58
62
  throw new CommandError(`Commit ${sha} is ${commitBytes} bytes; server-side publication reads commits up to ${SERVER_PUBLISH_MAX_COMMIT_BYTES} bytes.`, 1, "payload_too_large");
59
63
  }
60
- const commitObject = read(["cat-file", "commit", sha], cwd);
64
+ const commitObject = await read(["cat-file", "commit", sha], cwd);
61
65
  let raw;
62
66
  try {
63
67
  raw = new TextDecoder("utf-8", { fatal: true }).decode(commitObject);
@@ -87,8 +91,8 @@ export function readCommitHeader(read, sha, cwd) {
87
91
  }
88
92
  return { tree, parents, author: parseIdentity(author, sha), committer: parseIdentity(committer, sha), message };
89
93
  }
90
- export function readCommitChanges(read, parent, sha, cwd, budget) {
91
- const listing = read(["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-commit-id", "--abbrev=40", parent, sha], cwd);
94
+ async function readCommitChanges(read, parent, sha, cwd, budget) {
95
+ const listing = await read(["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-commit-id", "--abbrev=40", parent, sha], cwd);
92
96
  let raw;
93
97
  try {
94
98
  raw = new TextDecoder("utf-8", { fatal: true }).decode(listing);
@@ -114,7 +118,7 @@ export function readCommitChanges(read, parent, sha, cwd, budget) {
114
118
  throw new CommandError(`${path} has Git mode ${newMode}, which server-side publication does not support` +
115
119
  `${newMode === "160000" ? " (it is a submodule)" : ""}. Supported modes are ${SERVER_PUBLISH_FILE_MODES.join(", ")}.`, 1, "unsupported_publication");
116
120
  }
117
- const size = Number(text(read, ["cat-file", "-s", newSha], cwd));
121
+ const size = Number(await text(read, ["cat-file", "-s", newSha], cwd));
118
122
  if (size > SERVER_PUBLISH_MAX_FILE_BYTES) {
119
123
  throw new CommandError(`${path} is ${size} bytes; server-side publication carries files up to ${SERVER_PUBLISH_MAX_FILE_BYTES} bytes.`, 1, "payload_too_large");
120
124
  }
@@ -122,30 +126,32 @@ export function readCommitChanges(read, parent, sha, cwd, budget) {
122
126
  if (budget.bytes > SERVER_PUBLISH_MAX_TOTAL_BYTES) {
123
127
  throw new CommandError(`More than ${SERVER_PUBLISH_MAX_TOTAL_BYTES} bytes of file content; server-side publication cannot carry this branch in one request.`, 1, "payload_too_large");
124
128
  }
125
- const content = read(["cat-file", "blob", newSha], cwd);
129
+ const content = await read(["cat-file", "blob", newSha], cwd);
126
130
  changes.push({ path, mode: newMode, sha: newSha, content: content.toString("base64") });
127
131
  }
128
132
  return changes;
129
133
  }
130
- export function readPublicationCommits(read, from, to, cwd) {
134
+ export async function readPublicationCommits(read, from, to, cwd) {
131
135
  try {
132
- read(["merge-base", "--is-ancestor", from, to], cwd);
136
+ await read(["merge-base", "--is-ancestor", from, to], cwd);
133
137
  }
134
- catch {
138
+ catch (err) {
139
+ if (!isCommandErrorCode(err, "git_failed"))
140
+ throw err;
135
141
  throw new CommandError(`${from} is not an ancestor of ${to}; the commits to publish are not a straight line.`, 1, "unsupported_publication");
136
142
  }
137
- if (text(read, ["rev-list", "--min-parents=2", "--max-count=1", `${from}..${to}`], cwd)) {
143
+ if (await text(read, ["rev-list", "--min-parents=2", "--max-count=1", `${from}..${to}`], cwd)) {
138
144
  throw new CommandError(`A merge commit sits between ${from} and ${to}. Server-side publication carries single-parent commits only; rebase or squash the branch.`, 1, "unsupported_publication");
139
145
  }
140
- const shas = text(read, ["rev-list", "--reverse", `${from}..${to}`], cwd).split("\n").filter(Boolean);
146
+ const shas = (await text(read, ["rev-list", "--reverse", `${from}..${to}`], cwd)).split("\n").filter(Boolean);
141
147
  if (shas.length > SERVER_PUBLISH_MAX_COMMITS) {
142
148
  throw new CommandError(`${shas.length} commits to publish; server-side publication carries at most ${SERVER_PUBLISH_MAX_COMMITS} at once. Squash the branch.`, 1, "payload_too_large");
143
149
  }
144
150
  const commits = [];
145
151
  const budget = { changes: 0, bytes: 0 };
146
152
  for (const sha of shas) {
147
- const header = readCommitHeader(read, sha, cwd);
148
- const changes = readCommitChanges(read, header.parents[0], sha, cwd, budget);
153
+ const header = await readCommitHeader(read, sha, cwd);
154
+ const changes = await readCommitChanges(read, header.parents[0], sha, cwd, budget);
149
155
  commits.push({
150
156
  message: header.message,
151
157
  author: header.author,
@@ -156,8 +162,8 @@ export function readPublicationCommits(read, from, to, cwd) {
156
162
  }
157
163
  return commits;
158
164
  }
159
- function localCommitWithTree(read, from, branch, head, cwd) {
160
- const lines = text(read, ["rev-list", `--max-count=${SERVER_PUBLISH_HISTORY_SCAN}`, "--format=%H %T", "--no-commit-header", `${from}..${branch}`], cwd)
165
+ async function localCommitWithTree(read, from, branch, head, cwd) {
166
+ const lines = (await text(read, ["rev-list", `--max-count=${SERVER_PUBLISH_HISTORY_SCAN}`, "--format=%H %T", "--no-commit-header", `${from}..${branch}`], cwd))
161
167
  .split("\n")
162
168
  .filter(Boolean);
163
169
  let match = null;
@@ -176,22 +182,23 @@ export async function publishBranchServerSide(input, deps = {}) {
176
182
  const status = deps.getStackPublication ?? getStackPublication;
177
183
  const submit = deps.submitStackPublication ?? submitStackPublication;
178
184
  const { cfg, cwd, owner, repo, branch } = input;
179
- if (!resolves(read, branch, cwd))
180
- throw new CommandError(`Branch missing locally: ${branch}`);
185
+ const local = input.localRef ?? branch;
186
+ if (!(await resolves(read, local, cwd)))
187
+ throw new CommandError(`Branch missing locally: ${local}`);
181
188
  const remote = await status(cfg, { owner, repo, branch });
182
189
  const baseBranch = remote.pr?.base ?? input.baseBranch;
183
190
  deps.assertBase?.(baseBranch);
184
191
  const localBase = input.localBaseRef ??
185
- (resolves(read, `refs/remotes/origin/${input.baseBranch}`, cwd) ? `refs/remotes/origin/${input.baseBranch}` : input.baseBranch);
186
- if (!resolves(read, localBase, cwd))
192
+ ((await resolves(read, `refs/remotes/origin/${input.baseBranch}`, cwd)) ? `refs/remotes/origin/${input.baseBranch}` : input.baseBranch);
193
+ if (!(await resolves(read, localBase, cwd)))
187
194
  throw new CommandError(`Base branch missing locally: ${localBase}`);
188
- const fork = text(read, ["merge-base", localBase, branch], cwd);
195
+ const fork = await text(read, ["merge-base", localBase, local], cwd);
189
196
  const baseSha = input.remoteBaseSha ?? fork;
190
197
  let onto = "base";
191
198
  let expectedHead = null;
192
199
  let from = fork;
193
200
  if (remote.head) {
194
- const published = remote.head.tree ? localCommitWithTree(read, fork, branch, { sha: remote.head.sha, tree: remote.head.tree }, cwd) : null;
201
+ const published = remote.head.tree ? await localCommitWithTree(read, fork, local, { sha: remote.head.sha, tree: remote.head.tree }, cwd) : null;
195
202
  if (published) {
196
203
  onto = "head";
197
204
  expectedHead = remote.head.sha;
@@ -209,7 +216,7 @@ export async function publishBranchServerSide(input, deps = {}) {
209
216
  `To replace that head with your local branch, pass --replace-head ${remote.head.sha}.`, 1, "head_changed");
210
217
  }
211
218
  }
212
- const commits = readPublicationCommits(read, from, branch, cwd);
219
+ const commits = await readPublicationCommits(read, from, local, cwd);
213
220
  if (commits.length === 0 && onto === "base") {
214
221
  throw new CommandError(`No commits on \`${branch}\` ahead of \`${input.baseBranch}\`. Commit your changes, then retry.`);
215
222
  }
@@ -224,7 +231,7 @@ export async function publishBranchServerSide(input, deps = {}) {
224
231
  pr: { title: input.title, body: input.body },
225
232
  ...(typeof input.autoLand === "boolean" ? { autoLand: input.autoLand } : {}),
226
233
  });
227
- const localTree = text(read, ["rev-parse", `${branch}^{tree}`], cwd);
234
+ const localTree = await text(read, ["rev-parse", `${local}^{tree}`], cwd);
228
235
  if (publication.head.tree !== localTree) {
229
236
  throw new CommandError(`GitHub has tree ${publication.head.tree} at ${branch}, but your local branch has tree ${localTree}.`, 1, "tree_mismatch");
230
237
  }
@@ -237,7 +244,7 @@ export function assertExplicitStacking(input) {
237
244
  `• Stack onto \`${input.parent}\`: ${input.stackCommand}\n` +
238
245
  `• New stack from \`${input.trunk}\`: ${input.trunkCommand}`, 1, "implicit_stack");
239
246
  }
240
- export function workingBranchTarget(input) {
247
+ function workingBranchTarget(input) {
241
248
  const cwd = input.cwd ?? process.cwd();
242
249
  const origin = input.owner && input.repo ? { owner: input.owner, repo: input.repo } : parseGithubOriginRepo(cwd);
243
250
  if (!origin) {