@oh-my-pi/pi-coding-agent 15.2.1 → 15.2.3

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/src/tools/gh.ts CHANGED
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { scheduler } from "node:timers/promises";
5
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
6
 
7
- import { getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
+ import { getWorktreeDir, hashPath, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import * as z from "zod/v4";
9
9
  import type { Settings } from "../config/settings";
10
10
  import githubDescription from "../prompts/tools/github.md" with { type: "text" };
@@ -859,18 +859,8 @@ function sanitizeRemoteName(value: string): string {
859
859
  return sanitized.length > 0 ? `fork-${sanitized}` : "fork";
860
860
  }
861
861
 
862
- /**
863
- * Encode an absolute repository path into a single filesystem-safe segment.
864
- * Mirrors the legacy session-dir encoding used elsewhere in the project: drop
865
- * the leading separator, then collapse `/`, `\\`, and `:` to `-`. The result
866
- * is not strictly injective for pathological inputs (e.g. `/a/b` vs `/a-b`)
867
- * but matches the rest of the codebase and stays human-readable.
868
- */
869
- function encodeRepoPathForFilesystem(repoPath: string): string {
870
- const resolved = path.resolve(repoPath);
871
- const encoded = resolved.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
872
- return encoded || "root";
873
- }
862
+ /** Maximum disambiguation suffixes we try before giving up on a worktree path. */
863
+ const WORKTREE_PATH_MAX_SUFFIX = 100;
874
864
 
875
865
  function toLocalBranchRef(value: string): string {
876
866
  return `refs/heads/${value}`;
@@ -912,25 +902,38 @@ async function requireCurrentGitHead(cwd: string, signal?: AbortSignal): Promise
912
902
  return headSha;
913
903
  }
914
904
 
915
- async function ensureGitWorktreePathAvailable(
916
- worktreePath: string,
905
+ /**
906
+ * Resolve a worktree path that is free of conflicts.
907
+ *
908
+ * Given a `basePath`, return either `basePath` itself or `${basePath}-2`,
909
+ * `${basePath}-3`, … up to {@link WORKTREE_PATH_MAX_SUFFIX} — whichever is the
910
+ * first variant that is **not** registered with git as another worktree and
911
+ * **not** present on disk. The numeric tail salvages two rare cases that
912
+ * would otherwise abort a checkout: stale leftover dirs from an interrupted
913
+ * `git worktree add`, and the (vanishingly unlikely) `hashPath` collision
914
+ * between two repos that happen to produce the same 7-hex digest.
915
+ */
916
+ async function resolveAvailableWorktreePath(
917
+ basePath: string,
917
918
  existingWorktrees: git.GitWorktreeEntry[],
918
- ): Promise<void> {
919
- const normalizedTarget = path.resolve(worktreePath);
920
- const conflictingWorktree = existingWorktrees.find(entry => path.resolve(entry.path) === normalizedTarget);
921
- if (conflictingWorktree) {
922
- throw new ToolError(`worktree path is already registered: ${conflictingWorktree.path}`);
923
- }
924
-
925
- try {
926
- await fs.stat(normalizedTarget);
927
- throw new ToolError(`worktree path already exists: ${normalizedTarget}`);
928
- } catch (error) {
929
- if (isEnoent(error)) {
930
- return;
919
+ ): Promise<string> {
920
+ const registered = new Set(existingWorktrees.map(entry => path.resolve(entry.path)));
921
+ for (let attempt = 0; attempt < WORKTREE_PATH_MAX_SUFFIX; attempt += 1) {
922
+ const candidate = attempt === 0 ? basePath : `${basePath}-${attempt + 1}`;
923
+ const normalized = path.resolve(candidate);
924
+ if (registered.has(normalized)) continue;
925
+ try {
926
+ await fs.stat(normalized);
927
+ } catch (error) {
928
+ if (isEnoent(error)) {
929
+ return candidate;
930
+ }
931
+ throw error;
931
932
  }
932
- throw error;
933
933
  }
934
+ throw new ToolError(
935
+ `could not find an unused worktree path under ${basePath} (tried ${WORKTREE_PATH_MAX_SUFFIX} suffixes)`,
936
+ );
934
937
  }
935
938
 
936
939
  function selectPrCloneUrl(originUrl: string | undefined, repo: Pick<GhRepoViewData, "url" | "sshUrl">): string {
@@ -2939,7 +2942,7 @@ async function checkoutPullRequest(
2939
2942
  const repoRoot = await requireGitRepoRoot(session.cwd, signal);
2940
2943
  const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
2941
2944
  const localBranch = `pr-${prNumber}`;
2942
- const worktreePath = path.join(getWorktreesDir(), encodeRepoPathForFilesystem(primaryRepoRoot), localBranch);
2945
+ const worktreePath = getWorktreeDir(`${prNumber}-${hashPath(primaryRepoRoot)}`);
2943
2946
 
2944
2947
  // Every git mutation against `repoRoot` from here on must run under the
2945
2948
  // per-repo lock. Worktrees of the same primary repo share `.git/config`,
@@ -3003,9 +3006,9 @@ async function checkoutPullRequest(
3003
3006
  signal,
3004
3007
  );
3005
3008
 
3006
- const finalWorktreePath = existingWorktree?.path ?? worktreePath;
3009
+ let finalWorktreePath = existingWorktree?.path ?? worktreePath;
3007
3010
  if (!existingWorktree) {
3008
- await ensureGitWorktreePathAvailable(finalWorktreePath, existingWorktrees);
3011
+ finalWorktreePath = await resolveAvailableWorktreePath(worktreePath, existingWorktrees);
3009
3012
  await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
3010
3013
  await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
3011
3014
  }
package/src/utils/git.ts CHANGED
@@ -1157,6 +1157,10 @@ export const worktree = {
1157
1157
  async list(cwd: string, signal?: AbortSignal): Promise<GitWorktreeEntry[]> {
1158
1158
  return parseWorktreeList(await runText(cwd, ["worktree", "list", "--porcelain"], { readOnly: true, signal }));
1159
1159
  },
1160
+
1161
+ async prune(cwd: string, signal?: AbortSignal): Promise<void> {
1162
+ await runEffect(cwd, ["worktree", "prune"], { signal });
1163
+ },
1160
1164
  };
1161
1165
 
1162
1166
  // ════════════════════════════════════════════════════════════════════════════