feat(worktree): add managed workspace cloning (#30117)
This commit is contained in:
parent
331bed2469
commit
5661af2034
@ -9,6 +9,7 @@
|
||||
"opencode": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"dev": "bun run src/index.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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)
|
||||
}),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
{
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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", () =>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
1
packages/worktree/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target/
|
||||
992
packages/worktree/Cargo.lock
generated
Normal file
992
packages/worktree/Cargo.lock
generated
Normal 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"
|
||||
19
packages/worktree/Cargo.toml
Normal file
19
packages/worktree/Cargo.toml
Normal 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"
|
||||
13
packages/worktree/crates/cli/Cargo.toml
Normal file
13
packages/worktree/crates/cli/Cargo.toml
Normal 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" }
|
||||
79
packages/worktree/crates/cli/src/main.rs
Normal file
79
packages/worktree/crates/cli/src/main.rs
Normal 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(())
|
||||
}
|
||||
17
packages/worktree/crates/core/Cargo.toml
Normal file
17
packages/worktree/crates/core/Cargo.toml
Normal 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
|
||||
195
packages/worktree/crates/core/src/copy.rs
Normal file
195
packages/worktree/crates/core/src/copy.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
75
packages/worktree/crates/core/src/git.rs
Normal file
75
packages/worktree/crates/core/src/git.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
712
packages/worktree/crates/core/src/lib.rs
Normal file
712
packages/worktree/crates/core/src/lib.rs
Normal 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
182
packages/worktree/specs.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user