diff --git a/.mise.toml b/.mise.toml index 46ac2e8..cdd18df 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,5 +1,5 @@ [tools] -bun = "latest" +bun = "1.3.8" [env] _.path = ["bin"] diff --git a/bun.lock b/bun.lock index a7a04cd..75d5c5d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "git-witty", "dependencies": { + "@clack/prompts": "^1.1.0", "commander": "^14.0.3", }, "devDependencies": { @@ -35,6 +36,10 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], + + "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], @@ -43,6 +48,8 @@ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], diff --git a/package.json b/package.json index 0803b64..459de58 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "typescript": "^5" }, "dependencies": { + "@clack/prompts": "^1.1.0", "commander": "^14.0.3" } } diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts index a51b66e..556ede6 100644 --- a/src/commands/add.test.ts +++ b/src/commands/add.test.ts @@ -1,74 +1,32 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { $, type ShellExpression } from "bun"; - -const binDir = resolve(import.meta.dir, "../../bin"); - -const testEnv = { - ...Bun.env, - PATH: `${binDir}:${Bun.env.PATH}`, - GIT_AUTHOR_NAME: "test", - GIT_AUTHOR_EMAIL: "test@test.com", - GIT_COMMITTER_NAME: "test", - GIT_COMMITTER_EMAIL: "test@test.com", -}; - -let tempDir: string; - -beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), "git-witty-")); -}); - -afterEach(async () => { - await rm(tempDir, { recursive: true }); -}); - -function sh(strings: TemplateStringsArray, ...values: ShellExpression[]) { - return $(strings, ...values) - .cwd(tempDir) - .env(testEnv) - .quiet(); -} - -async function setupRepo(): Promise<{ repo: string; primaryBranch: string }> { - const origin = "origin"; - const repo = "my-repo"; - - await sh`git init ${origin}`; - await sh`git -C ${origin} commit --allow-empty -m "init"`; - await sh`git witty clone ${origin} ${repo}`; - - const primaryBranch = ( - await sh`git -C ${repo}/.bare symbolic-ref --short HEAD`.text() - ).trim(); - - return { repo, primaryBranch }; -} - -test("add creates a worktree for an existing branch", async () => { - const { repo, primaryBranch } = await setupRepo(); - - await sh`git -C ${join(repo, primaryBranch)} branch feature-a`; - await sh`cd ${join(repo, primaryBranch)} && git witty add feature-a`; - - const branch = ( - await sh`git -C ${join(repo, "feature-a")} branch --show-current`.text() - ).trim(); - expect(branch).toBe("feature-a"); -}); - -test("add creates a worktree that is listed", async () => { - const { repo, primaryBranch } = await setupRepo(); - - await sh`git -C ${join(repo, primaryBranch)} branch feature-b`; - await sh`cd ${join(repo, primaryBranch)} && git witty add feature-b`; - - const worktreeLines = (await sh`git -C ${repo} worktree list`.text()) - .trim() - .split("\n"); - - expect(worktreeLines).toHaveLength(3); - expect(worktreeLines.some((l) => l.includes("[feature-b]"))).toBe(true); +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { createRepo, useTestDir } from "../test-helpers"; + +describe("add", () => { + const ctx = useTestDir(); + + test("creates worktree as sibling from nested directory", async () => { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "main", + }); + const target = "my-repo"; + await ctx.sh`git witty clone ${origin} ${target}`; + + // Run add from a nested subdirectory inside an existing worktree + const nested = join(ctx.dir, target, branch, "some", "dir"); + await ctx.sh`mkdir -p ${nested}`; + const sh = ctx.at(nested); + + // Create the branch in the origin so git worktree add can find it + await ctx.sh`git -C ${origin} branch feature`; + + await sh`git witty add feature`; + + // Worktree should be at my-repo/feature, not nested under cwd + const worktreeDir = join(ctx.dir, target, "feature"); + const currentBranch = ( + await ctx.sh`git -C ${worktreeDir} branch --show-current`.text() + ).trim(); + expect(currentBranch).toBe("feature"); + }); }); diff --git a/src/commands/add.ts b/src/commands/add.ts index 05c6d8e..ba9cfe0 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,16 +1,16 @@ import { join } from "node:path"; -import { $ } from "bun"; -import { getRepoName, getRepoRoot } from "../repo"; -import { addFolder } from "../workspace"; +import { Git } from "../git"; +import { syncWorkspace } from "../workspace"; -export async function add({ branch }: { branch: string }) { - const root = await getRepoRoot(); - const repoName = await getRepoName(); - const worktreePath = join(root, branch); - - await $`git worktree add ${worktreePath}`; - await $`git -C ${worktreePath} branch --set-upstream-to=origin/${branch}` - .quiet() - .nothrow(); - await addFolder(root, repoName, worktreePath); +export async function add(branch: string) { + const git = await new Git().root(); + const root = await git.rootDir(); + const repoName = await git.repoName(); + await git.worktree("add", join(root, branch)); + await git + .C(join(root, branch)) + .exec("branch", `--set-upstream-to=origin/${branch}`) + .nothrow() + .quiet(); + await syncWorkspace(root, repoName); } diff --git a/src/commands/clone.test.ts b/src/commands/clone.test.ts index 17039a4..90fd4ef 100644 --- a/src/commands/clone.test.ts +++ b/src/commands/clone.test.ts @@ -1,117 +1,78 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { $, type ShellExpression } from "bun"; - -const binDir = resolve(import.meta.dir, "../../bin"); - -const testEnv = { - ...Bun.env, - PATH: `${binDir}:${Bun.env.PATH}`, - GIT_AUTHOR_NAME: "test", - GIT_AUTHOR_EMAIL: "test@test.com", - GIT_COMMITTER_NAME: "test", - GIT_COMMITTER_EMAIL: "test@test.com", -}; - -let tempDir: string; - -beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), "git-witty-")); -}); - -afterEach(async () => { - await rm(tempDir, { recursive: true }); -}); - -function sh(strings: TemplateStringsArray, ...values: ShellExpression[]) { - return $(strings, ...values) - .cwd(tempDir) - .env(testEnv) - .quiet(); -} - -test("clone creates worktree layout from local repo", async () => { - const origin = "origin"; - const target = "my-repo"; - - await sh`git init ${origin}`; - await sh`git -C ${origin} commit --allow-empty -m "init"`; - - await sh`git witty clone ${origin} ${target}`; - - // Verify .git pointer file we create - expect(await Bun.file(join(tempDir, target, ".git")).text()).toBe( - "gitdir: ./.bare\n", - ); - - // Verify worktree is a functional git checkout on the primary branch - const branch = ( - await sh`git -C ${target}/.bare symbolic-ref --short HEAD`.text() - ).trim(); - const worktreeBranch = ( - await sh`git -C ${join(target, branch)} branch --show-current`.text() - ).trim(); - expect(worktreeBranch).toBe(branch); - - // Verify both bare repo and worktree are registered - const worktreeLines = (await sh`git -C ${target} worktree list`.text()) - .trim() - .split("\n"); - expect(worktreeLines).toHaveLength(2); - expect(worktreeLines[0]).toContain(".bare"); - expect(worktreeLines[1]).toContain(`[${branch}]`); -}); - -test("clone infers repo name from url", async () => { - const origin = "origin/my-project"; - const targetDir = "target"; - - await sh`git init ${origin}`; - await sh`git -C ${origin} commit --allow-empty -m "init"`; - - await sh`mkdir -p ${targetDir}`; - await sh`cd ${targetDir} && git witty clone ${join(tempDir, origin)}`; - - expect( - await Bun.file(join(tempDir, targetDir, "my-project", ".git")).text(), - ).toBe("gitdir: ./.bare\n"); -}); - -test("clone creates a workspace file", async () => { - const origin = "origin"; - const target = "my-repo"; - - await sh`git init ${origin}`; - await sh`git -C ${origin} commit --allow-empty -m "init"`; - - await sh`git witty clone ${origin} ${target}`; - - const branch = ( - await sh`git -C ${target}/.bare symbolic-ref --short HEAD`.text() - ).trim(); - - const wsFile = Bun.file(join(tempDir, target, `${origin}.code-workspace`)); - expect(await wsFile.exists()).toBe(true); - - const workspace = await wsFile.json(); - expect(workspace.folders).toEqual([{ path: branch }]); -}); - -test("clone creates worktree for non-main primary branch", async () => { - const origin = "origin"; - const target = "my-repo"; - const branch = "develop"; - - await sh`git init -b ${branch} ${origin}`; - await sh`git -C ${origin} commit --allow-empty -m "init"`; - - await sh`git witty clone ${origin} ${target}`; - - const worktreeBranch = ( - await sh`git -C ${join(target, branch)} branch --show-current`.text() - ).trim(); - expect(worktreeBranch).toBe(branch); - expect(await Bun.file(join(tempDir, target, "main")).exists()).toBe(false); +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { BARE_DIR, GITDIR_POINTER } from "../git"; +import { createRepo, useTestDir } from "../test-helpers"; + +describe("clone", () => { + const ctx = useTestDir(); + + test("creates worktree layout from local repo", async () => { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "main", + }); + const target = "my-repo"; + + await ctx.sh`git witty clone ${origin} ${target}`; + + // Verify .git pointer file we create + expect(await Bun.file(join(ctx.dir, target, ".git")).text()).toBe( + GITDIR_POINTER, + ); + + // Verify worktree is a functional git checkout on the primary branch + const worktreeBranch = ( + await ctx.sh`git -C ${join(target, branch)} branch --show-current`.text() + ).trim(); + expect(worktreeBranch).toBe(branch); + + // Verify both bare repo and worktree are registered + const worktreeLines = (await ctx.sh`git -C ${target} worktree list`.text()) + .trim() + .split("\n"); + expect(worktreeLines).toHaveLength(2); + expect(worktreeLines[0]).toContain(BARE_DIR); + expect(worktreeLines[1]).toContain(`[${branch}]`); + }); + + test("infers repo name from url", async () => { + await createRepo(ctx.sh, { name: "origin/my-project", branch: "main" }); + const targetDir = "target"; + + await ctx.sh`mkdir -p ${targetDir}`; + await ctx.at( + join(ctx.dir, targetDir), + )`git witty clone ${join(ctx.dir, "origin/my-project")}`; + + expect( + await Bun.file(join(ctx.dir, targetDir, "my-project", ".git")).text(), + ).toBe(GITDIR_POINTER); + }); + + test("creates a workspace file", async () => { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "main", + }); + const target = "my-repo"; + + await ctx.sh`git witty clone ${origin} ${target}`; + + const wsPath = join(ctx.dir, target, `${target}.code-workspace`); + const workspace = await Bun.file(wsPath).json(); + expect(workspace.folders).toEqual([{ path: branch }]); + }); + + test("creates worktree for non-main primary branch", async () => { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "develop", + }); + const target = "my-repo"; + + await ctx.sh`git witty clone ${origin} ${target}`; + + const worktreeBranch = ( + await ctx.sh`git -C ${join(target, branch)} branch --show-current`.text() + ).trim(); + expect(worktreeBranch).toBe(branch); + expect(await Bun.file(join(ctx.dir, target, "main")).exists()).toBe(false); + }); }); diff --git a/src/commands/clone.ts b/src/commands/clone.ts index de092d8..4b7a488 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -1,49 +1,52 @@ -import { basename, resolve } from "node:path"; -import { $ } from "bun"; -import { addFolder } from "../workspace"; -import { installHook } from "./init"; - -export async function clone({ url, name }: { url: string; name?: string }) { - const repoName = repoNameFromUrl(url); - name ??= repoName; - const root = resolve(name); - const bareDir = `${root}/.bare`; +import { basename, join, resolve } from "node:path"; +import { BARE_DIR, GITDIR_POINTER, Git } from "../git"; +import { installHook } from "../hooks"; +import { syncWorkspace } from "../workspace"; + +export interface CloneResult { + root: string; + name: string; + primaryBranch: string; +} - // Clone as bare repo - console.log(`Cloning ${url} into ${root}...`); - await $`git clone --bare ${url} ${bareDir}`; +export async function clone({ + url, + name, +}: { + url: string; + name?: string; +}): Promise { + name ??= basename(url).replace(/\.git$/, ""); + const root = resolve(name); + const bareDir = join(root, BARE_DIR); + const git = new Git(); - // Bare clones need this to fetch all branches - await $`git -C ${bareDir} config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"`; - await $`git -C ${bareDir} fetch origin`; + await git.clone(url, bareDir); - // Make the setup portable (can move the folder) - await $`git -C ${bareDir} config worktree.useRelativePaths true`; + const bare = git.C(bareDir); + await bare.config.set( + "remote.origin.fetch", + "+refs/heads/*:refs/remotes/origin/*", + ); + await bare.exec("fetch", "origin"); + await bare.config.set("core.logAllRefUpdates", "true"); + await bare.config.set("worktree.useRelativePaths", "true"); - // Create .git file pointing to the bare repo - await Bun.write(`${root}/.git`, "gitdir: ./.bare\n"); + await Bun.write(join(root, ".git"), GITDIR_POINTER); - // Determine the primary branch const primaryBranch = ( - await $`git -C ${bareDir} symbolic-ref --short HEAD`.text() + await bare.exec("symbolic-ref", "--short", "HEAD").text() ).trim(); - // Create the first worktree - const worktreePath = resolve(root, primaryBranch); - await $`git -C ${bareDir} worktree add ${worktreePath}`; - await $`git -C ${worktreePath} branch --set-upstream-to=origin/${primaryBranch}`; - await addFolder(root, repoName, worktreePath); + const rootGit = git.C(root); + await rootGit.worktree("add", primaryBranch); + await rootGit + .C(primaryBranch) + .exec("branch", `--set-upstream-to=origin/${primaryBranch}`); - // Install git-witty hooks - await installHook({ bareDir }); + await installHook(bareDir); - console.log(`\nReady! Created worktree layout:`); - console.log(` ${name}/.bare/ (bare repo)`); - console.log(` ${name}/.git (gitdir pointer)`); - console.log(` ${name}/${primaryBranch}/ (worktree)`); - console.log(`\ncd ${name}/${primaryBranch} to get started.`); -} + await syncWorkspace(root, name); -function repoNameFromUrl(url: string): string { - return basename(url).replace(/\.git$/, ""); + return { root, name, primaryBranch }; } diff --git a/src/commands/index.ts b/src/commands/index.ts index b4528ef..ae19f3f 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,4 @@ export { add } from "./add"; export { clone } from "./clone"; -export { init } from "./init"; -export { list } from "./list"; -export { protect, unprotect } from "./protect"; +export { protect } from "./protect"; export { remove } from "./remove"; diff --git a/src/commands/list.ts b/src/commands/list.ts deleted file mode 100644 index 4b44d1f..0000000 --- a/src/commands/list.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { $ } from "bun"; -import { getProtectedBranches } from "./protect"; - -interface Worktree { - path: string; - head: string; - branch?: string; - bare: boolean; - locked: boolean; -} - -function parsePorcelain(output: string): Worktree[] { - const blocks = output.trim().split("\n\n"); - return blocks.map((block) => { - const lines = block.split("\n"); - const worktree: Worktree = { - path: "", - head: "", - bare: false, - locked: false, - }; - - for (const line of lines) { - if (line.startsWith("worktree ")) { - worktree.path = line.slice("worktree ".length); - } else if (line.startsWith("HEAD ")) { - worktree.head = line.slice("HEAD ".length); - } else if (line.startsWith("branch ")) { - worktree.branch = line.slice("branch refs/heads/".length); - } else if (line === "bare") { - worktree.bare = true; - } else if (line === "locked") { - worktree.locked = true; - } - } - - return worktree; - }); -} - -export async function list() { - const output = await $`git worktree list --porcelain`.text(); - const worktrees = parsePorcelain(output); - const protectedBranches = await getProtectedBranches(); - - const maxPathLen = Math.max(...worktrees.map((w) => w.path.length)); - - for (const wt of worktrees) { - const path = wt.path.padEnd(maxPathLen); - - if (wt.bare) { - const parts = [path, " (bare)"]; - console.log(parts.join("")); - continue; - } - - const shortHead = wt.head.slice(0, 7); - const branchTag = wt.branch ? ` [${wt.branch}]` : ""; - const annotations: string[] = []; - - if (wt.locked) annotations.push("locked"); - if (wt.branch && protectedBranches.includes(wt.branch)) - annotations.push("protected"); - - const suffix = annotations.length > 0 ? ` ${annotations.join(", ")}` : ""; - console.log(`${path} ${shortHead}${branchTag}${suffix}`); - } -} diff --git a/src/commands/protect.test.ts b/src/commands/protect.test.ts index 5cbe35d..44c731b 100644 --- a/src/commands/protect.test.ts +++ b/src/commands/protect.test.ts @@ -1,113 +1,60 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { $, type ShellExpression } from "bun"; +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { Git, PROTECT_CONFIG_KEY } from "../git"; +import { createRepo, useTestDir } from "../test-helpers"; +import { syncProtected } from "./protect"; -const binDir = resolve(import.meta.dir, "../../bin"); +describe("syncProtected", () => { + const ctx = useTestDir(); -const testEnv = { - ...Bun.env, - PATH: `${binDir}:${Bun.env.PATH}`, - GIT_AUTHOR_NAME: "test", - GIT_AUTHOR_EMAIL: "test@test.com", - GIT_COMMITTER_NAME: "test", - GIT_COMMITTER_EMAIL: "test@test.com", -}; + async function setup() { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "main", + }); + const target = "my-repo"; + await ctx.sh`git witty clone ${origin} ${target}`; + const dir = join(ctx.dir, target, branch); + const git = await new Git(["-C", dir]).root(); + return git.config; + } -let tempDir: string; + test("adds newly selected branches", async () => { + const config = await setup(); -beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), "git-witty-")); -}); - -afterEach(async () => { - await rm(tempDir, { recursive: true }); -}); - -function at(cwd: string) { - return (strings: TemplateStringsArray, ...values: ShellExpression[]) => - $(strings, ...values) - .cwd(cwd) - .env(testEnv) - .quiet(); -} - -async function setupRepo() { - const sh = at(tempDir); - await sh`git init -b main origin`; - await sh`git -C origin commit --allow-empty -m "init"`; - await sh`git -C origin branch develop`; - await sh`git witty clone origin my-repo`; - const worktree = join(tempDir, "my-repo", "main"); - return { worktree }; -} - -test("protect adds branch to config", async () => { - const { worktree } = await setupRepo(); - const sh = at(worktree); - - await sh`git witty protect main`; - - const branches = (await sh`git config --get-all witty.protect`.text()).trim(); - expect(branches).toBe("main"); -}); - -test("protect multiple branches in one command", async () => { - const { worktree } = await setupRepo(); - const sh = at(worktree); - - await sh`git worktree add ../develop develop`; - await sh`git witty protect main develop`; - - const branches = (await sh`git config --get-all witty.protect`.text()) - .trim() - .split("\n"); - expect(branches).toEqual(["main", "develop"]); -}); + await syncProtected([], ["main", "release"], config); -test("protect is idempotent", async () => { - const { worktree } = await setupRepo(); - const sh = at(worktree); + const result = await config.getAll(PROTECT_CONFIG_KEY); + expect(result).toEqual(["main", "release"]); + }); - await sh`git witty protect main`; - await sh`git witty protect main`; + test("removes deselected branches", async () => { + const config = await setup(); + await config.add(PROTECT_CONFIG_KEY, "main"); + await config.add(PROTECT_CONFIG_KEY, "release"); - const branches = (await sh`git config --get-all witty.protect`.text()) - .trim() - .split("\n"); - expect(branches).toEqual(["main"]); -}); + await syncProtected(["main", "release"], ["release"], config); -test("unprotect removes branch from config", async () => { - const { worktree } = await setupRepo(); - const sh = at(worktree); + const result = await config.getAll(PROTECT_CONFIG_KEY); + expect(result).toEqual(["release"]); + }); - await sh`git worktree add ../develop develop`; - await sh`git witty protect main`; - await sh`git witty protect develop`; - await sh`git witty unprotect main`; + test("handles mixed adds and removes", async () => { + const config = await setup(); + await config.add(PROTECT_CONFIG_KEY, "main"); - const branches = (await sh`git config --get-all witty.protect`.text()) - .trim() - .split("\n"); - expect(branches).toEqual(["develop"]); -}); + await syncProtected(["main"], ["release"], config); -test("protect non-existent branch fails", async () => { - const { worktree } = await setupRepo(); - const sh = at(worktree); + const result = await config.getAll(PROTECT_CONFIG_KEY); + expect(result).toEqual(["release"]); + }); - const result = await sh`git witty protect nope`.nothrow(); - expect(result.exitCode).not.toBe(0); - expect(result.stderr.toString()).toContain("not checked out"); -}); + test("no-op when selection unchanged", async () => { + const config = await setup(); + await config.add(PROTECT_CONFIG_KEY, "main"); -test("unprotect non-protected branch fails", async () => { - const { worktree } = await setupRepo(); - const sh = at(worktree); + await syncProtected(["main"], ["main"], config); - const result = await sh`git witty unprotect main`.nothrow(); - expect(result.exitCode).not.toBe(0); - expect(result.stderr.toString()).toContain("not protected"); + const result = await config.getAll(PROTECT_CONFIG_KEY); + expect(result).toEqual(["main"]); + }); }); diff --git a/src/commands/protect.ts b/src/commands/protect.ts index e9e5447..2f8cb74 100644 --- a/src/commands/protect.ts +++ b/src/commands/protect.ts @@ -1,52 +1,47 @@ -import { $ } from "bun"; - -export async function protect({ branches }: { branches: string[] }) { - const existing = await getProtectedBranches(); - - const worktreeOutput = ( - await $`git worktree list --porcelain`.quiet().nothrow().text() - ).trim(); - const checkedOutBranches = new Set( - worktreeOutput - .split("\n") - .filter((line) => line.startsWith("branch refs/heads/")) - .map((line) => line.slice("branch refs/heads/".length)), - ); - - for (const branch of branches) { - if (!checkedOutBranches.has(branch)) { - console.error(`Branch '${branch}' is not checked out in any worktree.`); - process.exit(1); - } - - if (existing.includes(branch)) { - continue; - } - - await $`git config --add witty.protect ${branch}`; - existing.push(branch); - console.log(`Protected branch: ${branch}`); +import * as p from "@clack/prompts"; +import { Git, type GitConfig, PROTECT_CONFIG_KEY } from "../git"; + +export async function syncProtected( + current: string[], + selected: string[], + config: GitConfig, +) { + const toAdd = selected.filter((b) => !current.includes(b)); + const toRemove = current.filter((b) => !selected.includes(b)); + + for (const branch of toAdd) { + await config.add(PROTECT_CONFIG_KEY, branch); } -} - -export async function unprotect({ branches }: { branches: string[] }) { - const existing = await getProtectedBranches(); - - for (const branch of branches) { - if (!existing.includes(branch)) { - console.error(`Branch '${branch}' is not protected.`); - process.exit(1); - } - - await $`git config --unset witty.protect ${branch}`; + for (const branch of toRemove) { + await config.unset(PROTECT_CONFIG_KEY, `^${branch}$`); } } -export async function getProtectedBranches(): Promise { - try { - const result = (await $`git config --get-all witty.protect`.text()).trim(); - return result ? result.split("\n") : []; - } catch { - return []; +export async function protect() { + const git = await new Git().root(); + const branches = await git.listBranches(); + const currentProtected = await git.config.getAll(PROTECT_CONFIG_KEY); + + const selected = await p.multiselect({ + message: "Select branches to protect", + options: [...branches] + .sort((a, b) => { + const aP = currentProtected.includes(a) ? 0 : 1; + const bP = currentProtected.includes(b) ? 0 : 1; + return aP - bP; + }) + .map((b) => ({ + value: b, + label: b, + })), + initialValues: currentProtected, + required: false, + }); + + if (p.isCancel(selected)) { + p.cancel("No changes made."); + process.exit(0); } + + await syncProtected(currentProtected, selected, git.config); } diff --git a/src/commands/remove.test.ts b/src/commands/remove.test.ts index 8dfaf4c..b47d181 100644 --- a/src/commands/remove.test.ts +++ b/src/commands/remove.test.ts @@ -1,63 +1,38 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { $, type ShellExpression } from "bun"; - -const binDir = resolve(import.meta.dir, "../../bin"); - -const testEnv = { - ...Bun.env, - PATH: `${binDir}:${Bun.env.PATH}`, - GIT_AUTHOR_NAME: "test", - GIT_AUTHOR_EMAIL: "test@test.com", - GIT_COMMITTER_NAME: "test", - GIT_COMMITTER_EMAIL: "test@test.com", -}; - -let tempDir: string; - -beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), "git-witty-")); -}); - -afterEach(async () => { - await rm(tempDir, { recursive: true }); -}); - -function sh(strings: TemplateStringsArray, ...values: ShellExpression[]) { - return $(strings, ...values) - .cwd(tempDir) - .env(testEnv) - .quiet(); -} - -async function setupRepo(): Promise<{ repo: string; primaryBranch: string }> { - const origin = "origin"; - const repo = "my-repo"; - - await sh`git init ${origin}`; - await sh`git -C ${origin} commit --allow-empty -m "init"`; - await sh`git witty clone ${origin} ${repo}`; - - const primaryBranch = ( - await sh`git -C ${repo}/.bare symbolic-ref --short HEAD`.text() - ).trim(); - - return { repo, primaryBranch }; -} - -test("remove deletes a worktree", async () => { - const { repo, primaryBranch } = await setupRepo(); - - await sh`git -C ${join(repo, primaryBranch)} branch feature-c`; - await sh`cd ${join(repo, primaryBranch)} && git witty add feature-c`; - await sh`cd ${join(repo, primaryBranch)} && git witty remove feature-c`; - - const worktreeLines = (await sh`git -C ${repo} worktree list`.text()) - .trim() - .split("\n"); - - expect(worktreeLines).toHaveLength(2); - expect(await Bun.file(join(tempDir, repo, "feature-c")).exists()).toBe(false); +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { createRepo, useTestDir } from "../test-helpers"; + +describe("remove", () => { + const ctx = useTestDir(); + + test("removes worktree as sibling from nested directory", async () => { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "main", + }); + const target = "my-repo"; + await ctx.sh`git witty clone ${origin} ${target}`; + + const repoSh = ctx.at(join(ctx.dir, target, branch)); + await ctx.sh`git -C ${origin} branch feature`; + await repoSh`git witty add feature`; + + // Remove from a nested directory + const nested = join(ctx.dir, target, branch, "some", "dir"); + await ctx.sh`mkdir -p ${nested}`; + const sh = ctx.at(nested); + + await sh`git witty remove feature`; + + const worktreeLines = (await repoSh`git worktree list`.text()) + .trim() + .split("\n"); + expect(worktreeLines).toHaveLength(2); + expect(worktreeLines.some((line) => line.includes("[feature]"))).toBe( + false, + ); + + // Directory should also be removed from disk + const featureDir = join(ctx.dir, target, "feature"); + expect(await Bun.file(featureDir).exists()).toBe(false); + }); }); diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 74f970f..a80b42d 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -1,13 +1,10 @@ -import { join } from "node:path"; -import { $ } from "bun"; -import { getRepoName, getRepoRoot } from "../repo"; -import { removeFolder } from "../workspace"; +import { Git } from "../git"; +import { syncWorkspace } from "../workspace"; -export async function remove({ branch }: { branch: string }) { - const root = await getRepoRoot(); - const repoName = await getRepoName(); - const worktreePath = join(root, branch); - - await $`git worktree remove ${worktreePath}`; - await removeFolder(root, repoName, worktreePath); +export async function remove(branch: string) { + const git = await new Git().root(); + const root = await git.rootDir(); + const repoName = await git.repoName(); + await git.worktree("remove", branch); + await syncWorkspace(root, repoName); } diff --git a/src/git.test.ts b/src/git.test.ts new file mode 100644 index 0000000..801afc4 --- /dev/null +++ b/src/git.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { Git } from "./git"; +import { createRepo, useTestDir } from "./test-helpers"; + +describe("GitConfig", () => { + const t = useTestDir(); + + async function setup() { + await createRepo(t.sh, { branch: "main" }); + return new Git().C(`${t.dir}/origin`).config; + } + + test("set + get round-trip", async () => { + const config = await setup(); + + await config.set("test.foo", "bar"); + expect(await config.get("test.foo")).toBe("bar"); + }); + + test("add + getAll multi-value", async () => { + const config = await setup(); + + await config.add("test.protect", "main"); + await config.add("test.protect", "release"); + expect(await config.getAll("test.protect")).toEqual(["main", "release"]); + }); + + test("unset with pattern removes one entry from multi-value", async () => { + const config = await setup(); + + await config.add("test.protect", "main"); + await config.add("test.protect", "release"); + await config.unset("test.protect", "^main$"); + expect(await config.getAll("test.protect")).toEqual(["release"]); + }); + + test("get missing key returns undefined", async () => { + const config = await setup(); + + expect(await config.get("test.nonexistent")).toBeUndefined(); + }); + + test("getAll missing key returns []", async () => { + const config = await setup(); + + expect(await config.getAll("test.nonexistent")).toEqual([]); + }); +}); + +describe("listWorktrees", () => { + const ctx = useTestDir(); + + test("returns branch names from worktrees", async () => { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "main", + }); + const target = "my-repo"; + await ctx.sh`git witty clone ${origin} ${target}`; + const sh = ctx.at(join(ctx.dir, target, branch)); + await ctx.sh`git -C ${origin} branch feature`; + await sh`git witty add feature`; + + const git = await new Git(["-C", join(ctx.dir, target, branch)]).root(); + const worktrees = await git.listWorktrees(); + const branches = worktrees.map((w) => w.branch); + + expect(branches).toContain(branch); + expect(branches).toContain("feature"); + }); + + test("skips bare entry", async () => { + const { name: origin, branch } = await createRepo(ctx.sh, { + branch: "main", + }); + const target = "my-repo"; + await ctx.sh`git witty clone ${origin} ${target}`; + + const git = await new Git(["-C", join(ctx.dir, target, branch)]).root(); + const worktrees = await git.listWorktrees(); + + // Only the primary worktree should be returned, not the bare entry + expect(worktrees).toHaveLength(1); + expect(worktrees[0].branch).toBe(branch); + }); +}); diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..5f7b6e4 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,128 @@ +import { basename, dirname, resolve } from "node:path"; +import { $ } from "bun"; + +export const BARE_DIR = ".bare"; +export const GITDIR_POINTER = `gitdir: ./${BARE_DIR}\n`; +export const PROTECT_CONFIG_KEY = "witty.protect"; + +export class GitConfig { + #git: Git; + + constructor(git: Git) { + this.#git = git; + } + + async get(key: string): Promise { + const result = await this.#git + .exec("config", "--get", key) + .nothrow() + .quiet(); + if (result.exitCode !== 0) return undefined; + return result.text().trim(); + } + + async getAll(key: string): Promise { + const result = await this.#git + .exec("config", "--get-all", key) + .nothrow() + .quiet(); + if (result.exitCode !== 0) return []; + return result + .text() + .trim() + .split("\n") + .filter((line) => line.length > 0); + } + + set(key: string, value: string) { + return this.#git.exec("config", key, value); + } + + add(key: string, value: string) { + return this.#git.exec("config", "--add", key, value); + } + + unset(key: string, valuePattern?: string) { + if (valuePattern !== undefined) { + return this.#git.exec("config", "--unset", key, valuePattern); + } + return this.#git.exec("config", "--unset-all", key); + } +} + +export class Git { + #flags: string[]; + #rootDir?: string; + + constructor(flags: string[] = []) { + this.#flags = flags; + } + + C(dir: string) { + return new Git([...this.#flags, "-C", dir]); + } + + async rootDir(): Promise { + if (!this.#rootDir) { + const commonDir = ( + await this.exec("rev-parse", "--git-common-dir").text() + ).trim(); + this.#rootDir = resolve(dirname(commonDir)); + } + return this.#rootDir; + } + + async root(): Promise { + return this.C(await this.rootDir()); + } + + get config() { + return new GitConfig(this); + } + + async repoName(): Promise { + const url = (await this.exec("remote", "get-url", "origin").text()).trim(); + return basename(url).replace(/\.git$/, ""); + } + + clone(url: string, dir: string) { + return $`git ${this.#flags} clone --bare ${url} ${dir}`; + } + + worktree(cmd: string, ...args: string[]) { + return $`git ${this.#flags} worktree ${cmd} ${args}`; + } + + async listWorktrees(): Promise<{ branch: string; path: string }[]> { + const result = await this.worktree("list", "--porcelain").quiet(); + const entries = result.text().split("\n\n"); + const worktrees: { branch: string; path: string }[] = []; + for (const entry of entries) { + if (entry.includes(BARE_DIR)) continue; + const branchMatch = entry.match(/^branch refs\/heads\/(.+)$/m); + const pathMatch = entry.match(/^worktree (.+)$/m); + if (branchMatch?.[1] && pathMatch?.[1]) { + worktrees.push({ branch: branchMatch[1], path: pathMatch[1] }); + } + } + return worktrees; + } + + async listBranches(): Promise { + const result = await this.exec( + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/", + ).quiet(); + return result + .text() + .trim() + .split("\n") + .filter((line) => line.length > 0); + } + + /** Escape hatch for subcommands not covered above */ + exec(...args: string[]) { + return $`git ${this.#flags} ${args}`; + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f7c55c2..077ddcf 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ +export { installHook } from "./install"; export { referenceTransaction } from "./reference-transaction"; diff --git a/src/commands/init.ts b/src/hooks/install.ts similarity index 50% rename from src/commands/init.ts rename to src/hooks/install.ts index 034db09..86a95bd 100644 --- a/src/commands/init.ts +++ b/src/hooks/install.ts @@ -1,20 +1,12 @@ import { chmod } from "node:fs/promises"; import { join } from "node:path"; -import { $ } from "bun"; const TRAMPOLINE = `#!/usr/bin/env sh exec git witty hook "$(basename "$0")" "$@" `; -export async function init() { - await installHook(); -} - -export async function installHook({ bareDir }: { bareDir?: string } = {}) { - bareDir ??= (await $`git rev-parse --git-common-dir`.text()).trim(); +export async function installHook(bareDir: string) { const hookPath = join(bareDir, "hooks", "reference-transaction"); - await Bun.write(hookPath, TRAMPOLINE); await chmod(hookPath, 0o755); - console.log(`Installed reference-transaction hook at ${hookPath}`); } diff --git a/src/hooks/reference-transaction.ts b/src/hooks/reference-transaction.ts index 7e69672..491e06c 100644 --- a/src/hooks/reference-transaction.ts +++ b/src/hooks/reference-transaction.ts @@ -1,92 +1,27 @@ -import { existsSync, readdirSync } from "node:fs"; -import { resolve } from "node:path"; -import { $ } from "bun"; -import { getProtectedBranches } from "../commands/protect"; +import { Git, PROTECT_CONFIG_KEY } from "../git"; + +const NULL_SHA = "0000000000000000000000000000000000000000"; export async function referenceTransaction(state: string) { - if (state !== "prepared") { - return; - } + if (state !== "prepared") return; - const input = await Bun.stdin.text(); - - const protectedBranches = await getProtectedBranches(); - if (protectedBranches.length === 0) { - return; - } + const git = await new Git().root(); + const protectedBranches = await git.config.getAll(PROTECT_CONFIG_KEY); + if (protectedBranches.length === 0) return; + const input = await Bun.stdin.text(); for (const line of input.trim().split("\n")) { const [, newValue, ref] = line.split(" "); + if (newValue !== NULL_SHA) continue; + if (!ref?.startsWith("refs/heads/")) continue; - if (ref !== "HEAD") { - continue; - } - - // symbolic-ref reads the pre-transaction HEAD, which is the branch - // we're leaving. This is correct — we want to block leaving a protected branch. - const currentBranch = ( - await $`git symbolic-ref --short HEAD`.quiet().nothrow().text() - ).trim(); - - if (!currentBranch) { - continue; - } - - // Allow checkout to the same branch (e.g. restoring working tree) - const targetBranch = newValue?.replace("ref:refs/heads/", ""); - if (targetBranch === currentBranch) { - continue; - } - - // During git worktree add, a new worktree directory is created under - // .bare/worktrees/ before the HEAD transaction fires, but it won't have - // a HEAD file yet (this transaction is what creates it). If we detect - // such a directory, the HEAD update is for the new worktree, not ours. - if (await isWorktreeBeingCreated()) { - continue; - } - - if (protectedBranches.includes(currentBranch)) { - // Reset index to HEAD without touching the working tree (-u omitted). - // Git's own rollback handles the working tree; we just fix the index. - await $`git read-tree --reset HEAD`.quiet().nothrow(); - - console.error( - `error: branch '${currentBranch}' is protected by git-witty.`, - ); + const branch = ref.slice("refs/heads/".length); + if (protectedBranches.includes(branch)) { console.error( - `Run 'git witty unprotect ${currentBranch}' to allow checkout.`, + `error: deletion of branch '${branch}' is blocked by git-witty.`, ); + console.error("Run 'git witty protect' to manage protected branches."); process.exit(1); } } } - -async function isWorktreeBeingCreated(): Promise { - const commonDir = ( - await $`git rev-parse --git-common-dir`.quiet().nothrow().text() - ).trim(); - const worktreesDir = resolve(commonDir, "worktrees"); - const gitDir = resolve( - (await $`git rev-parse --git-dir`.quiet().nothrow().text()).trim(), - ); - - if (!existsSync(worktreesDir)) { - return false; - } - - for (const entry of readdirSync(worktreesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const wtDir = resolve(worktreesDir, entry.name); - if (wtDir === gitDir) { - continue; - } - if (!existsSync(resolve(wtDir, "HEAD"))) { - return true; - } - } - - return false; -} diff --git a/src/index.ts b/src/index.ts index dcb1e51..ff2a727 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; -import { add, clone, init, list, protect, remove, unprotect } from "./commands"; +import { add, clone, protect, remove } from "./commands"; +import { Git } from "./git"; import { referenceTransaction } from "./hooks"; const program = new Command() @@ -11,49 +12,69 @@ program .description("Clone a repo into a worktree-friendly layout") .argument("", "Repository URL to clone") .argument("[name]", "Directory name for the clone") - .action((url, name) => clone({ url, name })); + .action(async (url, name) => { + const result = await clone({ url, name }); + console.log(`\nReady! Created worktree layout:`); + console.log(` ${result.name}/.bare/ (bare repo)`); + console.log(` ${result.name}/.git (gitdir pointer)`); + console.log(` ${result.name}/${result.primaryBranch}/ (worktree)`); + console.log(`\ncd ${result.name}/${result.primaryBranch} to get started.`); + }); program .command("protect") - .description("Protect branches from checkout") - .argument("", "Branches to protect") - .action((branches) => protect({ branches })); - -program - .command("unprotect") - .description("Remove branch protection") - .argument("", "Branches to unprotect") - .action((branches) => unprotect({ branches })); + .description("Interactively select branches to protect from worktree removal") + .action(async () => { + await protect(); + }); program .command("add") - .description("Create a new worktree for a branch") - .argument("", "Branch name") - .action((branch) => add({ branch })); + .description("Add a new worktree") + .argument("") + .allowUnknownOption() + .allowExcessArguments() + .action(async (branch) => { + await add(branch); + }); program .command("remove") .description("Remove a worktree") - .argument("", "Branch name") - .action((branch) => remove({ branch })); - -program - .command("list") - .description("List worktrees with protection status") - .action(() => list()); - -program - .command("init") - .description("Install git-witty hooks into the repository") - .action(() => init()); - -const hook = program - .command("hook", { hidden: true }) - .description("Internal hook dispatcher"); + .argument("") + .allowUnknownOption() + .allowExcessArguments() + .action(async (branch) => { + await remove(branch); + }); +const hook = program.command("hook", { hidden: true }); hook .command("reference-transaction") - .argument("", "Transaction state") - .action((state) => referenceTransaction(state)); + .argument("") + .action((state: string) => referenceTransaction(state)); + +const passthroughCommands = [ + "list", + "lock", + "unlock", + "move", + "prune", + "repair", +]; + +for (const cmd of passthroughCommands) { + program + .command(cmd) + .description(`Passthrough to git worktree ${cmd}`) + .allowUnknownOption() + .allowExcessArguments() + .action(async (_options, command) => { + const args = command.args as string[]; + const git = new Git(); + const result = await git.worktree(cmd, ...args).nothrow(); + process.exit(result.exitCode); + }); +} program.parse(); diff --git a/src/repo.ts b/src/repo.ts deleted file mode 100644 index 4c4287c..0000000 --- a/src/repo.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { basename, dirname } from "node:path"; -import { $ } from "bun"; - -/** - * Returns the root directory of the witty repo layout (the parent of .bare/). - */ -export async function getRepoRoot(): Promise { - const gitCommonDir = (await $`git rev-parse --git-common-dir`.text()).trim(); - - // gitCommonDir points to .bare — the repo root is its parent - return dirname(gitCommonDir); -} - -/** - * Returns the repository name derived from the remote origin URL. - */ -export async function getRepoName(): Promise { - const url = (await $`git remote get-url origin`.text()).trim(); - return basename(url).replace(/\.git$/, ""); -} diff --git a/src/test-helpers.ts b/src/test-helpers.ts new file mode 100644 index 0000000..0755445 --- /dev/null +++ b/src/test-helpers.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { $, type ShellExpression } from "bun"; + +const binDir = resolve(import.meta.dir, "../bin"); + +const testEnv = { + ...Bun.env, + PATH: `${binDir}:${Bun.env.PATH}`, + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@test.com", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@test.com", +}; + +type Sh = ( + strings: TemplateStringsArray, + ...values: ShellExpression[] +) => ReturnType; + +function makeSh(cwd: string): Sh { + return (strings, ...values) => + $(strings, ...values) + .cwd(cwd) + .env(testEnv) + .quiet(); +} + +export interface TestDir { + readonly dir: string; + sh: Sh; + at(cwd: string): Sh; +} + +export function useTestDir(): TestDir { + let dir = ""; + let sh: Sh; + let at: (cwd: string) => Sh; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "git-witty-")); + sh = makeSh(dir); + at = (cwd: string) => makeSh(cwd); + }); + + const ctx: TestDir = { + get dir() { + return dir; + }, + get sh() { + return sh; + }, + at(cwd: string) { + return at(cwd); + }, + }; + + afterEach(async () => { + await rm(ctx.dir, { recursive: true }); + }); + + return ctx; +} + +export interface RepoOptions { + name?: string; + branch: string; + commits?: number; +} + +export async function createRepo( + sh: Sh, + options: RepoOptions, +): Promise<{ name: string; branch: string }> { + const name = options.name ?? "origin"; + const branch = options.branch; + const commits = options.commits ?? 1; + + await sh`git init -b ${branch} ${name}`; + + for (let i = 0; i < commits; i++) { + await sh`git -C ${name} commit --allow-empty -m ${`commit ${i + 1}`}`; + } + + return { name, branch }; +} diff --git a/src/workspace.ts b/src/workspace.ts index 46ee7ab..ce0ca31 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -1,4 +1,5 @@ import { relative } from "node:path"; +import { Git } from "./git"; interface WorkspaceFolder { path: string; @@ -9,14 +10,10 @@ interface WorkspaceFile { [key: string]: unknown; } -export function workspacePath(root: string, repoName: string): string { - return `${root}/${repoName}.code-workspace`; -} - async function read(path: string): Promise { const file = Bun.file(path); if (await file.exists()) { - return (await file.json()) as WorkspaceFile; + return Bun.JSONC.parse(await file.text()) as WorkspaceFile; } return { folders: [] }; } @@ -25,33 +22,15 @@ async function write(path: string, workspace: WorkspaceFile): Promise { await Bun.write(path, `${JSON.stringify(workspace, null, "\t")}\n`); } -export async function addFolder( - root: string, - repoName: string, - worktreePath: string, -): Promise { - const wsPath = workspacePath(root, repoName); +export async function syncWorkspace(root: string, repoName: string) { + const wsPath = `${root}/${repoName}.code-workspace`; const workspace = await read(wsPath); - const rel = relative(root, worktreePath); - - if (workspace.folders.some((f) => f.path === rel)) { - return; - } - - workspace.folders.push({ path: rel }); - await write(wsPath, workspace); -} - -export async function removeFolder( - root: string, - repoName: string, - worktreePath: string, -): Promise { - const wsPath = workspacePath(root, repoName); - const workspace = await read(wsPath); + const git = new Git().C(root); + const worktrees = await git.listWorktrees(); + workspace.folders = worktrees.map((w) => ({ + path: relative(root, w.path), + })); - const rel = relative(root, worktreePath); - workspace.folders = workspace.folders.filter((f) => f.path !== rel); await write(wsPath, workspace); }