Skip to content

feat(new-change): add --branch flag to auto-create and checkout a git branch#939

Open
alargham-abdalla-delivery-hero wants to merge 1 commit intoFission-AI:mainfrom
alargham-abdalla-delivery-hero:feat/propose-git-branch
Open

feat(new-change): add --branch flag to auto-create and checkout a git branch#939
alargham-abdalla-delivery-hero wants to merge 1 commit intoFission-AI:mainfrom
alargham-abdalla-delivery-hero:feat/propose-git-branch

Conversation

@alargham-abdalla-delivery-hero
Copy link
Copy Markdown

@alargham-abdalla-delivery-hero alargham-abdalla-delivery-hero commented Apr 8, 2026

Summary

  • Adds an optional --branch flag to openspec new change <name> that automatically creates and checks out a git branch named openspec/<change-name> after the change directory is created
  • Introduces src/utils/git-utils.ts with three cross-platform helpers (isGitRepository, createAndCheckoutBranch, getBranchNameForChange) using Node.js built-in child_process.execFileSync — no new dependencies
  • On git failure (not a git repo, branch already exists, git not on PATH), shows a clear warning and exits with code 1, but the change directory creation is never rolled back

Motivation

When starting a new change, developers currently have to manually run git checkout -b <branch> as a second step. The --branch flag makes this a single command:

openspec new change my-feature --branch
# ✔ Created change 'my-feature' at openspec/changes/my-feature/ (schema: spec-driven)
# ✔ Created and checked out branch 'openspec/my-feature'

What Changed

New file: src/utils/git-utils.ts

  • isGitRepository(cwd) — detects git repos via git rev-parse --git-dir
  • createAndCheckoutBranch(cwd, branchName) — runs git checkout -b via execFileSync
  • getBranchNameForChange(changeName) — always returns openspec/<changeName>

Modified: src/commands/workflow/new-change.ts

  • Added branch?: boolean to NewChangeOptions
  • After successful change creation: validates git repo, creates and checks out the branch, shows spinner feedback, handles all error cases gracefully

Modified: src/cli/index.ts

  • Registered --branch flag on the new change command

New file: test/core/commands/new-change-git-branch.test.ts

  • 10 tests covering: isGitRepository (true/false), createAndCheckoutBranch (correct args, throws on failure), getBranchNameForChange (naming convention), and newChangeCommand integration (flag absent, success, not-git-repo, branch-exists)

New: openspec/changes/propose-git-branch/

  • Full OpenSpec change artifacts (proposal, design, spec, tasks) documenting this feature

Design Decisions

Decision Rationale
Branch name: openspec/<change-name> Namespaces branches in git tooling, avoids conflicts with main/feature/etc.
execFileSync directly (no lib) Consistent with existing codebase pattern; avoids adding a dependency for 2 git commands
Change dir creation never fails for git errors Git branching is a convenience side-effect; primary operation must always succeed
git rev-parse --git-dir for detection Fast, cross-platform, exits nonzero outside a git repo

Test Plan

  • pnpm build — no TypeScript errors
  • pnpm test — all 1351 tests pass (10 new tests added)
  • Manual test: openspec new change test-branch-feature --branch created the change and checked out openspec/test-branch-feature
  • Works cross-platform: uses execFileSync with git binary directly (no shell expansion)

🤖 This code was designed and implemented entirely using AI — specifically Claude Sonnet 4.6 (claude-sonnet-4-6) by Anthropic, via the Claude Code CLI. The feature was proposed, specced, implemented, tested, and verified through an AI-driven workflow using OpenSpec's own tooling.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added --branch flag to the openspec new change command. When enabled, the command automatically creates and checks out a git branch named openspec/<change-name> after creating the change directory, with graceful error handling for non-git repositories and existing branches.

… branch

Adds an optional --branch flag to the `openspec new change <name>` command
that, when provided, automatically creates and checks out a new git branch
named `openspec/<change-name>` after the change directory is created.

## What changed

### src/utils/git-utils.ts (new file)
- `isGitRepository(cwd)` — detects git repos via `git rev-parse --git-dir`,
  returns false gracefully when not in a git repo or git is not installed
- `createAndCheckoutBranch(cwd, branchName)` — runs `git checkout -b` via
  `execFileSync` (no shell, cross-platform safe on macOS/Linux/Windows)
- `getBranchNameForChange(changeName)` — derives branch name as
  `openspec/<changeName>`, namespacing branches for easy identification
  in git tooling (GitLens, GitHub, etc.)

### src/commands/workflow/new-change.ts
- Added `branch?: boolean` to `NewChangeOptions` interface
- After successful change directory creation, when `--branch` is set:
  1. Verifies the working directory is a git repository
  2. Creates and checks out `openspec/<change-name>` branch
  3. Shows a dedicated ora spinner for the git step
- On git failure (not a git repo, branch already exists, git not on PATH),
  shows an `ora().warn()` message and sets `process.exitCode = 1`, but does
  NOT roll back the already-created change directory

### src/cli/index.ts
- Registered `--branch` option on the `new change` command
- Commander.js automatically passes it through to `newChangeCommand`

### test/core/commands/new-change-git-branch.test.ts (new file)
- Unit tests for `isGitRepository`: true on success, false when throws
- Unit tests for `createAndCheckoutBranch`: verifies correct `execFileSync`
  args and propagates errors correctly
- Unit tests for `getBranchNameForChange`: correct `openspec/<name>` pattern
- Integration tests for `newChangeCommand`:
  - No git calls when `--branch` is absent
  - Git utilities called and change dir created when `--branch` is true
  - `exitCode=1` set when not in a git repo (change dir still created)
  - `exitCode=1` set when branch already exists (change dir still created)
- macOS symlink path resolution (`/var` → `/private/var`) handled in setup

### openspec/changes/propose-git-branch/ (new OpenSpec change)
- proposal.md, design.md, specs/, tasks.md — full spec-driven change
  artifacts documenting the what, why, and how of this feature

## Design decisions
- Branch name always `openspec/<change-name>` — no custom override
- Uses Node.js built-in `child_process.execFileSync` (no new dependencies)
- Change directory creation is the primary operation; git is a side-effect
  and failures are non-fatal to the change creation itself

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 8, 2026

📝 Walkthrough

Walkthrough

A new optional --branch flag is added to the openspec new change command, creating and checking out a deterministically-named git branch (openspec/<change-name>) after successful change directory creation. A new git-utilities module provides helper functions; specification and design documents detail the feature; comprehensive tests validate behavior across success, non-git-repo, and branch-conflict scenarios.

Changes

Cohort / File(s) Summary
Specification & Design Documents
openspec/changes/propose-git-branch/.openspec.yaml, openspec/changes/propose-git-branch/design.md, openspec/changes/propose-git-branch/proposal.md, openspec/changes/propose-git-branch/specs/new-change-git-branch/spec.md, openspec/changes/propose-git-branch/tasks.md
Introduces new spec-driven feature definition with metadata, design rationale, proposal details, formal specification, and implementation task breakdown for the --branch flag behavior, including error handling for non-git directories and existing branches.
Core Implementation
src/utils/git-utils.ts, src/commands/workflow/new-change.ts, src/cli/index.ts
Adds new git-utils module with helpers (isGitRepository, createAndCheckoutBranch, getBranchNameForChange); extends NewChangeOptions interface with optional branch flag; integrates conditional branch creation post-scaffolding in new-change command with spinner and error handling; registers --branch CLI option.
Test Coverage
test/core/commands/new-change-git-branch.test.ts
Adds comprehensive test suite covering git-utils unit behavior, newChangeCommand integration with mocked execFileSync, flag-absent paths, non-git-repo failures, and existing-branch error scenarios.

Sequence Diagram

sequenceDiagram
    actor User
    participant CLI
    participant NewChangeCommand
    participant FileSystem
    participant GitUtils
    participant Git

    User->>CLI: openspec new change <name> --branch
    CLI->>NewChangeCommand: invoke with branch=true
    NewChangeCommand->>FileSystem: create change directory
    NewChangeCommand->>NewChangeCommand: emit success message
    
    alt branch flag is true
        NewChangeCommand->>GitUtils: isGitRepository(cwd)
        GitUtils->>Git: git rev-parse --git-dir
        Git-->>GitUtils: success/failure
        
        alt is git repository
            NewChangeCommand->>GitUtils: getBranchNameForChange(name)
            GitUtils-->>NewChangeCommand: openspec/<name>
            NewChangeCommand->>GitUtils: createAndCheckoutBranch(cwd, branchName)
            GitUtils->>Git: git checkout -b openspec/<name>
            Git-->>GitUtils: success/error
            alt branch created
                GitUtils-->>NewChangeCommand: success
                NewChangeCommand->>User: branch created and checked out
            else branch exists or git unavailable
                GitUtils-->>NewChangeCommand: throws error
                NewChangeCommand->>User: emit warning, exit code 1
            end
        else not a git repository
            NewChangeCommand->>User: emit warning, exit code 1
        end
    else branch flag is false
        NewChangeCommand->>User: done (no git operations)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • TabishB

Poem

🐰 A branch blooms in the git garden fair,
With --branch flag, planted with care,
openspec/<name> springs to life,
No manual checkout, no CLI strife!
New changes now leap to their own verdant bough. 🌿

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a --branch flag to the new-change command that creates and checks out a git branch.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/core/commands/new-change-git-branch.test.ts (2)

122-122: Unused variable oraOutput.

This array is declared but never populated or asserted against. If spinner output verification is not needed, remove it; otherwise, implement the capture logic.

🧹 Remove unused variable
   let originalCwd: string;
   let originalExitCode: number | undefined;
-  let oraOutput: string[];

   beforeEach(async () => {
     ...
-    oraOutput = [];
     isGitRepositoryMock.mockReset();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/core/commands/new-change-git-branch.test.ts` at line 122, The test
declares an unused array oraOutput; remove this dead declaration or implement
spinner capture logic: either delete the let oraOutput: string[] declaration
from new-change-git-branch.test.ts, or if you intend to assert spinner output,
initialize and populate oraOutput by mocking the spinner (where the test uses
ora/spinner mocks) and push spinner messages into oraOutput before asserting;
update assertions to reference oraOutput accordingly.

45-54: Tests verify the mock, not the real implementation.

The getBranchNameForChange imported at line 35 is the mocked version (from getBranchNameForChangeMock at line 22). These tests pass because the mock is hardcoded to return openspec/${name}, not because the real function works correctly.

Unlike the isGitRepository and createAndCheckoutBranch tests which use vi.importActual to test the real implementations, this test provides no actual coverage for getBranchNameForChange.

♻️ Suggested fix using vi.importActual
 describe('getBranchNameForChange', () => {
+  // Import the real implementation bypassing the vi.mock
+  const { getBranchNameForChange: realGetBranchNameForChange } = await vi.importActual<
+    typeof import('../../../src/utils/git-utils.js')
+  >('../../../src/utils/git-utils.js');
+
   it('returns openspec/<changeName>', () => {
-    // Use the real function by importing from the mocked module with passthrough
-    expect(getBranchNameForChange('my-feature')).toBe('openspec/my-feature');
+    expect(realGetBranchNameForChange('my-feature')).toBe('openspec/my-feature');
   });

   it('works for multi-segment change names', () => {
-    expect(getBranchNameForChange('add-user-auth')).toBe('openspec/add-user-auth');
+    expect(realGetBranchNameForChange('add-user-auth')).toBe('openspec/add-user-auth');
   });
 });

Alternatively, convert to an async describe block like the other unit tests (lines 60, 88) to use vi.importActual.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/core/commands/new-change-git-branch.test.ts` around lines 45 - 54, The
tests are exercising the mocked getBranchNameForChangeMock rather than the real
implementation; update the test to import the actual function before assertions
(either convert the describe block to an async describe and use const {
getBranchNameForChange } = await vi.importActual('...') or call vi.importActual
inside the it blocks) so that getBranchNameForChange (not
getBranchNameForChangeMock) is the real implementation under test; replace
usages of the mock with the real getBranchNameForChange and keep assertions the
same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/workflow/new-change.ts`:
- Around line 72-79: The branch creation error handling in the block that sets
`message` (used to decide warnings via `branchSpinner.warn`) only inspects
`error.message`, which is brittle because `execFileSync(..., { stdio: 'pipe' })`
in `git-utils.ts` surfaces process stderr on the thrown error as `error.stderr`;
update the logic that computes `message` (the variable around
`branchSpinner.warn` and references to `branchName`) to coalesce `error.message`
and the stringified `error.stderr` (handle Buffer -> string) into one diagnostic
string and then use that combined string to test for "already exists", "not
found", "ENOENT" etc., so classification uses actual git stderr as well as the
JS message.

---

Nitpick comments:
In `@test/core/commands/new-change-git-branch.test.ts`:
- Line 122: The test declares an unused array oraOutput; remove this dead
declaration or implement spinner capture logic: either delete the let oraOutput:
string[] declaration from new-change-git-branch.test.ts, or if you intend to
assert spinner output, initialize and populate oraOutput by mocking the spinner
(where the test uses ora/spinner mocks) and push spinner messages into oraOutput
before asserting; update assertions to reference oraOutput accordingly.
- Around line 45-54: The tests are exercising the mocked
getBranchNameForChangeMock rather than the real implementation; update the test
to import the actual function before assertions (either convert the describe
block to an async describe and use const { getBranchNameForChange } = await
vi.importActual('...') or call vi.importActual inside the it blocks) so that
getBranchNameForChange (not getBranchNameForChangeMock) is the real
implementation under test; replace usages of the mock with the real
getBranchNameForChange and keep assertions the same.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e46a0794-645b-4490-a5cd-e2cdbf29725d

📥 Commits

Reviewing files that changed from the base of the PR and between 64d476f and 3c34278.

📒 Files selected for processing (9)
  • openspec/changes/propose-git-branch/.openspec.yaml
  • openspec/changes/propose-git-branch/design.md
  • openspec/changes/propose-git-branch/proposal.md
  • openspec/changes/propose-git-branch/specs/new-change-git-branch/spec.md
  • openspec/changes/propose-git-branch/tasks.md
  • src/cli/index.ts
  • src/commands/workflow/new-change.ts
  • src/utils/git-utils.ts
  • test/core/commands/new-change-git-branch.test.ts

Comment on lines +72 to +79
const message = (error as Error).message ?? String(error);
if (message.includes('already exists')) {
branchSpinner.warn(`Branch '${branchName}' already exists`);
} else if (message.includes('not found') || message.includes('ENOENT')) {
branchSpinner.warn(`Branch not created: git not found on PATH`);
} else {
branchSpinner.warn(`Branch not created: ${message}`);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Node.js v20+, for child_process.execFileSync('git', args, { stdio: 'pipe' }), when the command exits non-zero, is diagnostic output expected in error.stderr rather than reliably in error.message?

💡 Result:

Yes. With execFileSync(..., { stdio: 'pipe' }), when the child exits non‑zero Node throws an Error that includes the full spawnSync() result, i.e. you should expect the captured output to be available on err.stdout / err.stderr (as Buffers unless you set encoding). Node’s docs explicitly describe handling the failure case by reading const { stdout, stderr } = err; rather than relying on err.message. [1]

err.message is just a formatted diagnostic string (e.g., “Command failed: …”) and is not specified to reliably contain the process’s stderr output, so treating err.stderr as the authoritative diagnostic output is the correct/expected approach. [1][2]

Sources:
[1] Node.js docs: child_process.execFileSync() error includes full spawnSync() result; example reads err.stdout/err.stderr. (nodejs.org)
[2] Discussion noting stderr is available via spawnSync/sync error properties rather than “catching” a message string. (stackoverflow.com)

Citations:


🏁 Script executed:

# First, locate and read the file to understand the context around lines 72-79
cat -n src/commands/workflow/new-change.ts | sed -n '50,90p'

Repository: Fission-AI/OpenSpec

Length of output: 1926


🏁 Script executed:

# Search for the createAndCheckoutBranch function definition
rg -n "createAndCheckoutBranch" src/commands/workflow/new-change.ts -A 2 -B 2

Repository: Fission-AI/OpenSpec

Length of output: 551


🏁 Script executed:

# Find git-utils.js
find src -name "git-utils.js" -o -name "git-utils.ts"

Repository: Fission-AI/OpenSpec

Length of output: 85


🏁 Script executed:

# Read the git-utils.ts file to find createAndCheckoutBranch implementation
cat -n src/utils/git-utils.ts

Repository: Fission-AI/OpenSpec

Length of output: 1152


Use error.stderr for git failure classification, not only error.message.

With execFileSync(..., { stdio: 'pipe' }) at line 21 of git-utils.ts, when git exits non-zero, Node.js throws an error with the full spawnSync() result including error.stderr (as a Buffer). The current error handling at lines 72–79 only checks error.message, which is a formatted diagnostic string not specified to reliably contain the process's stderr output. This makes detection of "already exists" and "git not found" brittle and prone to misfiring.

Combine error.message and error.stderr to build a comprehensive diagnostic string for error classification:

Suggested fix
       } catch (error) {
-        const message = (error as Error).message ?? String(error);
-        if (message.includes('already exists')) {
+        const err = error as NodeJS.ErrnoException & { stderr?: Buffer | string };
+        const message = err.message ?? String(error);
+        const stderrText = Buffer.isBuffer(err.stderr) ? err.stderr.toString('utf8') : (err.stderr ?? '');
+        const diagnostic = `${message}\n${stderrText}`.toLowerCase();
+
+        if (diagnostic.includes('already exists')) {
           branchSpinner.warn(`Branch '${branchName}' already exists`);
-        } else if (message.includes('not found') || message.includes('ENOENT')) {
+        } else if (diagnostic.includes('not found') || diagnostic.includes('enoent')) {
           branchSpinner.warn(`Branch not created: git not found on PATH`);
         } else {
-          branchSpinner.warn(`Branch not created: ${message}`);
+          branchSpinner.warn(`Branch not created: ${stderrText || message}`);
         }
         process.exitCode = 1;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const message = (error as Error).message ?? String(error);
if (message.includes('already exists')) {
branchSpinner.warn(`Branch '${branchName}' already exists`);
} else if (message.includes('not found') || message.includes('ENOENT')) {
branchSpinner.warn(`Branch not created: git not found on PATH`);
} else {
branchSpinner.warn(`Branch not created: ${message}`);
}
const err = error as NodeJS.ErrnoException & { stderr?: Buffer | string };
const message = err.message ?? String(error);
const stderrText = Buffer.isBuffer(err.stderr) ? err.stderr.toString('utf8') : (err.stderr ?? '');
const diagnostic = `${message}\n${stderrText}`.toLowerCase();
if (diagnostic.includes('already exists')) {
branchSpinner.warn(`Branch '${branchName}' already exists`);
} else if (diagnostic.includes('not found') || diagnostic.includes('enoent')) {
branchSpinner.warn(`Branch not created: git not found on PATH`);
} else {
branchSpinner.warn(`Branch not created: ${stderrText || message}`);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/workflow/new-change.ts` around lines 72 - 79, The branch
creation error handling in the block that sets `message` (used to decide
warnings via `branchSpinner.warn`) only inspects `error.message`, which is
brittle because `execFileSync(..., { stdio: 'pipe' })` in `git-utils.ts`
surfaces process stderr on the thrown error as `error.stderr`; update the logic
that computes `message` (the variable around `branchSpinner.warn` and references
to `branchName`) to coalesce `error.message` and the stringified `error.stderr`
(handle Buffer -> string) into one diagnostic string and then use that combined
string to test for "already exists", "not found", "ENOENT" etc., so
classification uses actual git stderr as well as the JS message.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant