From 6eda1ef64e2089f2357bdc7cea7dd7724deea414 Mon Sep 17 00:00:00 2001 From: jony376 Date: Mon, 20 Jul 2026 14:46:16 -0700 Subject: [PATCH] fix(engine): trim attempt-id slug after truncation Reorder slugifyAttemptId so leading/trailing '-' and '.' are stripped after the 64-char cap, preventing git-invalid worktree branch names that end in '.'. Co-authored-by: Cursor --- .../src/miner/worktree-plan.ts | 7 +++++- test/unit/worktree-plan.test.ts | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/loopover-engine/src/miner/worktree-plan.ts b/packages/loopover-engine/src/miner/worktree-plan.ts index e052ad074c..17a09e50b8 100644 --- a/packages/loopover-engine/src/miner/worktree-plan.ts +++ b/packages/loopover-engine/src/miner/worktree-plan.ts @@ -36,13 +36,18 @@ const MAX_SLUG_LENGTH = 64; /** Deterministically slugify an attempt id into a filesystem- and git-ref-safe token (same id → same slug). */ function slugifyAttemptId(attemptId: string): string { + // Trim leading/trailing `-`/`.` both before and after truncation (#7528): `.` is allowed mid-slug, so a + // pre-trim-only pass misses the case where slice(0, MAX) lands on a literal `.`/`-` and leaves a + // git-ref-invalid trailing separator (git check-ref-format rejects refs ending in `.`). const slug = attemptId .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[-.]+|[-.]+$/g, "") + .slice(0, MAX_SLUG_LENGTH) .replace(/^[-.]+|[-.]+$/g, ""); if (!slug) throw new Error("invalid_attempt_id"); - return slug.slice(0, MAX_SLUG_LENGTH); + return slug; } /** diff --git a/test/unit/worktree-plan.test.ts b/test/unit/worktree-plan.test.ts index 0cd45fc484..91487bcb1e 100644 --- a/test/unit/worktree-plan.test.ts +++ b/test/unit/worktree-plan.test.ts @@ -38,8 +38,32 @@ describe("planWorktree (#4269)", () => { expect(long.branchName).toBe(`${WORKTREE_BRANCH_PREFIX}${"x".repeat(64)}`); }); + it("strips a trailing '.' produced by truncating at a mid-slug dot (#7528)", () => { + // 63 safe chars + '.' + more content past the cap → pre-trim keeps the dot (not at the edge), + // slice(0, 64) ends in '.', and the post-truncation trim must remove it for git ref-format. + const attemptId = `${"a".repeat(63)}.${"b".repeat(10)}`; + const plan = planWorktree({ repoPath: "/repo", attemptId }); + expect(plan.branchName).toBe(`${WORKTREE_BRANCH_PREFIX}${"a".repeat(63)}`); + expect(plan.branchName.endsWith(".")).toBe(false); + expect(plan.branchName.endsWith("-")).toBe(false); + }); + + it("strips a trailing '-' produced by truncating at a mid-slug hyphen (#7528)", () => { + const attemptId = `${"a".repeat(63)}-${"b".repeat(10)}`; + const plan = planWorktree({ repoPath: "/repo", attemptId }); + expect(plan.branchName).toBe(`${WORKTREE_BRANCH_PREFIX}${"a".repeat(63)}`); + expect(plan.branchName.endsWith("-")).toBe(false); + }); + + it("leaves a clean truncation boundary untouched when the 64th char is not a separator (#7528)", () => { + const attemptId = `${"a".repeat(64)}${"b".repeat(10)}`; + const plan = planWorktree({ repoPath: "/repo", attemptId }); + expect(plan.branchName).toBe(`${WORKTREE_BRANCH_PREFIX}${"a".repeat(64)}`); + }); + it("rejects an attempt id that sanitizes to nothing", () => { expect(() => planWorktree({ repoPath: "/repo", attemptId: " --- " })).toThrow(/invalid_attempt_id/); + expect(() => planWorktree({ repoPath: "/repo", attemptId: "..." })).toThrow(/invalid_attempt_id/); }); });