Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ wkb --version
## Use

```sh
wkb new <name> [--from <ref>] # create sandbox worktree
wkb new <name> [--from <ref>] # create and provision sandbox worktree
wkb rm <name> [--force] [--unmanaged] [--delete-branch] # remove worktree
wkb list # list workbox worktrees
wkb prune # prune stale git worktree metadata
Expand Down Expand Up @@ -60,12 +60,35 @@ steps = [
{ name = "build", run = "bun run build" }
]

[provision]
enabled = true

[[provision.copy]]
from = ".env"
to = ".env"

[[provision.copy]]
from = ".env.local"
to = ".env.local"
required = false

[[provision.steps]]
name = "generate"
run = "bun run generate"

[dev]
command = "bun run dev"
# Optional (explicit opt-in): open an editor when running `wkb dev`.
# open = "code ."
```

Provision runs automatically after `wkb new` creates a worktree. Copy sources resolve from the
current worktree where `wkb new` is run, copy destinations and steps run inside the new worktree,
and missing copied files are skipped unless `required = true`.

Bootstrap is separate: `wkb setup` runs bootstrap in the current worktree, and `wkb dev <name>`
runs bootstrap in the named sandbox before the dev command.

## Development

```sh
Expand Down
62 changes: 61 additions & 1 deletion src/commands/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, spyOn } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -65,6 +65,7 @@ const buildConfig = (
baseRef?: string;
bootstrapEnabled?: boolean;
bootstrapSteps?: ResolvedWorkboxConfig["bootstrap"]["steps"];
provision?: ResolvedWorkboxConfig["provision"];
dev?: ResolvedWorkboxConfig["dev"];
}
): ResolvedWorkboxConfig => {
Expand All @@ -78,6 +79,11 @@ const buildConfig = (
enabled: options?.bootstrapEnabled ?? false,
steps: options?.bootstrapSteps ?? [],
},
provision: options?.provision ?? {
enabled: false,
copy: [],
steps: [],
},
...(options?.dev ? { dev: options.dev } : {}),
};
};
Expand Down Expand Up @@ -150,6 +156,60 @@ describe("new command", () => {
expect(result.message).toContain('Created worktree "box1"');
});
});

it("provisions configured files after creating a worktree", async () => {
await withRepo(async (repoRoot) => {
await writeFile(join(repoRoot, ".env"), "TOKEN=local\n");
const context = buildContext(
repoRoot,
buildConfig(repoRoot, {
baseRef: "HEAD",
provision: {
enabled: true,
copy: [{ from: ".env", to: ".env", required: false }],
steps: [],
},
}),
{ json: true }
);

const result = await newCommand.run(context, ["box1"]);
const worktreePath = join(repoRoot, ".workbox", "worktrees", "box1");

expect(result.exitCode).toBeUndefined();
expect(result.data).toEqual(
expect.objectContaining({
worktree: expect.objectContaining({ name: "box1" }),
provision: expect.objectContaining({ status: "ok" }),
})
);
expect(await readFile(join(worktreePath, ".env"), "utf8")).toBe("TOKEN=local\n");
});
});

it("keeps the worktree when provisioning fails", async () => {
await withRepo(async (repoRoot) => {
const context = buildContext(
repoRoot,
buildConfig(repoRoot, {
baseRef: "HEAD",
provision: {
enabled: true,
copy: [{ from: ".env", to: ".env", required: true }],
steps: [],
},
}),
{ json: true }
);

const result = await newCommand.run(context, ["box1"]);
const worktreePath = join(repoRoot, ".workbox", "worktrees", "box1");

expect(result.exitCode).toBe(1);
expect(result.message).toContain("missing required source");
expect(await readFile(join(worktreePath, "README.md"), "utf8")).toBe("hello\n");
});
});
});

describe("list command", () => {
Expand Down
21 changes: 20 additions & 1 deletion src/commands/new.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createWorktree } from "../core/git";
import { runProvision } from "../provision/runner";
import { UsageError } from "../ui/errors";
import { parseArgsOrUsage } from "./parse";
import type { CommandDefinition } from "./types";
Expand Down Expand Up @@ -46,9 +47,27 @@ export const newCommand: CommandDefinition = {
baseRef,
});

let provisionResult: Awaited<ReturnType<typeof runProvision>> | undefined;
if (context.config.provision.enabled) {
provisionResult = await runProvision(context.config.provision, {
sourceRoot: context.worktreeRoot,
targetRoot: worktree.path,
worktreeName: worktree.name,
mode: context.flags.json ? "capture" : "inherit",
});

if (provisionResult.exitCode !== 0) {
return {
message: provisionResult.message,
data: { worktree, provision: provisionResult },
exitCode: provisionResult.exitCode,
};
}
}

return {
message: `Created worktree "${worktree.name}" at ${worktree.path} on branch ${worktree.managedBranch}.`,
data: worktree,
data: provisionResult ? { worktree, provision: provisionResult } : worktree,
};
},
};
35 changes: 35 additions & 0 deletions src/commands/rm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ describe("rm command", () => {
enabled: false,
steps: [],
},
provision: {
enabled: false,
copy: [],
steps: [],
},
};

const context = {
Expand Down Expand Up @@ -108,6 +113,11 @@ describe("rm command", () => {
enabled: false,
steps: [],
},
provision: {
enabled: false,
copy: [],
steps: [],
},
};

const context = {
Expand Down Expand Up @@ -139,6 +149,11 @@ describe("rm command", () => {
enabled: false,
steps: [],
},
provision: {
enabled: false,
copy: [],
steps: [],
},
};

const context = {
Expand Down Expand Up @@ -173,6 +188,11 @@ describe("rm command", () => {
enabled: false,
steps: [],
},
provision: {
enabled: false,
copy: [],
steps: [],
},
};

await createWorktree({
Expand Down Expand Up @@ -218,6 +238,11 @@ describe("rm command", () => {
enabled: false,
steps: [],
},
provision: {
enabled: false,
copy: [],
steps: [],
},
};

await createWorktree({
Expand Down Expand Up @@ -264,6 +289,11 @@ describe("rm command", () => {
enabled: false,
steps: [],
},
provision: {
enabled: false,
copy: [],
steps: [],
},
};

const created = await createWorktree({
Expand Down Expand Up @@ -314,6 +344,11 @@ describe("rm command", () => {
enabled: false,
steps: [],
},
provision: {
enabled: false,
copy: [],
steps: [],
},
};

const created = await createWorktree({
Expand Down
91 changes: 91 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ describe("loadConfig", () => {
});
});

it("defaults provision to disabled when it is not configured", async () => {
await withTempDir(async (cwd) => {
await writeFile(join(cwd, "workbox.toml"), minimalConfig);

const result = await loadTestConfig(cwd);

expect(result.config.provision).toEqual({
enabled: false,
copy: [],
steps: [],
});
});
});

it("loads global config from the fallback home path when no project config exists", async () => {
await withTempDir(async (cwd) => {
const homeDir = join(cwd, "home");
Expand Down Expand Up @@ -136,6 +150,47 @@ open = "open http://localhost:4000"
});
});

it("merges project provision config over global provision config", async () => {
await withTempDir(async (cwd) => {
const homeDir = join(cwd, "home");
await mkdir(join(homeDir, ".workbox"), { recursive: true });
await writeFile(
join(homeDir, ".workbox", "config.toml"),
`${minimalConfig}
[provision]
enabled = true

[[provision.copy]]
from = ".env"
to = ".env"
required = true

[[provision.steps]]
name = "global"
run = "echo global"
`
);
await writeFile(
join(cwd, "workbox.toml"),
`[provision]
enabled = false

[[provision.copy]]
from = ".env.local"
to = ".env.local"
`
);

const result = await loadTestConfig(cwd, homeDir);

expect(result.config.provision).toEqual({
enabled: false,
copy: [{ from: ".env.local", to: ".env.local", required: false }],
steps: [{ name: "global", run: "echo global" }],
});
});
});

it("rejects when merged global and project config is incomplete", async () => {
await withTempDir(async (cwd) => {
const homeDir = join(cwd, "home");
Expand Down Expand Up @@ -205,6 +260,42 @@ directory = ".workbox/worktrees"
});
});

it("rejects duplicate provision step names", async () => {
await withTempDir(async (cwd) => {
await writeFile(
join(cwd, "workbox.toml"),
`${minimalConfig}
[provision]
enabled = true
steps = [
{ name = "copy", run = "echo one" },
{ name = "copy", run = "echo two" }
]
`
);

await expect(loadTestConfig(cwd)).rejects.toThrow(/Duplicate provision step name/);
});
});

it("rejects invalid provision copy entries", async () => {
await withTempDir(async (cwd) => {
await writeFile(
join(cwd, "workbox.toml"),
`${minimalConfig}
[provision]
enabled = true

[[provision.copy]]
from = ""
to = ".env"
`
);

await expect(loadTestConfig(cwd)).rejects.toThrow(/provision.copy.0.from/);
});
});

it("rejects worktree directory that escapes the repo via symlink", async () => {
await withTempDir(async (cwd) => {
const outside = await mkdtemp(join(tmpdir(), "workbox-outside-"));
Expand Down
Loading