Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
parseWorktreePorcelain,
checkDirty,
cleanupStaleWindows,
initWorktreeEnv,
} from "./workmux";
import {
attach,
Expand Down Expand Up @@ -231,7 +232,11 @@ async function apiGetWorktrees(req: Request): Promise<Response> {
activeBranches.add("main");
cleanupStaleWindows(activeBranches, `${PROJECT_DIR}__worktrees/`);

const merged = await Promise.all(worktrees.map(async (wt) => {
// Filter out the main working tree — it has no entry in wtPaths
// (getWorktreePaths skips the first porcelain entry).
const nonMainWorktrees = worktrees.filter(wt => wtPaths.has(wt.branch));

const merged = await Promise.all(nonMainWorktrees.map(async (wt) => {
const st = status.find(s =>
s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch)
);
Expand Down Expand Up @@ -333,8 +338,26 @@ async function apiDeleteWorktree(name: string): Promise<Response> {

async function apiOpenWorktree(name: string): Promise<Response> {
log.info(`[worktree:open] name=${name}`);

// Lazily initialize env/hooks for externally-created worktrees
const wtPaths = await getWorktreePaths();
const wtDir = wtPaths.get(name);
if (wtDir) {
const env = await readEnvLocal(wtDir);
if (!env.PROFILE) {
log.info(`[worktree:open] initializing env for ${name}`);
await initWorktreeEnv(name, {
profile: config.profiles.default.name,
agent: "claude",
services: config.services,
});
wtCache = null;
}
}

const result = await openWorktree(name);
if (!result.ok) return errorResponse(result.error, 422);
wtCache = null;
return jsonResponse({ message: result.output });
}

Expand Down
97 changes: 64 additions & 33 deletions backend/src/workmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,66 @@ function parseBranchFromOutput(output: string): string | null {
return match?.[1] ?? null;
}

export interface InitWorktreeEnvOpts {
profile?: string;
agent?: string;
services?: ServiceConfig[];
envOverrides?: Record<string, string>;
}

/**
* Initialize a worktree's .env.local (ports, profile, agent) and Claude hook
* settings. Extracted so it can be called both from addWorktree() and when
* opening an externally-created worktree that lacks metadata.
*/
export async function initWorktreeEnv(
branch: string,
opts?: InitWorktreeEnvOpts,
): Promise<void> {
const profile = opts?.profile ?? "default";
const agent = opts?.agent ?? "claude";

const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
const wtDir = worktreeMap.get(branch) ?? null;
if (!wtDir) return;

// Read existing env so we don't overwrite pre-existing values
const existingEnv = await readEnvLocal(wtDir);
const allPaths = [...worktreeMap.values()];
const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
const defaults: Record<string, string> = { ...portAssignments, PROFILE: profile, AGENT: agent, ...opts?.envOverrides };
// Only write keys that don't already exist
const toWrite: Record<string, string> = {};
for (const [key, value] of Object.entries(defaults)) {
if (!existingEnv[key]) toWrite[key] = value;
}
if (Object.keys(toWrite).length > 0) {
await writeEnvLocal(wtDir, toWrite);
}

const rpcPort = Bun.env.BACKEND_PORT || "5111";
const hooksConfig = {
hooks: {
Stop: [{ hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-stop.sh`, async: true }] }],
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-pr.sh`, async: true }] }],
},
};
await mkdir(`${wtDir}/.claude`, { recursive: true });
const settingsPath = `${wtDir}/.claude/settings.local.json`;
let existing: Record<string, unknown> = {};
try {
const file = Bun.file(settingsPath);
if (await file.exists()) {
existing = (await file.json()) as Record<string, unknown>;
}
} catch { /* corrupted file — overwrite */ }
const existingHooks = (existing.hooks as Record<string, unknown>) ?? {};
const merged = { ...existing, hooks: { ...existingHooks, ...hooksConfig.hooks } };
await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + "\n");
}

export interface AddWorktreeOpts {
prompt?: string;
profile?: string;
Expand Down Expand Up @@ -308,42 +368,13 @@ export async function addWorktree(

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

// Parse worktree list once — used for both dir lookup and port allocation
// Initialize .env.local (ports, profile, agent) and Claude hooks
await initWorktreeEnv(branch, { profile, agent, services: opts?.services, envOverrides: opts?.envOverrides });

// Re-read worktree dir + env after init
const porcelainResult = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
const worktreeMap = parseWorktreePorcelain(new TextDecoder().decode(porcelainResult.stdout));
const wtDir = worktreeMap.get(branch) ?? null;

// Allocate ports + write PROFILE/AGENT to .env.local
if (wtDir) {
const allPaths = [...worktreeMap.values()];
const existingEnvs = await readAllWorktreeEnvs(allPaths, wtDir);
const portAssignments = opts?.services ? allocatePorts(existingEnvs, opts.services) : {};
await writeEnvLocal(wtDir, { ...portAssignments, ...opts?.envOverrides, PROFILE: profile, AGENT: agent });

// Inject Claude hook settings so notifications fire in managed worktrees.
// Bake the backend port into the command so hooks always reach the right backend,
// even when multiple projects run separate backends on different ports.
const rpcPort = Bun.env.BACKEND_PORT || "5111";
const hooksConfig = {
hooks: {
Stop: [{ hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-stop.sh`, async: true }] }],
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: `WORKMUX_RPC_PORT=${rpcPort} ~/.config/workmux/hooks/notify-pr.sh`, async: true }] }],
},
};
await mkdir(`${wtDir}/.claude`, { recursive: true });
const settingsPath = `${wtDir}/.claude/settings.local.json`;
let existing: Record<string, unknown> = {};
try {
const file = Bun.file(settingsPath);
if (await file.exists()) {
existing = (await file.json()) as Record<string, unknown>;
}
} catch { /* corrupted file — overwrite */ }
const existingHooks = (existing.hooks as Record<string, unknown>) ?? {};
const merged = { ...existing, hooks: { ...existingHooks, ...hooksConfig.hooks } };
await Bun.write(settingsPath, JSON.stringify(merged, null, 2) + "\n");
}

const env = wtDir ? await readEnvLocal(wtDir) : {};
log.debug(`[workmux:add] branch=${branch} dir=${wtDir ?? "(not found)"} env=${JSON.stringify(env)}`);

Expand Down
25 changes: 21 additions & 4 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,18 @@
// may not be fully flushed yet — this small delay lets it settle.
const ENTER_DELAY_MS = 200;

let visibleWorktrees = $derived(worktrees.filter((w) => w.mux === "✓"));
let openingBranches = $state<Set<string>>(new Set());
let visibleWorktrees = $derived(worktrees);
let selectedWorktree = $derived(
visibleWorktrees.find((w) => w.branch === selectedBranch),
);
let canConnect = $derived(!!selectedBranch);
let canConnect = $derived(!!selectedBranch && selectedWorktree?.mux === "✓");

$effect(() => {
if (!selectedBranch && visibleWorktrees.length > 0) {
selectedBranch = visibleWorktrees[0].branch;
// Prefer an open worktree, fall back to the first one
const open = visibleWorktrees.find((w) => w.mux === "✓");
selectedBranch = (open ?? visibleWorktrees[0]).branch;
}
});

Expand Down Expand Up @@ -343,11 +346,25 @@
worktrees={visibleWorktrees}
selected={selectedBranch}
removing={removingBranches}
initializing={openingBranches}
{notifiedBranches}
onselect={(b) => {
onselect={async (b) => {
selectedBranch = b;
notifiedBranches = new Set([...notifiedBranches].filter((x) => x !== b));
if (isMobile) sidebarOpen = false;
// Open closed worktrees on click
const wt = worktrees.find((w) => w.branch === b);
if (wt && wt.mux !== "✓") {
openingBranches = new Set([...openingBranches, b]);
try {
await api.openWorktree(b);
await refresh();
} catch (err) {
alert(`Failed to open worktree: ${errorMessage(err)}`);
} finally {
openingBranches = new Set([...openingBranches].filter((x) => x !== b));
}
}
}}
onremove={(b) => (removeBranch = b)}
/>
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/lib/WorktreeList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
worktrees,
selected,
removing,
initializing,
notifiedBranches,
onselect,
onremove,
}: {
worktrees: WorktreeInfo[];
selected: string | null;
removing: Set<string>;
initializing: Set<string>;
notifiedBranches: Set<string>;
onselect: (branch: string) => void;
onremove: (branch: string) => void;
Expand All @@ -25,22 +27,30 @@
{#each worktrees as wt (wt.branch)}
{@const isActive = wt.branch === selected}
{@const isRemoving = removing.has(wt.branch)}
{@const isClosed = wt.mux !== "✓"}
{@const isInitializing = initializing.has(wt.branch)}
<li
class="mb-0.5 group relative {isRemoving
class="mb-0.5 group relative {isRemoving || isInitializing
? 'opacity-40 pointer-events-none'
: ''}"
>
<button
type="button"
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
? 'bg-active border-accent'
: 'border-transparent'}"
: 'border-transparent'} {isClosed && !isInitializing ? 'opacity-50' : ''}"
onclick={() => onselect(wt.branch)}
>
<span class="flex items-center gap-1.5 pr-5 flex-wrap">
<div class="flex items-center gap-2 max-w-[90%] min-w-0">
<span class="font-medium truncate">{wt.branch}</span>
<span class="shrink-0"><AgentStatusIcon status={wt.agent} size={14} /></span>
{#if isInitializing}
<span class="shrink-0 text-[10px] text-muted">opening...</span>
{:else if isClosed}
<span class="shrink-0 text-[10px] text-muted">closed</span>
{:else}
<span class="shrink-0"><AgentStatusIcon status={wt.agent} size={14} /></span>
{/if}
{#if notifiedBranches.has(wt.branch)}
<span class="shrink-0 w-2 h-2 rounded-full bg-accent"></span>
{/if}
Expand Down