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
4 changes: 3 additions & 1 deletion docs/internals/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ All fields are optional. Defaults are applied when omitted.
|---|---|---|---|
| `root` | string | `../.repokernel-worktrees` | Root directory for managed worktrees. May be absolute or relative to the main checkout. |
| `branchPrefix` | string | `rk/` | Prefix for managed branches. Epic branches: `<prefix>epic/<epic-id>`. Sprint branches: `<prefix>sprint/<epic-id>/<sprint-id>`. |
| `baseBranch` | string | `main` | Branch used as the base when creating a new epic worktree. |
| `baseBranch` | string | `main` | Branch used as the base when creating a new epic worktree. Must be a legal Git branch name and must sit outside the epic and sprint branch namespaces. |
| `autoAcquire` | boolean | `true` | `rk run` creates or reuses the epic worktree automatically. |
| `branchPattern` | string \| omitted | omitted | Compatibility shorthand. Without `{sprintId}`, applies to epic branches only. With `{sprintId}`, applies to sprint branches only. See below. |
| `epicBranchPattern` | string \| omitted | omitted | Explicit epic branch template. Cannot contain `{sprintId}`. |
Expand Down Expand Up @@ -115,6 +115,8 @@ Prefer explicit `epicBranchPattern` + `sprintBranchPattern` for team-specific na

RepoKernel also renders representative epic and sprint refs at config load and validates the final Git ref strings. This catches unsafe `branchPrefix` values, dot-prefixed path components, `.lock` components, accidental double slashes after token substitution, and epic/sprint ref collisions such as `feature/E-001` plus `feature/E-001/S-001`.

`baseBranch` is validated against the same ref rules and is additionally rejected when it falls inside the namespace a branch pattern generates into — everything up to the pattern's first `{epicId}` or `{sprintId}` token. With the default patterns that rules out `rk/epic/...` and `rk/sprint/...` while leaving a base that merely shares `branchPrefix`, such as `release/current` under prefix `release/`, accepted. RepoKernel creates and deletes branches in those namespaces, so a base inside one makes work merged only into a worktree branch look merged into the project's trunk.

**Examples:**

```yaml
Expand Down
2 changes: 1 addition & 1 deletion docs/internals/specs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ All configured `paths` values must be repo-relative. Absolute paths, NUL bytes,
|---|---|---|---|
| `root` | string | `../.repokernel-worktrees` | Root directory for managed worktrees. May be absolute or relative to the control checkout. |
| `branchPrefix` | string | `rk/` | Prefix for managed branches. Epic branches use `<prefix>epic/<epic-id>`; sprint branches use `<prefix>sprint/<epic-id>/<sprint-id>`. |
| `baseBranch` | string | `main` | Base branch used when creating a new epic worktree branch. |
| `baseBranch` | string | `main` | Base branch used when creating a new epic worktree branch. Rejected at config load when it is not a legal Git branch name, or when it falls inside the epic or sprint branch namespace. |
| `autoAcquire` | boolean | `true` | `rk run` automatically creates/reuses the epic worktree. |
| `branchPattern` | string | omitted | Shorthand branch template. Without `{sprintId}`, applies to epic branches; with `{sprintId}`, applies to sprint branches. Rendered refs are validated at config load. |
| `epicBranchPattern` | string | omitted | Explicit epic branch template. Cannot contain `{sprintId}` and must render to a valid non-colliding Git ref. |
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/test/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,63 @@ describe('worktree naming — branchPattern', () => {
expect(worktreeBranch(eid('E-001'), config)).toBe('feat-2026.q2/E-001_a');
});
});

describe('worktree config — baseBranch', () => {
it('accepts the default base branch', () => {
expect(CONFIG.worktrees.baseBranch).toBe('main');
});

it('rejects a baseBranch inside the epic worktree branch namespace', () => {
expect(() => configWithWorktrees({ baseBranch: 'rk/epic/E-001' })).toThrow();
});

it('rejects a baseBranch inside the sprint worktree branch namespace', () => {
expect(() => configWithWorktrees({ baseBranch: 'rk/sprint/E-001/S-001' })).toThrow();
});

it('rejects a baseBranch in the generated namespace that is not an id', () => {
expect(() => configWithWorktrees({ baseBranch: 'rk/epic/legacy' })).toThrow();
});

it('rejects a baseBranch inside a custom epic branch namespace', () => {
expect(() =>
configWithWorktrees({
epicBranchPattern: 'feature/epic/{epicId}',
sprintBranchPattern: 'feature/sprint/{epicId}/{sprintId}',
baseBranch: 'feature/epic/E-001',
}),
).toThrow();
});

it('rejects a baseBranch equal to a pattern that generates one fixed branch', () => {
expect(() =>
configWithWorktrees({ epicBranchPattern: 'devel', baseBranch: 'devel' }),
).toThrow();
});

it('accepts a baseBranch under branchPrefix that no pattern generates', () => {
const config = configWithWorktrees({ branchPrefix: 'release/', baseBranch: 'release/current' });
expect(config.worktrees.baseBranch).toBe('release/current');
});

it('accepts a baseBranch that only shares a leading segment with the namespace', () => {
const config = configWithWorktrees({ baseBranch: 'rk-mainline' });
expect(config.worktrees.baseBranch).toBe('rk-mainline');
});

it('rejects a baseBranch that is not a legal git ref — leading dash', () => {
expect(() => configWithWorktrees({ baseBranch: '-x' })).toThrow();
});

it('rejects a baseBranch that is not a legal git ref — whitespace', () => {
expect(() => configWithWorktrees({ baseBranch: 'my branch' })).toThrow();
});

it('rejects a baseBranch that is not a legal git ref — `..` range syntax', () => {
expect(() => configWithWorktrees({ baseBranch: 'main..dev' })).toThrow();
});

it('rejects a baseBranch that is not a legal git ref — trailing .lock', () => {
expect(() => configWithWorktrees({ baseBranch: 'main.lock' })).toThrow();
});
});
56 changes: 56 additions & 0 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,33 @@ function refsConflict(a: string, b: string): boolean {
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
}

const ID_TOKEN_RE = /\{(?:epicId|sprintId)\}/;

/**
* The branch namespace a worktree pattern generates into: everything up to its
* first id token, with `{branchPrefix}` substituted.
*
* `isPrefix` is false when the pattern carries no id token at all — it then
* names a single fixed branch rather than a namespace, so containment has to be
* tested as a ref conflict instead of a string prefix.
*
* Cutting at the first id token rather than matching rendered ids keeps the
* test on the namespace RepoKernel claims, so a hand-made `rk/epic/legacy` is
* caught alongside `rk/epic/E-001` while a base that merely shares the
* configured `branchPrefix` (`release/current` under prefix `release/`) is not.
*/
function generatedBranchNamespace(
pattern: string,
branchPrefix: string,
): { readonly value: string; readonly isPrefix: boolean } {
const idToken = ID_TOKEN_RE.exec(pattern);
const head = idToken === null ? pattern : pattern.slice(0, idToken.index);
return {
value: head.replace(/\{branchPrefix\}/g, branchPrefix),
isPrefix: idToken !== null,
};
}

/**
* Validate a `worktrees.branchPattern` template string.
*
Expand Down Expand Up @@ -282,6 +309,14 @@ export const WorktreesSchema = z
});
}

if (!isValidGitBranchRef(value.baseBranch)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['baseBranch'],
message: `baseBranch \`${value.baseBranch}\` is not a valid git branch name`,
});
}

const epicPattern = epicBranchPatternFor(value);
const sprintPattern = sprintBranchPatternFor(value);
if (hasToken(epicPattern, 'sprintId')) {
Expand All @@ -301,6 +336,27 @@ export const WorktreesSchema = z

if (!hasOnlyCurrentTokens(epicPattern) || !hasOnlyCurrentTokens(sprintPattern)) return;

// A base inside the worktree branch namespace makes every worktree branch
// merged into it look merged into trunk, so cleanup deletes work that never
// reached the real base.
for (const [pattern, label] of [
[epicPattern, 'epic'],
[sprintPattern, 'sprint'],
] as const) {
const namespace = generatedBranchNamespace(pattern, value.branchPrefix);
const collides = namespace.isPrefix
? value.baseBranch.startsWith(namespace.value)
: refsConflict(value.baseBranch, namespace.value);
Comment on lines +347 to +349

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle patterns whose first component is an ID token

When an otherwise-valid pattern begins with {epicId} or {sprintId}, generatedBranchNamespace returns an empty prefix, so baseBranch.startsWith('') rejects every possible base branch. For example, epicBranchPattern: "{epicId}/epic", sprintBranchPattern: "{epicId}/sprint/{sprintId}", and baseBranch: "main" can no longer load even though the generated E-<number>/... refs cannot collide with main; handle the empty-prefix case using the rendered ID shape rather than treating it as the entire branch space.

Useful? React with 👍 / 👎.

if (collides) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['baseBranch'],
message: `baseBranch \`${value.baseBranch}\` sits inside the ${label} worktree branch namespace \`${namespace.value}\` — RepoKernel creates and deletes branches there, so the base must live outside it`,
});
break;
}
}

const sampleCtx = { branchPrefix: value.branchPrefix, epicId: 'E-001', sprintId: 'S-001' };
const epicRef = renderBranchPattern(epicPattern, sampleCtx);
const sprintRef = renderBranchPattern(sprintPattern, sampleCtx);
Expand Down