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
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,21 @@ Defaults:
- bare repos: `$XDG_DATA_HOME/timber/repos/<repo-name>.git` (fallback: `~/.local/share/timber/repos/<repo-name>.git`)
- worktrees: `$TIMBER_WORKTREE_ROOT/<repo-name>/<worktree-name>/<repo-short-name>` (fallback: `~/worktrees/<repo-name>/<worktree-name>/<repo-short-name>`)

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:

Expand All @@ -34,7 +48,7 @@ their full grouping):
- worktree path: `~/worktrees/nnutter/timber/structured-names/timber`

Rename with `timber repo rename <old> <new>` 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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -206,7 +221,7 @@ For full flags and examples, see `timber --help` and `timber <command> --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` |
Expand Down
32 changes: 29 additions & 3 deletions internal/timber/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
)
Expand All @@ -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,
}
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions internal/timber/create_path_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
55 changes: 55 additions & 0 deletions internal/timber/custom_worktree_remove_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
16 changes: 8 additions & 8 deletions internal/timber/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
40 changes: 31 additions & 9 deletions internal/timber/generate_zsh_create_functional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
})
}
}
Loading
Loading