git.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987
  1. export * as Git from "./git"
  2. import path from "path"
  3. import { randomUUID } from "crypto"
  4. import { Context, Effect, Layer, Schema, Stream } from "effect"
  5. import { ChildProcess } from "effect/unstable/process"
  6. import { AbsolutePath, RelativePath } from "./schema"
  7. import { FSUtil } from "./fs-util"
  8. import { AppProcess } from "./process"
  9. import { makeGlobalNode } from "./effect/app-node"
  10. import { File } from "./file"
  11. import { KeyedMutex } from "./effect/keyed-mutex"
  12. export class Repository extends Schema.Class<Repository>("Git.Repository")({
  13. worktree: AbsolutePath,
  14. gitDirectory: AbsolutePath,
  15. commonDirectory: AbsolutePath,
  16. }) {}
  17. export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
  18. export type ChangeSet = typeof ChangeSet.Type
  19. export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
  20. export type TreeID = typeof TreeID.Type
  21. export class OperationError extends Schema.TaggedErrorClass<OperationError>()("Git.OperationError", {
  22. operation: Schema.Literals([
  23. "clone",
  24. "fetch",
  25. "checkout",
  26. "reset",
  27. "create",
  28. "refresh",
  29. "write_tree",
  30. "list_files",
  31. "diff",
  32. "restore",
  33. ]),
  34. message: Schema.String,
  35. directory: Schema.optional(AbsolutePath),
  36. cause: Schema.optional(Schema.Defect()),
  37. }) {}
  38. export class Worktree extends Schema.Class<Worktree>("Git.Worktree")({
  39. directory: AbsolutePath,
  40. kind: Schema.Literals(["main", "linked"]),
  41. }) {}
  42. export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
  43. operation: Schema.Literals(["create", "remove", "list"]),
  44. message: Schema.String,
  45. directory: Schema.optional(AbsolutePath),
  46. forceRequired: Schema.optional(Schema.Boolean),
  47. cause: Schema.optional(Schema.Defect()),
  48. }) {}
  49. export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
  50. operation: Schema.Literals(["capture", "apply", "reset"]),
  51. directory: AbsolutePath,
  52. message: Schema.String,
  53. cause: Schema.optional(Schema.Defect()),
  54. }) {}
  55. export interface Interface {
  56. readonly repo: {
  57. readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
  58. readonly clone: (input: {
  59. remote: string
  60. directory: AbsolutePath
  61. branch?: string
  62. depth?: number
  63. }) => Effect.Effect<Repository, OperationError>
  64. readonly create: (input: {
  65. worktree: AbsolutePath
  66. gitDirectory: AbsolutePath
  67. seed?: Repository
  68. }) => Effect.Effect<Repository, OperationError>
  69. }
  70. readonly remote: {
  71. readonly get: (repository: Repository, name?: string) => Effect.Effect<string | undefined>
  72. }
  73. readonly history: {
  74. readonly head: (repository: Repository) => Effect.Effect<string | undefined>
  75. readonly branch: (repository: Repository) => Effect.Effect<string | undefined>
  76. readonly defaultRemoteBranch: (repository: Repository, remote?: string) => Effect.Effect<string | undefined>
  77. readonly rootCommits: (repository: Repository) => Effect.Effect<readonly string[]>
  78. }
  79. readonly sync: {
  80. readonly fetchRemotes: (repository: Repository, input?: { prune?: boolean }) => Effect.Effect<void, OperationError>
  81. readonly fetchBranch: (
  82. repository: Repository,
  83. input: { remote?: string; branch: string; force?: boolean },
  84. ) => Effect.Effect<void, OperationError>
  85. readonly checkoutRemoteBranch: (
  86. repository: Repository,
  87. input: { remote?: string; branch: string; reset?: boolean },
  88. ) => Effect.Effect<void, OperationError>
  89. readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
  90. }
  91. readonly change: {
  92. readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
  93. readonly apply: (input: {
  94. repository: Repository
  95. path: AbsolutePath
  96. changes: ChangeSet
  97. }) => Effect.Effect<void, PatchError>
  98. readonly discard: (input: {
  99. repository: Repository
  100. path: AbsolutePath
  101. index: "preserve" | "reset"
  102. untracked: "preserve" | "remove"
  103. }) => Effect.Effect<void, PatchError>
  104. }
  105. readonly worktree: {
  106. readonly create: (input: {
  107. repository: Repository
  108. directory: AbsolutePath
  109. }) => Effect.Effect<Repository, WorktreeError>
  110. readonly remove: (input: {
  111. repository: Repository
  112. directory: AbsolutePath
  113. force: boolean
  114. }) => Effect.Effect<void, WorktreeError>
  115. readonly list: (repository: Repository) => Effect.Effect<readonly Worktree[], WorktreeError>
  116. }
  117. readonly index: {
  118. /** Refresh only the requested project-relative scope, preserving all other entries. */
  119. readonly refresh: (input: {
  120. repository: Repository
  121. scope: RelativePath
  122. ignores?: Repository
  123. maximumUntrackedFileBytes?: number
  124. }) => Effect.Effect<{ readonly skipped: readonly RelativePath[] }, OperationError>
  125. readonly ignored: (input: {
  126. repository: Repository
  127. paths: readonly RelativePath[]
  128. }) => Effect.Effect<ReadonlySet<RelativePath>, OperationError>
  129. }
  130. readonly tree: {
  131. readonly capture: (input: {
  132. repository: Repository
  133. scopes: readonly RelativePath[]
  134. ignores?: Repository
  135. maximumUntrackedFileBytes?: number
  136. }) => Effect.Effect<TreeID, OperationError>
  137. readonly write: (repository: Repository) => Effect.Effect<TreeID, OperationError>
  138. readonly files: (input: {
  139. repository: Repository
  140. from: TreeID
  141. to: TreeID
  142. }) => Effect.Effect<readonly RelativePath[], OperationError>
  143. readonly diff: (input: {
  144. repository: Repository
  145. from: TreeID
  146. to: TreeID
  147. context?: number
  148. paths?: readonly RelativePath[]
  149. }) => Effect.Effect<readonly File.Diff[], OperationError>
  150. readonly preview: (input: {
  151. repository: Repository
  152. current: TreeID
  153. files: ReadonlyMap<RelativePath, TreeID>
  154. context?: number
  155. }) => Effect.Effect<readonly File.Diff[], OperationError>
  156. readonly restore: (input: {
  157. repository: Repository
  158. files: ReadonlyMap<RelativePath, TreeID>
  159. }) => Effect.Effect<void, OperationError>
  160. readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect<void, OperationError>
  161. }
  162. }
  163. export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
  164. const layer = Layer.effect(
  165. Service,
  166. Effect.gen(function* () {
  167. const fs = yield* FSUtil.Service
  168. const proc = yield* AppProcess.Service
  169. const locks = KeyedMutex.makeUnsafe<string>()
  170. const locked = <A, E, R>(repository: Repository, effect: Effect.Effect<A, E, R>) =>
  171. locks.withLock(repository.gitDirectory)(effect)
  172. const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
  173. const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
  174. Effect.map((matches) => matches[0]),
  175. Effect.catch(() => Effect.succeed(undefined)),
  176. )
  177. if (!dotgit) return undefined
  178. const cwd = path.dirname(dotgit)
  179. const git = run(cwd, proc)
  180. const topLevel = yield* git(["rev-parse", "--show-toplevel"])
  181. const gitDir = yield* git(["rev-parse", "--git-dir"])
  182. const commonDir = yield* git(["rev-parse", "--git-common-dir"])
  183. if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined
  184. return new Repository({
  185. worktree: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
  186. gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir.text)),
  187. commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
  188. })
  189. })
  190. const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
  191. const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
  192. if (result.exitCode !== 0) return undefined
  193. return result.text.trim() || undefined
  194. })
  195. const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
  196. const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
  197. if (result.exitCode !== 0) return []
  198. return result.text
  199. .split("\n")
  200. .map((item) => item.trim())
  201. .filter(Boolean)
  202. .toSorted()
  203. })
  204. const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
  205. const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
  206. if (result.exitCode !== 0) return undefined
  207. return result.text.trim() || undefined
  208. })
  209. const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
  210. const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
  211. if (result.exitCode !== 0) return undefined
  212. return result.text.trim() || undefined
  213. })
  214. const remoteHead = Effect.fn("Git.history.defaultRemoteBranch")(function* (
  215. repository: Repository,
  216. remoteName = "origin",
  217. ) {
  218. const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
  219. if (result.exitCode !== 0) return undefined
  220. return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
  221. })
  222. const operation = Effect.fnUntraced(function* (
  223. operation: OperationError["operation"],
  224. directory: AbsolutePath,
  225. args: string[],
  226. ) {
  227. const result = yield* execute(
  228. directory,
  229. proc,
  230. )(args).pipe(
  231. Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
  232. )
  233. if (result.exitCode === 0) return
  234. return yield* new OperationError({
  235. operation,
  236. directory,
  237. message: result.stderr.trim() || result.text.trim() || `Git ${operation} failed`,
  238. })
  239. })
  240. const clone = Effect.fn("Git.repo.clone")(function* (input: {
  241. remote: string
  242. directory: AbsolutePath
  243. branch?: string
  244. depth?: number
  245. }) {
  246. yield* operation("clone", AbsolutePath.make(path.dirname(input.directory)), [
  247. "clone",
  248. "--depth",
  249. String(input.depth ?? 100),
  250. ...(input.branch ? ["--branch", input.branch] : []),
  251. "--",
  252. input.remote,
  253. input.directory,
  254. ])
  255. const repository = yield* discover(input.directory)
  256. if (repository) return repository
  257. return yield* new OperationError({
  258. operation: "clone",
  259. directory: input.directory,
  260. message: "Cloned repository could not be opened",
  261. })
  262. })
  263. const fetch = Effect.fn("Git.sync.fetchRemotes")(function* (
  264. repository: Repository,
  265. input: { prune?: boolean } = {},
  266. ) {
  267. yield* operation("fetch", repository.worktree, ["fetch", "--all", ...(input.prune === false ? [] : ["--prune"])])
  268. })
  269. const fetchBranch = Effect.fn("Git.sync.fetchBranch")(function* (
  270. repository: Repository,
  271. input: { remote?: string; branch: string; force?: boolean },
  272. ) {
  273. const remoteName = input.remote ?? "origin"
  274. const spec = `refs/heads/${input.branch}:refs/remotes/${remoteName}/${input.branch}`
  275. yield* operation("fetch", repository.worktree, ["fetch", remoteName, input.force === false ? spec : `+${spec}`])
  276. })
  277. const checkout = Effect.fn("Git.sync.checkoutRemoteBranch")(function* (
  278. repository: Repository,
  279. input: { remote?: string; branch: string; reset?: boolean },
  280. ) {
  281. const remoteName = input.remote ?? "origin"
  282. yield* operation("checkout", repository.worktree, [
  283. "checkout",
  284. ...(input.reset === false ? [input.branch] : ["-B", input.branch, `${remoteName}/${input.branch}`]),
  285. ])
  286. })
  287. const reset = Effect.fn("Git.sync.resetHard")(function* (repository: Repository, revision: string) {
  288. yield* operation("reset", repository.worktree, ["reset", "--hard", revision])
  289. })
  290. const repositoryArgs = (repository: Repository, args: string[]) => [
  291. "--git-dir",
  292. repository.gitDirectory,
  293. "--work-tree",
  294. repository.worktree,
  295. ...args,
  296. ]
  297. const repositoryOperation = Effect.fnUntraced(function* (
  298. operationName: OperationError["operation"],
  299. repository: Repository,
  300. args: string[],
  301. options?: { stdin?: string; env?: Record<string, string> },
  302. ) {
  303. const result = yield* proc
  304. .run(
  305. ChildProcess.make("git", repositoryArgs(repository, args), {
  306. cwd: repository.worktree,
  307. env: options?.env,
  308. extendEnv: true,
  309. }),
  310. { stdin: options?.stdin },
  311. )
  312. .pipe(
  313. Effect.mapError(
  314. (cause) =>
  315. new OperationError({
  316. operation: operationName,
  317. directory: repository.worktree,
  318. message: cause.message,
  319. cause,
  320. }),
  321. ),
  322. )
  323. const text = result.stdout.toString("utf8")
  324. if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
  325. return yield* new OperationError({
  326. operation: operationName,
  327. directory: repository.worktree,
  328. message: result.stderr.toString("utf8").trim() || text.trim() || `Git ${operationName} failed`,
  329. })
  330. })
  331. const create = Effect.fn("Git.repo.create")(function* (input: {
  332. worktree: AbsolutePath
  333. gitDirectory: AbsolutePath
  334. seed?: Repository
  335. }) {
  336. yield* fs.ensureDir(input.gitDirectory).pipe(
  337. Effect.mapError(
  338. (cause) =>
  339. new OperationError({
  340. operation: "create",
  341. directory: input.gitDirectory,
  342. message: "Failed to create Git storage",
  343. cause,
  344. }),
  345. ),
  346. )
  347. const repository = new Repository({
  348. worktree: input.worktree,
  349. gitDirectory: input.gitDirectory,
  350. commonDirectory: input.gitDirectory,
  351. })
  352. yield* repositoryOperation("create", repository, ["init"])
  353. yield* Effect.forEach(
  354. [
  355. ["core.autocrlf", "false"],
  356. ["core.longpaths", "true"],
  357. ["core.symlinks", "true"],
  358. ["core.fsmonitor", "false"],
  359. ["feature.manyFiles", "true"],
  360. ["index.version", "4"],
  361. ["index.threads", "true"],
  362. ["core.untrackedCache", "true"],
  363. ],
  364. ([key, value]) => repositoryOperation("create", repository, ["config", key, value]),
  365. { discard: true },
  366. )
  367. if (!input.seed) return repository
  368. yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe(
  369. Effect.mapError(
  370. (cause) =>
  371. new OperationError({
  372. operation: "create",
  373. directory: input.gitDirectory,
  374. message: "Failed to configure shared Git objects",
  375. cause,
  376. }),
  377. ),
  378. )
  379. yield* fs
  380. .writeFileString(
  381. path.join(input.gitDirectory, "objects", "info", "alternates"),
  382. path.join(input.seed.commonDirectory, "objects") + "\n",
  383. )
  384. .pipe(
  385. Effect.mapError(
  386. (cause) =>
  387. new OperationError({
  388. operation: "create",
  389. directory: input.gitDirectory,
  390. message: "Failed to configure shared Git objects",
  391. cause,
  392. }),
  393. ),
  394. )
  395. yield* fs
  396. .copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
  397. .pipe(Effect.catch(() => Effect.void))
  398. return repository
  399. })
  400. const refresh = Effect.fn("Git.index.refresh")(function* (input: {
  401. repository: Repository
  402. scope: RelativePath
  403. ignores?: Repository
  404. maximumUntrackedFileBytes?: number
  405. }) {
  406. const list = (args: string[]) =>
  407. repositoryOperation("refresh", input.repository, args).pipe(
  408. Effect.map((result) => result.text.split("\0").filter(Boolean)),
  409. )
  410. const [tracked, untracked] = yield* Effect.all(
  411. [
  412. list(["diff-files", "--name-only", "-z", "--", input.scope]),
  413. list(["ls-files", "--others", "--exclude-standard", "-z", "--", input.scope]),
  414. ],
  415. { concurrency: 2 },
  416. )
  417. const candidates = Array.from(new Set([...tracked, ...untracked]))
  418. if (!candidates.length) return { skipped: [] }
  419. const ignored = input.ignores
  420. ? new Set(
  421. (yield* repositoryOperation("refresh", input.ignores, ["check-ignore", "--no-index", "--stdin", "-z"], {
  422. stdin: candidates.join("\0") + "\0",
  423. }).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" })))).text
  424. .split("\0")
  425. .filter(Boolean),
  426. )
  427. : new Set<string>()
  428. const allowed = candidates.filter((item) => !ignored.has(item))
  429. const maximum = input.maximumUntrackedFileBytes
  430. const skipped = maximum
  431. ? (yield* Effect.forEach(
  432. untracked.filter((item) => allowed.includes(item)),
  433. (item) =>
  434. fs.stat(path.join(input.repository.worktree, item)).pipe(
  435. Effect.map((info) =>
  436. info.type === "File" && Number(info.size) > maximum ? RelativePath.make(item) : undefined,
  437. ),
  438. Effect.catch(() => Effect.succeed(undefined)),
  439. ),
  440. { concurrency: 8 },
  441. )).filter((item): item is RelativePath => item !== undefined)
  442. : []
  443. const stage = allowed.filter((item) => !skipped.includes(RelativePath.make(item)))
  444. const remove = [...ignored, ...skipped]
  445. if (remove.length)
  446. yield* repositoryOperation(
  447. "refresh",
  448. input.repository,
  449. ["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"],
  450. { stdin: remove.join("\0") + "\0" },
  451. )
  452. if (stage.length)
  453. yield* repositoryOperation(
  454. "refresh",
  455. input.repository,
  456. ["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"],
  457. { stdin: stage.join("\0") + "\0" },
  458. )
  459. return { skipped }
  460. })
  461. const ignored = Effect.fn("Git.index.ignored")(function* (input: {
  462. repository: Repository
  463. paths: readonly RelativePath[]
  464. }) {
  465. if (!input.paths.length) return new Set<RelativePath>()
  466. const result = yield* proc
  467. .run(
  468. ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
  469. cwd: input.repository.worktree,
  470. extendEnv: true,
  471. }),
  472. { stdin: input.paths.join("\0") + "\0" },
  473. )
  474. .pipe(
  475. Effect.mapError(
  476. (cause) =>
  477. new OperationError({
  478. operation: "list_files",
  479. directory: input.repository.worktree,
  480. message: cause.message,
  481. cause,
  482. }),
  483. ),
  484. )
  485. if (result.exitCode !== 0 && result.exitCode !== 1)
  486. return yield* new OperationError({
  487. operation: "list_files",
  488. directory: input.repository.worktree,
  489. message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
  490. })
  491. return new Set(
  492. result.stdout
  493. .toString("utf8")
  494. .split("\0")
  495. .filter(Boolean)
  496. .map((file) => RelativePath.make(file)),
  497. )
  498. })
  499. const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
  500. return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
  501. })
  502. const captureTree = Effect.fn("Git.tree.capture")(
  503. (input: {
  504. repository: Repository
  505. scopes: readonly RelativePath[]
  506. ignores?: Repository
  507. maximumUntrackedFileBytes?: number
  508. }) =>
  509. locked(
  510. input.repository,
  511. Effect.gen(function* () {
  512. yield* Effect.forEach(input.scopes, (scope) => refresh({ ...input, scope }), { discard: true })
  513. return yield* writeTree(input.repository)
  514. }),
  515. ),
  516. )
  517. const treeFiles = Effect.fn("Git.tree.files")(function* (input: {
  518. repository: Repository
  519. from: TreeID
  520. to: TreeID
  521. }) {
  522. return (yield* repositoryOperation("list_files", input.repository, [
  523. "diff",
  524. "--name-only",
  525. "-z",
  526. input.from,
  527. input.to,
  528. ])).text
  529. .split("\0")
  530. .filter(Boolean)
  531. .map((file) => RelativePath.make(file))
  532. })
  533. const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
  534. repository: Repository
  535. from: TreeID
  536. to: TreeID
  537. context?: number
  538. paths?: readonly RelativePath[]
  539. }) {
  540. const paths = input.paths ?? (yield* treeFiles(input))
  541. return yield* Effect.forEach(paths, (file) =>
  542. Effect.gen(function* () {
  543. const statusText = (yield* repositoryOperation("diff", input.repository, [
  544. "diff",
  545. "--name-status",
  546. "--no-renames",
  547. input.from,
  548. input.to,
  549. "--",
  550. file,
  551. ])).text.trim()
  552. const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
  553. const stats = (yield* repositoryOperation("diff", input.repository, [
  554. "diff",
  555. "--numstat",
  556. "--no-renames",
  557. input.from,
  558. input.to,
  559. "--",
  560. file,
  561. ])).text.split("\t")
  562. const binary = stats[0] === "-" || stats[1] === "-"
  563. const patch = binary
  564. ? ""
  565. : (yield* repositoryOperation("diff", input.repository, [
  566. "diff",
  567. `--unified=${input.context ?? 3}`,
  568. "--no-renames",
  569. input.from,
  570. input.to,
  571. "--",
  572. file,
  573. ])).text
  574. return {
  575. path: file,
  576. status,
  577. additions: binary ? 0 : Number(stats[0] ?? 0),
  578. deletions: binary ? 0 : Number(stats[1] ?? 0),
  579. patch,
  580. } satisfies File.Diff
  581. }),
  582. )
  583. })
  584. const entry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
  585. const text = (yield* repositoryOperation("restore", repository, [
  586. "ls-tree",
  587. "-z",
  588. tree,
  589. "--",
  590. file,
  591. ])).text.replace(/\0$/, "")
  592. if (!text) return
  593. const match = text.match(/^(\d+)\s+\w+\s+([0-9a-f]+)\t/)
  594. if (!match)
  595. return yield* new OperationError({
  596. operation: "restore",
  597. directory: repository.worktree,
  598. message: `Invalid tree entry for ${file}`,
  599. })
  600. return { mode: match[1], object: match[2] }
  601. })
  602. const preview = Effect.fn("Git.tree.preview")(
  603. (input: {
  604. repository: Repository
  605. current: TreeID
  606. files: ReadonlyMap<RelativePath, TreeID>
  607. context?: number
  608. }) =>
  609. locked(
  610. input.repository,
  611. Effect.gen(function* () {
  612. const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`)
  613. const env = { GIT_INDEX_FILE: index }
  614. return yield* Effect.gen(function* () {
  615. yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env })
  616. yield* Effect.forEach(
  617. input.files,
  618. ([file, tree]) =>
  619. Effect.gen(function* () {
  620. const source = yield* entry(input.repository, tree, file)
  621. if (!source) {
  622. yield* repositoryOperation(
  623. "diff",
  624. input.repository,
  625. ["update-index", "--force-remove", "--", file],
  626. { env },
  627. )
  628. return
  629. }
  630. yield* repositoryOperation(
  631. "diff",
  632. input.repository,
  633. ["update-index", "--add", "--cacheinfo", source.mode, source.object, file],
  634. { env },
  635. )
  636. }),
  637. { discard: true },
  638. )
  639. const target = TreeID.make(
  640. (yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(),
  641. )
  642. return yield* treeDiff({
  643. repository: input.repository,
  644. from: input.current,
  645. to: target,
  646. context: input.context,
  647. paths: Array.from(input.files.keys()),
  648. })
  649. }).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void))))
  650. }),
  651. ),
  652. )
  653. const restore = Effect.fn("Git.tree.restore")(
  654. (input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
  655. locked(
  656. input.repository,
  657. Effect.forEach(
  658. input.files,
  659. ([file, tree]) =>
  660. Effect.gen(function* () {
  661. if (yield* entry(input.repository, tree, file)) {
  662. yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
  663. return
  664. }
  665. yield* fs.remove(path.join(input.repository.worktree, file), { recursive: true, force: true }).pipe(
  666. Effect.mapError(
  667. (cause) =>
  668. new OperationError({
  669. operation: "restore",
  670. directory: input.repository.worktree,
  671. message: `Failed to remove ${file}`,
  672. cause,
  673. }),
  674. ),
  675. )
  676. }),
  677. { discard: true },
  678. ),
  679. ),
  680. )
  681. const checkoutTree = Effect.fn("Git.tree.checkout")((input: { repository: Repository; tree: TreeID }) =>
  682. locked(
  683. input.repository,
  684. Effect.gen(function* () {
  685. yield* repositoryOperation("restore", input.repository, ["read-tree", input.tree])
  686. yield* repositoryOperation("restore", input.repository, ["checkout-index", "--all", "--force"])
  687. }),
  688. ),
  689. )
  690. const capture = Effect.fn("Git.change.capture")(function* (input: { repository: Repository; path: AbsolutePath }) {
  691. const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
  692. const tracked = yield* execute(
  693. input.repository.worktree,
  694. proc,
  695. )(["diff", "--binary", "HEAD", "--", scope]).pipe(
  696. Effect.mapError(
  697. (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
  698. ),
  699. )
  700. if (tracked.exitCode !== 0) {
  701. return yield* new PatchError({
  702. operation: "capture",
  703. directory: input.path,
  704. message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
  705. })
  706. }
  707. const untracked = yield* execute(
  708. input.repository.worktree,
  709. proc,
  710. )(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
  711. Effect.mapError(
  712. (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
  713. ),
  714. )
  715. if (untracked.exitCode !== 0) {
  716. return yield* new PatchError({
  717. operation: "capture",
  718. directory: input.path,
  719. message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
  720. })
  721. }
  722. const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
  723. execute(
  724. input.repository.worktree,
  725. proc,
  726. )(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
  727. Effect.mapError(
  728. (cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
  729. ),
  730. Effect.flatMap((result) =>
  731. // git diff --no-index returns 1 when differences were found.
  732. result.exitCode === 0 || result.exitCode === 1
  733. ? Effect.succeed(result.text)
  734. : Effect.fail(
  735. new PatchError({
  736. operation: "capture",
  737. directory: input.path,
  738. message:
  739. result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
  740. }),
  741. ),
  742. ),
  743. ),
  744. )
  745. return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
  746. })
  747. const apply = Effect.fn("Git.change.apply")(function* (input: {
  748. repository: Repository
  749. path: AbsolutePath
  750. changes: ChangeSet
  751. }) {
  752. const result = yield* proc
  753. .run(
  754. ChildProcess.make("git", ["apply", "-"], {
  755. cwd: input.path,
  756. extendEnv: true,
  757. stdin: Stream.make(new TextEncoder().encode(input.changes)),
  758. }),
  759. )
  760. .pipe(
  761. Effect.mapError(
  762. (cause) => new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
  763. ),
  764. )
  765. if (result.exitCode === 0) return
  766. return yield* new PatchError({
  767. operation: "apply",
  768. directory: input.path,
  769. message:
  770. result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
  771. })
  772. })
  773. const discard = Effect.fn("Git.change.discard")(function* (input: {
  774. repository: Repository
  775. path: AbsolutePath
  776. index: "preserve" | "reset"
  777. untracked: "preserve" | "remove"
  778. }) {
  779. const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
  780. const restore = yield* execute(
  781. input.repository.worktree,
  782. proc,
  783. )(input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope]).pipe(
  784. Effect.mapError(
  785. (cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
  786. ),
  787. )
  788. if (restore.exitCode !== 0) {
  789. return yield* new PatchError({
  790. operation: "reset",
  791. directory: input.path,
  792. message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
  793. })
  794. }
  795. if (input.untracked === "preserve") return
  796. const clean = yield* execute(
  797. input.repository.worktree,
  798. proc,
  799. )(["clean", "-fd", "--", scope]).pipe(
  800. Effect.mapError(
  801. (cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
  802. ),
  803. )
  804. if (clean.exitCode === 0) return
  805. return yield* new PatchError({
  806. operation: "reset",
  807. directory: input.path,
  808. message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
  809. })
  810. })
  811. const worktreeRun = Effect.fnUntraced(function* (
  812. operation: "create" | "remove" | "list",
  813. repository: Repository,
  814. args: string[],
  815. worktreeDirectory?: AbsolutePath,
  816. cwd = repository.worktree,
  817. ) {
  818. const result = yield* proc
  819. .run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
  820. .pipe(
  821. Effect.mapError(
  822. (cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
  823. ),
  824. )
  825. if (result.exitCode === 0) return result.stdout.toString("utf8")
  826. const message = result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed"
  827. return yield* new WorktreeError({
  828. operation,
  829. directory: worktreeDirectory,
  830. message,
  831. forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message),
  832. })
  833. })
  834. const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: {
  835. repository: Repository
  836. directory: AbsolutePath
  837. }) {
  838. yield* worktreeRun(
  839. "create",
  840. input.repository,
  841. ["worktree", "add", "--detach", input.directory, "HEAD"],
  842. input.directory,
  843. )
  844. const repository = yield* discover(input.directory)
  845. if (repository) return repository
  846. return yield* new WorktreeError({
  847. operation: "create",
  848. directory: input.directory,
  849. message: "Created worktree could not be opened",
  850. })
  851. })
  852. const worktreeRemove = Effect.fn("Git.worktree.remove")(function* (input: {
  853. repository: Repository
  854. directory: AbsolutePath
  855. force: boolean
  856. }) {
  857. yield* worktreeRun(
  858. "remove",
  859. input.repository,
  860. ["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
  861. input.directory,
  862. input.repository.commonDirectory,
  863. )
  864. })
  865. const worktreeList = Effect.fn("Git.worktree.list")(function* (repository: Repository) {
  866. return (yield* worktreeRun("list", repository, ["worktree", "list", "--porcelain"]))
  867. .split("\n")
  868. .filter((line) => line.startsWith("worktree "))
  869. .map(
  870. (line, index) =>
  871. new Worktree({
  872. directory: AbsolutePath.make(resolvePath(repository.worktree, line.slice("worktree ".length).trim())),
  873. kind: index === 0 ? "main" : "linked",
  874. }),
  875. )
  876. })
  877. return Service.of({
  878. repo: { discover, clone, create },
  879. remote: { get: remote },
  880. history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
  881. sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
  882. change: { capture, apply, discard },
  883. worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
  884. index: { refresh, ignored },
  885. tree: {
  886. capture: captureTree,
  887. write: writeTree,
  888. files: treeFiles,
  889. diff: treeDiff,
  890. preview,
  891. restore,
  892. checkout: checkoutTree,
  893. },
  894. })
  895. }),
  896. )
  897. export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
  898. interface Result {
  899. readonly exitCode: number
  900. readonly text: string
  901. readonly stderr: string
  902. }
  903. function run(cwd: string, proc: AppProcess.Interface) {
  904. return (args: string[]) =>
  905. execute(cwd, proc)(args).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, text: "", stderr: "" })))
  906. }
  907. function execute(cwd: string, proc: AppProcess.Interface) {
  908. return (args: string[]) =>
  909. proc
  910. .run(
  911. ChildProcess.make("git", args, {
  912. cwd,
  913. extendEnv: true,
  914. stdin: "ignore",
  915. }),
  916. )
  917. .pipe(
  918. Effect.map(
  919. (result) =>
  920. ({
  921. exitCode: result.exitCode,
  922. text: result.stdout.toString("utf8"),
  923. stderr: result.stderr.toString("utf8"),
  924. }) satisfies Result,
  925. ),
  926. )
  927. }
  928. function resolvePath(cwd: string, value: string) {
  929. const trimmed = value.replace(/[\r\n]+$/, "")
  930. if (!trimmed) return cwd
  931. const normalized = FSUtil.windowsPath(trimmed)
  932. if (path.isAbsolute(normalized)) return path.normalize(normalized)
  933. return path.resolve(cwd, normalized)
  934. }