Skip to content

feat(machines): normalize-and-accept branch/project names instead of rejecting - #2016

Merged
2witstudios merged 20 commits into
masterfrom
pu/machine-name-normalization
Jul 12, 2026
Merged

2witstudios merged 20 commits into
masterfrom
pu/machine-name-normalization

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 12, 2026 •

Copy link
Copy Markdown
Owner

Sub-task 1 of the Terminal UX redesign (Terminal→Machine GA epic), node c6lmwa9at0372ac0icaq9ncc. Independent of the tree/pane rewrite and the Development surface — touches no UI.

The problem

Branch and project name handling validated and rejected. isValidBranchName (branch-session.ts) and isValidProjectName (project-paths.ts) are boolean gates, so a user who typed My Cool Feature got an error and had to guess the charset a git ref allows.

The change: normalize and accept

Type anything; it becomes a valid name.

you type branch project
My Cool Feature my-cool-feature my-cool-feature
feat/JIRA-123 Fix!! feat/jira-123-fix feat-jira-123-fix
../escape escape escape
a.lock/b a-lock/b a.lock-b
HEAD head HEAD
Release-2.0 Release-2.0 (untouched) Release-2.0
日本語 xnbamha (distinct, never shared) xnbamha
rejected (400) rejected (400)

Two pure functions — normalizeBranchName, normalizeProjectName — colocated with the predicates they must satisfy, over a shared slugifySegment primitive (name-slug.ts). Three rules do the real work, and each exists because of a bug found in review:

1. A name the predicate already accepts is returned untouched. Normalization exists to accept text that would be rejected, never to rewrite text that already worked.

2. When the ASCII charset destroys identity-bearing content, a digest keeps the name distinct. Only structural noise (.., ., ) is dropped — which is what still collapses ../escape to escape.

3. The digest hashes the name's identity (folded, lowercased, ASCII-structure collapsed), not its raw text — so 日本語 stays apart from 한국어 without also splitting MY 🚀 FEATURE from my 🚀 feature.

Six real bugs caught in review — every one a silent wrong answer

  1. .lock on inner segments (Codex). Git forbids a .lock ending on every slash-separated component, not just the ref's tail, so feature/a.lock/foo normalized "cleanly", passed the predicate, then died at git checkout -b. Now rewritten per segment; FORBIDDEN_SEGMENT_RE tightened to \.lock(\/|$) to state git's real rule.

  2. Case/underscore regression. Git refs are case-sensitive. Slugifying Release-2.0 → release-2.0 made git checkout -b release-2.0 origin/release-2.0 miss the real upstream branch, and the existing fallback then silently created a new empty branch off HEAD while origin/Release-2.0 sat untouched. Uppercase was valid pre-PR, so this PR would have introduced it. → Rule 1.

  3. Non-ASCII names collapsed onto one branch — cross-attach. A branch name is a branch-terminal's identity (hashed into the Sprite session key, used as the store's lookup key). Every non-Latin name slugified to nothing and fell back to a shared branch, so spawnBranch('日本語') then spawnBranch('한국어') returned resumed: true and handed the second user the first user's Sprite and filesystem. The default outcome for every non-Latin-script user. → Rule 2.

  4. Partial annihilation. 日本語 feature → feature — a different branch that may already exist and belong to someone else. Digesting only total annihilation wasn't enough. → Rule 2, generalized.

  5. The digest split names it was meant to protect. It hashed raw text while the slug beside it was folded, so MY 🚀 FEATURE and my 🚀 feature minted two branches, two Sprites, two clones — for one name. → Rule 3.

  6. Nameless names addressed real resources. Every nameless string normalizes to a fallback (branch / project) — and a fallback is a real name someone can own. So DELETE /api/machines/projects?name=.. rm -rf'd the project called project, and POST /api/machines/branches {branchName: "."} attached the caller to the branch-terminal called branch, handing back its Sprite. This family produced four separate bugs (blank → whitespace → ./../// → the DELETE path), because each fix used a narrower predicate than the normalizer itself. The guards now key on the normalizer's own hasNameContent, verified as a two-way property: every input that reaches a fallback is rejected, and no real name (!!!, ___, 日本語, 🚀) is over-blocked.

Also: HEAD is git's reserved symbolic ref and the only such name in our charset (head, Head, FETCH_HEAD are all legal — verified against git); the length cut was itself lossy (two over-long names cut to one ref, so the cut now reserves room for a digest); and untrimmed input ("Release-2.0 ") skipped the pass-through and downcased.

A deliberately accepted limitation, stated rather than silent

Our charset is narrower than git's. Git accepts _wip, fix#123, v1.0+build, 日本語; we don't, because the charset is a confinement boundary (the name lands in git checkout -b argv, a store key, and a scope key). So those names are still rewritten — and if one names an existing upstream branch, git's fallback creates a new empty branch instead.

spawnBranch therefore returns createdNew (surfaced on the API response and threaded through the hook): did we check out an existing upstream branch, or create a brand-new one? Nothing renders it yet — that belongs with the spawn-flow sub-task — but the outcome is now knowable rather than invisible. Widening the charset to git's full rule is a separate, security-reviewable change.

The invariant

The isValid* predicates remain the contract:

  • isValid*(normalize*(x)) === true for every input
  • normalization is idempotent
  • every normalized project name resolves inside PROJECTS_ROOT
  • distinct names stay distinct (no cross-attach); names differing only in case / ASCII punctuation / whitespace collapse to one

Server-side wiring (authoritative)

Normalization lives in the services, not the routes, so it cannot be bypassed. Every name-keyed path normalizes — spawnBranch, attachBranch, killBranch, listBranches (both projectName and branchName), addProject, removeProject — so whatever text created a thing can also find, attach to, and delete it. Canonical names re-fetched from the list APIs pass through unchanged, because normalization is idempotent.

Both POST routes echo the service's canonical name rather than the request's. The existing hooks already re-fetch and render the server's record, so no client change is needed.

planSpawnBranch and the invalid_branch_name denial are deleted (feature is behind CODE_EXECUTION_ENABLED, no compat shim). invalid_name survives in planAddProject only as the unreachable second confinement gate.

Validation

  • Fuzzed 30k+ inputs (random unicode, ligatures, fullwidth, ㍿, combining marks, lone surrogates, CJK, emoji, both truncation boundaries): invariant, idempotency, and confinement all hold.
  • Ground truth, not just our own predicate: every distinct normalized branch name accepted by real git check-ref-format --branch.
  • No-collision and same-name-collapse both verified as properties, not just examples.
  • 438 lib + 154 web machine tests. @pagespace/lib and apps/web typecheck clean; eslint clean.
  • The tests are mutation-verified: each regression test was confirmed to FAIL when its own fix is reverted, so none of them is passing by accident.
  • The final commit is a behaviour-preserving refactor (the digest-disambiguation rule and the truncate-with-digest block were each written twice; they now live once in name-slug.ts as disambiguateSlug / truncateWithDigest). Proved rather than asserted: byte-identical output across 514,760 differential inputs vs the pre-refactor implementation.

Known issues NOT fixed here (deliberately)

A pre-existing concurrency race in addProject. Two concurrent adds of the same project both clone into one path; the loser's clone fails because the winner's succeeded, and the cleanup rm -rfs the winner's checkout. Normalization widens the trigger from "two callers typed the same text" to "two callers typed the same name" (My Repo / my repo), so it's called out in-code.

I attempted a fix three times and each attempt introduced a worse bug (reserving the name before the clone makes the row visible mid-clone, so a delete-then-re-add leaves an orphan directory and returns ok: true). addProject's control flow is therefore restored exactly to master's — only the normalized name differs. The real fix is structural and earns its own PR: make the clone path unique per row (${name}-${id}) so no two operations can own one directory, and make every destructive store op id-scoped. removeProject has the identical by-name TOCTOU on master today and needs the same treatment.

The sibling agent-terminal / files / diff surfaces don't normalize their name params, so a direct API caller can POST /api/machines/branches {branchName: "My Cool Feature"} successfully but get branch_not_found from POST /api/machines/agent-terminals with the same string. Harmless for the UI (it always passes canonical names back from the list APIs). Closing it needs the realtime session-key path too, so it's a follow-up rather than a drive-by.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD

Summary by CodeRabbit

  • New Features

    • Branch and project names now accept free text and are automatically normalized into safe, canonical names.
    • Responses display the normalized name and indicate whether a branch was newly created or resumed.
    • Existing branches and projects can be found, attached, or removed using their original free-text names.
  • Bug Fixes

    • Blank or whitespace-only names are rejected with a clear validation error.
    • Unsafe names and naming collisions are handled consistently and securely.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Machine branch and project names now accept free text, normalize server-side into canonical slugs, use those values for lookup and persistence, and return them through API responses. Branch provisioning metadata, validation, project confinement, and related tests were updated.

Changes

Machine name normalization

Layer / File(s) Summary
Project name normalization
packages/lib/src/services/machines/name-slug.ts, packages/lib/src/services/machines/project-paths.ts, packages/lib/src/services/machines/machine-projects.ts, packages/lib/src/services/machines/__tests__/*project*
Adds shared slugification and project normalization, applies normalized names before path resolution and duplicate checks, persists canonical names, and validates path confinement and normalization invariants.
Branch name normalization
packages/lib/src/services/machines/branch-session.ts, packages/lib/src/services/machines/__tests__/branch-session.test.ts
Adds branch normalization, deterministic digest disambiguation, fallback handling, length trimming, and stricter .lock validation with edge-case coverage.
Branch service integration
packages/lib/src/services/machines/machine-branches.ts, packages/lib/src/services/machines/__tests__/machine-branches.test.ts
Normalizes names during spawning, attachment, listing, and removal; returns canonical branchName values; reports creation state; and reconciles normalized branch collisions.
API canonical responses
apps/web/src/app/api/machines/branches/*, apps/web/src/app/api/machines/projects/*, apps/web/src/hooks/useMachineBranches.ts
Rejects blank names, forwards raw names to services, returns normalized names and creation metadata, and updates endpoint tests and client typing.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MachinesAPI
  participant MachineService
  participant NameNormalizer
  participant Store
  Client->>MachinesAPI: Submit free-text machine name
  MachinesAPI->>MachineService: Forward raw name
  MachineService->>NameNormalizer: Normalize branch or project name
  MachineService->>Store: Lookup or persist canonical name
  Store-->>MachineService: Canonical record
  MachineService-->>MachinesAPI: Normalized result and metadata
  MachinesAPI-->>Client: Canonical name in response
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: machines now normalize and accept branch/project names instead of rejecting them.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/machine-name-normalization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: db509d8b80

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +103 to +104
if (name.endsWith(LOCK_SUFFIX)) {
name = `${name.slice(0, -LOCK_SUFFIX.length)}-lock`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rewrite .lock on every branch path segment

When free text produces an internal .lock segment, such as feature/a.lock /foo normalizing to feature/a.lock/foo, this only rewrites a .lock suffix on the whole ref, so the normalized name is later passed to git checkout -b even though Git rejects it. Git's check-ref-format docs state that no slash-separated component may end with .lock (https://git-scm.com/docs/git-check-ref-format), and git check-ref-format --branch feature/a.lock/foo exits fatal, so these inputs still fail with checkout_failed instead of being normalized and accepted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in b05e949 — thank you, this was a real hole.

You're right that git check-ref-format forbids a .lock ending on every slash-separated component, not just the ref's tail. My rewrite was anchored on the whole name (\.lock$), so feature/a.lock/foo normalized "cleanly", passed the predicate, and then died at git checkout -b with checkout_failed — precisely the failure mode this PR exists to eliminate.

Two changes:

  1. rewriteLockSuffix now runs per segment in the slugify map, and once more after the length cut (the cut only ever lands in the last surviving segment, and can itself mint a .lock out of hotfix.lockdown truncated at 200).
  2. FORBIDDEN_SEGMENT_RE tightened from \.lock$ to \.lock(\/|$), so the predicate now states git's actual rule. This matters beyond the normalizer: the predicate is the contract the invariant is proven against, so a too-permissive predicate made the invariant weaker than it looked.

New pinned cases: a.lock/b → a-lock/b, feature/a.lock /foo → feature/a-lock/foo, plus both added to the gnarly corpus that sweeps validity + idempotency.

Verified against ground truth rather than just our own predicate: I ran every normalized name from the corpus (41 inputs, including x.lock/y.lock/z.lock, a@{b, ../../etc/passwd, 250-char strings) through real git check-ref-format --branch — 41/41 accepted.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/app/api/machines/projects/route.ts (1)

127-127: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

removeProject does not normalize the name before lookup — DELETE with free text will 404.

The PR objectives state "Server-side services normalize names before … lookup." The graph context for removeProject (machine-projects.ts:226-250) shows it calls deps.store.findByName(machineId, name) with the raw, un-normalized name from the DELETE handler. A user who creates a project with "My Cool Feature" (persisted as "my-cool-feature") and then DELETEs using the original free text will get a 404 not_found — inconsistent with addProject and attachBranch, which both normalize before lookup.

Consider normalizing in removeProject (or in the route before calling it) so DELETE accepts the same free text as POST.

Proposed fix: normalize in removeProject
 export async function removeProject({
   machineId,
   name,
   deps,
 }: {
   machineId: string;
   name: string;
   deps: MachineProjectsDeps;
 }): Promise<RemoveProjectResult> {
-  const existing = await deps.store.findByName(machineId, name);
+  const normalized = normalizeProjectName(name);
+  const existing = await deps.store.findByName(machineId, normalized);
   if (!existing) return { ok: false, reason: 'not_found' };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/api/machines/projects/route.ts` at line 127, Normalize the
project name before lookup in removeProject, ensuring its deps.store.findByName
call uses the same normalization behavior as addProject and attachBranch.
Preserve the existing deletion and not-found handling while allowing DELETE
requests to resolve free-text names such as “My Cool Feature”.
apps/web/src/app/api/machines/branches/route.ts (1)

139-139: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Normalize the branch name before killBranch() lookup. The DELETE path passes raw branchName through, but killBranch() looks up the row without normalizing first. A request using the user-entered name instead of the canonical slug will return not_found; match attachBranch() and normalize here too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/api/machines/branches/route.ts` at line 139, Normalize the
branch name before the DELETE path calls killBranch(), matching the
normalization used by attachBranch(). Pass the canonical normalized value in the
killBranch lookup instead of raw branchName.value, while preserving the existing
not_found behavior for genuinely missing branches.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/lib/src/services/machines/machine-projects.ts`:
- Around line 37-49: The removeProject flow still looks up projects using the
raw caller-supplied name. Normalize the name with normalizeProjectName before
performing the lookup and deletion, so original free-text names resolve to the
same canonical slug used by addProject, duplicate checks, clone paths, and
persistence.

---

Outside diff comments:
In `@apps/web/src/app/api/machines/branches/route.ts`:
- Line 139: Normalize the branch name before the DELETE path calls killBranch(),
matching the normalization used by attachBranch(). Pass the canonical normalized
value in the killBranch lookup instead of raw branchName.value, while preserving
the existing not_found behavior for genuinely missing branches.

In `@apps/web/src/app/api/machines/projects/route.ts`:
- Line 127: Normalize the project name before lookup in removeProject, ensuring
its deps.store.findByName call uses the same normalization behavior as
addProject and attachBranch. Preserve the existing deletion and not-found
handling while allowing DELETE requests to resolve free-text names such as “My
Cool Feature”.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 38edc0ca-05bd-4c23-b58e-ff7f25cf17ac

📥 Commits

Reviewing files that changed from the base of the PR and between 654ba81 and b05e949.

📒 Files selected for processing (13)
  • apps/web/src/app/api/machines/branches/__tests__/route.test.ts
  • apps/web/src/app/api/machines/branches/route.ts
  • apps/web/src/app/api/machines/projects/__tests__/route.test.ts
  • apps/web/src/app/api/machines/projects/route.ts
  • packages/lib/src/services/machines/__tests__/branch-session.test.ts
  • packages/lib/src/services/machines/__tests__/machine-branches.test.ts
  • packages/lib/src/services/machines/__tests__/machine-projects.test.ts
  • packages/lib/src/services/machines/__tests__/project-paths.test.ts
  • packages/lib/src/services/machines/branch-session.ts
  • packages/lib/src/services/machines/machine-branches.ts
  • packages/lib/src/services/machines/machine-projects.ts
  • packages/lib/src/services/machines/name-slug.ts
  • packages/lib/src/services/machines/project-paths.ts

Comment thread packages/lib/src/services/machines/machine-projects.ts
@2witstudios
2witstudios force-pushed the pu/machine-name-normalization branch from d5e2bdc to bfa07bf Compare July 12, 2026 16:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/lib/src/services/machines/name-slug.ts (1)

126-158: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider widening the digest to reduce collision risk.

FNV-1a/32-bit gives a real (if small) chance that two distinct non-sluggable names collide on the same slugDigest. Per the file's own docstring, that specific failure mode is severe — it would make spawnBranch hand one user another user's Sprite/filesystem. Widening the hash (e.g., two FNV-1a passes with different seeds, concatenated) would meaningfully shrink that residual risk while staying sync/pure/dependency-free for the browser live-preview use case.

♻️ Example approach to widen the digest
+const FNV_OFFSET_BASIS_2 = 0x9dc5;
+
 export function slugDigest(input: string): string {
-  let hash = FNV_OFFSET_BASIS;
   const text = nameIdentity(input);
-  for (let i = 0; i < text.length; i += 1) {
-    hash ^= text.charCodeAt(i);
-    hash = Math.imul(hash, FNV_PRIME);
-  }
-  return (hash >>> 0).toString(36);
+  let h1 = FNV_OFFSET_BASIS;
+  let h2 = FNV_OFFSET_BASIS ^ FNV_OFFSET_BASIS_2;
+  for (let i = 0; i < text.length; i += 1) {
+    const code = text.charCodeAt(i);
+    h1 = Math.imul(h1 ^ code, FNV_PRIME);
+    h2 = Math.imul(h2 ^ code, FNV_PRIME + 2);
+  }
+  return `${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/src/services/machines/name-slug.ts` around lines 126 - 158,
Widen the digest produced by slugDigest beyond the current single 32-bit FNV-1a
pass to materially reduce collisions between distinct non-sluggable names. Keep
it synchronous, pure, dependency-free, and based on nameIdentity; use an
additional independently seeded FNV-1a pass and combine the unsigned base36
results into the returned token.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/app/api/machines/projects/route.ts`:
- Around line 84-90: Update the validation condition in the projects route to
require body.repoUrl to be a string with non-empty trimmed content, matching the
existing body.name validation and the error message. Preserve the current 400
response for invalid name or repoUrl values.

---

Nitpick comments:
In `@packages/lib/src/services/machines/name-slug.ts`:
- Around line 126-158: Widen the digest produced by slugDigest beyond the
current single 32-bit FNV-1a pass to materially reduce collisions between
distinct non-sluggable names. Keep it synchronous, pure, dependency-free, and
based on nameIdentity; use an additional independently seeded FNV-1a pass and
combine the unsigned base36 results into the returned token.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f17b6695-01f9-4875-9522-43cc44a827d0

📥 Commits

Reviewing files that changed from the base of the PR and between b05e949 and c3ee261.

📒 Files selected for processing (14)
  • apps/web/src/app/api/machines/branches/__tests__/route.test.ts
  • apps/web/src/app/api/machines/branches/route.ts
  • apps/web/src/app/api/machines/projects/__tests__/route.test.ts
  • apps/web/src/app/api/machines/projects/route.ts
  • apps/web/src/hooks/useMachineBranches.ts
  • packages/lib/src/services/machines/__tests__/branch-session.test.ts
  • packages/lib/src/services/machines/__tests__/machine-branches.test.ts
  • packages/lib/src/services/machines/__tests__/machine-projects.test.ts
  • packages/lib/src/services/machines/__tests__/project-paths.test.ts
  • packages/lib/src/services/machines/branch-session.ts
  • packages/lib/src/services/machines/machine-branches.ts
  • packages/lib/src/services/machines/machine-projects.ts
  • packages/lib/src/services/machines/name-slug.ts
  • packages/lib/src/services/machines/project-paths.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • apps/web/src/app/api/machines/projects/tests/route.test.ts
  • packages/lib/src/services/machines/tests/project-paths.test.ts
  • packages/lib/src/services/machines/tests/machine-projects.test.ts
  • apps/web/src/app/api/machines/branches/tests/route.test.ts
  • packages/lib/src/services/machines/project-paths.ts
  • packages/lib/src/services/machines/tests/machine-branches.test.ts
  • packages/lib/src/services/machines/tests/branch-session.test.ts
  • apps/web/src/app/api/machines/branches/route.ts
  • packages/lib/src/services/machines/machine-branches.ts

Comment thread apps/web/src/app/api/machines/projects/route.ts Outdated
2witstudios and others added 15 commits July 12, 2026 13:25
…rejecting

Typing "My Cool Feature" as a branch or project name used to fail with an
error: `isValidBranchName` / `isValidProjectName` were boolean reject gates on
the create path. Now free text is slugified into a valid git ref / path
segment and accepted — `my-cool-feature`.

- `normalizeBranchName` (branch-session.ts) and `normalizeProjectName`
  (project-paths.ts), colocated with the predicates they must satisfy, over a
  shared `slugifySegment` primitive (name-slug.ts): NFKD-fold accents,
  lowercase, replace out-of-charset characters with `-`, collapse separator
  runs (which also destroys `..`), trim edge separators, enforce the existing
  max length, and fall back to `branch`/`project` when nothing survives.
  Branch names keep `/` as structural (git ref namespacing) and normalize each
  segment independently; a `.lock` suffix — which the length cut can itself
  mint — is rewritten last.
- The `isValid*` predicates are UNCHANGED and remain the contract. Hard
  invariant, swept over a gnarly-input corpus in the colocated table-driven
  tests: `isValid*(normalize*(x)) === true` for every input, normalization is
  idempotent, and every already-valid name is a fixed point.
- Wired into the authoritative server-side create path: `spawnBranch` /
  `attachBranch` (machine-branches.ts) and `planAddProject` / `addProject`
  (machine-projects.ts) normalize before the name is checked out, hashed into
  the session key, cloned to disk, or persisted — the raw text never reaches
  git or the DB. Both POST routes echo the canonical name back rather than the
  request's, so two spellings of one name land on ONE branch-terminal instead
  of two. `resolveProjectPath` still re-checks confinement.

Client-side live preview is a separate follow-up sub-task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…f's tail

git's check-ref-format forbids a `.lock` ending on every slash-separated
component, not only on the ref as a whole — so `feature/a.lock/foo` normalized
cleanly, passed our predicate, and then died at `git checkout -b` with
`checkout_failed`. That is exactly the "type anything, it works" promise this
PR exists to make.

- `rewriteLockSuffix` now runs per segment (and once more after the length cut,
  which lands only in the last segment and can itself mint a `.lock`).
- `FORBIDDEN_SEGMENT_RE` tightened from `\.lock$` to `\.lock(\/|$)` so the
  predicate states git's real rule; the normalizer's hard invariant is then a
  stronger guarantee, not a weaker one.
- Table + gnarly corpus extended: `a.lock/b` → `a-lock/b`,
  `feature/a.lock /foo` → `feature/a-lock/foo`.

Verified beyond our own predicate: every normalized name from the corpus is
accepted by real `git check-ref-format --branch` (41/41).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…ore regression)

Slugifying unconditionally was actively harmful, not merely lossy. Git refs are
CASE-SENSITIVE: `Release-2.0` normalized to `release-2.0`, so
`git checkout -b release-2.0 origin/release-2.0` missed the real upstream
branch — and cloneAndCheckoutBranch's existing fallback then SILENTLY created a
new empty branch off HEAD while `origin/Release-2.0` sat there untouched. The
user asked for their branch and got an empty one, with no error. Uppercase was
valid pre-PR, so this was a regression the PR introduced. `feature_x` was worse
still: pre-PR a loud 400, post-PR a silent wrong-and-empty `feature-x`.

- Both normalizers now return an ALREADY-VALID name untouched. Normalization
  exists to accept text git would REJECT — never to rewrite text git would have
  honored. Idempotency falls out of this for free (output is always valid, so a
  second pass is the identity).
- `HEAD` added to the predicate's reject set: it is git's reserved symbolic ref
  and the only such name in our charset (`head`, `Head`, `FETCH_HEAD` are all
  legal — verified against git). It now slugifies to `head` instead of being
  passed through to a fatal `git checkout -b HEAD`.
- `killBranch` normalizes its lookup key, like `attachBranch` — whatever text
  created a branch must also be able to kill it.
- Route/service docs no longer claim a client-side live preview exists; it is a
  separate follow-up sub-task.

Fuzzed 20,019 inputs (random unicode, ligatures, fullwidth, surrogates, length
boundaries): invariant, idempotency, and PROJECTS_ROOT confinement all hold, and
every distinct normalized branch name (737) is accepted by real
`git check-ref-format --branch`. 362 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
The last name-keyed lookup still doing an exact match on raw caller input.
`addProject` persists the CANONICAL name, so deleting by the free text that
created the project would 404 — the same asymmetry already fixed in
`attachBranch`/`killBranch`. Caught by CodeRabbit.

Tests pin the round trip on both tiers: create with "My Cool Feature", delete
with "My Cool Feature".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…r too

The last raw name-keyed lookup. `addProject` persists the CANONICAL project
name, so free text that created a project ("My Cool Feature" → my-cool-feature)
could not spawn, attach, kill, or list a branch inside it — `project_not_found`.
Same class as the removeProject fix; this closes the set.

spawnBranch / attachBranch / killBranch / listBranches now normalize projectName
before every store lookup and before the scope key. Canonical names — what the
UI sends, straight from listProjects — pass through unchanged, since
normalization is idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…-attach bug)

A branch name IS a branch-terminal's identity — it is hashed into the Sprite
session key and is the store's lookup key. Every non-Latin name slugified to
NOTHING and fell back to the shared `branch`, so:

    spawnBranch('日本語')  -> branch   (provisions Sprite A)
    spawnBranch('한국어')  -> branch   -> { resumed: true }, hands the user Sprite A

The second user silently got the FIRST user's Sprite and filesystem. Same for
`feature/日本語`, which collapsed to plain `feature` and collided with any real
`feature` branch. This was the default outcome for every non-Latin-script user.

- A segment the ASCII charset annihilates but which DID carry a name now keeps a
  deterministic digest token (`slugDigest`, FNV-1a/base36 — a disambiguator, not
  a security primitive; the Sprite name remains a keyed HMAC). Structural noise
  (`..`, `.`, `   `) still has no content and is still dropped, which is what
  keeps `../escape` → `escape`. The task spec explicitly allows "a short suffix"
  here. Kept pure/dependency-free so the live-preview sub-task can run it in the
  browser.
- `spawnBranch` now reports `createdNew`, surfaced on the API response. Our
  charset is deliberately NARROWER than git's (git accepts `_wip`, `fix#123`,
  `日本語`; we reject them, because the charset is a confinement boundary), so
  normalization can rewrite a name that DOES exist upstream into one that
  doesn't — and git's fallback then creates a new empty branch off HEAD. That
  outcome is now stated rather than silent.
- Corrected a doc claim that was false as written ("never rewrite text git would
  have honored") — it is only true of names our predicate accepts.

Fuzzed 30,024 inputs: invariant, idempotency, and PROJECTS_ROOT confinement hold;
all 748 distinct branch outputs accepted by real `git check-ref-format`; 10/10
distinct non-ASCII names stay distinct. 380 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…lation

The previous fix only disambiguated names the ASCII charset wiped out
ENTIRELY, so the partial case still cross-attached — the same bug, one step
subtler:

    '日本語 feature' -> 'feature'      (a DIFFERENT branch, likely someone else's)
    '🚀 launch'      -> 'launch'
    'feature/日本語-fix' -> 'feature/fix'
    '中文 feature' and '日本語 feature' -> the SAME branch

Now a digest is appended whenever normalization destroys identity-bearing
content — a letter, digit, or symbol from a script the charset cannot express.
Losing ASCII punctuation stays lossless-enough and is still accepted (`a b` and
`a!b` were always one branch; demanding otherwise would make `My Cool Feature`
ugly for no safety gain). Accented Latin folds to ASCII losslessly, so
`émoji branch` keeps its clean slug — only the emoji triggers a digest.

Also fixed in the same class:
- The LENGTH CUT was lossy: two different over-long names cut to the same ref.
  The cut now reserves room for a digest of the original.
- `___` is a legal git branch but fell into the shared fallback bucket
  (`_` was wrongly classed as structure, not content), so it cross-attached with
  every content-free input.
- `createdNew` now reaches the client: useMachineBranches types and returns it.
  The docs claimed the create-vs-attach outcome was "stated, not silent" — that
  was only true down to the JSON layer, so the claim was false end-to-end.
- POST /api/machines/projects with an EMPTY name returned 201 and cloned into a
  directory called `project`. An empty name is a missing field, not free text;
  it is a 400 now, matching the branches route.

Fuzzed 30,031 inputs: invariant, idempotency, confinement hold; all 788 distinct
branch outputs accepted by real `git check-ref-format`; 23/23 human-distinct
names stay distinct as both branches and projects. 384 + 35 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…ee over-claims

CI caught a real type error: `args.args` is optional on RunCommandArgs
(TS18048) in the createdNew test. Fixed — @pagespace/lib typecheck is green.

Also, from a fourth adversarial pass:

- REAL BUG: untrimmed input defeated the pass-through. `normalizeBranchName(
  'Release-2.0 ')` — one trailing space — failed the predicate, fell down the
  slug path, and downcased to `release-2.0`: exactly the case-regression the
  pass-through exists to prevent. Both normalizers now trim BEFORE the predicate
  test. The UI happened to .trim() first, so this only bit API/agent callers —
  but the normalizer is the stated authority, not the dialog.
- Three comments over-claimed and are now exactly true:
  * `createdNew` is "stated, not silent" — true only down to the JSON layer.
    Nothing renders it yet; the comments now say so, and it stays threaded
    through the hook so the spawn-flow sub-task can surface it.
  * "Normalizes the project key like every other name-keyed lookup" — true only
    within machine-branches/machine-projects. The agent-terminal / files / diff
    surfaces still take raw names; closing that needs the realtime session-key
    path too, so it is a stated follow-up rather than a drive-by.
  * `rewriteLockSuffix` no longer runs after the length cut (the appended digest
    is alphanumeric, so the cut cannot mint a `.lock`), so the note saying it
    does is gone.
- `hasNameContent`'s doc now explains why it is deliberately MORE eager than
  `destroysNameContent`: dropping `!` from `a!b` is harmless, but `!!!` slugifies
  to nothing, and dropping THAT lands the name in the shared fallback — the one
  bucket where distinct names collide and cross-attach.

Re-fuzzed 30,031 inputs after the trim change: invariant, idempotency,
confinement hold; 789 distinct branch outputs all accepted by real git;
23/23 human-distinct names stay distinct. 386 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
The digest hashed the RAW input while the slug beside it was built from the
folded, lowercased form. So the moment a name held any character the ASCII
charset cannot express, every "accepted loss" the design promises became
identity-bearing again:

    'MY 🚀 FEATURE' -> my-feature-n0kelm   }  two branches, two Sprites,
    'my 🚀 feature' -> my-feature-14q1eru  }  two clones — for ONE name

    'a 🚀 b' / 'a!🚀!b' / 'a  🚀  b'  -> three different branches

The direction was safe (it splits, never cross-attaches), but it broke the
"case and ASCII punctuation do not split a name" promise for precisely the
non-ASCII users the digest was introduced to protect: retyping your own branch
name with different capitalisation minted a second branch-terminal.

`slugDigest` now hashes `nameIdentity(input)` — folded, lowercased, ASCII
structure collapsed, non-ASCII characters KEPT. `日本語` stays apart from
`한국어`, and `日本語 feature` from `feature`, while `MY 🚀 FEATURE` and
`my 🚀 feature` are one branch again.

Two adjacent fixes:
- The blank-name guards tested `length === 0`, not `trim().length === 0`, so
  `name: " "` sailed through and cloned into a directory called `project`
  (branch: `branch`) — exactly what the guard's own comment said it prevented.
  Both routes now reject blank names as the missing fields they are.
- `addProject` could `rm -rf` a CONCURRENT winner's checkout: the loser's clone
  fails *because* the winner's succeeded ("destination path already exists"),
  and the blind cleanup deleted their files while their row lived on, leaving a
  project pointing at an empty directory. It now only cleans up a path no
  persisted project owns. Pre-existing, but normalization widens the race from
  "same text" to "same slug", so it is in scope. The test fails without the fix.

Fuzzed 30,031 inputs: invariant, idempotency, confinement hold; 784 distinct
branch outputs accepted by real git; 23/23 human-distinct names stay distinct;
and the same-name groups (case / punctuation / whitespace, with and without
emoji) each collapse to exactly ONE name. 389 + 35 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
My previous "don't delete the winner's clone" guard was in a place where it
could never fire. The winner's row lands only once ITS clone finishes, while the
loser's clone fails the instant the directory appears — so the loser looked for a
winner's row that did not exist yet, saw none, and `rm -rf`'d the winner's freshly
cloned files out from under them, leaving a project row pointing at an empty
directory. No amount of checking at cleanup time can fix that ordering.

`addProject` now reserves the name in the store BEFORE it clones. The unique
constraint on (machineId, name) is what makes the race safe: the loser fails
instantly on the constraint, never runs git, and never touches the filesystem. On
clone failure the winner rolls back both its own directory and its own row — a
path it provably owns.

The cost is a row that exists while the clone runs. That is the right trade: a row
with no directory is visible and deletable; a directory with no row is an orphan
nobody can reach.

The test now models the REAL interleaving (directory appears immediately, clone
finishes later, row lands last) instead of the one convenient ordering where the
old guard happened to fire. Verified honestly: it FAILS against the post-clone
guard and PASSES against the reservation — the previous version of this test
passed against both, which is exactly the sort of test that lets a bug ship.

Also un-exported BRANCH_NAME_FALLBACK / PROJECT_NAME_FALLBACK — nothing imports them.

389 lib + 128 web machine tests pass; fuzz (30,031 inputs) still clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
… window)

Reserving the name before cloning — the previous commit — made the row visible
for the WHOLE clone. That opened a window nobody had before: an impatient user
can delete the project mid-clone (clones take seconds to minutes) and re-add it.
The name is then owned by a NEW row and a NEW checkout — and the original call's
rollback, which cleaned up BY NAME, would `rm -rf` that new checkout and delete
that new row when its own clone finally failed:

    A: reserve my-repo, clone starts (slow, will fail)
    user: delete my-repo          -> row gone, dir gone
    C: re-add my-repo             -> new row, new clone, succeeds
    A: clone fails                -> rm -rf my-repo  ← destroys C's checkout
                                  -> remove my-repo  ← destroys C's row

The rollback now compares row identity first and touches nothing if the
reservation is no longer ours. Verified honestly: the test FAILS without the
identity check and PASSES with it.

Also covered the `!result.success` half of the failure branch (sandbox
unreachable / quota / provision failure), which reserves a row like any other add
and so must roll it back too — previously untested.

And corrected a comment that promised more than it delivers: the digest token
does not make punctuation-only names unique (`!!!` and `###` share an empty
identity, exactly as `a!b` and `a#b` share a slug). What it guarantees is that a
name with real identity never lands in the bucket with names that have none.

391 lib tests pass; fuzz (30,031 inputs) clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…race instead

I tried to fix a pre-existing concurrency bug inside a normalization PR, and each
fix introduced the next one:

  1. reserve-after-clone + a guard  -> guard could never fire (the winner's row
     lands only after ITS clone finishes, so the loser's fast-failing clone sees
     no row and rm -rf's the winner's checkout anyway)
  2. reserve-BEFORE-clone          -> made the row visible for the whole clone, so
     a delete-then-re-add mid-clone leaves an ORPHAN DIRECTORY and returns
     ok: true. Timing-independent — just an impatient Delete click.
  3. + identity-checked rollback   -> closed that, but left a TOCTOU where a
     stalled rollback still rm -rf's a later owner's checkout.

Three attempts, three new bugs, is the signal. `addProject`'s control flow is now
restored EXACTLY to master's — only the normalized name differs. The race is
documented in place instead, honestly: it is pre-existing, normalization widens
its trigger from "two callers typed the same text" to "two callers typed the same
NAME" (`My Repo` / `my repo`), and it is not fixable by reordering.

The real fix is structural and earns its own PR: make the clone path unique per
ROW (`${name}-${id}`) rather than per name, so no two operations can ever own the
same directory, and make every destructive store op id-scoped. `removeProject`
needs the same treatment — it has the identical by-name TOCTOU on master today.

This keeps the PR to what it is for: name normalization. It ships no new
concurrency semantics and no new bugs.

388 lib + 128 web machine tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
The last hole in the same family. POST and both branches routes reject blank
names; `DELETE /api/machines/projects` still used a bare `!name`, so `" "` sailed
through — and since `removeProject` normalizes its lookup key, a whitespace-only
name resolves to the FALLBACK and deletes a project literally called `project`,
`rm -rf`ing its checkout. Proven end-to-end against the real service.

A blank name is a missing field, not free text. 400 now, with a regression test.

129 web machine-route tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…llback too

The trim guards were the right idea with the wrong predicate. "Nameless" is
broader than "blank": `.`, `..`, `...`, `//`, `-` all have `trim().length > 0`
yet carry no name, and every one of them normalizes to the FALLBACK. So they
sailed straight through and ADDRESSED A REAL RESOURCE:

    DELETE /api/machines/projects?name=..   -> rm -rf's the project named `project`
    POST   /api/machines/branches {branchName: '.'}
                                            -> attaches to the branch-terminal named
                                               `branch`, handing back its Sprite

Both proven against the real services. Same class as the whitespace bug fixed one
commit ago, one spelling away — which is exactly why the guard now uses the
normalizer's OWN definition of namelessness (`hasNameContent`) instead of a
`.trim()` that only catches spaces. Verified as a property: every input that
would reach a fallback is blocked, and no input carrying a real name is
over-blocked (`!!!`, `___`, `-x`, `日本語`, `🚀` all still pass).

`name-slug` gets a package export so the routes can share that one predicate —
which the client-side live-preview sub-task will need anyway.

Also corrected the comment in project-paths.ts that justified the shared fallback
with "the routes reject blank names outright" — that claim was load-bearing and,
until this commit, false.

388 lib + 141 web machine tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…ectly

Two nits from the final review, neither a bug:

- `requireString` was guarding `machineId` with a NAME predicate, so `machineId=..`
  400'd as "machineId is required". An id is opaque, never normalized, and needs no
  name predicate — split into `requireId` (non-empty) and `requireName`
  (hasNameContent). Also renamed the local `Required<T>` helper, which shadowed
  TypeScript's built-in utility type.
- `name-slug.ts` had no direct test; its four exports were covered only
  transitively. Now unit-tested, including the two properties the API depends on:
  `hasNameContent` covers exactly the inputs that reach a fallback (it is what the
  route guards key on), and `slugDigest` hashes a name's IDENTITY — so it separates
  `日本語` from `한국어` without splitting `MY 🚀 FEATURE` from `my 🚀 feature`.

438 lib + 141 web machine tests pass; typecheck and eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
@2witstudios
2witstudios force-pushed the pu/machine-name-normalization branch from 508f2f5 to a353b59 Compare July 12, 2026 18:26
2witstudios and others added 5 commits July 12, 2026 13:44
…POST

The nameless guard was tested on POST only, yet DELETE is the destructive path —
`killBranch` normalizes BOTH keys, so an unguarded `?branchName=..` would resolve
to the FALLBACK and delete a real branch-terminal's Sprite, and a nameless
`?projectName=` on GET would list another project's branches. The guard was
already present on all three methods; now it is pinned there.

Also fixed a comment in the projects route that still pointed at `requireString`,
which the previous commit renamed to `requireName`.

151 web machine-route tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
CodeRabbit caught that the error message I wrote — "name and repoUrl are required
non-empty strings" — was only true of `name`: `repoUrl` was checked for `typeof
=== 'string'`, so `repoUrl: ""` sailed past it and failed further downstream as
`invalid_repo_url` instead. Still a 400, but the guard did not mean what it said.

`repoUrl` is now checked for presence here (it is NOT normalized — there is no
sensible way to turn a non-HTTPS remote into an HTTPS one — so `isValidRepoUrl`
inside `addProject` remains the real validator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
My previous commit hoisted the two checks into aliased booleans and OR'd them —
and TS's aliased-condition narrowing does not flow out of that, so `body.name`
stayed `unknown` at the `addProject` call (TS2322 x2). Caught by running the
apps/web typecheck rather than assuming; CI would have failed on it.

Two separate guard statements narrow correctly. Behaviour is identical: blank
name or blank repoUrl → 400.

apps/web typecheck: 0 errors. 153 web machine-route tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
… a GET case

Cosmetic follow-ups from the review of the last three commits:
- The two guards repeated the same 400 literal, which is how a message and its
  guard drift apart. One `missingNameOrRepoUrl()` helper now backs both, and it
  is a function rather than a hoisted const so the happy path does not build a
  Response it never returns.
- The GET nameless-projectName case list was missing `'.'` (DELETE had it). Free
  coverage; `requireName` is shared so nothing was actually unguarded.

apps/web typecheck 0 errors; 154 web machine-route tests pass; eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…it twice

Behaviour-preserving cleanup, from a simplification pass on the finished diff.

The digest-disambiguation rule — the single most safety-critical thing in this PR,
since it is what stops `日本語` and `한국어` collapsing onto one branch and handing
the second user the first one's Sprite — was implemented TWICE, in
`normalizeBranchSegment` and inside `normalizeProjectName`, with two separately
maintained WHY comments that had already begun to drift. So was the
truncate-with-digest block, and its trailing-separator regex existed in three
places.

Both now live once in name-slug.ts as `disambiguateSlug` and `truncateWithDigest`,
with the reasoning stated once. The two normalizers lose their `let` and their
if/else ladders and read as what they are. `attachBranch` / `killBranch` /
`removeProject` now shadow their raw params with the canonical values the way
`spawnBranch` already did, so "every name-typed identifier in these modules is the
canonical one" holds by inspection rather than by memory.

Proved behaviour-preserving rather than assumed: the new implementation is
BYTE-IDENTICAL to the old one across 63,027 inputs (curated adversarial + fuzz,
including the length and `.lock` boundaries). 438 lib tests pass — including the
pinned digest expectations, which would move on any behavioural drift — and the
property fuzz still holds (invariant, idempotency, confinement, real
`git check-ref-format`, no-collision).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
@2witstudios
2witstudios merged commit bb72a70 into master Jul 12, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/machine-name-normalization branch July 12, 2026 20:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant