| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- import { $ } from "bun"
- import { afterEach, describe, expect, test } from "bun:test"
- import { Effect } from "effect"
- import fs from "fs/promises"
- import path from "path"
- import { tmpdir } from "../fixture/fixture"
- import { AppRuntime } from "../../src/effect/app-runtime"
- import { FileWatcher } from "../../src/file/watcher"
- import { Instance } from "../../src/project/instance"
- import { GlobalBus } from "../../src/bus/global"
- import { Vcs } from "../../src/project/vcs"
- // Skip in CI — native @parcel/watcher binding needed
- const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
- // ---------------------------------------------------------------------------
- // Helpers
- // ---------------------------------------------------------------------------
- async function withVcs(directory: string, body: () => Promise<void>) {
- return Instance.provide({
- directory,
- fn: async () => {
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const watcher = yield* FileWatcher.Service
- const vcs = yield* Vcs.Service
- yield* watcher.init()
- yield* vcs.init()
- }),
- )
- await Bun.sleep(500)
- await body()
- },
- })
- }
- function withVcsOnly(directory: string, body: () => Promise<void>) {
- return Instance.provide({
- directory,
- fn: async () => {
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- yield* vcs.init()
- }),
- )
- await body()
- },
- })
- }
- type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
- const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
- /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
- function nextBranchUpdate(directory: string, timeout = 10_000) {
- return new Promise<string | undefined>((resolve, reject) => {
- let settled = false
- const timer = setTimeout(() => {
- if (settled) return
- settled = true
- GlobalBus.off("event", on)
- reject(new Error("timed out waiting for BranchUpdated event"))
- }, timeout)
- function on(evt: BranchEvent) {
- if (evt.directory !== directory) return
- if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
- if (settled) return
- settled = true
- clearTimeout(timer)
- GlobalBus.off("event", on)
- resolve(evt.payload.properties.branch)
- }
- GlobalBus.on("event", on)
- })
- }
- // ---------------------------------------------------------------------------
- // Tests
- // ---------------------------------------------------------------------------
- describeVcs("Vcs", () => {
- afterEach(async () => {
- await Instance.disposeAll()
- })
- test("branch() returns current branch name", async () => {
- await using tmp = await tmpdir({ git: true })
- await withVcs(tmp.path, async () => {
- const branch = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.branch()
- }),
- )
- expect(branch).toBeDefined()
- expect(typeof branch).toBe("string")
- })
- })
- test("branch() returns undefined for non-git directories", async () => {
- await using tmp = await tmpdir()
- await withVcs(tmp.path, async () => {
- const branch = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.branch()
- }),
- )
- expect(branch).toBeUndefined()
- })
- })
- test("publishes BranchUpdated when .git/HEAD changes", async () => {
- await using tmp = await tmpdir({ git: true })
- const branch = `test-${Math.random().toString(36).slice(2)}`
- await $`git branch ${branch}`.cwd(tmp.path).quiet()
- await withVcs(tmp.path, async () => {
- const pending = nextBranchUpdate(tmp.path)
- const head = path.join(tmp.path, ".git", "HEAD")
- await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
- const updated = await pending
- expect(updated).toBe(branch)
- })
- })
- test("branch() reflects the new branch after HEAD change", async () => {
- await using tmp = await tmpdir({ git: true })
- const branch = `test-${Math.random().toString(36).slice(2)}`
- await $`git branch ${branch}`.cwd(tmp.path).quiet()
- await withVcs(tmp.path, async () => {
- const pending = nextBranchUpdate(tmp.path)
- const head = path.join(tmp.path, ".git", "HEAD")
- await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
- await pending
- const current = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.branch()
- }),
- )
- expect(current).toBe(branch)
- })
- })
- })
- describe("Vcs diff", () => {
- afterEach(async () => {
- await Instance.disposeAll()
- })
- test("defaultBranch() falls back to main", async () => {
- await using tmp = await tmpdir({ git: true })
- await $`git branch -M main`.cwd(tmp.path).quiet()
- await withVcsOnly(tmp.path, async () => {
- const branch = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.defaultBranch()
- }),
- )
- expect(branch).toBe("main")
- })
- })
- test("defaultBranch() uses init.defaultBranch when available", async () => {
- await using tmp = await tmpdir({ git: true })
- await $`git branch -M trunk`.cwd(tmp.path).quiet()
- await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
- await withVcsOnly(tmp.path, async () => {
- const branch = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.defaultBranch()
- }),
- )
- expect(branch).toBe("trunk")
- })
- })
- test("detects current branch from the active worktree", async () => {
- await using tmp = await tmpdir({ git: true })
- await using wt = await tmpdir()
- await $`git branch -M main`.cwd(tmp.path).quiet()
- const dir = path.join(wt.path, "feature")
- await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
- await withVcsOnly(dir, async () => {
- const [branch, base] = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
- }),
- )
- expect(branch).toBe("feature/test")
- expect(base).toBe("main")
- })
- })
- test("diff('git') returns uncommitted changes", async () => {
- await using tmp = await tmpdir({ git: true })
- await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
- await $`git add .`.cwd(tmp.path).quiet()
- await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
- await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
- await withVcsOnly(tmp.path, async () => {
- const diff = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.diff("git")
- }),
- )
- expect(diff).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- file: "file.txt",
- status: "modified",
- }),
- ]),
- )
- })
- })
- test("diff('git') handles special filenames", async () => {
- await using tmp = await tmpdir({ git: true })
- await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
- await withVcsOnly(tmp.path, async () => {
- const diff = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.diff("git")
- }),
- )
- expect(diff).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- file: weird,
- status: "added",
- }),
- ]),
- )
- })
- })
- test("diff('branch') returns changes against default branch", async () => {
- await using tmp = await tmpdir({ git: true })
- await $`git branch -M main`.cwd(tmp.path).quiet()
- await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
- await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
- await $`git add .`.cwd(tmp.path).quiet()
- await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
- await withVcsOnly(tmp.path, async () => {
- const diff = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.diff("branch")
- }),
- )
- expect(diff).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- file: "branch.txt",
- status: "added",
- }),
- ]),
- )
- })
- })
- })
|