index.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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 NotGitError = NamedError.create(
  32. "WorktreeNotGitError",
  33. z.object({
  34. message: z.string(),
  35. }),
  36. )
  37. export const NameGenerationFailedError = NamedError.create(
  38. "WorktreeNameGenerationFailedError",
  39. z.object({
  40. message: z.string(),
  41. }),
  42. )
  43. export const CreateFailedError = NamedError.create(
  44. "WorktreeCreateFailedError",
  45. z.object({
  46. message: z.string(),
  47. }),
  48. )
  49. export const StartCommandFailedError = NamedError.create(
  50. "WorktreeStartCommandFailedError",
  51. z.object({
  52. message: z.string(),
  53. }),
  54. )
  55. const ADJECTIVES = [
  56. "brave",
  57. "calm",
  58. "clever",
  59. "cosmic",
  60. "crisp",
  61. "curious",
  62. "eager",
  63. "gentle",
  64. "glowing",
  65. "happy",
  66. "hidden",
  67. "jolly",
  68. "kind",
  69. "lucky",
  70. "mighty",
  71. "misty",
  72. "neon",
  73. "nimble",
  74. "playful",
  75. "proud",
  76. "quick",
  77. "quiet",
  78. "shiny",
  79. "silent",
  80. "stellar",
  81. "sunny",
  82. "swift",
  83. "tidy",
  84. "witty",
  85. ] as const
  86. const NOUNS = [
  87. "cabin",
  88. "cactus",
  89. "canyon",
  90. "circuit",
  91. "comet",
  92. "eagle",
  93. "engine",
  94. "falcon",
  95. "forest",
  96. "garden",
  97. "harbor",
  98. "island",
  99. "knight",
  100. "lagoon",
  101. "meadow",
  102. "moon",
  103. "mountain",
  104. "nebula",
  105. "orchid",
  106. "otter",
  107. "panda",
  108. "pixel",
  109. "planet",
  110. "river",
  111. "rocket",
  112. "sailor",
  113. "squid",
  114. "star",
  115. "tiger",
  116. "wizard",
  117. "wolf",
  118. ] as const
  119. function pick<const T extends readonly string[]>(list: T) {
  120. return list[Math.floor(Math.random() * list.length)]
  121. }
  122. function slug(input: string) {
  123. return input
  124. .trim()
  125. .toLowerCase()
  126. .replace(/[^a-z0-9]+/g, "-")
  127. .replace(/^-+/, "")
  128. .replace(/-+$/, "")
  129. }
  130. function randomName() {
  131. return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
  132. }
  133. async function exists(target: string) {
  134. return fs
  135. .stat(target)
  136. .then(() => true)
  137. .catch(() => false)
  138. }
  139. function outputText(input: Uint8Array | undefined) {
  140. if (!input?.length) return ""
  141. return new TextDecoder().decode(input).trim()
  142. }
  143. function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
  144. return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
  145. }
  146. async function candidate(root: string, base?: string) {
  147. for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
  148. const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
  149. const branch = `opencode/${name}`
  150. const directory = path.join(root, name)
  151. if (await exists(directory)) continue
  152. const ref = `refs/heads/${branch}`
  153. const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
  154. if (branchCheck.exitCode === 0) continue
  155. return Info.parse({ name, branch, directory })
  156. }
  157. throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
  158. }
  159. async function runStartCommand(directory: string, cmd: string) {
  160. if (process.platform === "win32") {
  161. return $`cmd /c ${cmd}`.nothrow().cwd(directory)
  162. }
  163. return $`bash -lc ${cmd}`.nothrow().cwd(directory)
  164. }
  165. export const create = fn(CreateInput.optional(), async (input) => {
  166. if (Instance.project.vcs !== "git") {
  167. throw new NotGitError({ message: "Worktrees are only supported for git projects" })
  168. }
  169. const root = path.join(Global.Path.data, "worktree", Instance.project.id)
  170. await fs.mkdir(root, { recursive: true })
  171. const base = input?.name ? slug(input.name) : ""
  172. const info = await candidate(root, base || undefined)
  173. const created = await $`git worktree add -b ${info.branch} ${info.directory}`
  174. .quiet()
  175. .nothrow()
  176. .cwd(Instance.worktree)
  177. if (created.exitCode !== 0) {
  178. throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
  179. }
  180. const cmd = input?.startCommand?.trim()
  181. if (!cmd) return info
  182. const ran = await runStartCommand(info.directory, cmd)
  183. if (ran.exitCode !== 0) {
  184. throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" })
  185. }
  186. return info
  187. })
  188. }