From b47ed5442776569e2ef703548ccc6c570efd322e Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Sun, 8 Nov 2026 22:03:58 -0600 Subject: [PATCH] Add timber move to relocate a worktree checkout Worktrees can already live outside the managed layout via custom create paths and manual git worktree links, but there was no way to move an existing checkout once created. `timber move ` runs `git worktree move`, keeps the worktree's selector name by recording it in the checkout's git dir when the destination leaves the standard layout, and reports the relocated cwd through the path file so the zsh wrapper can follow the shell into the new location. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Change-Id: I0e2c1e05d2ccb0ab2a076e65d7185a20fd54476c --- README.md | 3 +- internal/cli/commands.go | 7 ++ internal/cli/complete/complete.go | 15 ++++ internal/cli/generate/zsh/zsh.go | 21 +++++ internal/cli/generate_test.go | 34 ++++++++ internal/cli/move/move.go | 40 ++++++++++ internal/cli/move_test.go | 120 ++++++++++++++++++++++++++++ internal/cli/worktreeoutput/move.go | 18 +++++ internal/timber/runtime.go | 106 ++++++++++++++++++++++++ skills/timber/SKILL.md | 5 ++ 10 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 internal/cli/move/move.go create mode 100644 internal/cli/move_test.go create mode 100644 internal/cli/worktreeoutput/move.go diff --git a/README.md b/README.md index 82d8191..14d48a5 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ Worktree commands take an optional `@` qualifier on the name arg `list` and `prune` use every registered repository unless `@` pins one. `create` (and `switch -c`) use the current repository when the cwd is a managed worktree of a registered repo, otherwise an interactive picker. -`remove`, `herdr space`, and `pr create` with no name target the managed worktree that contains the cwd. +`remove`, `move`, `herdr space`, and `pr create` with no name target the managed worktree that contains the cwd. `bump` with no name targets the same worktree, and `bump @` targets the repository's default branch. In non-interactive environments commands that need a single repository fail unless `@` is set or the cwd auto-detects a managed repo. @@ -432,6 +432,7 @@ For full flags and examples, see `timber --help` and `timber --help`. | `list` | List worktrees with merge/dirtiness status, TODO.md progress, and commit review 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) | +| `move` | Move a worktree checkout to a new path (`name[@repo] `) | | `pr create` | Open a GitHub pull request for a pushed worktree branch (`--title`, `--body`, `--base`, `--draft`) | | `bump` | Create and push the next semantic version tag (`--major`/`--minor`/`--patch`, `--pattern`, `--no-push`, `--release`, `--no-open`) | | `hooks commit-msg` | Add a Gerrit-compatible `Change-Id` to a commit message (`timber review commit-msg` stays as a hidden alias) | diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 9a1bfb0..1a1338a 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -21,6 +21,7 @@ import ( "github.com/nnutter/timber/internal/cli/herdr/space" clihooks "github.com/nnutter/timber/internal/cli/hooks" clilist "github.com/nnutter/timber/internal/cli/list" + climove "github.com/nnutter/timber/internal/cli/move" clipr "github.com/nnutter/timber/internal/cli/pr" prcreate "github.com/nnutter/timber/internal/cli/pr/create" cliprune "github.com/nnutter/timber/internal/cli/prune" @@ -94,6 +95,12 @@ func Commands(runtime timber.Runtime) []*cobra.Command { }, Complete: cliComplete.RepoQualifiers(runtime), }), + climove.New(climove.Dependencies{ + Move: runtime.MoveWorktree, + Complete: cliComplete.MoveArgs(runtime), + RenderStatus: renderStatus, + RenderWarning: renderWarning, + }), cliprune.New(cliprune.Dependencies{ Prune: func(input io.Reader, output io.Writer, repoArg string, prompt, dryRun bool) error { return runtime.PruneWorktrees(input, output, repoArg, prompt, dryRun, huhWorktreePrompter{}, timber.PruneReporters{ diff --git a/internal/cli/complete/complete.go b/internal/cli/complete/complete.go index ca4f07b..0e097e9 100644 --- a/internal/cli/complete/complete.go +++ b/internal/cli/complete/complete.go @@ -58,6 +58,21 @@ func CreateArgs(queries Queries) func(*cobra.Command, []string, string) ([]strin } } +// MoveArgs completes the worktree name, then directories for the path +// argument. +func MoveArgs(queries Queries) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + names := QualifiedWorktreeNames(queries) + return func(command *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return names(command, args, toComplete) + } + if len(args) == 1 { + return nil, cobra.ShellCompDirectiveFilterDirs + } + return nil, cobra.ShellCompDirectiveNoFileComp + } +} + // RepoQualifiers completes @repo selectors. func RepoQualifiers(queries Queries) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/internal/cli/generate/zsh/zsh.go b/internal/cli/generate/zsh/zsh.go index 935b656..69997ec 100644 --- a/internal/cli/generate/zsh/zsh.go +++ b/internal/cli/generate/zsh/zsh.go @@ -513,6 +513,27 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { ;; esac ;; + move|mv) + local path_file + path_file=$(mktemp) || return $? + TIMBER_RENAME_PATH_FILE=$path_file command timber "$@" + local move_status=$? + local target_dir="" + if [[ -s "$path_file" ]]; then + target_dir=$(<"$path_file") + fi + rm -f "$path_file" + if (( move_status != 0 )); then + return $move_status + fi + if [[ -n "$target_dir" ]]; then + if ! [[ -d "$target_dir" ]]; then + echo "Moved worktree not found at $target_dir" >&2 + return 1 + fi + cd "$target_dir" || return $? + fi + ;; remove|rm) local command_status command timber "$@" diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go index bdf1abc..6de52c4 100644 --- a/internal/cli/generate_test.go +++ b/internal/cli/generate_test.go @@ -735,6 +735,40 @@ printf '%s\n' "$new_worktree/nested" > "$TIMBER_RENAME_PATH_FILE" assert.Equal(t, shellCanonicalPath(filepath.Join(worktreeParent, "new", "nested")), strings.TrimSpace(string(output))) } +func TestGeneratedZshWrapperChangesToMovedCurrentWorktree(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh is not installed") + } + + outDir := resolvedTempDir(t) + require.NoError(t, runTimberCommand(t, "generate", "zsh", "--out", outDir, "--force").err) + + worktreeParent := resolvedTempDir(t) + oldWorktree := filepath.Join(worktreeParent, "old") + oldSubdirectory := filepath.Join(oldWorktree, "nested") + require.NoError(t, os.MkdirAll(oldSubdirectory, 0o755)) + + binDir := resolvedTempDir(t) + fakeTimber := `#!/bin/sh +old_worktree=$(dirname "$PWD") +new_worktree=$(dirname "$old_worktree")/new +mv "$old_worktree" "$new_worktree" || exit $? +printf '%s\n' "$new_worktree/nested" > "$TIMBER_RENAME_PATH_FILE" +` + require.NoError(t, os.WriteFile(filepath.Join(binDir, "timber"), []byte(fakeTimber), 0o755)) + + command := shellTestCommand(t, + "zsh", "-f", "-c", + `source "$1"; cd "$2"; t move feature@repo "$3" >/dev/null; pwd -P`, + "--", filepath.Join(outDir, "t"), oldSubdirectory, filepath.Join(worktreeParent, "new"), + ) + command.Env = shellTestEnvironment(command.Env, "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + assert.Equal(t, shellCanonicalPath(filepath.Join(worktreeParent, "new", "nested")), strings.TrimSpace(string(output))) +} + func TestGeneratedZshWrapperRestoresDirectoryOnFailure(t *testing.T) { t.Parallel() if _, err := exec.LookPath("zsh"); err != nil { diff --git a/internal/cli/move/move.go b/internal/cli/move/move.go new file mode 100644 index 0000000..58de711 --- /dev/null +++ b/internal/cli/move/move.go @@ -0,0 +1,40 @@ +package move + +import ( + "io" + + "github.com/spf13/cobra" + + "github.com/nnutter/timber/internal/cli/worktreeoutput" +) + +// Dependencies connects the move CLI to worktree relocation and process state. +type Dependencies struct { + Move func(input io.Reader, raw, destination string) (name, path string, movingCurrentDirectory bool, err error) + Complete func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) + RenderStatus func(string) string + RenderWarning func(string) string +} + +// New constructs the command that moves a managed worktree. +func New(deps Dependencies) *cobra.Command { + command := &cobra.Command{ + Use: "move ", + Aliases: []string{"mv"}, + Short: "Move a managed Git worktree to a new path", + Args: cobra.ExactArgs(2), + RunE: run(deps), + ValidArgsFunction: deps.Complete, + } + return command +} + +func run(deps Dependencies) func(*cobra.Command, []string) error { + return func(command *cobra.Command, args []string) error { + name, path, movingCurrentDirectory, err := deps.Move(command.InOrStdin(), args[0], args[1]) + if err != nil { + return err + } + return worktreeoutput.ReportMove(command.ErrOrStderr(), deps.RenderStatus, deps.RenderWarning, name, path, movingCurrentDirectory) + } +} diff --git a/internal/cli/move_test.go b/internal/cli/move_test.go new file mode 100644 index 0000000..a5f9105 --- /dev/null +++ b/internal/cli/move_test.go @@ -0,0 +1,120 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMoveRelocatesWorktree(t *testing.T) { + t.Parallel() + const branchName = "feature/move" + + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + destination := filepath.Join(resolvedTempDir(t), "relocated", "checkout") + + result := testRepository.runTimber(t, "move", at(testRepoName, branchName), destination) + require.NoError(t, result.err, result.stderr) + assert.Contains(t, result.stderr, "moved "+branchName+" to "+destination) + + testRepository.assertPathMissing(t, testRepository.worktreePath(branchName)) + testRepository.assertPathPresent(t, destination) + + switched := testRepository.runTimber(t, "switch", branchName) + require.NoError(t, switched.err, switched.stderr) + assert.Equal(t, destination, strings.TrimSpace(switched.stdout)) +} + +func TestMovePreservesWorktreeNameForListAndPrune(t *testing.T) { + t.Parallel() + const branchName = "feature/move-name" + + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + testRepository.mergeWorktreeBranch(t, branchName) + destination := filepath.Join(resolvedTempDir(t), "checkout") + + result := testRepository.runTimber(t, "move", branchName, destination) + require.NoError(t, result.err, result.stderr) + + listed := testRepository.runTimber(t, "list", "--json") + require.NoError(t, listed.err, listed.stderr) + assert.Contains(t, listed.stdout, `"name":"`+branchName+`"`) + assert.Contains(t, listed.stdout, destination) + + pruned := testRepository.runTimber(t, "prune", "--dry-run") + require.NoError(t, pruned.err, pruned.stderr) + assert.Contains(t, pruned.stderr, "would prune "+branchName) +} + +func TestMoveReportsMovedCurrentDirectory(t *testing.T) { + t.Parallel() + const branchName = "feature/move-current" + + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + subdirectory := filepath.Join(testRepository.worktreePath(branchName), "nested") + require.NoError(t, os.MkdirAll(subdirectory, 0o755)) + destination := filepath.Join(resolvedTempDir(t), "moved-current") + pathFile := filepath.Join(resolvedTempDir(t), "moved-path") + runtime := testRepository.runtime + runtime.CurrentDirectory = subdirectory + runtime.RenamePathFile = pathFile + runtime.TemporaryDirectory = filepath.Dir(pathFile) + + result := runTimberCommandWithRuntime(t, runtime, "move", at(testRepoName, branchName), destination) + require.NoError(t, result.err, result.stderr) + assert.Contains(t, result.stderr, "current directory moved") + + contents, err := os.ReadFile(pathFile) + require.NoError(t, err) + assert.Equal(t, filepath.Join(destination, "nested")+"\n", string(contents)) +} + +func TestMoveRejectsExistingDestination(t *testing.T) { + t.Parallel() + const branchName = "feature/move-conflict" + + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + destination := filepath.Join(resolvedTempDir(t), "occupied") + require.NoError(t, os.MkdirAll(destination, 0o755)) + + result := testRepository.runTimber(t, "move", branchName, destination) + require.Error(t, result.err) + assert.Contains(t, result.err.Error(), "already exists") + testRepository.assertPathPresent(t, testRepository.worktreePath(branchName)) +} + +func TestMoveRejectsDestinationInsideWorktree(t *testing.T) { + t.Parallel() + const branchName = "feature/move-nested" + + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + destination := filepath.Join(testRepository.worktreePath(branchName), "inner") + + result := testRepository.runTimber(t, "move", branchName, destination) + require.Error(t, result.err) + assert.Contains(t, result.err.Error(), "into itself") + testRepository.assertPathPresent(t, testRepository.worktreePath(branchName)) +} + +func TestMoveCompletionOffersWorktreeNames(t *testing.T) { + t.Parallel() + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, "feature/a")).err) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, "feature/b")).err) + + stdout := runCompleteWithRuntime(t, testRepository.runtime, "move", "") + assert.Contains(t, stdout, "feature/a") + assert.Contains(t, stdout, "feature/b") + + stdout = runCompleteWithRuntime(t, testRepository.runtime, "move", "feature/a", "") + assert.NotContains(t, stdout, "feature/a") +} diff --git a/internal/cli/worktreeoutput/move.go b/internal/cli/worktreeoutput/move.go new file mode 100644 index 0000000..4f32238 --- /dev/null +++ b/internal/cli/worktreeoutput/move.go @@ -0,0 +1,18 @@ +package worktreeoutput + +import ( + "fmt" + "io" +) + +// ReportMove writes the messages for a moved worktree. +func ReportMove(output io.Writer, renderStatus, renderWarning func(string) string, name, path string, movingCurrentDirectory bool) error { + if _, err := fmt.Fprintf(output, "%s\n", renderStatus(fmt.Sprintf("moved %s to %s", name, path))); err != nil { + return err + } + if movingCurrentDirectory { + _, err := fmt.Fprintf(output, "%s\n", renderWarning("current directory moved")) + return err + } + return nil +} diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 1c6efa7..0ae93cf 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -482,6 +482,18 @@ func (x Runtime) LoadConfig() (coreconfig.Settings, error) { return x.loadConfig() } +// MoveWorktree resolves a worktree selector and moves the checkout to +// destination. It returns the worktree name, its new path, and whether the +// move relocated the directory containing the current directory. +func (runtime Runtime) MoveWorktree(input io.Reader, raw string, destination string) (string, string, bool, error) { + selection := repoSelection{runtime: runtime} + _, repository, worktree, err := selection.resolveQualifiedWorktree(input, raw) + if err != nil { + return "", "", false, err + } + return runtime.moveResolvedWorktree(repository, worktree, destination) +} + // NewCommand builds an external command with the runtime environment and // executable overrides applied. It exposes process creation for CLI wiring. func (x Runtime) NewCommand(ctx context.Context, name string, args ...string) *exec.Cmd { @@ -1754,6 +1766,69 @@ func (x Runtime) moveLegacyRepo(repo registeredRepo) (bool, error) { return true, nil } +func (runtime Runtime) moveResolvedWorktree(repository *Repository, worktree managedWorktree, destination string) (string, string, bool, error) { + targetPath, err := runtime.moveTargetPath(worktree, destination) + if err != nil { + return "", "", false, err + } + + currentTargetDir, movingCurrentDirectory, err := movedCurrentDirectory(worktree.Path, targetPath, runtime.CurrentDirectory) + if err != nil { + return "", "", false, err + } + + if err := ensureDirectory(filepath.Dir(targetPath)); err != nil { + return "", "", false, err + } + if _, err := repository.git("worktree", "move", worktree.Path, targetPath); err != nil { + return "", "", false, err + } + + if err := runtime.recordMovedWorktreeName(worktree, targetPath); err != nil { + return "", "", false, err + } + if err := runtime.removeEmptySourceParents(worktree.Path); err != nil { + return "", "", false, err + } + if movingCurrentDirectory { + if err := runtime.reportRenamedCurrentPath(currentTargetDir); err != nil { + return "", "", false, err + } + } + return worktree.Name, targetPath, movingCurrentDirectory, nil +} + +func (runtime Runtime) moveTargetPath(worktree managedWorktree, destination string) (string, error) { + if isEmpty(destination) { + return "", fmt.Errorf("destination path is required") + } + targetPath, err := runtime.absolutePath(destination) + if err != nil { + return "", err + } + same, err := samePath(worktree.Path, targetPath) + if err != nil { + return "", err + } + if same { + return "", fmt.Errorf("worktree %q is already at %s", worktree.Name, worktree.Path) + } + if pathIsWithin(worktree.Path, targetPath) { + return "", fmt.Errorf("cannot move worktree %q into itself", worktree.Name) + } + if _, err := os.Lstat(worktree.Path); errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("worktree %q directory %q does not exist", worktree.Name, worktree.Path) + } else if err != nil { + return "", fmt.Errorf("inspect worktree directory %q: %w", worktree.Path, err) + } + if _, err := os.Lstat(targetPath); err == nil { + return "", fmt.Errorf("destination %q already exists", targetPath) + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("inspect destination %q: %w", targetPath, err) + } + return targetPath, nil +} + // movedInternalWorktree recognizes a checkout that moved with a legacy bare // repository. Both old links must agree before Git can repair either one. func (x Runtime) movedInternalWorktree(repo registeredRepo, oldPath string) (string, error) { @@ -1907,6 +1982,24 @@ func (runtime Runtime) pullRequestTarget(input io.Reader, draft PullRequestDraft // Standard-layout worktrees predate explicit name records. An empty name // identifies a manual checkout whose name must be derived from its path. +// recordMovedWorktreeName preserves the worktree's selector name when the +// move leaves the standard managed layout. A worktree without an explicit +// name keeps deriving it from the new path. +func (x Runtime) recordMovedWorktreeName(worktree managedWorktree, targetPath string) error { + branchName := worktree.branchName() + if branchName == "" || !isSame(worktree.Name, branchName) { + return nil + } + standard, err := samePath(targetPath, x.managedWorktreePath(worktree.Repo, branchName)) + if err != nil { + return err + } + if standard { + return nil + } + return recordExplicitWorktreeName(x, targetPath, branchName, true) +} + func (x Runtime) recordedWorktreeName(repoName, path, branch string) (string, error) { same, err := samePath(path, x.managedWorktreePath(repoName, branch)) if err != nil { @@ -2380,3 +2473,16 @@ func RuntimeFromProcess() (Runtime, error) { Environment: slices.Clone(os.Environ()), }, nil } + +// movedCurrentDirectory maps a directory inside the source worktree to the +// matching directory under the move target. +func movedCurrentDirectory(worktreePath string, targetPath string, currentDirectory string) (string, bool, error) { + if currentDirectory == "" || !pathIsWithin(worktreePath, currentDirectory) { + return "", false, nil + } + relativePath, err := filepath.Rel(canonicalPath(worktreePath), canonicalPath(currentDirectory)) + if err != nil { + return "", false, fmt.Errorf("resolve current worktree subdirectory: %w", err) + } + return filepath.Join(targetPath, relativePath), true, nil +} diff --git a/skills/timber/SKILL.md b/skills/timber/SKILL.md index b3e7985..d90528a 100644 --- a/skills/timber/SKILL.md +++ b/skills/timber/SKILL.md @@ -102,6 +102,9 @@ timber bump --minor --release --no-open feature/login@owner/repo timber prune @owner/repo --dry-run # Only after approval for this exact worktree and its branch: timber remove feature/login@owner/repo + +# Move a worktree checkout to another path: +timber move feature/login@owner/repo ../login ``` ## Procedure @@ -156,6 +159,8 @@ timber remove feature/login@owner/repo Add a remote, then fetch, before relying on upstream or pull request status. - Detached worktrees are not managed. Names for manually linked checkouts can change when collisions appear; rediscover before acting. +- `move` relocates a checkout with `git worktree move`; the destination must not exist and must not sit inside the worktree. + A Timber-managed worktree keeps its name at the new path; a manually linked worktree re-derives its name from the new path's basename. - The zsh wrapper `t` supplies shell integration and `cd` behavior. Do not assume `timber switch` changes the agent runner's working directory, and avoid `tui` or interactive pickers in unattended sessions. - `todo --path` and `todo --json` create a missing TODO file inside the Git directory.