index.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  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 candidate(root: string, base?: string) {
  198. for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
  199. const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
  200. const branch = `opencode/${name}`
  201. const directory = path.join(root, name)
  202. if (await exists(directory)) continue
  203. const ref = `refs/heads/${branch}`
  204. const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
  205. if (branchCheck.exitCode === 0) continue
  206. return Info.parse({ name, branch, directory })
  207. }
  208. throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
  209. }
  210. async function runStartCommand(directory: string, cmd: string) {
  211. if (process.platform === "win32") {
  212. return $`cmd /c ${cmd}`.nothrow().cwd(directory)
  213. }
  214. return $`bash -lc ${cmd}`.nothrow().cwd(directory)
  215. }
  216. export const create = fn(CreateInput.optional(), async (input) => {
  217. if (Instance.project.vcs !== "git") {
  218. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  219. }
  220. const root = path.join(Global.Path.data, "worktree", Instance.project.id)
  221. await fs.mkdir(root, { recursive: true })
  222. const base = input?.name ? slug(input.name) : ""
  223. const info = await candidate(root, base || undefined)
  224. const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
  225. .quiet()
  226. .nothrow()
  227. .cwd(Instance.worktree)
  228. if (created.exitCode !== 0) {
  229. throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
  230. }
  231. await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
  232. const projectID = Instance.project.id
  233. const extra = input?.startCommand?.trim()
  234. setTimeout(() => {
  235. const start = async () => {
  236. const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
  237. if (populated.exitCode !== 0) {
  238. const message = errorText(populated) || "Failed to populate worktree"
  239. log.error("worktree checkout failed", { directory: info.directory, message })
  240. GlobalBus.emit("event", {
  241. directory: info.directory,
  242. payload: {
  243. type: Event.Failed.type,
  244. properties: {
  245. message,
  246. },
  247. },
  248. })
  249. return
  250. }
  251. const booted = await Instance.provide({
  252. directory: info.directory,
  253. init: InstanceBootstrap,
  254. fn: () => undefined,
  255. })
  256. .then(() => true)
  257. .catch((error) => {
  258. const message = error instanceof Error ? error.message : String(error)
  259. log.error("worktree bootstrap failed", { directory: info.directory, message })
  260. GlobalBus.emit("event", {
  261. directory: info.directory,
  262. payload: {
  263. type: Event.Failed.type,
  264. properties: {
  265. message,
  266. },
  267. },
  268. })
  269. return false
  270. })
  271. if (!booted) return
  272. GlobalBus.emit("event", {
  273. directory: info.directory,
  274. payload: {
  275. type: Event.Ready.type,
  276. properties: {
  277. name: info.name,
  278. branch: info.branch,
  279. },
  280. },
  281. })
  282. const project = await Storage.read<Project.Info>(["project", projectID]).catch(() => undefined)
  283. const startup = project?.commands?.start?.trim() ?? ""
  284. const run = async (cmd: string, kind: "project" | "worktree") => {
  285. const ran = await runStartCommand(info.directory, cmd)
  286. if (ran.exitCode === 0) return true
  287. log.error("worktree start command failed", {
  288. kind,
  289. directory: info.directory,
  290. message: errorText(ran),
  291. })
  292. return false
  293. }
  294. if (startup) {
  295. const ok = await run(startup, "project")
  296. if (!ok) return
  297. }
  298. if (extra) {
  299. await run(extra, "worktree")
  300. }
  301. }
  302. void start().catch((error) => {
  303. log.error("worktree start task failed", { directory: info.directory, error })
  304. })
  305. }, 0)
  306. return info
  307. })
  308. export const remove = fn(RemoveInput, async (input) => {
  309. if (Instance.project.vcs !== "git") {
  310. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  311. }
  312. const directory = path.resolve(input.directory)
  313. const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
  314. if (list.exitCode !== 0) {
  315. throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
  316. }
  317. const lines = outputText(list.stdout)
  318. .split("\n")
  319. .map((line) => line.trim())
  320. const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
  321. if (!line) return acc
  322. if (line.startsWith("worktree ")) {
  323. acc.push({ path: line.slice("worktree ".length).trim() })
  324. return acc
  325. }
  326. const current = acc[acc.length - 1]
  327. if (!current) return acc
  328. if (line.startsWith("branch ")) {
  329. current.branch = line.slice("branch ".length).trim()
  330. }
  331. return acc
  332. }, [])
  333. const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
  334. if (!entry?.path) {
  335. throw new RemoveFailedError({ message: "Worktree not found" })
  336. }
  337. const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
  338. if (removed.exitCode !== 0) {
  339. throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
  340. }
  341. const branch = entry.branch?.replace(/^refs\/heads\//, "")
  342. if (branch) {
  343. const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
  344. if (deleted.exitCode !== 0) {
  345. throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
  346. }
  347. }
  348. return true
  349. })
  350. export const reset = fn(ResetInput, async (input) => {
  351. if (Instance.project.vcs !== "git") {
  352. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  353. }
  354. const directory = path.resolve(input.directory)
  355. if (directory === path.resolve(Instance.worktree)) {
  356. throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
  357. }
  358. const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
  359. if (list.exitCode !== 0) {
  360. throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
  361. }
  362. const lines = outputText(list.stdout)
  363. .split("\n")
  364. .map((line) => line.trim())
  365. const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
  366. if (!line) return acc
  367. if (line.startsWith("worktree ")) {
  368. acc.push({ path: line.slice("worktree ".length).trim() })
  369. return acc
  370. }
  371. const current = acc[acc.length - 1]
  372. if (!current) return acc
  373. if (line.startsWith("branch ")) {
  374. current.branch = line.slice("branch ".length).trim()
  375. }
  376. return acc
  377. }, [])
  378. const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
  379. if (!entry?.path) {
  380. throw new ResetFailedError({ message: "Worktree not found" })
  381. }
  382. const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
  383. if (remoteList.exitCode !== 0) {
  384. throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
  385. }
  386. const remotes = outputText(remoteList.stdout)
  387. .split("\n")
  388. .map((line) => line.trim())
  389. .filter(Boolean)
  390. const remote = remotes.includes("origin")
  391. ? "origin"
  392. : remotes.length === 1
  393. ? remotes[0]
  394. : remotes.includes("upstream")
  395. ? "upstream"
  396. : ""
  397. const remoteHead = remote
  398. ? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
  399. : { exitCode: 1, stdout: undefined, stderr: undefined }
  400. const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
  401. const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
  402. const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
  403. const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
  404. const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
  405. .quiet()
  406. .nothrow()
  407. .cwd(Instance.worktree)
  408. const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
  409. const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
  410. if (!target) {
  411. throw new ResetFailedError({ message: "Default branch not found" })
  412. }
  413. if (remoteBranch) {
  414. const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
  415. if (fetch.exitCode !== 0) {
  416. throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
  417. }
  418. }
  419. if (!entry.path) {
  420. throw new ResetFailedError({ message: "Worktree path not found" })
  421. }
  422. const worktreePath = entry.path
  423. const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
  424. if (resetToTarget.exitCode !== 0) {
  425. throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
  426. }
  427. const clean = await $`git clean -fdx`.quiet().nothrow().cwd(worktreePath)
  428. if (clean.exitCode !== 0) {
  429. throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
  430. }
  431. const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
  432. if (update.exitCode !== 0) {
  433. throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
  434. }
  435. const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
  436. if (subReset.exitCode !== 0) {
  437. throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
  438. }
  439. const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
  440. if (subClean.exitCode !== 0) {
  441. throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
  442. }
  443. const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
  444. if (status.exitCode !== 0) {
  445. throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
  446. }
  447. const dirty = outputText(status.stdout)
  448. if (dirty) {
  449. throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
  450. }
  451. const projectID = Instance.project.id
  452. setTimeout(() => {
  453. const start = async () => {
  454. const project = await Storage.read<Project.Info>(["project", projectID]).catch(() => undefined)
  455. const startup = project?.commands?.start?.trim() ?? ""
  456. if (!startup) return
  457. const ran = await runStartCommand(worktreePath, startup)
  458. if (ran.exitCode === 0) return
  459. log.error("worktree start command failed", {
  460. kind: "project",
  461. directory: worktreePath,
  462. message: errorText(ran),
  463. })
  464. }
  465. void start().catch((error) => {
  466. log.error("worktree start task failed", { directory: worktreePath, error })
  467. })
  468. }, 0)
  469. return true
  470. })
  471. }