diff --git a/.github/ISSUE_TEMPLATE/chore.yml b/.github/ISSUE_TEMPLATE/chore.yml index a5f6d6c..9a2d419 100644 --- a/.github/ISSUE_TEMPLATE/chore.yml +++ b/.github/ISSUE_TEMPLATE/chore.yml @@ -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 ` Before implementation, add the issue to the configured Project and set its `Priority` Field. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml index 3a1e816..dcb6260 100644 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -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 ` Before implementation, add the issue to the configured Project and set its `Priority` Field. diff --git a/.github/ISSUE_TEMPLATE/feat.yml b/.github/ISSUE_TEMPLATE/feat.yml index ffd07c1..1bdf0e2 100644 --- a/.github/ISSUE_TEMPLATE/feat.yml +++ b/.github/ISSUE_TEMPLATE/feat.yml @@ -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 ` Before implementation, add the issue to the configured Project and set its `Priority` Field. diff --git a/.github/ISSUE_TEMPLATE/fix.yml b/.github/ISSUE_TEMPLATE/fix.yml index 50d34c2..1e41af7 100644 --- a/.github/ISSUE_TEMPLATE/fix.yml +++ b/.github/ISSUE_TEMPLATE/fix.yml @@ -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 ` Before implementation, add the issue to the configured Project and set its `Priority` Field. diff --git a/.github/ISSUE_TEMPLATE/refactor.yml b/.github/ISSUE_TEMPLATE/refactor.yml index 857252e..6e7ada6 100644 --- a/.github/ISSUE_TEMPLATE/refactor.yml +++ b/.github/ISSUE_TEMPLATE/refactor.yml @@ -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 ` Before implementation, add the issue to the configured Project and set its `Priority` Field. diff --git a/AGENTS.md b/AGENTS.md index 6a83f79..431e72f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` to create the task branch in `.worktrees/-` 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. @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e04856..4ae30ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `; the command creates `.worktrees/-` from `origin/main` and leaves the canonical checkout unchanged. Work and verify from that dedicated worktree. + ## Branches Create new branches with the repository CLI: @@ -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 `/-` 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 `/-` and its dedicated worktree before work starts, using `origin/main` as the base so the branch name is valid from the outset. ## Pull requests diff --git a/scripts/new-branch.mjs b/scripts/new-branch.mjs index ee9f5e2..757272d 100644 --- a/scripts/new-branch.mjs +++ b/scripts/new-branch.mjs @@ -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 "; +} function fail(message) { console.error(`Error: ${message}`); - console.error("Usage: pnpm branch "); + 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 "); + console.log(usage()); console.log("Example: pnpm branch feat 123 add-sso"); process.exit(0); } @@ -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}`); diff --git a/scripts/new-branch.test.mjs b/scripts/new-branch.test.mjs new file mode 100644 index 0000000..16b7ca9 --- /dev/null +++ b/scripts/new-branch.test.mjs @@ -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); + } +});