Skip to content
Open
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
58 changes: 58 additions & 0 deletions backend/src/__tests__/order-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "bun:test";
import { WORKTREE_ORDER_STATE_VERSION, type WorktreeOrderState } from "../domain/model";
import {
buildWorktreeOrderComparator,
createWorktreeOrderState,
moveBranchInOrder,
pruneWorktreeOrder,
} from "../services/order-service";

function orderState(branches: string[]): WorktreeOrderState {
return { schemaVersion: WORKTREE_ORDER_STATE_VERSION, branches };
}

describe("createWorktreeOrderState", () => {
it("dedupes and drops empty branch names", () => {
const state = createWorktreeOrderState(["a", "", "b", "a", "c", "b"]);
expect(state).toEqual(orderState(["a", "b", "c"]));
});
});

describe("pruneWorktreeOrder", () => {
it("keeps only branches that still exist", () => {
const state = pruneWorktreeOrder({
state: orderState(["a", "b", "c"]),
branches: ["c", "a"],
});
expect(state.branches).toEqual(["a", "c"]);
});
});

describe("moveBranchInOrder", () => {
it("moves a branch before a target", () => {
expect(moveBranchInOrder(["a", "b", "c"], "c", "a", "before")).toEqual(["c", "a", "b"]);
});

it("moves a branch after a target", () => {
expect(moveBranchInOrder(["a", "b", "c"], "a", "c", "after")).toEqual(["b", "c", "a"]);
});

it("returns null for a no-op or unknown branch", () => {
expect(moveBranchInOrder(["a", "b"], "a", "a", "before")).toBeNull();
expect(moveBranchInOrder(["a", "b"], "z", "a", "before")).toBeNull();
expect(moveBranchInOrder(["a", "b"], "a", "z", "after")).toBeNull();
});
});

describe("buildWorktreeOrderComparator", () => {
it("orders saved branches first, then unknown branches alphabetically", () => {
const compare = buildWorktreeOrderComparator(["c", "a"]);
const sorted = ["b", "a", "d", "c"].sort(compare);
expect(sorted).toEqual(["c", "a", "b", "d"]);
});

it("falls back to alphabetical when no order is saved", () => {
const compare = buildWorktreeOrderComparator([]);
expect(["c", "a", "b"].sort(compare)).toEqual(["a", "b", "c"]);
});
});
79 changes: 79 additions & 0 deletions backend/src/__tests__/order-state-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "bun:test";
import { WORKTREE_ORDER_STATE_VERSION, type WorktreeOrderState } from "../domain/model";
import { OrderStateService } from "../services/order-state-service";

function cloneOrderState(state: WorktreeOrderState): WorktreeOrderState {
return { schemaVersion: state.schemaVersion, branches: [...state.branches] };
}

function emptyOrderState(): WorktreeOrderState {
return { schemaVersion: WORKTREE_ORDER_STATE_VERSION, branches: [] };
}

describe("order-state-service", () => {
it("persists the requested branch order", async () => {
let state = emptyOrderState();
const service = new OrderStateService("/repo/.git", {
readState: async () => cloneOrderState(state),
writeState: async (_gitDir, nextState) => {
state = cloneOrderState(nextState);
},
});

await service.setOrder(["b", "a", "c"]);

expect(state.branches).toEqual(["b", "a", "c"]);
});

it("does not write when the order is unchanged", async () => {
let writes = 0;
let state: WorktreeOrderState = { schemaVersion: WORKTREE_ORDER_STATE_VERSION, branches: ["a", "b"] };
const service = new OrderStateService("/repo/.git", {
readState: async () => cloneOrderState(state),
writeState: async (_gitDir, nextState) => {
writes++;
state = cloneOrderState(nextState);
},
});

await service.setOrder(["a", "b"]);

expect(writes).toBe(0);
});

it("prunes branches that no longer exist", async () => {
let state: WorktreeOrderState = { schemaVersion: WORKTREE_ORDER_STATE_VERSION, branches: ["a", "b", "c"] };
const service = new OrderStateService("/repo/.git", {
readState: async () => cloneOrderState(state),
writeState: async (_gitDir, nextState) => {
state = cloneOrderState(nextState);
},
});

await service.prune(["a", "c"]);

expect(state.branches).toEqual(["a", "c"]);
});

it("serializes concurrent mutations", async () => {
let state = emptyOrderState();
const service = new OrderStateService("/repo/.git", {
readState: async () => {
const snapshot = cloneOrderState(state);
await Bun.sleep(5);
return snapshot;
},
writeState: async (_gitDir, nextState) => {
await Bun.sleep(5);
state = cloneOrderState(nextState);
},
});

await Promise.all([
service.setOrder(["a", "b"]),
service.setOrder(["b", "a"]),
]);

expect(state.branches).toEqual(["b", "a"]);
});
});
36 changes: 36 additions & 0 deletions backend/src/__tests__/snapshot-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,40 @@ describe("buildProjectSnapshot", () => {
"feature/zebra",
]);
});

it("applies a saved branch order, appending unknown branches alphabetically", () => {
const runtime = new ProjectRuntime();
runtime.upsertWorktree({
worktreeId: "wt_alpha",
branch: "feature/alpha",
path: "/repo/__worktrees/feature-alpha",
runtime: "host",
});
runtime.upsertWorktree({
worktreeId: "wt_beta",
branch: "feature/beta",
path: "/repo/__worktrees/feature-beta",
runtime: "host",
});
runtime.upsertWorktree({
worktreeId: "wt_gamma",
branch: "feature/gamma",
path: "/repo/__worktrees/feature-gamma",
runtime: "host",
});

const snapshot = buildProjectSnapshot({
projectName: "Project",
mainBranch: "main",
runtime,
notifications: [],
order: ["feature/gamma", "feature/alpha"],
});

expect(snapshot.worktrees.map((worktree) => worktree.branch)).toEqual([
"feature/gamma",
"feature/alpha",
"feature/beta",
]);
});
});
47 changes: 46 additions & 1 deletion backend/src/adapters/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ import type {
WorktreeConversationMeta,
WorktreeArchiveState,
WorktreeMeta,
WorktreeOrderState,
WorktreeStoragePaths,
WorktreeTab,
} from "../domain/model";
import { conversationSessionId, ROOT_TAB_ID, WORKTREE_ARCHIVE_STATE_VERSION } from "../domain/model";
import {
conversationSessionId,
ROOT_TAB_ID,
WORKTREE_ARCHIVE_STATE_VERSION,
WORKTREE_ORDER_STATE_VERSION,
} from "../domain/model";

const SAFE_ENV_VALUE_RE = /^[A-Za-z0-9_./:@%+=,-]+$/;
const DOTENV_LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)/;
Expand Down Expand Up @@ -74,6 +80,10 @@ export function getProjectArchiveStatePath(gitDir: string): string {
return join(gitDir, "webmux", "archive.json");
}

export function getProjectOrderStatePath(gitDir: string): string {
return join(gitDir, "webmux", "order.json");
}

export async function ensureWorktreeStorageDirs(gitDir: string): Promise<WorktreeStoragePaths> {
const paths = getWorktreeStoragePaths(gitDir);
await mkdir(paths.webmuxDir, { recursive: true });
Expand Down Expand Up @@ -136,6 +146,41 @@ export async function writeWorktreeArchiveState(gitDir: string, state: WorktreeA
await Bun.write(archivePath, JSON.stringify(state, null, 2) + "\n");
}

function emptyWorktreeOrderState(): WorktreeOrderState {
return {
schemaVersion: WORKTREE_ORDER_STATE_VERSION,
branches: [],
};
}

function isWorktreeOrderState(raw: unknown): raw is WorktreeOrderState {
return isRecord(raw)
&& typeof raw.schemaVersion === "number"
&& Array.isArray(raw.branches)
&& raw.branches.every((branch) => typeof branch === "string");
}

export async function readWorktreeOrderState(gitDir: string): Promise<WorktreeOrderState> {
const orderPath = getProjectOrderStatePath(gitDir);
try {
const raw: unknown = await Bun.file(orderPath).json();
return isWorktreeOrderState(raw)
? {
schemaVersion: raw.schemaVersion,
branches: [...raw.branches],
}
: emptyWorktreeOrderState();
} catch {
return emptyWorktreeOrderState();
}
}

export async function writeWorktreeOrderState(gitDir: string, state: WorktreeOrderState): Promise<void> {
const orderPath = getProjectOrderStatePath(gitDir);
await ensureWorktreeStorageDirs(gitDir);
await Bun.write(orderPath, JSON.stringify(state, null, 2) + "\n");
}

export function buildRuntimeEnvMap(
meta: WorktreeMeta,
extraEnv: Record<string, string> = {},
Expand Down
6 changes: 6 additions & 0 deletions backend/src/domain/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AgentId, RuntimeKind } from "./config";

export const WORKTREE_META_SCHEMA_VERSION = 1;
export const WORKTREE_ARCHIVE_STATE_VERSION = 1;
export const WORKTREE_ORDER_STATE_VERSION = 1;

export type WorktreeConversationProvider = "codexAppServer" | "claudeCode";

Expand Down Expand Up @@ -99,6 +100,11 @@ export interface WorktreeArchiveState {
entries: ArchivedWorktreeEntry[];
}

export interface WorktreeOrderState {
schemaVersion: number;
branches: string[];
}

export interface WorktreeStoragePaths {
gitDir: string;
webmuxDir: string;
Expand Down
4 changes: 4 additions & 0 deletions backend/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BunTmuxGateway } from "./adapters/tmux";
import { FileSessionDiscovery } from "./adapters/session-discovery";
import { AutoNameService } from "./services/auto-name-service";
import { ArchiveStateService } from "./services/archive-state-service";
import { OrderStateService } from "./services/order-state-service";
import { LifecycleService, type CreateWorktreeProgress } from "./services/lifecycle-service";
import { NotificationService as RuntimeNotificationService } from "./services/notification-service";
import { ProjectRuntime } from "./services/project-runtime";
Expand All @@ -25,6 +26,7 @@ export interface WebmuxRuntime {
projectDir: string;
config: ProjectConfig;
archiveStateService: ArchiveStateService;
orderStateService: OrderStateService;
git: BunGitGateway;
portProbe: BunPortProbe;
tmux: BunTmuxGateway;
Expand All @@ -44,6 +46,7 @@ export function createWebmuxRuntime(options: WebmuxRuntimeOptions = {}): WebmuxR
const config = loadConfig(projectDir, { resolvedRoot: true });
const git = new BunGitGateway();
const archiveStateService = new ArchiveStateService(git.resolveWorktreeGitDir(projectDir));
const orderStateService = new OrderStateService(git.resolveWorktreeGitDir(projectDir));
const portProbe = new BunPortProbe();
const tmux = new BunTmuxGateway();
const docker = new BunDockerGateway();
Expand Down Expand Up @@ -86,6 +89,7 @@ export function createWebmuxRuntime(options: WebmuxRuntimeOptions = {}): WebmuxR
projectDir,
config,
archiveStateService,
orderStateService,
git,
portProbe,
tmux,
Expand Down
17 changes: 17 additions & 0 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
SendWorktreePromptRequestSchema,
SetWorktreeArchivedRequestSchema,
SetWorktreeLabelRequestSchema,
SetWorktreeOrderRequestSchema,
ToggleEnabledRequestSchema,
UpsertCustomAgentRequestSchema,
WorktreeNameParamsSchema,
Expand Down Expand Up @@ -119,6 +120,7 @@ const PROJECT_DIR = runtime.projectDir;
const config: ProjectConfig = runtime.config;
const git = runtime.git;
const archiveStateService = runtime.archiveStateService;
const orderStateService = runtime.orderStateService;
const tmux = runtime.tmux;
const projectRuntime = runtime.projectRuntime;
const worktreeCreationTracker = runtime.worktreeCreationTracker;
Expand Down Expand Up @@ -580,6 +582,7 @@ async function readProjectSnapshot(): Promise<ProjectSnapshot> {
: Promise.resolve({ ok: true as const, data: [] });
await reconciliationService.reconcile(PROJECT_DIR);
const archiveState = await archiveStateService.prune(projectRuntime.listWorktrees().map((worktree) => worktree.path));
const orderState = await orderStateService.prune(projectRuntime.listWorktrees().map((worktree) => worktree.branch));
const linearResult = await linearIssuesPromise;
const archivedPaths = buildArchivedWorktreePathSet(archiveState);
const linearIssues = linearResult.ok ? linearResult.data : [];
Expand All @@ -589,6 +592,7 @@ async function readProjectSnapshot(): Promise<ProjectSnapshot> {
runtime: projectRuntime,
creatingWorktrees: worktreeCreationTracker.list(),
notifications: runtimeNotifications.list(),
order: orderState.branches,
isArchived: (path) => archivedPaths.has(normalizeArchivePath(path)),
findLinearIssue: (branch) => {
const match = linearIssues.find((issue) => branchMatchesIssue(branch, issue.branchName));
Expand Down Expand Up @@ -1219,6 +1223,15 @@ async function apiSetWorktreeLabel(name: string, req: Request): Promise<Response
return jsonResponse({ ok: true, label: result.label });
}

async function apiSetWorktreeOrder(req: Request): Promise<Response> {
const parsed = await parseJsonBody(req, SetWorktreeOrderRequestSchema);
if (!parsed.ok) return parsed.response;

log.info(`[worktree:order] branches=${parsed.data.branches.length}`);
const state = await orderStateService.setOrder(parsed.data.branches);
return jsonResponse({ ok: true, branches: state.branches });
}

async function apiSendPrompt(name: string, req: Request): Promise<Response> {
ensureBranchNotBusy(name);
const parsed = await parseJsonBody(req, SendWorktreePromptRequestSchema);
Expand Down Expand Up @@ -1754,6 +1767,10 @@ function startServer(port: number): ReturnType<typeof Bun.serve> {
POST: (req) => catching("POST /api/worktrees", () => apiCreateWorktree(req)),
},

[apiPaths.setWorktreeOrder]: {
PUT: (req) => catching("PUT /api/worktrees/order", () => apiSetWorktreeOrder(req)),
},

[apiPaths.removeWorktree]: {
DELETE: (req) => {
const parsed = parseWorktreeNameParam(req.params);
Expand Down
Loading
Loading