worktrees - Implementation
This commit is contained in:
@@ -50,6 +50,36 @@ export interface WorkspaceDeleteResponse {
|
||||
status: WorkspaceStatus
|
||||
}
|
||||
|
||||
export type WorktreeKind = "root" | "worktree"
|
||||
|
||||
export interface WorktreeDescriptor {
|
||||
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
|
||||
slug: string
|
||||
/** Absolute directory path on the server host. */
|
||||
directory: string
|
||||
kind: WorktreeKind
|
||||
/** Optional VCS branch name when available. */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeListResponse {
|
||||
worktrees: WorktreeDescriptor[]
|
||||
}
|
||||
|
||||
export interface WorktreeCreateRequest {
|
||||
slug: string
|
||||
/** Optional branch name (defaults to slug). */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeMap {
|
||||
version: 1
|
||||
/** Default worktree to use for new sessions and as fallback. */
|
||||
defaultWorktreeSlug: string
|
||||
/** Mapping of *parent* session IDs to a worktree slug. */
|
||||
parentSessionWorktreeSlug: Record<string, string>
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
export interface WorkspaceLogEntry {
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "path"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
@@ -20,6 +21,7 @@ import { registerEventRoutes } from "./routes/events"
|
||||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
@@ -222,6 +224,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
@@ -312,31 +315,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
||||
instance.removeAllContentTypeParsers()
|
||||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||
|
||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
const proxyBaseHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
worktreeSlug: request.params.slug,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
worktreeSlug: request.params.slug,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
||||
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,9 +355,10 @@ async function proxyWorkspaceRequest(args: {
|
||||
reply: FastifyReply
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
worktreeSlug: string
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const { request, reply, workspaceManager, logger } = args
|
||||
const { request, reply, workspaceManager, logger, worktreeSlug } = args
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
@@ -364,6 +373,23 @@ async function proxyWorkspaceRequest(args: {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidWorktreeSlug(worktreeSlug)) {
|
||||
reply.code(400).send({ error: "Invalid worktree slug" })
|
||||
return
|
||||
}
|
||||
|
||||
const directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
@@ -381,9 +407,7 @@ async function proxyWorkspaceRequest(args: {
|
||||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
|
||||
// Enforce per-workspace directory scoping for all proxied OpenCode requests.
|
||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
||||
const directory = workspace.path
|
||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
||||
|
||||
@@ -409,6 +433,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
type WorktreeCacheEntry = {
|
||||
expiresAt: number
|
||||
repoRoot: string
|
||||
worktrees: Array<{ slug: string; directory: string }>
|
||||
}
|
||||
|
||||
const WORKTREE_CACHE_TTL_MS = 2000
|
||||
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||
|
||||
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
|
||||
const cached = worktreeCache.get(params.workspaceId)
|
||||
const now = Date.now()
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
||||
const entry: WorktreeCacheEntry = {
|
||||
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
||||
repoRoot,
|
||||
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
||||
}
|
||||
worktreeCache.set(params.workspaceId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function resolveWorktreeDirectory(params: {
|
||||
workspaceId: string
|
||||
workspacePath: string
|
||||
worktreeSlug: string
|
||||
logger: Logger
|
||||
}): Promise<string | null> {
|
||||
const { worktreeSlug } = params
|
||||
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
|
||||
if (match) {
|
||||
return match.directory
|
||||
}
|
||||
|
||||
// If the slug is new (e.g., created moments ago), refresh once.
|
||||
worktreeCache.delete(params.workspaceId)
|
||||
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
|
||||
195
packages/server/src/server/routes/worktrees.ts
Normal file
195
packages/server/src/server/routes/worktrees.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
import {
|
||||
resolveRepoRoot,
|
||||
listWorktrees,
|
||||
isValidWorktreeSlug,
|
||||
createManagedWorktree,
|
||||
removeWorktree,
|
||||
} from "../../workspaces/git-worktrees"
|
||||
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
|
||||
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
}
|
||||
|
||||
const WorktreeMapSchema = z.object({
|
||||
version: z.literal(1),
|
||||
defaultWorktreeSlug: z.string().min(1).default("root"),
|
||||
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
|
||||
})
|
||||
|
||||
const WorktreeCreateSchema = z.object({
|
||||
slug: z.string().trim().min(1),
|
||||
branch: z.string().trim().min(1).optional(),
|
||||
})
|
||||
|
||||
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const { repoRoot } = await resolveRepoRoot(workspace.path, request.log)
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||
const response: WorktreeListResponse = { worktrees }
|
||||
return response
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = WorktreeCreateSchema.parse(request.body ?? {})
|
||||
const slug = body.slug
|
||||
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug" }
|
||||
}
|
||||
if (body.branch) {
|
||||
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree branch" }
|
||||
}
|
||||
if (body.branch !== slug) {
|
||||
reply.code(400)
|
||||
return { error: "Branch must match slug" }
|
||||
}
|
||||
}
|
||||
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||
if (!isGitRepo) {
|
||||
reply.code(400)
|
||||
return { error: "Workspace is not a Git repository" }
|
||||
}
|
||||
|
||||
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
|
||||
|
||||
const created = await createManagedWorktree({
|
||||
repoRoot,
|
||||
workspaceFolder: workspace.path,
|
||||
slug,
|
||||
logger: request.log,
|
||||
})
|
||||
|
||||
reply.code(201)
|
||||
return created
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
|
||||
"/api/workspaces/:id/worktrees/:slug",
|
||||
async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const slug = (request.params.slug ?? "").trim()
|
||||
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug" }
|
||||
}
|
||||
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||
if (!isGitRepo) {
|
||||
reply.code(400)
|
||||
return { error: "Workspace is not a Git repository" }
|
||||
}
|
||||
|
||||
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
|
||||
|
||||
try {
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||
const match = worktrees.find((wt) => wt.slug === slug)
|
||||
if (!match || match.kind === "root") {
|
||||
reply.code(404)
|
||||
return { error: "Worktree not found" }
|
||||
}
|
||||
|
||||
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
|
||||
|
||||
// Best-effort: prune any mappings that point at the deleted worktree.
|
||||
const current = await readWorktreeMap(workspace.path, request.log)
|
||||
let changed = false
|
||||
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
|
||||
if (mapped === slug) {
|
||||
delete nextMapping[sessionId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
|
||||
if (nextDefault !== current.defaultWorktreeSlug) {
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
await writeWorktreeMap(
|
||||
workspace.path,
|
||||
{
|
||||
version: 1,
|
||||
defaultWorktreeSlug: nextDefault,
|
||||
parentSessionWorktreeSlug: nextMapping,
|
||||
},
|
||||
request.log,
|
||||
)
|
||||
}
|
||||
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
return await readWorktreeMap(workspace.path, request.log)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
|
||||
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
|
||||
reply.code(400)
|
||||
return { error: "Invalid defaultWorktreeSlug" }
|
||||
}
|
||||
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
|
||||
if (!isValidWorktreeSlug(slug)) {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug in mapping" }
|
||||
}
|
||||
}
|
||||
await writeWorktreeMap(workspace.path, parsed, request.log)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleError(error: unknown, reply: FastifyReply) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
|
||||
}
|
||||
248
packages/server/src/workspaces/git-worktrees.ts
Normal file
248
packages/server/src/workspaces/git-worktrees.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import path from "path"
|
||||
import { spawn } from "child_process"
|
||||
import type { WorktreeDescriptor } from "../api-types"
|
||||
import { promises as fsp } from "fs"
|
||||
|
||||
export interface LogLike {
|
||||
debug?: (obj: any, msg?: string) => void
|
||||
warn?: (obj: any, msg?: string) => void
|
||||
}
|
||||
|
||||
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
|
||||
|
||||
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
child.once("error", (error) => {
|
||||
resolve({ ok: false, error, stdout, stderr })
|
||||
})
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ ok: true, stdout })
|
||||
} else {
|
||||
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
|
||||
resolve({ ok: false, error, stdout, stderr })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
||||
if (!result.ok) {
|
||||
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||
return { repoRoot: folder, isGitRepo: false }
|
||||
}
|
||||
const repoRoot = result.stdout.trim()
|
||||
if (!repoRoot) {
|
||||
return { repoRoot: folder, isGitRepo: false }
|
||||
}
|
||||
return { repoRoot, isGitRepo: true }
|
||||
}
|
||||
|
||||
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
|
||||
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||
const lines = output.split(/\r?\n/)
|
||||
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
|
||||
|
||||
const flush = () => {
|
||||
if (current.worktree) {
|
||||
records.push({ worktree: current.worktree, branch: current.branch })
|
||||
}
|
||||
current = {}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
const [key, ...rest] = trimmed.split(" ")
|
||||
const value = rest.join(" ").trim()
|
||||
if (key === "worktree") {
|
||||
current.worktree = value
|
||||
} else if (key === "branch") {
|
||||
// branch is like refs/heads/foo
|
||||
current.branch = value.replace(/^refs\/heads\//, "")
|
||||
} else if (key === "HEAD") {
|
||||
current.head = value
|
||||
} else if (key === "detached") {
|
||||
current.detached = true
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return records
|
||||
}
|
||||
|
||||
export async function listWorktrees(params: {
|
||||
repoRoot: string
|
||||
workspaceFolder: string
|
||||
logger?: LogLike
|
||||
}): Promise<WorktreeDescriptor[]> {
|
||||
const { repoRoot, workspaceFolder, logger } = params
|
||||
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||
|
||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||
if (!result.ok) {
|
||||
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||
return [rootDescriptor]
|
||||
}
|
||||
|
||||
const records = parseWorktreePorcelain(result.stdout)
|
||||
|
||||
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||
const slugCounts = new Map<string, number>([["root", 1]])
|
||||
|
||||
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
|
||||
const branch = (record.branch ?? "").trim()
|
||||
if (branch) {
|
||||
return branch
|
||||
}
|
||||
const head = (record.head ?? "").trim()
|
||||
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
|
||||
return `detached-${head.slice(0, 7)}`
|
||||
}
|
||||
// Fallback: stable-ish identifier derived from directory basename.
|
||||
const base = path.basename(record.worktree || "")
|
||||
return base ? `worktree-${base}` : "worktree"
|
||||
}
|
||||
|
||||
const uniquify = (slug: string): string => {
|
||||
const count = slugCounts.get(slug) ?? 0
|
||||
slugCounts.set(slug, count + 1)
|
||||
if (count === 0) {
|
||||
return slug
|
||||
}
|
||||
return `${slug}@${count + 1}`
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const abs = record.worktree
|
||||
if (!abs || typeof abs !== "string") continue
|
||||
|
||||
// Skip the root record (we always expose it as slug="root").
|
||||
if (path.resolve(abs) === path.resolve(repoRoot)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = uniquify(normalizeSlug(record))
|
||||
// Never emit a worktree slug that collides with our reserved root slug.
|
||||
if (slug === "root") {
|
||||
worktrees.push({ slug: uniquify("root-worktree"), directory: abs, kind: "worktree", branch: record.branch })
|
||||
continue
|
||||
}
|
||||
|
||||
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
|
||||
}
|
||||
|
||||
return worktrees
|
||||
}
|
||||
|
||||
export function isValidWorktreeSlug(slug: string): boolean {
|
||||
if (!slug) return false
|
||||
const trimmed = slug.trim()
|
||||
if (!trimmed) return false
|
||||
if (trimmed.length > 200) return false
|
||||
// Disallow control characters; allow branch-like slugs including '/'.
|
||||
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export async function createManagedWorktree(params: {
|
||||
repoRoot: string
|
||||
workspaceFolder: string
|
||||
slug: string
|
||||
logger?: LogLike
|
||||
}): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||
const { repoRoot, workspaceFolder, logger } = params
|
||||
const branch = params.slug.trim()
|
||||
|
||||
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
|
||||
throw new Error("Invalid worktree slug")
|
||||
}
|
||||
|
||||
const sanitizeDirName = (input: string): string => {
|
||||
const normalized = input
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return normalized || "worktree"
|
||||
}
|
||||
|
||||
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
|
||||
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
|
||||
await fsp.mkdir(worktreesDir, { recursive: true })
|
||||
|
||||
try {
|
||||
const stat = await fsp.stat(targetDir)
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error("Worktree directory already exists")
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
|
||||
|
||||
// Prefer creating a new branch from HEAD.
|
||||
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
|
||||
if (first.ok) {
|
||||
return { slug: branch, directory: targetDir, branch }
|
||||
}
|
||||
|
||||
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
|
||||
if (message.includes("already exists")) {
|
||||
// If the branch already exists, add worktree for that branch.
|
||||
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
|
||||
if (second.ok) {
|
||||
return { slug: branch, directory: targetDir, branch }
|
||||
}
|
||||
throw second.error
|
||||
}
|
||||
|
||||
throw first.error
|
||||
}
|
||||
|
||||
export async function removeWorktree(params: {
|
||||
workspaceFolder: string
|
||||
directory: string
|
||||
force?: boolean
|
||||
logger?: LogLike
|
||||
}): Promise<void> {
|
||||
const { workspaceFolder, logger } = params
|
||||
const directory = (params.directory ?? "").trim()
|
||||
if (!directory) {
|
||||
throw new Error("Invalid worktree directory")
|
||||
}
|
||||
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
|
||||
|
||||
const args = ["worktree", "remove"]
|
||||
if (params.force) {
|
||||
args.push("--force")
|
||||
}
|
||||
args.push(directory)
|
||||
|
||||
const result = await runGit(args, workspaceFolder)
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
// Best-effort cleanup of stale metadata.
|
||||
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export class InstanceEventBridge {
|
||||
}
|
||||
|
||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||
const url = `http://${INSTANCE_HOST}:${port}/global/event`
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
@@ -165,8 +165,17 @@ export class InstanceEventBridge {
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
||||
const parsed = JSON.parse(payload) as any
|
||||
const event: InstanceStreamEvent | null = parsed && typeof parsed === "object"
|
||||
? (parsed.payload && parsed.directory && typeof parsed.payload === "object" ? { ...parsed.payload, directory: parsed.directory } : parsed)
|
||||
: null
|
||||
|
||||
if (!event || typeof (event as any).type !== "string") {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
|
||||
return
|
||||
}
|
||||
|
||||
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
|
||||
if (this.options.logger.isLevelEnabled("trace")) {
|
||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export class WorkspaceManager {
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
|
||||
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
|
||||
129
packages/server/src/workspaces/worktree-map.ts
Normal file
129
packages/server/src/workspaces/worktree-map.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import path from "path"
|
||||
import type { WorktreeMap } from "../api-types"
|
||||
import { resolveRepoRoot } from "./git-worktrees"
|
||||
import type { LogLike } from "./git-worktrees"
|
||||
|
||||
const DEFAULT_MAP: WorktreeMap = {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: "root",
|
||||
parentSessionWorktreeSlug: {},
|
||||
}
|
||||
|
||||
function getMapPath(repoRoot: string): string {
|
||||
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
|
||||
}
|
||||
|
||||
function getGitExcludePath(repoRoot: string): string {
|
||||
return path.join(repoRoot, ".git", "info", "exclude")
|
||||
}
|
||||
|
||||
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
|
||||
const excludePath = getGitExcludePath(repoRoot)
|
||||
try {
|
||||
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = [
|
||||
".codenomad/worktrees/",
|
||||
".codenomad/worktreeMap.json",
|
||||
]
|
||||
|
||||
let existing = ""
|
||||
try {
|
||||
existing = await fsp.readFile(excludePath, "utf-8")
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== "ENOENT") {
|
||||
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
|
||||
return
|
||||
}
|
||||
existing = ""
|
||||
}
|
||||
|
||||
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
|
||||
const missing = entries.filter((e) => !lines.has(e))
|
||||
if (missing.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
|
||||
const suffix = missing.map((e) => `${e}\n`).join("")
|
||||
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
|
||||
}
|
||||
|
||||
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
if (!isGitRepo) {
|
||||
return
|
||||
}
|
||||
await ensureGitExclude(repoRoot, logger)
|
||||
}
|
||||
|
||||
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
const filePath = getMapPath(repoRoot)
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
const version = (parsed as any).version
|
||||
if (version !== 1) {
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
|
||||
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
|
||||
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
|
||||
return {
|
||||
version: 1,
|
||||
defaultWorktreeSlug,
|
||||
parentSessionWorktreeSlug: { ...mapping },
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === "ENOENT") {
|
||||
if (isGitRepo) {
|
||||
// Best-effort ignore setup on first use.
|
||||
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||
}
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
const filePath = getMapPath(repoRoot)
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||
|
||||
// Ensure ignore rules are present (local-only).
|
||||
if (isGitRepo) {
|
||||
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||
}
|
||||
|
||||
const payload: WorktreeMap = {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
|
||||
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
|
||||
}
|
||||
|
||||
// Write atomically.
|
||||
const tmpPath = `${filePath}.${process.pid}.tmp`
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
|
||||
await fsp.rename(tmpPath, filePath)
|
||||
}
|
||||
|
||||
export function worktreeMapExists(repoRoot: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(getMapPath(repoRoot))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user