@norman-else/dsh-claude 0.1.51 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -3478,13 +3478,17 @@ async function summarizeSessionTitle(executablePath, request, factory = query) {
3478
3478
  abortController: lifetime,
3479
3479
  model: SESSION_TITLE_MODEL,
3480
3480
  allowedTools: [],
3481
- settingSources: [],
3481
+ settingSources: [
3482
+ "user",
3483
+ "project",
3484
+ "local"
3485
+ ],
3482
3486
  maxTurns: 1,
3483
3487
  ...executablePath.length === 0 ? {} : { pathToClaudeCodeExecutable: executablePath }
3484
3488
  }
3485
3489
  });
3486
3490
  for await (const message of query) {
3487
- if (message.type !== "result" || message.subtype !== "success") continue;
3491
+ if (message.type !== "result" || message.subtype !== "success" || message.is_error === true) continue;
3488
3492
  const title = sessionTitleLine(message.result);
3489
3493
  if (title.length > 0) return title;
3490
3494
  break;
@@ -5214,9 +5218,8 @@ function uniqueBranchName(candidate, taken) {
5214
5218
  /** Compress a composer draft into a branch slug with a throwaway Claude turn.
5215
5219
  *
5216
5220
  * Deliberately NOT routed through the supervisor, for the same reasons as the
5217
- * plan-usage probe: there is no session to borrow yet. The turn is isolated
5218
- * from filesystem settings as well, because a CLAUDE.md instruction ("always
5219
- * reply in the user's language") turns the answer into an unusable slug. */
5221
+ * plan-usage probe: there is no session to borrow yet. Settings sources match
5222
+ * conversation turns so the CLI can resolve settings-based authentication. */
5220
5223
  async function summarizeBranchSlug(executablePath, intent, factory = query) {
5221
5224
  const task = intent.trim().slice(0, MAX_INTENT_CHARS);
5222
5225
  if (task.length === 0) return void 0;
@@ -5231,12 +5234,16 @@ async function summarizeBranchSlug(executablePath, intent, factory = query) {
5231
5234
  abortController: lifetime,
5232
5235
  model: BRANCH_SUMMARY_MODEL,
5233
5236
  allowedTools: [],
5234
- settingSources: [],
5237
+ settingSources: [
5238
+ "user",
5239
+ "project",
5240
+ "local"
5241
+ ],
5235
5242
  maxTurns: 1,
5236
5243
  ...executablePath.length === 0 ? {} : { pathToClaudeCodeExecutable: executablePath }
5237
5244
  }
5238
5245
  });
5239
- for await (const message of query) if (message.type === "result" && message.subtype === "success") return branchSlug(message.result);
5246
+ for await (const message of query) if (message.type === "result" && message.subtype === "success" && message.is_error !== true) return branchSlug(message.result);
5240
5247
  return;
5241
5248
  } catch {
5242
5249
  return;
@@ -5407,9 +5414,10 @@ var RepositorySetupService = class {
5407
5414
  progress("switching-branch");
5408
5415
  return !local && remote ? this.#checkoutRemote(info, branch) : this.#checkout(info, branch);
5409
5416
  }
5410
- /** Tear down a merged branch: remove a plugin worktree (and its lease and
5411
- * branch), or switch a plain checkout back to the base branch and delete
5412
- * the merged branch. Refuses dirty trees. */
5417
+ /** Tear down a merged branch: remove a worktree (a plugin one with its
5418
+ * lease, or one another tool added) and its branch, or switch a plain
5419
+ * checkout back to the base branch and delete the merged branch. Refuses
5420
+ * dirty trees. */
5413
5421
  /** `branch` names the merged branch when the checkout is no longer on it:
5414
5422
  * a session that opened a pull request in another clone switched that
5415
5423
  * clone back to base itself, and only the local branch is left to delete.
@@ -5453,6 +5461,42 @@ var RepositorySetupService = class {
5453
5461
  };
5454
5462
  });
5455
5463
  }
5464
+ const dirs = await this.#run(git, [
5465
+ "rev-parse",
5466
+ "--path-format=absolute",
5467
+ "--git-dir",
5468
+ "--git-common-dir"
5469
+ ], path);
5470
+ const [gitDir = "", commonDir = ""] = dirs.exitCode === 0 ? dirs.stdout.trim().split(/\r?\n/u) : [];
5471
+ if (gitDir.length > 0 && comparablePath(gitDir) !== comparablePath(commonDir)) {
5472
+ const mainRoot = resolve(commonDir, "..");
5473
+ const head = named === void 0 ? await this.#run(git, [
5474
+ "symbolic-ref",
5475
+ "--quiet",
5476
+ "--short",
5477
+ "HEAD"
5478
+ ], path) : void 0;
5479
+ const branch = named ?? (head?.exitCode === 0 ? head.stdout.trim() : "");
5480
+ if (branch.length === 0) throw new RepositorySetupError("nothing-to-clean", "The worktree is not on a branch.");
5481
+ if (requirePushed) await this.#requirePushed(git, mainRoot, branch);
5482
+ if ((await this.#run(git, [
5483
+ "worktree",
5484
+ "remove",
5485
+ "--",
5486
+ path
5487
+ ], mainRoot)).exitCode !== 0) throw new RepositorySetupError("worktree-remove-failed", "Git could not remove the worktree.");
5488
+ await this.#run(git, [
5489
+ "branch",
5490
+ "-D",
5491
+ "--",
5492
+ branch
5493
+ ], mainRoot).catch(() => void 0);
5494
+ return {
5495
+ mode: "worktree",
5496
+ root: mainRoot,
5497
+ branch
5498
+ };
5499
+ }
5456
5500
  const root = await this.#repositoryRoot(git, path);
5457
5501
  if (base === void 0) throw new RepositorySetupError("nothing-to-clean", "A plain checkout needs the base branch to return to.");
5458
5502
  const head = await this.#run(git, [
@@ -5849,9 +5893,19 @@ var RepositorySetupService = class {
5849
5893
  //#region src/repository-actions.ts
5850
5894
  const MAX_OUTPUT_BYTES$1 = 262144;
5851
5895
  const MAX_PATCH_CHARS = 65536;
5852
- const MAX_MESSAGE_CHARS = 512;
5896
+ const MAX_MESSAGE_CHARS = 2048;
5853
5897
  const MAX_PR_TEXT_CHARS = 8192;
5854
5898
  const MAX_UNPUSHED_COMMITS = 20;
5899
+ /** What one generation shows the model: the whole patch when it fits, the
5900
+ * head of it otherwise, and the prompt says which. */
5901
+ const MAX_GENERATE_PATCH_CHARS = 49152;
5902
+ const MAX_SUBJECT_CHARS = 72;
5903
+ /** Past this the first line is not a subject at all; under it, a subject a
5904
+ * few characters over what the prompt asked for is the user's to trim. */
5905
+ const MAX_SUBJECT_KEPT_CHARS = 120;
5906
+ const MAX_PR_TITLE_CHARS = 100;
5907
+ const MAX_RECENT_SUBJECTS = 10;
5908
+ const MAX_PR_COMMITS = 50;
5855
5909
  const GIT_TIMEOUT_MS$1 = 15e3;
5856
5910
  const REMOTE_TIMEOUT_MS = 6e4;
5857
5911
  const GENERATE_TIMEOUT_MS = 6e4;
@@ -5859,10 +5913,19 @@ const GENERATE_TIMEOUT_MS = 6e4;
5859
5913
  * use is cost: MCP servers (which `ask` already skips for the same reason,
5860
5914
  * and which stall for as long as an unreachable one takes to give up) and the
5861
5915
  * user's own hooks and settings. Project settings stay: a repository's commit
5862
- * conventions belong in the message. `--tools ''` keeps `--output-format`
5863
- * between it and the prompt -- both flags are variadic. */
5916
+ * conventions belong in the message. The prompt itself goes in on stdin:
5917
+ * a 48 KB diff on argv is past what Windows lets a process be started with
5918
+ * (ENAMETOOLONG, and the fallback subject where a message should be).
5919
+ *
5920
+ * Sonnet rather than the session's default: describing a diff needs no
5921
+ * frontier model, but telling three unrelated changes apart in one diff is
5922
+ * where haiku starts to blur them, and sonnet costs only a second or two
5923
+ * more. Extended thinking is off (see {@link GENERATE_ENV}): the naming call
5924
+ * in prompts.ts measured it as most of a ten-second run. */
5864
5925
  const GENERATE_ARGUMENTS = [
5865
5926
  "-p",
5927
+ "--model",
5928
+ "sonnet",
5866
5929
  "--strict-mcp-config",
5867
5930
  "--mcp-config",
5868
5931
  "{\"mcpServers\":{}}",
@@ -5873,6 +5936,8 @@ const GENERATE_ARGUMENTS = [
5873
5936
  "--output-format",
5874
5937
  "text"
5875
5938
  ];
5939
+ /** A CLI that stops honouring the variable is slow again, never wrong. */
5940
+ const GENERATE_ENV = { MAX_THINKING_TOKENS: "0" };
5876
5941
  var RepositoryActionError = class extends Error {
5877
5942
  code;
5878
5943
  commit;
@@ -5932,11 +5997,82 @@ function fallbackCommitMessage(files) {
5932
5997
  if (files.length === 1) return `Update ${files[0]?.path ?? "repository files"}`;
5933
5998
  return `Update ${files.length} repository files`;
5934
5999
  }
5935
- function normalizedGeneratedMessage(value, fallback) {
5936
- const first = value.split(/\r?\n/u).map((line) => line.trim()).find(Boolean);
5937
- if (first === void 0) return fallback;
5938
- const message = first.replace(/^['"`]+|['"`]+$/gu, "").replace(/[\0\r\n]/gu, " ").trim();
5939
- return message.length === 0 || message.length > 72 ? fallback : message;
6000
+ /** The model's answer as lines: fences and a trailing prose apology dropped,
6001
+ * carriage returns and NULs (which git and the request validator refuse)
6002
+ * gone, leading blank lines gone. */
6003
+ function answerLines(value) {
6004
+ const kept = value.replace(/\0/gu, "").split(/\r?\n/u).map((line) => line.trimEnd()).filter((line) => !line.trim().startsWith("```"));
6005
+ while (kept.length > 0 && kept[0]?.trim() === "") kept.shift();
6006
+ while (kept.length > 0 && kept.at(-1)?.trim() === "") kept.pop();
6007
+ return kept;
6008
+ }
6009
+ function unquoted(line) {
6010
+ return line.trim().replace(/^['"`]+|['"`]+$/gu, "").trim();
6011
+ }
6012
+ /** Subject line, then a body when the model wrote one: the subject must at
6013
+ * least look like one, the body is kept as the bullets the model listed, and the
6014
+ * whole thing is cut at a line boundary under the commit-message cap. */
6015
+ function normalizeCommitMessage(value, fallback) {
6016
+ const lines = answerLines(value);
6017
+ const subject = unquoted(lines[0] ?? "");
6018
+ if (subject.length === 0 || subject.length > MAX_SUBJECT_KEPT_CHARS) return fallback;
6019
+ const body = lines.slice(1);
6020
+ while (body.length > 0 && body[0]?.trim() === "") body.shift();
6021
+ const kept = [subject];
6022
+ if (body.length > 0) kept.push("");
6023
+ let previousBlank = false;
6024
+ for (const line of body) {
6025
+ const blank = line.trim() === "";
6026
+ if (blank && previousBlank) continue;
6027
+ previousBlank = blank;
6028
+ if ([...kept, line].join("\n").length > MAX_MESSAGE_CHARS) break;
6029
+ kept.push(line);
6030
+ }
6031
+ while (kept.length > 1 && kept.at(-1)?.trim() === "") kept.pop();
6032
+ return kept.join("\n");
6033
+ }
6034
+ /** `Title:` on its own line, then the `Summary:` / `Changes:` body the
6035
+ * create-pr arm insists on. Anything else is the caller's fallback. */
6036
+ function parsePullRequestText(value) {
6037
+ const lines = answerLines(value);
6038
+ const titleAt = lines.findIndex((line) => /^title:/iu.test(line.trim()));
6039
+ if (titleAt < 0) return void 0;
6040
+ const title = unquoted(lines[titleAt].trim().replace(/^title:/iu, ""));
6041
+ if (title.length === 0 || title.length > MAX_PR_TITLE_CHARS) return void 0;
6042
+ const summaryAt = lines.findIndex((line, index) => index > titleAt && /^summary:/iu.test(line.trim()));
6043
+ if (summaryAt < 0) return void 0;
6044
+ const body = lines.slice(summaryAt).map((line) => line.trim() === "" ? "" : line).join("\n").trim();
6045
+ return validPullRequestBody(body) && body.length <= MAX_PR_TEXT_CHARS ? {
6046
+ title,
6047
+ body
6048
+ } : void 0;
6049
+ }
6050
+ function fallbackPullRequestText(commits, files) {
6051
+ const title = commits[0]?.subject ?? fallbackCommitMessage(files);
6052
+ const changes = commits.length > 0 ? commits.map((commit) => commit.subject) : files.map((file) => `Update ${file.path}`);
6053
+ return {
6054
+ title,
6055
+ body: `Summary: ${title}\n\nChanges:\n${(changes.length > 0 ? changes : [title]).map((item) => `- ${item}`).join("\n")}`
6056
+ };
6057
+ }
6058
+ function parseBranchCommits(output) {
6059
+ return output.split("\0").flatMap((record) => {
6060
+ const [subject = "", ...rest] = record.replace(/^\r?\n/u, "").split(/\r?\n/u);
6061
+ if (subject.trim().length === 0) return [];
6062
+ return [{
6063
+ subject: subject.trim().slice(0, 140),
6064
+ body: rest.join("\n").trim()
6065
+ }];
6066
+ }).slice(0, MAX_PR_COMMITS);
6067
+ }
6068
+ function boundedPatch(patch) {
6069
+ return patch.length > MAX_GENERATE_PATCH_CHARS ? {
6070
+ text: patch.slice(0, MAX_GENERATE_PATCH_CHARS),
6071
+ truncated: true
6072
+ } : {
6073
+ text: patch,
6074
+ truncated: false
6075
+ };
5940
6076
  }
5941
6077
  function validPrUrl(value) {
5942
6078
  try {
@@ -5973,19 +6109,126 @@ var RepositoryActionService = class {
5973
6109
  const preview = await this.#preview(cwd);
5974
6110
  if (preview.fingerprint !== fingerprint) throw new RepositoryActionError("repository-changed", "Repository changes have changed. Refresh the commit panel.");
5975
6111
  const fallback = fallbackCommitMessage(preview.files);
6112
+ const git = await this.#git();
6113
+ const recent = await this.#run(git, [
6114
+ "log",
6115
+ "--no-merges",
6116
+ "--format=%s",
6117
+ "-n",
6118
+ String(MAX_RECENT_SUBJECTS),
6119
+ "HEAD",
6120
+ "--"
6121
+ ], preview.root, GIT_TIMEOUT_MS$1);
6122
+ const subjects = recent.exitCode === 0 && !recent.lossy ? recent.stdout.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0) : [];
6123
+ const patch = boundedPatch(preview.patch);
5976
6124
  const prompt = [
5977
- "Write one concise English git commit subject (imperative mood, maximum 72 characters).",
5978
- "Return only the subject without quotes, markdown, body, or explanation.",
6125
+ "Write a git commit message for the changes below, in English.",
6126
+ `Line 1 is the subject: imperative mood, at most ${MAX_SUBJECT_CHARS} characters, saying what the change does rather than which files it touches.`,
6127
+ "If the diff contains more than one independent change, leave line 2 blank and then list each change on its own line starting with \"- \", one sentence each, describing what changed as the diff shows it.",
6128
+ "A change with a single purpose gets the subject line only.",
6129
+ "Describe only what the diff shows. Do not invent motivation, do not summarise the file list, and do not mention that the diff is truncated.",
6130
+ "Return only the message: no quotes, no markdown fences, no explanation before or after it.",
6131
+ ...subjects.length > 0 ? [`Recent commit subjects of this repository, as a style reference only:\n${subjects.map((subject) => `- ${subject}`).join("\n")}`] : [],
5979
6132
  `Files: ${preview.files.map((file) => file.path).join(", ")}`,
5980
- `Diff:\n${preview.patch.slice(0, 24576)}`
6133
+ ...patch.truncated || preview.truncated ? ["The diff below is cut short; the file list above is complete."] : [],
6134
+ `Diff:\n${patch.text}`
5981
6135
  ].join("\n");
5982
6136
  try {
5983
- const result = await this.#run(this.#claudeExecutable, GENERATE_ARGUMENTS.concat(prompt), preview.root, GENERATE_TIMEOUT_MS);
5984
- return result.exitCode === 0 && !result.lossy ? normalizedGeneratedMessage(result.stdout, fallback) : fallback;
6137
+ const result = await this.#run(this.#claudeExecutable, GENERATE_ARGUMENTS, preview.root, GENERATE_TIMEOUT_MS, MAX_OUTPUT_BYTES$1, GENERATE_ENV, prompt);
6138
+ return result.exitCode === 0 && !result.lossy ? normalizeCommitMessage(result.stdout, fallback) : fallback;
5985
6139
  } catch {
5986
6140
  return fallback;
5987
6141
  }
5988
6142
  }
6143
+ /** Title and description for the pull request the branch would open: the
6144
+ * commits and diff since the base, plus whatever is still uncommitted,
6145
+ * since create-pr commits that first. Base is the named branch on origin,
6146
+ * else origin's default; with neither, the tree alone. */
6147
+ async generatePullRequest(cwd, fingerprint, baseBranch) {
6148
+ const preview = await this.#preview(cwd);
6149
+ if (preview.fingerprint !== fingerprint) throw new RepositoryActionError("repository-changed", "Repository changes have changed. Refresh the commit panel.");
6150
+ const git = await this.#git();
6151
+ const base = await this.#baseRef(git, preview.root, baseBranch);
6152
+ let commits = [];
6153
+ let branchPatch = "";
6154
+ let branchTruncated = false;
6155
+ if (base !== void 0) {
6156
+ const log = await this.#run(git, [
6157
+ "log",
6158
+ "--no-merges",
6159
+ "--format=%s%n%b%x00",
6160
+ "-n",
6161
+ String(51),
6162
+ `${base}..HEAD`,
6163
+ "--"
6164
+ ], preview.root, GIT_TIMEOUT_MS$1);
6165
+ if (log.exitCode === 0 && !log.lossy) commits = parseBranchCommits(log.stdout);
6166
+ const funcname = await diffFuncnameArgs();
6167
+ const diff = await this.#run(git, [
6168
+ ...funcname,
6169
+ "diff",
6170
+ "--no-ext-diff",
6171
+ "--no-color",
6172
+ "--unified=3",
6173
+ `${base}...HEAD`,
6174
+ "--",
6175
+ ":(exclude)WARP.md",
6176
+ ":(exclude)**/WARP.md"
6177
+ ], preview.root, GIT_TIMEOUT_MS$1, MAX_OUTPUT_BYTES$1);
6178
+ if (diff.exitCode === 0) {
6179
+ branchPatch = diff.stdout;
6180
+ branchTruncated = diff.lossy;
6181
+ }
6182
+ }
6183
+ const fallback = fallbackPullRequestText(commits, preview.files);
6184
+ const patch = boundedPatch([branchPatch, preview.patch].filter((part) => part.length > 0).join("\n"));
6185
+ const prompt = [
6186
+ "Write the title and description of a GitHub pull request for the changes below, in English.",
6187
+ "Answer in exactly this shape and nothing else:",
6188
+ `Title: <imperative title, at most ${MAX_SUBJECT_CHARS} characters, saying what the pull request does>`,
6189
+ "Summary: <one sentence saying what the pull request achieves as a whole>",
6190
+ "",
6191
+ "Changes:",
6192
+ "- <one line per independent change, describing what changed in the code>",
6193
+ "",
6194
+ "List every independent change the diff contains as its own bullet. Do not merge unrelated changes into one bullet, and do not describe anything the diff does not show.",
6195
+ "No markdown headings, no quotes, no fences, no text before \"Title:\" or after the last bullet.",
6196
+ ...commits.length > 0 ? [`Commits on this branch, newest first:\n${commits.map((commit) => commit.body.length > 0 ? `- ${commit.subject}\n${commit.body}` : `- ${commit.subject}`).join("\n")}`] : [],
6197
+ ...preview.files.length > 0 ? [`Uncommitted files that will go into the same pull request: ${preview.files.map((file) => file.path).join(", ")}`] : [],
6198
+ ...patch.truncated || branchTruncated || preview.truncated ? ["The diff below is cut short; the commit list is complete."] : [],
6199
+ `Diff:\n${patch.text}`
6200
+ ].join("\n");
6201
+ try {
6202
+ const result = await this.#run(this.#claudeExecutable, GENERATE_ARGUMENTS, preview.root, GENERATE_TIMEOUT_MS, MAX_OUTPUT_BYTES$1, GENERATE_ENV, prompt);
6203
+ return (result.exitCode === 0 && !result.lossy ? parsePullRequestText(result.stdout) : void 0) ?? fallback;
6204
+ } catch {
6205
+ return fallback;
6206
+ }
6207
+ }
6208
+ /** `origin/<branch>` when the named branch exists on origin, else the branch
6209
+ * origin's HEAD points at. Neither on a checkout that never fetched. */
6210
+ async #baseRef(git, root, baseBranch) {
6211
+ const named = baseBranch?.trim() ?? "";
6212
+ if (named.length > 0) {
6213
+ if (/[\0\r\n\s]|\.\.|^-/u.test(named)) return void 0;
6214
+ const verified = await this.#run(git, [
6215
+ "rev-parse",
6216
+ "--verify",
6217
+ "--quiet",
6218
+ "--symbolic-full-name",
6219
+ `refs/remotes/origin/${named}`
6220
+ ], root, GIT_TIMEOUT_MS$1);
6221
+ return verified.exitCode === 0 && !verified.lossy && verified.stdout.trim() === `refs/remotes/origin/${named}` ? `origin/${named}` : void 0;
6222
+ }
6223
+ const head = await this.#run(git, [
6224
+ "symbolic-ref",
6225
+ "--quiet",
6226
+ "--short",
6227
+ "refs/remotes/origin/HEAD"
6228
+ ], root, GIT_TIMEOUT_MS$1);
6229
+ const ref = head.stdout.trim();
6230
+ return head.exitCode === 0 && !head.lossy && /^origin\/[^\s]+$/u.test(ref) ? ref : void 0;
6231
+ }
5989
6232
  execute(cwd, request) {
5990
6233
  const operation = this.#pending.then(() => this.#execute(cwd, request));
5991
6234
  this.#pending = operation.then(() => void 0, () => void 0);
@@ -6378,18 +6621,18 @@ var RepositoryActionService = class {
6378
6621
  if (result.exitCode !== 0 || result.lossy) throw new RepositoryActionError(code, message);
6379
6622
  return result;
6380
6623
  }
6381
- #run(executable, args, cwd, timeoutMs, maxBytes = MAX_OUTPUT_BYTES$1) {
6624
+ #run(executable, args, cwd, timeoutMs, maxBytes = MAX_OUTPUT_BYTES$1, env = {}, stdin) {
6382
6625
  return collect$1(this.#runtime.spawn({
6383
6626
  argv: [executable, ...args],
6384
6627
  cwd,
6385
6628
  stdio: {
6386
- stdin: "ignore",
6629
+ stdin: stdin === void 0 ? "ignore" : { data: stdin },
6387
6630
  stdout: { maxBytes },
6388
6631
  stderr: { maxBytes: MAX_OUTPUT_BYTES$1 }
6389
6632
  },
6390
6633
  graceMs: 1e3,
6391
6634
  signal: AbortSignal.timeout(timeoutMs),
6392
- env: {}
6635
+ env
6393
6636
  }));
6394
6637
  }
6395
6638
  };
@@ -6648,6 +6891,17 @@ function registerRepositoryActionRoute(ctx, service, cwdForSession) {
6648
6891
  value: { message: await service.generateMessage(cwd, string$1(input, "fingerprint")) }
6649
6892
  };
6650
6893
  }
6894
+ if (url.pathname === `/plugins/dsh-claude/repository/action/pull-request`) {
6895
+ if (io.method !== "POST") return {
6896
+ status: 405,
6897
+ value: { error: "method not allowed" }
6898
+ };
6899
+ const input = await readJson$5(io);
6900
+ return {
6901
+ status: 200,
6902
+ value: await service.generatePullRequest(cwd, string$1(input, "fingerprint"), optionalString(input, "baseBranch"))
6903
+ };
6904
+ }
6651
6905
  if (url.pathname === "/plugins/dsh-claude/repository/action") {
6652
6906
  if (io.method !== "POST") return {
6653
6907
  status: 405,
@@ -8762,6 +9016,13 @@ async function isDirectory(path) {
8762
9016
  }
8763
9017
  /** Repository roots behind the touched paths, minus the session's own, in
8764
9018
  * first-seen order. `rootOf` answers undefined outside any repository. */
9019
+ /** Whether `child` lies strictly under `parent`. Compared with one separator:
9020
+ * on Windows git prints forward slashes where Node resolves to backslashes,
9021
+ * and a root can arrive in either form. */
9022
+ function isInside(child, parent) {
9023
+ const slashed = (value) => value.replaceAll("\\", "/").replace(/\/+$/u, "");
9024
+ return slashed(child).startsWith(`${slashed(parent)}/`);
9025
+ }
8765
9026
  async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max, directory = isDirectory) {
8766
9027
  const roots = [];
8767
9028
  const directories = /* @__PURE__ */ new Set();
@@ -8769,7 +9030,7 @@ async function touchedRepositoryRoots(paths, sessionRoot, rootOf, max, directory
8769
9030
  for (const directory of directories) {
8770
9031
  const root = await rootOf(directory);
8771
9032
  if (root === void 0 || root === sessionRoot || roots.includes(root)) continue;
8772
- if (sessionRoot.startsWith(root.endsWith(sep) ? root : root + sep)) continue;
9033
+ if (isInside(sessionRoot, root)) continue;
8773
9034
  roots.push(root);
8774
9035
  if (roots.length >= max) break;
8775
9036
  }