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. 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 7bf87b6..9b07be7 100644 --- a/internal/timber/generate_zsh.go +++ b/internal/timber/generate_zsh.go @@ -273,275 +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} + # 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 + fi - case $state in - 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}") - 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' + 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 - return 0 - fi - for repo_dir in "$data_home"/timber/repos/*.git(N/); do - repo_name=${repo_dir:t:r} - [[ $repo_name == ${repo_prefix}* ]] || continue - qualifiers+=("${name}@$repo_name") + 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:t:r} - [[ $repo_name == ${repo_prefix}* ]] || continue - [[ -e "$worktree_root/$repo_name/$worktree_name/$repo_name/.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:t:r} - for worktree_dir in "$worktree_root/$repo_name"/**/"$repo_name"(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) } diff --git a/internal/timber/paths.go b/internal/timber/paths.go index 241c7a9..abd9fb8 100644 --- a/internal/timber/paths.go +++ b/internal/timber/paths.go @@ -9,10 +9,25 @@ 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 +// 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 { 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/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) { 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_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_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..7973303 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), @@ -166,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 new file mode 100644 index 0000000..7cb5829 --- /dev/null +++ b/internal/timber/repo_structured_test.go @@ -0,0 +1,63 @@ +package timber + +import ( + "path/filepath" + "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)) + // 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) { + 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 c74dc9b..39a4e78 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) { @@ -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 { @@ -576,8 +583,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 +591,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 { @@ -640,9 +657,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 +669,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 { 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