From 647e0f541654811be7fa0d7792f2e905a102e281 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 16:24:18 -0400 Subject: [PATCH] fix(pstack): preserve worktree paths containing spaces Signed-off-by: Rudy Celekli --- .../poteto-mode/scripts/worktree-audit.sh | 8 +-- .../scripts/worktree-audit.test.mjs | 51 +++++++++++++++++++ .../poteto-mode/scripts/worktree-audit.sh | 8 +-- .../scripts/worktree-audit.test.mjs | 51 +++++++++++++++++++ 4 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.mjs create mode 100644 pstack/skills/poteto-mode/scripts/worktree-audit.test.mjs diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh index 743c4e925..1cbd37fe3 100755 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh @@ -28,7 +28,7 @@ cd "$repo" || exit 1 git rev-parse --git-dir >/dev/null 2>&1 || { echo "error: not a git repo: $repo" >&2; exit 1; } # Main worktree is the first entry; everything else is a candidate. -main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') +main_wt=$(git worktree list --porcelain | sed -n 's/^worktree //p' | head -1) if [ -z "$base" ]; then for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do @@ -69,7 +69,7 @@ now=$(date +%s) echo "# base: $base" printf "SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n" -git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; do +git worktree list --porcelain | sed -n 's/^worktree //p' | while read -r wt; do [ "$wt" = "$main_wt" ] && continue size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}') @@ -104,8 +104,8 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; # followed by "/" or a quote so glint-482 does not match glint-482-r37. last="-"; last_ts=0 if [ -d "$transcripts" ]; then - f=$(rg -l -e "${wt}/" -e "${wt}\"" "$transcripts" 2>/dev/null \ - | xargs stat -f '%m %N' 2>/dev/null | sort -rn | head -1) + f=$(rg -l -0 -F -e "${wt}/" -e "${wt}\"" "$transcripts" 2>/dev/null \ + | xargs -0 stat -f '%m %N' 2>/dev/null | sort -rn | head -1) if [ -n "$f" ]; then last_ts=$(echo "$f" | awk '{print $1}') last=$(date -r "$last_ts" '+%Y-%m-%d' 2>/dev/null); fi fi diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.mjs b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.mjs new file mode 100644 index 000000000..19aad300c --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.mjs @@ -0,0 +1,51 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import { test } from "node:test"; + +const script = fileURLToPath(new URL("worktree-audit.sh", import.meta.url)); + +for (const spaced of [false, true]) { + test(`audits worktree paths ${spaced ? "with" : "without"} spaces`, () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "worktree-audit-"))); + const repo = join(directory, spaced ? "main checkout" : "main"); + const worktree = join(directory, spaced ? "feature checkout" : "feature"); + const bin = join(directory, "bin"); + mkdirSync(repo); + mkdirSync(bin); + // Keep the fixture offline, regardless of the caller's forge installation. + for (const command of ["gh", "origin"]) + writeFileSync(join(bin, command), "#!/bin/sh\nprintf '[]\\n'\n", { mode: 0o755 }); + const git = (...args) => { + const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + }; + try { + git("init", "-q"); + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "fixture"); + git("worktree", "add", "-qb", "feature", worktree); + const transcripts = join(directory, ".cursor/projects", repo.slice(1).replaceAll("/", "-"), "agent-transcripts"); + mkdirSync(transcripts, { recursive: true }); + writeFileSync(join(transcripts, spaced ? "chat record.txt" : "chat.txt"), JSON.stringify({ cwd: worktree })); + const result = spawnSync("bash", [script, "-b", "HEAD", repo], { + encoding: "utf8", + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, HOME: directory }, + }); + assert.equal(result.status, 0, result.stderr); + const rows = result.stdout.trim().split("\n").slice(2).map((row) => row.split("\t")); + assert.equal(rows.length, 1, result.stdout); + assert.equal(rows[0][8], worktree); + assert.equal(rows[0][2], "YES"); + assert.equal(rows[0][3], "clean"); + if (process.platform === "darwin") { + assert.notEqual(rows[0][6], "-"); + assert.equal(rows[0][7], "verify-recent-chat"); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +} diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 743c4e925..1cbd37fe3 100755 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -28,7 +28,7 @@ cd "$repo" || exit 1 git rev-parse --git-dir >/dev/null 2>&1 || { echo "error: not a git repo: $repo" >&2; exit 1; } # Main worktree is the first entry; everything else is a candidate. -main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') +main_wt=$(git worktree list --porcelain | sed -n 's/^worktree //p' | head -1) if [ -z "$base" ]; then for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do @@ -69,7 +69,7 @@ now=$(date +%s) echo "# base: $base" printf "SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n" -git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; do +git worktree list --porcelain | sed -n 's/^worktree //p' | while read -r wt; do [ "$wt" = "$main_wt" ] && continue size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}') @@ -104,8 +104,8 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; # followed by "/" or a quote so glint-482 does not match glint-482-r37. last="-"; last_ts=0 if [ -d "$transcripts" ]; then - f=$(rg -l -e "${wt}/" -e "${wt}\"" "$transcripts" 2>/dev/null \ - | xargs stat -f '%m %N' 2>/dev/null | sort -rn | head -1) + f=$(rg -l -0 -F -e "${wt}/" -e "${wt}\"" "$transcripts" 2>/dev/null \ + | xargs -0 stat -f '%m %N' 2>/dev/null | sort -rn | head -1) if [ -n "$f" ]; then last_ts=$(echo "$f" | awk '{print $1}') last=$(date -r "$last_ts" '+%Y-%m-%d' 2>/dev/null); fi fi diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.test.mjs b/pstack/skills/poteto-mode/scripts/worktree-audit.test.mjs new file mode 100644 index 000000000..19aad300c --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.test.mjs @@ -0,0 +1,51 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import { test } from "node:test"; + +const script = fileURLToPath(new URL("worktree-audit.sh", import.meta.url)); + +for (const spaced of [false, true]) { + test(`audits worktree paths ${spaced ? "with" : "without"} spaces`, () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "worktree-audit-"))); + const repo = join(directory, spaced ? "main checkout" : "main"); + const worktree = join(directory, spaced ? "feature checkout" : "feature"); + const bin = join(directory, "bin"); + mkdirSync(repo); + mkdirSync(bin); + // Keep the fixture offline, regardless of the caller's forge installation. + for (const command of ["gh", "origin"]) + writeFileSync(join(bin, command), "#!/bin/sh\nprintf '[]\\n'\n", { mode: 0o755 }); + const git = (...args) => { + const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + }; + try { + git("init", "-q"); + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-qm", "fixture"); + git("worktree", "add", "-qb", "feature", worktree); + const transcripts = join(directory, ".cursor/projects", repo.slice(1).replaceAll("/", "-"), "agent-transcripts"); + mkdirSync(transcripts, { recursive: true }); + writeFileSync(join(transcripts, spaced ? "chat record.txt" : "chat.txt"), JSON.stringify({ cwd: worktree })); + const result = spawnSync("bash", [script, "-b", "HEAD", repo], { + encoding: "utf8", + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, HOME: directory }, + }); + assert.equal(result.status, 0, result.stderr); + const rows = result.stdout.trim().split("\n").slice(2).map((row) => row.split("\t")); + assert.equal(rows.length, 1, result.stdout); + assert.equal(rows[0][8], worktree); + assert.equal(rows[0][2], "YES"); + assert.equal(rows[0][3], "clean"); + if (process.platform === "darwin") { + assert.notEqual(rows[0][6], "-"); + assert.equal(rows[0][7], "verify-recent-chat"); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}