Skip to content

Commit 5b3c8c2

Browse files
centdixclaude
andauthored
feat: show externally-created worktrees in UI (#62)
* feat: show externally-created worktrees in UI and lazy-init on open Worktrees created via `workmux add` CLI now appear in the sidebar (dimmed with "closed" label). Clicking opens them with auto-initialized env/hooks so they work seamlessly without manual setup. - Extract initWorktreeEnv() from addWorktree() for reuse - Lazy-init .env.local and Claude hooks in apiOpenWorktree() - Remove mux === "✓" filter so all worktrees are visible - Add visual distinction (opacity, labels) for closed/opening states Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: filter out main branch from worktree sidebar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: do not overwrite pre-existing .env.local fields in initWorktreeEnv Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review feedback - Filter main working tree in backend (not hard-coded branch name) - Show alert on open-worktree failure for user feedback - Let envOverrides take precedence over default PROFILE/AGENT - Use Set<string> for openingBranches to handle concurrent opens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cf87fbe commit 5b3c8c2

4 files changed

Lines changed: 122 additions & 41 deletions

File tree

backend/src/server.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
parseWorktreePorcelain,
1414
checkDirty,
1515
cleanupStaleWindows,
16+
initWorktreeEnv,
1617
} from "./workmux";
1718
import {
1819
attach,
@@ -237,7 +238,11 @@ async function apiGetWorktrees(req: Request): Promise<Response> {
237238
activeBranches.add("main");
238239
cleanupStaleWindows(activeBranches, `${PROJECT_DIR}__worktrees/`);
239240

240-
const merged = await Promise.all(worktrees.map(async (wt) => {
241+
// Filter out the main working tree — it has no entry in wtPaths
242+
// (getWorktreePaths skips the first porcelain entry).
243+
const nonMainWorktrees = worktrees.filter(wt => wtPaths.has(wt.branch));
244+
245+
const merged = await Promise.all(nonMainWorktrees.map(async (wt) => {
241246
const st = status.find(s =>
242247
s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch)
243248
);
@@ -339,8 +344,26 @@ async function apiDeleteWorktree(name: string): Promise<Response> {
339344

340345
async function apiOpenWorktree(name: string): Promise<Response> {
341346
log.info(`[worktree:open] name=${name}`);
347+
348+
// Lazily initialize env/hooks for externally-created worktrees
349+
const wtPaths = await getWorktreePaths();
350+
const wtDir = wtPaths.get(name);
351+
if (wtDir) {
352+
const env = await readEnvLocal(wtDir);
353+
if (!env.PROFILE) {
354+
log.info(`[worktree:open] initializing env for ${name}`);
355+
await initWorktreeEnv(name, {
356+
profile: config.profiles.default.name,
357+
agent: "claude",
358+
services: config.services,
359+
});
360+
wtCache = null;
361+
}
362+
}
363+
342364
const result = await openWorktree(name);
343365
if (!result.ok) return errorResponse(result.error, 422);
366+
wtCache = null;
344367
return jsonResponse({ message: result.output });
345368
}
346369

backend/src/workmux.ts

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,66 @@ function parseBranchFromOutput(output: string): string | null {
223223
return match?.[1] ?? null;
224224
}
225225

226+
export interface InitWorktreeEnvOpts {
227+
profile?: string;
228+
agent?: string;
229+
services?: ServiceConfig[];
230+
envOverrides?: Record<string, string>;
231+
}
232+
233+
/**
234+
* Initialize a worktree's .env.local (ports, profile, agent) and Claude hook
235+
* settings. Extracted so it can be called both from addWorktree() and when
236+
* opening an externally-created worktree that lacks metadata.
237+
*/
238+
export async function initWorktreeEnv(
239+
branch: string,
240+
opts?: InitWorktreeEnvOpts,
241+
): Promise<void> {
242+
const profile = opts?.profile ?? "default";
243+
const agent = opts?.agent ?? "claude";
244+
245+
const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
246+
const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
247+
const wtDir = worktreeMap.get(branch) ?? null;
248+
if (!wtDir) return;
249+
250+
// Read existing env so we don't overwrite pre-existing values
251+
const existingEnv = await readEnvLocal(wtDir);
252+
const allPaths = [...worktreeMap.values()];
253+
const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
254+
const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
255+
const defaults: Record<string, string> = { ...portAssignments, PROFILE: profile, AGENT: agent, ...opts?.envOverrides };
256+
// Only write keys that don't already exist
257+
const toWrite: Record<string, string> = {};
258+
for (const [key, value] of Object.entries(defaults)) {
259+
if (!existingEnv[key]) toWrite[key] = value;
260+
}
261+
if (Object.keys(toWrite).length > 0) {
262+
await writeEnvLocal(wtDir, toWrite);
263+
}
264+
265+
const rpcPort = Bun.env.BACKEND_PORT || "5111";
266+
const hooksConfig = {
267+
hooks: {
268+
Stop: [{ hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-stop.sh`, async: true }] }],
269+
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-pr.sh`, async: true }] }],
270+
},
271+
};
272+
await mkdir(`${wtDir}/.claude`, { recursive: true });
273+
const settingsPath = `${wtDir}/.claude/settings.local.json`;
274+
let existing: Record<string, unknown> = {};
275+
try {
276+
const file = Bun.file(settingsPath);
277+
if (await file.exists()) {
278+
existing = (await file.json()) as Record<string, unknown>;
279+
}
280+
} catch { /* corrupted file — overwrite */ }
281+
const existingHooks = (existing.hooks as Record<string, unknown>) ?? {};
282+
const merged = { ...existing, hooks: { ...existingHooks, ...hooksConfig.hooks } };
283+
await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + "\n");
284+
}
285+
226286
export interface AddWorktreeOpts {
227287
prompt?: string;
228288
profile?: string;
@@ -308,42 +368,13 @@ export async function addWorktree(
308368

309369
const windowTarget = `wm-${branch}`;
310370

311-
// Parse worktree list once — used for both dir lookup and port allocation
371+
// Initialize .env.local (ports, profile, agent) and Claude hooks
372+
await initWorktreeEnv(branch, { profile, agent, services: opts?.services, envOverrides: opts?.envOverrides });
373+
374+
// Re-read worktree dir + env after init
312375
const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
313376
const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
314377
const wtDir = worktreeMap.get(branch) ?? null;
315-
316-
// Allocate ports + write PROFILE/AGENT to .env.local
317-
if (wtDir) {
318-
const allPaths = [...worktreeMap.values()];
319-
const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
320-
const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
321-
await writeEnvLocal(wtDir, { ...portAssignments, ...opts?.envOverrides, PROFILE: profile, AGENT: agent });
322-
323-
// Inject Claude hook settings so notifications fire in managed worktrees.
324-
// Bake the backend port into the command so hooks always reach the right backend,
325-
// even when multiple projects run separate backends on different ports.
326-
const rpcPort = Bun.env.BACKEND_PORT || "5111";
327-
const hooksConfig = {
328-
hooks: {
329-
Stop: [{ hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-stop.sh`, async: true }] }],
330-
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-pr.sh`, async: true }] }],
331-
},
332-
};
333-
await mkdir(`${wtDir}/.claude`, { recursive: true });
334-
const settingsPath = `${wtDir}/.claude/settings.local.json`;
335-
let existing: Record<string, unknown> = {};
336-
try {
337-
const file = Bun.file(settingsPath);
338-
if (await file.exists()) {
339-
existing = (await file.json()) as Record<string, unknown>;
340-
}
341-
} catch { /* corrupted file — overwrite */ }
342-
const existingHooks = (existing.hooks as Record<string, unknown>) ?? {};
343-
const merged = { ...existing, hooks: { ...existingHooks, ...hooksConfig.hooks } };
344-
await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + "\n");
345-
}
346-
347378
const env = wtDir ? await readEnvLocal(wtDir) : {};
348379
log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);
349380

frontend/src/App.svelte

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,18 @@
9898
// may not be fully flushed yet — this small delay lets it settle.
9999
const ENTER_DELAY_MS = 200;
100100
101-
let visibleWorktrees = $derived(worktrees.filter((w) => w.mux === ""));
101+
let openingBranches = $state<Set<string>>(new Set());
102+
let visibleWorktrees = $derived(worktrees);
102103
let selectedWorktree = $derived(
103104
visibleWorktrees.find((w) => w.branch === selectedBranch),
104105
);
105-
let canConnect = $derived(!!selectedBranch);
106+
let canConnect = $derived(!!selectedBranch && selectedWorktree?.mux === "");
106107
107108
$effect(() => {
108109
if (!selectedBranch && visibleWorktrees.length > 0) {
109-
selectedBranch = visibleWorktrees[0].branch;
110+
// Prefer an open worktree, fall back to the first one
111+
const open = visibleWorktrees.find((w) => w.mux === "");
112+
selectedBranch = (open ?? visibleWorktrees[0]).branch;
110113
}
111114
});
112115
@@ -343,11 +346,25 @@
343346
worktrees={visibleWorktrees}
344347
selected={selectedBranch}
345348
removing={removingBranches}
349+
initializing={openingBranches}
346350
{notifiedBranches}
347-
onselect={(b) => {
351+
onselect={async (b) => {
348352
selectedBranch = b;
349353
notifiedBranches = new Set([...notifiedBranches].filter((x) => x !== b));
350354
if (isMobile) sidebarOpen = false;
355+
// Open closed worktrees on click
356+
const wt = worktrees.find((w) => w.branch === b);
357+
if (wt && wt.mux !== "") {
358+
openingBranches = new Set([...openingBranches, b]);
359+
try {
360+
await api.openWorktree(b);
361+
await refresh();
362+
} catch (err) {
363+
alert(`Failed to open worktree: ${errorMessage(err)}`);
364+
} finally {
365+
openingBranches = new Set([...openingBranches].filter((x) => x !== b));
366+
}
367+
}
351368
}}
352369
onremove={(b) => (removeBranch = b)}
353370
/>

frontend/src/lib/WorktreeList.svelte

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
worktrees,
99
selected,
1010
removing,
11+
initializing,
1112
notifiedBranches,
1213
onselect,
1314
onremove,
1415
}: {
1516
worktrees: WorktreeInfo[];
1617
selected: string | null;
1718
removing: Set<string>;
19+
initializing: Set<string>;
1820
notifiedBranches: Set<string>;
1921
onselect: (branch: string) => void;
2022
onremove: (branch: string) => void;
@@ -25,22 +27,30 @@
2527
{#each worktrees as wt (wt.branch)}
2628
{@const isActive = wt.branch === selected}
2729
{@const isRemoving = removing.has(wt.branch)}
30+
{@const isClosed = wt.mux !== ""}
31+
{@const isInitializing = initializing.has(wt.branch)}
2832
<li
29-
class="mb-0.5 group relative {isRemoving
33+
class="mb-0.5 group relative {isRemoving || isInitializing
3034
? 'opacity-40 pointer-events-none'
3135
: ''}"
3236
>
3337
<button
3438
type="button"
3539
class="w-full py-2.5 px-3 rounded-md border cursor-pointer flex flex-col gap-1 text-left text-inherit text-sm bg-transparent hover:bg-hover {isActive
3640
? 'bg-active border-accent'
37-
: 'border-transparent'}"
41+
: 'border-transparent'} {isClosed && !isInitializing ? 'opacity-50' : ''}"
3842
onclick={() => onselect(wt.branch)}
3943
>
4044
<span class="flex items-center gap-1.5 pr-5 flex-wrap">
4145
<div class="flex items-center gap-2 max-w-[90%] min-w-0">
4246
<span class="font-medium truncate">{wt.branch}</span>
43-
<span class="shrink-0"><AgentStatusIcon status={wt.agent} size={14} /></span>
47+
{#if isInitializing}
48+
<span class="shrink-0 text-[10px] text-muted">opening...</span>
49+
{:else if isClosed}
50+
<span class="shrink-0 text-[10px] text-muted">closed</span>
51+
{:else}
52+
<span class="shrink-0"><AgentStatusIcon status={wt.agent} size={14} /></span>
53+
{/if}
4454
{#if notifiedBranches.has(wt.branch)}
4555
<span class="shrink-0 w-2 h-2 rounded-full bg-accent"></span>
4656
{/if}

0 commit comments

Comments
 (0)