diff --git a/README.md b/README.md index e5860aa..05bafa6 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ See [Commands Reference](docs/commands.md) for full documentation. - `stack prune` - Clean up branches with merged PRs - `stack rename ` - Rename branch preserving stack relationships - `stack parent ` - Change the parent of the current branch -- `stack worktree ` - Create a worktree for a branch +- `stack worktree [branch-name]` - Create a worktree, generating a randomized branch name when omitted ## Configuration diff --git a/cmd/worktree.go b/cmd/worktree.go index 4d14f39..ead8fb5 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -1,6 +1,8 @@ package cmd import ( + "crypto/rand" + "encoding/hex" "fmt" "os" "path/filepath" @@ -18,20 +20,24 @@ var worktreePruneAll bool var worktreeList bool var worktreeCmd = &cobra.Command{ - Use: "worktree [base-branch]", + Use: "worktree [branch-name] [base-branch]", Short: "Create a worktree in the configured worktrees directory", - Long: `Create a git worktree in the configured worktrees directory for the specified branch. + Long: `Create a git worktree in the configured worktrees directory. If the branch exists locally or on the remote, it will be used. If the branch doesn't exist, a new branch will be created from the current branch (or from base-branch if specified) and stack tracking will be set up automatically. +If no branch name is specified, a randomized branch name will be generated. Use --list to show worktrees for this repository, or --list --all for all repos. Use --prune to clean up worktrees for branches with merged PRs. Use --prune --all to remove all worktrees for this repository. By default, worktrees are created under ~/.stack/worktrees/. You can change this with: git config stack.worktreesDir (or use 'stack config set')`, - Example: ` # Create worktree for new branch (from current branch, with stack tracking) + Example: ` # Create a worktree with a randomized branch name + stack worktree + + # Create worktree for new branch (from current branch, with stack tracking) stack worktree my-feature # Create worktree from a fresh main branch @@ -55,6 +61,9 @@ You can change this with: git config stack.worktreesDir (or use 'stack co # Preview without executing stack worktree my-feature --dry-run`, Args: func(cmd *cobra.Command, args []string) error { + if worktreePruneAll && !worktreeList && !worktreePrune { + return fmt.Errorf("--all requires --list or --prune") + } if worktreeList { if len(args) > 0 { return fmt.Errorf("--list does not take arguments") @@ -67,8 +76,8 @@ You can change this with: git config stack.worktreesDir (or use 'stack co } return nil } - if len(args) < 1 || len(args) > 2 { - return fmt.Errorf("requires 1 or 2 arguments: branch name [base-branch]") + if len(args) > 2 { + return fmt.Errorf("accepts at most 2 arguments: branch name [base-branch]") } return nil }, @@ -83,11 +92,23 @@ You can change this with: git config stack.worktreesDir (or use 'stack co } else if worktreePrune { err = runWorktreePrune(gitClient, githubClient) } else { + branchName := "" + if len(args) > 0 { + branchName = args[0] + } var baseBranch string if len(args) > 1 { baseBranch = args[1] } - err = runWorktree(gitClient, githubClient, args[0], baseBranch) + if branchName == "" { + branchName, err = generateRandomWorktreeName() + if err == nil { + fmt.Printf("Generated branch name %s\n", ui.Branch(branchName)) + } + } + if err == nil { + err = runWorktree(gitClient, githubClient, branchName, baseBranch) + } } if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -96,6 +117,14 @@ You can change this with: git config stack.worktreesDir (or use 'stack co }, } +func generateRandomWorktreeName() (string, error) { + randomBytes := make([]byte, 8) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random worktree name: %w", err) + } + return "worktree-" + hex.EncodeToString(randomBytes), nil +} + func init() { worktreeCmd.Flags().BoolVarP(&worktreeList, "list", "l", false, "List all worktrees for this repository") worktreeCmd.Flags().BoolVar(&worktreePrune, "prune", false, "Remove worktrees for branches with merged PRs") diff --git a/cmd/worktree_test.go b/cmd/worktree_test.go new file mode 100644 index 0000000..6a5d9c6 --- /dev/null +++ b/cmd/worktree_test.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateRandomWorktreeName(t *testing.T) { + first, err := generateRandomWorktreeName() + require.NoError(t, err) + second, err := generateRandomWorktreeName() + require.NoError(t, err) + + assert.Regexp(t, regexp.MustCompile(`^worktree-[0-9a-f]{16}$`), first) + assert.Regexp(t, regexp.MustCompile(`^worktree-[0-9a-f]{16}$`), second) + assert.NotEqual(t, first, second) +} + +func TestWorktreeArgs(t *testing.T) { + originalList := worktreeList + originalPrune := worktreePrune + originalAll := worktreePruneAll + t.Cleanup(func() { + worktreeList = originalList + worktreePrune = originalPrune + worktreePruneAll = originalAll + }) + + worktreeList = false + worktreePrune = false + worktreePruneAll = false + + assert.NoError(t, worktreeCmd.Args(worktreeCmd, nil)) + assert.NoError(t, worktreeCmd.Args(worktreeCmd, []string{"feature"})) + assert.NoError(t, worktreeCmd.Args(worktreeCmd, []string{"feature", "main"})) + assert.Error(t, worktreeCmd.Args(worktreeCmd, []string{"feature", "main", "extra"})) + + worktreePruneAll = true + assert.EqualError(t, worktreeCmd.Args(worktreeCmd, nil), "--all requires --list or --prune") +} diff --git a/decision-log.md b/decision-log.md index f5ab587..4c08399 100644 --- a/decision-log.md +++ b/decision-log.md @@ -2,6 +2,14 @@ Architectural and design decisions for Stackinator. +## 2026-08-17 — Generate a randomized worktree when no branch is provided + +**Decision**: Allow `stack worktree` with no arguments to create a new branch and worktree using a randomized `worktree-<16 hex characters>` name. + +**Context**: Creating a disposable worktree required inventing and typing a branch name even when the name itself was unimportant. + +**Resolution**: Made the branch-name argument optional. No-argument invocation generates the name with the operating system's cryptographic random source, then follows the existing new-branch worktree flow so the current branch is recorded as its stack parent. Explicit branch names and worktree management flags retain their existing behavior. + ## 2026-08-12 — Bound and separate sync network operations **Decision**: Give git fetches a five-minute timeout and GitHub CLI operations a 30-second timeout. Display fetch and PR loading as separate sync progress steps, and propagate PR lookup failures. diff --git a/docs/commands.md b/docs/commands.md index 803287b..081303b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -109,9 +109,9 @@ stack rename feature-improved-name stack rename feature-improved-name --dry-run ``` -## `stack worktree [base-branch]` +## `stack worktree [branch-name] [base-branch]` -Create a git worktree in the configured worktrees directory for the specified branch. +Create a git worktree in the configured worktrees directory. With no arguments, Stackinator generates a randomized branch name such as `worktree-a1b2c3d4e5f60718` and creates it from the current branch. If the branch exists locally or on the remote, it will be used. If the branch doesn't exist, a new branch will be created from the current branch (or from base-branch if specified) and stack tracking will be set up automatically. @@ -137,6 +137,9 @@ Interactively choose where worktrees are created for this repo: - `./.worktrees` (project-local) ```bash +# Create a worktree with a randomized branch name +stack worktree + # Create worktree for new branch (from current branch, with stack tracking) stack worktree my-feature