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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/chore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
This template assigns the native GitHub Issue Type `Task`. After this issue is created, create the branch with:
This template assigns the native GitHub Issue Type `Task`. After this issue is created, create its dedicated worktree with:
`pnpm branch chore <issue-number> <short-kebab-slug>`
Before implementation, add the issue to the configured Project and set its `Priority` Field.

Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
This template assigns the native GitHub Issue Type `Task`. After this issue is created, create the branch with:
This template assigns the native GitHub Issue Type `Task`. After this issue is created, create its dedicated worktree with:
`pnpm branch docs <issue-number> <short-kebab-slug>`
Before implementation, add the issue to the configured Project and set its `Priority` Field.

Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
This template assigns the native GitHub Issue Type `Feature`. After this issue is created, create the branch with:
This template assigns the native GitHub Issue Type `Feature`. After this issue is created, create its dedicated worktree with:
`pnpm branch feat <issue-number> <short-kebab-slug>`
Before implementation, add the issue to the configured Project and set its `Priority` Field.

Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/fix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
This template assigns the native GitHub Issue Type `Bug`. After this issue is created, create the branch with:
This template assigns the native GitHub Issue Type `Bug`. After this issue is created, create its dedicated worktree with:
`pnpm branch fix <issue-number> <short-kebab-slug>`
Before implementation, add the issue to the configured Project and set its `Priority` Field.

Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/refactor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
This template assigns the native GitHub Issue Type `Task`. After this issue is created, create the branch with:
This template assigns the native GitHub Issue Type `Task`. After this issue is created, create its dedicated worktree with:
`pnpm branch refactor <issue-number> <short-kebab-slug>`
Before implementation, add the issue to the configured Project and set its `Priority` Field.

Expand Down
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ or commit and perform the work there, leaving unrelated user changes untouched.
Do not copy, move, stash, or discard those changes unless the user explicitly asks
you to do so.

## Canonical checkout and worktrees

Keep the primary checkout on a clean, synchronized `main` branch. Before starting an Issue, update it with:

```sh
git fetch origin main
git switch main
git pull --ff-only origin main
```

Use `pnpm branch <type> <issue-number> <short-kebab-slug>` to create the task branch in `.worktrees/<issue-number>-<short-kebab-slug>` from `origin/main`. The command leaves the canonical checkout and its current branch unchanged. Perform implementation and verification from the new worktree.

## Issue lifecycle

1. Search existing open and recently closed Issues before creating a new one.
Expand Down Expand Up @@ -87,7 +99,7 @@ repository CLI so the branch is valid before work begins:
pnpm branch feat 123 add-sso
```

This creates `feat/123-add-sso` from the current branch. Do not use a
This creates `feat/123-add-sso` in `.worktrees/123-add-sso` from `origin/main` without switching the canonical checkout. Do not use a
product-specific prefix such as `codex/` or `copilot/`. Branches created by
other paths are allowed, but the pull request branch-name check will prevent
them from merging until the name is corrected.
Expand Down
8 changes: 5 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ the work is merged. Do not use classification or priority labels as a
substitute for native Issue Type or Project Fields. See
[`docs/issue-field-guidance.md`](docs/issue-field-guidance.md).

## Canonical checkout and worktrees

Keep the primary checkout on a clean, synchronized `main` branch. Before starting an Issue, update it with `git fetch origin main`, `git switch main`, and `git pull --ff-only origin main`. Create implementation work with `pnpm branch <type> <issue-number> <short-kebab-slug>`; the command creates `.worktrees/<issue-number>-<short-kebab-slug>` from `origin/main` and leaves the canonical checkout unchanged. Work and verify from that dedicated worktree.

## Branches

Create new branches with the repository CLI:
Expand All @@ -57,9 +61,7 @@ Create new branches with the repository CLI:
pnpm branch feat 123 add-sso
```

Supported types are `feat`, `fix`, `refactor`, `docs`, and `chore`. The CLI
creates branches in the format `<type>/<issue-number>-<short-kebab-slug>` and
should be used before work starts so the branch name is valid from the outset.
Supported types are `feat`, `fix`, `refactor`, `docs`, and `chore`. The CLI creates a branch in the format `<type>/<issue-number>-<short-kebab-slug>` and its dedicated worktree before work starts, using `origin/main` as the base so the branch name is valid from the outset.

## Pull requests

Expand Down
112 changes: 96 additions & 16 deletions scripts/new-branch.mjs
Original file line number Diff line number Diff line change
@@ -1,23 +1,84 @@
#!/usr/bin/env node

import { existsSync, mkdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";

const TYPES = new Set(["feat", "fix", "refactor", "docs", "chore"]);
const ISSUE_NUMBER_PATTERN = /^[0-9]+$/;
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const BRANCH_PATTERN = /^(feat|fix|refactor|docs|chore)\/[0-9]+-[a-z0-9]+(?:-[a-z0-9]+)*$/;
const BASE_REF = "origin/main";

function usage() {
return "Usage: pnpm branch <feat|fix|refactor|docs|chore> <issue-number> <short-kebab-slug>";
}

function fail(message) {
console.error(`Error: ${message}`);
console.error("Usage: pnpm branch <feat|fix|refactor|docs|chore> <issue-number> <short-kebab-slug>");
console.error(usage());
console.error("Example: pnpm branch feat 123 add-sso");
process.exit(1);
}

function runGit(args, options = {}) {
const result = spawnSync("git", args, {
encoding: "utf8",
...options,
});

if (result.error) {
throw result.error;
}

return result;
}

function gitOutput(args, cwd) {
const result = runGit(args, { cwd });
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `git ${args.join(" ")} failed`);
}
return result.stdout.trim();
}

function getRepositoryRoot() {
const commonGitDir = gitOutput([
"rev-parse",
"--path-format=absolute",
"--git-common-dir",
]);
return resolve(dirname(commonGitDir));
}

function isRefAvailable(repoRoot, ref) {
return runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
cwd: repoRoot,
stdio: "ignore",
}).status === 0;
}

function isLocalBranchAvailable(repoRoot, branchName) {
return runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd: repoRoot,
stdio: "ignore",
}).status === 0;
}

function getCanonicalStatus(repoRoot) {
return gitOutput([
"-C",
repoRoot,
"status",
"--porcelain",
"--untracked-files=all",
]);
}

const args = process.argv.slice(2);

if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
console.log("Usage: pnpm branch <feat|fix|refactor|docs|chore> <issue-number> <short-kebab-slug>");
console.log(usage());
console.log("Example: pnpm branch feat 123 add-sso");
process.exit(0);
}
Expand Down Expand Up @@ -46,26 +107,45 @@ if (!BRANCH_PATTERN.test(branchName)) {
fail(`generated branch name is invalid: '${branchName}'`);
}

const currentBranch = spawnSync("git", ["branch", "--show-current"], {
encoding: "utf8",
});
let repositoryRoot;
try {
repositoryRoot = getRepositoryRoot();
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}

if (!isRefAvailable(repositoryRoot, BASE_REF)) {
fail(`${BASE_REF} is unavailable; run 'git fetch origin main' and retry`);
}

if (isLocalBranchAvailable(repositoryRoot, branchName)) {
fail(`branch '${branchName}' already exists`);
}

const worktreePath = resolve(repositoryRoot, ".worktrees", `${issueNumber}-${slug}`);

if (currentBranch.error || currentBranch.status !== 0) {
fail("could not determine the current Git branch");
if (existsSync(worktreePath)) {
fail(`worktree path already exists: ${worktreePath}`);
}

if (!currentBranch.stdout.trim()) {
fail("run this command from an existing branch, not a detached HEAD");
const canonicalStatus = getCanonicalStatus(repositoryRoot);
if (canonicalStatus) {
console.warn(
`Warning: canonical checkout ${repositoryRoot} has uncommitted changes; leaving them untouched.`,
);
}

console.log(`Creating branch ${branchName} from ${currentBranch.stdout.trim()}...`);
mkdirSync(resolve(repositoryRoot, ".worktrees"), { recursive: true });

const result = spawnSync("git", ["switch", "-c", branchName], {
stdio: "inherit",
});
console.log(`Creating worktree ${worktreePath} from ${BASE_REF}...`);
const result = runGit(
["worktree", "add", "-b", branchName, worktreePath, BASE_REF],
{ cwd: repositoryRoot, stdio: "inherit" },
);

if (result.error) {
fail(result.error.message);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}

process.exit(result.status ?? 1);
console.log(`Worktree ready: ${worktreePath}`);
console.log(`Branch: ${branchName}`);
108 changes: 108 additions & 0 deletions scripts/new-branch.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import test from "node:test";

const SCRIPT_PATH = fileURLToPath(new URL("./new-branch.mjs", import.meta.url));

function git(cwd, args, options = {}) {
const result = spawnSync("git", args, {
cwd,
encoding: "utf8",
...options,
});

if (result.status !== 0) {
throw new Error(result.stderr?.trim() || `git ${args.join(" ")} failed`);
}

return result.stdout.trim();
}

function createRepository() {
const repository = mkdtempSync(join(tmpdir(), "new-branch-"));
git(repository, ["init", "--quiet", "-b", "main"]);
git(repository, ["config", "user.name", "New Branch Test"]);
git(repository, ["config", "user.email", "new-branch-test@example.com"]);
writeFileSync(join(repository, "README.md"), "initial\n");
git(repository, ["add", "README.md"]);
git(repository, ["commit", "--quiet", "-m", "initial"]);
git(repository, ["update-ref", "refs/remotes/origin/main", "HEAD"]);
return repository;
}

function runBranch(repository, args) {
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], {
cwd: repository,
encoding: "utf8",
});
}

function removeRepository(repository, worktreePath = "") {
if (worktreePath && existsSync(worktreePath)) {
git(repository, ["worktree", "remove", "--force", worktreePath]);
}
rmSync(repository, { recursive: true, force: true });
}

test("creates a worktree from origin/main without switching the canonical checkout", () => {
const repository = createRepository();
const worktreePath = join(repository, ".worktrees", "123-add-sso");

try {
const result = runBranch(repository, ["feat", "123", "add-sso"]);

assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /from origin\/main/);
assert.equal(git(repository, ["branch", "--show-current"]), "main");
assert.equal(existsSync(worktreePath), true);
assert.equal(
git(worktreePath, ["branch", "--show-current"]),
"feat/123-add-sso",
);
assert.equal(
git(worktreePath, ["rev-parse", "HEAD"]),
git(repository, ["rev-parse", "origin/main"]),
);
} finally {
removeRepository(repository, worktreePath);
}
});

test("warns about a dirty canonical checkout and preserves its changes", () => {
const repository = createRepository();
const worktreePath = join(repository, ".worktrees", "24-dirty-safe");
const readmePath = join(repository, "README.md");

try {
writeFileSync(readmePath, "uncommitted change\n");
const result = runBranch(repository, ["chore", "24", "dirty-safe"]);

assert.equal(result.status, 0, result.stderr);
assert.match(result.stderr, /canonical checkout .* uncommitted changes/);
assert.equal(readFileSync(readmePath, "utf8"), "uncommitted change\n");
assert.equal(existsSync(worktreePath), true);
assert.equal(git(repository, ["branch", "--show-current"]), "main");
} finally {
removeRepository(repository, worktreePath);
}
});

test("requires origin/main as the worktree base", () => {
const repository = createRepository();
const worktreePath = join(repository, ".worktrees", "123-missing-base");

try {
git(repository, ["update-ref", "-d", "refs/remotes/origin/main"]);
const result = runBranch(repository, ["feat", "123", "missing-base"]);

assert.equal(result.status, 1);
assert.match(result.stderr, /origin\/main is unavailable/);
assert.equal(existsSync(worktreePath), false);
} finally {
removeRepository(repository);
}
});
Loading