diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 018e062..41a1c4c 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -23,6 +23,9 @@ Run after core or adapter changes; all manual (no automated tests). # New branch creation ./bin/gtr new brand-new-feature # creates branch + worktree +# Machine-readable creation (stdout: path, branch, hook_status records) +./bin/gtr new agent-feature --from HEAD --no-fetch --porcelain + # Force multiple worktrees same branch ./bin/gtr new test-feature --force --name backend # test-feature-backend @@ -87,6 +90,7 @@ echo "DEBUG worktree_path=$worktree_path" >&2 # variable inspection - All commands exit 0 (except intentional failures) and produce expected side-effects. - No unquoted path errors; spaces handled. - Hooks run only once per creation/removal. +- `new --porcelain` emits only stable records on stdout; progress and hook output use stderr. - `list --porcelain` stable for scripting. ## When Adding Features diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f22cbf..6ce0e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] +### Added + +- `git gtr new --porcelain` emits stable worktree path, branch, and hook-status records for shell automation and coding agents, with incidental output isolated on stderr. +- Agent integration guidance documents the machine-readable contract and recommended trust and cleanup boundaries. + ## [2.9.0] - 2026-08-12 ### Added diff --git a/README.md b/README.md index c18ebcc..7afd0e0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - [Features](#features) - [Requirements](#requirements) - [Commands](#commands) +- [AI Agent Usage](#ai-agent-usage) - [Configuration](#configuration) - [Shell Completions](#shell-completions-optional) - [Platform Support](#platform-support) @@ -169,6 +170,7 @@ git gtr new feature/auth git gtr new feature/implement-user-authentication-with-oauth2-integration --folder auth # Custom folder name git gtr new feature-auth --name backend --force # Same branch, custom name git gtr new my-feature --name descriptive-variant # Optional: custom name without --force +git gtr new agent-task --porcelain # Stable output for automation ``` **Options:** @@ -186,6 +188,13 @@ git gtr new my-feature --name descriptive-variant - `--editor`, `-e`: Open in editor after creation - `--ai`, `-a`: Start AI tool after creation - `--yes`: Non-interactive mode +- `--porcelain`: Emit stable `keyvalue` records (implies `--yes`; incompatible with `--editor` and `--ai`) + +On success, `--porcelain` writes exactly three records to stdout: `path`, +`branch`, and `hook_status`. Progress messages and hook output are written to +stderr. If a post-create hook fails, the command exits non-zero and emits no +success records. See [AI agent usage](docs/agent-usage.md) for the format and +recommended safety boundaries. ### `git gtr pr [options]` @@ -496,6 +505,15 @@ Requires Git 2.17+ and Bash 3.2+. > For troubleshooting, platform-specific notes, and architecture details, see [docs/troubleshooting.md](docs/troubleshooting.md) +## AI Agent Usage + +Shell-capable coding agents can use `git gtr` directly; a separate MCP server is +not required. Use `git gtr new --porcelain` to create a worktree and +reliably capture its path, then run the agent inside that directory. + +See [docs/agent-usage.md](docs/agent-usage.md) for a copy-paste `AGENTS.md` +policy, output contract, and safe lifecycle examples. + ## Advanced Usage For advanced workflows including: diff --git a/completions/_git-gtr b/completions/_git-gtr index c8df383..75c9175 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -74,6 +74,7 @@ _git-gtr() { '--name[Custom folder name suffix]:name:' \ '--folder[Custom folder name (replaces default)]:folder:' \ '--yes[Non-interactive mode]' \ + '--porcelain[Machine-readable output]' \ '--editor[Open in editor after creation]' \ '-e[Open in editor after creation]' \ '--ai[Start AI tool after creation]' \ diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index b92111c..039873b 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -73,6 +73,7 @@ complete -c git -n '__fish_git_gtr_using_command new' -l force -d 'Allow same br complete -c git -n '__fish_git_gtr_using_command new' -l name -d 'Custom folder name suffix' -r complete -c git -n '__fish_git_gtr_using_command new' -l folder -d 'Custom folder name (replaces default)' -r complete -c git -n '__fish_git_gtr_using_command new' -l yes -d 'Non-interactive mode' +complete -c git -n '__fish_git_gtr_using_command new' -l porcelain -d 'Machine-readable output' complete -c git -n '__fish_git_gtr_using_command new' -s e -l editor -d 'Open in editor after creation' complete -c git -n '__fish_git_gtr_using_command new' -s a -l ai -d 'Start AI tool after creation' diff --git a/completions/gtr.bash b/completions/gtr.bash index 31e6a2a..a7f2ea5 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -99,7 +99,7 @@ _git_gtr() { new) # Complete flags if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --sparse --no-sparse --force --name --folder --yes --editor -e --ai -a" -- "$cur")) + COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --sparse --no-sparse --force --name --folder --yes --porcelain --editor -e --ai -a" -- "$cur")) elif [ "$prev" = "--track" ]; then COMPREPLY=($(compgen -W "auto remote local none" -- "$cur")) fi diff --git a/docs/agent-usage.md b/docs/agent-usage.md new file mode 100644 index 0000000..3ecc36c --- /dev/null +++ b/docs/agent-usage.md @@ -0,0 +1,66 @@ +# AI Agent Usage + +Shell-capable coding agents can call `git gtr` directly. A separate MCP server +is not necessary for creating, locating, using, and removing worktrees. + +## Machine-readable creation + +Create a worktree with stable output: + +```bash +git gtr new agent/my-task --porcelain +``` + +The command writes exactly three tab-separated records to stdout: + +```text +path /absolute/path/to/repo-worktrees/agent-my-task +branch agent/my-task +hook_status ran +``` + +Values escape backslashes, tabs, and newlines as `\\`, `\t`, and `\n`. +Progress messages, warnings, and hook output go to stderr. `--porcelain` implies +`--yes` and cannot be combined with `--editor` or `--ai`. + +`hook_status` is one of: + +| Value | Meaning | +| --- | --- | +| `disabled` | Hooks were disabled with `--no-hooks`. | +| `none` | No post-create hooks were configured. | +| `ran` | All configured post-create hooks were trusted and ran successfully. | +| `skipped-untrusted` | Only untrusted `.gtrconfig` hooks were configured, so none ran. | +| `partial` | Trusted hooks ran, while untrusted `.gtrconfig` hooks were skipped. | + +A non-zero exit means creation or a post-create hook failed. No success records +are emitted in that case. + +## Recommended agent lifecycle + +1. Inspect existing worktrees with `git gtr list --porcelain`. +2. Create an isolated worktree with `git gtr new --porcelain`. +3. Parse the `path` record and perform all task work inside that directory. +4. Before handing off, run `git status --short --branch` in the worktree and + report its branch, changes, and validation results. +5. Remove the worktree only when the user explicitly asks for cleanup. + +Agents should not run `git gtr trust`. Trusting committed `.gtrconfig` commands +authorizes code execution and requires human review. Agents should also avoid +`git gtr rm --force`, `--delete-branch`, and `git gtr clean` unless the user has +explicitly authorized the destructive scope. + +## `AGENTS.md` example + +```markdown +## Worktree policy + +- Use `git gtr list --porcelain` to inspect worktrees. +- For implementation tasks, create an isolated worktree with + `git gtr new --porcelain` and work only in the returned `path`. +- Treat a non-zero exit as failure; do not infer success from human-readable logs. +- If `hook_status` is `skipped-untrusted` or `partial`, report it. Never run + `git gtr trust` on the user's behalf. +- Do not remove worktrees, force cleanup, or delete branches without explicit + user authorization. +``` diff --git a/lib/commands/create.sh b/lib/commands/create.sh index c0c7b51..4fca228 100644 --- a/lib/commands/create.sh +++ b/lib/commands/create.sh @@ -84,8 +84,31 @@ _create_resolve_from_ref() { printf "%s" "$from_ref" } + +# Print a stable, escaped record stream for scripting and agent integrations. +# Format: keyvalue, one record per line. +_create_print_porcelain() { + local worktree_path="$1" branch_name="$2" hook_status="$3" + printf "path\t%s\n" "$(_tsv_escape_field "$worktree_path")" + printf "branch\t%s\n" "$(_tsv_escape_field "$branch_name")" + printf "hook_status\t%s\n" "$hook_status" +} + +# Detect machine mode before parsing so all incidental stdout, including hook +# output, can be redirected away from the stable record stream. +_create_wants_porcelain() { + local arg + for arg in "$@"; do + case "$arg" in + --porcelain) return 0 ;; + --) return 1 ;; + esac + done + return 1 +} + # shellcheck disable=SC2154 # _arg_* _pa_* set by parse_args, _ctx_* set by resolve_* -cmd_create() { +_cmd_create_impl() { local _spec _spec="--from: value --from-current @@ -100,6 +123,7 @@ cmd_create() { --force --name: value --folder: value +--porcelain --editor|-e --ai|-a" parse_args "$_spec" "$@" @@ -118,9 +142,18 @@ cmd_create() { local force="${_arg_force:-0}" local custom_name="${_arg_name:-}" local folder_override="${_arg_folder:-}" + local porcelain="${_arg_porcelain:-0}" local open_editor="${_arg_editor:-0}" local start_ai="${_arg_ai:-0}" + if [ "$porcelain" -eq 1 ]; then + yes_mode=1 + if [ "$open_editor" -eq 1 ] || [ "$start_ai" -eq 1 ]; then + log_error "--porcelain cannot be combined with --editor or --ai" + exit 1 + fi + fi + # Validate flag combinations if [ -n "$folder_override" ] && [ -n "$custom_name" ]; then log_error "--folder and --name cannot be used together" @@ -232,16 +265,25 @@ cmd_create() { fi # Run post-create hooks (unless --no-hooks) + local hook_status="disabled" if [ "$skip_hooks" -eq 0 ]; then - run_hooks_in postCreate "$worktree_path" \ + hook_status=$(_hooks_phase_status postCreate) + if ! run_hooks_in postCreate "$worktree_path" \ REPO_ROOT="$repo_root" \ WORKTREE_PATH="$worktree_path" \ - BRANCH="$branch_name" + BRANCH="$branch_name"; then + exit 1 + fi fi echo "" log_info "Worktree created: $worktree_path" + if [ "$porcelain" -eq 1 ]; then + _create_print_porcelain "$worktree_path" "$branch_name" "$hook_status" >&3 + return 0 + fi + # Auto-launch editor/AI or show next steps [ "$open_editor" -eq 1 ] && { _auto_launch_editor "$worktree_path" || true; } [ "$start_ai" -eq 1 ] && { _auto_launch_ai "$worktree_path" "$repo_root" "$branch_name" || true; } @@ -249,3 +291,11 @@ cmd_create() { _post_create_next_steps "$branch_name" "$folder_name" "$folder_override" "$repo_root" "$base_dir" "$prefix" fi } + +cmd_create() { + if _create_wants_porcelain "$@"; then + _cmd_create_impl "$@" 3>&1 1>&2 + else + _cmd_create_impl "$@" + fi +} diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 758b780..91409ff 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -30,6 +30,8 @@ Options: --name Custom folder name suffix (appended after branch name) --folder Custom folder name (replaces default entirely) --yes Non-interactive mode (skip prompts) + --porcelain Machine-readable keyvalue output (implies --yes) + Cannot be combined with --editor or --ai -e, --editor Open in editor after creation -a, --ai Start AI tool after creation @@ -37,6 +39,7 @@ Examples: git gtr new feature/user-auth # Folder: feature-user-auth git gtr new hotfix --from v2.0.0 # Branch from tag git gtr new my-feature --from-current # Branch from current HEAD + git gtr new agent-task --porcelain # Stable output for automation git gtr new feature -e -a # Create, open editor + AI git gtr new feature --force --name backend # Second worktree for same branch git gtr new feature --folder my-dir # Custom folder name @@ -561,6 +564,7 @@ CORE COMMANDS (daily workflow): --name : custom folder name suffix (e.g., backend, frontend) --folder : custom folder name (replaces default, useful for long branches) --yes: non-interactive mode + --porcelain: machine-readable keyvalue output (implies --yes) -e, --editor: open in editor after creation -a, --ai: start AI tool after creation diff --git a/lib/hooks.sh b/lib/hooks.sh index c82cef8..4622a1c 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -212,6 +212,40 @@ _hooks_get_trusted() { } | awk '!seen[$0]++' } +# Report the outcome a successful hook phase will have without executing it. +# Usage: _hooks_phase_status +# Prints one of: none, ran, skipped-untrusted, partial +_hooks_phase_status() { + local phase="$1" + local git_hooks="" config_file="" file_hooks="" + local has_runnable=0 has_untrusted=0 + + git_hooks=$(git config --get-all "gtr.hook.$phase" 2>/dev/null) || true + [ -n "$git_hooks" ] && has_runnable=1 + + config_file=$(_gtrconfig_path) || true + if [ -n "$config_file" ] && [ -f "$config_file" ]; then + file_hooks=$(git config -f "$config_file" --get-all "hooks.$phase" 2>/dev/null) || true + if [ -n "$file_hooks" ]; then + if _hooks_are_trusted "$config_file"; then + has_runnable=1 + else + has_untrusted=1 + fi + fi + fi + + if [ "$has_runnable" -eq 1 ] && [ "$has_untrusted" -eq 1 ]; then + printf "partial" + elif [ "$has_runnable" -eq 1 ]; then + printf "ran" + elif [ "$has_untrusted" -eq 1 ]; then + printf "skipped-untrusted" + else + printf "none" + fi +} + # Run hooks for a specific phase # Usage: run_hooks phase [env_vars...] # Example: run_hooks postCreate REPO_ROOT="$root" WORKTREE_PATH="$path" diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index aee2e9c..ea192ba 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -193,7 +193,7 @@ MIDDLE1 new) # Complete flags if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --sparse --no-sparse --force --name --folder --yes --editor -e --ai -a" -- "$cur")) + COMPREPLY=($(compgen -W "--from --from-current --remote --track --no-copy --no-fetch --no-hooks --sparse --no-sparse --force --name --folder --yes --porcelain --editor -e --ai -a" -- "$cur")) elif [ "$prev" = "--track" ]; then COMPREPLY=($(compgen -W "auto remote local none" -- "$cur")) fi @@ -338,6 +338,7 @@ _git-gtr() { '--name[Custom folder name suffix]:name:' \ '--folder[Custom folder name (replaces default)]:folder:' \ '--yes[Non-interactive mode]' \ + '--porcelain[Machine-readable output]' \ '--editor[Open in editor after creation]' \ '-e[Open in editor after creation]' \ '--ai[Start AI tool after creation]' \ @@ -582,6 +583,7 @@ complete -c git -n '__fish_git_gtr_using_command new' -l force -d 'Allow same br complete -c git -n '__fish_git_gtr_using_command new' -l name -d 'Custom folder name suffix' -r complete -c git -n '__fish_git_gtr_using_command new' -l folder -d 'Custom folder name (replaces default)' -r complete -c git -n '__fish_git_gtr_using_command new' -l yes -d 'Non-interactive mode' +complete -c git -n '__fish_git_gtr_using_command new' -l porcelain -d 'Machine-readable output' complete -c git -n '__fish_git_gtr_using_command new' -s e -l editor -d 'Open in editor after creation' complete -c git -n '__fish_git_gtr_using_command new' -s a -l ai -d 'Start AI tool after creation' diff --git a/tests/cmd_create_integration.bats b/tests/cmd_create_integration.bats index 2b40140..462efb3 100644 --- a/tests/cmd_create_integration.bats +++ b/tests/cmd_create_integration.bats @@ -5,13 +5,21 @@ load test_helper setup() { setup_integration_repo + export XDG_CONFIG_HOME="$BATS_TMPDIR/gtr-create-config-$$" source_gtr_commands } teardown() { + rm -rf "$XDG_CONFIG_HOME" teardown_integration_repo } +_cmd_create_capture_stdout() { + local stdout_file="$1" + shift + cmd_create "$@" > "$stdout_file" +} + # Note: --from HEAD is needed because test repos have no origin remote, # so resolve_default_branch can't detect a default branch. @@ -20,6 +28,76 @@ teardown() { [ -d "$TEST_WORKTREES_DIR/new-feature" ] } +@test "_create_print_porcelain escapes record values" { + local result expected + result=$(_create_print_porcelain $'/tmp/a\tb\nc\\d' $'feature/a\tb' partial) + expected=$'path\t/tmp/a\\tb\\nc\\\\d\nbranch\tfeature/a\\tb\nhook_status\tpartial' + [ "$result" = "$expected" ] +} + +@test "cmd_create --porcelain emits only stable records and implies --yes" { + local stderr_file="$BATS_TMPDIR/create-porcelain-stderr-$$" + local result expected_path + result=$(cmd_create machine-feature --from HEAD --no-fetch --porcelain 2>"$stderr_file") + expected_path=$(canonicalize_path "$TEST_WORKTREES_DIR/machine-feature") + + [ "$result" = $'path\t'"$expected_path"$'\nbranch\tmachine-feature\nhook_status\tnone' ] + [ -d "$TEST_WORKTREES_DIR/machine-feature" ] + grep -q "Worktree created" "$stderr_file" +} + +@test "cmd_create --porcelain keeps hook stdout off the record stream" { + local stderr_file="$BATS_TMPDIR/create-hook-stderr-$$" + local result expected_path + git config --add gtr.hook.postCreate "echo hook-noise" + + result=$(cmd_create machine-hook --from HEAD --no-fetch --porcelain 2>"$stderr_file") + expected_path=$(canonicalize_path "$TEST_WORKTREES_DIR/machine-hook") + + [ "$result" = $'path\t'"$expected_path"$'\nbranch\tmachine-hook\nhook_status\tran' ] + grep -q "hook-noise" "$stderr_file" +} + +@test "cmd_create --porcelain reports untrusted hooks as skipped" { + local stderr_file="$BATS_TMPDIR/create-untrusted-stderr-$$" + local result expected_path + git config -f "$TEST_REPO/.gtrconfig" --add hooks.postCreate "touch should-not-run" + + result=$(cmd_create machine-untrusted --from HEAD --no-fetch --porcelain 2>"$stderr_file") + expected_path=$(canonicalize_path "$TEST_WORKTREES_DIR/machine-untrusted") + + [ "$result" = $'path\t'"$expected_path"$'\nbranch\tmachine-untrusted\nhook_status\tskipped-untrusted' ] + [ ! -e "$TEST_WORKTREES_DIR/machine-untrusted/should-not-run" ] + grep -q "Untrusted .gtrconfig hooks" "$stderr_file" +} + +@test "cmd_create --porcelain reports disabled hooks with --no-hooks" { + local result expected_path + git config --add gtr.hook.postCreate "touch should-not-run" + + result=$(cmd_create machine-no-hooks --from HEAD --no-fetch --no-hooks --porcelain 2>/dev/null) + expected_path=$(canonicalize_path "$TEST_WORKTREES_DIR/machine-no-hooks") + + [ "$result" = $'path\t'"$expected_path"$'\nbranch\tmachine-no-hooks\nhook_status\tdisabled' ] + [ ! -e "$TEST_WORKTREES_DIR/machine-no-hooks/should-not-run" ] +} + +@test "cmd_create rejects interactive launches in porcelain mode" { + run cmd_create machine-editor --from HEAD --no-fetch --porcelain --editor + [ "$status" -eq 1 ] + [ ! -d "$TEST_WORKTREES_DIR/machine-editor" ] +} + +@test "cmd_create --porcelain does not emit success records when a hook fails" { + local stdout_file="$BATS_TMPDIR/create-failed-stdout-$$" + git config --add gtr.hook.postCreate "exit 7" + + run _cmd_create_capture_stdout "$stdout_file" machine-failed --from HEAD --no-fetch --porcelain + + [ "$status" -eq 1 ] + [ ! -s "$stdout_file" ] +} + @test "cmd_create creates worktree with --track none" { cmd_create track-none --from HEAD --track none --no-fetch --yes [ -d "$TEST_WORKTREES_DIR/track-none" ] diff --git a/tests/hooks.bats b/tests/hooks.bats index 296ffc6..a94543e 100644 --- a/tests/hooks.bats +++ b/tests/hooks.bats @@ -19,6 +19,32 @@ teardown() { [ "$status" -eq 0 ] } +@test "_hooks_phase_status reports none without configured hooks" { + [ "$(_hooks_phase_status postCreate)" = "none" ] +} + +@test "_hooks_phase_status reports ran for trusted git config hooks" { + git config --add gtr.hook.postCreate "echo local" + [ "$(_hooks_phase_status postCreate)" = "ran" ] +} + +@test "_hooks_phase_status reports skipped-untrusted for untrusted file hooks" { + git config -f "$TEST_REPO/.gtrconfig" --add hooks.postCreate "echo shared" + [ "$(_hooks_phase_status postCreate)" = "skipped-untrusted" ] +} + +@test "_hooks_phase_status reports ran for trusted file hooks" { + git config -f "$TEST_REPO/.gtrconfig" --add hooks.postCreate "echo shared" + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + [ "$(_hooks_phase_status postCreate)" = "ran" ] +} + +@test "_hooks_phase_status reports partial for runnable and untrusted hooks" { + git config --add gtr.hook.postCreate "echo local" + git config -f "$TEST_REPO/.gtrconfig" --add hooks.postCreate "echo shared" + [ "$(_hooks_phase_status postCreate)" = "partial" ] +} + @test "_hooks_file_hash matches the init wrapper trust hash" { cat > "$TEST_REPO/.gtrconfig" <<'EOF' [hooks]