index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  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 { Project } from "../project/project"
  9. import { fn } from "../util/fn"
  10. import { Config } from "@/config/config"
  11. export namespace Worktree {
  12. export const Info = z
  13. .object({
  14. name: z.string(),
  15. branch: z.string(),
  16. directory: z.string(),
  17. })
  18. .meta({
  19. ref: "Worktree",
  20. })
  21. export type Info = z.infer<typeof Info>
  22. export const CreateInput = z
  23. .object({
  24. name: z.string().optional(),
  25. startCommand: z.string().optional(),
  26. })
  27. .meta({
  28. ref: "WorktreeCreateInput",
  29. })
  30. export type CreateInput = z.infer<typeof CreateInput>
  31. export const RemoveInput = z
  32. .object({
  33. directory: z.string(),
  34. })
  35. .meta({
  36. ref: "WorktreeRemoveInput",
  37. })
  38. export type RemoveInput = z.infer<typeof RemoveInput>
  39. export const ResetInput = z
  40. .object({
  41. directory: z.string(),
  42. })
  43. .meta({
  44. ref: "WorktreeResetInput",
  45. })
  46. export type ResetInput = z.infer<typeof ResetInput>
  47. export const NotGitError = NamedError.create(
  48. "WorktreeNotGitError",
  49. z.object({
  50. message: z.string(),
  51. }),
  52. )
  53. export const NameGenerationFailedError = NamedError.create(
  54. "WorktreeNameGenerationFailedError",
  55. z.object({
  56. message: z.string(),
  57. }),
  58. )
  59. export const CreateFailedError = NamedError.create(
  60. "WorktreeCreateFailedError",
  61. z.object({
  62. message: z.string(),
  63. }),
  64. )
  65. export const StartCommandFailedError = NamedError.create(
  66. "WorktreeStartCommandFailedError",
  67. z.object({
  68. message: z.string(),
  69. }),
  70. )
  71. export const RemoveFailedError = NamedError.create(
  72. "WorktreeRemoveFailedError",
  73. z.object({
  74. message: z.string(),
  75. }),
  76. )
  77. export const ResetFailedError = NamedError.create(
  78. "WorktreeResetFailedError",
  79. z.object({
  80. message: z.string(),
  81. }),
  82. )
  83. const ADJECTIVES = [
  84. "brave",
  85. "calm",
  86. "clever",
  87. "cosmic",
  88. "crisp",
  89. "curious",
  90. "eager",
  91. "gentle",
  92. "glowing",
  93. "happy",
  94. "hidden",
  95. "jolly",
  96. "kind",
  97. "lucky",
  98. "mighty",
  99. "misty",
  100. "neon",
  101. "nimble",
  102. "playful",
  103. "proud",
  104. "quick",
  105. "quiet",
  106. "shiny",
  107. "silent",
  108. "stellar",
  109. "sunny",
  110. "swift",
  111. "tidy",
  112. "witty",
  113. ] as const
  114. const NOUNS = [
  115. "cabin",
  116. "cactus",
  117. "canyon",
  118. "circuit",
  119. "comet",
  120. "eagle",
  121. "engine",
  122. "falcon",
  123. "forest",
  124. "garden",
  125. "harbor",
  126. "island",
  127. "knight",
  128. "lagoon",
  129. "meadow",
  130. "moon",
  131. "mountain",
  132. "nebula",
  133. "orchid",
  134. "otter",
  135. "panda",
  136. "pixel",
  137. "planet",
  138. "river",
  139. "rocket",
  140. "sailor",
  141. "squid",
  142. "star",
  143. "tiger",
  144. "wizard",
  145. "wolf",
  146. ] as const
  147. function pick<const T extends readonly string[]>(list: T) {
  148. return list[Math.floor(Math.random() * list.length)]
  149. }
  150. function slug(input: string) {
  151. return input
  152. .trim()
  153. .toLowerCase()
  154. .replace(/[^a-z0-9]+/g, "-")
  155. .replace(/^-+/, "")
  156. .replace(/-+$/, "")
  157. }
  158. function randomName() {
  159. return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
  160. }
  161. async function exists(target: string) {
  162. return fs
  163. .stat(target)
  164. .then(() => true)
  165. .catch(() => false)
  166. }
  167. function outputText(input: Uint8Array | undefined) {
  168. if (!input?.length) return ""
  169. return new TextDecoder().decode(input).trim()
  170. }
  171. function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
  172. return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
  173. }
  174. async function candidate(root: string, base?: string) {
  175. for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
  176. const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
  177. const branch = `opencode/${name}`
  178. const directory = path.join(root, name)
  179. if (await exists(directory)) continue
  180. const ref = `refs/heads/${branch}`
  181. const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
  182. if (branchCheck.exitCode === 0) continue
  183. return Info.parse({ name, branch, directory })
  184. }
  185. throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
  186. }
  187. async function runStartCommand(directory: string, cmd: string) {
  188. if (process.platform === "win32") {
  189. return $`cmd /c ${cmd}`.nothrow().cwd(directory)
  190. }
  191. return $`bash -lc ${cmd}`.nothrow().cwd(directory)
  192. }
  193. export const create = fn(CreateInput.optional(), async (input) => {
  194. if (Instance.project.vcs !== "git") {
  195. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  196. }
  197. const root = path.join(Global.Path.data, "worktree", Instance.project.id)
  198. await fs.mkdir(root, { recursive: true })
  199. const base = input?.name ? slug(input.name) : ""
  200. const info = await candidate(root, base || undefined)
  201. const created = await $`git worktree add -b ${info.branch} ${info.directory}`
  202. .quiet()
  203. .nothrow()
  204. .cwd(Instance.worktree)
  205. if (created.exitCode !== 0) {
  206. throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
  207. }
  208. const cmd = input?.startCommand?.trim()
  209. if (!cmd) return info
  210. const ran = await runStartCommand(info.directory, cmd)
  211. if (ran.exitCode !== 0) {
  212. throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" })
  213. }
  214. return info
  215. })
  216. export const remove = fn(RemoveInput, async (input) => {
  217. if (Instance.project.vcs !== "git") {
  218. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  219. }
  220. const directory = path.resolve(input.directory)
  221. const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
  222. if (list.exitCode !== 0) {
  223. throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
  224. }
  225. const lines = outputText(list.stdout)
  226. .split("\n")
  227. .map((line) => line.trim())
  228. const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
  229. if (!line) return acc
  230. if (line.startsWith("worktree ")) {
  231. acc.push({ path: line.slice("worktree ".length).trim() })
  232. return acc
  233. }
  234. const current = acc[acc.length - 1]
  235. if (!current) return acc
  236. if (line.startsWith("branch ")) {
  237. current.branch = line.slice("branch ".length).trim()
  238. }
  239. return acc
  240. }, [])
  241. const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
  242. if (!entry?.path) {
  243. throw new RemoveFailedError({ message: "Worktree not found" })
  244. }
  245. const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
  246. if (removed.exitCode !== 0) {
  247. throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
  248. }
  249. const branch = entry.branch?.replace(/^refs\/heads\//, "")
  250. if (branch) {
  251. const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
  252. if (deleted.exitCode !== 0) {
  253. throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
  254. }
  255. }
  256. return true
  257. })
  258. export const reset = fn(ResetInput, async (input) => {
  259. if (Instance.project.vcs !== "git") {
  260. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  261. }
  262. const directory = path.resolve(input.directory)
  263. if (directory === path.resolve(Instance.worktree)) {
  264. throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
  265. }
  266. const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
  267. if (list.exitCode !== 0) {
  268. throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
  269. }
  270. const lines = outputText(list.stdout)
  271. .split("\n")
  272. .map((line) => line.trim())
  273. const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
  274. if (!line) return acc
  275. if (line.startsWith("worktree ")) {
  276. acc.push({ path: line.slice("worktree ".length).trim() })
  277. return acc
  278. }
  279. const current = acc[acc.length - 1]
  280. if (!current) return acc
  281. if (line.startsWith("branch ")) {
  282. current.branch = line.slice("branch ".length).trim()
  283. }
  284. return acc
  285. }, [])
  286. const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
  287. if (!entry?.path) {
  288. throw new ResetFailedError({ message: "Worktree not found" })
  289. }
  290. const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
  291. if (remoteList.exitCode !== 0) {
  292. throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
  293. }
  294. const remotes = outputText(remoteList.stdout)
  295. .split("\n")
  296. .map((line) => line.trim())
  297. .filter(Boolean)
  298. const remote = remotes.includes("origin")
  299. ? "origin"
  300. : remotes.length === 1
  301. ? remotes[0]
  302. : remotes.includes("upstream")
  303. ? "upstream"
  304. : ""
  305. const remoteHead = remote
  306. ? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
  307. : { exitCode: 1, stdout: undefined, stderr: undefined }
  308. const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
  309. const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
  310. const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
  311. const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
  312. const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
  313. .quiet()
  314. .nothrow()
  315. .cwd(Instance.worktree)
  316. const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
  317. const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
  318. if (!target) {
  319. throw new ResetFailedError({ message: "Default branch not found" })
  320. }
  321. if (remoteBranch) {
  322. const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
  323. if (fetch.exitCode !== 0) {
  324. throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
  325. }
  326. }
  327. const checkout = await $`git checkout ${target}`.quiet().nothrow().cwd(entry.path)
  328. if (checkout.exitCode !== 0) {
  329. throw new ResetFailedError({ message: errorText(checkout) || `Failed to checkout ${target}` })
  330. }
  331. const clean = await $`git clean -fd`.quiet().nothrow().cwd(entry.path)
  332. if (clean.exitCode !== 0) {
  333. throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
  334. }
  335. const worktreeBranch = entry.branch?.replace(/^refs\/heads\//, "")
  336. if (!worktreeBranch) {
  337. throw new ResetFailedError({ message: "Worktree branch not found" })
  338. }
  339. const reset = await $`git reset --hard ${target}`.quiet().nothrow().cwd(entry.path)
  340. if (reset.exitCode !== 0) {
  341. throw new ResetFailedError({ message: errorText(reset) || "Failed to reset worktree" })
  342. }
  343. const cleanAfter = await $`git clean -fd`.quiet().nothrow().cwd(entry.path)
  344. if (cleanAfter.exitCode !== 0) {
  345. throw new ResetFailedError({ message: errorText(cleanAfter) || "Failed to clean worktree" })
  346. }
  347. const branchReset = await $`git branch -f ${worktreeBranch} ${target}`.quiet().nothrow().cwd(entry.path)
  348. if (branchReset.exitCode !== 0) {
  349. throw new ResetFailedError({ message: errorText(branchReset) || "Failed to update worktree branch" })
  350. }
  351. const checkoutBranch = await $`git checkout ${worktreeBranch}`.quiet().nothrow().cwd(entry.path)
  352. if (checkoutBranch.exitCode !== 0) {
  353. throw new ResetFailedError({ message: errorText(checkoutBranch) || "Failed to checkout worktree branch" })
  354. }
  355. return true
  356. })
  357. }