feat(worktree): add managed workspace cloning (#30117)

This commit is contained in:
Dax 2026-05-31 11:57:44 -04:00 committed by GitHub
parent 331bed2469
commit 5661af2034
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2374 additions and 28 deletions

View File

@ -9,6 +9,7 @@
"opencode": "./src/index.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"dev": "bun run src/index.ts",
"typecheck": "tsgo --noEmit"
},

View File

@ -8,8 +8,12 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
export const AgentsCommand = Command.make("agents", {}, () =>
Effect.gen(function* () {
yield* PluginBoot.Service.use((service) => service.wait())
const agents = yield* AgentV2.Service.use((service) => service.all())
const svc = {
plugin: yield* PluginBoot.Service,
agent: yield* AgentV2.Service,
}
yield* svc.plugin.wait()
const agents = yield* svc.agent.all()
process.stdout.write(
JSON.stringify(
agents.sort((a, b) => a.id.localeCompare(b.id)),

View File

@ -122,6 +122,7 @@ export interface Interface {
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly activeAll: () => Effect.Effect<Map<ServiceID, Info>, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
@ -216,6 +217,19 @@ export const layer = Layer.effect(
)
}),
activeAll: Effect.fn("Auth.activeAll")(function* () {
const data = yield* SynchronizedRef.get(state)
const result = new Map<ServiceID, Info>()
for (const account of Object.values(data.accounts)) {
if (!result.has(account.serviceID)) result.set(account.serviceID, account)
}
for (const [serviceID, id] of Object.entries(data.active)) {
const account = data.accounts[id]
if (account) result.set(ServiceID.make(serviceID), account)
}
return result
}),
forService: Effect.fn("Auth.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),

View File

@ -166,8 +166,14 @@ export const layer = Layer.effect(
model: {
get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID),
update: (providerID, modelID, fn) => {
result.provider.update(providerID, () => {})
const record = draft.providers.get(providerID)!
let record = draft.providers.get(providerID)
if (!record) {
record = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
draft.providers.set(providerID, record)
}
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
@ -190,6 +196,7 @@ export const layer = Layer.effect(
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
if (!policy.hasStatements()) return
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)

View File

@ -68,8 +68,8 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
output: Schema.Int,
}),
}) {
static empty(providerID: ProviderV2.ID, modelID: ID) {
return new Info({
static empty(providerID: ProviderV2.ID, modelID: ID): Info {
return {
id: modelID,
apiID: modelID,
providerID,
@ -101,7 +101,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
context: 0,
output: 0,
},
})
}
}
}

View File

@ -111,7 +111,14 @@ export const layer = Layer.effect(
const existing = hooks.find((item) => item.id === input.id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
const scope = yield* Scope.make()
const result = yield* input.effect.pipe(Scope.provide(scope))
const result = yield* input.effect.pipe(
Scope.provide(scope),
Effect.withSpan("Plugin.load", {
attributes: {
"plugin.id": input.id,
},
}),
)
hooks = [
...hooks.filter((item) => item.id !== input.id),
{

View File

@ -21,8 +21,10 @@ export const AccountPlugin = PluginV2.define({
return {
"catalog.transform": Effect.fn(function* (evt) {
const active = yield* accounts.activeAll().pipe(Effect.orDie)
if (active.size === 0) return
for (const item of evt.provider.list()) {
const account = yield* accounts.active(Auth.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
const account = active.get(Auth.ServiceID.make(item.provider.id))
if (!account) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {

View File

@ -16,6 +16,7 @@ export class Info extends Schema.Class<Info>("Policy.Info")({
export interface Interface {
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
readonly hasStatements: () => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
@ -30,6 +31,7 @@ export const layer = Layer.effect(
load: EffectRuntime.fn("Policy.load")(function* (input) {
statements = input
}),
hasStatements: () => statements.length > 0,
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(

View File

@ -101,8 +101,8 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
endpoint: Endpoint,
options: Options,
}) {
static empty(providerID: ID) {
return new Info({
static empty(providerID: ID): Info {
return {
id: providerID,
name: providerID,
enabled: false,
@ -118,6 +118,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
request: {},
},
},
})
}
}
}

View File

@ -1,7 +1,7 @@
export * as State from "./state"
import { Effect, Scope, Semaphore } from "effect"
import { createDraft, finishDraft, type Draft, type Objectish } from "immer"
import type { Draft, Objectish } from "immer"
export type Transform<Editor> = (editor: Editor) => void
export type MakeEditor<State extends Objectish, Editor> = (draft: Draft<State>) => Editor
@ -24,17 +24,18 @@ export function create<State extends Objectish, Editor>(options: Options<State,
let transforms: { update: Transform<Editor> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (draft: Draft<State>, reason?: string) {
const api = options.editor(draft)
const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) {
const api = options.editor(next as Draft<State>)
if (options.finalize) yield* options.finalize(api, reason)
state = finishDraft(draft) as State
state = next
})
const rebuild = Effect.fn("State.rebuild")(function* () {
const draft = createDraft(options.initial())
const api = options.editor(draft)
for (const transform of transforms) transform.update(api)
yield* commit(draft)
const next = options.initial()
const api = options.editor(next as Draft<State>)
for (const transform of transforms)
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
yield* commit(next)
}, semaphore.withPermit)
return {
@ -55,9 +56,9 @@ export function create<State extends Objectish, Editor>(options: Options<State,
})
}),
update: Effect.fn("State.update")(function* (update, reason) {
const draft = createDraft(state)
yield* update(options.editor(draft))
yield* commit(draft, reason)
const api = options.editor(state as Draft<State>)
yield* update(api)
if (options.finalize) yield* options.finalize(api, reason)
}, semaphore.withPermit),
}
}

View File

@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
@ -9,8 +9,29 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { AppFileSystem } from "../src/filesystem"
import { Auth } from "../src/auth"
import { EventV2 } from "../src/event"
import { Global } from "../src/global"
import { ModelsDev } from "../src/models-dev"
import { Npm } from "../src/npm"
import { Project } from "../src/project"
const it = testEffect(LocationServiceMap.layer)
const it = testEffect(
LocationServiceMap.layer.pipe(
Layer.provide(
Layer.mergeAll(
Project.defaultLayer,
EventV2.defaultLayer,
Auth.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
AppFileSystem.defaultLayer,
Global.defaultLayer,
),
),
),
)
describe("LocationServiceMap", () => {
it.live("isolates location state while sharing location policy with catalog", () =>

View File

@ -1,6 +1,8 @@
import { Config } from "@/config/config"
import { EventV2 } from "@opencode-ai/core/event"
import { InstanceDisposed } from "@/server/event"
import "@opencode-ai/core/account"
import "@/server/event"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"

View File

@ -19,6 +19,9 @@ const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
// Raw HttpApi routes expose an opaque handler context at the request boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
@ -33,9 +36,6 @@ const apiLayer = HttpRouter.serve(
}),
),
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
// Raw HttpApi routes expose an opaque handler context at the web boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.provide(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
)
const it = testEffect(apiLayer)

1
packages/worktree/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target/

992
packages/worktree/Cargo.lock generated Normal file
View File

@ -0,0 +1,992 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]]
name = "rusqlite"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "ulid"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
dependencies = [
"rand",
"web-time",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "worktree"
version = "0.1.0"
dependencies = [
"dirs",
"filetime",
"libc",
"rusqlite",
"tempfile",
"thiserror",
"ulid",
"walkdir",
]
[[package]]
name = "worktree-cli"
version = "0.1.0"
dependencies = [
"clap",
"worktree",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@ -0,0 +1,19 @@
[workspace]
resolver = "2"
members = ["crates/core", "crates/cli"]
[workspace.package]
edition = "2024"
license = "MIT"
version = "0.1.0"
[workspace.dependencies]
clap = { version = "4.5", features = ["derive"] }
dirs = "6.0"
filetime = "0.2"
libc = "0.2"
rusqlite = { version = "0.35", features = ["bundled"] }
tempfile = "3.20"
thiserror = "2.0"
ulid = "1.2"
walkdir = "2.5"

View File

@ -0,0 +1,13 @@
[package]
name = "worktree-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "worktree"
path = "src/main.rs"
[dependencies]
clap.workspace = true
worktree = { path = "../core" }

View File

@ -0,0 +1,79 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use worktree::{Create, Link, Manager};
#[derive(Parser)]
#[command(name = "worktree")]
struct Cli {
#[arg(long, hide = true)]
database: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Create {
from: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
into: Option<PathBuf>,
},
Remove {
at: PathBuf,
},
Link {
at: PathBuf,
#[arg(long)]
to: Option<PathBuf>,
},
Children {
of: PathBuf,
},
Ancestors {
of: PathBuf,
},
}
fn main() {
if let Err(error) = run() {
eprintln!("worktree: {error}");
std::process::exit(1);
}
}
fn run() -> worktree::Result<()> {
let cli = Cli::parse();
let mut manager = match cli.database {
Some(path) => Manager::open(path)?,
None => Manager::open_default()?,
};
match cli.command {
Command::Create { from, name, into } => {
println!(
"{}",
manager
.create(Create {
from: from.unwrap_or(std::env::current_dir()?),
name,
into,
})?
.display()
);
}
Command::Remove { at } => manager.remove(at)?,
Command::Link { at, to } => manager.link(Link { at, to })?,
Command::Children { of } => {
for path in manager.children(of)? {
println!("{}", path.display());
}
}
Command::Ancestors { of } => {
for path in manager.ancestors(of)? {
println!("{}", path.display());
}
}
}
Ok(())
}

View File

@ -0,0 +1,17 @@
[package]
name = "worktree"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
dirs.workspace = true
filetime.workspace = true
libc.workspace = true
rusqlite.workspace = true
thiserror.workspace = true
ulid.workspace = true
walkdir.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@ -0,0 +1,195 @@
use crate::{Error, Result};
#[cfg(target_os = "linux")]
use filetime::{FileTime, set_file_times};
use std::fs;
#[cfg(target_os = "linux")]
use std::fs::{File, OpenOptions};
use std::path::Path;
#[cfg(any(target_os = "linux", test))]
use walkdir::WalkDir;
pub(crate) trait CopyStrategy {
fn copy_directory(&self, from: &Path, to: &Path) -> Result<()>;
}
pub(crate) struct CowStrategy;
impl CopyStrategy for CowStrategy {
fn copy_directory(&self, from: &Path, to: &Path) -> Result<()> {
#[cfg(target_os = "linux")]
return copy_directory_linux(from, to);
#[cfg(target_os = "macos")]
return copy_directory_macos(from, to);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (from, to);
Err(Error::CowUnavailable(
"no copy-on-write strategy has been implemented for this platform".into(),
))
}
}
}
#[cfg(target_os = "macos")]
fn copy_directory_macos(from: &Path, to: &Path) -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let source = CString::new(from.as_os_str().as_bytes())
.map_err(|_| Error::Path(format!("path contains a null byte: {}", from.display())))?;
let destination = CString::new(to.as_os_str().as_bytes())
.map_err(|_| Error::Path(format!("path contains a null byte: {}", to.display())))?;
let result = unsafe { libc::clonefile(source.as_ptr(), destination.as_ptr(), 0) };
if result == 0 {
return Ok(());
}
Err(Error::CowUnavailable(format!(
"failed to clone {}: {}",
from.display(),
std::io::Error::last_os_error()
)))
}
#[cfg(target_os = "linux")]
fn copy_directory_linux(from: &Path, to: &Path) -> Result<()> {
fs::create_dir(to)?;
fs::set_permissions(to, fs::metadata(from)?.permissions())?;
let entries = WalkDir::new(from)
.min_depth(1)
.follow_links(false)
.into_iter()
.collect::<std::result::Result<Vec<_>, _>>()?;
for entry in &entries {
let relative = entry
.path()
.strip_prefix(from)
.map_err(|error| Error::Path(error.to_string()))?;
let destination = to.join(relative);
let metadata = fs::symlink_metadata(entry.path())?;
if metadata.is_dir() {
fs::create_dir(&destination)?;
fs::set_permissions(&destination, metadata.permissions())?;
continue;
}
if metadata.is_symlink() {
copy_symlink(entry.path(), &destination)?;
continue;
}
if !metadata.is_file() {
return Err(Error::UnsupportedEntry(entry.path().to_path_buf()));
}
reflink_file(entry.path(), &destination)?;
fs::set_permissions(&destination, metadata.permissions())?;
set_file_times(
&destination,
FileTime::from_last_access_time(&metadata),
FileTime::from_last_modification_time(&metadata),
)?;
}
for entry in entries
.iter()
.rev()
.filter(|entry| entry.file_type().is_dir())
{
let destination = to.join(
entry
.path()
.strip_prefix(from)
.map_err(|error| Error::Path(error.to_string()))?,
);
let metadata = fs::metadata(entry.path())?;
set_file_times(
&destination,
FileTime::from_last_access_time(&metadata),
FileTime::from_last_modification_time(&metadata),
)?;
}
let metadata = fs::metadata(from)?;
set_file_times(
to,
FileTime::from_last_access_time(&metadata),
FileTime::from_last_modification_time(&metadata),
)?;
Ok(())
}
#[cfg(target_os = "linux")]
fn reflink_file(from: &Path, to: &Path) -> Result<()> {
use std::os::fd::AsRawFd;
const FICLONE: libc::c_ulong = 0x4004_9409;
let source = File::open(from)?;
let destination = OpenOptions::new().write(true).create_new(true).open(to)?;
let result = unsafe { libc::ioctl(destination.as_raw_fd(), FICLONE, source.as_raw_fd()) };
if result == 0 {
return Ok(());
}
let error = std::io::Error::last_os_error();
Err(Error::CowUnavailable(format!(
"failed to reflink {}: {}",
from.display(),
error
)))
}
#[cfg(unix)]
fn copy_symlink(from: &Path, to: &Path) -> Result<()> {
std::os::unix::fs::symlink(fs::read_link(from)?, to)?;
Ok(())
}
#[cfg(windows)]
fn copy_symlink(from: &Path, to: &Path) -> Result<()> {
let target = fs::read_link(from)?;
if fs::metadata(from)?.is_dir() {
std::os::windows::fs::symlink_dir(target, to)?;
return Ok(());
}
std::os::windows::fs::symlink_file(target, to)?;
Ok(())
}
#[cfg(test)]
pub(crate) struct TestStrategy;
#[cfg(test)]
impl CopyStrategy for TestStrategy {
fn copy_directory(&self, from: &Path, to: &Path) -> Result<()> {
fs::create_dir(to)?;
for entry in WalkDir::new(from).min_depth(1).follow_links(false) {
let entry = entry?;
let destination = to.join(
entry
.path()
.strip_prefix(from)
.map_err(|error| Error::Path(error.to_string()))?,
);
if entry.file_type().is_dir() {
fs::create_dir(&destination)?;
continue;
}
if entry.file_type().is_symlink() {
copy_symlink(entry.path(), &destination)?;
continue;
}
fs::copy(entry.path(), destination)?;
}
Ok(())
}
}
#[cfg(test)]
pub(crate) struct FailureStrategy;
#[cfg(test)]
impl CopyStrategy for FailureStrategy {
fn copy_directory(&self, _from: &Path, _to: &Path) -> Result<()> {
Err(Error::CowUnavailable("test failure".into()))
}
}

View File

@ -0,0 +1,75 @@
use crate::{Error, Result};
use std::fs;
use std::path::Path;
use std::process::Command;
pub(crate) fn check_source(path: &Path) -> Result<bool> {
let git = path.join(".git");
if !git.exists() {
return Ok(false);
}
if !git.is_dir() {
return Err(Error::UnsafeGit(
"linked Git worktree sources are not supported".into(),
));
}
for state in [
"MERGE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
"BISECT_LOG",
"rebase-merge",
"rebase-apply",
"index.lock",
"HEAD.lock",
] {
if git.join(state).exists() {
return Err(Error::UnsafeGit(format!("Git state in progress: {state}")));
}
}
Ok(true)
}
pub(crate) fn hide_marker(path: &Path) -> Result<()> {
let info = path.join(".git").join("info");
fs::create_dir_all(&info)?;
let exclude = info.join("exclude");
let existing = if exclude.exists() {
fs::read_to_string(&exclude)?
} else {
String::new()
};
if existing.lines().any(|line| line.trim() == "/.worktree") {
return Ok(());
}
let separator = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
fs::write(exclude, format!("{existing}{separator}/.worktree\n"))?;
Ok(())
}
pub(crate) fn detach_destination(path: &Path) -> Result<()> {
let head = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "--verify", "HEAD^{commit}"])
.output()?;
if !head.status.success() {
return Ok(());
}
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(["switch", "--detach", "--quiet", "HEAD"])
.output()?;
if output.status.success() {
return Ok(());
}
Err(Error::UnsafeGit(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
))
}

View File

@ -0,0 +1,712 @@
mod copy;
mod git;
use copy::{CopyStrategy, CowStrategy};
use rusqlite::{Connection, OptionalExtension, params};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use ulid::Ulid;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Database(#[from] rusqlite::Error),
#[error("{0}")]
Walk(#[from] walkdir::Error),
#[error("invalid path: {0}")]
Path(String),
#[error("copy-on-write cloning unavailable: {0}")]
CowUnavailable(String),
#[error("unsupported filesystem entry: {0}")]
UnsupportedEntry(PathBuf),
#[error("unsafe Git source: {0}")]
UnsafeGit(String),
#[error("worktree is not managed: {0}")]
NotManaged(PathBuf),
#[error("worktree marker does not match the registry at: {0}")]
MarkerMismatch(PathBuf),
#[error("worktree marker belongs to an unknown registry entry at: {0}")]
UnknownMarker(PathBuf),
#[error("worktree already exists: {0}")]
AlreadyExists(PathBuf),
#[error("cannot remove the original registered workspace: {0}")]
CannotRemoveRoot(PathBuf),
#[error("cannot reparent the original registered workspace: {0}")]
CannotLinkRoot(PathBuf),
#[error("cannot remove subtree while a recorded worktree path is missing: {0}")]
MissingWorktree(PathBuf),
#[error("cannot link a worktree to itself or its descendant")]
Cycle,
#[error("cannot copy a workspace into itself: {0}")]
InsideSource(PathBuf),
}
pub struct Create {
pub from: PathBuf,
pub name: Option<String>,
pub into: Option<PathBuf>,
}
pub struct Link {
pub at: PathBuf,
pub to: Option<PathBuf>,
}
#[derive(Clone)]
struct Record {
id: String,
parent_id: Option<String>,
path: PathBuf,
}
pub struct Manager {
database: Connection,
copier: Box<dyn CopyStrategy>,
}
impl Manager {
pub fn open_default() -> Result<Self> {
let path = default_database_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Self::open(path)
}
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::with_copier(path, Box::new(CowStrategy))
}
fn with_copier(path: impl AsRef<Path>, copier: Box<dyn CopyStrategy>) -> Result<Self> {
let database = Connection::open(path)?;
database.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS worktree (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES worktree(id) ON DELETE CASCADE,
path TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS worktree_parent_id_idx ON worktree(parent_id);",
)?;
Ok(Self { database, copier })
}
pub fn create(&mut self, input: Create) -> Result<PathBuf> {
let from = existing_directory(&input.from)?;
let git = git::check_source(&from)?;
let (source, register_source) = self.source(&from)?;
let root = self.root(&source)?;
let id = Ulid::new().to_string();
let destination_parent = match input.into {
Some(path) => absolute_path(&path)?,
None => default_storage(&root.path)?,
};
let name = destination_name(input.name, &id)?;
if destination_parent.join(&name).starts_with(&from) {
return Err(Error::InsideSource(destination_parent.join(name)));
}
fs::create_dir_all(&destination_parent)?;
let destination_parent = fs::canonicalize(destination_parent)?;
let destination = destination_parent.join(name);
if destination.starts_with(&from) {
return Err(Error::InsideSource(destination));
}
if destination.exists() {
return Err(Error::AlreadyExists(destination));
}
if let Err(error) = self.copier.copy_directory(&from, &destination) {
let _ = fs::remove_dir_all(&destination);
return Err(error);
}
let result = (|| {
write_marker(&destination, &id)?;
if git {
git::hide_marker(&destination)?;
git::detach_destination(&destination)?;
}
if register_source {
write_marker(&from, &source.id)?;
self.database.execute(
"INSERT INTO worktree (id, parent_id, path, created_at) VALUES (?1, NULL, ?2, ?3)",
params![source.id, path_text(&from)?, timestamp()],
)?;
}
if git {
git::hide_marker(&from)?;
}
self.database.execute(
"INSERT INTO worktree (id, parent_id, path, created_at) VALUES (?1, ?2, ?3, ?4)",
params![id, source.id, path_text(&destination)?, timestamp()],
)?;
Ok(destination.clone())
})();
if result.is_err() {
let _ = fs::remove_dir_all(&destination);
}
result
}
pub fn remove(&mut self, at: impl AsRef<Path>) -> Result<()> {
let at = existing_directory(at.as_ref())?;
let record = self.record_at(&at)?;
if record.parent_id.is_none() {
return Err(Error::CannotRemoveRoot(at));
}
verify_marker(&record)?;
let mut statement = self.database.prepare(
"WITH RECURSIVE subtree(id, path, depth) AS (
SELECT id, path, 0 FROM worktree WHERE id = ?1
UNION ALL
SELECT worktree.id, worktree.path, subtree.depth + 1
FROM worktree JOIN subtree ON worktree.parent_id = subtree.id
) SELECT id, path, depth FROM subtree ORDER BY depth DESC",
)?;
let rows = statement
.query_map([&record.id], |row| {
Ok((
row.get::<_, String>(0)?,
PathBuf::from(row.get::<_, String>(1)?),
row.get::<_, i64>(2)?,
))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
drop(statement);
for (id, path, _) in &rows {
if !path.exists() {
return Err(Error::MissingWorktree(path.clone()));
}
verify_marker(&Record {
id: id.clone(),
parent_id: None,
path: path.clone(),
})?;
}
for (id, path, _) in &rows {
fs::remove_dir_all(path)?;
self.database
.execute("DELETE FROM worktree WHERE id = ?1", [id])?;
}
Ok(())
}
pub fn link(&mut self, input: Link) -> Result<()> {
let at = existing_directory(&input.at)?;
let record = match read_marker(&at)? {
Some(id) => {
let record = self
.record_id(&id)?
.ok_or_else(|| Error::UnknownMarker(at.clone()))?;
if record.path != at {
if record.path.exists() {
return Err(Error::MarkerMismatch(at));
}
self.database.execute(
"UPDATE worktree SET path = ?1 WHERE id = ?2",
params![path_text(&at)?, record.id],
)?;
}
Record {
path: at.clone(),
..record
}
}
None => {
let record = self.record_at(&at)?;
write_marker(&at, &record.id)?;
record
}
};
if at.join(".git").is_dir() {
git::hide_marker(&at)?;
}
let Some(to) = input.to else {
return Ok(());
};
if record.parent_id.is_none() {
return Err(Error::CannotLinkRoot(at));
}
let parent = self.record_at(&existing_directory(&to)?)?;
if parent.id == record.id || self.is_descendant(&parent.id, &record.id)? {
return Err(Error::Cycle);
}
self.database.execute(
"UPDATE worktree SET parent_id = ?1 WHERE id = ?2",
params![parent.id, record.id],
)?;
Ok(())
}
pub fn children(&self, of: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
let record = self.record_at(&existing_directory(of.as_ref())?)?;
let mut statement = self
.database
.prepare("SELECT path FROM worktree WHERE parent_id = ?1 ORDER BY created_at, id")?;
Ok(statement
.query_map([record.id], |row| {
Ok(PathBuf::from(row.get::<_, String>(0)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn ancestors(&self, of: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
let record = self.record_at(&existing_directory(of.as_ref())?)?;
let mut paths = Vec::new();
let mut parent_id = record.parent_id;
while let Some(id) = parent_id {
let parent = self
.record_id(&id)?
.ok_or_else(|| Error::NotManaged(record.path.clone()))?;
paths.push(parent.path);
parent_id = parent.parent_id;
}
Ok(paths)
}
fn source(&self, path: &Path) -> Result<(Record, bool)> {
if let Some(id) = read_marker(path)? {
let record = self
.record_id(&id)?
.ok_or_else(|| Error::UnknownMarker(path.to_path_buf()))?;
if record.path != path {
return Err(Error::MarkerMismatch(path.to_path_buf()));
}
return Ok((record, false));
}
if self.record_at_optional(path)?.is_some() {
return Err(Error::MarkerMismatch(path.to_path_buf()));
}
let id = Ulid::new().to_string();
Ok((
Record {
id,
parent_id: None,
path: path.to_path_buf(),
},
true,
))
}
fn root(&self, record: &Record) -> Result<Record> {
let mut current = record.clone();
while let Some(id) = current.parent_id.clone() {
current = self
.record_id(&id)?
.ok_or_else(|| Error::NotManaged(record.path.clone()))?;
}
Ok(current)
}
fn record_at(&self, path: &Path) -> Result<Record> {
self.record_at_optional(path)?
.ok_or_else(|| Error::NotManaged(path.to_path_buf()))
}
fn record_at_optional(&self, path: &Path) -> Result<Option<Record>> {
self.database
.query_row(
"SELECT id, parent_id, path FROM worktree WHERE path = ?1",
[path_text(path)?],
|row| {
Ok(Record {
id: row.get(0)?,
parent_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
})
},
)
.optional()
.map_err(Error::from)
}
fn record_id(&self, id: &str) -> Result<Option<Record>> {
self.database
.query_row(
"SELECT id, parent_id, path FROM worktree WHERE id = ?1",
[id],
|row| {
Ok(Record {
id: row.get(0)?,
parent_id: row.get(1)?,
path: PathBuf::from(row.get::<_, String>(2)?),
})
},
)
.optional()
.map_err(Error::from)
}
fn is_descendant(&self, candidate: &str, of: &str) -> Result<bool> {
Ok(self.database.query_row(
"WITH RECURSIVE descendants(id) AS (
SELECT id FROM worktree WHERE parent_id = ?1
UNION ALL
SELECT worktree.id FROM worktree JOIN descendants ON worktree.parent_id = descendants.id
) SELECT EXISTS(SELECT 1 FROM descendants WHERE id = ?2)",
params![of, candidate],
|row| row.get(0),
)?)
}
}
fn default_database_path() -> Result<PathBuf> {
let base = dirs::data_local_dir()
.ok_or_else(|| Error::Path("user data directory is unavailable".into()))?;
Ok(base.join("worktree").join("worktree.sqlite"))
}
fn existing_directory(path: &Path) -> Result<PathBuf> {
let path = fs::canonicalize(path)?;
if !path.is_dir() {
return Err(Error::Path(format!("not a directory: {}", path.display())));
}
Ok(path)
}
fn absolute_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
Ok(std::env::current_dir()?.join(path))
}
fn default_storage(root: &Path) -> Result<PathBuf> {
let parent = root
.parent()
.ok_or_else(|| Error::Path(format!("workspace has no parent: {}", root.display())))?;
let name = root
.file_name()
.ok_or_else(|| Error::Path(format!("workspace has no name: {}", root.display())))?;
Ok(parent.join(".worktrees").join(name))
}
fn destination_name(name: Option<String>, id: &str) -> Result<String> {
let name = name.unwrap_or_else(|| id.to_owned());
if name.is_empty() || name == "." || name == ".." || Path::new(&name).components().count() != 1
{
return Err(Error::Path(format!("invalid worktree name: {name}")));
}
Ok(name)
}
fn marker(path: &Path) -> PathBuf {
path.join(".worktree")
}
fn write_marker(path: &Path, id: &str) -> Result<()> {
fs::write(marker(path), format!("{id}\n"))?;
Ok(())
}
fn read_marker(path: &Path) -> Result<Option<String>> {
let marker = marker(path);
if !marker.exists() {
return Ok(None);
}
Ok(Some(fs::read_to_string(marker)?.trim().to_owned()))
}
fn verify_marker(record: &Record) -> Result<()> {
if read_marker(&record.path)?.as_deref() == Some(&record.id) {
return Ok(());
}
Err(Error::MarkerMismatch(record.path.clone()))
}
fn path_text(path: &Path) -> Result<String> {
path.to_str()
.map(ToOwned::to_owned)
.ok_or_else(|| Error::Path(format!("path is not valid UTF-8: {}", path.display())))
}
fn timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::copy::{FailureStrategy, TestStrategy};
use std::process::Command;
use tempfile::TempDir;
fn manager(temp: &TempDir) -> Manager {
Manager::with_copier(temp.path().join("registry.sqlite"), Box::new(TestStrategy)).unwrap()
}
fn source(temp: &TempDir) -> PathBuf {
let source = temp.path().join("app");
fs::create_dir(&source).unwrap();
fs::write(source.join("file.txt"), "hello").unwrap();
source
}
#[test]
fn create_tracks_parentage_and_default_storage() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let first = manager
.create(Create {
from: source.clone(),
name: Some("first".into()),
into: None,
})
.unwrap();
let second = manager
.create(Create {
from: first.clone(),
name: Some("second".into()),
into: None,
})
.unwrap();
assert_eq!(first, temp.path().join(".worktrees/app/first"));
assert_eq!(second, temp.path().join(".worktrees/app/second"));
assert_ne!(
fs::read_to_string(source.join(".worktree")).unwrap(),
fs::read_to_string(first.join(".worktree")).unwrap()
);
assert_eq!(manager.children(&source).unwrap(), vec![first.clone()]);
assert_eq!(manager.ancestors(&second).unwrap(), vec![first, source]);
}
#[test]
fn remove_deletes_a_full_subtree() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let first = manager
.create(Create {
from: source.clone(),
name: Some("first".into()),
into: None,
})
.unwrap();
let second = manager
.create(Create {
from: first.clone(),
name: Some("second".into()),
into: None,
})
.unwrap();
manager.remove(&first).unwrap();
assert!(!first.exists());
assert!(!second.exists());
assert!(manager.children(&source).unwrap().is_empty());
assert!(matches!(
manager.remove(&source),
Err(Error::CannotRemoveRoot(_))
));
}
#[test]
fn remove_refuses_a_subtree_with_an_unlinked_move() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let first = manager
.create(Create {
from: source,
name: Some("first".into()),
into: None,
})
.unwrap();
let second = manager
.create(Create {
from: first.clone(),
name: Some("second".into()),
into: None,
})
.unwrap();
fs::rename(&second, temp.path().join("moved")).unwrap();
assert!(matches!(
manager.remove(&first),
Err(Error::MissingWorktree(_))
));
assert!(first.exists());
}
#[test]
fn link_restores_moves_markers_and_reparents() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let first = manager
.create(Create {
from: source.clone(),
name: Some("first".into()),
into: None,
})
.unwrap();
let second = manager
.create(Create {
from: source.clone(),
name: Some("second".into()),
into: None,
})
.unwrap();
let moved = temp.path().join("moved");
fs::rename(&second, &moved).unwrap();
manager
.link(Link {
at: moved.clone(),
to: Some(first.clone()),
})
.unwrap();
assert_eq!(
manager.ancestors(&moved).unwrap(),
vec![first, source.clone()]
);
fs::remove_file(source.join(".worktree")).unwrap();
manager
.link(Link {
at: source.clone(),
to: None,
})
.unwrap();
assert!(source.join(".worktree").exists());
}
#[test]
fn link_does_not_reparent_a_registered_source() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = manager(&temp);
let child = manager
.create(Create {
from: source.clone(),
name: Some("child".into()),
into: None,
})
.unwrap();
assert!(matches!(
manager.link(Link {
at: source.clone(),
to: Some(child),
}),
Err(Error::CannotLinkRoot(_))
));
assert!(matches!(
manager.remove(&source),
Err(Error::CannotRemoveRoot(_))
));
}
#[test]
fn git_copy_detaches_head_and_preserves_dirty_state() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
run(&source, &["init"]);
run(&source, &["config", "user.email", "test@example.com"]);
run(&source, &["config", "user.name", "Test"]);
run(&source, &["add", "file.txt"]);
run(&source, &["commit", "-m", "initial"]);
fs::write(source.join("file.txt"), "changed").unwrap();
run(&source, &["add", "file.txt"]);
fs::write(source.join("untracked.txt"), "new").unwrap();
let mut manager = manager(&temp);
let destination = manager
.create(Create {
from: source.clone(),
name: Some("git".into()),
into: None,
})
.unwrap();
assert!(
!Command::new("git")
.arg("-C")
.arg(&destination)
.args(["symbolic-ref", "-q", "HEAD"])
.status()
.unwrap()
.success()
);
let staged = Command::new("git")
.arg("-C")
.arg(&destination)
.args(["diff", "--cached", "--name-only"])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&staged.stdout).contains("file.txt"));
assert!(destination.join("untracked.txt").exists());
let status = Command::new("git")
.arg("-C")
.arg(&destination)
.args(["status", "--porcelain", "--", ".worktree"])
.output()
.unwrap();
assert!(status.stdout.is_empty());
}
#[test]
fn unsafe_git_source_is_rejected_without_registering_it() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
run(&source, &["init"]);
fs::write(source.join(".git/MERGE_HEAD"), "commit").unwrap();
let mut manager = manager(&temp);
assert!(matches!(
manager.create(Create {
from: source.clone(),
name: Some("unsafe".into()),
into: None,
}),
Err(Error::UnsafeGit(_))
));
assert!(!source.join(".worktree").exists());
}
#[test]
fn unavailable_cow_does_not_register_the_source() {
let temp = TempDir::new().unwrap();
let source = source(&temp);
let mut manager = Manager::with_copier(
temp.path().join("registry.sqlite"),
Box::new(FailureStrategy),
)
.unwrap();
assert!(matches!(
manager.create(Create {
from: source.clone(),
name: Some("failure".into()),
into: None,
}),
Err(Error::CowUnavailable(_))
));
assert!(!source.join(".worktree").exists());
assert!(manager.record_at_optional(&source).unwrap().is_none());
}
fn run(path: &Path, args: &[&str]) {
assert!(
Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.unwrap()
.success()
);
}
}

182
packages/worktree/specs.md Normal file
View File

@ -0,0 +1,182 @@
# Worktree Specs
## Requirement
`worktree` must be cross-platform as far as practical. Core semantics should work across macOS, Linux, and Windows. Copy-on-write is a platform/filesystem acceleration and must not define the product model.
## API
### `create`
```ts
create(input: {
from: AbsolutePath
name?: string
into?: AbsolutePath
}): AbsolutePath
```
Default behavior:
- Source is `from`.
- `name` defaults to a generated directory name.
- `into` defaults to the managed worktree directory.
- Copy the whole workspace, including dirty, staged, untracked, and ignored files.
- Detach `HEAD` in the new workspace.
- Return the path of the new workspace.
If `from` is already a managed worktree, create copies that exact worktree. Do not resolve back to an earlier workspace. Metadata should record the immediate source worktree as its parent.
Default storage is a hidden sibling directory of the original registered workspace:
```text
/projects/app/ original workspace
/projects/.worktrees/app/task-a/ created worktree
/projects/.worktrees/app/task-b/ created worktree
```
- Created worktrees must not be stored inside the workspace being copied, because an exact copy would recursively contain existing worktrees.
- If `from` is an original unregistered workspace, its sibling `.worktrees/<workspace-name>/` directory becomes the default destination directory.
- If `from` is already managed, descendants use the default destination directory associated with the original workspace rather than nesting storage beside each descendant.
- If `into` is provided, use it instead of the default destination directory.
- If the original workspace is itself a filesystem mount root, its sibling default destination may not support copy-on-write with it; provide `into` on the same filesystem in that case.
### `remove`
```ts
remove(input: {
at: AbsolutePath
}): void
```
`remove` deletes a managed worktree and its full descendant subtree.
- `at` must identify a worktree created by this tool; the registered source root cannot be removed.
- Resolve all descendants through `parent_id` and remove their directories deepest-first.
- Verify each existing directory's `.worktree` marker before deleting it.
- Refuse removal if any descendant path is missing, because it may be a moved workspace that has not been linked yet.
- After successful filesystem removal, delete the subtree records from the database.
### `link`
```ts
link(input: {
at: AbsolutePath
to?: AbsolutePath
}): void
```
`link` reconnects a moved managed worktree to its registry record and can change its parent.
- Read the ULID from `.worktree` at `at`.
- Look up the existing worktree record by ULID.
- If its recorded path is `at`, leave its location unchanged.
- If its recorded path is different and missing, update it to `at`.
- If its recorded path is different and still exists, fail because this is a duplicate identity, not a move.
- If the ULID is unknown to the database, fail; `.worktree` alone does not include the ancestry needed to rebuild the record.
- If `.worktree` is missing, look up `at` by its absolute path. If it matches an existing record, recreate the marker with that record's ULID.
- If `.worktree` is missing and `at` does not match an existing record, fail. A moved workspace without its marker cannot be identified safely.
- If `to` is provided, set the worktree's parent to the managed worktree at `to`.
- Refuse `to` for an original registered workspace; only worktrees created by this tool can be reparented.
- Refuse `to` if it is `at` or a descendant of `at`, because reparenting must not create a cycle.
### `children`
```ts
children(input: {
of: AbsolutePath
}): AbsolutePath[]
```
`children` returns the direct managed children created from `of`.
### `ancestors`
```ts
ancestors(input: {
of: AbsolutePath
}): AbsolutePath[]
```
`ancestors` returns the managed ancestry of `of`, ordered from its immediate parent to the root workspace.
## Metadata
Metadata is stored in a central SQLite database in the platform-appropriate user data directory.
SQLite is not overkill: multiple processes and agents may create, inspect, or remove worktrees concurrently. It provides cross-platform transactions and locking without building a safe JSON registry protocol.
Start with one table:
```sql
CREATE TABLE worktree (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES worktree(id) ON DELETE CASCADE,
path TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);
CREATE INDEX worktree_parent_id_idx ON worktree(parent_id);
```
- Every managed worktree has a stable generated `id`.
- `id` is a ULID generated when the workspace is first registered or created.
- `id` is stored in the central database and in a `.worktree` marker file at the root of the workspace.
- `.worktree` contains the worktree ULID and allows a moved workspace to be rediscovered and verified against the database.
- When a managed workspace is copied, the copied `.worktree` marker is replaced with the new workspace's ULID.
- The original registered workspace has `parent_id = NULL`.
- A created worktree has `parent_id` set to the source worktree `id`.
- `path` is its current location, not its identity.
- Provenance is a rooted tree. Descendants of any worktree can be listed through recursive queries over `parent_id`.
- `remove` deletes a whole subtree, so no surviving record depends on deleted ancestry.
### Moved Worktrees
If a worktree is moved outside the tool, its recorded path becomes missing. The tool cannot discover an arbitrary new location without being given a path or scanning a configured directory.
When `link` is run against a directory containing `.worktree`, the tool reads its ULID and reconciles the database path if the recorded path no longer exists.
If both the recorded path and the provided path exist with the same ULID, the tool must refuse automatic reconciliation because the directory was copied without assigning a new identity.
## Git Integration
Git support is an integration for directories that contain repositories; it does not define the core worktree model.
When registering or creating from a Git repository:
- Add `/.worktree` to `.git/info/exclude` so the identity marker does not appear in local Git status.
- Copy the directory with its staged, unstaged, untracked, ignored, and cached state intact.
- If `HEAD` resolves to a commit, detach `HEAD` in the created destination at that same commit.
- Preserve the copied index and working tree state while detaching.
- If the repository has no commits yet, leave its unborn branch state unchanged because there is no commit to detach to.
Refuse creation from a Git repository when:
- It is a linked Git worktree whose `.git` is not an independent directory.
- A merge, rebase, cherry-pick, revert, or bisect is in progress.
- Git lock or inconsistent index state makes an exact safe copy unclear.
The tool does not create branches, commit changes, or otherwise replace normal Git commands.
## Copy Strategies
Copying is implemented behind a strategy boundary so platform-specific copy-on-write backends can be added independently.
- The production strategy on Linux uses reflink cloning.
- The production strategy on macOS uses APFS `clonefile` directory cloning.
- If no implemented copy-on-write strategy succeeds, `create` fails.
- Full byte copying is not implemented as a fallback.
- Future strategies may add Windows copy-on-write support without changing the API.
## Packaging
The project ships four interfaces backed by the same implementation and metadata model:
1. Native library containing the core API and implementation.
2. CLI package providing the `worktree` executable.
3. Bun FFI package for use from Bun applications.
4. Node FFI package for use from Node.js applications.
The CLI and language bindings should remain thin and expose the same API semantics as the native library.
For CLI ergonomics, `worktree create` defaults `from` to the current working directory when no source path is provided.