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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ Worktree commands take an optional `<worktree>@<repo>` qualifier on the name arg
`list` and `prune` use every registered repository unless `@<repo>` 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 @<repo>` targets the repository's default branch.

In non-interactive environments commands that need a single repository fail unless `@<repo>` is set or the cwd auto-detects a managed repo.
Expand Down Expand Up @@ -432,6 +432,7 @@ For full flags and examples, see `timber --help` and `timber <command> --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] <path>`) |
| `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) |
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down
15 changes: 15 additions & 0 deletions internal/cli/complete/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions internal/cli/generate/zsh/zsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "$@"
Expand Down
34 changes: 34 additions & 0 deletions internal/cli/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions internal/cli/move/move.go
Original file line number Diff line number Diff line change
@@ -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 <name[@repo]> <path>",
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)
}
}
120 changes: 120 additions & 0 deletions internal/cli/move_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
18 changes: 18 additions & 0 deletions internal/cli/worktreeoutput/move.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading