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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

`<worktree-root>/<repo-name>/<worktree-name>/<repo-name>`
`<worktree-root>/<repo-name>/<worktree-name>/<repo-short-name>`

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/<repo-name>.git` (fallback: `~/.local/share/timber/repos/<repo-name>.git`)
- worktrees: `$TIMBER_WORKTREE_ROOT/<repo-name>/<worktree-name>/<repo-name>` (fallback: `~/worktrees/<repo-name>/<worktree-name>/<repo-name>`)
- worktrees: `$TIMBER_WORKTREE_ROOT/<repo-name>/<worktree-name>/<repo-short-name>` (fallback: `~/worktrees/<repo-name>/<worktree-name>/<repo-short-name>`)

The worktree name and branch name are identical (including `/`).

Expand All @@ -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 <old> <new>` 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 <path>` 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 <path>`), the shell also `cd`s to `$HOME` after success.

Expand Down
212 changes: 194 additions & 18 deletions internal/timber/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -99,15 +112,47 @@ 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)
assert.Contains(t, string(functionContents), "foo() {")
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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand All @@ -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 {
Expand Down
Loading
Loading