Skip to content

Commit 48df758

Browse files
paul-bouzianclaude
andauthored
feat: human-readable worktree folder names (#87)
* feat: human-readable worktree folder names Replace cryptic base36 IDs with readable names for worktree directories: - Worktree folders now use adjective-landscape pattern (e.g., "golden-meadow") - Project folders now use the sanitized project name (e.g., "lumpos-saas") Before: ~/.21st/worktrees/mksuo7x8o4j231wz/mkt3o6tuipqprfwl/ After: ~/.21st/worktrees/lumpos-saas/golden-meadow/ Backward compatible: existing worktrees continue to work via dual-lookup strategy in resolveProjectPathFromWorktree(). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review feedback - Add short projectId suffix to sanitized project names to prevent slug collisions (e.g., "My Project" and "my_project" now produce distinct folder names like "my-project-abc123" vs "my-project-def456") - Truncate sanitized project names to 50 chars to avoid filesystem path length limits - Add database index on chats.worktree_path for faster lookups in resolveProjectPathFromWorktree() - Document TOCTOU race condition as acceptable (180k combinations, git worktree add fails atomically on collision) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: remove projectId suffix from project folder names Slug collisions (e.g., "My Project" and "my_project" → "my-project") are safe because resolveProjectPathFromWorktree() resolves via the full chats.worktreePath (unique per chat), not the project folder name alone. Keeps the 50-char truncation for filesystem safety. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ec9329c commit 48df758

10 files changed

Lines changed: 293 additions & 45 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CREATE INDEX `chats_worktree_path_idx` ON `chats` (`worktree_path`);--> statement-breakpoint
2+
ALTER TABLE `sub_chats` DROP COLUMN `additions`;--> statement-breakpoint
3+
ALTER TABLE `sub_chats` DROP COLUMN `deletions`;--> statement-breakpoint
4+
ALTER TABLE `sub_chats` DROP COLUMN `file_count`;

drizzle/meta/0005_snapshot.json

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"version": "6",
33
"dialect": "sqlite",
4-
"id": "a5b2c3d4-e5f6-7890-abcd-ef1234567890",
5-
"prevId": "1c211023-1270-4cdd-934d-4396e72557e9",
4+
"id": "0284f691-83e5-43d8-8e73-0918cc3435e8",
5+
"prevId": "a5b2c3d4-e5f6-7890-abcd-ef1234567890",
66
"tables": {
77
"chats": {
88
"name": "chats",
@@ -85,7 +85,15 @@
8585
"autoincrement": false
8686
}
8787
},
88-
"indexes": {},
88+
"indexes": {
89+
"chats_worktree_path_idx": {
90+
"name": "chats_worktree_path_idx",
91+
"columns": [
92+
"worktree_path"
93+
],
94+
"isUnique": false
95+
}
96+
},
8997
"foreignKeys": {
9098
"chats_project_id_projects_id_fk": {
9199
"name": "chats_project_id_projects_id_fk",
@@ -279,30 +287,6 @@
279287
"autoincrement": false,
280288
"default": "'[]'"
281289
},
282-
"additions": {
283-
"name": "additions",
284-
"type": "integer",
285-
"primaryKey": false,
286-
"notNull": false,
287-
"autoincrement": false,
288-
"default": 0
289-
},
290-
"deletions": {
291-
"name": "deletions",
292-
"type": "integer",
293-
"primaryKey": false,
294-
"notNull": false,
295-
"autoincrement": false,
296-
"default": 0
297-
},
298-
"file_count": {
299-
"name": "file_count",
300-
"type": "integer",
301-
"primaryKey": false,
302-
"notNull": false,
303-
"autoincrement": false,
304-
"default": 0
305-
},
306290
"created_at": {
307291
"name": "created_at",
308292
"type": "integer",
@@ -349,4 +333,4 @@
349333
"internal": {
350334
"indexes": {}
351335
}
352-
}
336+
}

drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@
3636
"when": 1768199613729,
3737
"tag": "0004_melted_prism",
3838
"breakpoints": true
39+
},
40+
{
41+
"idx": 5,
42+
"version": "6",
43+
"when": 1769310092745,
44+
"tag": "0005_marvelous_master_chief",
45+
"breakpoints": true
3946
}
4047
]
4148
}

src/main/lib/claude-config.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as fs from "fs/promises"
77
import * as os from "os"
88
import * as path from "path"
99
import { getDatabase } from "./db"
10-
import { projects } from "./db/schema"
10+
import { chats, projects } from "./db/schema"
1111

1212
export const CLAUDE_CONFIG_PATH = path.join(os.homedir(), ".claude.json")
1313

@@ -151,7 +151,8 @@ export function updateMcpServerConfig(
151151

152152
/**
153153
* Resolve original project path from a worktree path.
154-
* Worktree paths follow: ~/.21st/worktrees/{projectId}/{chatId}/
154+
* Supports legacy (~/.21st/worktrees/{projectId}/{chatId}/) and
155+
* new format (~/.21st/worktrees/{projectName}/{worktreeFolder}/).
155156
*
156157
* @param pathToResolve - Either a worktree path or regular project path
157158
* @returns The original project path, or the input if not a worktree, or null if resolution fails
@@ -171,8 +172,8 @@ export function resolveProjectPathFromWorktree(
171172
}
172173

173174
try {
174-
// Extract projectId from path structure
175-
// Path format: /Users/.../.21st/worktrees/{projectId}/{chatId}
175+
// Extract segments from path structure
176+
// Path format: /Users/.../.21st/worktrees/{projectSlug}/{worktreeFolder}
176177
const worktreeBase = path.join(os.homedir(), ".21st", "worktrees")
177178
const normalizedBase = worktreeBase.replace(/\\/g, "/")
178179
const relativePath = normalizedPath
@@ -184,17 +185,43 @@ export function resolveProjectPathFromWorktree(
184185
return null
185186
}
186187

187-
const projectId = parts[0]
188-
189-
// Look up original project path from database
190188
const db = getDatabase()
191-
const project = db
189+
190+
// Strategy 1: Legacy lookup - folder name is a projectId
191+
const projectById = db
192192
.select({ path: projects.path })
193193
.from(projects)
194-
.where(eq(projects.id, projectId))
194+
.where(eq(projects.id, parts[0]))
195195
.get()
196196

197-
return project?.path ?? null
197+
if (projectById) {
198+
return projectById.path
199+
}
200+
201+
// Strategy 2: New format - folder name is the project name.
202+
// Look up via chats.worktreePath which stores the full path.
203+
if (parts.length >= 2) {
204+
const expectedWorktreePath = path.join(worktreeBase, parts[0], parts[1])
205+
const chat = db
206+
.select({ projectId: chats.projectId })
207+
.from(chats)
208+
.where(eq(chats.worktreePath, expectedWorktreePath))
209+
.get()
210+
211+
if (chat) {
212+
const project = db
213+
.select({ path: projects.path })
214+
.from(projects)
215+
.where(eq(projects.id, chat.projectId))
216+
.get()
217+
218+
if (project) {
219+
return project.path
220+
}
221+
}
222+
}
223+
224+
return null
198225
} catch (error) {
199226
console.error("[worktree-utils] Failed to resolve project path:", error)
200227
return null

src/main/lib/db/schema/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
1+
import { index, sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
22
import { relations } from "drizzle-orm"
33
import { createId } from "../utils"
44

@@ -49,7 +49,9 @@ export const chats = sqliteTable("chats", {
4949
// PR tracking fields
5050
prUrl: text("pr_url"),
5151
prNumber: integer("pr_number"),
52-
})
52+
}, (table) => [
53+
index("chats_worktree_path_idx").on(table.worktreePath),
54+
])
5355

5456
export const chatsRelations = relations(chats, ({ one, many }) => ({
5557
project: one(projects, {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Nature/landscape themed nouns for human-readable worktree folder names.
3+
* Used with adjectives from unique-names-generator to produce names like
4+
* "golden-meadow", "quiet-ridge", "misty-canyon".
5+
*
6+
* ~120 words organized by category for easy maintenance.
7+
*/
8+
export const landscapes: string[] = [
9+
// Water features
10+
"brook",
11+
"creek",
12+
"delta",
13+
"fjord",
14+
"lagoon",
15+
"marsh",
16+
"pond",
17+
"rapids",
18+
"reef",
19+
"river",
20+
"shoal",
21+
"spring",
22+
"strait",
23+
"stream",
24+
"tide",
25+
"bay",
26+
27+
// Elevated terrain
28+
"bluff",
29+
"butte",
30+
"cliff",
31+
"crag",
32+
"crest",
33+
"dune",
34+
"hill",
35+
"knoll",
36+
"mesa",
37+
"peak",
38+
"ridge",
39+
"summit",
40+
"tor",
41+
42+
// Low terrain / depressions
43+
"basin",
44+
"canyon",
45+
"cave",
46+
"cove",
47+
"dale",
48+
"dell",
49+
"glade",
50+
"glen",
51+
"gorge",
52+
"grotto",
53+
"gulch",
54+
"hollow",
55+
"ravine",
56+
"vale",
57+
"valley",
58+
59+
// Flat / open terrain
60+
"field",
61+
"heath",
62+
"moor",
63+
"plain",
64+
"prairie",
65+
"savanna",
66+
"steppe",
67+
"tundra",
68+
69+
// Forested / vegetated
70+
"copse",
71+
"forest",
72+
"grove",
73+
"jungle",
74+
"meadow",
75+
"orchard",
76+
"thicket",
77+
"woods",
78+
79+
// Geological
80+
"arch",
81+
"boulder",
82+
"cairn",
83+
"crater",
84+
"ledge",
85+
"quarry",
86+
"shelf",
87+
"spire",
88+
89+
// Coastal / island
90+
"atoll",
91+
"beach",
92+
"harbor",
93+
"inlet",
94+
"isle",
95+
"shore",
96+
97+
// Sky / weather / atmospheric
98+
"aurora",
99+
"breeze",
100+
"cloud",
101+
"dawn",
102+
"dusk",
103+
"ember",
104+
"frost",
105+
"glacier",
106+
"haze",
107+
"horizon",
108+
"mist",
109+
"shadow",
110+
"snow",
111+
"storm",
112+
"sunset",
113+
"twilight",
114+
"wind",
115+
116+
// Other natural features
117+
"cascade",
118+
"clearing",
119+
"crossing",
120+
"falls",
121+
"oasis",
122+
"passage",
123+
"plateau",
124+
"terrace",
125+
"trail",
126+
"woodland",
127+
128+
// Extra for variety
129+
"anchor",
130+
"beacon",
131+
"canopy",
132+
"drift",
133+
"echo",
134+
"fern",
135+
"haven",
136+
"mirage",
137+
"nectar",
138+
"orbit",
139+
"pebble",
140+
"quartz",
141+
"ripple",
142+
"solstice",
143+
"stone",
144+
"timber",
145+
"vertex",
146+
"zenith",
147+
];

src/main/lib/git/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const execAsync = promisify(exec);
1515

1616
// Re-export worktree utilities
1717
export * from "./worktree";
18+
export * from "./worktree-naming";
1819

1920
// Re-export GitHub utilities
2021
export * from "./github";

0 commit comments

Comments
 (0)