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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ wkb exec <name> -- <cmd...> # run a command in a sandbox

## Config

Looks for config in:
Looks for global config in:

1. `$XDG_CONFIG_HOME/workbox/config.toml`
2. `~/.workbox/config.toml` when `$XDG_CONFIG_HOME` is not set

Then looks for project config in:

1. `.workbox/config.toml`
2. `workbox.toml`

Config is required. Paths are resolved relative to the repo root.
Config is required from at least one global or project location. Global config provides defaults;
project config overrides only the settings it defines. Paths are resolved relative to the repo root.
`worktrees.directory` must resolve within the repo root.

Example:
Expand Down
30 changes: 30 additions & 0 deletions global-config-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Global Config Resolution

## Problem
Workbox users must create or copy the same configuration into every repository before they
can use the CLI. This adds repetitive setup friction, especially when trying Workbox in a
new repository with otherwise standard preferences.

## Desired Outcome
Users can define shared Workbox defaults once and have them apply across repositories, while
still being able to customize behavior for an individual project when needed.

## Acceptance Criteria
1. A user can place shared configuration in the standard global Workbox config location.
2. If no platform-specific global config directory is configured, Workbox looks for shared
configuration at `~/.workbox/config.toml`.
3. A user with only a global Workbox configuration can run commands in a repository that has
no project configuration.
4. A user with only a project Workbox configuration continues to get the same behavior they
get today.
5. A user with both global and project configuration gets project-specific behavior where
the project configuration intentionally differs from the global defaults.
6. A user with both global and project configuration gets global defaults for settings the
project has not customized.
7. A user with neither global nor project configuration receives a clear error explaining
that Workbox configuration is missing.
8. Invalid global configuration is reported clearly when it is needed to run the command.
9. Invalid project configuration is reported clearly and is not hidden by the presence of a
valid global configuration.
10. Existing repositories with project configuration do not need to change their configuration
to keep working.
128 changes: 121 additions & 7 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ enabled = false
steps = []
`;

const loadTestConfig = (cwd: string, homeDir = join(cwd, "home")) =>
loadConfig(cwd, { env: {}, homeDir });

describe("loadConfig", () => {
it("rejects when no config exists", async () => {
await withTempDir(async (cwd) => {
await expect(loadConfig(cwd)).rejects.toThrow(/No workbox config found/);
await expect(loadTestConfig(cwd)).rejects.toThrow(/No workbox config found/);
});
});

Expand All @@ -42,16 +45,127 @@ describe("loadConfig", () => {
minimalConfig.replace('.workbox/worktrees"', 'fallback"')
);

const result = await loadConfig(cwd);
const result = await loadTestConfig(cwd);
expect(result.path).toBe(join(cwd, ".workbox", "config.toml"));
expect(result.config.worktrees.directory).toBe(join(cwd, "sandbox"));
});
});

it("loads global config from the fallback home path when no project config exists", 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.replace('.workbox/worktrees"', 'global-worktrees"')
);

const result = await loadTestConfig(cwd, homeDir);
expect(result.path).toBe(join(homeDir, ".workbox", "config.toml"));
expect(result.config.worktrees.directory).toBe(join(cwd, "global-worktrees"));
});
});

it("loads global config from XDG_CONFIG_HOME when it is set", async () => {
await withTempDir(async (cwd) => {
const configHome = join(cwd, "xdg");
await mkdir(join(configHome, "workbox"), { recursive: true });
await writeFile(
join(configHome, "workbox", "config.toml"),
minimalConfig.replace('branch_prefix = "wkb/"', 'branch_prefix = "global/"')
);

const result = await loadConfig(cwd, {
env: { XDG_CONFIG_HOME: configHome },
homeDir: join(cwd, "home"),
});
expect(result.path).toBe(join(configHome, "workbox", "config.toml"));
expect(result.config.worktrees.branch_prefix).toBe("global/");
});
});

it("merges project config over global config", async () => {
await withTempDir(async (cwd) => {
const homeDir = join(cwd, "home");
await mkdir(join(homeDir, ".workbox"), { recursive: true });
await mkdir(join(cwd, ".workbox"), { recursive: true });
await writeFile(
join(homeDir, ".workbox", "config.toml"),
minimalConfig
.replace('.workbox/worktrees"', 'global-worktrees"')
.replace('branch_prefix = "wkb/"', 'branch_prefix = "global/"')
);
await writeFile(
join(cwd, ".workbox", "config.toml"),
`[worktrees]
branch_prefix = "local/"
`
);

const result = await loadTestConfig(cwd, homeDir);
expect(result.path).toBe(join(cwd, ".workbox", "config.toml"));
expect(result.config.worktrees.directory).toBe(join(cwd, "global-worktrees"));
expect(result.config.worktrees.branch_prefix).toBe("local/");
});
});

it("merges project dev config over global dev 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}
[dev]
command = "bun run dev"
open = "open http://localhost:3000"
`
);
await writeFile(
join(cwd, "workbox.toml"),
`[dev]
open = "open http://localhost:4000"
`
);

const result = await loadTestConfig(cwd, homeDir);
expect(result.config.dev).toEqual({
command: "bun run dev",
open: "open http://localhost:4000",
});
});
});

it("rejects when merged global and project config is incomplete", async () => {
await withTempDir(async (cwd) => {
const homeDir = join(cwd, "home");
await mkdir(join(homeDir, ".workbox"), { recursive: true });
await writeFile(
join(homeDir, ".workbox", "config.toml"),
`[worktrees]
directory = ".workbox/worktrees"
`
);

await expect(loadTestConfig(cwd, homeDir)).rejects.toThrow(/worktrees.branch_prefix/);
});
});

it("rejects invalid project config even when global config is valid", 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);
await writeFile(join(cwd, "workbox.toml"), `[worktrees]\ndirectory = 123\n`);

await expect(loadTestConfig(cwd, homeDir)).rejects.toThrow(/workbox\.toml/);
});
});

it("rejects invalid TOML", async () => {
await withTempDir(async (cwd) => {
await writeFile(join(cwd, "workbox.toml"), "=broken");
await expect(loadConfig(cwd)).rejects.toThrow(/Invalid TOML/);
await expect(loadTestConfig(cwd)).rejects.toThrow(/Invalid TOML/);
});
});

Expand All @@ -61,7 +175,7 @@ describe("loadConfig", () => {
join(cwd, "workbox.toml"),
minimalConfig.replace('directory = ".workbox/worktrees"', "directory = 123")
);
await expect(loadConfig(cwd)).rejects.toThrow(/worktrees.directory/);
await expect(loadTestConfig(cwd)).rejects.toThrow(/worktrees.directory/);
});
});

Expand All @@ -71,7 +185,7 @@ describe("loadConfig", () => {
join(cwd, "workbox.toml"),
minimalConfig.replace('directory = ".workbox/worktrees"', 'directory = "../worktrees"')
);
await expect(loadConfig(cwd)).rejects.toThrow(/must be within repo root/);
await expect(loadTestConfig(cwd)).rejects.toThrow(/must be within repo root/);
});
});

Expand All @@ -87,7 +201,7 @@ describe("loadConfig", () => {
)
.replace("enabled = false", "enabled = true");
await writeFile(join(cwd, "workbox.toml"), duplicateConfig);
await expect(loadConfig(cwd)).rejects.toThrow(/Duplicate bootstrap step name/);
await expect(loadTestConfig(cwd)).rejects.toThrow(/Duplicate bootstrap step name/);
});
});

Expand All @@ -97,7 +211,7 @@ describe("loadConfig", () => {
try {
await symlink(outside, join(cwd, ".workbox"));
await writeFile(join(cwd, "workbox.toml"), minimalConfig);
await expect(loadConfig(cwd)).rejects.toThrow(/escapes repo root via symlink/);
await expect(loadTestConfig(cwd)).rejects.toThrow(/escapes repo root via symlink/);
} finally {
await rm(outside, { recursive: true, force: true });
}
Expand Down
Loading