index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import { $ } from "bun"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import z from "zod"
  5. import { NamedError } from "@opencode-ai/util/error"
  6. import { Global } from "../global"
  7. import { Instance } from "../project/instance"
  8. import { InstanceBootstrap } from "../project/bootstrap"
  9. import { Project } from "../project/project"
  10. import { Storage } from "../storage/storage"
  11. import { fn } from "../util/fn"
  12. import { Log } from "../util/log"
  13. import { BusEvent } from "@/bus/bus-event"
  14. import { GlobalBus } from "@/bus/global"
  15. export namespace Worktree {
  16. const log = Log.create({ service: "worktree" })
  17. export const Event = {
  18. Ready: BusEvent.define(
  19. "worktree.ready",
  20. z.object({
  21. name: z.string(),
  22. branch: z.string(),
  23. }),
  24. ),
  25. Failed: BusEvent.define(
  26. "worktree.failed",
  27. z.object({
  28. message: z.string(),
  29. }),
  30. ),
  31. }
  32. export const Info = z
  33. .object({
  34. name: z.string(),
  35. branch: z.string(),
  36. directory: z.string(),
  37. })
  38. .meta({
  39. ref: "Worktree",
  40. })
  41. export type Info = z.infer<typeof Info>
  42. export const CreateInput = z
  43. .object({
  44. name: z.string().optional(),
  45. startCommand: z
  46. .string()
  47. .optional()
  48. .describe("Additional startup script to run after the project's start command"),
  49. })
  50. .meta({
  51. ref: "WorktreeCreateInput",
  52. })
  53. export type CreateInput = z.infer<typeof CreateInput>
  54. export const RemoveInput = z
  55. .object({
  56. directory: z.string(),
  57. })
  58. .meta({
  59. ref: "WorktreeRemoveInput",
  60. })
  61. export type RemoveInput = z.infer<typeof RemoveInput>
  62. export const ResetInput = z
  63. .object({
  64. directory: z.string(),
  65. })
  66. .meta({
  67. ref: "WorktreeResetInput",
  68. })
  69. export type ResetInput = z.infer<typeof ResetInput>
  70. export const NotGitError = NamedError.create(
  71. "WorktreeNotGitError",
  72. z.object({
  73. message: z.string(),
  74. }),
  75. )
  76. export const NameGenerationFailedError = NamedError.create(
  77. "WorktreeNameGenerationFailedError",
  78. z.object({
  79. message: z.string(),
  80. }),
  81. )
  82. export const CreateFailedError = NamedError.create(
  83. "WorktreeCreateFailedError",
  84. z.object({
  85. message: z.string(),
  86. }),
  87. )
  88. export const StartCommandFailedError = NamedError.create(
  89. "WorktreeStartCommandFailedError",
  90. z.object({
  91. message: z.string(),
  92. }),
  93. )
  94. export const RemoveFailedError = NamedError.create(
  95. "WorktreeRemoveFailedError",
  96. z.object({
  97. message: z.string(),
  98. }),
  99. )
  100. export const ResetFailedError = NamedError.create(
  101. "WorktreeResetFailedError",
  102. z.object({
  103. message: z.string(),
  104. }),
  105. )
  106. const ADJECTIVES = [
  107. "brave",
  108. "calm",
  109. "clever",
  110. "cosmic",
  111. "crisp",
  112. "curious",
  113. "eager",
  114. "gentle",
  115. "glowing",
  116. "happy",
  117. "hidden",
  118. "jolly",
  119. "kind",
  120. "lucky",
  121. "mighty",
  122. "misty",
  123. "neon",
  124. "nimble",
  125. "playful",
  126. "proud",
  127. "quick",
  128. "quiet",
  129. "shiny",
  130. "silent",
  131. "stellar",
  132. "sunny",
  133. "swift",
  134. "tidy",
  135. "witty",
  136. ] as const
  137. const NOUNS = [
  138. "cabin",
  139. "cactus",
  140. "canyon",
  141. "circuit",
  142. "comet",
  143. "eagle",
  144. "engine",
  145. "falcon",
  146. "forest",
  147. "garden",
  148. "harbor",
  149. "island",
  150. "knight",
  151. "lagoon",
  152. "meadow",
  153. "moon",
  154. "mountain",
  155. "nebula",
  156. "orchid",
  157. "otter",
  158. "panda",
  159. "pixel",
  160. "planet",
  161. "river",
  162. "rocket",
  163. "sailor",
  164. "squid",
  165. "star",
  166. "tiger",
  167. "wizard",
  168. "wolf",
  169. ] as const
  170. function pick<const T extends readonly string[]>(list: T) {
  171. return list[Math.floor(Math.random() * list.length)]
  172. }
  173. function slug(input: string) {
  174. return input
  175. .trim()
  176. .toLowerCase()
  177. .replace(/[^a-z0-9]+/g, "-")
  178. .replace(/^-+/, "")
  179. .replace(/-+$/, "")
  180. }
  181. function randomName() {
  182. return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
  183. }
  184. async function exists(target: string) {
  185. return fs
  186. .stat(target)
  187. .then(() => true)
  188. .catch(() => false)
  189. }
  190. function outputText(input: Uint8Array | undefined) {
  191. if (!input?.length) return ""
  192. return new TextDecoder().decode(input).trim()
  193. }
  194. function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
  195. return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
  196. }
  197. async function canonical(input: string) {
  198. const abs = path.resolve(input)
  199. const real = await fs.realpath(abs).catch(() => abs)
  200. const normalized = path.normalize(real)
  201. return process.platform === "win32" ? normalized.toLowerCase() : normalized
  202. }
  203. async function candidate(root: string, base?: string) {
  204. for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
  205. const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
  206. const branch = `opencode/${name}`
  207. const directory = path.join(root, name)
  208. if (await exists(directory)) continue
  209. const ref = `refs/heads/${branch}`
  210. const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
  211. if (branchCheck.exitCode === 0) continue
  212. return Info.parse({ name, branch, directory })
  213. }
  214. throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
  215. }
  216. async function runStartCommand(directory: string, cmd: string) {
  217. if (process.platform === "win32") {
  218. return $`cmd /c ${cmd}`.nothrow().cwd(directory)
  219. }
  220. return $`bash -lc ${cmd}`.nothrow().cwd(directory)
  221. }
  222. type StartKind = "project" | "worktree"
  223. async function runStartScript(directory: string, cmd: string, kind: StartKind) {
  224. const text = cmd.trim()
  225. if (!text) return true
  226. const ran = await runStartCommand(directory, text)
  227. if (ran.exitCode === 0) return true
  228. log.error("worktree start command failed", {
  229. kind,
  230. directory,
  231. message: errorText(ran),
  232. })
  233. return false
  234. }
  235. async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) {
  236. const project = await Storage.read<Project.Info>(["project", input.projectID]).catch(() => undefined)
  237. const startup = project?.commands?.start?.trim() ?? ""
  238. const ok = await runStartScript(directory, startup, "project")
  239. if (!ok) return false
  240. const extra = input.extra ?? ""
  241. await runStartScript(directory, extra, "worktree")
  242. return true
  243. }
  244. function queueStartScripts(directory: string, input: { projectID: string; extra?: string }) {
  245. setTimeout(() => {
  246. const start = async () => {
  247. await runStartScripts(directory, input)
  248. }
  249. void start().catch((error) => {
  250. log.error("worktree start task failed", { directory, error })
  251. })
  252. }, 0)
  253. }
  254. export const create = fn(CreateInput.optional(), async (input) => {
  255. if (Instance.project.vcs !== "git") {
  256. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  257. }
  258. const root = path.join(Global.Path.data, "worktree", Instance.project.id)
  259. await fs.mkdir(root, { recursive: true })
  260. const base = input?.name ? slug(input.name) : ""
  261. const info = await candidate(root, base || undefined)
  262. const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
  263. .quiet()
  264. .nothrow()
  265. .cwd(Instance.worktree)
  266. if (created.exitCode !== 0) {
  267. throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
  268. }
  269. await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
  270. const projectID = Instance.project.id
  271. const extra = input?.startCommand?.trim()
  272. setTimeout(() => {
  273. const start = async () => {
  274. const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
  275. if (populated.exitCode !== 0) {
  276. const message = errorText(populated) || "Failed to populate worktree"
  277. log.error("worktree checkout failed", { directory: info.directory, message })
  278. GlobalBus.emit("event", {
  279. directory: info.directory,
  280. payload: {
  281. type: Event.Failed.type,
  282. properties: {
  283. message,
  284. },
  285. },
  286. })
  287. return
  288. }
  289. const booted = await Instance.provide({
  290. directory: info.directory,
  291. init: InstanceBootstrap,
  292. fn: () => undefined,
  293. })
  294. .then(() => true)
  295. .catch((error) => {
  296. const message = error instanceof Error ? error.message : String(error)
  297. log.error("worktree bootstrap failed", { directory: info.directory, message })
  298. GlobalBus.emit("event", {
  299. directory: info.directory,
  300. payload: {
  301. type: Event.Failed.type,
  302. properties: {
  303. message,
  304. },
  305. },
  306. })
  307. return false
  308. })
  309. if (!booted) return
  310. GlobalBus.emit("event", {
  311. directory: info.directory,
  312. payload: {
  313. type: Event.Ready.type,
  314. properties: {
  315. name: info.name,
  316. branch: info.branch,
  317. },
  318. },
  319. })
  320. await runStartScripts(info.directory, { projectID, extra })
  321. }
  322. void start().catch((error) => {
  323. log.error("worktree start task failed", { directory: info.directory, error })
  324. })
  325. }, 0)
  326. return info
  327. })
  328. export const remove = fn(RemoveInput, async (input) => {
  329. if (Instance.project.vcs !== "git") {
  330. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  331. }
  332. const directory = await canonical(input.directory)
  333. const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
  334. if (list.exitCode !== 0) {
  335. throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
  336. }
  337. const lines = outputText(list.stdout)
  338. .split("\n")
  339. .map((line) => line.trim())
  340. const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
  341. if (!line) return acc
  342. if (line.startsWith("worktree ")) {
  343. acc.push({ path: line.slice("worktree ".length).trim() })
  344. return acc
  345. }
  346. const current = acc[acc.length - 1]
  347. if (!current) return acc
  348. if (line.startsWith("branch ")) {
  349. current.branch = line.slice("branch ".length).trim()
  350. }
  351. return acc
  352. }, [])
  353. const entry = await (async () => {
  354. for (const item of entries) {
  355. if (!item.path) continue
  356. const key = await canonical(item.path)
  357. if (key === directory) return item
  358. }
  359. })()
  360. if (!entry?.path) {
  361. const directoryExists = await exists(directory)
  362. if (directoryExists) {
  363. await fs.rm(directory, { recursive: true, force: true })
  364. }
  365. return true
  366. }
  367. const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
  368. if (removed.exitCode !== 0) {
  369. throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
  370. }
  371. const branch = entry.branch?.replace(/^refs\/heads\//, "")
  372. if (branch) {
  373. const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
  374. if (deleted.exitCode !== 0) {
  375. throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
  376. }
  377. }
  378. return true
  379. })
  380. export const reset = fn(ResetInput, async (input) => {
  381. if (Instance.project.vcs !== "git") {
  382. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  383. }
  384. const directory = await canonical(input.directory)
  385. const primary = await canonical(Instance.worktree)
  386. if (directory === primary) {
  387. throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
  388. }
  389. const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
  390. if (list.exitCode !== 0) {
  391. throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
  392. }
  393. const lines = outputText(list.stdout)
  394. .split("\n")
  395. .map((line) => line.trim())
  396. const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
  397. if (!line) return acc
  398. if (line.startsWith("worktree ")) {
  399. acc.push({ path: line.slice("worktree ".length).trim() })
  400. return acc
  401. }
  402. const current = acc[acc.length - 1]
  403. if (!current) return acc
  404. if (line.startsWith("branch ")) {
  405. current.branch = line.slice("branch ".length).trim()
  406. }
  407. return acc
  408. }, [])
  409. const entry = await (async () => {
  410. for (const item of entries) {
  411. if (!item.path) continue
  412. const key = await canonical(item.path)
  413. if (key === directory) return item
  414. }
  415. })()
  416. if (!entry?.path) {
  417. throw new ResetFailedError({ message: "Worktree not found" })
  418. }
  419. const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
  420. if (remoteList.exitCode !== 0) {
  421. throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
  422. }
  423. const remotes = outputText(remoteList.stdout)
  424. .split("\n")
  425. .map((line) => line.trim())
  426. .filter(Boolean)
  427. const remote = remotes.includes("origin")
  428. ? "origin"
  429. : remotes.length === 1
  430. ? remotes[0]
  431. : remotes.includes("upstream")
  432. ? "upstream"
  433. : ""
  434. const remoteHead = remote
  435. ? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
  436. : { exitCode: 1, stdout: undefined, stderr: undefined }
  437. const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
  438. const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
  439. const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
  440. const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
  441. const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
  442. .quiet()
  443. .nothrow()
  444. .cwd(Instance.worktree)
  445. const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
  446. const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
  447. if (!target) {
  448. throw new ResetFailedError({ message: "Default branch not found" })
  449. }
  450. if (remoteBranch) {
  451. const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
  452. if (fetch.exitCode !== 0) {
  453. throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
  454. }
  455. }
  456. if (!entry.path) {
  457. throw new ResetFailedError({ message: "Worktree path not found" })
  458. }
  459. const worktreePath = entry.path
  460. const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
  461. if (resetToTarget.exitCode !== 0) {
  462. throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
  463. }
  464. const clean = await $`git clean -fdx`.quiet().nothrow().cwd(worktreePath)
  465. if (clean.exitCode !== 0) {
  466. throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
  467. }
  468. const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
  469. if (update.exitCode !== 0) {
  470. throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
  471. }
  472. const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
  473. if (subReset.exitCode !== 0) {
  474. throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
  475. }
  476. const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
  477. if (subClean.exitCode !== 0) {
  478. throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
  479. }
  480. const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
  481. if (status.exitCode !== 0) {
  482. throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
  483. }
  484. const dirty = outputText(status.stdout)
  485. if (dirty) {
  486. throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
  487. }
  488. const projectID = Instance.project.id
  489. queueStartScripts(worktreePath, { projectID })
  490. return true
  491. })
  492. }