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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ cp examples/rules/working-directory-on-git-worktree-with-baretree.md .cursor/rul

| Command | Description |
|---------|-------------|
| `bt add <branch>` | Add worktree (`-b` for new branch, auto-fetches remotes, `--no-fetch` to skip) |
| `bt add <branch>` | Add worktree (`-b` for new branch, `--base` for base branch/commit, auto-fetches remotes) |

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

This command reference row previously documented --no-fetch but now omits it. If --no-fetch is still supported, consider keeping it in the short description while adding the new --base mention to avoid a docs regression.

Suggested change
| `bt add <branch>` | Add worktree (`-b` for new branch, `--base` for base branch/commit, auto-fetches remotes) |
| `bt add <branch>` | Add worktree (`-b` for new branch, `--base` for base branch/commit, auto-fetches remotes unless \`--no-fetch\`) |

Copilot uses AI. Check for mistakes.
| `bt list` / `bt ls` | List worktrees |
| `bt remove` / `bt rm` | Remove worktree (`--with-branch` to delete branch) |
| `bt cd <name>` | Switch to worktree (`@` for default, `-` for previous) |
Expand Down
10 changes: 9 additions & 1 deletion cmd/bt/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Branch resolution order:

Examples:
bt add -b feature/auth # Creates new branch and worktree
bt add -b feature/new --base abc123 # Creates new branch based on a commit
bt add existing-local-branch # Uses existing local branch
bt add feature/remote # Auto-detects and tracks origin/feature/remote
bt add upstream/feature/test # Tracks upstream/feature/test
Expand All @@ -54,7 +55,7 @@ Examples:

func init() {
addCmd.Flags().BoolVarP(&addNewBranch, "branch", "b", false, "Create new branch")
addCmd.Flags().StringVar(&addBaseBranch, "base", "", "Base branch for new branch (default: HEAD)")
addCmd.Flags().StringVar(&addBaseBranch, "base", "", "Base branch or commit hash for new branch (default: HEAD)")
addCmd.Flags().BoolVar(&addDetach, "detach", false, "Create detached HEAD worktree")
addCmd.Flags().BoolVar(&addForce, "force", false, "Force creation even if worktree exists")
addCmd.Flags().BoolVar(&addNoFetch, "no-fetch", false, "Skip auto-fetch from remotes")
Expand Down Expand Up @@ -114,6 +115,13 @@ func runAdd(cmd *cobra.Command, args []string) error {
} else if baseInfo.IsRemote {
resolvedBaseBranch = baseInfo.RemoteRef
baseDisplayInfo = baseInfo.RemoteRef + " (remote)"
} else if wtMgr.Executor.IsCommitHash(addBaseBranch) {
resolvedBaseBranch = addBaseBranch
shortHash := addBaseBranch
if len(shortHash) > 7 {
shortHash = shortHash[:7]
}
baseDisplayInfo = shortHash + " (commit)"
} else {
return fmt.Errorf("base branch '%s' not found locally or on any remote", addBaseBranch)

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

Now that --base supports commit hashes, this error message is misleading for invalid hashes because it only mentions branches/remotes. Consider updating it to indicate the value wasn’t found as either a branch or a commit, so users understand why a hash-like value failed.

Suggested change
return fmt.Errorf("base branch '%s' not found locally or on any remote", addBaseBranch)
return fmt.Errorf("base '%s' not found as a local/remote branch or commit", addBaseBranch)

Copilot uses AI. Check for mistakes.
}
Expand Down
1 change: 1 addition & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Remote branch operation tests.
| `TestAddNewBranchWithLocalBase` | `--base` with local branch works correctly |
| `TestAddNewBranchWithNonexistentBase` | Error when `--base` specifies non-existent branch |
| `TestAddNewBranchShowsBaseInfo` | Display of base branch information (HEAD fallback) |
| `TestAddNewBranchWithCommitBase` | `--base` with commit hash (full and short) works correctly |

### journey_rename_test.go

Expand Down
45 changes: 45 additions & 0 deletions e2e/journey_remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,48 @@ func TestAddNewBranchShowsBaseInfo(t *testing.T) {
assertOutputContains(t, stdout, "Worktree created")
})
}

// TestAddNewBranchWithCommitBase tests --base with a commit hash
func TestAddNewBranchWithCommitBase(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

tempDir := createTempDir(t, "commit-base")

runBtSuccess(t, tempDir, "repo", "init", "test-repo")
projectDir := filepath.Join(tempDir, "test-repo")
bareDir := filepath.Join(projectDir, ".git")

// Get the full commit hash of HEAD
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = bareDir
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get commit hash: %v", err)
}
fullHash := strings.TrimSpace(string(output))

t.Run("new branch based on full commit hash", func(t *testing.T) {
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/from-commit", "--base", fullHash)

assertOutputContains(t, stdout, "(commit)")
assertOutputContains(t, stdout, "Worktree created")
assertFileExists(t, filepath.Join(projectDir, "feat", "from-commit"))
})

t.Run("new branch based on short commit hash", func(t *testing.T) {
shortHash := fullHash[:7]
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/from-short-commit", "--base", shortHash)

assertOutputContains(t, stdout, "(commit)")
assertOutputContains(t, stdout, "Worktree created")
assertFileExists(t, filepath.Join(projectDir, "feat", "from-short-commit"))
})

t.Run("error when base is invalid commit hash", func(t *testing.T) {
_, stderr := runBtExpectError(t, projectDir, "add", "-b", "feat/bad-hash", "--base", "deadbeef00deadbeef00")

assertOutputContains(t, stderr, "not found")
})
}
6 changes: 6 additions & 0 deletions internal/git/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,9 @@ func (e *Executor) GetUpstreamBehindCount(localBranch string) (int, error) {
}
return count, nil
}

// IsCommitHash checks if the given string resolves to a valid commit object
func (e *Executor) IsCommitHash(ref string) bool {
Comment on lines +172 to +173

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

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

IsCommitHash is named like it validates a hex SHA, but git rev-parse --verify <ref>^{commit} will also succeed for non-hash commit-ish values (e.g. HEAD, tags, or other refs). This makes the API misleading and can unintentionally broaden what --base accepts. Consider either renaming this to something like IsCommitRef/IsCommitish, or tightening the check (e.g., first require the input to match a hex SHA pattern of an acceptable length) so the behavior matches the name/help text.

Suggested change
// IsCommitHash checks if the given string resolves to a valid commit object
func (e *Executor) IsCommitHash(ref string) bool {
// IsCommitHash checks if the given string looks like a hex SHA and resolves to a valid commit object
func (e *Executor) IsCommitHash(ref string) bool {
ref = strings.TrimSpace(ref)
// Require a plausible abbreviated or full SHA-1 (Git allows abbreviations, typically >= 4 chars)
if len(ref) < 4 || len(ref) > 40 {
return false
}
for _, c := range ref {
if !((c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F')) {
return false
}
}

Copilot uses AI. Check for mistakes.
_, err := e.Execute("rev-parse", "--verify", "--quiet", ref+"^{commit}")
return err == nil
}