Explorar o código

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

Dax hai 2 meses
pai
achega
5661af2034

+ 1 - 0
packages/cli/package.json

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

+ 6 - 2
packages/cli/src/debug/agents.ts

@@ -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)),

+ 14 - 0
packages/core/src/auth.ts

@@ -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)
       }),

+ 9 - 2
packages/core/src/catalog.ts

@@ -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)

+ 3 - 3
packages/core/src/model.ts

@@ -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,
       },
-    })
+    }
   }
 }
 

+ 8 - 1
packages/core/src/plugin.ts

@@ -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),
           {

+ 3 - 1
packages/core/src/plugin/account.ts

@@ -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 = {

+ 2 - 0
packages/core/src/policy.ts

@@ -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(

+ 3 - 3
packages/core/src/provider.ts

@@ -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: {},
         },
       },
-    })
+    }
   }
 }

+ 12 - 11
packages/core/src/state.ts

@@ -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),
   }
 }

+ 23 - 2
packages/core/test/location-layer.test.ts

@@ -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", () =>

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/global.ts

@@ -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"

+ 3 - 3
packages/opencode/test/server/httpapi-global.test.ts

@@ -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 - 0
packages/worktree/.gitignore

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

+ 992 - 0
packages/worktree/Cargo.lock

@@ -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"

+ 19 - 0
packages/worktree/Cargo.toml

@@ -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"

+ 13 - 0
packages/worktree/crates/cli/Cargo.toml

@@ -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" }

+ 79 - 0
packages/worktree/crates/cli/src/main.rs

@@ -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(())
+}

+ 17 - 0
packages/worktree/crates/core/Cargo.toml

@@ -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

+ 195 - 0
packages/worktree/crates/core/src/copy.rs

@@ -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()))
+    }
+}

+ 75 - 0
packages/worktree/crates/core/src/git.rs

@@ -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(),
+    ))
+}

+ 712 - 0
packages/worktree/crates/core/src/lib.rs

@@ -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 - 0
packages/worktree/specs.md

@@ -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.