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.