diff --git a/README.md b/README.md index 90a7351..d3f931e 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,9 @@ Trust requires a known filesystem birth time for the Git directory. If `stat` ca All configuration is stored via `git config`. For team settings, create a `.gtrconfig` file in your repository root. +The CLI uses the bare Git directory itself as the root for bare repositories; see +[configuration sources](docs/configuration.md#configuration-sources) for config and copy locations. + ### Quick Setup ```bash diff --git a/docs/configuration.md b/docs/configuration.md index 3073533..874e776 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,6 +37,13 @@ All configuration is stored via `git config`, making it easy to manage per-repos 5. Environment variables 6. Default values +For the CLI, a bare repository's root is the bare Git directory itself, including +when a command is run from one of its linked worktrees. `.gtrconfig`, +`.worktreeinclude`, relative worktree locations, and the default copy source use +that root, never its parent directory. The default worktree location is a sibling +named `-worktrees`. Files stored only in Git history are not +copy sources; use `git gtr copy --from ` to copy from a checkout. + --- ## Team Configuration (.gtrconfig) diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 3cf5fd0..f49db82 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -4,6 +4,7 @@ # Each _help_() provides detailed help for a single command. # Dispatched by cmd_help when a command argument is provided. +# Print usage, options, examples, and repository-root semantics for git gtr new. _help_new() { cat <<'EOF' git gtr new - Create a new worktree @@ -14,6 +15,9 @@ Creates a new git worktree with the given branch name. The worktree folder is named after the branch (slashes and special chars become hyphens, e.g., feature/user-auth becomes folder "feature-user-auth"). +For bare repositories, configuration and copied files come from the bare Git +directory itself. The default worktree directory is its sibling -worktrees. + Options: --from Create from a specific ref (default: default branch) --from-current Create from the current branch (for parallel variants) diff --git a/lib/config.sh b/lib/config.sh index 8a5f57c..c485ec2 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -12,13 +12,32 @@ # Resolve the main repo root from the current git context. # Works from the main repo root, a subdirectory, or a linked worktree. +# A bare repository is its own root; its parent is not a checkout. # Returns: absolute path to main repo root or empty on failure _resolve_main_repo_root() { - local git_common_dir repo_root + local git_common_dir repo_root is_bare git_common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || return 1 [ -n "$git_common_dir" ] || return 1 + # Query the common directory, not the current worktree: a linked worktree + # reports false even when its main repository is bare. Do not infer this + # from the directory name (a bare repository can itself be named .git). + # GIT_WORK_TREE and GIT_COMMON_DIR can force a false result. Discovery above + # already resolved the common directory; ignore these only for this probe. + is_bare=$( + unset GIT_WORK_TREE GIT_COMMON_DIR + git --git-dir="$git_common_dir" rev-parse --is-bare-repository 2>/dev/null + ) || return 1 + if [ "$is_bare" = "true" ]; then + repo_root=$( + unset CDPATH + cd -P -- "$git_common_dir" 2>/dev/null && pwd -P + ) || return 1 + printf "%s" "$repo_root" + return + fi + case "$git_common_dir" in /*) repo_root="${git_common_dir%/.git}" diff --git a/tests/bare_repositories.bats b/tests/bare_repositories.bats new file mode 100644 index 0000000..b60aa83 --- /dev/null +++ b/tests/bare_repositories.bats @@ -0,0 +1,210 @@ +#!/usr/bin/env bats +# Repository-root and copy isolation for bare repositories and their worktrees. + +load test_helper + +setup() { + TEST_FIXTURE=$(cd -P "$(mktemp -d)" && pwd) + TEST_PARENT="$TEST_FIXTURE/parent space" + TEST_BARE="$TEST_PARENT/repository.git" + export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null + export XDG_CONFIG_HOME="$TEST_FIXTURE/user-config" + export XDG_CACHE_HOME="$TEST_FIXTURE/cache" + export GIT_TEMPLATE_DIR="$TEST_FIXTURE/template" + export GTR_DIR="$PROJECT_ROOT" + mkdir -p "$TEST_PARENT" "$GIT_TEMPLATE_DIR" + git init --quiet "$TEST_FIXTURE/seed" + git -C "$TEST_FIXTURE/seed" -c user.name=Test -c user.email=test@example.com \ + commit --allow-empty -m init --quiet + git clone --bare --quiet "$TEST_FIXTURE/seed" "$TEST_BARE" + source_gtr_libs + cd "$TEST_BARE" +} + +teardown() { + cd / + rm -rf "$TEST_FIXTURE" +} + +@test "bare root and subdirectories resolve to the bare repository" { + local context + for context in "$TEST_BARE" "$TEST_BARE/objects"; do + cd "$context" + [ "$(_resolve_main_repo_root)" = "$TEST_BARE" ] + [ "$(_gtrconfig_path)" = "$TEST_BARE/.gtrconfig" ] + done +} + +@test "relative and absolute GIT_DIR resolve to the same bare root" { + cd "$TEST_PARENT" + local git_dir + for git_dir in repository.git "$TEST_BARE"; do + [ "$(GIT_DIR="$git_dir" _resolve_main_repo_root)" = "$TEST_BARE" ] + done +} + +@test "bare root probe ignores GIT_WORK_TREE without changing the caller environment" { + export GIT_DIR=. GIT_WORK_TREE=. + [ "$(git rev-parse --is-bare-repository)" = false ] + + # Call directly so a global unset cannot be hidden by command substitution. + _resolve_main_repo_root > "$TEST_FIXTURE/resolved-root" + + [ "$(cat "$TEST_FIXTURE/resolved-root")" = "$TEST_BARE" ] + [ "$(_gtrconfig_path)" = "$TEST_BARE/.gtrconfig" ] + [ "$GIT_DIR" = . ] + [ "$GIT_WORK_TREE" = . ] + [ "$(git rev-parse --show-toplevel)" = "$TEST_BARE" ] +} + +@test "new with GIT_DIR and GIT_WORK_TREE uses bare config and copy source" { + local worktrees="$TEST_FIXTURE/worktrees" + git config gtr.worktrees.dir "$worktrees" + git config -f "$TEST_PARENT/.gtrconfig" copy.include secret.txt + git config -f "$TEST_BARE/.gtrconfig" copy.include own.txt + printf 'benign parent fixture\n' > "$TEST_PARENT/secret.txt" + printf 'bare fixture\n' > "$TEST_BARE/own.txt" + + run env GIT_DIR=. GIT_WORK_TREE=. \ + "$PROJECT_ROOT/bin/gtr" new env-isolated --from HEAD --no-fetch --porcelain + + [ "$status" -eq 0 ] + [ -f "$worktrees/env-isolated/.git" ] + [ ! -e "$worktrees/env-isolated/secret.txt" ] + [ "$(cat "$worktrees/env-isolated/own.txt")" = "bare fixture" ] +} + +@test "bare root probe ignores GIT_COMMON_DIR without changing the caller environment" { + export GIT_DIR=. GIT_COMMON_DIR=. GIT_WORK_TREE=. + [ "$(git --git-dir=. rev-parse --is-bare-repository)" = false ] + + # The initial discovery must honor GIT_COMMON_DIR; only the probe ignores it. + _resolve_main_repo_root > "$TEST_FIXTURE/resolved-root" + + [ "$(cat "$TEST_FIXTURE/resolved-root")" = "$TEST_BARE" ] + [ "$(_gtrconfig_path)" = "$TEST_BARE/.gtrconfig" ] + [ "$GIT_DIR" = . ] + [ "$GIT_COMMON_DIR" = . ] + [ "$GIT_WORK_TREE" = . ] + [ "$(git rev-parse --git-common-dir)" = . ] +} + +@test "new with GIT_DIR and GIT_COMMON_DIR uses bare config and copy source" { + local worktrees="$TEST_FIXTURE/worktrees" + # A relative GIT_COMMON_DIR breaks Git's own checkout in the new worktree. + # Use an absolute override and a .git name to expose the parent-root fallback. + TEST_BARE="$TEST_PARENT/.git" + git clone --bare --quiet "$TEST_FIXTURE/seed" "$TEST_BARE" + cd "$TEST_BARE" + git config gtr.worktrees.dir "$worktrees" + git config -f "$TEST_PARENT/.gtrconfig" copy.include secret.txt + git config -f "$TEST_BARE/.gtrconfig" copy.include own.txt + printf 'benign parent fixture\n' > "$TEST_PARENT/secret.txt" + printf 'bare fixture\n' > "$TEST_BARE/own.txt" + + run env GIT_DIR=. GIT_COMMON_DIR="$TEST_BARE" \ + "$PROJECT_ROOT/bin/gtr" new common-isolated --from HEAD --no-fetch --porcelain + + [ "$status" -eq 0 ] + [ -f "$worktrees/common-isolated/.git" ] + [ ! -e "$worktrees/common-isolated/secret.txt" ] + [ "$(cat "$worktrees/common-isolated/own.txt")" = "bare fixture" ] +} + +@test "bare repositories named .git keep their own root from linked worktrees" { + local bare="$TEST_PARENT/.git" linked="$TEST_FIXTURE/linked" + git clone --bare --quiet "$TEST_FIXTURE/seed" "$bare" + git -C "$bare" worktree add --quiet -b linked "$linked" HEAD + mkdir -p "$linked/subdir" + + local context + for context in "$bare" "$bare/objects" "$linked" "$linked/subdir"; do + cd "$context" + [ "$(_resolve_main_repo_root)" = "$bare" ] + [ "$(_gtrconfig_path)" = "$bare/.gtrconfig" ] + done +} + +@test "bare root determines default and relative worktree locations" { + [ "$(resolve_base_dir "$(_resolve_main_repo_root)")" = "${TEST_BARE}-worktrees" ] + git config gtr.worktrees.dir local-worktrees + [ "$(resolve_base_dir "$(_resolve_main_repo_root)" 2>/dev/null)" = "$TEST_BARE/local-worktrees" ] +} + +@test "new from bare repo ignores parent config and copy patterns" { + local worktrees="$TEST_FIXTURE/worktrees" + git config gtr.worktrees.dir "$worktrees" + printf 'benign parent fixture\n' > "$TEST_PARENT/secret.txt" + printf 'parent include fixture\n' > "$TEST_PARENT/parent-include.txt" + mkdir "$TEST_PARENT/parent-assets" + printf 'parent asset fixture\n' > "$TEST_PARENT/parent-assets/asset.txt" + git config -f "$TEST_PARENT/.gtrconfig" copy.include secret.txt + git config -f "$TEST_PARENT/.gtrconfig" copy.includeDirs parent-assets + git config -f "$TEST_PARENT/.gtrconfig" hooks.postCreate 'touch parent-hook-ran' + printf 'parent-include.txt\n' > "$TEST_PARENT/.worktreeinclude" + + run "$PROJECT_ROOT/bin/gtr" new isolated --from HEAD --no-fetch --porcelain + + [ "$status" -eq 0 ] + [ -f "$worktrees/isolated/.git" ] + [ ! -e "$worktrees/isolated/secret.txt" ] + [ ! -e "$worktrees/isolated/parent-include.txt" ] + [ ! -e "$worktrees/isolated/parent-assets" ] + [ ! -e "$worktrees/isolated/parent-hook-ran" ] + [[ "$output" == *$'hook_status\tnone'* ]] +} + +@test "bare and linked worktree creation use the bare root config and copy source" { + printf 'bare fixture\n' > "$TEST_BARE/own.txt" + printf 'bare include fixture\n' > "$TEST_BARE/own-include.txt" + mkdir "$TEST_BARE/own-assets" + printf 'bare asset fixture\n' > "$TEST_BARE/own-assets/asset.txt" + git config -f "$TEST_BARE/.gtrconfig" copy.include own.txt + git config -f "$TEST_BARE/.gtrconfig" copy.includeDirs own-assets + printf 'own-include.txt\n' > "$TEST_BARE/.worktreeinclude" + + run "$PROJECT_ROOT/bin/gtr" new first --from HEAD --no-fetch --porcelain + [ "$status" -eq 0 ] + [ "$(cat "${TEST_BARE}-worktrees/first/own.txt")" = "bare fixture" ] + [ -f "${TEST_BARE}-worktrees/first/own-include.txt" ] + [ -f "${TEST_BARE}-worktrees/first/own-assets/asset.txt" ] + + mkdir "${TEST_BARE}-worktrees/first/subdir" + cd "${TEST_BARE}-worktrees/first/subdir" + # A linked worktree reports --is-bare-repository=false, even for a bare common dir. + [ "$(git rev-parse --is-bare-repository)" = false ] + [ "$(_resolve_main_repo_root)" = "$TEST_BARE" ] + printf 'linked fixture\n' > ../own.txt + git config -f ../.gtrconfig copy.include should-not-copy.txt + run "$PROJECT_ROOT/bin/gtr" new second --from HEAD --no-fetch --porcelain + [ "$status" -eq 0 ] + [ "$(cat "${TEST_BARE}-worktrees/second/own.txt")" = "bare fixture" ] + [ -f "${TEST_BARE}-worktrees/second/own-include.txt" ] + [ -f "${TEST_BARE}-worktrees/second/own-assets/asset.txt" ] +} + +@test "normal repo and linked worktrees retain main config and copy source" { + local normal="$TEST_PARENT/normal" linked="$TEST_FIXTURE/linked" + git clone --quiet "$TEST_FIXTURE/seed" "$normal" + git -C "$normal" worktree add --quiet -b linked "$linked" HEAD + mkdir -p "$normal/subdir" "$linked/subdir" + git config -f "$TEST_PARENT/.gtrconfig" copy.include parent.txt + printf 'parent fixture\n' > "$TEST_PARENT/parent.txt" + git config -f "$normal/.gtrconfig" copy.include main.txt + printf 'main fixture\n' > "$normal/main.txt" + git config -f "$linked/.gtrconfig" copy.include linked.txt + printf 'linked fixture\n' > "$linked/linked.txt" + + local context branch=0 + for context in "$normal" "$normal/subdir" "$linked" "$linked/subdir"; do + cd "$context" + [ "$(_resolve_main_repo_root)" = "$normal" ] + [ "$(_gtrconfig_path)" = "$normal/.gtrconfig" ] + branch=$((branch + 1)) + run "$PROJECT_ROOT/bin/gtr" new "control-$branch" --from HEAD --no-fetch --porcelain + [ "$status" -eq 0 ] + [ "$(cat "${normal}-worktrees/control-$branch/main.txt")" = "main fixture" ] + [ ! -e "${normal}-worktrees/control-$branch/parent.txt" ] + [ ! -e "${normal}-worktrees/control-$branch/linked.txt" ] + done +}