mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 13:14:39 +00:00
fix(app): align desktop worktree location with TUI (#47370)
This commit is contained in:
parent
0991e8b5a5
commit
16601775f1
11 changed files with 82 additions and 23 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -12124,7 +12124,7 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
"required": ["strategy"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {}
|
||||
|
|
|
|||
24
packages/schema/test/worktree.test.ts
Normal file
24
packages/schema/test/worktree.test.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -12124,7 +12124,7 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
"required": ["strategy"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12124,7 +12124,7 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
"required": ["strategy"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue