diff --git a/README.md b/README.md index 94076bd..c1b9695 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,22 @@ `git-wt` manages Git worktrees using a consistent path layout. -The main worktree is checked out in a directory named `main`. -Its parent is the root directory for every worktree, and each additional worktree uses its branch name as its relative path: +Every managed worktree lives under a shared root, with the worktree/branch name as an intermediate directory and the repository name as the final checkout directory: -`/` +`//` -The branch name is used as-is (including `/`), so the worktree name and branch name are identical. +The worktree name and branch name are identical (including `/`). +The main worktree uses the name `main`. Example: -- worktree root: `my-repo` -- main worktree: `my-repo/main` -- branch: `feature/login` -- worktree path: `my-repo/feature/login` +- worktree root: `~/src/github.com/nnutter/git-wt` +- repo name: `git-wt` +- main worktree: `~/src/github.com/nnutter/git-wt/main/git-wt` +- branch: `nn/my-feature` +- worktree path: `~/src/github.com/nnutter/git-wt/nn/my-feature/git-wt` -Use `git-wt migrate` to move existing worktrees into this layout. +Use `git-wt migrate` to move existing worktrees (including main) into this layout. ## Installation @@ -45,6 +46,7 @@ The generated function: - after a successful `wt create`, `cd`s into the new worktree unless `--no-cd`, `-r` | `--herdr`, or automatic Herdr workspace creation applies - provides a shell-only `switch` that `cd`s into a worktree - after a successful `wt remove`, `cd`s to the main worktree +- after a successful `wt off`, `cd`s to the collapsed worktree root ```bash wt switch main @@ -99,6 +101,7 @@ Columns: Bring existing branch worktrees under `git-wt` management. +- Moves the main worktree into `/main/` when it is still at `/main`. - Creates managed worktrees for local branches that do not already have one. - Renames existing non-managed branch worktrees into the managed path format. @@ -111,6 +114,24 @@ git-wt migrate git-wt migrate --prompt ``` +### `git-wt off` + +Tear down the managed worktree layout into a single checkout at the worktree root. + +- Refuses if any managed worktree (including main) is dirty unless `--force` | `-f`. +- Removes every non-main managed worktree. +- Deletes a feature branch with `git branch -d` when it is fully merged; otherwise keeps the branch. +- Moves `/main/` to `` so the repository is a normal single checkout. + +When invoked through the shell wrapper (`wt off`), the shell also `cd`s to the collapsed root after success. + +Example: + +```bash +git-wt off +git-wt off --force +``` + ### `git-wt prune` Remove managed worktrees that are both clean, no uncommitted changes, and merged into their upstream branch. diff --git a/internal/gitwt/git_helpers.go b/internal/gitwt/git_helpers.go index a5666ab..bb1adb7 100644 --- a/internal/gitwt/git_helpers.go +++ b/internal/gitwt/git_helpers.go @@ -40,14 +40,38 @@ func gitOutput(directory string, args ...string) (gitCommandResult, error) { return result, nil } -func managedWorktreePath(mainPath string, branchName string) string { - return filepath.Join(filepath.Dir(mainPath), branchName) +// Layout (steady state): +// +// = /main/ +// managed = // +// +// Old main layout (basename mainPath == "main") is only handled by migrate. +func worktreeRoot(mainPath string) string { + return filepath.Dir(filepath.Dir(mainPath)) } -func ensureWorktreeParent(worktreePath string) error { - parent := filepath.Dir(worktreePath) - if err := os.MkdirAll(parent, 0o755); err != nil { - return fmt.Errorf("create worktree parent directory %q: %w", parent, err) +func repoName(mainPath string) string { + return filepath.Base(mainPath) +} + +func managedWorktreePath(mainPath string, worktreeName string) string { + return filepath.Join(worktreeRoot(mainPath), worktreeName, repoName(mainPath)) +} + +func mainNeedsLayoutMigration(mainPath string) bool { + return filepath.Base(mainPath) == "main" +} + +// migratedMainPath returns the nested main path when main is still on the old +// layout (/main). repo name is the basename of the worktree root. +func migratedMainPath(mainPath string) string { + root := filepath.Dir(mainPath) + return filepath.Join(root, "main", filepath.Base(root)) +} + +func ensureWorktreeDirectory(worktreePath string) error { + if err := os.MkdirAll(worktreePath, 0o755); err != nil { + return fmt.Errorf("create worktree directory %q: %w", worktreePath, err) } return nil } diff --git a/internal/gitwt/gitwt.go b/internal/gitwt/gitwt.go index 8846e11..be4aca8 100644 --- a/internal/gitwt/gitwt.go +++ b/internal/gitwt/gitwt.go @@ -16,6 +16,7 @@ func NewRootCommand() *cobra.Command { rootCommand.AddCommand(NewCreateCommand()) rootCommand.AddCommand(NewListCommand()) rootCommand.AddCommand(NewMigrateCommand()) + rootCommand.AddCommand(NewOffCommand()) rootCommand.AddCommand(NewPruneCommand()) rootCommand.AddCommand(NewRemoveCommand()) rootCommand.AddCommand(NewGenerateCommand()) diff --git a/internal/gitwt/gitwt_create.go b/internal/gitwt/gitwt_create.go index e8626c4..cf352a8 100644 --- a/internal/gitwt/gitwt_create.go +++ b/internal/gitwt/gitwt_create.go @@ -63,7 +63,7 @@ func (x *createCommandOptions) Execute(command *cobra.Command, args []string) er if err != nil { return err } - if err := ensureWorktreeParent(worktreePath); err != nil { + if err := ensureWorktreeDirectory(worktreePath); err != nil { return err } if branchExists { diff --git a/internal/gitwt/gitwt_generate_zsh.go b/internal/gitwt/gitwt_generate_zsh.go index 017e6d3..fbc2519 100644 --- a/internal/gitwt/gitwt_generate_zsh.go +++ b/internal/gitwt/gitwt_generate_zsh.go @@ -131,8 +131,9 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { echo "Main worktree not found" >&2 return 1 fi - local root_dir=${main_dir:h} - local target_dir=$root_dir/$name + local root_dir=${main_dir:h:h} + local repo_name=${main_dir:t} + local target_dir=$root_dir/$name/$repo_name if ! [[ -d "$target_dir" ]]; then echo "Worktree $name not found at $target_dir" >&2 return 1 @@ -168,8 +169,9 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { cd "$main_dir" ;; *) - local root_dir=${main_dir:h} - local target_dir=$root_dir/$arg + local root_dir=${main_dir:h:h} + local repo_name=${main_dir:t} + local target_dir=$root_dir/$arg/$repo_name if [[ $(pwd) == "$target_dir" ]]; then echo "Already in $arg" return 0 @@ -192,6 +194,17 @@ func (x *zshCommandOptions) writeFunctionFile(target string) error { command git-wt "$@" || return $? cd "$main_dir" ;; + off) + local main_dir + main_dir=$(git worktree list --porcelain | head -n1 | sed "s/^worktree //") + if [[ -z "$main_dir" ]]; then + echo "Main worktree not found" >&2 + return 1 + fi + local root_dir=${main_dir:h:h} + command git-wt "$@" || return $? + cd "$root_dir" + ;; *) command git-wt "$@" ;; @@ -214,6 +227,7 @@ _` + x.name + `() { 'create:Create a managed Git worktree' 'list:List managed Git worktrees' 'migrate:Bring existing worktrees under management' + 'off:Tear down managed worktrees into a single checkout' 'prune:Remove clean merged managed worktrees' 'remove:Remove a managed Git worktree' 'generate:Generate shell integration' @@ -244,7 +258,8 @@ _` + x.name + `() { local main_dir main_dir=$(git worktree list --porcelain | head -n1 | sed "s/^worktree //") - local root_dir=${main_dir:h} + local root_dir=${main_dir:h:h} + local repo_name=${main_dir:t} local -a worktrees if [[ $words[2] == switch ]]; then @@ -259,7 +274,7 @@ _` + x.name + `() { ;; 'branch refs/heads/'*) branch=${line#branch refs/heads/} - if [[ "$worktree_path" != "$main_dir" && "$worktree_path" == "$root_dir/$branch" ]]; then + if [[ "$worktree_path" != "$main_dir" && "$worktree_path" == "$root_dir/$branch/$repo_name" ]]; then worktrees+=("$branch") fi ;; diff --git a/internal/gitwt/gitwt_migrate.go b/internal/gitwt/gitwt_migrate.go index ff7e515..e98a606 100644 --- a/internal/gitwt/gitwt_migrate.go +++ b/internal/gitwt/gitwt_migrate.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/charmbracelet/huh" "github.com/samber/lo" @@ -54,6 +55,23 @@ func (x *migrateCommandOptions) Execute(command *cobra.Command, args []string) e return err } + mainPath, err := repository.mainWorktreePath() + if err != nil { + return err + } + + if mainNeedsLayoutMigration(mainPath) { + targetMainPath := migratedMainPath(mainPath) + if err := migrateMainWorktree(repository, command.ErrOrStderr(), mainPath, targetMainPath); err != nil { + return err + } + // Re-open from the new main path (cwd may still point at the old location). + repository, err = openRepository(targetMainPath) + if err != nil { + return err + } + } + candidates, err := migrationCandidatesFromRepository(repository) if err != nil { return err @@ -72,18 +90,9 @@ func (x *migrateCommandOptions) Execute(command *cobra.Command, args []string) e } for _, candidate := range selectedCandidates { - if err := ensureWorktreeParent(candidate.TargetPath); err != nil { + if err := applyMigrationCandidate(repository, candidate); err != nil { return err } - if candidate.CurrentPath == "" { - if _, err := repository.git("worktree", "add", candidate.TargetPath, candidate.Name); err != nil { - return err - } - } else { - if _, err := repository.git("worktree", "move", candidate.CurrentPath, candidate.TargetPath); err != nil { - return err - } - } message := fmt.Sprintf("%sd %s to %s", candidate.Action, candidate.Name, candidate.TargetPath) if _, err := fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render(message)); err != nil { @@ -94,6 +103,105 @@ func (x *migrateCommandOptions) Execute(command *cobra.Command, args []string) e return nil } +// migrateMainWorktree moves main from /main to /main/ via a +// temporary sibling path (a directory cannot be moved into itself). +// +// git worktree move refuses to move the main working tree, so this uses +// filesystem renames and git worktree repair to fix linked worktree gitdirs. +func migrateMainWorktree(repository *Repository, stderr io.Writer, mainPath string, targetPath string) error { + if _, err := os.Stat(targetPath); err == nil { + return fmt.Errorf("worktree directory %q already exists", targetPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect worktree directory %q: %w", targetPath, err) + } + + root := filepath.Dir(mainPath) + temporaryPath := filepath.Join(root, ".git-wt-main-migrate") + if _, err := os.Stat(temporaryPath); err == nil { + return fmt.Errorf("temporary main migration path %q already exists", temporaryPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect temporary main migration path %q: %w", temporaryPath, err) + } + + if err := os.Rename(mainPath, temporaryPath); err != nil { + return fmt.Errorf("move main worktree to temporary path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return fmt.Errorf("create main parent directory %q: %w", filepath.Dir(targetPath), err) + } + if err := os.Rename(temporaryPath, targetPath); err != nil { + return fmt.Errorf("move main worktree to %q: %w", targetPath, err) + } + + repository.WorkTree = targetPath + repository.GitDir = filepath.Join(targetPath, ".git") + if _, err := repository.git("worktree", "repair"); err != nil { + return err + } + + message := fmt.Sprintf("migrated main to %s", targetPath) + _, err := fmt.Fprintf(stderr, "%s\n", statusStyle.Render(message)) + return err +} + +func applyMigrationCandidate(repository *Repository, candidate migrateCandidate) error { + if candidate.CurrentPath == "" { + if err := ensureWorktreeDirectory(candidate.TargetPath); err != nil { + return err + } + _, err := repository.git("worktree", "add", candidate.TargetPath, candidate.Name) + return err + } + + currentPath := filepath.Clean(candidate.CurrentPath) + targetPath := filepath.Clean(candidate.TargetPath) + if pathIsWithin(currentPath, targetPath) { + return moveWorktreeViaTemporaryPath(repository, currentPath, targetPath) + } + + parent := filepath.Dir(targetPath) + if err := os.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("create worktree parent directory %q: %w", parent, err) + } + _, err := repository.git("worktree", "move", currentPath, targetPath) + return err +} + +// pathIsWithin reports whether child is the same as parent or nested under it. +func pathIsWithin(parent string, child string) bool { + relativePath, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return relativePath == "." || (relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator))) +} + +// moveWorktreeViaTemporaryPath moves a worktree to a path nested under its +// current location (e.g. /feature -> /feature/repo). +func moveWorktreeViaTemporaryPath(repository *Repository, currentPath string, targetPath string) error { + root := worktreeRoot(repository.WorkTree) + temporaryPath := filepath.Join(root, ".git-wt-migrate-"+filepath.Base(currentPath)) + // Disambiguate when Base collides (nested branch names share final segment). + if filepath.Clean(temporaryPath) == currentPath || filepath.Clean(temporaryPath) == targetPath { + temporaryPath = filepath.Join(root, ".git-wt-migrate-tmp") + } + if _, err := os.Stat(temporaryPath); err == nil { + return fmt.Errorf("temporary migration path %q already exists", temporaryPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect temporary migration path %q: %w", temporaryPath, err) + } + + if _, err := repository.git("worktree", "move", currentPath, temporaryPath); err != nil { + return err + } + parent := filepath.Dir(targetPath) + if err := os.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("create worktree parent directory %q: %w", parent, err) + } + _, err := repository.git("worktree", "move", temporaryPath, targetPath) + return err +} + func (huhMigratePrompter) Prompt(input io.Reader, output io.Writer, candidates []migrateCandidate) ([]migrateCandidate, error) { selectedNames := make([]string, 0, len(candidates)) options := lo.Map(candidates, func(candidate migrateCandidate, _ int) huh.Option[string] { diff --git a/internal/gitwt/gitwt_off.go b/internal/gitwt/gitwt_off.go new file mode 100644 index 0000000..17892c1 --- /dev/null +++ b/internal/gitwt/gitwt_off.go @@ -0,0 +1,237 @@ +package gitwt + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +type offCommandOptions struct { + force bool +} + +func NewOffCommand() *cobra.Command { + options := new(offCommandOptions) + + command := &cobra.Command{ + Use: "off", + Short: "Tear down managed worktrees and collapse to a single checkout", + Args: cobra.NoArgs, + RunE: options.Execute, + } + + command.Flags().BoolVarP(&options.force, "force", "f", false, "Allow dirty worktrees") + + return command +} + +func (x *offCommandOptions) Execute(command *cobra.Command, args []string) error { + repository, err := openRepository(".") + if err != nil { + return err + } + + worktrees, mainPath, err := managedWorktreesFromRepository(repository) + if err != nil { + return err + } + + if !mainIsNestedLayout(mainPath) { + return fmt.Errorf("main worktree is not in managed nested layout (%s)", mainPath) + } + + rootPath := worktreeRoot(mainPath) + if filepath.Clean(repository.WorkTree) != filepath.Clean(mainPath) { + repository, err = openRepository(mainPath) + if err != nil { + return err + } + } + + if err := ensureManagedWorktreesClean(worktrees, x.force); err != nil { + return err + } + + for _, worktree := range worktrees { + if worktree.Main { + continue + } + if err := removeManagedWorktreeForOff(repository, command.ErrOrStderr(), worktree, x.force); err != nil { + return err + } + } + + if err := collapseMainToRoot(repository, command.ErrOrStderr(), mainPath, rootPath); err != nil { + return err + } + + _, err = fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render("collapsed main to "+rootPath)) + return err +} + +func mainIsNestedLayout(mainPath string) bool { + return filepath.Base(filepath.Dir(mainPath)) == "main" && filepath.Base(mainPath) != "main" +} + +func ensureManagedWorktreesClean(worktrees []managedWorktree, force bool) error { + if force { + return nil + } + + for _, worktree := range worktrees { + worktreeRepository, err := openRepository(worktree.Path) + if err != nil { + return err + } + clean, err := worktreeRepository.isClean() + if err != nil { + return err + } + if !clean { + return fmt.Errorf("worktree %q is not clean", worktree.Name) + } + } + + return nil +} + +func removeManagedWorktreeForOff(repository *Repository, stderr io.Writer, worktree managedWorktree, force bool) error { + removeArguments := []string{"worktree", "remove"} + if force { + removeArguments = append(removeArguments, "--force") + } + removeArguments = append(removeArguments, worktree.Path) + if _, err := repository.git(removeArguments...); err != nil { + return err + } + + if _, err := fmt.Fprintf(stderr, "%s\n", statusStyle.Render("removed worktree "+worktree.Name)); err != nil { + return err + } + + branchExists, err := repository.branchStillExists(worktree.BranchReference) + if err != nil { + return err + } + if !branchExists { + return removeEmptyParents(worktree.Path, worktreeRoot(repository.WorkTree)) + } + + if _, err := repository.git("branch", "-d", worktree.Name); err != nil { + if _, writeErr := fmt.Fprintf(stderr, "%s\n", warningStyle.Render("kept branch "+worktree.Name+" (not fully merged)")); writeErr != nil { + return writeErr + } + } else { + if _, writeErr := fmt.Fprintf(stderr, "%s\n", statusStyle.Render("deleted branch "+worktree.Name)); writeErr != nil { + return writeErr + } + } + + return removeEmptyParents(worktree.Path, worktreeRoot(repository.WorkTree)) +} + +// collapseMainToRoot moves /main/ to via a temporary sibling +// path, then merges the checkout into the root directory. +func collapseMainToRoot(repository *Repository, stderr io.Writer, mainPath string, rootPath string) error { + parentOfRoot := filepath.Dir(rootPath) + temporaryPath := filepath.Join(parentOfRoot, ".git-wt-off-"+filepath.Base(rootPath)) + if _, err := os.Stat(temporaryPath); err == nil { + return fmt.Errorf("temporary off path %q already exists", temporaryPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect temporary off path %q: %w", temporaryPath, err) + } + + if err := os.Rename(mainPath, temporaryPath); err != nil { + return fmt.Errorf("move main worktree to temporary path: %w", err) + } + + if err := removeEmptyParents(mainPath, rootPath); err != nil { + return err + } + + if err := mergeDirectoryContents(temporaryPath, rootPath); err != nil { + return err + } + if err := os.Remove(temporaryPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove temporary off path %q: %w", temporaryPath, err) + } + + repository.WorkTree = rootPath + repository.GitDir = filepath.Join(rootPath, ".git") + if _, err := repository.git("worktree", "repair"); err != nil { + return err + } + + _, err := fmt.Fprintf(stderr, "%s\n", statusStyle.Render("moved main worktree to "+rootPath)) + return err +} + +func mergeDirectoryContents(sourceDirectory string, destinationDirectory string) error { + entries, err := os.ReadDir(sourceDirectory) + if err != nil { + return fmt.Errorf("read temporary checkout %q: %w", sourceDirectory, err) + } + + for _, entry := range entries { + sourcePath := filepath.Join(sourceDirectory, entry.Name()) + destinationPath := filepath.Join(destinationDirectory, entry.Name()) + if _, err := os.Stat(destinationPath); err == nil { + return fmt.Errorf("cannot collapse main to %q: %q already exists", destinationDirectory, destinationPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect %q: %w", destinationPath, err) + } + if err := os.Rename(sourcePath, destinationPath); err != nil { + return fmt.Errorf("move %q to %q: %w", sourcePath, destinationPath, err) + } + } + + return nil +} + +// removeEmptyParents removes path and empty ancestor directories up to (but not +// including) stopPath. +func removeEmptyParents(path string, stopPath string) error { + current := filepath.Clean(path) + stopPath = filepath.Clean(stopPath) + + for { + if current == stopPath || current == string(filepath.Separator) || current == "." { + return nil + } + if !pathIsWithin(stopPath, current) { + return nil + } + + err := os.Remove(current) + if err == nil { + current = filepath.Dir(current) + continue + } + if os.IsNotExist(err) { + current = filepath.Dir(current) + continue + } + // Directory not empty or not removable — stop walking up. + if isNotEmptyError(err) { + return nil + } + return fmt.Errorf("remove %q: %w", current, err) + } +} + +func isNotEmptyError(err error) bool { + if err == nil { + return false + } + // POSIX: directory not empty; also match path errors from os.Remove. + if pathError, ok := err.(*fs.PathError); ok { + err = pathError.Err + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "not empty") || strings.Contains(message, "directory not empty") +} diff --git a/internal/gitwt/gitwt_test.go b/internal/gitwt/gitwt_test.go index 6c06466..52719e3 100644 --- a/internal/gitwt/gitwt_test.go +++ b/internal/gitwt/gitwt_test.go @@ -617,12 +617,15 @@ func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { "command git-wt create \"${forward[@]}\"", "switch)", "remove)", + "off)", "command git-wt \"$@\"", "cd \"$main_dir\"", + "cd \"$root_dir\"", "cd \"$target_dir\"", - "$root_dir/$name", - "$root_dir/$arg", - "local root_dir=${main_dir:h}", + "$root_dir/$name/$repo_name", + "$root_dir/$arg/$repo_name", + "local root_dir=${main_dir:h:h}", + "local repo_name=${main_dir:t}", "git worktree list --porcelain", "Usage: " + functionName + " switch ", } { @@ -648,6 +651,7 @@ func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { "#compdef " + functionName, "switch:Switch to a worktree", "remove:Remove a managed Git worktree", + "off:Tear down managed worktrees into a single checkout", "create:Create a managed Git worktree", "case $words[2] in", "create)", @@ -659,7 +663,7 @@ func TestGenerateZshGeneratesWrapperFunctionAndCompletion(t *testing.T) { "worktrees=(main)", "worktree_path=${line#worktree }", "branch=${line#branch refs/heads/}", - "$worktree_path\" == \"$root_dir/$branch", + "$worktree_path\" == \"$root_dir/$branch/$repo_name\"", } { if !strings.Contains(completionText, want) { t.Fatalf("completion missing %q:\n%s", want, completionText) @@ -932,6 +936,181 @@ func TestMigrateRenamesExistingUnmanagedWorktrees(t *testing.T) { assertCurrentBranchAtPath(t, testRepository.mainPath, "main") } +func TestOffCollapsesMainOnlyLayout(t *testing.T) { + testRepository := newTestRepository(t) + + result := testRepository.runGitWT(t, "off") + if result.err != nil { + t.Fatalf("off failed: %v\n%s", result.err, result.stderr) + } + + assertMainWorktreePath(t, testRepository.rootPath) + assertCurrentBranchAtPath(t, testRepository.rootPath, "main") + testRepository.assertPathMissing(t, testRepository.mainPath) + if !strings.Contains(result.stderr, "collapsed main to") { + t.Fatalf("expected collapse message, got stderr:\n%s", result.stderr) + } +} + +func TestOffCollapsesLayoutAndDeletesMergedBranch(t *testing.T) { + const branchName = "feature/off-merged" + + testRepository := newTestRepository(t) + createResult := testRepository.runGitWT(t, "create", branchName) + if createResult.err != nil { + t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) + } + testRepository.mergeWorktreeBranch(t, branchName) + + result := testRepository.runGitWT(t, "off") + if result.err != nil { + t.Fatalf("off failed: %v\n%s", result.err, result.stderr) + } + + assertMainWorktreePath(t, testRepository.rootPath) + assertCurrentBranchAtPath(t, testRepository.rootPath, "main") + testRepository.assertPathMissing(t, testRepository.mainPath) + testRepository.assertPathMissing(t, testRepository.worktreePath(branchName)) + testRepository.mainPath = testRepository.rootPath + testRepository.assertBranchMissing(t, branchName) + if !strings.Contains(result.stderr, "collapsed main to") { + t.Fatalf("expected collapse message, got stderr:\n%s", result.stderr) + } +} + +func TestOffKeepsUnmergedBranch(t *testing.T) { + const branchName = "feature/off-unmerged" + const dirtyFileName = "unmerged.txt" + + testRepository := newTestRepository(t) + createResult := testRepository.runGitWT(t, "create", branchName) + if createResult.err != nil { + t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) + } + + worktreePath := testRepository.worktreePath(branchName) + testRepository.writeFile(t, filepath.Join(worktreePath, dirtyFileName), "keep me\n") + runGitCommand(t, worktreePath, "add", dirtyFileName) + runGitCommand(t, worktreePath, "commit", "-m", "unmerged change") + + result := testRepository.runGitWT(t, "off") + if result.err != nil { + t.Fatalf("off failed: %v\n%s", result.err, result.stderr) + } + + assertMainWorktreePath(t, testRepository.rootPath) + testRepository.mainPath = testRepository.rootPath + testRepository.assertBranchPresent(t, branchName) + if !strings.Contains(result.stderr, "kept branch "+branchName) { + t.Fatalf("expected kept branch warning, got stderr:\n%s", result.stderr) + } +} + +func TestOffFailsWhenDirtyWithoutForce(t *testing.T) { + const branchName = "feature/off-dirty" + const dirtyFileName = "dirty.txt" + + testRepository := newTestRepository(t) + createResult := testRepository.runGitWT(t, "create", branchName) + if createResult.err != nil { + t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) + } + testRepository.writeFile(t, filepath.Join(testRepository.worktreePath(branchName), dirtyFileName), "dirty\n") + + result := testRepository.runGitWT(t, "off") + if result.err == nil { + t.Fatal("expected off to fail for dirty worktree") + } + if !strings.Contains(result.err.Error(), "is not clean") { + t.Fatalf("expected dirty error, got: %v", result.err) + } + testRepository.assertPathPresent(t, testRepository.mainPath) + testRepository.assertPathPresent(t, testRepository.worktreePath(branchName)) +} + +func TestOffForceRemovesDirtyWorktree(t *testing.T) { + const branchName = "feature/off-force-dirty" + const dirtyFileName = "dirty.txt" + + testRepository := newTestRepository(t) + createResult := testRepository.runGitWT(t, "create", branchName) + if createResult.err != nil { + t.Fatalf("create failed: %v\n%s", createResult.err, createResult.stderr) + } + testRepository.writeFile(t, filepath.Join(testRepository.worktreePath(branchName), dirtyFileName), "dirty\n") + + result := testRepository.runGitWT(t, "off", "--force") + if result.err != nil { + t.Fatalf("off --force failed: %v\n%s", result.err, result.stderr) + } + + assertMainWorktreePath(t, testRepository.rootPath) + assertCurrentBranchAtPath(t, testRepository.rootPath, "main") + testRepository.assertPathMissing(t, testRepository.mainPath) + testRepository.assertPathMissing(t, testRepository.worktreePath(branchName)) +} + +func TestOffFailsWhenMainIsNotNested(t *testing.T) { + testRepository := newOldLayoutTestRepository(t) + + result := testRepository.runGitWT(t, "off") + if result.err == nil { + t.Fatal("expected off to fail for non-nested main layout") + } + if !strings.Contains(result.err.Error(), "not in managed nested layout") { + t.Fatalf("expected nested layout error, got: %v", result.err) + } +} + +func TestMigrateMovesMainIntoNestedLayout(t *testing.T) { + testRepository := newOldLayoutTestRepository(t) + oldMainPath := testRepository.mainPath + nestedMainPath := migratedMainPath(oldMainPath) + + result := testRepository.runGitWT(t, "migrate") + if result.err != nil { + t.Fatalf("migrate failed: %v\n%s", result.err, result.stderr) + } + + // /main remains as the intermediate directory containing . + testRepository.assertPathPresent(t, nestedMainPath) + assertCurrentBranchAtPath(t, nestedMainPath, "main") + assertMainWorktreePath(t, nestedMainPath) + if filepath.Dir(nestedMainPath) != oldMainPath { + t.Fatalf("expected nested main under %s, got %s", oldMainPath, nestedMainPath) + } + if !strings.Contains(result.stderr, "migrated main to") { + t.Fatalf("expected main migration message, got stderr:\n%s", result.stderr) + } +} + +func TestMigrateMovesMainAndOldLayoutFeatureWorktrees(t *testing.T) { + const branchName = "feature/login" + + testRepository := newOldLayoutTestRepository(t) + oldFeaturePath := filepath.Join(testRepository.rootPath, branchName) + nestedMainPath := migratedMainPath(testRepository.mainPath) + nestedFeaturePath := managedWorktreePath(nestedMainPath, branchName) + + testRepository.createLocalBranch(t, branchName) + runGitCommand(t, testRepository.mainPath, "worktree", "add", oldFeaturePath, branchName) + + result := testRepository.runGitWT(t, "migrate") + if result.err != nil { + t.Fatalf("migrate failed: %v\n%s", result.err, result.stderr) + } + + // Old feature path remains as the intermediate directory containing . + testRepository.assertPathPresent(t, nestedMainPath) + testRepository.assertPathPresent(t, nestedFeaturePath) + assertCurrentBranchAtPath(t, nestedMainPath, "main") + assertCurrentBranchAtPath(t, nestedFeaturePath, branchName) + assertMainWorktreePath(t, nestedMainPath) + if filepath.Dir(nestedFeaturePath) != oldFeaturePath { + t.Fatalf("expected nested feature under %s, got %s", oldFeaturePath, nestedFeaturePath) + } +} + func TestMigrateCreatesWorktreesForExistingBranches(t *testing.T) { const branchOne = "feature/alpha" const branchTwo = "feature/beta" @@ -999,14 +1178,37 @@ type testRepository struct { remotePath string } +const testRepoName = "repo" + func newTestRepository(t *testing.T) testRepository { t.Helper() - t.Setenv("HERDR_ENV", "") - rootPath := t.TempDir() - remotePath := filepath.Join(rootPath, "remote.git") + mainPath := filepath.Join(rootPath, "main", testRepoName) + return initTestRepository(t, rootPath, mainPath) +} + +// newOldLayoutTestRepository creates a repository with main at /main +// (pre-nested layout) so migrate can move main into /main/. +func newOldLayoutTestRepository(t *testing.T) testRepository { + t.Helper() + // Root basename becomes the repo name when migrating main. + rootPath := filepath.Join(t.TempDir(), testRepoName) mainPath := filepath.Join(rootPath, "main") + return initTestRepository(t, rootPath, mainPath) +} + +func initTestRepository(t *testing.T, rootPath string, mainPath string) testRepository { + t.Helper() + t.Setenv("HERDR_ENV", "") + + if err := os.MkdirAll(filepath.Dir(mainPath), 0o755); err != nil { + t.Fatalf("create main parent: %v", err) + } + if err := os.MkdirAll(rootPath, 0o755); err != nil { + t.Fatalf("create root: %v", err) + } + remotePath := filepath.Join(rootPath, "remote.git") runGitCommand(t, rootPath, "init", "--bare", remotePath) runGitCommand(t, rootPath, "init", "--initial-branch=main", mainPath) runGitCommand(t, mainPath, "config", "user.name", "Test User") @@ -1123,6 +1325,20 @@ func assertCurrentBranchAtPath(t *testing.T, path string, branchName string) { } } +func assertMainWorktreePath(t *testing.T, wantPath string) { + t.Helper() + output := runGitCommand(t, wantPath, "worktree", "list", "--porcelain") + firstLine := strings.SplitN(strings.TrimSpace(output), "\n", 2)[0] + const prefix = "worktree " + if !strings.HasPrefix(firstLine, prefix) { + t.Fatalf("unexpected worktree list output: %s", output) + } + gotPath := strings.TrimPrefix(firstLine, prefix) + if filepath.Clean(gotPath) != filepath.Clean(wantPath) { + t.Fatalf("expected main worktree path %s, got %s", wantPath, gotPath) + } +} + func assertCurrentBranch(t *testing.T, branchName string) { t.Helper() currentBranch := strings.TrimSpace(runGitCommand(t, "", "branch", "--show-current"))