fix(app): align desktop worktree location with TUI (#47370)

This commit is contained in:
Luke Parker 2026-09-05 08:12:15 +10:00 committed by GitHub
parent 0991e8b5a5
commit 16601775f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 82 additions and 23 deletions

View file

@ -7,30 +7,27 @@ import { createWorktree } from "./create"
describe("worktree creation", () => {
test.each(
[
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo" },
{
name: "clone subdirectory",
directory: "/copies/repo/packages/app",
root: "/copies/repo",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "linked worktree subdirectory",
directory: "/linked/task/packages/app",
root: "/linked/task",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "Windows clone",
directory: "C:\\copies\\repo\\packages\\app",
root: "C:\\copies\\repo",
canonical: "C:\\copies\\repo",
parent: "C:/copies/",
},
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
)("uses the server destination and clone-local main for $name (cached: $cached)", async (input) => {
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
const requests: Request[] = []
const api = OpenCode.make({
@ -66,7 +63,6 @@ describe("worktree creation", () => {
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",

View file

@ -1,6 +1,5 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
@ -15,7 +14,6 @@ export async function createWorktree(input: {
strategy: "git",
from: project.canonical,
branch: input.branch,
directory: getDirectory(project.canonical),
})
// Populate the client cache before the destination session mounts.
await input.data.location.syncInfo({ directory: created.directory })

View file

@ -1940,7 +1940,7 @@ export type WorktreeCreateInput = {
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly branch?: string | undefined
readonly directory: AbsolutePath
readonly directory?: AbsolutePath | undefined
readonly name?: string | undefined
}
export type WorktreeCreateOutput = Worktree.Info

View file

@ -6087,35 +6087,35 @@ export type WorktreeCreateInput = {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["strategy"]
readonly from?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["from"]
readonly branch?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["branch"]
readonly directory: {
readonly directory?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["directory"]
readonly name?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["name"]
}

View file

@ -5,6 +5,7 @@ import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Git } from "./git.js"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { ProjectSchema } from "./project/schema.js"
@ -149,6 +150,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
@ -243,14 +245,15 @@ const layer = Layer.effect(
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.from, input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const directory = input.directory ?? path.join(global.data, "worktree", input.projectID.slice(0, 6))
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
let worktreeDirectory = AbsolutePath.make(path.join(directory, name))
while (yield* fs.existsSafe(worktreeDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
@ -368,7 +371,7 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
deps: [FSUtil.node, Global.node, Git.node, Bus.node, Database.node, AppProcess.node],
})
export const refreshNode = makeLocationNode({

View file

@ -6,6 +6,7 @@ import { and, eq, isNull } from "drizzle-orm"
import { Effect, Fiber, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Git } from "@opencode-ai/core/git"
import { Database } from "@opencode-ai/core/database/database"
@ -17,12 +18,18 @@ import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { initRepo } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
const projectIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])),
)
const defaultIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Global.node]), [
Global.node.replace(tempGlobalLayer),
]),
)
function abs(input: string) {
return AbsolutePath.make(input)
@ -180,6 +187,34 @@ describe("Worktree", () => {
}),
)
defaultIt.live("defaults to the TUI worktree directory and suffixes duplicate names", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const global = yield* Global.Service
const parent = path.join(global.data, "worktree", "worktr")
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
name: "task",
})
const duplicate = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
name: "task",
})
expect(created.directory).toBe(abs(path.join(parent, "task")))
expect(duplicate.directory).toBe(abs(path.join(parent, "task-2")))
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, ".git")).exists())).toBe(true)
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: duplicate.directory, force: false })
}),
)
it.live("runs the project setup script with worktree paths", () =>
Effect.gen(function* () {
const input = yield* setup()

View file

@ -12124,7 +12124,7 @@
"type": "string"
}
},
"required": ["strategy", "directory"],
"required": ["strategy"],
"additionalProperties": false
}
}

View file

@ -14,7 +14,10 @@ export const CreateInput = Schema.Struct({
strategy: StrategyID,
from: optional(AbsolutePath),
branch: optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))),
directory: AbsolutePath,
directory: optional(AbsolutePath).annotate({
description:
"Parent directory for the new worktree. Defaults to the server's data directory under worktree/<first six project ID characters>.",
}),
name: optional(Schema.String),
}).annotate({ identifier: "Worktree.CreateInput" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}

View file

@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Worktree } from "../src/worktree.js"
describe("Worktree.CreateInput", () => {
test("allows the server to choose the destination", () => {
const input = Schema.decodeUnknownSync(Worktree.CreateInput)({
projectID: "project",
strategy: "git",
})
expect(input.directory).toBeUndefined()
expect(Schema.encodeSync(Worktree.CreateInput)({ ...input, directory: undefined })).toEqual({
projectID: "project",
strategy: "git",
})
})
test("preserves an explicit destination", () => {
const input = { projectID: "project", strategy: "git", directory: "/custom/worktrees" }
expect(Schema.encodeSync(Worktree.CreateInput)(Schema.decodeUnknownSync(Worktree.CreateInput)(input))).toEqual(
input,
)
})
})

View file

@ -12124,7 +12124,7 @@
"type": "string"
}
},
"required": ["strategy", "directory"],
"required": ["strategy"],
"additionalProperties": false
}
}

View file

@ -12124,7 +12124,7 @@
"type": "string"
}
},
"required": ["strategy", "directory"],
"required": ["strategy"],
"additionalProperties": false
}
}