Skip to content

Commit cd61d96

Browse files
committed
feat: support commit hash in --base flag for bt add
The --base flag previously only accepted branch names (local or remote). Now it also accepts commit hashes (full or short SHA), falling back to git rev-parse verification when branch resolution fails.
1 parent 0fe253c commit cd61d96

5 files changed

Lines changed: 62 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ cp examples/rules/working-directory-on-git-worktree-with-baretree.md .cursor/rul
326326

327327
| Command | Description |
328328
|---------|-------------|
329-
| `bt add <branch>` | Add worktree (`-b` for new branch, auto-fetches remotes, `--no-fetch` to skip) |
329+
| `bt add <branch>` | Add worktree (`-b` for new branch, `--base` for base branch/commit, auto-fetches remotes) |
330330
| `bt list` / `bt ls` | List worktrees |
331331
| `bt remove` / `bt rm` | Remove worktree (`--with-branch` to delete branch) |
332332
| `bt cd <name>` | Switch to worktree (`@` for default, `-` for previous) |

cmd/bt/add.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Branch resolution order:
4444
4545
Examples:
4646
bt add -b feature/auth # Creates new branch and worktree
47+
bt add -b feature/new --base abc123 # Creates new branch based on a commit
4748
bt add existing-local-branch # Uses existing local branch
4849
bt add feature/remote # Auto-detects and tracks origin/feature/remote
4950
bt add upstream/feature/test # Tracks upstream/feature/test
@@ -54,7 +55,7 @@ Examples:
5455

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

e2e/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ Remote branch operation tests.
134134
| `TestAddNewBranchWithLocalBase` | `--base` with local branch works correctly |
135135
| `TestAddNewBranchWithNonexistentBase` | Error when `--base` specifies non-existent branch |
136136
| `TestAddNewBranchShowsBaseInfo` | Display of base branch information (HEAD fallback) |
137+
| `TestAddNewBranchWithCommitBase` | `--base` with commit hash (full and short) works correctly |
137138

138139
### journey_rename_test.go
139140

e2e/journey_remote_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,3 +459,48 @@ func TestAddNewBranchShowsBaseInfo(t *testing.T) {
459459
assertOutputContains(t, stdout, "Worktree created")
460460
})
461461
}
462+
463+
// TestAddNewBranchWithCommitBase tests --base with a commit hash
464+
func TestAddNewBranchWithCommitBase(t *testing.T) {
465+
if testing.Short() {
466+
t.Skip("skipping e2e test in short mode")
467+
}
468+
469+
tempDir := createTempDir(t, "commit-base")
470+
471+
runBtSuccess(t, tempDir, "repo", "init", "test-repo")
472+
projectDir := filepath.Join(tempDir, "test-repo")
473+
bareDir := filepath.Join(projectDir, ".git")
474+
475+
// Get the full commit hash of HEAD
476+
cmd := exec.Command("git", "rev-parse", "HEAD")
477+
cmd.Dir = bareDir
478+
output, err := cmd.Output()
479+
if err != nil {
480+
t.Fatalf("failed to get commit hash: %v", err)
481+
}
482+
fullHash := strings.TrimSpace(string(output))
483+
484+
t.Run("new branch based on full commit hash", func(t *testing.T) {
485+
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/from-commit", "--base", fullHash)
486+
487+
assertOutputContains(t, stdout, "(commit)")
488+
assertOutputContains(t, stdout, "Worktree created")
489+
assertFileExists(t, filepath.Join(projectDir, "feat", "from-commit"))
490+
})
491+
492+
t.Run("new branch based on short commit hash", func(t *testing.T) {
493+
shortHash := fullHash[:7]
494+
stdout := runBtSuccess(t, projectDir, "add", "-b", "feat/from-short-commit", "--base", shortHash)
495+
496+
assertOutputContains(t, stdout, "(commit)")
497+
assertOutputContains(t, stdout, "Worktree created")
498+
assertFileExists(t, filepath.Join(projectDir, "feat", "from-short-commit"))
499+
})
500+
501+
t.Run("error when base is invalid commit hash", func(t *testing.T) {
502+
_, stderr := runBtExpectError(t, projectDir, "add", "-b", "feat/bad-hash", "--base", "deadbeef00deadbeef00")
503+
504+
assertOutputContains(t, stderr, "not found")
505+
})
506+
}

internal/git/branch.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,9 @@ func (e *Executor) GetUpstreamBehindCount(localBranch string) (int, error) {
168168
}
169169
return count, nil
170170
}
171+
172+
// IsCommitHash checks if the given string resolves to a valid commit object
173+
func (e *Executor) IsCommitHash(ref string) bool {
174+
_, err := e.Execute("rev-parse", "--verify", "--quiet", ref+"^{commit}")
175+
return err == nil
176+
}

0 commit comments

Comments
 (0)