diff --git a/README.md b/README.md index 7f9717a..a7d9572 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,21 @@ Defaults: - bare repos: `$XDG_DATA_HOME/timber/repos/.git` (fallback: `~/.local/share/timber/repos/.git`) - worktrees: `$TIMBER_WORKTREE_ROOT///` (fallback: `~/worktrees///`) -The worktree name and branch name are identical (including `/`). +For Timber-created worktrees, the worktree name and branch name are identical (including `/`). + +Pass an optional second argument to create a worktree at an exact checkout path instead of using the shared root: + +```bash +timber create feature/login@timber /path/to/checkout +timber create feature/login@timber ../checkout +timber create @timber ../checkout # generated name +``` + +Branch-attached worktrees created directly with Git are also visible when linked to a registered repository. +Other manual worktrees use their checkout directory name; conflicts within a repository are resolved by adding parent directory components until the name is unique, reserving Timber-created names. +For example, two `checkout` directories can become `one/checkout` and `two/checkout`. +These derived names can change when conflicting worktrees are added or removed. +Detached worktrees are not listed or managed. Example: @@ -34,7 +48,7 @@ their full grouping): - worktree path: `~/worktrees/nnutter/timber/structured-names/timber` Rename with `timber repo rename ` to move between simple and -structured names; managed worktrees move with the repo. Sorting +structured names; standard-layout worktrees move with the repo. Sorting `timber list --sort repo` orders by the full structured name, so the grouping prefix controls grouping. @@ -142,6 +156,7 @@ The generated function: ```bash t repo add nnutter/timber t create feature/login@timber # then cd into it +t create feature/custom@timber ../checkout # custom location, then cd t create @timber # random name, then cd into it t switch feature/login@timber t switch feature/login # unique name across repositories @@ -206,7 +221,7 @@ For full flags and examples, see `timber --help` and `timber --help`. | `tui` | Interactively open an existing worktree or create a new one | | `herdr install` | Install the bundled Herdr plugin | | `herdr space` | Open a Herdr Agent + Shell space for a worktree | -| `list` | List worktrees with merge/dirtiness status and TODO.md progress (`--json`, `--pr`, `--sort`) | +| `list` | List worktrees with merge/dirtiness status and TODO.md progress (`--json`, `--pr`, `--sort`, `-L`/`--location` for checkout paths in the last column) | | `prune` | Remove clean, merged worktrees (`--dry-run`, `--prompt`) | | `remove` | Remove one worktree and delete its branch (`--force` overrides safety) | | `todo` | Open the current worktree's `TODO.md` in `$EDITOR` | diff --git a/internal/timber/create.go b/internal/timber/create.go index 111bc01..3061403 100644 --- a/internal/timber/create.go +++ b/internal/timber/create.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "github.com/spf13/cobra" ) @@ -19,9 +20,9 @@ func NewCreateCommand(runtime Runtime) *cobra.Command { options := &createCommandOptions{runtime: runtime} command := &cobra.Command{ - Use: "create [name[@repo]]", + Use: "create [name[@repo]] [path]", Short: "Create a managed Git worktree", - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(2), RunE: options.Execute, ValidArgsFunction: runtime.completeCreateArgs, } @@ -44,7 +45,7 @@ func (x *createCommandOptions) Execute(command *cobra.Command, args []string) er func (x *createCommandOptions) createWorktree(command *cobra.Command, args []string) (string, error) { var raw string - if len(args) == 1 { + if len(args) > 0 { raw = args[0] } qualified, err := x.runtime.parseQualifiedName(raw) @@ -69,9 +70,25 @@ func (x *createCommandOptions) createWorktree(command *cobra.Command, args []str } worktreePath := x.runtime.managedWorktreePath(repo.Name, branchName) + if len(args) == 2 { + if args[1] == "" { + return "", errors.New("worktree path must not be empty") + } + worktreePath, err = x.runtime.absolutePath(args[1]) + if err != nil { + return "", err + } + } if err := rejectNonEmptyWorktreeDirectory(worktreePath); err != nil { return "", err } + worktrees, err := x.runtime.managedWorktreesFromRepository(repository, repo.Name) + if err != nil { + return "", err + } + if _, err := managedWorktreeByName(worktrees, branchName); err == nil { + return "", fmt.Errorf("worktree %q already exists", branchName) + } if _, err := repository.git("fetch", remoteName); err != nil { return "", fmt.Errorf("fetch %s: %w", remoteName, err) @@ -106,6 +123,15 @@ func (x *createCommandOptions) createWorktree(command *cobra.Command, args []str if err := repository.setBranchUpstream(branchName, upstreamBranch); err != nil { return "", err } + if len(args) == 2 { + gitDir, err := x.runtime.worktreeGitDir(worktreePath) + if err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(gitDir, worktreeNameFile), []byte(branchName+"\n"), 0o600); err != nil { + return "", fmt.Errorf("record worktree name: %w", err) + } + } if _, err := fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render("created "+worktreePath)); err != nil { return "", err diff --git a/internal/timber/create_path_test.go b/internal/timber/create_path_test.go new file mode 100644 index 0000000..b5dd99e --- /dev/null +++ b/internal/timber/create_path_test.go @@ -0,0 +1,107 @@ +package timber + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateCustomPathPreservesExplicitNameAcrossLifecycle(t *testing.T) { + t.Parallel() + for _, relative := range []bool{false, true} { + label := "absolute" + if relative { + label = "relative" + } + t.Run(label, func(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(repo.home, "custom projects", "checkout") + argument := path + if relative { + argument = filepath.Join("custom projects", "checkout") + } + const name = "feature/custom" + created := repo.runTimber(t, "create", at(testRepoName, name), argument) + require.NoError(t, created.err, created.stderr) + assert.Equal(t, path+"\n", created.stdout) + assert.Equal(t, name, strings.TrimSpace(runGitCommand(t, path, "branch", "--show-current"))) + assert.NoDirExists(t, repo.worktreePath(name)) + + // A subsequent command has no in-memory knowledge of create. + listed := repo.runTimber(t, "list", "--json") + require.NoError(t, listed.err, listed.stderr) + var records []listJSONWorktree + require.NoError(t, json.Unmarshal([]byte(listed.stdout), &records)) + require.Len(t, records, 1) + assert.Equal(t, name, records[0].Name) + assert.Equal(t, path, records[0].Path) + + duplicate := repo.runTimber(t, "create", at(testRepoName, name), filepath.Join(repo.home, "other")) + require.Error(t, duplicate.err) + assert.Contains(t, duplicate.err.Error(), "already exists") + + renamed := repo.runTimber(t, "repo", "rename", testRepoName, "renamed") + require.NoError(t, renamed.err, renamed.stderr) + switched := repo.runTimber(t, "switch", name) + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, path+"\n", switched.stdout) + assert.Contains(t, runCompleteWithRuntime(t, repo.runtime, "switch", "feature/c"), name+"\n") + + removed := repo.runTimberFrom(t, path, "remove") + require.NoError(t, removed.err, removed.stderr) + assert.NoDirExists(t, path) + assert.DirExists(t, filepath.Dir(path)) + }) + } +} + +func TestCreateCustomPathWithGeneratedName(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(repo.home, "custom", "checkout") + created := repo.runTimber(t, "create", at(testRepoName, ""), path) + require.NoError(t, created.err, created.stderr) + branch := strings.TrimSpace(runGitCommand(t, path, "branch", "--show-current")) + assert.NotEmpty(t, branch) + assert.NotEqual(t, "checkout", branch) + switched := repo.runTimber(t, "switch", branch) + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, path+"\n", switched.stdout) +} + +func TestCreateCustomPathChecksExistingDestination(t *testing.T) { + t.Parallel() + for _, kind := range []string{"empty", "nonempty", "file", "missing"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(repo.home, "checkout") + switch kind { + case "empty", "nonempty": + require.NoError(t, os.Mkdir(path, 0o755)) + if kind == "nonempty" { + require.NoError(t, os.WriteFile(filepath.Join(path, "keep"), []byte("keep"), 0o600)) + } + case "file": + require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600)) + case "missing": + path = "" + } + result := repo.runTimber(t, "create", at(testRepoName, "custom"), path) + if kind == "empty" { + require.NoError(t, result.err, result.stderr) + assert.FileExists(t, filepath.Join(path, ".git")) + return + } + require.Error(t, result.err) + _, err := runGitCommandResult(repo.barePath, "show-ref", "--verify", "refs/heads/custom") + assert.Error(t, err) + }) + } +} diff --git a/internal/timber/custom_worktree_remove_test.go b/internal/timber/custom_worktree_remove_test.go new file mode 100644 index 0000000..f5fdb9d --- /dev/null +++ b/internal/timber/custom_worktree_remove_test.go @@ -0,0 +1,55 @@ +package timber + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCustomWorktreeRemovalPreservesParents(t *testing.T) { + t.Parallel() + for _, command := range []string{"remove", "prune"} { + t.Run(command, func(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + // Empty parents inside HOME would previously be removed along + // with the checkout. Two conflicts also exercise prune's snapshot. + for _, group := range []string{"one", "two"} { + path := filepath.Join(repo.home, "projects", group, "checkout") + runGitCommand(t, repo.barePath, "worktree", "add", "-b", "feature/"+group, path, "origin/main") + } + args := []string{command} + if command == "remove" { + args = append(args, "one/checkout") + } + result := repo.runTimber(t, args...) + require.NoError(t, result.err, result.stderr) + assert.NoDirExists(t, filepath.Join(repo.home, "projects", "one", "checkout")) + assert.DirExists(t, filepath.Join(repo.home, "projects", "one")) + if command == "prune" { + assert.NoDirExists(t, filepath.Join(repo.home, "projects", "two", "checkout")) + assert.DirExists(t, filepath.Join(repo.home, "projects", "two")) + } + }) + } +} + +func TestManualWorktreeWithoutUpstreamRequiresForceRemoval(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(repo.home, "projects", "checkout") + runGitCommand(t, repo.barePath, "worktree", "add", "-b", "manual-no-upstream", path, "main") + runGitCommandAllowError(t, repo.barePath, "branch", "--unset-upstream", "manual-no-upstream") + pruned := repo.runTimber(t, "prune") + require.NoError(t, pruned.err, pruned.stderr) + assert.DirExists(t, path) + rejected := repo.runTimber(t, "remove", "checkout") + require.Error(t, rejected.err) + assert.Contains(t, rejected.err.Error(), "has no upstream branch") + result := repo.runTimber(t, "remove", "--force", "checkout") + require.NoError(t, result.err, result.stderr) + assert.NoDirExists(t, path) + assert.DirExists(t, filepath.Dir(path)) +} diff --git a/internal/timber/generate_test.go b/internal/timber/generate_test.go index 50d12dd..196e462 100644 --- a/internal/timber/generate_test.go +++ b/internal/timber/generate_test.go @@ -286,16 +286,16 @@ func TestGeneratedSwitchCompletesWorktreeNamesAcrossRepos(t *testing.T) { } skipIfNoPty(t) - home := resolvedTempDir(t) - dataHome := filepath.Join(home, ".local", "share") - worktreeRoot := filepath.Join(home, "worktrees") - require.NoError(t, os.MkdirAll(filepath.Join(dataHome, "timber", "repos", "timber.git"), 0o755)) - require.NoError(t, os.MkdirAll(filepath.Join(dataHome, "timber", "repos", "other.git"), 0o755)) + repository := newTestRepository(t) + home := repository.home + dataHome := repository.runtime.DataHome + worktreeRoot := repository.worktreeRoot + registerAdditionalRepo(t, repository, "timber") + registerAdditionalRepo(t, repository, "other") makeWorktree := func(repoName, worktreeName string) { t.Helper() - worktreePath := filepath.Join(worktreeRoot, repoName, worktreeName, repoName) - require.NoError(t, os.MkdirAll(worktreePath, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(worktreePath, ".git"), nil, 0o644)) + result := repository.runTimber(t, "create", at(repoName, worktreeName)) + require.NoError(t, result.err, result.stderr) } makeWorktree("timber", "feature/login") makeWorktree("other", "feature/api") diff --git a/internal/timber/generate_zsh_create_functional_test.go b/internal/timber/generate_zsh_create_functional_test.go index 27db3ab..fdff68a 100644 --- a/internal/timber/generate_zsh_create_functional_test.go +++ b/internal/timber/generate_zsh_create_functional_test.go @@ -12,16 +12,38 @@ import ( func TestFunctionalZshCreateChangesDirectoryToReportedPath(t *testing.T) { t.Parallel() - target := filepath.Join(resolvedTempDir(t), "worktree with spaces") - require.NoError(t, os.Mkdir(target, 0o755)) - command := generatedZshCommand(t, `#!/bin/sh -[ "$#" = 2 ] && [ "$1" = create ] && [ "$2" = feature@repo ] || exit 98 + for _, custom := range []bool{false, true} { + name := "default" + if custom { + name = "custom" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + target := filepath.Join(resolvedTempDir(t), "worktree with spaces") + require.NoError(t, os.Mkdir(target, 0o755)) + invocation := `source "$1"; t create feature@repo >/dev/null || exit $?; pwd -P` + if custom { + invocation = `source "$1"; t create feature@repo "$TARGET_DIR" >/dev/null || exit $?; pwd -P` + } + command := generatedZshCommand(t, `#!/bin/sh +[ "$1" = create ] && [ "$2" = feature@repo ] || exit 98 +if [ "$CUSTOM_PATH" = true ]; then + [ "$#" = 3 ] && [ "$3" = "$TARGET_DIR" ] || exit 97 +else + [ "$#" = 2 ] || exit 96 +fi printf '%s\n' "$TARGET_DIR" > "$TIMBER_CREATE_PATH_FILE" -`, `source "$1"; t create feature@repo >/dev/null || exit $?; pwd -P`) - command.Env = replaceTestEnvironment(command.Env, "TARGET_DIR="+target) +`, invocation) + customValue := "false" + if custom { + customValue = "true" + } + command.Env = replaceTestEnvironment(command.Env, "TARGET_DIR="+target, "CUSTOM_PATH="+customValue) - output, err := command.CombinedOutput() + output, err := command.CombinedOutput() - require.NoError(t, err, string(output)) - assert.Equal(t, target, strings.TrimSpace(string(output))) + require.NoError(t, err, string(output)) + assert.Equal(t, target, strings.TrimSpace(string(output))) + }) + } } diff --git a/internal/timber/list.go b/internal/timber/list.go index eaa2f58..55f74d9 100644 --- a/internal/timber/list.go +++ b/internal/timber/list.go @@ -11,6 +11,7 @@ import ( type listCommandOptions struct { repoSelection pullRequests bool + location bool jsonOutput bool sortBy listSort } @@ -27,6 +28,7 @@ func NewListCommand(runtime Runtime) *cobra.Command { ValidArgsFunction: runtime.completeRepoQualifiers, } command.Flags().BoolVar(&options.pullRequests, "pr", false, "Include open pull request status from gh") + command.Flags().BoolVarP(&options.location, "location", "L", false, "Include worktree location as the last column") command.Flags().BoolVar(&options.jsonOutput, "json", false, "Output worktrees as JSON instead of a table") command.Flags().Var(&options.sortBy, "sort", "Sort worktrees by recency (newest commit first), repo, or worktree") if err := command.RegisterFlagCompletionFunc("sort", completeListSort); err != nil { @@ -59,9 +61,15 @@ func (x *listCommandOptions) Execute(command *cobra.Command, args []string) erro if x.pullRequests { headers = append(headers, "PR") } + if x.location { + headers = append(headers, "Location") + for index := range worktrees { + worktrees[index].DisplayPath = x.runtime.displayHomePath(worktrees[index].Path) + } + } statusFormatter := newListStatusFormatter(worktrees) tableView := newOutputTable(headers...).BorderRow(true) - tableView.Rows(groupListTableRows(worktrees, statusFormatter, x.pullRequests)...) + tableView.Rows(groupListTableRows(worktrees, statusFormatter, x.pullRequests, x.location)...) _, err = fmt.Fprintln(command.OutOrStdout(), dottedListRowRules(tableView.String())) return err @@ -136,7 +144,7 @@ func dottedListRowRules(tableOutput string) string { return strings.Join(lines, "\n") } -func groupListTableRows(worktrees []managedWorktree, statusFormatter listStatusFormatter, showPullRequests bool) [][]string { +func groupListTableRows(worktrees []managedWorktree, statusFormatter listStatusFormatter, showPullRequests, showLocation bool) [][]string { rows := make([][]string, 0, (len(worktrees)+1)/2) for index, worktree := range worktrees { status := statusFormatter.format(worktree) @@ -154,6 +162,9 @@ func groupListTableRows(worktrees []managedWorktree, statusFormatter listStatusF if showPullRequests { row = append(row, worktree.PullRequest) } + if showLocation { + row = append(row, worktree.DisplayPath) + } if index%2 == 0 { rows = append(rows, row) continue diff --git a/internal/timber/list_test.go b/internal/timber/list_test.go index 4bc8533..c169f72 100644 --- a/internal/timber/list_test.go +++ b/internal/timber/list_test.go @@ -3,6 +3,7 @@ package timber import ( "encoding/json/v2" "os" + "path/filepath" "strings" "testing" @@ -45,7 +46,7 @@ func TestGroupListTableRowsAddsRuleAfterEverySecondWorktree(t *testing.T) { for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { - rows := groupListTableRows(testCase.worktrees, newListStatusFormatter(testCase.worktrees), false) + rows := groupListTableRows(testCase.worktrees, newListStatusFormatter(testCase.worktrees), false, false) require.Len(t, rows, len(testCase.groupedNames)) for index, names := range testCase.groupedNames { assert.Equal(t, names, rows[index][0]) @@ -68,15 +69,61 @@ func TestGroupListTableRowsIncludesPullRequestColumnWhenEnabled(t *testing.T) { {Name: "two", Clean: true}, } - rows := groupListTableRows(worktrees, newListStatusFormatter(worktrees), true) + rows := groupListTableRows(worktrees, newListStatusFormatter(worktrees), true, false) require.Len(t, rows, 1) assert.Equal(t, "#42 ✓\n", rows[0][6]) - rows = groupListTableRows(worktrees, newListStatusFormatter(worktrees), false) + rows = groupListTableRows(worktrees, newListStatusFormatter(worktrees), false, false) require.Len(t, rows, 1) assert.Len(t, rows[0], 6) } +func TestListLocationIsOptionalLastColumn(t *testing.T) { + t.Parallel() + repository := newTestRepository(t) + const standardName = "feature/standard" + created := repository.runTimber(t, "create", at(testRepoName, standardName)) + require.NoError(t, created.err, created.stderr) + customPath := filepath.Join(resolvedTempDir(t), "checkout with spaces") + runGitCommand(t, repository.barePath, "worktree", "add", "-b", "feature/manual", customPath, "origin/main") + repository.runtime.GhExecutable = installFakeGh(t) + paths := map[string]string{ + standardName: "~/worktrees/repo/feature/standard/repo", + "checkout with spaces": customPath, + } + + without := repository.runTimber(t, "ls") + require.NoError(t, without.err, without.stderr) + assert.NotContains(t, without.stdout, "Location") + for _, path := range paths { + assert.NotContains(t, without.stdout, path) + } + + for _, flags := range [][]string{{"-L"}, {"--location"}, {"--location", "--pr"}} { + result := repository.runTimber(t, append([]string{"ls"}, flags...)...) + require.NoError(t, result.err, result.stderr) + seen := 0 + for line := range strings.SplitSeq(result.stdout, "\n") { + if !strings.HasPrefix(line, "│") { + continue + } + columns := strings.Split(strings.Trim(line, "│"), "│") + name := strings.TrimSpace(columns[0]) + if name == "Name" { + assert.Equal(t, "Location", strings.TrimSpace(columns[len(columns)-1])) + if len(flags) == 2 { + assert.Equal(t, "PR", strings.TrimSpace(columns[len(columns)-2])) + } + } + if path, found := paths[name]; found { + assert.Equal(t, path, strings.TrimSpace(columns[len(columns)-1])) + seen++ + } + } + assert.Equal(t, len(paths), seen, "flags=%v", flags) + } +} + func TestFormatDirtyStatusColorsTrueYellow(t *testing.T) { t.Parallel() assert.Equal(t, "clean", formatDirtyStatus(true)) diff --git a/internal/timber/manual_worktree_test.go b/internal/timber/manual_worktree_test.go new file mode 100644 index 0000000..1d2cae5 --- /dev/null +++ b/internal/timber/manual_worktree_test.go @@ -0,0 +1,75 @@ +package timber + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManualWorktreeLifecycle(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + path := filepath.Join(resolvedTempDir(t), "checkout") + runGitCommand(t, repo.barePath, "worktree", "add", "-b", "feature/manual", path, "origin/main") + + listed := repo.runTimber(t, "list", "--json") + require.NoError(t, listed.err, listed.stderr) + var records []listJSONWorktree + require.NoError(t, json.Unmarshal([]byte(listed.stdout), &records)) + require.Len(t, records, 1) + assert.Equal(t, "checkout", records[0].Name) + assert.Equal(t, path, records[0].Path) + assert.True(t, records[0].Merged) + + for _, name := range []string{"checkout", at(testRepoName, "checkout")} { + switched := repo.runTimber(t, "switch", name) + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, path, strings.TrimSpace(switched.stdout)) + } + assert.Contains(t, runCompleteWithRuntime(t, repo.runtime, "switch", "check"), "checkout\n") + assert.Contains(t, runCompleteWithRuntime(t, repo.runtime, "switch", "checkout@"), at(testRepoName, "checkout")) + + todo := repo.runTimber(t, "todo", "--path", "checkout") + require.NoError(t, todo.err, todo.stderr) + gitDir := strings.TrimSpace(runGitCommand(t, path, "rev-parse", "--absolute-git-dir")) + assert.Equal(t, filepath.Join(gitDir, "TODO.md"), strings.TrimSpace(todo.stdout)) + + pruned := repo.runTimber(t, "prune", "--dry-run") + require.NoError(t, pruned.err, pruned.stderr) + assert.Contains(t, pruned.stderr, "would prune checkout") + + removed := repo.runTimber(t, "remove", "checkout") + require.NoError(t, removed.err, removed.stderr) + assert.NoDirExists(t, path) + _, err := runGitCommandResult(repo.barePath, "show-ref", "--verify", "refs/heads/feature/manual") + assert.Error(t, err) +} + +func TestManualWorktreeNamesExpandPastConflicts(t *testing.T) { + t.Parallel() + repo := newTestRepository(t) + require.NoError(t, repo.runTimber(t, "create", at(testRepoName, "checkout")).err) + root := resolvedTempDir(t) + for _, group := range []string{"one", "two"} { + path := filepath.Join(root, group, "project", "checkout") + runGitCommand(t, repo.barePath, "worktree", "add", "-b", "branch-"+group, path, "origin/main") + } + listed := repo.runTimber(t, "list", "--json") + require.NoError(t, listed.err, listed.stderr) + var records []listJSONWorktree + require.NoError(t, json.Unmarshal([]byte(listed.stdout), &records)) + var names []string + for _, record := range records { + names = append(names, record.Name) + } + assert.ElementsMatch(t, []string{"checkout", "one/project/checkout", "two/project/checkout"}, names) + for _, group := range []string{"one", "two"} { + switched := repo.runTimber(t, "switch", group+"/project/checkout") + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, filepath.Join(root, group, "project", "checkout"), strings.TrimSpace(switched.stdout)) + } +} diff --git a/internal/timber/prune.go b/internal/timber/prune.go index 22218fb..57a089a 100644 --- a/internal/timber/prune.go +++ b/internal/timber/prune.go @@ -73,10 +73,13 @@ func (x *pruneCommandOptions) Execute(command *cobra.Command, args []string) err if !x.prompt && (!worktree.Clean || !worktree.Merged) { continue } - if worktree.Repo != "" { - removeOptions.RepoName = worktree.Repo + // Removing a checkout can shorten other directory-derived names. + // Use the selected identity rather than resolving its name again. + repository, _, err := x.runtime.openRegisteredRepository(worktree.Repo) + if err != nil { + return err } - if err := removeOptions.removeWorktree(command, worktree.Name, true); err != nil { + if err := removeOptions.removeResolvedWorktree(command, repository, worktree, true); err != nil { return err } } diff --git a/internal/timber/prune_test.go b/internal/timber/prune_test.go index 62efb1c..03987b9 100644 --- a/internal/timber/prune_test.go +++ b/internal/timber/prune_test.go @@ -198,7 +198,7 @@ func TestPruneKeepsWorktreeWhenBranchHasNoUpstream(t *testing.T) { result := testRepository.runTimber(t, "prune", at(testRepoName, "")) testRepository.assertPathPresent(t, testRepository.worktreePath(branchName)) runGitCommand(t, testRepository.barePath, "show-ref", "--verify", "refs/heads/"+branchName) - require.ErrorContains(t, result.err, "no upstream branch") + require.NoError(t, result.err, result.stderr) } func TestPrunePromptCanForceRemoveSelectedWorktrees(t *testing.T) { diff --git a/internal/timber/remove.go b/internal/timber/remove.go index 509877b..a7fc3b3 100644 --- a/internal/timber/remove.go +++ b/internal/timber/remove.go @@ -44,7 +44,15 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, raw string if err != nil { return err } + return x.removeResolvedWorktree(command, repository, worktree, force) +} + +func (x *removeCommandOptions) removeResolvedWorktree(command *cobra.Command, repository *Repository, worktree managedWorktree, force bool) error { name := worktree.Name + standardLayout, err := samePath(worktree.Path, x.runtime.managedWorktreePath(worktree.Repo, worktree.branchName())) + if err != nil { + return err + } if !force { if _, err := repository.git("fetch", remoteName); err != nil { @@ -52,16 +60,20 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, raw string } } - worktree, err = x.runtime.enrichManagedWorktree(repository, worktree) - if err != nil { - return err - } - - if !force && !worktree.Clean { - return fmt.Errorf("worktree %q is not clean", name) - } - if !force && !worktree.Merged { - return fmt.Errorf("branch %q is not merged to %s", name, shortReference(worktree.UpstreamRef)) + if !force { + worktree, err = x.runtime.enrichManagedWorktree(repository, worktree) + if err != nil { + return err + } + if !worktree.Clean { + return fmt.Errorf("worktree %q is not clean", name) + } + if worktree.UpstreamRef == "" { + return fmt.Errorf("branch %q has no upstream branch; use --force to remove it", worktree.branchName()) + } + if !worktree.Merged { + return fmt.Errorf("branch %q is not merged to %s", worktree.branchName(), shortReference(worktree.UpstreamRef)) + } } currentDirectory := x.runtime.CurrentDirectory @@ -75,8 +87,10 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, raw string if _, err := repository.git(removeArguments...); err != nil { return err } - if err := removeEmptyParents(worktree.Path, x.runtime.HomeDirectory); err != nil { - return err + if standardLayout { + if err := removeEmptyParents(worktree.Path, x.runtime.HomeDirectory); err != nil { + return err + } } branchExists, err := repository.branchStillExists(worktree.BranchReference) @@ -84,7 +98,7 @@ func (x *removeCommandOptions) removeWorktree(command *cobra.Command, raw string return err } if branchExists { - if _, err := repository.git("branch", branchDeleteFlag(force), name); err != nil { + if _, err := repository.git("branch", branchDeleteFlag(force), worktree.branchName()); err != nil { return err } } diff --git a/internal/timber/repo_remove_functional_test.go b/internal/timber/repo_remove_functional_test.go index adb6a71..89a26de 100644 --- a/internal/timber/repo_remove_functional_test.go +++ b/internal/timber/repo_remove_functional_test.go @@ -12,7 +12,7 @@ func TestFunctionalRepoRemoveRefusesUnmanagedLinkedWorktrees(t *testing.T) { t.Parallel() repository := newTestRepository(t) unmanaged := filepath.Join(resolvedTempDir(t), "unmanaged") - runGitCommand(t, repository.barePath, "worktree", "add", unmanaged, "main") + runGitCommand(t, repository.barePath, "worktree", "add", "--detach", unmanaged, "main") head := runGitCommand(t, unmanaged, "rev-parse", "HEAD") result := repository.runTimber(t, "repo", "remove", testRepoName) diff --git a/internal/timber/repo_rename_functional_test.go b/internal/timber/repo_rename_functional_test.go index 9b6ca7b..ba07379 100644 --- a/internal/timber/repo_rename_functional_test.go +++ b/internal/timber/repo_rename_functional_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestRepoRenameMovesManagedWorktreesAndPreservesUnmanagedWorktrees(t *testing.T) { +func TestRepoRenameMovesStandardWorktreesAndPreservesCustomWorktrees(t *testing.T) { t.Parallel() const ( branchName = "feature/rename/nested" @@ -69,6 +69,10 @@ func TestRepoRenameMovesManagedWorktreesAndPreservesUnmanagedWorktrees(t *testin listResult := runTimberCommandWithRuntime(t, testRepository.runtime, "list", at(newRepoName, "")) require.NoError(t, listResult.err, listResult.stderr) assert.Contains(t, listResult.stdout, branchName) + assert.Contains(t, listResult.stdout, "unmanaged") + switchResult := testRepository.runTimber(t, "switch", at(newRepoName, "unmanaged")) + require.NoError(t, switchResult.err, switchResult.stderr) + assert.Equal(t, unmanagedPath+"\n", switchResult.stdout) } func TestRepoRenameWithoutWorktrees(t *testing.T) { diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 6c25023..35b2f7b 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -466,14 +466,12 @@ func (x Runtime) inferUniqueRepoForWorktree(worktreeName string) (string, error) var matches []string for _, repo := range repos { - worktreePath := x.managedWorktreePath(repo.Name, worktreeName) - _, err := os.Stat(worktreePath) - if err == nil { - matches = append(matches, repo.Name) - continue + worktrees, err := x.worktreesForRepo(repo) + if err != nil { + return "", err } - if !errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("inspect worktree directory %q: %w", worktreePath, err) + if _, err := managedWorktreeByName(worktrees, worktreeName); err == nil { + matches = append(matches, repo.Name) } } @@ -508,7 +506,10 @@ func (x Runtime) completeQualifiedWorktreeNames(_ *cobra.Command, args []string, } func (x Runtime) completeCreateArgs(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { + if len(args) == 1 { + return nil, cobra.ShellCompDirectiveFilterDirs + } + if len(args) > 1 { return nil, cobra.ShellCompDirectiveNoFileComp } if name, repoPrefix, found := strings.CutLast(toComplete, "@"); found { @@ -539,7 +540,11 @@ func (x Runtime) completeRepoSuffix(worktreeName string, repoPrefix string, requ continue } if requireWorktree { - if _, err := os.Stat(x.managedWorktreePath(repo.Name, worktreeName)); err != nil { + worktrees, err := x.worktreesForRepo(repo) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + if _, err := managedWorktreeByName(worktrees, worktreeName); err != nil { continue } } @@ -558,7 +563,15 @@ func (x Runtime) completeWorktreeNamesAcrossRepos(toComplete string) ([]string, reposForName := make(map[string][]string) var names []string for _, repo := range repos { - for _, name := range x.managedWorktreeNamesOnDisk(repo.Name, toComplete) { + worktrees, err := x.worktreesForRepo(repo) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + for _, worktree := range worktrees { + name := worktree.Name + if !strings.HasPrefix(name, toComplete) { + continue + } if _, exists := reposForName[name]; !exists { names = append(names, name) } @@ -656,37 +669,12 @@ func (x Runtime) openRegisteredRepository(name string) (*Repository, registeredR return repository, repo, nil } -// managedWorktreeNamesOnDisk lists worktree names under the managed root for repoName -// (layout: ///), filtered by toComplete prefix. -func (x Runtime) managedWorktreeNamesOnDisk(repoName string, toComplete string) []string { - repoRoot := filepath.Join(x.worktreeRoot(), filepath.FromSlash(repoName)) - shortName := repoShortName(repoName) - var names []string - _ = filepath.WalkDir(repoRoot, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return nil - } - if !entry.IsDir() { - return nil - } - if entry.Name() != shortName { - return nil - } - if _, err := os.Stat(filepath.Join(path, ".git")); err != nil { - return nil - } - parent := filepath.Dir(path) - name, err := filepath.Rel(repoRoot, parent) - if err != nil || name == "." || strings.HasPrefix(name, "..") { - return nil - } - if strings.HasPrefix(name, toComplete) { - names = append(names, name) - } - return filepath.SkipDir - }) - slices.Sort(names) - return names +func (x Runtime) worktreesForRepo(repo registeredRepo) ([]managedWorktree, error) { + repository, err := openBareRepository(x, repo.BarePath) + if err != nil { + return nil, err + } + return x.managedWorktreesFromRepository(repository, repo.Name) } func (x Runtime) completeRegisteredRepoNames(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -953,12 +941,21 @@ func (x Runtime) enrichManagedWorktree(repository *Repository, worktree managedW worktree.Status = status worktree.Clean = clean - upstreamRef, err := repository.upstreamReference(worktree.Name) + upstreamRef, err := repository.upstreamReference(worktree.branchName()) if err != nil { + // Manual branches need not track an upstream. They remain visible + // to the prune picker, but are never eligible for automatic pruning. + _, configured, configErr := repository.gitConfigValue("branch." + worktree.branchName() + ".merge") + if configErr != nil { + return managedWorktree{}, configErr + } + if !configured { + return worktree, nil + } return managedWorktree{}, err } - merged, err := repository.branchMergedToUpstream(worktree.Name, worktree.BranchReference, upstreamRef) + merged, err := repository.branchMergedToUpstream(worktree.branchName(), worktree.BranchReference, upstreamRef) if err != nil { return managedWorktree{}, err } @@ -984,18 +981,14 @@ func (x Runtime) managedWorktreesFromRepository(repository *Repository, repoName continue } - expectedPath := x.managedWorktreePath(repoName, branchName) - same, err := samePath(expectedPath, porcelainWorktree.Path) + name, err := x.recordedWorktreeName(repoName, porcelainWorktree.Path, branchName) if err != nil { return nil, err } - if !same { - continue - } managedWorktrees = append(managedWorktrees, managedWorktree{ Repo: repoName, - Name: branchName, + Name: name, Path: porcelainWorktree.Path, DisplayPath: currentRelativePath(currentDirectory, porcelainWorktree.Path), CommitHash: porcelainWorktree.CommitHash, @@ -1003,6 +996,7 @@ func (x Runtime) managedWorktreesFromRepository(repository *Repository, repoName }) } + assignManualWorktreeNames(managedWorktrees) slices.SortFunc(managedWorktrees, compareManagedWorktrees) return managedWorktrees, nil @@ -1071,8 +1065,8 @@ func (x Runtime) enrichWorktreeForList(repository *Repository, worktree managedW // A branch without a resolvable upstream is not merged anywhere timber // tracks; list stays read-only and reports it as unmerged. - if upstreamRef, err := repository.upstreamReference(worktree.Name); err == nil { - merged, err := repository.branchMergedToUpstream(worktree.Name, worktree.BranchReference, upstreamRef) + if upstreamRef, err := repository.upstreamReference(worktree.branchName()); err == nil { + merged, err := repository.branchMergedToUpstream(worktree.branchName(), worktree.BranchReference, upstreamRef) if err != nil { return managedWorktree{}, err } @@ -1127,7 +1121,7 @@ func (x Runtime) enrichListedWorktreesWithPullRequests(ctx context.Context, work return err } for _, index := range byRepo[repoName] { - worktrees[index].PullRequest = pullRequests[worktrees[index].Name] + worktrees[index].PullRequest = pullRequests[worktrees[index].branchName()] } } return nil @@ -1213,8 +1207,17 @@ func (x Runtime) worktreeDirectoryExists(repoName string, name string) (bool, er if err == nil { return true, nil } - if errors.Is(err, os.ErrNotExist) { - return false, nil + if !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("inspect worktree directory %q: %w", path, err) + } + repository, _, err := x.openRegisteredRepository(repoName) + if err != nil { + return false, err + } + worktrees, err := x.managedWorktreesFromRepository(repository, repoName) + if err != nil { + return false, err } - return false, fmt.Errorf("inspect worktree directory %q: %w", path, err) + _, err = managedWorktreeByName(worktrees, name) + return err == nil, nil } diff --git a/internal/timber/switch.go b/internal/timber/switch.go index dbe66e9..d546ca9 100644 --- a/internal/timber/switch.go +++ b/internal/timber/switch.go @@ -62,7 +62,19 @@ func (x *switchCommandOptions) Execute(command *cobra.Command, args []string) er return err } - worktreePath := x.runtime.managedWorktreePath(repoName, name) + repository, _, err := x.runtime.openRegisteredRepository(repoName) + if err != nil { + return err + } + worktrees, err := x.runtime.managedWorktreesFromRepository(repository, repoName) + if err != nil { + return err + } + worktree, err := managedWorktreeByName(worktrees, name) + if err != nil { + return fmt.Errorf("worktree %s not found in repository %s", name, repoName) + } + worktreePath := worktree.Path if _, err := os.Stat(worktreePath); err != nil { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("worktree %s not found at %s", name, worktreePath) diff --git a/internal/timber/test_helpers_test.go b/internal/timber/test_helpers_test.go index d8c33d7..e4afb70 100644 --- a/internal/timber/test_helpers_test.go +++ b/internal/timber/test_helpers_test.go @@ -36,7 +36,18 @@ type stubCreateWizardPrompter struct { } func (x stubPrompter) Prompt(input io.Reader, output io.Writer, worktrees []managedWorktree) ([]managedWorktree, error) { - return x.selected, x.err + if x.err != nil { + return nil, x.err + } + var selected []managedWorktree + for _, choice := range x.selected { + for _, worktree := range worktrees { + if worktree.Name == choice.Name && (choice.Repo == "" || worktree.Repo == choice.Repo) { + selected = append(selected, worktree) + } + } + } + return selected, nil } func (x *stubCreateWizardPrompter) Prompt( diff --git a/internal/timber/tui.go b/internal/timber/tui.go index d81a94d..f651f33 100644 --- a/internal/timber/tui.go +++ b/internal/timber/tui.go @@ -114,10 +114,17 @@ func (x *tuiCreateCommandOptions) openSelectedWorktree( command *cobra.Command, selection createWizardSelection, ) error { - worktree := managedWorktree{ - Repo: selection.repoName, - Name: selection.worktreeName, - Path: x.runtime.managedWorktreePath(selection.repoName, selection.worktreeName), + repository, _, err := x.runtime.openRegisteredRepository(selection.repoName) + if err != nil { + return err + } + worktrees, err := x.runtime.managedWorktreesFromRepository(repository, selection.repoName) + if err != nil { + return err + } + worktree, err := managedWorktreeByName(worktrees, selection.worktreeName) + if err != nil { + return err } if err := x.runtime.openHerdrSpace(command.Context(), worktree); err != nil { return err diff --git a/internal/timber/tui_functional_test.go b/internal/timber/tui_functional_test.go index 1c820e2..99ab8ad 100644 --- a/internal/timber/tui_functional_test.go +++ b/internal/timber/tui_functional_test.go @@ -106,30 +106,44 @@ func TestTUICreateWithHerdrOpensStandardHerdrSpace(t *testing.T) { func TestTUICreateOpensSelectedWorktreeInHerdrSpace(t *testing.T) { t.Parallel() - const branchName = "feature/ui-open" - - testRepository := newTestRepository(t) - require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) - logPath := filepath.Join(resolvedTempDir(t), "herdr.log") - testRepository.runtime.HerdrExecutable = installFakeHerdrSpace(t, logPath) - testRepository.runtime.GhExecutable = installFakeGh(t) - - prompter := &stubCreateWizardPrompter{ - selection: createWizardSelection{ - action: wizardActionOpen, - repoName: testRepoName, - worktreeName: branchName, - }, + for _, layout := range []string{"standard", "manual"} { + t.Run(layout, func(t *testing.T) { + t.Parallel() + const branchName = "feature/ui-open" + testRepository := newTestRepository(t) + name := branchName + path := testRepository.worktreePath(branchName) + if layout == "manual" { + name = "checkout" + path = filepath.Join(resolvedTempDir(t), name) + runGitCommand(t, testRepository.barePath, "worktree", "add", "-b", branchName, path, "origin/main") + } else { + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + } + logPath := filepath.Join(resolvedTempDir(t), "herdr.log") + testRepository.runtime.HerdrExecutable = installFakeHerdrSpace(t, logPath) + testRepository.runtime.GhExecutable = installFakeGh(t) + + prompter := &stubCreateWizardPrompter{ + selection: createWizardSelection{ + action: wizardActionOpen, + repoName: testRepoName, + worktreeName: name, + }, + } + options := &tuiCreateCommandOptions{runtime: testRepository.runtime} + result := runTUICreate(t, options, prompter) + + require.NoError(t, result.err, result.stderr) + assert.Contains(t, result.stderr, "opened herdr space for "+name) + calls := readFakeHerdrLog(t, logPath) + assert.Len(t, calls, 11) + assert.Contains(t, calls, fakeHerdrLogLine("worktree", "open", "--workspace", "w1", "--path", path, "--label", name, "--no-focus")) + require.Len(t, prompter.worktrees, 1) + assert.Equal(t, testRepoName, prompter.worktrees[0].Repo) + assert.Equal(t, name, prompter.worktrees[0].Name) + }) } - options := &tuiCreateCommandOptions{runtime: testRepository.runtime} - result := runTUICreate(t, options, prompter) - - require.NoError(t, result.err, result.stderr) - assert.Contains(t, result.stderr, "opened herdr space for "+branchName) - assert.Len(t, readFakeHerdrLog(t, logPath), 11) - require.Len(t, prompter.worktrees, 1) - assert.Equal(t, testRepoName, prompter.worktrees[0].Repo) - assert.Equal(t, branchName, prompter.worktrees[0].Name) } func TestTUICreateWithNoHerdrDoesNotInvokeHerdr(t *testing.T) { diff --git a/internal/timber/worktree.go b/internal/timber/worktree.go index 1b449e9..b6d8a04 100644 --- a/internal/timber/worktree.go +++ b/internal/timber/worktree.go @@ -4,6 +4,7 @@ import ( "cmp" "fmt" "slices" + "strings" "time" ) @@ -26,6 +27,10 @@ type managedWorktree struct { PullRequest string } +func (x managedWorktree) branchName() string { + return strings.TrimPrefix(string(x.BranchReference), branchRefPrefix) +} + func (x managedWorktree) shortCommitHash() string { if len(x.CommitHash) <= 7 { return x.CommitHash diff --git a/internal/timber/worktree_identity.go b/internal/timber/worktree_identity.go new file mode 100644 index 0000000..d50cea0 --- /dev/null +++ b/internal/timber/worktree_identity.go @@ -0,0 +1,76 @@ +package timber + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const worktreeNameFile = "timber-name" + +// Standard-layout worktrees predate explicit name records. An empty name +// identifies a manual checkout whose name must be derived from its path. +func (x Runtime) recordedWorktreeName(repoName, path, branch string) (string, error) { + same, err := samePath(path, x.managedWorktreePath(repoName, branch)) + if err != nil { + return "", err + } + if same { + return branch, nil + } + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return "", nil + } + gitDir, err := x.worktreeGitDir(path) + if err != nil { + return "", err + } + content, err := os.ReadFile(filepath.Join(gitDir, worktreeNameFile)) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read worktree name: %w", err) + } + return strings.TrimSpace(string(content)), nil +} + +// Expand all conflicting manual names together, reserving explicit names. +// Distinct absolute paths eventually yield distinct names, even when one +// checkout's path is a suffix of another's. +func assignManualWorktreeNames(worktrees []managedWorktree) { + manual := make([]bool, len(worktrees)) + parents := make([]string, len(worktrees)) + for index := range worktrees { + if worktrees[index].Name == "" { + manual[index] = true + worktrees[index].Name = filepath.Base(worktrees[index].Path) + parents[index] = filepath.Dir(worktrees[index].Path) + } + } + for { + counts := make(map[string]int) + for _, worktree := range worktrees { + counts[worktree.Name]++ + } + changed := false + for index := range worktrees { + if !manual[index] || counts[worktrees[index].Name] < 2 { + continue + } + parent := parents[index] + if parent == string(filepath.Separator) { + worktrees[index].Name = "/" + worktrees[index].Name + } else { + worktrees[index].Name = filepath.Base(parent) + "/" + worktrees[index].Name + } + parents[index] = filepath.Dir(parent) + changed = true + } + if !changed { + return + } + } +}