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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@ git gtr config set gtr.ui.color never

**Adapter safety:** Generic `gtr.editor.default` and `gtr.ai.default` values must resolve to safe PATH commands. Filesystem paths such as `./tool` and shell wrapper forms such as `sh -c ...` are rejected. Override-backed adapters like `claude`, `cursor`, and `nano` may include additional flags, for example `claude --continue` or `nano -w`.

**Workspace files:** `editor.workspace` in `.gtrconfig` does not require command approval. Workspace paths from all config sources must resolve inside the selected worktree, including symlink targets; invalid paths open the folder instead. See [workspace settings](docs/configuration.md#editor-settings) for supported paths and editor trust boundaries.

**Configuration precedence** (highest to lowest):

1. `git config --local` (`.git/config`) - personal overrides
Expand Down
18 changes: 16 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,24 @@ gtr.editor.workspace = project.code-workspace

VS Code, Cursor, and Antigravity support `.code-workspace` files for multi-root workspaces, custom settings, and recommended extensions. When opening a worktree:

1. If `gtr.editor.workspace` is set to a path, opens that file (relative to worktree root)
1. If `gtr.editor.workspace` is set to a path, opens that file (relative to and contained within the worktree root)
2. If set to `none`, disables workspace lookup (always opens folder)
3. Otherwise, auto-detects any `*.code-workspace` file in the worktree root
4. Falls back to opening the folder if no workspace file is found
4. Falls back to opening the folder if no valid workspace file is found

Workspace paths from every config source must resolve to a file inside the selected
worktree. Nested paths, spaces, and symlinks that resolve inside the worktree are
supported. Absolute configured paths and paths or symlinks that resolve outside
the worktree are ignored. An invalid or missing explicit path opens the folder;
it does not trigger auto-detection. Previously accepted outside-worktree paths
now open the folder instead.

The shared `.gtrconfig` `editor.workspace` setting is workspace data and does not
require command approval. Selecting an editor through `.gtrconfig`
`defaults.editor` still requires `git gtr trust`; a local/global editor default
or an explicit `--editor` can also select the editor. Containment applies to the
workspace file itself, not the folders or settings described inside it. The
editor remains responsible for its own workspace trust checks.

---

Expand Down
52 changes: 46 additions & 6 deletions lib/adapters.sh
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,50 @@ _editor_define_terminal() {
}
}

# Resolve workspace file for VS Code/Cursor/Antigravity editors
# Validate a relative workspace file against the physical worktree root.
# Follow internal symlinks without requiring readlink -f (unavailable on macOS).
# Return the original path so workspace-relative editor settings keep their meaning.
# Returns 1 for missing files, link cycles, absolute config paths or escapes.
_workspace_file_within_worktree() {
local worktree_path="$1" relative_path="$2" root path parent target os
local links=0
os=$(detect_os)
# Windows accepts backslashes as separators, including in traversal paths.
if [ "$os" = windows ]; then relative_path="${relative_path//\\//}"; fi
case "$relative_path" in
/*|[a-zA-Z]:*|\\*) return 1 ;;
esac
# Sentinels preserve trailing newlines in physical directory/link names.
root=$(canonicalize_path "$worktree_path" && printf '.') || return 1
root="${root%$'\n.'}"
path="${root%/}/$relative_path"

while :; do
parent=$(canonicalize_path "${path%/*}" && printf '.') || return 1
parent="${parent%$'\n.'}"
path="${parent%/}/${path##*/}"
[ -L "$path" ] || break
[ "$links" -lt 40 ] || return 1
links=$((links + 1))
target=$(readlink "$path" && printf '.') || return 1
target="${target%$'\n.'}"
if [ "$os" = windows ]; then target="${target//\\//}"; fi
path="${parent%/}/$target"
case "$target" in
/*) path="$target" ;;
[a-zA-Z]:/*) if [ "$os" = windows ]; then path="$target"; fi ;;
esac
done

[ -f "$path" ] || return 1
case "$path" in
"${root%/}/"*) printf '%s\n' "$worktree_path/$relative_path" ;;
*) return 1 ;;
esac
}

# Resolve workspace file for VS Code/Cursor/Antigravity editors.
# Workspace data is not command-trust-gated; confine it for every config source.
# Returns the workspace file path if found, empty otherwise
resolve_workspace_file() {
local worktree_path="$1"
Expand All @@ -421,10 +464,7 @@ resolve_workspace_file() {
fi

if [ -n "$configured" ]; then
local full_path="$worktree_path/$configured"
if [ -f "$full_path" ]; then
echo "$full_path"
fi
_workspace_file_within_worktree "$worktree_path" "$configured" || true
# Explicit config set - don't fall through to auto-detect
return 0
fi
Expand All @@ -433,7 +473,7 @@ resolve_workspace_file() {
local ws_file
ws_file=$(find "$worktree_path" -maxdepth 1 -name "*.code-workspace" -type f 2>/dev/null | head -1)
if [ -n "$ws_file" ]; then
echo "$ws_file"
_workspace_file_within_worktree "$worktree_path" "${ws_file##*/}" || true
fi
}

Expand Down
4 changes: 3 additions & 1 deletion lib/commands/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Usage: git gtr editor <branch> [--editor <name>]

Opens the specified worktree in your configured editor. Uses the default
editor from gtr.editor.default config, or the --editor flag to override.
Configured workspace files must resolve inside the worktree, including symlinks.
Invalid workspace paths fall back to opening the worktree folder.

Options:
--editor <name> Override the default editor for this invocation
Expand Down Expand Up @@ -756,7 +758,7 @@ CONFIGURATION OPTIONS:
nvim, pycharm, sublime, vim, vscode, webstorm,
zed, none (legacy: atom)
gtr.editor.workspace Workspace file for VS Code/Cursor/Antigravity
(relative path, auto-detects, or "none")
(path inside worktree, auto-detects, or "none")
gtr.ai.default Default AI tool
Options: antigravity, aider, auggie, claude,
codex, copilot, cursor, gemini, opencode, none
Expand Down
237 changes: 237 additions & 0 deletions tests/workspace.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
#!/usr/bin/env bats
# Workspace selection through the real CLI with a benign editor that only logs argv.

load test_helper

setup() {
export GIT_CONFIG_NOSYSTEM=1
export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig"
export XDG_CONFIG_HOME="$BATS_TEST_TMPDIR/config"
unset GTR_EDITOR_CMD GTR_EDITOR_DEFAULT GTR_WORKTREES_DIR
TEST_REPO="$BATS_TEST_TMPDIR/repo"
mkdir -p "$TEST_REPO" "$BATS_TEST_TMPDIR/bin"
TEST_REPO=$(cd -P "$TEST_REPO" && pwd -P)
git -C "$TEST_REPO" init --quiet
git -C "$TEST_REPO" config user.name "Test User"
git -C "$TEST_REPO" config user.email "test@example.com"
git -C "$TEST_REPO" commit --allow-empty -m init --quiet
TEST_WT_PATH="$TEST_REPO/worktrees/feature"
git -C "$TEST_REPO" worktree add --quiet -b feature "$TEST_WT_PATH"
git -C "$TEST_REPO" config gtr.worktrees.dir worktrees
export WORKSPACE_EDITOR_LOG="$BATS_TEST_TMPDIR/editor-argv"
cat > "$BATS_TEST_TMPDIR/bin/code" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$#" "$@" > "$WORKSPACE_EDITOR_LOG"
EOF
chmod +x "$BATS_TEST_TMPDIR/bin/code"
export PATH="$BATS_TEST_TMPDIR/bin:$PATH"
cd "$TEST_REPO"
source_gtr_commands
}

assert_editor_target() {
[ "$status" -eq 0 ]
local expected actual
expected=$(printf '1\n%s' "$1")
actual=$(cat "$WORKSPACE_EDITOR_LOG")
if [ "$actual" != "$expected" ]; then
printf 'Expected editor argv: %s\nActual editor argv: %s\n' "$expected" "$actual" >&2
return 1
fi
}

open_workspace() {
run /bin/bash "$PROJECT_ROOT/bin/git-gtr" editor feature --editor vscode
}

@test "workspace traversal in untrusted shared config falls back to the worktree" {
touch "$TEST_REPO/outside.code-workspace" "$TEST_WT_PATH/auto.code-workspace"
git config -f .gtrconfig editor.workspace ../../outside.code-workspace
git config gtr.editor.default vscode

run /bin/bash "$PROJECT_ROOT/bin/git-gtr" editor feature

assert_editor_target "$TEST_WT_PATH"
[ ! -d "$XDG_CONFIG_HOME/gtr/trusted" ]
}

@test "workspace containment also applies to explicit local and global config" {
touch "$TEST_REPO/outside.code-workspace"
git config --global gtr.editor.workspace ../../outside.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH"

git config --local gtr.editor.workspace ../../outside.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH"
}

@test "workspace file symlink cannot select an external file" {
touch "$TEST_REPO/outside.code-workspace"
ln -s ../../outside.code-workspace "$TEST_WT_PATH/project.code-workspace"
git config -f .gtrconfig editor.workspace project.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH"
}

@test "workspace directory symlink cannot select a sibling with the same path prefix" {
mkdir "$TEST_WT_PATH-other"
touch "$TEST_WT_PATH-other/project.code-workspace"
ln -s ../feature-other "$TEST_WT_PATH/linked"
git config -f .gtrconfig editor.workspace linked/project.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH"
}

@test "workspace supports nested paths with spaces and internal parent components" {
mkdir -p "$TEST_WT_PATH/configs/subdir"
touch "$TEST_WT_PATH/configs/my project.code-workspace"
git config -f .gtrconfig editor.workspace './configs/subdir/../my project.code-workspace'
open_workspace
assert_editor_target "$TEST_WT_PATH/./configs/subdir/../my project.code-workspace"
}

@test "workspace preserves internal directory and chained file symlinks" {
mkdir "$TEST_WT_PATH/configs"
touch "$TEST_WT_PATH/configs/project.code-workspace"
ln -s configs "$TEST_WT_PATH/linked"
ln -s linked/project.code-workspace "$TEST_WT_PATH/relative.code-workspace"
ln -s "$TEST_WT_PATH/relative.code-workspace" "$TEST_WT_PATH/absolute.code-workspace"
git config -f .gtrconfig editor.workspace absolute.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH/absolute.code-workspace"
}

@test "workspace resolves a symlinked worktree root without changing the caller directory" {
touch "$TEST_WT_PATH/project.code-workspace"
ln -s "$TEST_WT_PATH" "$TEST_REPO/linked-worktree"
git config -f .gtrconfig editor.workspace project.code-workspace
local before="$PWD" result
result=$(resolve_workspace_file "$TEST_REPO/linked-worktree")
[ "$result" = "$TEST_REPO/linked-worktree/project.code-workspace" ]
[ "$PWD" = "$before" ]
}

@test "workspace none and missing explicit files disable auto-detection" {
touch "$TEST_WT_PATH/auto.code-workspace"
local configured
for configured in none missing.code-workspace; do
git config -f .gtrconfig editor.workspace "$configured"
open_workspace
assert_editor_target "$TEST_WT_PATH"
done
}

@test "workspace auto-detects a root file and otherwise opens the folder" {
open_workspace
assert_editor_target "$TEST_WT_PATH"
touch "$TEST_WT_PATH/project.code-workspace"
open_workspace
assert_editor_target "$TEST_WT_PATH/project.code-workspace"
}

@test "workspace auto-detection does not follow external file symlinks" {
touch "$TEST_REPO/outside.code-workspace"
ln -s ../../outside.code-workspace "$TEST_WT_PATH/project.code-workspace"
open_workspace
assert_editor_target "$TEST_WT_PATH"
}

@test "workspace selection retains local over shared over global precedence" {
touch "$TEST_WT_PATH/local.code-workspace" "$TEST_WT_PATH/shared.code-workspace"
touch "$TEST_WT_PATH/global.code-workspace"
git config --global gtr.editor.workspace global.code-workspace
git config -f .gtrconfig editor.workspace shared.code-workspace
git config --local gtr.editor.workspace local.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH/local.code-workspace"
git config --unset gtr.editor.workspace
open_workspace
assert_editor_target "$TEST_WT_PATH/shared.code-workspace"
git config -f .gtrconfig --unset editor.workspace
open_workspace
assert_editor_target "$TEST_WT_PATH/global.code-workspace"
}

@test "workspace data does not approve a shared editor command" {
git config -f .gtrconfig defaults.editor vscode
git config -f .gtrconfig editor.workspace project.code-workspace
touch "$TEST_WT_PATH/project.code-workspace"
[ "$(_cfg_editor_default)" = none ]
open_workspace
assert_editor_target "$TEST_WT_PATH/project.code-workspace"
[ ! -d "$XDG_CONFIG_HOME/gtr/trusted" ]
}

@test "workspace broken links and link cycles fall back under set -e" {
ln -s missing.code-workspace "$TEST_WT_PATH/broken.code-workspace"
ln -s cycle.code-workspace "$TEST_WT_PATH/cycle.code-workspace"
local configured
for configured in broken.code-workspace cycle.code-workspace; do
git config -f .gtrconfig editor.workspace "$configured"
open_workspace
assert_editor_target "$TEST_WT_PATH"
done
}

@test "workspace cannot confuse a newline-suffixed sibling with the worktree" {
local outside="$TEST_WT_PATH"$'\n'
mkdir "$outside"
touch "$outside/project.code-workspace"
ln -s "$outside" "$TEST_WT_PATH/linked"
git config -f .gtrconfig editor.workspace linked/project.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH"
}

@test "workspace treats shell metacharacters as a single literal filename" {
local name='$(touch should-not-run); project.code-workspace'
touch "$TEST_WT_PATH/$name"
git config -f .gtrconfig editor.workspace "$name"
open_workspace
assert_editor_target "$TEST_WT_PATH/$name"
[ ! -e "$TEST_REPO/should-not-run" ]
}

@test "workspace rejects absolute configuration even when the joined path exists" {
mkdir -p "$TEST_WT_PATH/absolute"
touch "$TEST_WT_PATH/absolute/project.code-workspace"
git config -f .gtrconfig editor.workspace /absolute/project.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH"
}

@test "workspace normalizes Windows separators before checking containment" {
# Exercise Windows path handling without claiming a native Windows test.
detect_os() { printf windows; }
touch "$TEST_REPO/outside.code-workspace"
git config -f .gtrconfig editor.workspace '..\..\outside.code-workspace'
run resolve_workspace_file "$TEST_WT_PATH"
[ "$status" -eq 0 ]
[ -z "$output" ]

mkdir "$TEST_WT_PATH/configs"
touch "$TEST_WT_PATH/configs/project.code-workspace"
git config -f .gtrconfig editor.workspace 'configs\project.code-workspace'
run resolve_workspace_file "$TEST_WT_PATH"
[ "$status" -eq 0 ]
[ "$output" = "$TEST_WT_PATH/configs/project.code-workspace" ]
}

@test "workspace resolves drive-like symlink targets relative to their parent on POSIX" {
[ "$(detect_os)" != windows ] || skip "POSIX path semantics"
mkdir "$TEST_REPO/outside" "$TEST_WT_PATH/inside"
touch "$TEST_REPO/outside/project.code-workspace"
touch "$TEST_WT_PATH/inside/project.code-workspace"
ln -s "$TEST_REPO/outside" "$TEST_WT_PATH/a:"
ln -s "$TEST_WT_PATH/inside" "$TEST_REPO/a:"
ln -s a:/project.code-workspace "$TEST_WT_PATH/project.code-workspace"
git config -f .gtrconfig editor.workspace project.code-workspace
open_workspace
assert_editor_target "$TEST_WT_PATH"

rm "$TEST_WT_PATH/a:"
ln -s inside "$TEST_WT_PATH/a:"
open_workspace
assert_editor_target "$TEST_WT_PATH/project.code-workspace"
}
Loading