From 7cf71cf19bf835c947242494b0196e592aa76a88 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 21:33:21 +0000 Subject: [PATCH 1/7] Refactor: Extract short-name helper for worktree leaf directory Introduce repoShortName for the final repo-name segment and use it in bare/worktree path helpers. Simple names behave identically; this prepares the path layout for structured names like nnutter/timber. --- internal/timber/paths.go | 11 +++++++++++ internal/timber/runtime.go | 13 +++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/timber/paths.go b/internal/timber/paths.go index 241c7a9..58dbf0e 100644 --- a/internal/timber/paths.go +++ b/internal/timber/paths.go @@ -15,6 +15,17 @@ func normalizeRepoName(name string) string { return strings.TrimSuffix(strings.TrimSpace(name), bareRepoSuffix) } +// repoShortName returns the final segment of a repository name. Simple names +// return unchanged; structured names (e.g. "nnutter/timber") drop the +// grouping prefix ("timber"). The short name is the leaf directory of a +// managed worktree checkout. +func repoShortName(name string) string { + if _, short, found := strings.CutLast(name, "/"); found { + return short + } + return name +} + func ensureDirectory(path string) error { if err := os.MkdirAll(path, 0o755); err != nil { return fmt.Errorf("create directory %q: %w", path, err) diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index c74dc9b..78b0470 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -131,13 +131,13 @@ func (x Runtime) worktreeRoot() string { } func (x Runtime) bareRepoPath(repoName string) string { - return filepath.Join(x.reposDirectory(), repoName+bareRepoSuffix) + return filepath.Join(x.reposDirectory(), filepath.FromSlash(repoName)+bareRepoSuffix) } // managedWorktreePath returns -// ///. +// ///. func (x Runtime) managedWorktreePath(repoName string, worktreeName string) string { - return filepath.Join(x.worktreeRoot(), repoName, worktreeName, repoName) + return filepath.Join(x.worktreeRoot(), filepath.FromSlash(repoName), worktreeName, repoShortName(repoName)) } func (x Runtime) absolutePath(path string) (string, error) { @@ -640,9 +640,10 @@ func (x Runtime) openRegisteredRepository(name string) (*Repository, registeredR } // managedWorktreeNamesOnDisk lists worktree names under the managed root for repoName -// (layout: ///), filtered by toComplete prefix. +// (layout: ///), filtered by toComplete prefix. func (x Runtime) managedWorktreeNamesOnDisk(repoName string, toComplete string) []string { - repoRoot := filepath.Join(x.worktreeRoot(), repoName) + repoRoot := filepath.Join(x.worktreeRoot(), filepath.FromSlash(repoName)) + shortName := repoShortName(repoName) var names []string _ = filepath.WalkDir(repoRoot, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { @@ -651,7 +652,7 @@ func (x Runtime) managedWorktreeNamesOnDisk(repoName string, toComplete string) if !entry.IsDir() { return nil } - if entry.Name() != repoName { + if entry.Name() != shortName { return nil } if _, err := os.Stat(filepath.Join(path, ".git")); err != nil { From 923cbdb9d483ee1fd5b91b086cfcaa9d63b09e78 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 21:34:58 +0000 Subject: [PATCH 2/7] Allow structured repository names with nested paths Accept slash-separated names like nnutter/timber. Bare repos live at repos/nnutter/timber.git and worktrees at /nnutter/timber//timber, so the leaf keeps the short name. Discovery walks repos recursively and rename ensures nested destination parents. --- internal/timber/paths.go | 10 ++-- internal/timber/paths_test.go | 24 ++++++++++ internal/timber/repo.go | 23 +++++++-- .../timber/repo_rename_functional_test.go | 10 +++- internal/timber/repo_rename_plan.go | 3 ++ internal/timber/repo_structured_test.go | 48 +++++++++++++++++++ internal/timber/runtime.go | 40 ++++++++++------ internal/timber/test_helpers_test.go | 3 +- 8 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 internal/timber/repo_structured_test.go diff --git a/internal/timber/paths.go b/internal/timber/paths.go index 58dbf0e..abd9fb8 100644 --- a/internal/timber/paths.go +++ b/internal/timber/paths.go @@ -9,10 +9,14 @@ import ( const bareRepoSuffix = ".git" -// normalizeRepoName strips a trailing ".git" so worktree paths use the short -// repo name (e.g. "roam") rather than the bare-dir style name ("roam.git"). +// normalizeRepoName strips surrounding whitespace and slashes plus a +// trailing ".git" so worktree paths use the clean repo name (e.g. "roam") +// rather than the bare-dir style name ("roam.git"). Structured names keep +// their grouping prefix ("nnutter/timber"). func normalizeRepoName(name string) string { - return strings.TrimSuffix(strings.TrimSpace(name), bareRepoSuffix) + trimmed := strings.Trim(strings.TrimSpace(name), "/") + trimmed = strings.TrimSuffix(trimmed, bareRepoSuffix) + return strings.Trim(trimmed, "/") } // repoShortName returns the final segment of a repository name. Simple names diff --git a/internal/timber/paths_test.go b/internal/timber/paths_test.go index ea9b1e4..b2110f2 100644 --- a/internal/timber/paths_test.go +++ b/internal/timber/paths_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNormalizeRepoNameStripsGitSuffix(t *testing.T) { @@ -11,4 +12,27 @@ func TestNormalizeRepoNameStripsGitSuffix(t *testing.T) { assert.Equal(t, "roam", normalizeRepoName("roam.git")) assert.Equal(t, "roam", normalizeRepoName(" roam.git ")) assert.Equal(t, "roam", normalizeRepoName("roam")) + assert.Equal(t, "nnutter/timber", normalizeRepoName("nnutter/timber.git")) + assert.Equal(t, "nnutter/timber", normalizeRepoName("nnutter/timber/")) + assert.Equal(t, "nnutter/timber", normalizeRepoName("/nnutter/timber/")) +} + +func TestRepoShortName(t *testing.T) { + t.Parallel() + assert.Equal(t, "roam", repoShortName("roam")) + assert.Equal(t, "timber", repoShortName("nnutter/timber")) + assert.Equal(t, "c", repoShortName("a/b/c")) +} + +func TestValidateRepoName(t *testing.T) { + t.Parallel() + for _, name := range []string{"roam", "nnutter/timber", "a/b/c"} { + require.NoError(t, validateRepoName(name), "name %q", name) + } + for _, name := range []string{ + "", "roam.git", "nnutter/timber.git", "a//b", "/a/b", "a/b/", + "a/./b", "a/../b", ".", "..", "a\\b", "a@b", "a:b", + } { + require.Error(t, validateRepoName(name), "name %q", name) + } } diff --git a/internal/timber/repo.go b/internal/timber/repo.go index 391591f..56efd47 100644 --- a/internal/timber/repo.go +++ b/internal/timber/repo.go @@ -2,7 +2,6 @@ package timber import ( "fmt" - "path/filepath" "strings" "github.com/spf13/cobra" @@ -54,11 +53,25 @@ func validateRepoName(name string) error { if strings.HasSuffix(name, bareRepoSuffix) { return fmt.Errorf("repository name %q must not end with %s", name, bareRepoSuffix) } - if strings.Contains(name, "/") || strings.Contains(name, string(filepath.Separator)) { - return fmt.Errorf("repository name %q must not contain path separators", name) + if strings.Contains(name, "\\") { + return fmt.Errorf("repository name %q must not contain \\ character", name) } - if name == "." || name == ".." { - return fmt.Errorf("repository name %q is invalid", name) + if strings.Contains(name, "@") { + return fmt.Errorf("repository name %q must not contain @ character", name) + } + if strings.Contains(name, ":") { + return fmt.Errorf("repository name %q must not contain : character", name) + } + if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.Contains(name, "//") { + return fmt.Errorf("repository name %q must not have empty path segments", name) + } + for segment := range strings.SplitSeq(name, "/") { + if segment == "" { + return fmt.Errorf("repository name %q must not have empty path segments", name) + } + if segment == "." || segment == ".." { + return fmt.Errorf("repository name %q is invalid", name) + } } return nil } diff --git a/internal/timber/repo_rename_functional_test.go b/internal/timber/repo_rename_functional_test.go index f1d1a93..9b6ca7b 100644 --- a/internal/timber/repo_rename_functional_test.go +++ b/internal/timber/repo_rename_functional_test.go @@ -122,9 +122,15 @@ func TestRepoRenameRejectsInvalidAndConflictingNames(t *testing.T) { }, { name: "invalid name", - newName: "invalid/name", + newName: "invalid//name", prepare: func(*testing.T, testRepository) {}, - wantError: "must not contain path separators", + wantError: "empty path segments", + }, + { + name: "invalid at", + newName: "invalid@name", + prepare: func(*testing.T, testRepository) {}, + wantError: "must not contain @", }, { name: "repository collision", diff --git a/internal/timber/repo_rename_plan.go b/internal/timber/repo_rename_plan.go index 67fa0f7..0c109d8 100644 --- a/internal/timber/repo_rename_plan.go +++ b/internal/timber/repo_rename_plan.go @@ -151,6 +151,9 @@ func (x repositoryRenamePlan) apply(renamePath renamePathFunc, repairWorktrees r completedMoves = append(completedMoves, move) } + if err := ensureDirectory(filepath.Dir(x.destinationRepo.BarePath)); err != nil { + return errors.Join(err, x.rollback(renamePath, repairWorktrees, completedMoves, false)) + } if err := renamePath(x.sourceRepo.BarePath, x.destinationRepo.BarePath); err != nil { return errors.Join( fmt.Errorf("rename bare repository %q to %q: %w", x.sourceRepo.BarePath, x.destinationRepo.BarePath, err), diff --git a/internal/timber/repo_structured_test.go b/internal/timber/repo_structured_test.go new file mode 100644 index 0000000..832fd1f --- /dev/null +++ b/internal/timber/repo_structured_test.go @@ -0,0 +1,48 @@ +package timber + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStructuredRepoRenameMovesWorktreeHierarchy(t *testing.T) { + t.Parallel() + const branchName = "feature/structured" + + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + + result := testRepository.runTimber(t, "repo", "rename", testRepoName, "nnutter/timber") + require.NoError(t, result.err, result.stderr) + + runtime := testRepository.runtime + assert.DirExists(t, runtime.bareRepoPath("nnutter/timber")) + assert.DirExists(t, runtime.managedWorktreePath("nnutter/timber", branchName)) + assert.NoDirExists(t, testRepository.barePath) + assert.NoDirExists(t, testRepository.worktreePath(branchName)) + + listResult := runTimberCommandWithRuntime(t, runtime, "list", at("nnutter/timber", "")) + require.NoError(t, listResult.err, listResult.stderr) + assert.Contains(t, listResult.stdout, branchName) + + back := runTimberCommandWithRuntime(t, runtime, "repo", "rename", "nnutter/timber", "timber") + require.NoError(t, back.err, back.stderr) + assert.DirExists(t, runtime.bareRepoPath("timber")) + assert.DirExists(t, runtime.managedWorktreePath("timber", branchName)) +} + +func TestStructuredRepoCreateUsesNestedLayout(t *testing.T) { + t.Parallel() + fixture := newTestRepository(t) + registerAdditionalRepo(t, fixture, "acme/timber") + + result := fixture.runTimber(t, "create", at("acme/timber", "feature/nested")) + require.NoError(t, result.err, result.stderr) + assert.DirExists(t, fixture.runtime.managedWorktreePath("acme/timber", "feature/nested")) + + quiet := runTimberCommandWithRuntime(t, fixture.runtime, "repo", "list", "-q") + require.NoError(t, quiet.err, quiet.stderr) + assert.Contains(t, quiet.stdout, "acme/timber\n") +} diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 78b0470..4887d6c 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -576,8 +576,7 @@ func (x Runtime) completeWorktreeNamesAcrossRepos(toComplete string) ([]string, func (x Runtime) listRegisteredRepos() ([]registeredRepo, error) { directory := x.reposDirectory() - entries, err := os.ReadDir(directory) - if err != nil { + if _, err := os.Stat(directory); err != nil { if errors.Is(err, os.ErrNotExist) { return make([]registeredRepo, 0), nil } @@ -585,26 +584,37 @@ func (x Runtime) listRegisteredRepos() ([]registeredRepo, error) { } repos := make([]registeredRepo, 0) - for _, entry := range entries { - name := entry.Name() - repoName, found := strings.CutSuffix(name, bareRepoSuffix) - if !found || repoName == "" { - continue + walkErr := filepath.WalkDir(directory, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return nil } - - fullPath := filepath.Join(directory, name) - info, err := os.Stat(fullPath) + if path == directory { + return nil + } + if !entry.IsDir() { + return nil + } + if !strings.HasSuffix(entry.Name(), bareRepoSuffix) { + return nil + } + relative, err := filepath.Rel(directory, path) if err != nil { - return nil, fmt.Errorf("stat registered repo %q: %w", name, err) + return nil } - if !info.IsDir() { - continue + repoName := filepath.ToSlash(strings.TrimSuffix(relative, bareRepoSuffix)) + if err := validateRepoName(repoName); err != nil { + return filepath.SkipDir } - repos = append(repos, registeredRepo{ Name: repoName, - BarePath: fullPath, + BarePath: path, }) + // Bare repositories contain their own object database; never + // descend into them looking for nested registrations. + return filepath.SkipDir + }) + if walkErr != nil { + return nil, fmt.Errorf("read repos directory %q: %w", directory, walkErr) } slices.SortFunc(repos, func(left, right registeredRepo) int { diff --git a/internal/timber/test_helpers_test.go b/internal/timber/test_helpers_test.go index 64ff79a..752f08f 100644 --- a/internal/timber/test_helpers_test.go +++ b/internal/timber/test_helpers_test.go @@ -415,7 +415,8 @@ func registerAdditionalRepo(t *testing.T, base testRepository, name string) stri require.NoError(t, fixture.err) reposDir := filepath.Join(base.home, ".local", "share", "timber", "repos") - barePath := filepath.Join(reposDir, name+".git") + barePath := filepath.Join(reposDir, filepath.FromSlash(name)+".git") + require.NoError(t, os.MkdirAll(filepath.Dir(barePath), 0o755)) require.NoError(t, os.CopyFS(barePath, os.DirFS(fixture.fixture.barePath))) require.NoError(t, replaceGitRemotePath(barePath, fixture.fixture.remotePath, base.remotePath)) return barePath From 34d0080a722f9c2e5c5ace916bc3ef10d6b714c8 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 21:35:27 +0000 Subject: [PATCH 3/7] Derive structured default repo names from remotes GitHub URLs default to owner/repo and other hosted paths keep their full grouping; local paths still use the basename. This makes repo add nnutter/timber register the structured name directly. --- internal/timber/remote_url.go | 47 +++++++++++++++++++++++------- internal/timber/remote_url_test.go | 16 ++++++++-- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/internal/timber/remote_url.go b/internal/timber/remote_url.go index ed58cca..1c6d3ea 100644 --- a/internal/timber/remote_url.go +++ b/internal/timber/remote_url.go @@ -105,20 +105,47 @@ func defaultRepoAliasFromRemote(remoteURL string) string { return repoPath } +// defaultRepoNameFromRemote derives a structured repository name from a +// remote URL. Hosted paths preserve their grouping (e.g. GitHub +// "nnutter/timber", GitLab "group/sub/repo"); local filesystem paths fall +// back to their basename. func defaultRepoNameFromRemote(remoteURL string) (string, error) { - name := remoteURL - if strings.HasPrefix(name, "git@") { - _, remainder, found := strings.Cut(name, ":") - if found { - name = remainder + trimmed := strings.TrimSpace(remoteURL) + if trimmed == "" { + return "", fmt.Errorf("could not derive repository name from %q", remoteURL) + } + if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, ".") || strings.HasPrefix(trimmed, "~") { + name := strings.TrimSuffix(path.Base(strings.TrimSuffix(trimmed, "/")), bareRepoSuffix) + if name == "" || name == "." || name == "/" { + return "", fmt.Errorf("could not derive repository name from %q", remoteURL) + } + return name, nil + } + + var repoPath string + switch { + case strings.HasPrefix(trimmed, "git@"): + _, remainder, found := strings.Cut(trimmed, ":") + if !found { + return "", fmt.Errorf("could not derive repository name from %q", remoteURL) + } + repoPath = remainder + case strings.Contains(trimmed, "://"): + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Path == "" { + return "", fmt.Errorf("could not derive repository name from %q", remoteURL) + } + repoPath = parsed.Path + default: + if _, remainder, found := strings.Cut(trimmed, ":"); found { + repoPath = remainder + } else { + repoPath = trimmed } - } else if parsed, err := url.Parse(name); err == nil && parsed.Path != "" { - name = parsed.Path } - name = strings.TrimSuffix(name, "/") - name = path.Base(name) - name = strings.TrimSuffix(name, bareRepoSuffix) + name := strings.TrimSuffix(strings.Trim(repoPath, "/"), bareRepoSuffix) + name = strings.Trim(name, "/") if name == "" || name == "." || name == "/" { return "", fmt.Errorf("could not derive repository name from %q", remoteURL) } diff --git a/internal/timber/remote_url_test.go b/internal/timber/remote_url_test.go index c1711d0..6c405cf 100644 --- a/internal/timber/remote_url_test.go +++ b/internal/timber/remote_url_test.go @@ -18,11 +18,23 @@ func TestDefaultRepoNameFromRemote(t *testing.T) { t.Parallel() name, err := defaultRepoNameFromRemote("https://github.com/nnutter/timber.git") require.NoError(t, err) - assert.Equal(t, "timber", name) + assert.Equal(t, "nnutter/timber", name) name, err = defaultRepoNameFromRemote("git@github.com:nnutter/timber.git") require.NoError(t, err) - assert.Equal(t, "timber", name) + assert.Equal(t, "nnutter/timber", name) + + name, err = defaultRepoNameFromRemote("git@gitlab.com:group/sub/repo.git") + require.NoError(t, err) + assert.Equal(t, "group/sub/repo", name) + + name, err = defaultRepoNameFromRemote("https://example.com/r.git") + require.NoError(t, err) + assert.Equal(t, "r", name) + + name, err = defaultRepoNameFromRemote("/local/path/roam.git") + require.NoError(t, err) + assert.Equal(t, "roam", name) } func TestDefaultRepoAliasFromRemote(t *testing.T) { From 6c19b055c582e2bad8a7601864878643379dc459 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 21:36:08 +0000 Subject: [PATCH 4/7] Prune empty repo grouping directories on rename and remove Moving repos/nnutter/timber.git away leaves repos/nnutter behind; rename and remove now prune newly-empty bare parents up to the store. Worktree parents were already pruned to home. Git cannot move the bare itself, so rename keeps using git worktree repair to repoint checkouts. --- internal/timber/repo_remove.go | 3 +++ internal/timber/repo_rename_plan.go | 3 +++ internal/timber/repo_structured_test.go | 15 +++++++++++++++ internal/timber/runtime.go | 7 +++++++ 4 files changed, 28 insertions(+) diff --git a/internal/timber/repo_remove.go b/internal/timber/repo_remove.go index 246e53e..02fff46 100644 --- a/internal/timber/repo_remove.go +++ b/internal/timber/repo_remove.go @@ -70,6 +70,9 @@ func (x *repoRemoveCommandOptions) Execute(command *cobra.Command, args []string if err := os.RemoveAll(repo.BarePath); err != nil { return fmt.Errorf("remove bare repository %q: %w", repo.BarePath, err) } + if err := x.runtime.removeEmptyBareParents(repo.BarePath); err != nil { + return err + } _, err = fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render("removed repository "+repoName)) return err diff --git a/internal/timber/repo_rename_plan.go b/internal/timber/repo_rename_plan.go index 0c109d8..7973303 100644 --- a/internal/timber/repo_rename_plan.go +++ b/internal/timber/repo_rename_plan.go @@ -169,6 +169,9 @@ func (x repositoryRenamePlan) apply(renamePath renamePathFunc, repairWorktrees r return err } } + if err := x.runtime.removeEmptyBareParents(x.sourceRepo.BarePath); err != nil { + return err + } return nil } diff --git a/internal/timber/repo_structured_test.go b/internal/timber/repo_structured_test.go index 832fd1f..7cb5829 100644 --- a/internal/timber/repo_structured_test.go +++ b/internal/timber/repo_structured_test.go @@ -1,6 +1,7 @@ package timber import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -31,6 +32,20 @@ func TestStructuredRepoRenameMovesWorktreeHierarchy(t *testing.T) { require.NoError(t, back.err, back.stderr) assert.DirExists(t, runtime.bareRepoPath("timber")) assert.DirExists(t, runtime.managedWorktreePath("timber", branchName)) + // Empty grouping directories are pruned on the way back to a simple name. + assert.NoDirExists(t, filepath.Join(runtime.reposDirectory(), "nnutter")) + assert.NoDirExists(t, filepath.Join(runtime.WorktreeRoot, "nnutter")) +} + +func TestStructuredRepoRemovePrunesEmptyParents(t *testing.T) { + t.Parallel() + fixture := newTestRepository(t) + registerAdditionalRepo(t, fixture, "acme/timber") + + result := fixture.runTimber(t, "repo", "remove", "acme/timber") + require.NoError(t, result.err, result.stderr) + assert.NoDirExists(t, fixture.runtime.bareRepoPath("acme/timber")) + assert.NoDirExists(t, filepath.Join(fixture.runtime.reposDirectory(), "acme")) } func TestStructuredRepoCreateUsesNestedLayout(t *testing.T) { diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 4887d6c..39a4e78 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -371,6 +371,13 @@ func (x Runtime) removeEmptySourceParents(path string) error { return removeEmptyParents(path, x.HomeDirectory) } +// removeEmptyBareParents prunes newly-empty grouping directories left +// behind in the bare-repo store (e.g. repos/nnutter/ after moving +// repos/nnutter/timber.git away). It never removes the store itself. +func (x Runtime) removeEmptyBareParents(path string) error { + return removeEmptyParents(path, x.reposDirectory()) +} + func (x Runtime) writePathFile(pathFile string, value string) (err error) { temporaryDirectory, err := x.absolutePath(x.TemporaryDirectory) if err != nil { From 2b5aee6c70eed8675e37fa5917e30e9a2d614472 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 21:36:33 +0000 Subject: [PATCH 5/7] Support nested repos in zsh completion List registrations with repos/**/*.git and derive slash-separated names so structured repos complete. Worktree checks use the short leaf name. --- internal/timber/generate_zsh.go | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/internal/timber/generate_zsh.go b/internal/timber/generate_zsh.go index 7bf87b6..473bb27 100644 --- a/internal/timber/generate_zsh.go +++ b/internal/timber/generate_zsh.go @@ -472,8 +472,10 @@ _` + x.name + `() { repos) local -a repos local repo_dir repo_name - for repo_dir in "$data_home"/timber/repos/*.git(N/); do - repos+=("${repo_dir:t:r}") + for repo_dir in "$data_home"/timber/repos/**/*.git(N/); do + repo_name=${repo_dir#$data_home/timber/repos/} + repo_name=${repo_name%.git} + repos+=("$repo_name") done _describe 'repositories' repos ;; @@ -489,8 +491,9 @@ _` + x.name + `() { fi return 0 fi - for repo_dir in "$data_home"/timber/repos/*.git(N/); do - repo_name=${repo_dir:t:r} + for repo_dir in "$data_home"/timber/repos/**/*.git(N/); do + repo_name=${repo_dir#$data_home/timber/repos/} + repo_name=${repo_name%.git} [[ $repo_name == ${repo_prefix}* ]] || continue qualifiers+=("${name}@$repo_name") done @@ -503,10 +506,12 @@ _` + x.name + `() { local worktree_name=${PREFIX%@*} local repo_prefix=${PREFIX##*@} [[ -n $worktree_name ]] || return 0 - for repo_dir in "$data_home"/timber/repos/*.git(N/); do - repo_name=${repo_dir:t:r} + for repo_dir in "$data_home"/timber/repos/**/*.git(N/); do + repo_name=${repo_dir#$data_home/timber/repos/} + repo_name=${repo_name%.git} [[ $repo_name == ${repo_prefix}* ]] || continue - [[ -e "$worktree_root/$repo_name/$worktree_name/$repo_name/.git" ]] || continue + local short=${repo_name##*/} + [[ -e "$worktree_root/$repo_name/$worktree_name/$short/.git" ]] || continue completions+=("$worktree_name@$repo_name") done (( $#completions )) && compadd -Q -- "${completions[@]}" @@ -514,9 +519,11 @@ _` + x.name + `() { fi local -A name_count local -A name_repos - for repo_dir in "$data_home"/timber/repos/*.git(N/); do - repo_name=${repo_dir:t:r} - for worktree_dir in "$worktree_root/$repo_name"/**/"$repo_name"(N/); do + for repo_dir in "$data_home"/timber/repos/**/*.git(N/); do + repo_name=${repo_dir#$data_home/timber/repos/} + repo_name=${repo_name%.git} + local short=${repo_name##*/} + for worktree_dir in "$worktree_root/$repo_name"/**/"$short"(N/); do [[ -e "$worktree_dir/.git" ]] || continue parent=${worktree_dir:h} name=${parent#$worktree_root/$repo_name/} From a5f8d72ff8fbd45e243001dd981c134a84863a5b Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 21:36:53 +0000 Subject: [PATCH 6/7] Document structured repository names Explain the nested bare/worktree layout, the owner/repo default for repo add, rename between simple and structured names, and repo-sort grouping. --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7233366..7cc174d 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,15 @@ There is no required “main” worktree. Repositories are stored as bare Git directories, and worktrees are created on demand under a shared root: -`///` +`///` + +Repository names may be structured with `/` (e.g. `nnutter/timber`). +The worktree leaf keeps only the short name (the last segment). Defaults: - bare repos: `$XDG_DATA_HOME/timber/repos/.git` (fallback: `~/.local/share/timber/repos/.git`) -- worktrees: `$TIMBER_WORKTREE_ROOT///` (fallback: `~/worktrees///`) +- worktrees: `$TIMBER_WORKTREE_ROOT///` (fallback: `~/worktrees///`) The worktree name and branch name are identical (including `/`). @@ -21,6 +24,20 @@ Example: - branch: `nn/my-feature` - worktree path: `~/worktrees/timber/nn/my-feature/timber` +Structured example (`t repo add nnutter/timber` defaults to the +structured name; GitHub URLs keep `owner/repo`, other hosted paths keep +their full grouping): + +- repo name: `nnutter/timber` +- bare repo: `~/.local/share/timber/repos/nnutter/timber.git` +- branch: `structured-names` +- worktree path: `~/worktrees/nnutter/timber/structured-names/timber` + +Rename with `timber repo rename ` to move between simple and +structured names; managed worktrees move with the repo. Sorting +`timber list --sort repo` orders by the full structured name, so the +grouping prefix controls grouping. + Use `timber repo import ` to register an existing clean clone and recreate its worktrees (including the former main checkout) in this layout. When invoked through the shell wrapper (`t repo import `), the shell also `cd`s to `$HOME` after success. From 172f8e70acf63de60e4f010e71dbd1059986de5c Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 25 Sep 2026 22:30:39 +0000 Subject: [PATCH 7/7] Delegate zsh completion to timber __complete The generated _t completion hand-maintained every subcommand's flags and argument states, so it drifted from the Cobra definitions: list completed only --help, missing --pr, --json, and --sort. Generate a completion that calls 'command timber __complete' instead, so flags, subcommands, and argument completions always match the CLI. The wrapper-only create --no-cd flag, which timber itself does not define, is layered on top. Pty completion tests now build the current source and put it on PATH so the delegation is exercised end to end rather than against whatever timber happens to be installed. Reviewed-by: Nathan Nutter Reviewed-at: dc7ef64ea49edbfc7194d6a8d4376621eea5dacd --- internal/timber/generate_test.go | 212 ++++++++++++-- internal/timber/generate_zsh.go | 470 ++++++++++++++----------------- 2 files changed, 406 insertions(+), 276 deletions(-) diff --git a/internal/timber/generate_test.go b/internal/timber/generate_test.go index 1cd6699..ddece12 100644 --- a/internal/timber/generate_test.go +++ b/internal/timber/generate_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -28,8 +29,8 @@ func TestGenerateZshGeneratesWrapperCompletionAndAutoloadHelper(t *testing.T) { completionContents, err := os.ReadFile(completionPath) require.NoError(t, err) assert.Equal(t, "#compdef t", strings.SplitN(string(completionContents), "\n", 2)[0]) - assert.NotContains(t, string(completionContents), "_foo() {") - assert.Contains(t, string(completionContents), "_t() {") + assert.NotContains(t, string(completionContents), "_foo()") + assert.Contains(t, string(completionContents), "_t()\n{") functionPath := filepath.Join(outDir, "t") functionContents, err := os.ReadFile(functionPath) @@ -52,7 +53,7 @@ func TestGeneratedZshCompletionHasValidSyntax(t *testing.T) { require.NoError(t, err, string(output)) } -func TestGeneratedTodoCompletesWorktrees(t *testing.T) { +func TestGeneratedCompletionDelegatesToTimber(t *testing.T) { t.Parallel() outDir := resolvedTempDir(t) @@ -61,23 +62,35 @@ func TestGeneratedTodoCompletesWorktrees(t *testing.T) { completionContents, err := os.ReadFile(filepath.Join(outDir, "_t")) require.NoError(t, err) completion := string(completionContents) - assert.Contains(t, completion, "'todo:Open the worktree-specific TODO.md'") - assert.Contains(t, completion, "todo)") - assert.Contains(t, completion, "'1:worktree name:->worktrees'") + // Flags and arguments come from 'timber __complete' so they cannot + // drift from the Cobra definitions; only wrapper-only flags are + // layered on top. + assert.Contains(t, completion, "command timber __complete") + assert.Contains(t, completion, "--no-cd:Create without changing directories") + assert.NotContains(t, completion, "->worktrees") + assert.NotContains(t, completion, "->repo_qualifiers") } -func TestGeneratedGitCompletesWorktrees(t *testing.T) { +func TestListFlagCompletionOffersPrJsonSort(t *testing.T) { t.Parallel() + testRepository := newTestRepository(t) - outDir := resolvedTempDir(t) - require.NoError(t, runTimberCommand(t, "generate", "zsh", "--out", outDir).err) + for _, command := range []string{"list", "ls"} { + stdout := runCompleteWithRuntime(t, testRepository.runtime, command, "--") + assert.Contains(t, stdout, "--pr", "command=%s", command) + assert.Contains(t, stdout, "--json", "command=%s", command) + assert.Contains(t, stdout, "--sort", "command=%s", command) + } +} - completionContents, err := os.ReadFile(filepath.Join(outDir, "_t")) - require.NoError(t, err) - completion := string(completionContents) - assert.Contains(t, completion, "'git:Run git with --git-dir set to a managed worktree'") - assert.Contains(t, completion, "git)") - assert.Contains(t, completion, "'1:worktree name:->worktrees'") +func TestListSortCompletionOffersModes(t *testing.T) { + t.Parallel() + testRepository := newTestRepository(t) + + stdout := runCompleteWithRuntime(t, testRepository.runtime, "list", "--sort", "") + assert.Contains(t, stdout, "recency") + assert.Contains(t, stdout, "repo") + assert.Contains(t, stdout, "worktree") } func TestGenerateZshUsesCustomWrapperName(t *testing.T) { @@ -99,8 +112,8 @@ func TestGenerateZshUsesCustomWrapperName(t *testing.T) { completionContents, err := os.ReadFile(filepath.Join(outDir, "_foo")) require.NoError(t, err) assert.Equal(t, "#compdef foo", strings.SplitN(string(completionContents), "\n", 2)[0]) - assert.Contains(t, string(completionContents), "_foo() {") - assert.NotContains(t, string(completionContents), "_t() {") + assert.Contains(t, string(completionContents), "_foo()\n{") + assert.NotContains(t, string(completionContents), "_t()") functionContents, err := os.ReadFile(filepath.Join(outDir, "foo")) require.NoError(t, err) @@ -108,6 +121,38 @@ func TestGenerateZshUsesCustomWrapperName(t *testing.T) { assert.NotContains(t, string(functionContents), "t() {") } +// buildTestTimberBinary builds the current module into a temporary bin +// directory and returns it, so pty completion tests exercise the same +// binary the generated completion delegates to via 'timber __complete'. +func buildTestTimberBinary(t *testing.T) string { + t.Helper() + _, callerFile, _, ok := runtime.Caller(0) + require.True(t, ok) + repoRoot := filepath.Join(filepath.Dir(callerFile), "..", "..") + binDir := resolvedTempDir(t) + + goEnv := testCommand(t, "go", "env", "GOMODCACHE", "GOCACHE", "GOPROXY", "GOTOOLCHAIN") + goEnv.Dir = repoRoot + goEnv.Env = os.Environ() + envOutput, err := goEnv.Output() + require.NoError(t, err) + envKeys := []string{"GOMODCACHE", "GOCACHE", "GOPROXY", "GOTOOLCHAIN"} + + build := testCommand(t, "go", "build", "-o", filepath.Join(binDir, "timber"), ".") + build.Dir = repoRoot + // testCommand isolates HOME, which would empty the module and build + // caches; keep the real Go toolchain directories so the build stays + // offline and fast. + for index, line := range strings.Split(strings.TrimSpace(string(envOutput)), "\n") { + if index < len(envKeys) && line != "" { + build.Env = replaceTestEnvironment(build.Env, envKeys[index]+"="+line) + } + } + buildOutput, err := build.CombinedOutput() + require.NoError(t, err, string(buildOutput)) + return binDir +} + func TestGeneratedCreateCompletesUniqueRepoPrefix(t *testing.T) { t.Parallel() if _, err := exec.LookPath("zsh"); err != nil { @@ -137,6 +182,7 @@ os.environ["ZDOTDIR"] = zdot os.environ["HOME"] = sys.argv[3] os.environ["XDG_DATA_HOME"] = sys.argv[4] os.environ["TIMBER_WORKTREE_ROOT"] = sys.argv[5] +os.environ["PATH"] = sys.argv[6] + ":" + os.environ["PATH"] pid, fd = pty.fork() if pid == 0: @@ -196,6 +242,8 @@ sys.stdout.write(log.decode("utf-8", "replace")) ` require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) + timberBin := buildTestTimberBinary(t) + command := testCommand(t, "python3", scriptPath, @@ -204,6 +252,7 @@ sys.stdout.write(log.decode("utf-8", "replace")) home, dataHome, worktreeRoot, + timberBin, ) output, err := command.CombinedOutput() require.NoError(t, err, string(output)) @@ -240,13 +289,14 @@ func TestGeneratedSwitchCompletesWorktreeNamesAcrossRepos(t *testing.T) { scriptPath := filepath.Join(resolvedTempDir(t), "complete.py") script := `import os, pty, select, time, sys -compdir, zdot, home, data_home, worktree_root, line = sys.argv[1:7] +compdir, zdot, home, data_home, worktree_root, line, bindir = sys.argv[1:8] os.makedirs(zdot, exist_ok=True) open(os.path.join(zdot, ".zshrc"), "w").write("") os.environ["ZDOTDIR"] = zdot os.environ["HOME"] = home os.environ["XDG_DATA_HOME"] = data_home os.environ["TIMBER_WORKTREE_ROOT"] = worktree_root +os.environ["PATH"] = bindir + ":" + os.environ["PATH"] pid, fd = pty.fork() if pid == 0: @@ -306,6 +356,8 @@ sys.stdout.write(log.decode("utf-8", "replace")) ` require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) + timberBin := buildTestTimberBinary(t) + runComplete := func(line string) string { t.Helper() command := testCommand(t, @@ -317,6 +369,7 @@ sys.stdout.write(log.decode("utf-8", "replace")) dataHome, worktreeRoot, line, + timberBin, ) output, err := command.CombinedOutput() require.NoError(t, err, string(output)) @@ -336,6 +389,129 @@ sys.stdout.write(log.decode("utf-8", "replace")) assert.Contains(t, ambiguous, "t switch feature/login@") } +// TestGeneratedDelegatedCompletionEndToEnd proves the generated completion +// delegates to 'timber __complete': list flags, sort values, and the +// wrapper-only create --no-cd flag all complete in a real zsh session. +func TestGeneratedDelegatedCompletionEndToEnd(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh is not installed") + } + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 is not installed") + } + skipIfNoPty(t) + + home := resolvedTempDir(t) + dataHome := filepath.Join(home, ".local", "share") + worktreeRoot := filepath.Join(home, "worktrees") + + outDir := resolvedTempDir(t) + require.NoError(t, runTimberCommand(t, "generate", "zsh", "--out", outDir, "--force").err) + timberBin := buildTestTimberBinary(t) + + scriptPath := filepath.Join(resolvedTempDir(t), "complete.py") + script := `import os, pty, select, time, sys + +compdir, zdot, home, data_home, worktree_root, bindir, line = sys.argv[1:8] +wants = sys.argv[8:] +os.makedirs(zdot, exist_ok=True) +open(os.path.join(zdot, ".zshrc"), "w").write("") +os.environ["ZDOTDIR"] = zdot +os.environ["HOME"] = home +os.environ["XDG_DATA_HOME"] = data_home +os.environ["TIMBER_WORKTREE_ROOT"] = worktree_root +os.environ["PATH"] = bindir + ":" + os.environ["PATH"] + +pid, fd = pty.fork() +if pid == 0: + os.execvp("zsh", ["zsh", "-f", "-i"]) + +def recv(timeout=1.0): + buf = b"" + end = time.time() + timeout + while time.time() < end: + ready, _, _ = select.select([fd], [], [], max(0.05, end - time.time())) + if not ready: + continue + try: + chunk = os.read(fd, 8192) + except OSError: + break + if not chunk: + break + buf += chunk + end = time.time() + 0.2 + return buf + +def send(data): + os.write(fd, data.encode() if isinstance(data, str) else data) + +def wait_compinit(timeout=30.0): + out = b"" + end = time.time() + timeout + while time.time() < end and b"COMPINIT_DONE" not in out: + out += recv(1.0) + return out + +def wait_expansion(timeout=10.0): + out = b"" + end = time.time() + timeout + want = [w.encode() for w in wants] + while time.time() < end and not all(w in out for w in want): + out += recv(0.5) + return out + +recv(0.3) +log = b"" +send("fpath=(" + compdir + " $fpath); autoload -Uz compinit; compinit -u -D; echo COMPINIT_DONE:$SECONDS\n") +log += wait_compinit() +send("(( $+_comps[t] )) && echo HAVE_T_COMP || echo NO_T_COMP\n") +log += recv(1.0) +send("\x15") +recv(0.1) +send(line) +time.sleep(0.05) +send("\t") +log += wait_expansion() +send("exit\n") +recv(0.2) +sys.stdout.write(log.decode("utf-8", "replace")) +` + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) + + runComplete := func(line string, wants ...string) string { + t.Helper() + args := append([]string{ + "python3", + scriptPath, + outDir, + filepath.Join(resolvedTempDir(t), "zdot"), + home, + dataHome, + worktreeRoot, + timberBin, + line, + }, wants...) + command := testCommand(t, args[0], args[1:]...) + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + return string(output) + } + + flags := runComplete("t ls --", "--sort", "--pr", "--json") + assert.Contains(t, flags, "--sort") + assert.Contains(t, flags, "--pr") + assert.Contains(t, flags, "--json") + + sortValue := runComplete("t list --sort rec", "recency") + assert.Contains(t, sortValue, "t list --sort recency") + + wrapperFlags := runComplete("t create --no-", "--no-cd", "--no-herdr") + assert.Contains(t, wrapperFlags, "--no-cd") + assert.Contains(t, wrapperFlags, "--no-herdr") +} + func TestGeneratedZshWrapperAutoloadsAfterCompinit(t *testing.T) { t.Parallel() if _, err := exec.LookPath("zsh"); err != nil { diff --git a/internal/timber/generate_zsh.go b/internal/timber/generate_zsh.go index 473bb27..9b07be7 100644 --- a/internal/timber/generate_zsh.go +++ b/internal/timber/generate_zsh.go @@ -273,282 +273,236 @@ func (x *zshCommandOptions) writeAutoloadFile(target string) error { } func (x *zshCommandOptions) writeCompletionFile(target string) error { - content := `#compdef ` + x.name + ` + // Completion delegates to 'timber __complete', so flags, subcommands, + // and argument completions never drift from the Cobra definitions. + // Only wrapper-only flags are layered on top. + content := fmt.Sprintf(`#compdef %[1]s +compdef _%[1]s %[1]s # Generated by timber generate zsh # DO NOT EDIT. Regenerate with timber generate zsh -_` + x.name + `() { - local context state state_descr line - typeset -A opt_args - - local -a subcommands - subcommands=( - 'create:Create a managed Git worktree' - 'list:List managed Git worktrees' - 'ls:List managed Git worktrees' - 'prune:Remove clean merged managed worktrees' - 'clean:Remove clean merged managed worktrees' - 'remove:Remove a managed Git worktree' - 'rm:Remove a managed Git worktree' - 'repo:Manage registered repositories' - 'herdr:Manage the Herdr plugin and spaces' - 'generate:Generate shell integration' - 'switch:Switch to a worktree' - 'todo:Open the worktree-specific TODO.md' - 'git:Run git with --git-dir set to a managed worktree' - 'tui:Interactively create a worktree or open an existing one' - ) - - if (( CURRENT == 2 )); then - _describe 'command' subcommands +__%[1]s_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_%[1]s() +{ + local shellCompDirectiveError=%[3]d + local shellCompDirectiveNoSpace=%[4]d + local shellCompDirectiveNoFileComp=%[5]d + local shellCompDirectiveFilterFileExt=%[6]d + local shellCompDirectiveFilterDirs=%[7]d + local shellCompDirectiveKeepOrder=%[8]d + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __%[1]s_debug "\n========= starting completion logic ==========" + __%[1]s_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __%[1]s_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __%[1]s_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., %[1]s -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions. + # %[1]s is a shell wrapper, so always call through to the timber binary + # instead of recursing into the wrapper. + requestComp="command timber %[2]s ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __%[1]s_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __%[1]s_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __%[1]s_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%%s\n" "${out[@]}") + __%[1]s_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __%[1]s_debug "No directive found. Setting do default" + directive=0 + fi + + __%[1]s_debug "directive: ${directive}" + __%[1]s_debug "completions: ${out}" + __%[1]s_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __%[1]s_debug "Completion received error. Ignoring completions." return fi - case $words[2] in - create) - shift words - (( CURRENT-- )) - _arguments -M 'r:|=*' \ - '--no-cd[Create without changing directories]' \ - '(--no-herdr)--herdr[Also create a Herdr workspace for the new worktree]' \ - '(--herdr)--no-herdr[Do not create a Herdr workspace]' \ - '(-u --upstream)'{-u,--upstream}'[Upstream branch]:upstream branch:' \ - '(-h --help)'{-h,--help}'[help for create]' \ - '1:worktree name:->create_name' - ;; - list|ls) - shift words - (( CURRENT-- )) - _arguments \ - '(-h --help)'{-h,--help}'[help for list]' \ - '1:repository:->repo_qualifiers' - ;; - switch|sw) - shift words - (( CURRENT-- )) - _arguments -M 'r:|=*' \ - {-c,--create}'[Create the worktree if it does not exist]' \ - '--no-cd[Create without changing directories]' \ - '(--no-herdr)--herdr[Also create a Herdr workspace for the new worktree]' \ - '(--herdr)--no-herdr[Do not create a Herdr workspace]' \ - '(-u --upstream)'{-u,--upstream}'[Upstream branch]:upstream branch:' \ - '1:worktree name:->switch_name' - ;; - remove|rm) - shift words - (( CURRENT-- )) - _arguments \ - '1:worktree name:->worktrees' - ;; - todo) - shift words - (( CURRENT-- )) - _arguments \ - '(--install-skill)--path[Print the TODO.md path instead of opening it]' \ - '(--path)--install-skill[Install the timber-todo skill to ~/.agents/skills]' \ - '(-f --force)'{-f,--force}'[Overwrite an existing installed skill]' \ - '(-h --help)'{-h,--help}'[help for todo]' \ - '1:worktree name:->worktrees' - ;; - git) - shift words - (( CURRENT-- )) - _arguments \ - '1:worktree name:->worktrees' - ;; - herdr) - local -a herdr_commands - herdr_commands=( - 'install:Install the Herdr plugin and print keybinding instructions' - 'space:Set up a Herdr space for a managed Git worktree' - ) - if (( CURRENT == 3 )); then - _describe 'herdr command' herdr_commands - return + local activeHelpMarker="%[9]s" + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __%[1]s_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __%[1]s_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue fi - case $words[3] in - install) - shift words - (( CURRENT-- )) - shift words - (( CURRENT-- )) - _arguments \ - '(-h --help)'{-h,--help}'[help for install]' - ;; - space) - shift words - (( CURRENT-- )) - shift words - (( CURRENT-- )) - _arguments \ - '(-n --new)'{-n,--new}'[Open a new Herdr workspace]' \ - '1:worktree name:->worktrees' - ;; - esac - ;; - prune|clean) - shift words - (( CURRENT-- )) - _arguments \ - '(-p --prompt)'{-p,--prompt}'[Prompt before pruning]' \ - '(-n --dry-run)'{-n,--dry-run}'[List worktrees that would be pruned]' \ - '(-h --help)'{-h,--help}'[help for prune]' \ - '1:repository:->repo_qualifiers' - ;; - tui) - shift words - (( CURRENT-- )) - _arguments \ - '(--no-herdr)--herdr[Also create a Herdr workspace for a new worktree]' \ - '(--herdr)--no-herdr[Do not create a Herdr workspace]' \ - '(-h --help)'{-h,--help}'[help for tui]' - ;; - repo) - local -a repo_commands - repo_commands=( - 'add:Register a bare repository' - 'import:Convert an existing clone into a managed Timber repository' - 'list:List registered repositories' - 'ls:List registered repositories' - 'remove:Remove a registered repository' - 'rm:Remove a registered repository' - 'rename:Rename a registered repository' - 'mv:Rename a registered repository' - ) - if (( CURRENT == 3 )); then - _describe 'repo command' repo_commands - return + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __%[1]s_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp fi - case $words[3] in - list|ls) - shift words - (( CURRENT-- )) - shift words - (( CURRENT-- )) - _arguments \ - '(-q --quiet)'{-q,--quiet}'[Print repository names only]' \ - '(-h --help)'{-h,--help}'[help for list]' - ;; - import) - shift words - (( CURRENT-- )) - shift words - (( CURRENT-- )) - _arguments \ - '(-n --name)'{-n,--name}'[Repository name]:repository name:' \ - '(-h --help)'{-h,--help}'[help for import]' \ - '1:source path:_files -/' - ;; - remove|rm) - state=repos - ;; - rename|mv) - if (( CURRENT == 4 )); then - state=repos - elif (( CURRENT == 5 )); then - _message 'new repository name' - return - fi - ;; - esac - ;; - esac + done < <(printf "%%s\n" "${out[@]}") - case $state in - switch_name) - if (( ${words[(I)-c]} || ${words[(I)--create]} )); then - state=create_name - else - state=worktrees + # The shell wrapper adds 'create --no-cd', which timber itself does not + # define, so offer it alongside Cobra's completions when completing flags. + if [[ ${words[2]} == create && ${lastParam} == -* ]]; then + if (( ${completions[(I)--no-cd:*]:-0} == 0 )); then + completions+=('--no-cd:Create without changing directories') fi - ;; - esac + fi - local data_home=${XDG_DATA_HOME:-$HOME/.local/share} - local worktree_root=${TIMBER_WORKTREE_ROOT:-$HOME/worktrees} - - case $state in - repos) - local -a repos - local repo_dir repo_name - for repo_dir in "$data_home"/timber/repos/**/*.git(N/); do - repo_name=${repo_dir#$data_home/timber/repos/} - repo_name=${repo_name%.git} - repos+=("$repo_name") - done - _describe 'repositories' repos - ;; - repo_qualifiers|create_name) - local -a qualifiers - local repo_dir repo_name name="" repo_prefix="" - if [[ $PREFIX == *@* ]]; then - name=${PREFIX%@*} - repo_prefix=${PREFIX##*@} - elif [[ -n $PREFIX ]]; then - if [[ $state == create_name ]]; then - _message 'worktree name' - fi - return 0 + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __%[1]s_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 fi - for repo_dir in "$data_home"/timber/repos/**/*.git(N/); do - repo_name=${repo_dir#$data_home/timber/repos/} - repo_name=${repo_name%.git} - [[ $repo_name == ${repo_prefix}* ]] || continue - qualifiers+=("${name}@$repo_name") + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __%[1]s_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __%[1]s_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" done - (( $#qualifiers )) && compadd -S '' -- "${qualifiers[@]}" - ;; - worktrees) - local -a completions - local repo_dir repo_name worktree_dir parent name - if [[ $PREFIX == *@* ]]; then - local worktree_name=${PREFIX%@*} - local repo_prefix=${PREFIX##*@} - [[ -n $worktree_name ]] || return 0 - for repo_dir in "$data_home"/timber/repos/**/*.git(N/); do - repo_name=${repo_dir#$data_home/timber/repos/} - repo_name=${repo_name%.git} - [[ $repo_name == ${repo_prefix}* ]] || continue - local short=${repo_name##*/} - [[ -e "$worktree_root/$repo_name/$worktree_name/$short/.git" ]] || continue - completions+=("$worktree_name@$repo_name") - done - (( $#completions )) && compadd -Q -- "${completions[@]}" - return 0 + filteringCmd+=" ${flagPrefix}" + + __%[1]s_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __%[1]s_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __%[1]s_debug "Listing directories in ." fi - local -A name_count - local -A name_repos - for repo_dir in "$data_home"/timber/repos/**/*.git(N/); do - repo_name=${repo_dir#$data_home/timber/repos/} - repo_name=${repo_name%.git} - local short=${repo_name##*/} - for worktree_dir in "$worktree_root/$repo_name"/**/"$short"(N/); do - [[ -e "$worktree_dir/.git" ]] || continue - parent=${worktree_dir:h} - name=${parent#$worktree_root/$repo_name/} - if [[ -n "$name" && "$name" != "$parent" ]]; then - name_count[$name]=$(( ${name_count[$name]:-0} + 1 )) - name_repos[$name]+="$repo_name " - fi - done - done - local repo - for name in ${(k)name_count}; do - if [[ ${name_count[$name]} -eq 1 ]]; then - completions+=("$name") + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __%[1]s_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __%[1]s_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __%[1]s_debug "_describe did not find completions." + __%[1]s_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __%[1]s_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 else - for repo in ${(z)name_repos[$name]}; do - completions+=("$name@$repo") - done + # Perform file completion + __%[1]s_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" fi - done - (( $#completions )) && compadd -Q -- "${completions[@]}" - return 0 - ;; - esac + fi + fi } -` + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_%[1]s" ]; then + _%[1]s +fi +`, x.name, cobra.ShellCompRequestCmd, + cobra.ShellCompDirectiveError, cobra.ShellCompDirectiveNoSpace, cobra.ShellCompDirectiveNoFileComp, + cobra.ShellCompDirectiveFilterFileExt, cobra.ShellCompDirectiveFilterDirs, cobra.ShellCompDirectiveKeepOrder, + "_activeHelp_ ") return os.WriteFile(target, []byte(content), 0o644) }