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
2 changes: 2 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ jobs:
packages/ui/src/stores/session-generation-recovery.test.ts
packages/ui/src/stores/session-pagination.test.ts
packages/ui/src/stores/session-pending-state.test.ts
packages/ui/src/stores/session-tree.test.ts
packages/ui/src/stores/workspace-load-readiness.test.ts
packages/ui/src/types/session.test.ts
packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts
Expand All @@ -155,6 +156,7 @@ jobs:
packages/ui/src/stores/session-request-authority.test.ts
packages/ui/src/stores/session-send-lifecycle.test.ts
packages/ui/src/stores/session-status.test.ts
packages/ui/src/stores/worktree-ready.test.ts

- name: Test server
run: node --import tsx --test "packages/server/src/**/*.test.ts"
Expand Down
16 changes: 16 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,15 @@ export interface WorktreeDescriptor {
slug: string
/** Absolute directory path on the server host. */
directory: string
/** Equivalent path in the OpenCode service namespace (notably WSL). */
serviceDirectory?: string
/** Exact path registered in Git's worktree inventory. */
registeredDirectory?: string
kind: WorktreeKind
/** Optional VCS branch name when available. */
branch?: string
/** Commit recorded by the Git worktree inventory. */
head?: string
}

export interface WorktreeListResponse {
Expand All @@ -110,6 +116,16 @@ export interface WorktreeCreateRequest {
branch?: string
}

export interface WorktreeSessionMoveRequest {
worktreeSlug: string
}

export interface WorktreeSessionMoveResponse {
rootSessionId: string
sessionIds: string[]
worktreeSlug: string
}

export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"

export interface WorktreeGitStatusEntry {
Expand Down
107 changes: 100 additions & 7 deletions packages/server/src/server/routes/worktrees.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,105 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { describe, it } from "node:test"
import Fastify from "fastify"
import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client"
import Fastify from "fastify"
import type { WorkspaceDescriptor } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager"
import { registerWorktreeRoutes } from "./worktrees"

describe("worktree routes", () => {
it("fails a direct delete call closed when session evacuation fails", async () => {
it("reserves the physical worktree and rejects a HEAD change immediately before deletion", async () => {
const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-"))
const repo = path.join(temp, "repo")
const linked = path.join(temp, "feature-worktree")
const workspacePath = path.join(repo, "apps", "web")
const linkedWorkspacePath = path.join(linked, "apps", "web")
const app = Fastify({ logger: false })

try {
mkdirSync(repo, { recursive: true })
execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" })
mkdirSync(workspacePath, { recursive: true })
writeFileSync(path.join(workspacePath, "README.md"), "nested workspace\n")
execFileSync("git", ["-C", repo, "add", "."], { stdio: "ignore" })
execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" })
execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" })

const current: SessionInfo = {
id: "session",
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: linkedWorkspacePath, workspaceID: "native-feature" },
}
let lists = 0
const client = {
location: {
get: async ({ location }: { location?: { directory?: string } }) => ({
directory: location?.directory ?? workspacePath,
workspaceID: path.resolve(location?.directory ?? workspacePath) === path.resolve(linkedWorkspacePath) ? "native-feature" : undefined,
project: { id: "project", directory: workspacePath, canonical: workspacePath },
}),
},
session: {
list: async () => {
if (++lists === 3) {
execFileSync("git", ["-C", linked, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "replace head"], { stdio: "ignore" })
}
return { data: [structuredClone(current)], cursor: {} }
},
active: async () => ({}),
move: async ({ directory, workspaceID }: { directory: string; workspaceID?: string }) => {
current.location = { directory, workspaceID }
},
get: async () => structuredClone(current),
},
} as unknown as OpenCodeClient
let reserved = ""
let released = false
const manager = {
get: () => ({
id: "workspace",
path: workspacePath,
status: "ready",
proxyPath: "/workspaces/workspace/instance",
binaryId: "opencode",
binaryLabel: "opencode",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
reserveWorktreeDeletion: async (directory: string) => {
reserved = directory
return () => { released = true }
},
getSharedServiceClient: async () => client,
getServiceDirectory: () => workspacePath,
getServiceDirectoryForPath: async (_id: string, directory: string) => {
assert.notEqual(path.resolve(directory), path.resolve(linked), "OpenCode must receive the mirrored workspace path")
return directory
},
} as unknown as WorkspaceManager
registerWorktreeRoutes(app, { workspaceManager: manager })

const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/feature" })

assert.equal(response.statusCode, 409)
assert.equal(path.resolve(reserved), path.resolve(linked))
assert.equal(released, true)
assert.equal(path.resolve(current.location.directory), path.resolve(workspacePath))
const inventory = execFileSync("git", ["-C", repo, "worktree", "list", "--porcelain"], { encoding: "utf8" })
assert.ok(inventory.replace(/\\/g, "/").includes(linked.replace(/\\/g, "/")))
} finally {
await app.close()
rmSync(temp, { recursive: true, force: true })
}
})

it("fails a direct delete call closed when session evacuation fails", async () => {
const temp = mkdtempSync(path.join(tmpdir(), "codenomad-delete-worktree-"))
const target = path.join(temp, "doomed")
const app = Fastify({ logger: false })
Expand All @@ -25,33 +113,38 @@ describe("worktree routes", () => {
const workspace = { id: "workspace", path: temp, status: "ready" } as WorkspaceDescriptor
const nativeSession = { id: "unloaded", projectID: "project", location: { directory: target }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo
const client = {
project: {
list: async () => [{ id: "project", canonical: temp, sandboxes: [target], time: { created: 1, updated: 1 } }],
location: {
get: async ({ location }: { location?: { directory?: string } }) => ({
directory: location?.directory ?? temp,
project: { id: "project", canonical: temp, directory: temp },
}),
},
session: {
list: async () => ({ data: [nativeSession], cursor: {} }),
active: async () => ({}),
move: async (input: { directory: string }) => {
if (input.directory === temp) throw new Error("native move failed")
},
get: async () => nativeSession,
},
} as unknown as OpenCodeClient
const manager = {
get: () => workspace,
getSharedServiceClient: async () => client,
getServiceDirectory: () => temp,
getServiceDirectoryForPath: async (_id: string, directory: string) => directory,
reserveWorktreeDeletion: async () => () => undefined,
} as unknown as WorkspaceManager
registerWorktreeRoutes(app, { workspaceManager: manager })

const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" })

assert.equal(response.statusCode, 400)
assert.equal(response.statusCode, 502)
assert.match(response.json().error, /native move failed/)
assert.match(execFileSync("git", ["-C", temp, "worktree", "list", "--porcelain"], { encoding: "utf8" }), /doomed/)
} finally {
await app.close()
rmSync(temp, { recursive: true, force: true })
}
})
})
})
Loading
Loading