index.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  2. import { path } from "@kirincode-ai/core/effect/app-node-platform"
  3. import { Global } from "@kirincode-ai/core/global"
  4. import { InstanceStore } from "@/project/instance-store"
  5. import { Project } from "@/project/project"
  6. import { Database } from "@kirincode-ai/core/database/database"
  7. import { eq } from "drizzle-orm"
  8. import { ProjectTable } from "@kirincode-ai/core/project/sql"
  9. import type { ProjectV2 } from "@kirincode-ai/core/project"
  10. import { Slug } from "@kirincode-ai/core/util/slug"
  11. import { errorMessage } from "../util/error"
  12. import { GlobalBus } from "@/bus/global"
  13. import { Git } from "@/git"
  14. import { Effect, Layer, Path, Schema, Scope, Context } from "effect"
  15. import { ChildProcess } from "effect/unstable/process"
  16. import { FSUtil } from "@kirincode-ai/core/fs-util"
  17. import { AppProcess } from "@kirincode-ai/core/process"
  18. import { InstanceState } from "@/effect/instance-state"
  19. import { WorktreeEvent } from "@kirincode-ai/schema/worktree-event"
  20. export const Event = WorktreeEvent
  21. export const Info = Schema.Struct({
  22. name: Schema.String,
  23. branch: Schema.optional(Schema.String),
  24. directory: Schema.String,
  25. }).annotate({ identifier: "Worktree" })
  26. export type Info = Schema.Schema.Type<typeof Info>
  27. export const CreateInput = Schema.Struct({
  28. name: Schema.optional(Schema.String),
  29. startCommand: Schema.optional(
  30. Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }),
  31. ),
  32. }).annotate({ identifier: "WorktreeCreateInput" })
  33. export type CreateInput = Schema.Schema.Type<typeof CreateInput>
  34. export const RemoveInput = Schema.Struct({
  35. directory: Schema.String,
  36. }).annotate({ identifier: "WorktreeRemoveInput" })
  37. export type RemoveInput = Schema.Schema.Type<typeof RemoveInput>
  38. export const ResetInput = Schema.Struct({
  39. directory: Schema.String,
  40. }).annotate({ identifier: "WorktreeResetInput" })
  41. export type ResetInput = Schema.Schema.Type<typeof ResetInput>
  42. export class NotGitError extends Schema.TaggedErrorClass<NotGitError>()("WorktreeNotGitError", {
  43. message: Schema.String,
  44. }) {}
  45. export class NameGenerationFailedError extends Schema.TaggedErrorClass<NameGenerationFailedError>()(
  46. "WorktreeNameGenerationFailedError",
  47. {
  48. message: Schema.String,
  49. },
  50. ) {}
  51. export class CreateFailedError extends Schema.TaggedErrorClass<CreateFailedError>()("WorktreeCreateFailedError", {
  52. message: Schema.String,
  53. }) {}
  54. export class StartCommandFailedError extends Schema.TaggedErrorClass<StartCommandFailedError>()(
  55. "WorktreeStartCommandFailedError",
  56. {
  57. message: Schema.String,
  58. },
  59. ) {}
  60. export class RemoveFailedError extends Schema.TaggedErrorClass<RemoveFailedError>()("WorktreeRemoveFailedError", {
  61. message: Schema.String,
  62. }) {}
  63. export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("WorktreeResetFailedError", {
  64. message: Schema.String,
  65. }) {}
  66. export class ListFailedError extends Schema.TaggedErrorClass<ListFailedError>()("WorktreeListFailedError", {
  67. message: Schema.String,
  68. }) {}
  69. export type Error =
  70. | NotGitError
  71. | NameGenerationFailedError
  72. | CreateFailedError
  73. | StartCommandFailedError
  74. | RemoveFailedError
  75. | ResetFailedError
  76. | ListFailedError
  77. function slugify(input: string) {
  78. return input
  79. .trim()
  80. .toLowerCase()
  81. .replace(/[^a-z0-9]+/g, "-")
  82. .replace(/^-+/, "")
  83. .replace(/-+$/, "")
  84. }
  85. function failedRemoves(...chunks: string[]) {
  86. return chunks.filter(Boolean).flatMap((chunk) =>
  87. chunk
  88. .split("\n")
  89. .map((line) => line.trim())
  90. .flatMap((line) => {
  91. const match = line.match(/^warning:\s+failed to remove\s+(.+):\s+/i)
  92. if (!match) return []
  93. const value = match[1]?.trim().replace(/^['"]|['"]$/g, "")
  94. if (!value) return []
  95. return [value]
  96. }),
  97. )
  98. }
  99. // ---------------------------------------------------------------------------
  100. // Effect service
  101. // ---------------------------------------------------------------------------
  102. export interface Interface {
  103. readonly makeWorktreeInfo: (options?: { name?: string; detached?: boolean }) => Effect.Effect<Info, Error>
  104. readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void, Error>
  105. readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
  106. readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[], Error>
  107. readonly remove: (input: RemoveInput) => Effect.Effect<boolean, Error>
  108. readonly reset: (input: ResetInput) => Effect.Effect<boolean, Error>
  109. }
  110. export class Service extends Context.Service<Service, Interface>()("@kirincode/Worktree") {}
  111. type GitResult = { code: number; text: string; stderr: string }
  112. const layer: Layer.Layer<
  113. Service,
  114. never,
  115. | FSUtil.Service
  116. | Path.Path
  117. | AppProcess.Service
  118. | Git.Service
  119. | Project.Service
  120. | InstanceStore.Service
  121. | Database.Service
  122. > = Layer.effect(
  123. Service,
  124. Effect.gen(function* () {
  125. const scope = yield* Scope.Scope
  126. const fs = yield* FSUtil.Service
  127. const pathSvc = yield* Path.Path
  128. const appProcess = yield* AppProcess.Service
  129. const { db } = yield* Database.Service
  130. const gitSvc = yield* Git.Service
  131. const project = yield* Project.Service
  132. const store = yield* InstanceStore.Service
  133. const git = Effect.fnUntraced(
  134. function* (args: string[], opts?: { cwd?: string }) {
  135. const result = yield* appProcess.run(
  136. ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
  137. )
  138. return {
  139. code: result.exitCode,
  140. text: result.stdout.toString("utf8"),
  141. stderr: result.stderr.toString("utf8"),
  142. } satisfies GitResult
  143. },
  144. Effect.catch((e) =>
  145. Effect.succeed({
  146. code: 1,
  147. text: "",
  148. stderr: e instanceof Error ? e.message : String(e),
  149. } satisfies GitResult),
  150. ),
  151. )
  152. const MAX_NAME_ATTEMPTS = 26
  153. const candidate = Effect.fn("Worktree.candidate")(function* (input: {
  154. root: string
  155. name?: string
  156. detached?: boolean
  157. }) {
  158. const ctx = yield* InstanceState.context
  159. for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
  160. const name = input.name ? (attempt === 0 ? input.name : `${input.name}-${Slug.create()}`) : Slug.create()
  161. const branch = input.detached ? undefined : `opencode/${name}`
  162. const directory = pathSvc.join(input.root, name)
  163. if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
  164. if (branch) {
  165. const ref = `refs/heads/${branch}`
  166. const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
  167. if (branchCheck.code === 0) continue
  168. }
  169. return { name, directory, ...(branch ? { branch } : {}) }
  170. }
  171. return yield* new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
  172. })
  173. const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (input?: {
  174. name?: string
  175. detached?: boolean
  176. }) {
  177. const ctx = yield* InstanceState.context
  178. if (ctx.project.vcs !== "git") {
  179. return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
  180. }
  181. const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
  182. yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
  183. return yield* candidate({ root, name: input?.name ? slugify(input.name) : "", detached: input?.detached })
  184. })
  185. const setup = Effect.fnUntraced(function* (info: Info) {
  186. const ctx = yield* InstanceState.context
  187. const created = yield* git(
  188. info.branch
  189. ? ["worktree", "add", "--no-checkout", "-b", info.branch, info.directory]
  190. : ["worktree", "add", "--no-checkout", "--detach", info.directory, "HEAD"],
  191. { cwd: ctx.worktree },
  192. )
  193. if (created.code !== 0) {
  194. return yield* new CreateFailedError({
  195. message: created.stderr || created.text || "Failed to create git worktree",
  196. })
  197. }
  198. yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
  199. })
  200. const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
  201. const ctx = yield* InstanceState.context
  202. const workspaceID = yield* InstanceState.workspaceID
  203. const projectID = ctx.project.id
  204. const extra = startCommand?.trim()
  205. const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
  206. if (populated.code !== 0) {
  207. const message = populated.stderr || populated.text || "Failed to populate worktree"
  208. yield* Effect.logError("worktree checkout failed", { directory: info.directory, message })
  209. GlobalBus.emit("event", {
  210. directory: info.directory,
  211. project: ctx.project.id,
  212. workspace: workspaceID,
  213. payload: { type: Event.Failed.type, properties: { message } },
  214. })
  215. return
  216. }
  217. const booted = yield* store.load({ directory: info.directory }).pipe(
  218. Effect.as(true),
  219. Effect.catch((error) =>
  220. Effect.gen(function* () {
  221. const message = errorMessage(error)
  222. yield* Effect.logError("worktree bootstrap failed", { directory: info.directory, message })
  223. GlobalBus.emit("event", {
  224. directory: info.directory,
  225. project: ctx.project.id,
  226. workspace: workspaceID,
  227. payload: { type: Event.Failed.type, properties: { message } },
  228. })
  229. return false
  230. }),
  231. ),
  232. )
  233. if (!booted) return
  234. GlobalBus.emit("event", {
  235. directory: info.directory,
  236. project: ctx.project.id,
  237. workspace: workspaceID,
  238. payload: {
  239. type: Event.Ready.type,
  240. properties: { name: info.name, ...(info.branch ? { branch: info.branch } : {}) },
  241. },
  242. })
  243. yield* runStartScripts(info.directory, { projectID, extra })
  244. })
  245. const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
  246. yield* setup(info)
  247. yield* boot(info, startCommand).pipe(
  248. Effect.catchCause((cause) => Effect.logError("worktree bootstrap failed", { cause })),
  249. Effect.forkIn(scope),
  250. )
  251. })
  252. const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
  253. const info = yield* makeWorktreeInfo({ name: input?.name })
  254. yield* createFromInfo(info, input?.startCommand)
  255. return info
  256. })
  257. const canonical = Effect.fnUntraced(function* (input: string) {
  258. const abs = pathSvc.resolve(input)
  259. const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
  260. const normalized = pathSvc.normalize(real)
  261. return process.platform === "win32" ? normalized.toLowerCase() : normalized
  262. })
  263. function parseWorktreeList(text: string) {
  264. return text
  265. .split("\n")
  266. .map((line) => line.trim())
  267. .reduce<{ path?: string; branch?: string }[]>((acc, line) => {
  268. if (!line) return acc
  269. if (line.startsWith("worktree ")) {
  270. acc.push({ path: line.slice("worktree ".length).trim() })
  271. return acc
  272. }
  273. const current = acc[acc.length - 1]
  274. if (!current) return acc
  275. if (line.startsWith("branch ")) {
  276. current.branch = line.slice("branch ".length).trim()
  277. }
  278. return acc
  279. }, [])
  280. }
  281. const locateWorktree = Effect.fnUntraced(function* (
  282. entries: { path?: string; branch?: string }[],
  283. directory: string,
  284. ) {
  285. for (const item of entries) {
  286. if (!item.path) continue
  287. const key = yield* canonical(item.path)
  288. if (key === directory) return item
  289. }
  290. return undefined
  291. })
  292. const list = Effect.fn("Worktree.list")(function* () {
  293. const ctx = yield* InstanceState.context
  294. if (ctx.project.vcs !== "git") {
  295. return []
  296. }
  297. const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
  298. if (result.code !== 0) {
  299. return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
  300. }
  301. const primary = yield* canonical(ctx.project.worktree)
  302. const primaryName = pathSvc.basename(primary).toLowerCase()
  303. return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
  304. Effect.gen(function* () {
  305. if (!entry.path) return undefined
  306. const directory = yield* canonical(entry.path)
  307. if (directory === primary) return undefined
  308. const name = pathSvc.basename(directory).toLowerCase()
  309. return {
  310. name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name,
  311. directory,
  312. ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}),
  313. }
  314. }),
  315. ).pipe(Effect.map((items) => items.filter((item) => item !== undefined)))
  316. })
  317. function stopFsmonitor(target: string) {
  318. return fs.exists(target).pipe(
  319. Effect.orDie,
  320. Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
  321. )
  322. }
  323. function cleanDirectory(target: string) {
  324. return Effect.tryPromise({
  325. try: async () => {
  326. const fsp = await import("fs/promises")
  327. const attempts = process.platform === "win32" ? 50 : 5
  328. for (const attempt of Array.from({ length: attempts }, (_, i) => i)) {
  329. try {
  330. await fsp.rm(target, { recursive: true, force: true })
  331. return
  332. } catch (error) {
  333. if (attempt === attempts - 1) throw error
  334. await new Promise((resolve) => setTimeout(resolve, 100))
  335. }
  336. }
  337. },
  338. catch: (error) =>
  339. new RemoveFailedError({ message: errorMessage(error) || "Failed to remove git worktree directory" }),
  340. })
  341. }
  342. const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
  343. const ctx = yield* InstanceState.context
  344. if (ctx.project.vcs !== "git") {
  345. return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
  346. }
  347. const directory = yield* canonical(input.directory)
  348. // Preserve the loaded path casing for the store cache; `directory` is lowercased on Windows.
  349. if (directory !== (yield* canonical(ctx.worktree))) yield* store.disposeDirectory(input.directory)
  350. const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
  351. if (list.code !== 0) {
  352. return yield* new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
  353. }
  354. const entries = parseWorktreeList(list.text)
  355. const entry = yield* locateWorktree(entries, directory)
  356. if (!entry?.path) {
  357. const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie)
  358. if (directoryExists) {
  359. yield* stopFsmonitor(directory)
  360. yield* cleanDirectory(directory)
  361. }
  362. return true
  363. }
  364. // Git may return the original casing when a caller supplied a normalized Windows path.
  365. yield* store.disposeDirectory(entry.path)
  366. yield* stopFsmonitor(entry.path)
  367. const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: ctx.worktree })
  368. if (removed.code !== 0) {
  369. const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
  370. if (next.code !== 0) {
  371. return yield* new RemoveFailedError({
  372. message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
  373. })
  374. }
  375. const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
  376. if (stale?.path) {
  377. return yield* new RemoveFailedError({
  378. message: removed.stderr || removed.text || "Failed to remove git worktree",
  379. })
  380. }
  381. }
  382. yield* cleanDirectory(entry.path)
  383. const branch = entry.branch?.replace(/^refs\/heads\//, "")
  384. if (branch) {
  385. const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree })
  386. if (deleted.code !== 0) {
  387. return yield* new RemoveFailedError({
  388. message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
  389. })
  390. }
  391. }
  392. return true
  393. })
  394. const gitExpect = Effect.fnUntraced(function* (
  395. args: string[],
  396. opts: { cwd: string },
  397. error: (r: GitResult) => Error,
  398. ) {
  399. const result = yield* git(args, opts)
  400. if (result.code !== 0) return yield* error(result)
  401. return result
  402. })
  403. const runStartCommand = Effect.fnUntraced(
  404. function* (directory: string, cmd: string) {
  405. const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
  406. const result = yield* appProcess.run(
  407. ChildProcess.make(shell, args as string[], { cwd: directory, extendEnv: true, stdin: "ignore" }),
  408. )
  409. return { code: result.exitCode, stderr: result.stderr.toString("utf8") }
  410. },
  411. Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
  412. )
  413. const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
  414. const text = cmd.trim()
  415. if (!text) return true
  416. const result = yield* runStartCommand(directory, text)
  417. if (result.code === 0) return true
  418. yield* Effect.logError("worktree start command failed", { kind, directory, message: result.stderr })
  419. return false
  420. })
  421. const runStartScripts = Effect.fnUntraced(function* (
  422. directory: string,
  423. input: { projectID: ProjectV2.ID; extra?: string },
  424. ) {
  425. const row = yield* db
  426. .select()
  427. .from(ProjectTable)
  428. .where(eq(ProjectTable.id, input.projectID))
  429. .get()
  430. .pipe(Effect.orDie)
  431. const project = row ? Project.fromRow(row) : undefined
  432. const startup = project?.commands?.start?.trim() ?? ""
  433. const ok = yield* runStartScript(directory, startup, "project")
  434. if (!ok) return false
  435. yield* runStartScript(directory, input.extra ?? "", "worktree")
  436. return true
  437. })
  438. const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
  439. const base = yield* canonical(root)
  440. yield* Effect.forEach(
  441. entries,
  442. (entry) =>
  443. Effect.gen(function* () {
  444. const target = yield* canonical(pathSvc.resolve(root, entry))
  445. if (target === base) return
  446. if (!target.startsWith(`${base}${pathSvc.sep}`)) return
  447. yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore)
  448. }),
  449. { concurrency: "unbounded" },
  450. )
  451. })
  452. const sweep = Effect.fnUntraced(function* (root: string) {
  453. const first = yield* git(["clean", "-ffdx"], { cwd: root })
  454. if (first.code === 0) return first
  455. const entries = failedRemoves(first.stderr, first.text)
  456. if (!entries.length) return first
  457. yield* prune(root, entries)
  458. return yield* git(["clean", "-ffdx"], { cwd: root })
  459. })
  460. const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
  461. const ctx = yield* InstanceState.context
  462. if (ctx.project.vcs !== "git") {
  463. return yield* new NotGitError({ message: "Worktrees are only supported for git projects" })
  464. }
  465. const directory = yield* canonical(input.directory)
  466. const primary = yield* canonical(ctx.worktree)
  467. if (directory === primary) {
  468. return yield* new ResetFailedError({ message: "Cannot reset the primary workspace" })
  469. }
  470. const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
  471. if (list.code !== 0) {
  472. return yield* new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
  473. }
  474. const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
  475. if (!entry?.path) {
  476. return yield* new ResetFailedError({ message: "Worktree not found" })
  477. }
  478. const worktreePath = entry.path
  479. const base = yield* gitSvc.defaultBranch(ctx.worktree)
  480. if (!base) {
  481. return yield* new ResetFailedError({ message: "Default branch not found" })
  482. }
  483. const sep = base.ref.indexOf("/")
  484. if (base.ref !== base.name && sep > 0) {
  485. const remote = base.ref.slice(0, sep)
  486. const branch = base.ref.slice(sep + 1)
  487. yield* gitExpect(
  488. ["fetch", remote, branch],
  489. { cwd: ctx.worktree },
  490. (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
  491. )
  492. }
  493. yield* gitExpect(
  494. ["reset", "--hard", base.ref],
  495. { cwd: worktreePath },
  496. (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
  497. )
  498. const cleanResult = yield* sweep(worktreePath)
  499. if (cleanResult.code !== 0) {
  500. return yield* new ResetFailedError({
  501. message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree",
  502. })
  503. }
  504. yield* gitExpect(
  505. ["submodule", "update", "--init", "--recursive", "--force"],
  506. { cwd: worktreePath },
  507. (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
  508. )
  509. yield* gitExpect(
  510. ["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
  511. { cwd: worktreePath },
  512. (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
  513. )
  514. yield* gitExpect(
  515. ["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
  516. { cwd: worktreePath },
  517. (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
  518. )
  519. const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
  520. if (status.code !== 0) {
  521. return yield* new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
  522. }
  523. if (status.text.trim()) {
  524. return yield* new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
  525. }
  526. yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe(
  527. Effect.catchCause((cause) => Effect.logError("worktree start task failed", { cause })),
  528. Effect.forkIn(scope),
  529. )
  530. return true
  531. })
  532. return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset })
  533. }),
  534. )
  535. export const node = LayerNode.make({
  536. service: Service,
  537. layer: layer,
  538. deps: [FSUtil.node, path, AppProcess.node, Git.node, Project.node, InstanceStore.node, Database.node],
  539. })
  540. export * as Worktree from "."