diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cce59fe..5f59927 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -54,7 +54,7 @@ "name": "git-pilot", "source": "./plugins/git-pilot", "description": "Automated git workflow management for Claude Code — branch creation, commit formatting, push/MR workflows, rebase and conflict resolution, worktree management, stash automation, agent teams support, and natural language configuration.", - "version": "2.2.1", + "version": "3.0.0", "author": { "name": "moukrea" }, diff --git a/TECHNICAL-SPEC.md b/TECHNICAL-SPEC.md deleted file mode 100644 index 63a5c01..0000000 --- a/TECHNICAL-SPEC.md +++ /dev/null @@ -1,2056 +0,0 @@ -# git-pilot v2 — Technical Specification - -## 1. Overview - -git-pilot v2 is a major upgrade to the existing Claude Code plugin that automates git workflows. While v1 handles branch creation, commit formatting, push prompts, and MR creation, it leaves significant gaps: users must drop into a separate terminal for fetching, rebasing, conflict resolution, stash management, and multi-agent coordination. - -v2 closes every gap so the user never runs a git command outside Claude Code. The plugin remains Bash-only (hook scripts) with Markdown skills and a JSON config system. It integrates with Claude Code's hook lifecycle (SessionStart, PreToolUse, PostToolUse, Stop) and adds awareness of Agent Teams for parallel work. - -### Primary use cases - -1. Start a session on a stale branch and have it automatically fetched and fast-forwarded. -2. Finish work and have the plugin rebase onto an updated base branch before pushing. -3. Encounter rebase or merge conflicts and get guided resolution with contextual recommendations. -4. Start working on something unrelated to the current branch and get prompted to switch branches. -5. Run multiple agents in parallel via Agent Teams with isolated git worktrees. -6. Switch branches without manually stashing and restoring uncommitted changes. - -### Target platforms - -Linux, macOS. Any environment where Claude Code runs with `git`, `jq`, and optionally `gh`/`glab`. - -### Key non-goals - -- **Not a git GUI.** The plugin augments Claude's git operations via hooks and behavioral rules; it does not render diffs visually. -- **Not a CI system.** It does not run tests, linters, or builds. It manages git state only. -- **No interactive rebase.** Rebase operations are non-interactive (`git rebase`, not `git rebase -i`). Claude resolves conflicts via file edits, not an interactive TUI. -- **No submodule management.** Git submodules are out of scope. - ---- - -## 2. Architecture - -### 2.1 Component Overview - -``` -┌─────────────────────────────────────────────────────────┐ -│ Claude Code │ -│ │ -│ ┌──────────┐ ┌────────────┐ ┌──────────┐ │ -│ │ CLAUDE.md │ │ Hook Scripts│ │ Skills │ │ -│ │ (rules) │ │ (bash) │ │ (md) │ │ -│ └────┬─────┘ └──────┬─────┘ └────┬─────┘ │ -│ │ │ │ │ -│ └───────┬───────┘──────────────┘ │ -│ │ │ -│ ┌──────┴──────┐ │ -│ │ Shared Libs │ │ -│ │ config.sh │ │ -│ │ git-utils.sh │ │ -│ │ state.sh │ │ -│ │ rebase.sh │ ← NEW │ -│ │ worktree.sh │ ← NEW │ -│ │ agent.sh │ ← NEW │ -│ └──────┬──────┘ │ -│ │ │ -│ ┌──────┴──────┐ │ -│ │ State Files │ │ -│ │ /tmp/git- │ │ -│ │ pilot-*.json │ │ -│ └─────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -### 2.2 Data Flow - -1. **SessionStart**: `session-start.sh` → fetch remote → detect branch freshness → emit systemMessage with branch status and context. -2. **UserPromptSubmit** (NEW): `prompt-context.sh` → gather branch context (name, recent commits) → emit systemMessage for Claude's unrelated-work assessment. -3. **PreToolUse (Bash)**: `pre-commit.sh` → validate commit messages, enforce branch protection, detect branch creation. -4. **PostToolUse (Write|Edit)**: `post-write.sh` → track file changes for auto-commit suggestions. -5. **PostToolUse (Bash)**: `post-bash.sh` → detect push rejection, prompt for push, handle post-rebase state. -6. **Stop**: `session-stop.sh` → drift detection, optional rebase, summary, push/MR workflow, worktree cleanup. - -### 2.3 New Shared Libraries - -| File | Purpose | -|------|---------| -| `scripts/rebase.sh` | Rebase operations, conflict detection, resolution helpers | -| `scripts/worktree.sh` | Worktree creation, removal, listing, registry management | -| `scripts/agent.sh` | Agent Teams detection, prompt suppression logic | - -### 2.4 Key Technology Choices - -- **Bash-only hooks**: Required by Claude Code's hook execution model. All hook scripts are Bash. -- **jq for JSON**: All config, state, and hook I/O is JSON. `jq` is the only parser. -- **Atomic state writes**: All state file updates use `write_state()` (temp file + `mv`) from v1. -- **git worktrees**: Native git feature for parallel working directories. No third-party tools. - ---- - -## 3. Data Model - -### 3.1 Configuration Schema - -The configuration uses a three-tier merge: plugin defaults → global (`~/.claude/git-pilot.json`) → local (`.claude/git-pilot.json`). Local overrides global, global overrides defaults. The merge is a shallow recursive merge via `jq -s '.[0] * .[1]'`. - -#### 3.1.1 Complete v2 Defaults (`defaults/config.json`) - -```jsonc -{ - "git": { - "defaultBranch": "main", - "autoInit": true, - "protectDefaultBranch": "warn", - "autoFetch": true, - "fetchRetries": 2, - "fetchRetryDelaySec": 3 - }, - "branch": { - "pattern": "{{type}}/{{description}}", - "types": ["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci"], - "descriptionSeparator": "-", - "descriptionCase": "kebab", - "maxLength": 72, - "autoCreate": true, - "unrelatedWorkDetection": true, - "autoStashOnSwitch": true - }, - "commit": { - "pattern": "{{type}}({{scope}}): {{description}}", - "types": ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"], - "maxSubjectLength": 72, - "scopeRequired": false, - "body": { - "required": false, - "wrap": 72, - "includeChangedFiles": false - }, - "signature": { - "stripCoAuthoredBy": true, - "stripAiAttribution": true, - "stripSignedOffBy": false - }, - "breakingChange": { - "appendExclamation": true, - "requireBody": true, - "bodyPrefix": "BREAKING CHANGE: " - } - }, - "autoCommit": { - "enabled": true, - "mode": "suggest", - "threshold": 3, - "includeWip": false, - "wipPrefix": "wip: " - }, - "remote": { - "promptForRemote": true, - "skipRemotePrompt": false, - "defaultName": "origin", - "autoPush": false, - "pushOnFinish": "ask" - }, - "rebase": { - "autoRebaseBeforePush": true, - "conflictStrategy": "prompt", - "allowForcePush": "ask" - }, - "mergeRequest": { - "enabled": true, - "createOnFinish": "ask", - "platform": "auto", - "titleFromBranch": true, - "bodyTemplate": null, - "draft": false, - "labels": [], - "assignToSelf": true - }, - "worktree": { - "enabled": true, - "basePath": "../{{project}}-worktrees", - "cleanupOnMerge": true - }, - "agentTeams": { - "suppressPromptsForAgents": true, - "orchestratorOnly": ["push", "mr"] - }, - "summary": { - "includeFileChanges": true, - "includeDiff": false, - "includeCommitLog": true, - "format": "markdown" - } -} -``` - -#### 3.1.2 Backward Compatibility: `protectDefaultBranch` - -v1 used a boolean for `git.protectDefaultBranch`. v2 uses a string enum. The config loading code must handle both: - -```bash -# In config.sh or git-utils.sh -normalize_protect_default_branch() { - local value="$1" - case "$value" in - true) echo "warn" ;; - false) echo "off" ;; - warn|block|off) echo "$value" ;; - *) echo "warn" ;; - esac -} -``` - -#### 3.1.3 New Config Keys Reference - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `git.autoFetch` | boolean | `true` | Run `git fetch` on session start | -| `git.fetchRetries` | integer | `2` | Number of retry attempts on fetch failure | -| `git.fetchRetryDelaySec` | integer | `3` | Seconds between fetch retries | -| `git.protectDefaultBranch` | string | `"warn"` | `"warn"` / `"block"` / `"off"` — was boolean in v1 | -| `branch.unrelatedWorkDetection` | boolean | `true` | Enable unrelated work detection prompts | -| `branch.autoStashOnSwitch` | boolean | `true` | Auto-stash uncommitted changes on branch switch | -| `rebase.autoRebaseBeforePush` | boolean | `true` | Rebase onto base branch before push/MR | -| `rebase.conflictStrategy` | string | `"prompt"` | `"prompt"` / `"abort"` / `"merge-fallback"` | -| `rebase.allowForcePush` | string | `"ask"` | `"ask"` / `"never"` / `"always"` | -| `worktree.enabled` | boolean | `true` | Enable worktree management for Agent Teams | -| `worktree.basePath` | string | `"../{{project}}-worktrees"` | Worktree directory pattern | -| `worktree.cleanupOnMerge` | boolean | `true` | Remove worktree after successful merge | -| `agentTeams.suppressPromptsForAgents` | boolean | `true` | Suppress interactive prompts for spawned agents | -| `agentTeams.orchestratorOnly` | array | `["push", "mr"]` | Operations restricted to orchestrator agent | - -### 3.2 Session State Schema - -State files are stored at `/tmp/git-pilot-${SESSION_ID}.json`. v2 extends the v1 schema: - -```jsonc -{ - "sessionId": "abc123", - "startTime": "2026-02-24T20:00:00Z", - "workingBranch": "feat/add-dark-mode", - "previousBranch": "main", - "headAtStart": "a1b2c3d4", - "baseBranch": "main", - "branchPurpose": "add dark mode", - "changeCount": 0, - "lastCommitAt": null, - "modifiedFiles": [], - "remoteSkipped": false, - "lastFetchAt": "2026-02-24T20:00:05Z", - "isAgent": false, - "agentRole": null, - "activeWorktrees": [], - "stashRefs": [] -} -``` - -New fields (v2): - -| Field | Type | Description | -|-------|------|-------------| -| `baseBranch` | string | The branch this feature branch was created from or targets for MR | -| `branchPurpose` | string | Human-readable purpose derived from branch name (e.g., `"add dark mode"` from `feat/add-dark-mode`) | -| `lastFetchAt` | string\|null | ISO timestamp of last `git fetch` in this session | -| `isAgent` | boolean | Whether this session is a spawned agent (not orchestrator) | -| `agentRole` | string\|null | `"orchestrator"`, `"implementer"`, `"validator"`, or null | -| `activeWorktrees` | array | List of `{path, branch, createdAt}` objects for worktrees created in this session | -| `stashRefs` | array | List of `{ref, branch, message, createdAt}` for stashes created by git-pilot | - -### 3.3 Worktree Registry - -For multi-session worktree tracking, a registry file is stored at `.git/git-pilot-worktrees.json`: - -```jsonc -{ - "worktrees": [ - { - "path": "../myproject-worktrees/feat-add-auth", - "branch": "feat/add-auth", - "baseBranch": "main", - "createdAt": "2026-02-24T20:00:00Z", - "createdBy": "session-abc123", - "status": "active" - } - ] -} -``` - -This registry persists across sessions, unlike session state files which are cleaned up on session stop. - ---- - -## 4. Feature Specifications - -### 4.1 Branch Freshness Detection - -**Trigger**: SessionStart hook (`session-start.sh`) - -**Preconditions**: Repository has at least one remote. `git.autoFetch` is `true`. - -**Behavior**: - -1. Run `git fetch ${remote_name}` with retry logic (see §4.10). -2. Record `lastFetchAt` in session state. -3. Determine the current branch's tracking status: - -```bash -# In git-utils.sh -get_branch_tracking_status() { - local branch="$1" - local remote_name="$2" - local remote_branch="${remote_name}/${branch}" - - # Check if remote branch exists - if ! git rev-parse --verify "$remote_branch" >/dev/null 2>&1; then - echo "no-remote" - return - fi - - local local_ref remote_ref base_ref - local_ref=$(git rev-parse "$branch" 2>/dev/null) - remote_ref=$(git rev-parse "$remote_branch" 2>/dev/null) - base_ref=$(git merge-base "$branch" "$remote_branch" 2>/dev/null || true) - - if [[ "$local_ref" == "$remote_ref" ]]; then - echo "up-to-date" - elif [[ "$local_ref" == "$base_ref" ]]; then - local behind_count - behind_count=$(git rev-list --count "${branch}..${remote_branch}" 2>/dev/null) - echo "behind:${behind_count}" - elif [[ "$remote_ref" == "$base_ref" ]]; then - local ahead_count - ahead_count=$(git rev-list --count "${remote_branch}..${branch}" 2>/dev/null) - echo "ahead:${ahead_count}" - else - local ahead_count behind_count - ahead_count=$(git rev-list --count "${remote_branch}..${branch}" 2>/dev/null) - behind_count=$(git rev-list --count "${branch}..${remote_branch}" 2>/dev/null) - echo "diverged:${ahead_count}:${behind_count}" - fi -} -``` - -4. Emit systemMessage based on status: - -| Status | Action | Message | -|--------|--------|---------| -| `up-to-date` | None | No message | -| `behind:N` | Auto fast-forward | `"[git-pilot] Branch '${branch}' was ${N} commit(s) behind '${remote}/${branch}'. Fast-forwarded to latest."` | -| `behind:N` (ff fails) | Warn | `"[git-pilot] Branch '${branch}' is ${N} commit(s) behind '${remote}/${branch}' but fast-forward failed. Prompt the user: pull with merge, reset to remote, or continue as-is."` | -| `ahead:N` | Inform | `"[git-pilot] Branch '${branch}' is ${N} commit(s) ahead of '${remote}/${branch}'. Unpushed changes."` | -| `diverged:A:B` | Warn | `"[git-pilot] Branch '${branch}' has diverged from '${remote}/${branch}' (${A} local, ${B} remote). Prompt the user: rebase onto remote, merge remote, reset to remote, or continue."` | -| `no-remote` | Inform | No message (new branch, not yet pushed) | - -5. For fast-forward (behind with no local changes): - -```bash -if git merge --ff-only "${remote_branch}" >/dev/null 2>&1; then - # Success — emit fast-forward message -else - # Cannot fast-forward — emit warning with options -fi -``` - -**Edge cases**: -- If `git.autoFetch` is `false`, skip fetch entirely. Still check local vs. tracking branch status if tracking info exists. -- If no remote exists, skip all freshness checks. -- If the branch has no tracking branch (new local branch), skip comparison. -- If fetch fails after all retries, emit a warning and continue without freshness data (see §4.10). - -### 4.2 Base Branch Drift Detection - -**Trigger**: Before push or MR creation. Called from `/finish` skill, `session-stop.sh`, and `post-bash.sh` (on push commands). - -**Preconditions**: On a feature branch (not default branch). Remote exists. `rebase.autoRebaseBeforePush` is `true`. - -**Behavior**: - -```bash -# In git-utils.sh -get_base_branch_drift() { - local current_branch="$1" - local base_branch="$2" - local remote_name="$3" - local remote_base="${remote_name}/${base_branch}" - - # Fetch latest base branch state - git fetch "$remote_name" "$base_branch" 2>/dev/null || return 1 - - # Find the merge base between current branch and remote base - local merge_base - merge_base=$(git merge-base "$current_branch" "$remote_base" 2>/dev/null || true) - - if [[ -z "$merge_base" ]]; then - echo "no-common-ancestor" - return - fi - - # Count commits on base branch since the branch point - local drift_count - drift_count=$(git rev-list --count "${merge_base}..${remote_base}" 2>/dev/null) - - if [[ "$drift_count" -eq 0 ]]; then - echo "no-drift" - else - echo "drifted:${drift_count}" - fi -} -``` - -**Decision flow**: - -1. If `no-drift`: proceed with push/MR. -2. If `drifted:N`: - a. Emit: `"[git-pilot] Base branch '${base}' has ${N} new commit(s) since this branch diverged. Rebasing '${current}' onto '${remote}/${base}'..."` - b. Attempt rebase (see §4.3). - c. If rebase succeeds: emit `"[git-pilot] Rebase succeeded cleanly. Ready to push."` and continue. - d. If rebase has conflicts: handle per `rebase.conflictStrategy` (see §4.3). -3. If `no-common-ancestor`: emit warning: `"[git-pilot] Cannot determine common ancestor between '${current}' and '${base}'. Skipping rebase. Push may require manual review."` Proceed with push. - -**Base branch determination**: - -The base branch is determined in order: -1. From session state `baseBranch` field (set when branch was created via `/branch` skill). -2. From git tracking: `git config branch.${current}.merge` → strip `refs/heads/`. -3. Fall back to `git.defaultBranch` from config. - -### 4.3 Intelligent Rebase and Conflict Resolution - -**New library**: `scripts/rebase.sh` - -#### 4.3.1 Rebase Execution - -```bash -# In rebase.sh -attempt_rebase() { - local target_branch="$1" # Branch to rebase onto (e.g., origin/main) - - # Ensure clean working tree - if has_uncommitted_changes; then - echo "error:dirty-worktree" - return 1 - fi - - # Attempt rebase - local rebase_output - if rebase_output=$(git rebase "$target_branch" 2>&1); then - echo "success" - return 0 - else - # Check if it's a conflict - if git diff --name-only --diff-filter=U 2>/dev/null | head -1 | grep -q .; then - echo "conflict" - return 1 - else - echo "error:${rebase_output}" - return 1 - fi - fi -} -``` - -#### 4.3.2 Conflict Detection and Reporting - -```bash -# In rebase.sh -get_conflict_details() { - local conflict_files - conflict_files=$(git diff --name-only --diff-filter=U 2>/dev/null) - - if [[ -z "$conflict_files" ]]; then - echo "[]" - return - fi - - local result="[" - local first=true - - while IFS= read -r file; do - # Determine conflict type by examining the conflict markers - local ours_only=false - local theirs_only=false - local both_modified=false - - # Check if the file exists in both sides - local ours_exists theirs_exists - ours_exists=$(git ls-files --stage "$file" 2>/dev/null | grep "^[0-9]* [a-f0-9]* 2" | wc -l | tr -d ' ') - theirs_exists=$(git ls-files --stage "$file" 2>/dev/null | grep "^[0-9]* [a-f0-9]* 3" | wc -l | tr -d ' ') - - local conflict_type="both-modified" - if [[ "$ours_exists" == "0" ]]; then - conflict_type="deleted-by-us" - elif [[ "$theirs_exists" == "0" ]]; then - conflict_type="deleted-by-them" - fi - - # Get conflict marker count as complexity indicator - local marker_count=0 - if [[ -f "$file" ]]; then - marker_count=$(grep -c '^<<<<<<< ' "$file" 2>/dev/null || echo "0") - fi - - if [[ "$first" == "true" ]]; then - first=false - else - result+="," - fi - result+=$(jq -n \ - --arg f "$file" \ - --arg t "$conflict_type" \ - --argjson m "$marker_count" \ - '{file: $f, type: $t, conflictRegions: $m}') - done <<< "$conflict_files" - - result+="]" - echo "$result" -} -``` - -#### 4.3.3 Conflict Resolution Messages - -When conflicts are detected, the hook emits a systemMessage with structured conflict information: - -``` -[git-pilot] Rebase conflict in ${count} file(s): - -${for each conflict} -- `${file}` (${type}, ${regions} conflict region(s)) - Recommendation: ${recommendation} -${end for} - -Prompt the user with these options: -1. Resolve conflicts manually (edit the conflicting files, then run `git add ` and `git rebase --continue`) -2. Accept all changes from the base branch (`git checkout --theirs ` for each file) -3. Keep all local changes (`git checkout --ours ` for each file) -4. Abort the rebase (`git rebase --abort`) and push without rebasing -5. Abort the rebase and use merge instead (`git merge ${base_branch}`) -``` - -**Recommendation heuristics**: - -| Conflict type | Recommendation | -|--------------|----------------| -| `deleted-by-us` | `"File was deleted locally but modified on base. If the deletion was intentional, accept ours (delete). Otherwise, accept theirs."` | -| `deleted-by-them` | `"File was deleted on base but modified locally. If your changes are still needed, accept ours. Otherwise, accept theirs (delete)."` | -| `both-modified`, 1 region | `"Single conflict region — likely a small overlap. Manual review recommended."` | -| `both-modified`, >3 regions | `"Multiple conflict regions — significant concurrent changes. Manual review required."` | - -#### 4.3.4 Conflict Strategy Handling - -Based on `rebase.conflictStrategy`: - -| Strategy | Behavior | -|----------|----------| -| `"prompt"` | Emit conflict details and prompt user for resolution choice (default) | -| `"abort"` | Immediately abort rebase: `git rebase --abort`. Emit: `"[git-pilot] Rebase aborted due to conflicts. Push without rebase."` | -| `"merge-fallback"` | Abort rebase, attempt merge instead: `git rebase --abort && git merge ${target}`. If merge also conflicts, fall back to `"prompt"` behavior | - -#### 4.3.5 Force Push Handling - -After a successful rebase that rewrites history (branch was previously pushed), a force push is needed: - -```bash -# In rebase.sh -needs_force_push() { - local branch="$1" - local remote_name="$2" - - # Check if remote tracking branch exists - if ! git rev-parse --verify "${remote_name}/${branch}" >/dev/null 2>&1; then - return 1 # No remote branch, normal push works - fi - - # Check if local and remote have diverged after rebase - local local_ref remote_ref - local_ref=$(git rev-parse "$branch" 2>/dev/null) - remote_ref=$(git rev-parse "${remote_name}/${branch}" 2>/dev/null) - - if [[ "$local_ref" != "$remote_ref" ]]; then - # Check if remote is NOT an ancestor of local (diverged, not just ahead) - if ! git merge-base --is-ancestor "$remote_ref" "$local_ref" 2>/dev/null; then - return 0 # Needs force push - fi - fi - - return 1 -} -``` - -Based on `rebase.allowForcePush`: - -| Setting | Behavior | -|---------|----------| -| `"ask"` | Emit: `"[git-pilot] Rebase rewrote history. Force push required. Prompt the user: force push (git push --force-with-lease) or abort."` | -| `"never"` | Emit: `"[git-pilot] Rebase rewrote history but force push is disabled. The rebase changes are local only. Push manually if needed."` | -| `"always"` | Use `git push --force-with-lease` automatically. Emit: `"[git-pilot] Force-pushed '${branch}' to '${remote}/${branch}' after rebase."` | - -Always use `--force-with-lease` (never bare `--force`) for safety. - -#### 4.3.6 Push Rejection Handling - -When `post-bash.sh` detects a failed `git push` (exit code non-zero, stderr contains "rejected" or "failed to push"): - -``` -[git-pilot] Push rejected — remote '${remote}/${branch}' has new commits. -Prompt the user: -1. Pull and rebase, then retry push (`git pull --rebase && git push`) -2. Force push with lease (`git push --force-with-lease`) — overwrites remote changes -3. Pull and merge (`git pull`) — creates a merge commit -4. Cancel -``` - -### 4.4 Unrelated Work Detection - -**Mechanism**: CLAUDE.md behavioral rule + session state context. No additional hook required. - -**Preconditions**: On a feature branch (not default branch). `branch.unrelatedWorkDetection` is `true`. Branch has at least one commit. - -**CLAUDE.md Rule** (new Rule 7): - -```markdown -## Rule 7: Unrelated work detection - -Before starting work on a new user request, assess whether the request is related -to the current branch's purpose: - -1. **Branch name**: Parse the branch name for semantic meaning. For example, - `feat/add-dark-mode` implies work on dark mode; `fix/login-timeout` implies - fixing a login timeout bug. -2. **Recent commits**: Review the commit log on this branch for scope context. -3. **Assessment**: If the user's request is clearly unrelated to the branch's - purpose (different feature, different bug, different module), prompt the user: - - "This work appears unrelated to the current branch (``). - Options: - 1. Create a new branch from `` (recommended — keeps branches focused) - 2. Create a new branch from the current branch (if this work depends on current changes) - 3. Continue on this branch" - -4. **When NOT to prompt**: Do not prompt for closely related work (e.g., fixing - a bug discovered while implementing a feature on the same branch), for work - on the default branch, or for branches with no commits yet. -5. **If the user chooses to create a new branch**: Follow the branch switch - workflow (see Rule 8). -``` - -**Branch purpose derivation** (stored in session state at init): - -```bash -# In git-utils.sh -derive_branch_purpose() { - local branch_name="$1" - - # Strip type prefix (e.g., "feat/", "fix/") - local description - description=$(echo "$branch_name" | sed 's|^[^/]*/||') - - # Convert kebab-case/snake_case to words - description=$(echo "$description" | tr '-' ' ' | tr '_' ' ') - - echo "$description" -} -``` - -The session-start.sh script stores `branchPurpose` in state and includes it in its systemMessage so Claude has context for the entire session. - -### 4.5 Agent Teams Detection and Prompt Suppression - -**New library**: `scripts/agent.sh` - -#### 4.5.1 Agent Detection - -```bash -# In agent.sh -is_agent_context() { - # Primary: check Claude Code spawned-by indicator - if [[ -n "${CLAUDE_SPAWNED_BY:-}" ]]; then - return 0 - fi - - # Secondary: check for agent role in session state - local session_id="${1:-}" - if [[ -n "$session_id" ]]; then - local state_file - state_file=$(get_state_file "$session_id") - local state - state=$(read_state "$state_file") - local is_agent - is_agent=$(echo "$state" | jq -r '.isAgent // false') - if [[ "$is_agent" == "true" ]]; then - return 0 - fi - fi - - return 1 -} - -# Check if a specific operation should be suppressed for agents -is_operation_agent_restricted() { - local config="$1" - local operation="$2" # "push", "mr", "branch-prompt", etc. - - local suppress - suppress=$(get_config "$config" '.agentTeams.suppressPromptsForAgents' 'true') - - if [[ "$suppress" != "true" ]]; then - return 1 # Not suppressed - fi - - # Check if this specific operation is orchestrator-only - local restricted - restricted=$(echo "$config" | jq -r --arg op "$operation" \ - '.agentTeams.orchestratorOnly // ["push", "mr"] | map(select(. == $op)) | length') - - if [[ "$restricted" -gt 0 ]]; then - return 0 # Restricted to orchestrator - fi - - return 1 -} -``` - -#### 4.5.2 Prompt Suppression - -All hook scripts that emit interactive systemMessages must check agent context: - -```bash -# Pattern used in all hooks before emitting interactive prompts -if is_agent_context "$SESSION_ID" && \ - is_operation_agent_restricted "$CONFIG" "push"; then - # Agent mode — suppress prompt, exit silently - echo '{"continue": true}' - exit 0 -fi -``` - -**Operations and their agent behavior**: - -| Operation | Orchestrator | Agent | -|-----------|-------------|-------| -| Branch creation prompt | Interactive | Suppressed | -| Push prompt after commit | Interactive | Suppressed | -| MR creation | Interactive | Suppressed | -| Commit validation | Active | Active (agents must also follow commit rules) | -| Auto-commit suggestions | Active | Suppressed | -| Rebase/conflict prompts | Interactive | Suppressed (abort rebase silently) | -| Session summary | Active | Suppressed | -| Branch freshness warnings | Active | Log to state only (no prompt) | - -### 4.6 Git Worktree Management - -**New library**: `scripts/worktree.sh` - -#### 4.6.1 Worktree Creation - -```bash -# In worktree.sh -create_worktree() { - local config="$1" - local branch_name="$2" - local base_branch="$3" - local session_id="${4:-}" - - local project_name - project_name=$(basename "$(git rev-parse --show-toplevel)") - - local base_path - base_path=$(get_config "$config" '.worktree.basePath' "../{{project}}-worktrees") - base_path="${base_path//\{\{project\}\}/$project_name}" - - # Sanitize branch name for directory path - local dir_name - dir_name=$(echo "$branch_name" | tr '/' '-') - local worktree_path="${base_path}/${dir_name}" - - # Create parent directory - mkdir -p "$(dirname "$worktree_path")" - - # Create worktree - local output - if output=$(git worktree add "$worktree_path" -b "$branch_name" "$base_branch" 2>&1); then - # Register in worktree registry - register_worktree "$worktree_path" "$branch_name" "$base_branch" "$session_id" - echo "$worktree_path" - return 0 - else - echo "error:${output}" >&2 - return 1 - fi -} -``` - -#### 4.6.2 Worktree Removal - -```bash -remove_worktree() { - local worktree_path="$1" - local force="${2:-false}" - - local flags="" - if [[ "$force" == "true" ]]; then - flags="--force" - fi - - if git worktree remove "$worktree_path" $flags 2>/dev/null; then - unregister_worktree "$worktree_path" - return 0 - else - return 1 - fi -} -``` - -#### 4.6.3 Worktree Registry - -```bash -WORKTREE_REGISTRY=".git/git-pilot-worktrees.json" - -register_worktree() { - local path="$1" - local branch="$2" - local base_branch="$3" - local session_id="${4:-}" - - local registry - if [[ -f "$WORKTREE_REGISTRY" ]]; then - registry=$(cat "$WORKTREE_REGISTRY") - else - registry='{"worktrees":[]}' - fi - - local entry - entry=$(jq -n \ - --arg p "$path" \ - --arg b "$branch" \ - --arg bb "$base_branch" \ - --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --arg sid "$session_id" \ - '{path:$p, branch:$b, baseBranch:$bb, createdAt:$ts, createdBy:$sid, status:"active"}') - - registry=$(echo "$registry" | jq --argjson e "$entry" '.worktrees += [$e]') - - local tmp="${WORKTREE_REGISTRY}.tmp.$$" - echo "$registry" > "$tmp" && mv "$tmp" "$WORKTREE_REGISTRY" -} - -unregister_worktree() { - local path="$1" - - if [[ ! -f "$WORKTREE_REGISTRY" ]]; then - return - fi - - local registry - registry=$(cat "$WORKTREE_REGISTRY") - registry=$(echo "$registry" | jq --arg p "$path" \ - '.worktrees = [.worktrees[] | select(.path != $p)]') - - local tmp="${WORKTREE_REGISTRY}.tmp.$$" - echo "$registry" > "$tmp" && mv "$tmp" "$WORKTREE_REGISTRY" -} - -list_worktrees() { - if [[ -f "$WORKTREE_REGISTRY" ]]; then - cat "$WORKTREE_REGISTRY" - else - echo '{"worktrees":[]}' - fi -} -``` - -#### 4.6.4 Worktree Merge Back - -```bash -merge_worktree_branch() { - local worktree_path="$1" - local target_branch="$2" - local config="$3" - - # Get the worktree's branch - local wt_branch - wt_branch=$(git -C "$worktree_path" branch --show-current 2>/dev/null) - - if [[ -z "$wt_branch" ]]; then - echo "error:cannot-determine-branch" - return 1 - fi - - # Switch to target branch in main worktree - git checkout "$target_branch" 2>/dev/null || return 1 - - # Attempt merge - local merge_output - if merge_output=$(git merge "$wt_branch" --no-edit 2>&1); then - echo "success" - - # Cleanup if configured - local cleanup - cleanup=$(get_config "$config" '.worktree.cleanupOnMerge' 'true') - if [[ "$cleanup" == "true" ]]; then - remove_worktree "$worktree_path" "false" - git branch -d "$wt_branch" 2>/dev/null || true - fi - - return 0 - else - echo "conflict" - return 1 - fi -} -``` - -### 4.7 Stash Management - -**Modified file**: `scripts/git-utils.sh` - -#### 4.7.1 Auto-stash on Branch Switch - -When `branch.autoStashOnSwitch` is `true` and a branch switch is detected in `pre-commit.sh` (which also intercepts branch commands): - -```bash -# In git-utils.sh -auto_stash() { - local current_branch="$1" - local session_id="$2" - - if ! has_uncommitted_changes; then - return 1 # Nothing to stash - fi - - local stash_msg="git-pilot auto-stash on ${current_branch}" - if git stash push -m "$stash_msg" >/dev/null 2>&1; then - local stash_ref - stash_ref=$(git stash list --format='%gd' | head -1) - - # Record in session state - if [[ -n "$session_id" ]]; then - local state_file - state_file=$(get_state_file "$session_id") - update_state "$state_file" \ - --arg ref "$stash_ref" \ - --arg branch "$current_branch" \ - --arg msg "$stash_msg" \ - '.stashRefs += [{ref: $ref, branch: $branch, message: $msg, createdAt: (now | todate)}]' - fi - - return 0 - fi - - return 1 -} -``` - -#### 4.7.2 Auto-restore on Branch Switch - -When switching back to a branch that had changes stashed: - -```bash -auto_restore_stash() { - local target_branch="$1" - local session_id="$2" - - if [[ -z "$session_id" ]]; then - return 1 - fi - - local state_file - state_file=$(get_state_file "$session_id") - local state - state=$(read_state "$state_file") - - # Find stash for this branch - local stash_ref - stash_ref=$(echo "$state" | jq -r \ - --arg branch "$target_branch" \ - '.stashRefs[] | select(.branch == $branch) | .ref' | head -1) - - if [[ -z "$stash_ref" ]] || [[ "$stash_ref" == "null" ]]; then - return 1 # No stash for this branch - fi - - # Find the stash index by message - local stash_msg - stash_msg=$(echo "$state" | jq -r \ - --arg branch "$target_branch" \ - '.stashRefs[] | select(.branch == $branch) | .message' | head -1) - - local stash_index - stash_index=$(git stash list --format='%gd %s' | grep "$stash_msg" | head -1 | cut -d' ' -f1) - - if [[ -n "$stash_index" ]]; then - if git stash pop "$stash_index" >/dev/null 2>&1; then - # Remove from state - update_state "$state_file" \ - --arg branch "$target_branch" \ - '.stashRefs = [.stashRefs[] | select(.branch != $branch)]' - return 0 - fi - fi - - return 1 -} -``` - -#### 4.7.3 Messages - -| Event | Message | -|-------|---------| -| Auto-stash on switch | `"[git-pilot] Stashed uncommitted changes on '${branch}' before switching."` | -| Auto-restore on return | `"[git-pilot] Restored stashed changes on '${branch}'."` | -| Restore failed (conflicts) | `"[git-pilot] Could not auto-restore stash on '${branch}' — conflicts detected. Run 'git stash pop' manually to resolve."` | - -### 4.8 Detached HEAD Recovery - -**Trigger**: `session-start.sh`, when `get_current_branch` returns empty. - -```bash -# In session-start.sh -current_branch=$(get_current_branch) - -if [[ -z "$current_branch" ]]; then - # Detached HEAD - local head_sha - head_sha=$(git rev-parse --short HEAD 2>/dev/null) - - # Try to find what branch we were on - local prev_branch - prev_branch=$(git reflog show --format='%gs' | grep -m1 'checkout: moving from' | \ - sed 's/checkout: moving from \([^ ]*\) to .*/\1/') - - if [[ -n "$prev_branch" ]]; then - messages+=("[git-pilot] Detached HEAD at ${head_sha}. Previous branch was '${prev_branch}'. Prompt the user: return to '${prev_branch}', create a new branch from HEAD, or continue in detached state.") - else - messages+=("[git-pilot] Detached HEAD at ${head_sha}. Prompt the user: create a new branch from HEAD or continue in detached state.") - fi -fi -``` - -### 4.9 Protected Branch Enforcement (Enhanced) - -**Modified file**: `scripts/pre-commit.sh` - -v1 only warns when committing to the default branch. v2 adds `"block"` mode: - -```bash -protect_mode=$(get_config "$CONFIG" '.git.protectDefaultBranch' 'warn') -protect_mode=$(normalize_protect_default_branch "$protect_mode") - -if is_on_default_branch "$CONFIG" 2>/dev/null; then - default_br=$(get_default_branch "$CONFIG") - - case "$protect_mode" in - warn) - # v1 behavior — allow with warning - SYSTEM_MSG="[git-pilot] Warning: You are committing directly to '${default_br}'. Consider creating a feature branch first." - ;; - block) - # v2 — prevent the commit - output_block "[git-pilot] Commits to '${default_br}' are blocked by policy (git.protectDefaultBranch: block). Create a feature branch first using /branch." - ;; - off) - # No protection - ;; - esac -fi -``` - -### 4.10 Network Error Handling - -**Modified file**: `scripts/git-utils.sh` - -```bash -# In git-utils.sh -fetch_with_retry() { - local remote_name="$1" - local config="$2" - local specific_branch="${3:-}" - - local max_retries - max_retries=$(get_config "$config" '.git.fetchRetries' '2') - local retry_delay - retry_delay=$(get_config "$config" '.git.fetchRetryDelaySec' '3') - - local fetch_args="$remote_name" - if [[ -n "$specific_branch" ]]; then - fetch_args="$remote_name $specific_branch" - fi - - local attempt=0 - local last_error="" - - while (( attempt <= max_retries )); do - if git fetch $fetch_args 2>/dev/null; then - return 0 - fi - - last_error=$(git fetch $fetch_args 2>&1 || true) - attempt=$((attempt + 1)) - - if (( attempt <= max_retries )); then - sleep "$retry_delay" - fi - done - - # All retries exhausted - echo "$last_error" >&2 - return 1 -} -``` - -Messages: - -| Event | Message | -|-------|---------| -| Fetch failed, retrying | (no message — retries are silent) | -| All retries exhausted | `"[git-pilot] Warning: Could not fetch from '${remote}' after ${retries} attempts. Network may be unavailable. Proceeding without remote sync."` | -| Push failed (network) | `"[git-pilot] Push to '${remote}/${branch}' failed. Check network connectivity and try again."` | - ---- - -## 5. External Interfaces - -### 5.1 Hook Scripts - -#### 5.1.1 Updated hooks.json - -```json -{ - "description": "git-pilot: Automated git workflow management", - "hooks": { - "SessionStart": [ - { - "matcher": "startup", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh", - "timeout": 30 - } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/prompt-context.sh", - "timeout": 5 - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/pre-commit.sh", - "timeout": 10 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/post-write.sh", - "timeout": 10 - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/post-bash.sh", - "timeout": 15 - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/session-stop.sh", - "timeout": 45 - } - ] - } - ] - } -} -``` - -**Changes from v1**: -- SessionStart timeout: 15 → 30 (to accommodate fetch + freshness check) -- PostToolUse Bash timeout: 10 → 15 (to accommodate push rejection detection + rebase) -- Stop timeout: 30 → 45 (to accommodate drift detection + rebase + MR) -- NEW: UserPromptSubmit hook for branch context (5s timeout) - -#### 5.1.2 New Script: `prompt-context.sh` - -Runs on every user prompt submission. Provides branch context for unrelated work detection. - -```bash -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/config.sh" -source "$SCRIPT_DIR/git-utils.sh" -source "$SCRIPT_DIR/agent.sh" - -input=$(cat) -session_id=$(echo "$input" | jq -r '.session_id // empty') -cwd=$(echo "$input" | jq -r '.cwd // empty') - -if [[ -z "$cwd" ]]; then - echo '{"continue": true}' - exit 0 -fi - -cd "$cwd" 2>/dev/null || { echo '{"continue": true}'; exit 0; } - -if ! is_git_repo; then - echo '{"continue": true}' - exit 0 -fi - -config=$(load_config "$cwd") - -# Check if unrelated work detection is enabled -detection_enabled=$(get_config "$config" '.branch.unrelatedWorkDetection' 'true') -if [[ "$detection_enabled" != "true" ]]; then - echo '{"continue": true}' - exit 0 -fi - -# Skip if agent context -if is_agent_context "$session_id"; then - echo '{"continue": true}' - exit 0 -fi - -current_branch=$(get_current_branch) -default_branch=$(get_default_branch "$config") - -# Skip if on default branch or no branch (detached HEAD) -if [[ -z "$current_branch" ]] || [[ "$current_branch" == "$default_branch" ]]; then - echo '{"continue": true}' - exit 0 -fi - -# Check if branch has commits -commit_count=$(git rev-list --count "${default_branch}..${current_branch}" 2>/dev/null || echo "0") -if [[ "$commit_count" == "0" ]]; then - echo '{"continue": true}' - exit 0 -fi - -# Gather context -branch_purpose=$(derive_branch_purpose "$current_branch") -recent_commits=$(git log "${default_branch}..${current_branch}" --oneline --no-decorate -5 2>/dev/null || true) - -message="[git-pilot] Branch context: '${current_branch}' (${branch_purpose}). ${commit_count} commit(s). Recent: ${recent_commits}" - -jq -n --arg msg "$message" '{"continue": true, "systemMessage": $msg}' -``` - -#### 5.1.3 Modified Script: `session-start.sh` - -Key additions to existing session-start.sh: - -1. **Fetch remote** (after git init check, before branch detection): - -```bash -# After Step 6 (git init check), before Step 7 (branch detection): - -# Step 6.5: Fetch remote -if is_git_repo && has_remote; then - auto_fetch=$(get_config "$CONFIG" '.git.autoFetch' 'true') - remote_name=$(get_config "$CONFIG" '.remote.defaultName' 'origin') - - if [[ "$auto_fetch" == "true" ]]; then - if ! fetch_with_retry "$remote_name" "$CONFIG"; then - messages+=("[git-pilot] Warning: Could not fetch from '${remote_name}'. Proceeding without remote sync.") - fi - fi -fi -``` - -2. **Branch freshness check** (after branch detection): - -```bash -# After getting current_branch: -if is_git_repo && has_remote && [[ -n "$current_branch" ]]; then - remote_name=$(get_config "$CONFIG" '.remote.defaultName' 'origin') - tracking_status=$(get_branch_tracking_status "$current_branch" "$remote_name") - - case "$tracking_status" in - behind:*) - behind_count="${tracking_status#behind:}" - if git merge --ff-only "${remote_name}/${current_branch}" >/dev/null 2>&1; then - messages+=("[git-pilot] Branch '${current_branch}' was ${behind_count} commit(s) behind '${remote_name}/${current_branch}'. Fast-forwarded to latest.") - else - messages+=("[git-pilot] Branch '${current_branch}' is ${behind_count} commit(s) behind '${remote_name}/${current_branch}' but fast-forward failed. Prompt the user: pull with merge, reset to remote, or continue as-is.") - fi - ;; - diverged:*:*) - IFS=':' read -r _ ahead_count behind_count <<< "$tracking_status" - messages+=("[git-pilot] Branch '${current_branch}' has diverged from '${remote_name}/${current_branch}' (${ahead_count} ahead, ${behind_count} behind). Prompt the user: rebase onto remote, merge remote, reset to remote, or continue.") - ;; - ahead:*) - ahead_count="${tracking_status#ahead:}" - messages+=("[git-pilot] ${ahead_count} unpushed commit(s) on '${current_branch}'.") - ;; - # up-to-date and no-remote: no message - esac -fi -``` - -3. **Detached HEAD detection** (in branch detection): - -```bash -if [[ -z "$current_branch" ]]; then - # Detached HEAD handling (see §4.8) -fi -``` - -4. **Extended state init** (replace Step 9): - -```bash -if [[ -n "$SESSION_ID" ]]; then - base_branch="$default_branch" - branch_purpose="" - if [[ -n "$current_branch" ]] && [[ "$current_branch" != "$default_branch" ]]; then - branch_purpose=$(derive_branch_purpose "$current_branch") - # Try to detect base branch from tracking config - configured_base=$(git config "branch.${current_branch}.merge" 2>/dev/null | sed 's|refs/heads/||' || true) - if [[ -n "$configured_base" ]]; then - base_branch="$configured_base" - fi - fi - init_state "$SESSION_ID" "$current_branch" "$previous_branch" "$base_branch" "$branch_purpose" -fi -``` - -#### 5.1.4 Modified Script: `session-stop.sh` - -Key additions: - -1. **Drift detection before push/MR** (after work summary, before push workflow): - -```bash -# After section 1 (work summary), before section 2 (push workflow): - -# 1.5: Base branch drift detection -if [[ "$session_had_changes" == "true" ]] && has_remote; then - auto_rebase=$(get_config "$config" '.rebase.autoRebaseBeforePush' 'true') - - if [[ "$auto_rebase" == "true" ]] && [[ "$current_branch" != "$default_branch" ]]; then - source "$SCRIPT_DIR/rebase.sh" - - drift_status=$(get_base_branch_drift "$current_branch" "$default_branch" "$remote_name") - - case "$drift_status" in - drifted:*) - drift_count="${drift_status#drifted:}" - messages+=("[git-pilot] Base branch '${default_branch}' has ${drift_count} new commit(s). Rebasing '${current_branch}' onto '${remote_name}/${default_branch}'...") - - rebase_result=$(attempt_rebase "${remote_name}/${default_branch}") - - case "$rebase_result" in - success) - messages+=("[git-pilot] Rebase succeeded cleanly. Ready to push.") - ;; - conflict) - conflict_strategy=$(get_config "$config" '.rebase.conflictStrategy' 'prompt') - case "$conflict_strategy" in - prompt) - conflicts=$(get_conflict_details) - conflict_count=$(echo "$conflicts" | jq 'length') - conflict_files=$(echo "$conflicts" | jq -r '.[].file' | paste -sd', ') - messages+=("[git-pilot] Rebase conflicts in ${conflict_count} file(s): ${conflict_files}. Prompt the user to resolve conflicts, abort rebase, or use merge instead.") - ;; - abort) - git rebase --abort 2>/dev/null - messages+=("[git-pilot] Rebase aborted due to conflicts. Pushing without rebase.") - ;; - merge-fallback) - git rebase --abort 2>/dev/null - if git merge "${remote_name}/${default_branch}" --no-edit 2>/dev/null; then - messages+=("[git-pilot] Merge with '${default_branch}' succeeded (rebase had conflicts).") - else - conflicts=$(get_conflict_details) - conflict_count=$(echo "$conflicts" | jq 'length') - messages+=("[git-pilot] Both rebase and merge have conflicts in ${conflict_count} file(s). Prompt the user to resolve.") - fi - ;; - esac - ;; - esac - ;; - # no-drift, no-common-ancestor: no action - esac - fi -fi -``` - -2. **Worktree cleanup** (after MR/push, before state cleanup): - -```bash -# 3.5: Worktree cleanup -source "$SCRIPT_DIR/worktree.sh" -registry=$(list_worktrees) -active_count=$(echo "$registry" | jq '.worktrees | length') - -if [[ "$active_count" -gt 0 ]]; then - cleanup_on_merge=$(get_config "$config" '.worktree.cleanupOnMerge' 'true') - if [[ "$cleanup_on_merge" == "true" ]]; then - # Clean up merged worktrees - echo "$registry" | jq -r '.worktrees[] | select(.status == "active") | .path' | \ - while IFS= read -r wt_path; do - if [[ -d "$wt_path" ]]; then - wt_branch=$(git -C "$wt_path" branch --show-current 2>/dev/null || true) - # Check if branch is merged into default - if [[ -n "$wt_branch" ]] && git branch --merged "$default_branch" | grep -q "$wt_branch"; then - remove_worktree "$wt_path" "false" - fi - else - # Worktree directory gone, just unregister - unregister_worktree "$wt_path" - fi - done - fi - - remaining=$(list_worktrees | jq '.worktrees | length') - if [[ "$remaining" -gt 0 ]]; then - messages+=("[git-pilot] ${remaining} active worktree(s) remain. Use /worktree to manage them.") - fi -fi -``` - -#### 5.1.5 Modified Script: `post-bash.sh` - -Key additions: - -1. **Agent suppression**: - -```bash -source "$SCRIPT_DIR/agent.sh" - -# After loading config, before checking for push: -if is_agent_context "$session_id"; then - if is_operation_agent_restricted "$config" "push"; then - echo '{"continue": true}' - exit 0 - fi -fi -``` - -2. **Push rejection detection** (new, after existing push prompt logic): - -```bash -# Detect push rejection from the command output -exit_code=$(echo "$input" | jq -r '.tool_result.exitCode // 0') -stdout=$(echo "$input" | jq -r '.tool_result.stdout // empty') -stderr=$(echo "$input" | jq -r '.tool_result.stderr // empty') - -if echo "$command" | grep -qE 'git\s+push' && [[ "$exit_code" != "0" ]]; then - if echo "$stderr" | grep -qiE 'rejected|failed to push|non-fast-forward'; then - current_branch=$(get_current_branch) - remote_name=$(get_config "$config" '.remote.defaultName' 'origin') - - message="[git-pilot] Push rejected — remote '${remote_name}/${current_branch}' has new commits. Prompt the user: -1. Pull and rebase, then retry push (git pull --rebase && git push) -2. Force push with lease (git push --force-with-lease) -3. Pull and merge (git pull) -4. Cancel" - - jq -n --arg msg "$message" '{"continue": true, "systemMessage": $msg}' - exit 0 - fi -fi -``` - -#### 5.1.6 Modified Script: `post-write.sh` - -Agent suppression: - -```bash -source "$SCRIPT_DIR/agent.sh" - -# After loading config: -if is_agent_context "$session_id"; then - # Still track file changes but don't emit commit suggestions - # (tracking code runs, but skip the threshold message) - exit 0 -fi -``` - -#### 5.1.7 Modified Script: `pre-commit.sh` - -Key additions: - -1. **Branch switch detection with auto-stash**: - -```bash -# In the branch creation detection section, add branch switch detection: -is_branch_switch_command() { - local cmd="$1" - SWITCH_TARGET="" - - # git checkout (not -b) - if [[ "$cmd" =~ git[[:space:]]+checkout[[:space:]]+([^-][^[:space:]]+) ]] && \ - ! [[ "$cmd" =~ git[[:space:]]+checkout[[:space:]]+-[bB] ]]; then - SWITCH_TARGET="${BASH_REMATCH[1]}" - return 0 - fi - - # git switch (not -c) - if [[ "$cmd" =~ git[[:space:]]+switch[[:space:]]+([^-][^[:space:]]+) ]] && \ - ! [[ "$cmd" =~ git[[:space:]]+switch[[:space:]]+-[cC] ]]; then - SWITCH_TARGET="${BASH_REMATCH[1]}" - return 0 - fi - - return 1 -} - -# Before existing commit detection: -SWITCH_TARGET="" -if is_branch_switch_command "$COMMAND"; then - auto_stash=$(get_config "$CONFIG" '.branch.autoStashOnSwitch' 'true') - if [[ "$auto_stash" == "true" ]] && has_uncommitted_changes; then - current_br=$(get_current_branch) - if auto_stash "$current_br" "$SESSION_ID"; then - output_allow_with_message "[git-pilot] Stashed uncommitted changes on '${current_br}' before switching to '${SWITCH_TARGET}'." - fi - fi -fi -``` - -2. **Enhanced protected branch blocking** (see §4.9). - -### 5.2 Skills - -#### 5.2.1 Existing Skills (Modified) - -**`/branch`**: Add `baseBranch` recording to session state after branch creation. Emit the new branch's purpose in the confirmation message. - -Add after Step 4: - -```markdown -## Step 5: Record Branch Context - -After creating the branch, note the base branch (the branch you were on before switching) -and the branch purpose (derived from the description). These are used for unrelated work -detection and drift checks. -``` - -**`/finish`**: Add drift detection before push (see §4.2). The skill should call the same drift detection logic as `session-stop.sh`. - -Add between Step 1 and Step 2: - -```markdown -## Step 1.5: Check Base Branch Drift - -Before pushing, check if the base branch has advanced: - -1. Run `git fetch `. -2. Check for new commits on the base branch since this branch diverged. -3. If the base has new commits and `rebase.autoRebaseBeforePush` is `true`: - - Attempt `git rebase /`. - - If rebase succeeds: continue to push. - - If rebase has conflicts: present conflict details and options to the user. -4. If force push is needed after rebase, follow `rebase.allowForcePush` policy. -``` - -**`/summary`**: No changes needed. - -**`/configure`**: Add all new config keys to the reference table. - -Add to the reference table: - -```markdown -| "Fetch remote on session start" | `git.autoFetch: true` | -| "Block commits to main" | `git.protectDefaultBranch: "block"` | -| "Don't warn about main branch commits" | `git.protectDefaultBranch: "off"` | -| "Disable unrelated work detection" | `branch.unrelatedWorkDetection: false` | -| "Don't auto-stash on branch switch" | `branch.autoStashOnSwitch: false` | -| "Don't auto-rebase before push" | `rebase.autoRebaseBeforePush: false` | -| "Never force push" | `rebase.allowForcePush: "never"` | -| "Always force push after rebase" | `rebase.allowForcePush: "always"` | -| "Use merge when rebase conflicts" | `rebase.conflictStrategy: "merge-fallback"` | -| "Disable worktrees" | `worktree.enabled: false` | -| "Worktrees in /tmp" | `worktree.basePath: "/tmp/{{project}}-worktrees"` | -``` - -#### 5.2.2 New Skills - -**`/stash`** — Manual stash management. - -```markdown ---- -name: stash -description: Manage git stashes - save, list, apply, or drop ---- - -# /stash - -Manage git stashes for the current repository. - -## Step 1: Determine Action - -If the user provided an argument after `/stash`, parse it: -- `/stash` or `/stash save` — stash current changes -- `/stash list` — list all stashes -- `/stash pop` or `/stash apply` — restore the most recent stash -- `/stash drop` — drop the most recent stash - -If no argument, ask the user what they want to do. - -## Step 2: Execute - -### Save -1. Check for uncommitted changes. If none: "Nothing to stash." -2. Ask the user for an optional stash message. -3. Run `git stash push -m ""` (or `git stash push` if no message). -4. Confirm: "Stashed changes: " - -### List -1. Run `git stash list`. -2. If empty: "No stashes found." -3. Present the list with index, branch, and message. - -### Pop / Apply -1. Run `git stash list`. If empty: "No stashes to restore." -2. If multiple stashes, show the list and ask which one. -3. For pop: `git stash pop `. For apply: `git stash apply `. -4. If conflicts: warn the user and show conflicting files. - -### Drop -1. Run `git stash list`. If empty: "No stashes to drop." -2. If multiple stashes, show the list and ask which one. -3. Confirm before dropping. -4. Run `git stash drop `. -``` - -**`/worktree`** — Manual worktree management. - -```markdown ---- -name: worktree -description: Manage git worktrees for parallel branch work ---- - -# /worktree - -Manage git worktrees for parallel branch work. - -## Step 1: Determine Action - -Parse the user's intent: -- `/worktree` or `/worktree list` — list active worktrees -- `/worktree create ` — create a new worktree for a branch -- `/worktree remove ` — remove a worktree -- `/worktree merge ` — merge a worktree's branch and clean up - -If no argument, show the list of active worktrees. - -## Step 2: Execute - -### List -1. Read the worktree registry (`.git/git-pilot-worktrees.json`). -2. Also run `git worktree list` for system-level view. -3. Present: path, branch, base branch, creation date, status. - -### Create -1. Ask for the branch name (or parse from argument). -2. Ask for the base branch (default: `git.defaultBranch`). -3. Determine the worktree path using `worktree.basePath` config. -4. Run `git worktree add -b `. -5. Register in the worktree registry. -6. Confirm with the worktree path. - -### Remove -1. Identify the worktree by path or branch name. -2. Check for uncommitted changes in the worktree. -3. If uncommitted changes, warn and ask to confirm. -4. Run `git worktree remove `. -5. Optionally delete the branch: `git branch -d `. -6. Unregister from the registry. - -### Merge -1. Identify the worktree by path or branch name. -2. Get the worktree's branch and its base branch from the registry. -3. Switch main worktree to the base branch. -4. Attempt `git merge --no-edit`. -5. If conflicts: present them and ask user to resolve. -6. If `worktree.cleanupOnMerge` is `true`: remove the worktree and delete the branch. -7. Confirm the merge. -``` - -**`/rebase`** — Manual rebase operations. - -```markdown ---- -name: rebase -description: Rebase current branch onto another branch ---- - -# /rebase - -Rebase the current branch onto a target branch. - -## Step 1: Determine Target - -If the user specified a target branch (e.g., `/rebase main`), use it. -Otherwise, default to the configured `git.defaultBranch`. - -## Step 2: Pre-flight Checks - -1. Ensure the working tree is clean. If uncommitted changes exist, ask to stash or commit first. -2. Fetch the remote target branch: `git fetch `. -3. Check how many commits will be rebased: `git rev-list --count /..HEAD`. -4. Show the user: "Rebasing N commit(s) onto /." - -## Step 3: Execute Rebase - -1. Run `git rebase /`. -2. If success: "Rebase completed successfully." -3. If conflicts: - - Show conflicting files and conflict details. - - Present resolution options: - a. Resolve manually (edit files, then `git add ` and `git rebase --continue`) - b. Accept theirs for all (`git checkout --theirs . && git add .`) - c. Accept ours for all (`git checkout --ours . && git add .`) - d. Abort (`git rebase --abort`) - - After resolution, run `git rebase --continue`. - -## Step 4: Handle Force Push - -If the branch was previously pushed and rebase rewrote history: -1. Inform: "Rebase rewrote history. Force push is needed to update the remote." -2. Based on `rebase.allowForcePush`: - - `"ask"`: Ask user to confirm force push. - - `"always"`: Push with `--force-with-lease` automatically. - - `"never"`: Inform that force push is disabled. -3. Use `git push --force-with-lease `. -``` - -### 5.3 CLAUDE.md Behavioral Rules - -The complete v2 CLAUDE.md replaces the v1 version. Key additions: - -**Rule 7**: Unrelated work detection (see §4.4 for full text). - -**Rule 8**: Branch switching with stash management. - -```markdown -## Rule 8: Branch switching - -When switching branches (via /branch, user request, or unrelated work detection): - -1. If there are uncommitted changes and `branch.autoStashOnSwitch` is `true`, stash - them automatically before switching. Inform the user: "Stashed changes on ''." -2. After switching, check if there's a git-pilot stash for the target branch and - restore it automatically. -3. If stash restoration fails (conflicts), inform the user and suggest manual resolution. -``` - -**Rule 9**: Conflict resolution guidance. - -```markdown -## Rule 9: Conflict resolution - -When a rebase or merge results in conflicts: - -1. Read the conflicting files to understand the nature of each conflict. -2. For each conflict, provide a recommendation: - - If only one side modified the region, recommend accepting that side. - - If both sides modified the same lines, recommend manual review. - - If a file was deleted on one side, explain the tradeoff. -3. Present clear options: resolve manually, accept ours, accept theirs, abort. -4. After the user resolves conflicts, continue the interrupted operation - (`git rebase --continue` or `git merge --continue`). -``` - -**Rule 10**: Agent awareness. - -```markdown -## Rule 10: Agent Teams - -When operating as a spawned agent (not the orchestrator): - -1. Do not prompt for push, MR creation, or branch switching. These are - orchestrator-only operations. -2. Follow commit rules normally — agents must still use proper commit format. -3. Do not run auto-commit suggestions. Commit when instructed by the orchestrator. -4. If instructed to work in a specific worktree directory, stay in that directory. -``` - -**Updated skill reference table**: - -```markdown -| Skill | When to use | -|-------|-------------| -| `/branch` | Proactively when on the default branch before making changes | -| `/finish` | When the user says they're done, or at session end | -| `/summary` | When the user asks for a recap of branch work | -| `/configure` | When the user wants to change git-pilot settings | -| `/stash` | When the user wants to manage git stashes | -| `/worktree` | When the user wants to manage git worktrees | -| `/rebase` | When the user wants to rebase the current branch | -``` - ---- - -## 6. Error Handling - -### 6.1 Error Types - -| Error Category | Source | Handling | -|---------------|--------|----------| -| Network failure | `git fetch`, `git push` | Retry with backoff (§4.10). Warn user after exhaustion. Continue without blocking. | -| Git operation failure | `git rebase`, `git merge`, `git stash` | Detect error type. Emit specific message. Never leave repo in broken state (abort incomplete operations). | -| Config parse failure | `jq` parsing | Fall back to defaults. Emit: `"[git-pilot] Warning: Could not parse config file. Using defaults."` | -| State file failure | `/tmp` write | Emit warning. Disable state-dependent features for this session. Do not crash. | -| Missing dependency | `git`, `jq` not installed | Emit warning at session start. Disable affected features. | - -### 6.2 Invariant: Never Leave Broken State - -If any git operation is interrupted or fails mid-way: -- An in-progress rebase MUST be aborted: `git rebase --abort`. -- An in-progress merge MUST be aborted: `git merge --abort`. -- A half-created worktree MUST be cleaned up. -- Stash operations that fail MUST NOT lose data (use `stash apply` before `stash drop`). - -Every function that starts a multi-step git operation must have cleanup logic in its error path. - -### 6.3 User-Facing Error Messages - -All error messages follow the pattern: `[git-pilot] : . .` - -Examples: -- `"[git-pilot] Rebase failed: could not apply commit abc1234. Conflicts in 2 file(s). Resolve conflicts or run 'git rebase --abort' to cancel."` -- `"[git-pilot] Worktree creation failed: branch 'feat/auth' already exists. Use a different name or delete the existing branch."` -- `"[git-pilot] Stash restore failed: conflicts in src/main.rs. Run 'git stash pop' manually to resolve."` - ---- - -## 7. Configuration - -### 7.1 Config File Locations - -| Priority | Path | Scope | -|----------|------|-------| -| 1 (lowest) | `${PLUGIN_ROOT}/defaults/config.json` | Plugin defaults | -| 2 | `~/.claude/git-pilot.json` | User global | -| 3 (highest) | `${CWD}/.claude/git-pilot.json` | Project local | - -### 7.2 Config Loading - -The existing `config.sh` `load_config()` function handles the three-tier merge. No changes needed to the merge logic. - -The `normalize_protect_default_branch()` function (§3.1.2) must be called wherever `git.protectDefaultBranch` is read, to handle the boolean → string migration. - -### 7.3 Complete Config Key Reference - -See §3.1.1 for the full defaults file. See §3.1.3 for the new keys reference table. - ---- - -## 8. Testing Strategy - -### 8.1 Unit Testing Approach - -Each bash function should be testable in isolation. Use a test harness that: -1. Creates temporary git repositories with controlled state. -2. Sources the script under test. -3. Calls functions and asserts outputs. - -Test framework: `bats-core` (Bash Automated Testing System). - -### 8.2 Key Test Scenarios - -#### Branch Freshness (§4.1) -- Branch is behind remote → auto fast-forward succeeds. -- Branch is behind remote → fast-forward fails (merge commit needed) → warning emitted. -- Branch has diverged from remote → warning with options emitted. -- Branch is ahead of remote → unpushed info emitted. -- No remote → no freshness check. -- Fetch fails → warning emitted, continues without freshness data. - -#### Base Branch Drift (§4.2) -- Base branch has drifted → rebase attempted. -- Base branch has not drifted → no action. -- No common ancestor → warning emitted, push proceeds. - -#### Rebase and Conflicts (§4.3) -- Clean rebase succeeds → success message. -- Rebase with conflicts → conflict details reported. -- Conflict strategy "abort" → rebase aborted immediately. -- Conflict strategy "merge-fallback" → merge attempted after rebase fails. -- Force push detection after rebase → appropriate prompt based on config. -- Push rejection detected → options presented. - -#### Agent Detection (§4.5) -- `CLAUDE_SPAWNED_BY` set → prompts suppressed. -- State file `isAgent: true` → prompts suppressed. -- Neither set → normal interactive behavior. -- Commit validation still active for agents. - -#### Worktree Management (§4.6) -- Worktree created → registered in registry. -- Worktree removed → unregistered from registry. -- Worktree merge → branch merged, worktree cleaned up. -- Worktree directory already exists → error reported. - -#### Stash Management (§4.7) -- Auto-stash on branch switch → changes stashed, message emitted. -- Auto-restore on return → stash popped, message emitted. -- Restore fails → warning with manual instructions. - -#### Protected Branch (§4.9) -- `"warn"` mode → warning emitted, commit allowed. -- `"block"` mode → commit blocked with exit code 2. -- `"off"` mode → no message. -- Boolean `true` → treated as `"warn"`. -- Boolean `false` → treated as `"off"`. - -#### Config Backward Compatibility -- v1 config with `protectDefaultBranch: true` → normalized to `"warn"`. -- v1 config with `protectDefaultBranch: false` → normalized to `"off"`. -- v2 config with all new keys → works as specified. -- Missing new keys → defaults used. - -### 8.3 Integration Test Scenarios - -1. **Full session lifecycle**: Start session → detect stale branch → fetch → fast-forward → work → commit → detect drift → rebase → push → create MR → summary → cleanup. -2. **Conflict resolution flow**: Start session → work → attempt push → drift detected → rebase fails → conflicts shown → user resolves → rebase continues → push succeeds. -3. **Branch switching flow**: Start on feature branch → user requests unrelated work → prompt shown → user creates new branch → changes auto-stashed → new branch created → work done → switch back → stash restored. -4. **Agent teams flow**: Orchestrator creates worktree → agent works in worktree → agent commits without push prompts → orchestrator merges worktree → cleanup. - ---- - -## 9. Build and Deployment - -### 9.1 Build Commands - -```bash -# Syntax check all bash scripts -for f in plugins/git-pilot/scripts/*.sh; do bash -n "$f"; done - -# Lint with shellcheck -shellcheck plugins/git-pilot/scripts/*.sh - -# Run tests (bats) -bats plugins/git-pilot/tests/ -``` - -### 9.2 Dependencies - -| Dependency | Version | Required | -|-----------|---------|----------| -| `bash` | >= 4.0 | Yes | -| `git` | >= 2.30 (worktree support) | Yes | -| `jq` | >= 1.6 | Yes | -| `gh` | any | Optional (GitHub PR creation) | -| `glab` | any | Optional (GitLab MR creation) | -| `shellcheck` | any | Dev only | -| `bats-core` | >= 1.0 | Dev only | - -### 9.3 File Manifest - -``` -plugins/git-pilot/ -├── .claude-plugin/ -│ └── plugin.json # Version bumped to 2.0.0 -├── CLAUDE.md # Updated behavioral rules (10 rules) -├── README.md # Updated documentation -├── defaults/ -│ └── config.json # Extended with new keys -├── hooks/ -│ └── hooks.json # Updated timeouts + UserPromptSubmit hook -├── scripts/ -│ ├── agent.sh # NEW: Agent Teams detection -│ ├── config.sh # Minor: normalize_protect_default_branch() -│ ├── git-utils.sh # Extended: fetch, tracking, stash, branch utils -│ ├── post-bash.sh # Modified: agent suppression, push rejection -│ ├── post-write.sh # Modified: agent suppression -│ ├── pre-commit.sh # Modified: branch switch detection, block mode -│ ├── prompt-context.sh # NEW: UserPromptSubmit branch context -│ ├── rebase.sh # NEW: Rebase operations and conflict handling -│ ├── session-start.sh # Modified: fetch, freshness, detached HEAD -│ ├── session-stop.sh # Modified: drift detection, worktree cleanup -│ ├── state.sh # Extended: new state fields -│ └── worktree.sh # NEW: Worktree management -├── skills/ -│ ├── branch/SKILL.md # Modified: base branch recording -│ ├── configure/SKILL.md # Modified: new config keys -│ ├── finish/SKILL.md # Modified: drift detection -│ ├── rebase/SKILL.md # NEW: Manual rebase skill -│ ├── stash/SKILL.md # NEW: Stash management skill -│ ├── summary/SKILL.md # Unchanged -│ └── worktree/SKILL.md # NEW: Worktree management skill -└── tests/ # NEW: Test directory - ├── test_helper/ - │ └── common.bash # Shared test fixtures - ├── branch-freshness.bats - ├── drift-detection.bats - ├── rebase.bats - ├── agent-detection.bats - ├── worktree.bats - ├── stash.bats - ├── protected-branch.bats - └── config-compat.bats -``` - -### 9.4 Version Bump - -`plugin.json` version changes from `"1.1.0"` to `"2.0.0"`. This is a major version bump because: -- `git.protectDefaultBranch` type changed from boolean to string (breaking for strict consumers). -- New behavioral rules in CLAUDE.md change default agent behavior. -- New hook (UserPromptSubmit) changes the lifecycle. - ---- - -## 10. Implementation Notes - -### 10.1 Performance Considerations - -- `git fetch` is the most expensive operation (network I/O). The session-start.sh timeout is increased to 30s, but fetch should typically complete in <5s. The retry logic adds up to `fetchRetries * fetchRetryDelaySec` seconds worst-case. -- `prompt-context.sh` runs on every user prompt. It must be fast (<1s). It only runs `git rev-list --count` and `git log -5` which are fast local operations. -- `jq` is called multiple times per hook invocation for config parsing. Each call is fast (<10ms) but adds up. The existing pattern of loading config once and passing it to functions is maintained. - -### 10.2 Security Considerations - -- **Never bare `--force`**: Always use `--force-with-lease` for force pushes to prevent overwriting others' work. -- **No secrets in state files**: State files in `/tmp` are readable by other processes on the system. They contain only branch names, timestamps, and commit hashes — no credentials. -- **Config file permissions**: The plugin does not manage file permissions. Users should ensure `~/.claude/git-pilot.json` has appropriate permissions if it contains sensitive settings (unlikely, but noted). - -### 10.3 Known Complexity Areas - -- **Heredoc commit message parsing** in `pre-commit.sh`: The existing regex-based parser handles most cases but may fail on deeply nested or unusual quoting. v2 does not change this parser. -- **Branch switch detection** in `pre-commit.sh`: Detecting `git checkout ` vs. `git checkout ` is inherently ambiguous. The regex checks for the absence of `-b` flag, but `git checkout` with a path and no `--` separator may be misdetected. This is a known limitation. -- **Stash identification**: Finding a specific stash by message is fragile if the user manually creates stashes with similar messages. The `auto_restore_stash` function searches by the exact message prefix `"git-pilot auto-stash on "`. -- **Agent detection**: The `CLAUDE_SPAWNED_BY` environment variable depends on Claude Code's implementation. If the variable name changes, the detection will need updating. The state-file fallback (`isAgent` flag) provides a manual override. - -### 10.4 Backward Compatibility Guarantees - -1. All v1 config files work unchanged with v2. -2. The default behavior of v2 with a v1 config is identical to v1, except: - - `git fetch` runs on session start (new behavior, can be disabled with `git.autoFetch: false`). - - Branch freshness information is shown (informational only, no action forced). - - The `UserPromptSubmit` hook runs (lightweight, emits context for unrelated work detection). -3. No existing config keys are removed. -4. The `protectDefaultBranch` boolean→string migration is handled transparently. diff --git a/plugins/git-pilot/.claude-plugin/plugin.json b/plugins/git-pilot/.claude-plugin/plugin.json index bf526ae..dc5f864 100644 --- a/plugins/git-pilot/.claude-plugin/plugin.json +++ b/plugins/git-pilot/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "git-pilot", - "version": "2.2.1", + "version": "3.0.0", "description": "Automated git workflow management for Claude Code — branch creation, commit formatting, push/MR workflows, rebase and conflict resolution, worktree management, stash automation, agent teams support, and natural language configuration", "author": { "name": "git-pilot contributors" diff --git a/plugins/git-pilot/CLAUDE.md b/plugins/git-pilot/CLAUDE.md index e6dd549..ef82a15 100644 --- a/plugins/git-pilot/CLAUDE.md +++ b/plugins/git-pilot/CLAUDE.md @@ -1,14 +1,11 @@ -> **Note:** The `git-pilot-workflow` skill contains the definitive behavioral contract for git-pilot. This CLAUDE.md file serves as reinforcement. If both are loaded, the skill takes precedence. - # git-pilot — Git Workflow Autopilot -git-pilot manages the full git workflow lifecycle. You MUST follow these rules throughout every session. - -## Rule 1: Always act on hook messages +## How hooks work -When you receive a system message prefixed with `[git-pilot]` from any hook (SessionStart, PostToolUse, Stop), you MUST act on it using AskUserQuestion BEFORE continuing with other work. Never ignore these messages. Present clear, concise options relevant to the prompt. +Hook messages appear as `[git-pilot]` prefixed context. Act on them according +to the rules below. Hooks provide facts -- you decide the response. -## Rule 2: Branch discipline +## Rule 1: Branch discipline - Never work directly on the default branch. If you're on the default branch when starting work, follow the `/branch` skill workflow to create a feature branch before making any changes. - Name branches using the configured pattern (default: `{{type}}/{{description}}`, kebab-case). @@ -16,7 +13,7 @@ When you receive a system message prefixed with `[git-pilot]` from any hook (Ses - If the user's request clearly implies a branch type and description, you can infer the branch name and propose it. Otherwise, ask. - Check `.claude/git-pilot.json` (local) or `~/.claude/git-pilot.json` (global) for project-specific overrides. -## Rule 3: Commit discipline +## Rule 2: Commit discipline - Follow the configured commit format (default: `{{type}}({{scope}}): {{description}}`). - Do NOT include `Co-Authored-By`, `Generated with`, or any AI attribution lines in commits. @@ -25,21 +22,23 @@ When you receive a system message prefixed with `[git-pilot]` from any hook (Ses - One logical change per commit. When you've completed a coherent unit of work, commit it — don't accumulate unrelated changes. - **Body policy**: Check `commit.body.required` in the effective config (defaults, global, local merged). If `false`, commits MUST be subject-line only — do NOT include a body. The only exception is breaking changes: when the subject contains `!:` and `commit.breakingChange.requireBody` is `true`, a body starting with the configured `bodyPrefix` (default: `BREAKING CHANGE: `) is required. -## Rule 4: Push workflow +## Rule 3: Push workflow After every successful `git commit`, check for unpushed commits. If there are any and a remote exists: - Use AskUserQuestion to prompt the user with two options: **"Push to /"** and **"Skip"**. - If they choose to push, run: `git push -u `. - Do NOT silently skip this step or just mention it in passing. The user expects an interactive prompt. -## Rule 5: Session end +When you see '[git-pilot] Push rejected' in hook context, present resolution options: pull & rebase, force-with-lease, pull & merge, cancel. + +## Rule 4: Session end When finishing a session, follow the `/finish` skill workflow: 1. Commit any remaining uncommitted changes. -2. If unpushed commits exist, prompt to push (same as Rule 4). +2. If unpushed commits exist, prompt to push (same as Rule 3). 3. If merge request creation is enabled, offer to create one using the appropriate platform CLI (`gh` for GitHub, `glab` for GitLab) or skip silently if the CLI isn't available. -## Rule 6: Configuration +## Rule 5: Configuration - Users can change git-pilot settings by asking in natural language (via `/configure` skill). - Global config: `~/.claude/git-pilot.json` @@ -47,7 +46,7 @@ When finishing a session, follow the `/finish` skill workflow: - Local settings override global settings which override plugin defaults. - When changing config, ask whether the change should be global or project-specific. -## Rule 7: Unrelated work detection +## Rule 6: Unrelated work detection Before starting work on a new user request, assess whether the request is related to the current branch's purpose: @@ -69,9 +68,11 @@ to the current branch's purpose: a bug discovered while implementing a feature on the same branch), for work on the default branch, or for branches with no commits yet. 5. **If the user chooses to create a new branch**: Follow the branch switch - workflow (see Rule 8). + workflow (see Rule 7). + +When you see '[git-pilot] Branch (, N commits)' in hook context, assess whether the user's request is related. If unrelated, use AskUserQuestion with options. -## Rule 8: Branch switching +## Rule 7: Branch switching When switching branches (via /branch, user request, or unrelated work detection): @@ -81,7 +82,7 @@ When switching branches (via /branch, user request, or unrelated work detection) restore it automatically. 3. If stash restoration fails (conflicts), inform the user and suggest manual resolution. -## Rule 9: Conflict resolution +## Rule 8: Conflict resolution When a rebase or merge results in conflicts: @@ -94,7 +95,9 @@ When a rebase or merge results in conflicts: 4. After the user resolves conflicts, continue the interrupted operation (`git rebase --continue` or `git merge --continue`). -## Rule 10: Agent Teams +When you see '[git-pilot] Push rejected' or '[git-pilot] Auto-rebase aborted (conflicts)', present resolution options to the user. + +## Rule 9: Agent Teams When operating as a spawned agent (not the orchestrator): @@ -104,6 +107,21 @@ When operating as a spawned agent (not the orchestrator): 3. Do not run auto-commit suggestions. Commit when instructed by the orchestrator. 4. If instructed to work in a specific worktree directory, stay in that directory. +## Rule 10: Hook context responses + +When you see hook context messages, respond according to this map: + +| Hook Output Pattern | Claude Action | +|---------------------|---------------| +| `[git-pilot] Detached HEAD at {sha}` | AskUserQuestion: return to branch, create new, continue | +| `[git-pilot] On default branch` | Create feature branch before changes | +| `[git-pilot] Branch diverged` | AskUserQuestion: rebase, merge, reset, continue | +| `[git-pilot] N unpushed commit(s)` (after commit) | AskUserQuestion: push or skip | +| `[git-pilot] Push rejected` | AskUserQuestion: pull & rebase, force-with-lease, merge, cancel | +| `[git-pilot] N file changes since last commit` | AskUserQuestion: commit now or continue | +| `[git-pilot] No git remote configured` | AskUserQuestion: add remote or skip | +| `[git-pilot] Branch behind` (when ff failed) | AskUserQuestion: pull merge, reset, continue | + ## Skill reference | Skill | When to use | diff --git a/plugins/git-pilot/scripts/post-bash.sh b/plugins/git-pilot/scripts/post-bash.sh index dfbd818..2833e5e 100755 --- a/plugins/git-pilot/scripts/post-bash.sh +++ b/plugins/git-pilot/scripts/post-bash.sh @@ -56,8 +56,6 @@ if echo "$command" | grep -qE 'git\s+commit'; then # Only prompt when mode is "ask" — "always" and "never" are handled elsewhere if [[ "$push_on_finish" == "ask" ]] && has_remote; then current_branch=$(get_current_branch) - remote_name=$(get_config "$config" '.remote.defaultName' 'origin') - # Check for unpushed commits unpushed=$(git log '@{u}..HEAD' --oneline 2>/dev/null || true) if ! git rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then @@ -68,10 +66,9 @@ if echo "$command" | grep -qE 'git\s+commit'; then if [[ -n "$unpushed" ]]; then unpushed_count=$(echo "$unpushed" | wc -l | tr -d ' ') - # Emit push prompt — CLAUDE.md instructs Claude to act on this with AskUserQuestion - message="[git-pilot] ${unpushed_count} unpushed commit(s) on '${current_branch}'. You MUST use AskUserQuestion NOW to ask the user whether to push. Push command: git push -u ${remote_name} ${current_branch}. Do not silently skip this prompt." + message="[git-pilot] ${unpushed_count} unpushed commit(s) on '${current_branch}'" - jq -n --arg msg "$message" '{"continue": true, "systemMessage": $msg}' + jq -n --arg msg "$message" '{"continue": true, "additionalContext": $msg}' exit 0 fi fi @@ -87,14 +84,8 @@ if echo "$command" | grep -qE 'git\s+push'; then if [[ "$exit_code" != "0" ]]; then if echo "$stderr" | grep -qiE 'rejected|failed to push|non-fast-forward'; then current_branch=$(get_current_branch) - remote_name=$(get_config "$config" '.remote.defaultName' 'origin') - message="[git-pilot] Push rejected — remote '${remote_name}/${current_branch}' has new commits. STOP and use AskUserQuestion NOW to present these options: -1. Pull and rebase, then retry push (git pull --rebase && git push) -2. Force push with lease (git push --force-with-lease) -3. Pull and merge (git pull) -4. Cancel -Do not attempt any resolution without the user's choice." - jq -n --arg msg "$message" '{"continue": true, "systemMessage": $msg}' + message="[git-pilot] Push rejected — remote has new commits on '${current_branch}'" + jq -n --arg msg "$message" '{"continue": true, "additionalContext": $msg}' exit 0 fi fi diff --git a/plugins/git-pilot/scripts/post-write.sh b/plugins/git-pilot/scripts/post-write.sh index 1004dd7..c87aad5 100755 --- a/plugins/git-pilot/scripts/post-write.sh +++ b/plugins/git-pilot/scripts/post-write.sh @@ -32,8 +32,6 @@ fi threshold=$(get_config "$config" '.autoCommit.threshold' '3') mode=$(get_config "$config" '.autoCommit.mode' 'suggest') wip_prefix=$(get_config "$config" '.autoCommit.wipPrefix' 'wip: ') -commit_pattern=$(get_config "$config" '.commit.pattern' '{{type}}({{scope}}): {{description}}') - # Compute state file path state_file=$(get_state_file "$session_id") @@ -73,10 +71,10 @@ write_state "$state_file" "$reset_state" # Act based on mode case "$mode" in suggest) - message="[git-pilot] You have made ${threshold} file changes since the last commit (${modified_files}). You MUST commit your progress now for easier rollback. Use AskUserQuestion to confirm with the user, then commit with a descriptive message following the configured commit format." + message="[git-pilot] ${threshold} file changes since last commit (${modified_files})" ;; auto) - message="[git-pilot] Auto-commit threshold reached (${threshold} file changes). You MUST commit your current changes NOW with a descriptive message following the commit format: ${commit_pattern}. Do not continue writing code until this commit is made." + message="[git-pilot] Auto-commit threshold reached (${threshold} changes) — commit now" ;; silent) commit_msg="${wip_prefix}checkpoint after ${threshold} file changes" @@ -90,9 +88,9 @@ case "$mode" in ;; *) # Unknown mode, fall back to suggest - message="[git-pilot] You have made ${threshold} file changes since the last commit (${modified_files}). You MUST commit your progress now for easier rollback. Use AskUserQuestion to confirm with the user, then commit with a descriptive message following the configured commit format." + message="[git-pilot] ${threshold} file changes since last commit (${modified_files})" ;; esac # Output the result -jq -n --arg msg "$message" '{continue: true, systemMessage: $msg}' +jq -n --arg msg "$message" '{"continue": true, "additionalContext": $msg}' diff --git a/plugins/git-pilot/scripts/prompt-context.sh b/plugins/git-pilot/scripts/prompt-context.sh index c1f0bd8..06ef959 100755 --- a/plugins/git-pilot/scripts/prompt-context.sh +++ b/plugins/git-pilot/scripts/prompt-context.sh @@ -37,6 +37,5 @@ commit_count=$(git rev-list --count "${default_branch}..${current_branch}" 2>/de if [[ "$commit_count" == "0" ]]; then echo '{"continue": true}'; exit 0; fi branch_purpose=$(derive_branch_purpose "$current_branch") -recent_commits=$(git log "${default_branch}..${current_branch}" --oneline --no-decorate -5 2>/dev/null || true) -message="[git-pilot] You are on branch '${current_branch}' (purpose: ${branch_purpose}, ${commit_count} commit(s)). Before acting on the user's next request, assess whether the request is related to this branch's purpose. If unrelated, STOP and use AskUserQuestion to suggest creating a new branch. Recent commits: ${recent_commits}" -jq -n --arg msg "$message" '{"continue": true, "systemMessage": $msg}' +message="[git-pilot] Branch '${current_branch}' (${branch_purpose}, ${commit_count} commits)" +jq -n --arg msg "$message" '{"continue": true, "additionalContext": $msg}' diff --git a/plugins/git-pilot/scripts/session-start.sh b/plugins/git-pilot/scripts/session-start.sh index 96a0c08..42c36df 100755 --- a/plugins/git-pilot/scripts/session-start.sh +++ b/plugins/git-pilot/scripts/session-start.sh @@ -26,11 +26,11 @@ messages=() # Step 5: Check dependencies if ! command -v git >/dev/null 2>&1; then - messages+=("[git-pilot] Warning: git is not installed. Git workflow features are disabled.") + messages+=("[git-pilot] git not installed -- workflow features disabled") fi if ! command -v jq >/dev/null 2>&1; then - messages+=("[git-pilot] Warning: jq is not installed. Configuration loading may not work correctly.") + messages+=("[git-pilot] jq not installed -- config loading may fail") fi # Step 6: Git init check @@ -43,12 +43,12 @@ if ! is_git_repo; then if [[ ! -f ".gitignore" ]]; then touch .gitignore fi - messages+=("[git-pilot] Initialized git repository with default branch '${default_branch}'.") + messages+=("[git-pilot] Initialized git repo (branch: ${default_branch})") else - messages+=("[git-pilot] Warning: Failed to initialize git repository.") + messages+=("[git-pilot] git init failed") fi else - messages+=("[git-pilot] Warning: Current directory is not a git repository. Run 'git init' to initialize one.") + messages+=("[git-pilot] Not a git repository") fi fi @@ -57,9 +57,8 @@ if is_git_repo && has_remote; then auto_fetch=$(get_config "$CONFIG" '.git.autoFetch' 'true') remote_name=$(get_config "$CONFIG" '.remote.defaultName' 'origin') if [[ "$auto_fetch" == "true" ]]; then - retries=$(get_config "$CONFIG" '.git.fetchRetries' '2') if ! fetch_with_retry "$remote_name" "$CONFIG"; then - messages+=("[git-pilot] Warning: Could not fetch from '${remote_name}' after ${retries} attempts. Network may be unavailable. Proceeding without remote sync.") + messages+=("[git-pilot] Remote fetch failed -- proceeding offline") fi fi fi @@ -78,9 +77,9 @@ if is_git_repo; then | grep -m1 'checkout: moving from' \ | sed 's/checkout: moving from \([^ ]*\) to .*/\1/' || true) if [[ -n "$prev_branch" ]]; then - messages+=("[git-pilot] Detached HEAD at ${head_sha}. You MUST NOT make any code changes in detached HEAD state. Use AskUserQuestion NOW to ask the user to choose: (1) return to '${prev_branch}', (2) create a new branch from HEAD, or (3) continue in detached state. Do not proceed until the user responds.") + messages+=("[git-pilot] Detached HEAD at ${head_sha} (previous branch: ${prev_branch})") else - messages+=("[git-pilot] Detached HEAD at ${head_sha}. You MUST NOT make any code changes in detached HEAD state. Use AskUserQuestion NOW to ask the user to choose: (1) create a new branch from HEAD, or (2) continue in detached state. Do not proceed until the user responds.") + messages+=("[git-pilot] Detached HEAD at ${head_sha}") fi fi @@ -92,47 +91,35 @@ if is_git_repo; then behind:*) behind_count="${tracking_status#behind:}" if git merge --ff-only "${remote_name}/${current_branch}" >/dev/null 2>&1; then - messages+=("[git-pilot] Branch '${current_branch}' was ${behind_count} commit(s) behind '${remote_name}/${current_branch}'. Fast-forwarded to latest.") + messages+=("[git-pilot] Branch '${current_branch}' fast-forwarded ${behind_count} commit(s)") else - messages+=("[git-pilot] Branch '${current_branch}' is ${behind_count} commit(s) behind '${remote_name}/${current_branch}' and fast-forward failed. You MUST use AskUserQuestion NOW to ask the user to choose: (1) pull with merge, (2) reset to remote, or (3) continue as-is. Do not proceed with any code changes until this is resolved.") + messages+=("[git-pilot] Branch '${current_branch}' is ${behind_count} commit(s) behind remote") fi ;; diverged:*:*) IFS=':' read -r _ ahead_count behind_count <<< "$tracking_status" - messages+=("[git-pilot] Branch '${current_branch}' has diverged from '${remote_name}/${current_branch}' (${ahead_count} local, ${behind_count} remote). You MUST use AskUserQuestion NOW to ask the user to choose: (1) rebase onto remote, (2) merge remote changes, (3) reset to remote, or (4) continue as-is. Do not make any code changes until this is resolved.") + messages+=("[git-pilot] Branch '${current_branch}' diverged: ${ahead_count} local, ${behind_count} remote") ;; ahead:*) ahead_count="${tracking_status#ahead:}" - messages+=("[git-pilot] Branch '${current_branch}' has ${ahead_count} unpushed commit(s). Before continuing, use AskUserQuestion to ask the user whether to push now or continue working.") + messages+=("[git-pilot] ${ahead_count} unpushed commit(s) on '${current_branch}'") ;; # up-to-date and no-remote: no message esac fi - # Step 7c: Branch creation prompt (existing v1 logic) + # Step 7c: Default branch detection auto_create=$(get_config "$CONFIG" '.branch.autoCreate' 'true') if [[ "$auto_create" == "true" ]] && [[ "$current_branch" == "$default_branch" ]]; then - branch_pattern=$(get_config "$CONFIG" '.branch.pattern' '{{type}}/{{description}}') - branch_types=$(echo "$CONFIG" | jq -r '.branch.types // ["feat","fix","refactor","docs","test","chore","style","perf","build","ci"] | join(", ")') - - if has_uncommitted_changes; then - messages+=("[git-pilot] You are on the default branch '${default_branch}' with uncommitted changes. You MUST NOT make any further code changes until this is resolved. Use AskUserQuestion NOW to ask the user to choose: (1) stash changes and create a feature branch, (2) commit current changes first, or (3) continue on '${default_branch}'. Do not proceed until the user responds.") - else - messages+=("[git-pilot] You are on the default branch '${default_branch}'. You MUST NOT make any code changes until a feature branch is created. Use AskUserQuestion to ask the user for the branch type and description NOW, before doing anything else. Pattern: ${branch_pattern}, types: ${branch_types}.") - fi + messages+=("[git-pilot] On default branch '${default_branch}'") fi fi # Step 8: Remote detection (only if we are in a git repo) if is_git_repo; then if ! has_remote; then - prompt_remote=$(get_config "$CONFIG" '.remote.promptForRemote' 'true') - skip_remote=$(get_config "$CONFIG" '.remote.skipRemotePrompt' 'false') - - if [[ "$prompt_remote" == "true" ]] && [[ "$skip_remote" == "false" ]]; then - messages+=("[git-pilot] No git remote configured. Use AskUserQuestion to ask the user whether to add a remote now (provide the command format: git remote add origin ) or skip. This affects push and sync capabilities for this session.") - fi + messages+=("[git-pilot] No git remote configured") fi fi @@ -152,17 +139,17 @@ if [[ -n "$SESSION_ID" ]]; then fi # Step 10: Build and output final JSON -system_message="" +context_lines="" for msg in "${messages[@]+${messages[@]}}"; do - if [[ -n "$system_message" ]]; then - system_message="${system_message}"$'\n'"${msg}" + if [[ -n "$context_lines" ]]; then + context_lines="${context_lines}"$'\n'"${msg}" else - system_message="$msg" + context_lines="$msg" fi done -if [[ -n "$system_message" ]]; then - jq -n --arg msg "$system_message" '{"continue": true, "systemMessage": $msg}' +if [[ -n "$context_lines" ]]; then + jq -n --arg ctx "$context_lines" '{"continue": true, "additionalContext": $ctx}' else jq -n '{"continue": true}' fi diff --git a/plugins/git-pilot/scripts/session-stop.sh b/plugins/git-pilot/scripts/session-stop.sh index 4c2cec0..fe37195 100755 --- a/plugins/git-pilot/scripts/session-stop.sh +++ b/plugins/git-pilot/scripts/session-stop.sh @@ -124,30 +124,16 @@ fi # Suppressed for agents (handled by early exit above). # --------------------------------------------------------------------------- if [[ "$session_had_changes" == "true" ]]; then - # Show only session commits when possible, fall back to full branch diff if [[ -n "$head_at_start" ]]; then - commit_log=$(git log "${head_at_start}..HEAD" --oneline --no-decorate 2>/dev/null || true) - files_changed_count=$(git diff --stat "${head_at_start}...HEAD" 2>/dev/null | tail -1 | grep -oP '^\s*\K\d+(?= files? changed)' || echo "0") + commit_count=$(git rev-list --count "${head_at_start}..HEAD" 2>/dev/null || echo "0") + files_changed_count=$(git diff --name-only "${head_at_start}...HEAD" 2>/dev/null | wc -l | tr -d ' ') else - commit_log=$(git log "${default_branch}..${current_branch}" --oneline --no-decorate 2>/dev/null || true) - files_changed_count=$(git diff --stat "${default_branch}...HEAD" 2>/dev/null | tail -1 | grep -oP '^\s*\K\d+(?= files? changed)' || echo "0") + commit_count=$(git rev-list --count "${default_branch}..${current_branch}" 2>/dev/null || echo "0") + files_changed_count=$(git diff --name-only "${default_branch}...HEAD" 2>/dev/null | wc -l | tr -d ' ') fi - if [[ -n "$commit_log" ]]; then - commit_count=$(echo "$commit_log" | wc -l | tr -d ' ') - - commit_list="" - while IFS= read -r line; do - commit_list+="- ${line}"$'\n' - done <<< "$commit_log" - commit_list="${commit_list%$'\n'}" - - summary="[git-pilot] Session Summary: ${current_branch}" - summary+=$'\n\n'"Commits (${commit_count}):" - summary+=$'\n'"${commit_list}" - summary+=$'\n'"Files changed: ${files_changed_count}" - - messages+=("$summary") + if [[ "$commit_count" -gt 0 ]]; then + messages+=("[git-pilot] Session: ${commit_count} commit(s), ${files_changed_count} file(s) changed on '${current_branch}'") fi fi @@ -164,45 +150,18 @@ if [[ "$session_had_changes" == "true" ]] && has_remote; then case "$drift_status" in drifted:*) drift_count="${drift_status#drifted:}" - messages+=("[git-pilot] Base branch '${default_branch}' has ${drift_count} new commit(s) since you branched. Rebasing '${current_branch}' onto '${remote_name}/${default_branch}' now. If conflicts arise, you MUST use AskUserQuestion to present resolution options before proceeding.") + messages+=("[git-pilot] '${default_branch}' has ${drift_count} new commit(s) -- consider rebasing before push") rebase_result=$(attempt_rebase "${remote_name}/${default_branch}" || true) case "$rebase_result" in success) - messages+=("[git-pilot] Rebase succeeded cleanly. Ready to push.") + messages+=("[git-pilot] Auto-rebase onto '${default_branch}' succeeded") ;; conflict) - conflict_strategy=$(get_config "$config" '.rebase.conflictStrategy' 'prompt') - case "$conflict_strategy" in - prompt) - conflicts=$(get_conflict_details) - conflict_count=$(echo "$conflicts" | jq 'length') - conflict_files=$(echo "$conflicts" | jq -r '.[].file' | paste -sd', ') - messages+=("[git-pilot] Rebase conflicts in ${conflict_count} file(s): ${conflict_files}. STOP and use AskUserQuestion NOW to ask the user to choose: (1) resolve conflicts manually, (2) abort rebase, or (3) fall back to merge. Do not proceed until the user responds.") - ;; - abort) - git rebase --abort 2>/dev/null || true - messages+=("[git-pilot] Rebase aborted due to conflicts. Pushing without rebase.") - ;; - merge-fallback) - git rebase --abort 2>/dev/null || true - if git merge "${remote_name}/${default_branch}" --no-edit 2>/dev/null; then - messages+=("[git-pilot] Merge with '${default_branch}' succeeded (rebase had conflicts).") - else - fallback_conflicts=$(get_conflict_details) - fallback_conflict_count=$(echo "$fallback_conflicts" | jq 'length') - messages+=("[git-pilot] Both rebase and merge have conflicts in ${fallback_conflict_count} file(s). STOP and use AskUserQuestion NOW to present the conflict details and ask the user how to proceed. Do not push or continue until conflicts are resolved.") - fi - ;; - esac + git rebase --abort 2>/dev/null || true + messages+=("[git-pilot] Auto-rebase aborted (conflicts) -- manual rebase needed") ;; esac ;; - no-drift) - # No action needed - ;; - no-common-ancestor) - messages+=("[git-pilot] Cannot determine common ancestor between '${current_branch}' and '${default_branch}'. Skipping rebase. Before pushing, use AskUserQuestion to warn the user that the push may require manual review.") - ;; esac fi fi @@ -228,11 +187,11 @@ if [[ "$session_had_changes" == "true" ]] && has_remote; then case "$push_on_finish" in always) - messages+=("[git-pilot] You MUST push ${unpushed_count} commit(s) to '${remote_name}/${current_branch}' now. Run: git push -u ${remote_name} ${current_branch}. Execute this command immediately.") + messages+=("[git-pilot] Auto-push: run 'git push -u ${remote_name} ${current_branch}'") ;; *) - # "ask" and "never" are silent in the stop hook. - # Use /finish skill for interactive push/MR workflows. + # Only show unpushed count when auto-push is not emitted (auto-push implies unpushed) + messages+=("[git-pilot] ${unpushed_count} unpushed commit(s) remaining") ;; esac fi @@ -272,82 +231,10 @@ if [[ "$session_had_changes" == "true" ]] && has_remote; then esac if [[ -n "$cli_tool" ]] && command -v "$cli_tool" >/dev/null 2>&1; then - title_from_branch=$(get_config "$config" '.mergeRequest.titleFromBranch' 'true') - mr_title="" - if [[ "$title_from_branch" == "true" ]]; then - branch_pattern=$(get_config "$config" '.branch.pattern' '{{type}}/{{description}}') - branch_type=$(echo "$current_branch" | cut -d'/' -f1) - branch_rest=$(echo "$current_branch" | cut -d'/' -f2-) - - if [[ "$branch_pattern" == *"{{scope}}"* ]]; then - branch_scope=$(echo "$branch_rest" | cut -d'/' -f1) - branch_desc=$(echo "$branch_rest" | cut -d'/' -f2-) - branch_desc=$(echo "$branch_desc" | tr '-' ' ' | tr '_' ' ') - mr_title="${branch_type}(${branch_scope}): ${branch_desc}" - else - branch_desc=$(echo "$branch_rest" | tr '-' ' ' | tr '_' ' ') - mr_title="${branch_type}: ${branch_desc}" - fi - else - mr_title=$(git log -1 --format=%s 2>/dev/null || echo "$current_branch") - fi - - body_template=$(echo "$config" | jq -r '.mergeRequest.bodyTemplate // empty') - mr_body="" - if [[ -n "$body_template" ]]; then - commits_text=$(git log "${default_branch}..${current_branch}" --oneline --no-decorate 2>/dev/null || true) - files_text=$(git diff --stat "${default_branch}...HEAD" 2>/dev/null || true) - summary_text="" - while IFS= read -r line; do - msg="${line#* }" - summary_text+="- ${msg}"$'\n' - done <<< "$commits_text" - summary_text="${summary_text%$'\n'}" - - mr_body="$body_template" - mr_body="${mr_body//\{\{summary\}\}/$summary_text}" - mr_body="${mr_body//\{\{commits\}\}/$commits_text}" - mr_body="${mr_body//\{\{files\}\}/$files_text}" - else - commits_text=$(git log "${default_branch}..${current_branch}" --oneline --no-decorate 2>/dev/null || true) - files_text=$(git diff --stat "${default_branch}...HEAD" 2>/dev/null || true) - summary_text="" - while IFS= read -r line; do - msg="${line#* }" - summary_text+="- ${msg}"$'\n' - done <<< "$commits_text" - summary_text="${summary_text%$'\n'}" - - mr_body="## Summary"$'\n'"${summary_text}"$'\n\n'"## Commits"$'\n'"${commits_text}"$'\n\n'"## Files Changed"$'\n'"${files_text}" - fi - - mr_flags="" - mr_draft=$(get_config "$config" '.mergeRequest.draft' 'false') - if [[ "$mr_draft" == "true" ]]; then - mr_flags+=" --draft" - fi - - mr_labels=$(echo "$config" | jq -r '.mergeRequest.labels // [] | .[]' 2>/dev/null) - while IFS= read -r label; do - if [[ -n "$label" ]]; then - mr_flags+=" --label \"${label}\"" - fi - done <<< "$mr_labels" - - mr_assign=$(get_config "$config" '.mergeRequest.assignToSelf' 'true') - if [[ "$mr_assign" == "true" ]]; then - mr_flags+=" --assignee @me" - fi - - mr_cmd="" if [[ "$platform" == "github" ]]; then - mr_cmd="gh pr create --title \"${mr_title}\" --body \"${mr_body}\" --base ${default_branch}${mr_flags}" + messages+=("[git-pilot] Auto-MR: run 'gh pr create --base ${default_branch}'") elif [[ "$platform" == "gitlab" ]]; then - mr_cmd="glab mr create --title \"${mr_title}\" --description \"${mr_body}\" --target-branch ${default_branch}${mr_flags}" - fi - - if [[ -n "$mr_cmd" ]]; then - messages+=("[git-pilot] You MUST create a merge/pull request now. Run: ${mr_cmd}. Execute this command immediately.") + messages+=("[git-pilot] Auto-MR: run 'glab mr create --target-branch ${default_branch}'") fi fi # If CLI tool is missing, stay silent — user can use /finish or create MR manually @@ -379,7 +266,7 @@ if [[ "$active_count" -gt 0 ]]; then fi remaining=$(list_worktrees | jq '.worktrees | length') if [[ "$remaining" -gt 0 ]]; then - messages+=("[git-pilot] ${remaining} active worktree(s) remain after session cleanup. Before ending, use AskUserQuestion to ask the user whether to clean up remaining worktrees using the /worktree skill.") + messages+=("[git-pilot] ${remaining} active worktree(s) remaining") fi fi @@ -391,6 +278,9 @@ if [[ -n "$session_id" ]]; then cleanup_state "$state_file" fi +# Cap at 5 lines per spec +messages=("${messages[@]:0:5}") + # --------------------------------------------------------------------------- # 5. Output final JSON # --------------------------------------------------------------------------- @@ -400,12 +290,12 @@ else full_message="" for i in "${!messages[@]}"; do if [[ $i -gt 0 ]]; then - full_message+=$'\n\n' + full_message+=$'\n' fi full_message+="${messages[$i]}" done - jq -n --arg msg "$full_message" '{"continue": true, "systemMessage": $msg}' + jq -n --arg ctx "$full_message" '{"continue": true, "additionalContext": $ctx}' fi exit 0 diff --git a/plugins/git-pilot/skills/git-pilot-workflow/SKILL.md b/plugins/git-pilot/skills/git-pilot-workflow/SKILL.md index c17aa3a..a185f5f 100644 --- a/plugins/git-pilot/skills/git-pilot-workflow/SKILL.md +++ b/plugins/git-pilot/skills/git-pilot-workflow/SKILL.md @@ -5,160 +5,16 @@ description: "USE THIS SKILL WHEN the user asks to write code, fix a bug, implem # git-pilot Workflow -This skill is the definitive behavioral contract for git-pilot. It governs the -full git workflow lifecycle: branching, committing, pushing, session end, and -hook compliance. Follow these rules throughout every session where file changes -occur in a git repository. +This skill activates the git-pilot behavioral contract defined in CLAUDE.md. +All rules are in CLAUDE.md -- this skill serves as a trigger to ensure the +contract is loaded. ---- - -## 1. Branch Discipline - -**Never work directly on the default branch.** Before making any changes, ensure -you are on a feature branch. If you are on the default branch, create a new -branch first. - -### Branch Naming Convention - -Use the configured pattern (default: `{{type}}/{{description}}`). - -- Format the description in kebab-case (e.g., `feat/add-user-auth`). -- Available types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `style`, - `perf`, `build`, `ci`. -- If the user's request clearly implies a type and description, infer the branch - name and propose it. Otherwise, ask the user. - -### Configuration - -Check `.claude/git-pilot.json` (local) or `~/.claude/git-pilot.json` (global) -for project-specific overrides to the branch pattern, types, separator, case, -and max length. - -> For the full branch creation procedure, see the `/branch` skill. - ---- - -## 2. Commit Discipline - -Follow the configured commit message format (default: -`{{type}}({{scope}}): {{description}}`). - -### Rules - -- **No AI attribution.** Do NOT include `Co-Authored-By`, `Generated with`, or - any AI attribution lines in commit messages. -- **Subject-only by default.** Check `commit.body.required` in the effective - config. If `false`, commits MUST be subject-line only -- no body. - - **Exception -- breaking changes:** When the subject contains `!:` and - `commit.breakingChange.requireBody` is `true`, include a body starting with - the configured `bodyPrefix` (default: `BREAKING CHANGE: `). -- **Max length.** Keep subjects under the configured max length (default: 72). -- **Imperative mood.** Use "add" not "added", "fix" not "fixed". -- **One logical change per commit.** When you complete a coherent unit of work, - commit it. Do not accumulate unrelated changes. - ---- - -## 3. Push Workflow - -After every successful `git commit`, check for unpushed commits. If there are -unpushed commits and a remote exists: - -1. Use **AskUserQuestion** to prompt the user with two options: - - **"Push to ``/``"** - - **"Skip"** -2. If the user chooses to push, run: `git push -u `. - -**Do NOT silently skip this step** or just mention it in passing. The user -expects an interactive prompt after each commit. - ---- - -## 4. Session End - -When the user indicates work is done, or the session is ending, follow the -`/finish` skill workflow: - -1. Commit any remaining uncommitted changes (following the commit rules above). -2. If unpushed commits exist, prompt to push (same as Section 3). -3. If merge request creation is enabled in config, offer to create one using the - appropriate platform CLI (`gh` for GitHub, `glab` for GitLab). Skip silently - if the CLI is not available. - -> For the full session-end procedure, see the `/finish` skill. - ---- - -## 5. Hook Compliance - -When you receive a system message prefixed with `[git-pilot]` from any hook -(SessionStart, PostToolUse, Stop): - -- **Act on it immediately** using AskUserQuestion BEFORE continuing with other - work. -- **Never ignore** hook messages. Present clear, concise options relevant to the - prompt. - -These messages are generated by git-pilot hooks and require your attention -before proceeding with any other task. - ---- - -## 6. Unrelated Work Detection - -Before starting work on a new user request, assess whether the request belongs -on the current branch: - -1. **Parse the branch name** for semantic meaning. For example, - `feat/add-dark-mode` implies dark mode work; `fix/login-timeout` implies a - login timeout fix. -2. **Review recent commits** on this branch for scope context. -3. **If the request is clearly unrelated** to the branch's purpose (different - feature, different bug, different module), prompt the user with options: - - Create a new branch from `` (recommended -- keeps branches - focused) - - Create a new branch from the current branch (if the new work depends on - current changes) - - Continue on this branch - -### Branch Switching on New Branch - -If the user chooses to create a new branch (option 1 or 2 above), follow the -branch switch workflow: - -- If there are uncommitted changes and `branch.autoStashOnSwitch` is `true` in - the effective config, stash them automatically before switching. Inform the - user: "Stashed changes on ''." -- After switching to the target branch, check if there is a git-pilot stash for - that branch and restore it automatically. -- If stash restoration fails due to conflicts, inform the user and suggest - manual resolution. - -### When NOT to Prompt - -Do NOT prompt for unrelated work detection when: - -- The work is closely related (e.g., fixing a bug discovered while implementing - a feature on the same branch). -- You are on the default branch. -- The current branch has no commits yet. - ---- - -## 7. Cross-References - -This skill provides the high-level behavioral contract. For detailed procedures, -consult the following sub-skills: +## Quick Reference -| Skill | Purpose | -|-------|---------| -| `/branch` | Branch creation and switching | -| `/finish` | Session end workflow (commit, push, MR) | -| `/summary` | Branch work recap | -| `/configure` | Settings management | -| `/stash` | Stash management | -| `/worktree` | Worktree management | -| `/rebase` | Rebasing | +1. Never work on default branch -- use /branch first +2. Follow commit format -- type(scope): description +3. After each commit -- prompt to push +4. On session end -- use /finish +5. Act on [git-pilot] hook context per CLAUDE.md Rule 10 -Do NOT duplicate the full content of these sub-skills. Use them for the detailed -step-by-step procedures; this skill defines when and why to invoke them. +For detailed rules, see CLAUDE.md. diff --git a/plugins/git-pilot/tests/post-bash.bats b/plugins/git-pilot/tests/post-bash.bats new file mode 100644 index 0000000..c82b37a --- /dev/null +++ b/plugins/git-pilot/tests/post-bash.bats @@ -0,0 +1,210 @@ +#!/usr/bin/env bats + +# Tests for post-bash.sh: push prompt after commit, push rejection detection, +# agent suppression, error resilience, and anti-pattern compliance. + +load test_helper/common + +HOOK_SCRIPT="$PLUGIN_DIR/scripts/post-bash.sh" + +setup() { + setup_test_repo + setup_remote_repo + + # Create a feature branch with an unpushed commit + cd "$TEST_REPO" + git checkout -b feat/test-feature >/dev/null 2>&1 + echo "feature" > feature.txt + git add feature.txt + git commit -m "feat: add feature" >/dev/null 2>&1 + + unset CLAUDE_SPAWNED_BY +} + +teardown() { + unset CLAUDE_SPAWNED_BY + rm -f /tmp/git-pilot-test-post-bash-*.json + teardown_test_repo +} + +# Helper: run the hook script with JSON input +run_hook() { + local json="$1" + run bash -c "echo '$json' | bash '$HOOK_SCRIPT'" +} + +# ---------- Push prompt after git commit ---------- + +@test "post-bash: unpushed commits after git commit uses additionalContext" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-1","tool_input":{"command":"git commit -m \"feat: something\""}}' + + [ "$status" -eq 0 ] + echo "$output" | jq -e '.additionalContext' >/dev/null + echo "$output" | jq -e '.continue == true' >/dev/null +} + +@test "post-bash: unpushed commits message matches expected format" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-2","tool_input":{"command":"git commit -m \"feat: something\""}}' + + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext') + [[ "$ctx" == *"[git-pilot]"* ]] + [[ "$ctx" == *"unpushed commit(s) on 'feat/test-feature'"* ]] +} + +@test "post-bash: unpushed commits output does not use systemMessage" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-3","tool_input":{"command":"git commit -m \"feat: something\""}}' + + [ "$status" -eq 0 ] + local has_sys + has_sys=$(echo "$output" | jq 'has("systemMessage")') + [ "$has_sys" = "false" ] +} + +@test "post-bash: output is valid JSON" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-4","tool_input":{"command":"git commit -m \"feat: something\""}}' + + [ "$status" -eq 0 ] + echo "$output" | jq . >/dev/null +} + +# ---------- Push rejection detection ---------- + +@test "post-bash: push rejection uses additionalContext" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-5","tool_input":{"command":"git push origin feat/test-feature"},"tool_result":{"exitCode":1,"stdout":"","stderr":"error: failed to push some refs, rejected, non-fast-forward"}}' + + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext') + [[ "$ctx" == "[git-pilot] Push rejected"* ]] +} + +@test "post-bash: push rejection message contains branch name" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-6","tool_input":{"command":"git push origin feat/test-feature"},"tool_result":{"exitCode":1,"stdout":"","stderr":"rejected non-fast-forward"}}' + + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext') + [[ "$ctx" == *"feat/test-feature"* ]] +} + +@test "post-bash: push rejection does not use systemMessage" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-7","tool_input":{"command":"git push origin feat/test-feature"},"tool_result":{"exitCode":1,"stdout":"","stderr":"rejected"}}' + + [ "$status" -eq 0 ] + local has_sys + has_sys=$(echo "$output" | jq 'has("systemMessage")') + [ "$has_sys" = "false" ] +} + +# ---------- Non-git commands ---------- + +@test "post-bash: non-git command returns continue true only" { + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-8","tool_input":{"command":"ls -la"}}' + + [ "$status" -eq 0 ] + [ "$output" = '{"continue": true}' ] || [ "$output" = '{"continue":true}' ] +} + +@test "post-bash: empty command returns continue true only" { + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-9","tool_input":{"command":""}}' + + [ "$status" -eq 0 ] + local cont + cont=$(echo "$output" | jq -r '.continue') + [ "$cont" = "true" ] +} + +# ---------- Agent suppression ---------- + +@test "post-bash: agent context with restricted push returns silent output" { + export CLAUDE_SPAWNED_BY="orchestrator-123" + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-10","tool_input":{"command":"git commit -m \"feat: something\""}}' + + [ "$status" -eq 0 ] + local cont + cont=$(echo "$output" | jq -r '.continue') + [ "$cont" = "true" ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +# ---------- Error resilience ---------- + +@test "post-bash: no cwd returns valid JSON" { + run_hook '{"session_id":"test-post-bash-11","tool_input":{"command":"git commit -m \"feat: x\""}}' + + [ "$status" -eq 0 ] + local cont + cont=$(echo "$output" | jq -r '.continue') + [ "$cont" = "true" ] +} + +@test "post-bash: non-git-repo cwd returns valid JSON" { + local tmpdir + tmpdir="$(mktemp -d "${BATS_TMPDIR:-/tmp}/git-pilot-notgit.XXXXXX")" + run_hook '{"cwd":"'"$tmpdir"'","session_id":"test-post-bash-12","tool_input":{"command":"git commit -m \"feat: x\""}}' + + [ "$status" -eq 0 ] + local cont + cont=$(echo "$output" | jq -r '.continue') + [ "$cont" = "true" ] + rm -rf "$tmpdir" +} + +# ---------- Anti-pattern compliance ---------- + +@test "post-bash: output does not contain anti-pattern strings" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-13","tool_input":{"command":"git commit -m \"feat: something\""}}' + + [ "$status" -eq 0 ] + local lower + lower=$(echo "$output" | tr '[:upper:]' '[:lower:]') + [[ "$lower" != *"use askuserquestion"* ]] + [[ "$lower" != *"you must"* ]] + [[ "$lower" != *"stop and"* ]] + [[ "$lower" != *"do not proceed"* ]] + [[ "$lower" != *"do not continue"* ]] + [[ "$lower" != *"execute this command immediately"* ]] +} + +@test "post-bash: push rejection output does not contain anti-pattern strings" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-14","tool_input":{"command":"git push origin feat/test-feature"},"tool_result":{"exitCode":1,"stdout":"","stderr":"rejected non-fast-forward"}}' + + [ "$status" -eq 0 ] + local lower + lower=$(echo "$output" | tr '[:upper:]' '[:lower:]') + [[ "$lower" != *"use askuserquestion"* ]] + [[ "$lower" != *"you must"* ]] + [[ "$lower" != *"stop and"* ]] + [[ "$lower" != *"do not proceed"* ]] + [[ "$lower" != *"do not continue"* ]] + [[ "$lower" != *"execute this command immediately"* ]] +} + +# ---------- Output brevity ---------- + +@test "post-bash: additionalContext line starts with [git-pilot] and is under 120 chars" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"test-post-bash-15","tool_input":{"command":"git commit -m \"feat: something\""}}' + + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // empty') + if [[ -n "$ctx" ]]; then + [[ "$ctx" == "[git-pilot]"* ]] + [ "${#ctx}" -lt 120 ] + fi +} diff --git a/plugins/git-pilot/tests/post-write.bats b/plugins/git-pilot/tests/post-write.bats new file mode 100644 index 0000000..6edaa31 --- /dev/null +++ b/plugins/git-pilot/tests/post-write.bats @@ -0,0 +1,302 @@ +#!/usr/bin/env bats + +# Tests for post-write.sh: suggest mode, auto mode, silent mode, +# agent suppression, state tracking, error resilience, and anti-pattern compliance. + +load test_helper/common + +HOOK_SCRIPT="$PLUGIN_DIR/scripts/post-write.sh" + +setup() { + setup_test_repo + + cd "$TEST_REPO" + git checkout -b feat/test-feature >/dev/null 2>&1 + + # Create a default local config with autoCommit enabled + mkdir -p "$TEST_REPO/.claude" + echo '{"autoCommit":{"enabled":true,"mode":"suggest","threshold":3}}' > "$TEST_REPO/.claude/git-pilot.json" + + TEST_SESSION="test-post-write-$$-$RANDOM" + + unset CLAUDE_SPAWNED_BY +} + +teardown() { + unset CLAUDE_SPAWNED_BY + rm -f "/tmp/git-pilot-${TEST_SESSION}.json" + teardown_test_repo +} + +# Helper: run the hook script with JSON input +run_hook() { + local json="$1" + run bash -c "echo '$json' | bash '$HOOK_SCRIPT'" +} + +# Helper: run the hook N times to reach threshold +run_hook_n_times() { + local n="$1" + local session="$2" + local file_prefix="${3:-src/file}" + for i in $(seq 1 "$n"); do + local json='{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"'"${file_prefix}${i}.py"'"}}' + echo "$json" | bash "$HOOK_SCRIPT" + done +} + +# ---------- Suggest mode ---------- + +@test "post-write: suggest mode uses additionalContext at threshold" { + cd "$TEST_REPO" + # Run below threshold (2 of 3) + run_hook_n_times 2 "$TEST_SESSION" "src/a" >/dev/null 2>&1 + + # Third call should trigger + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$TEST_SESSION"'","tool_input":{"file_path":"src/a3.py"}}' + + [ "$status" -eq 0 ] + echo "$output" | jq -e '.additionalContext' >/dev/null + echo "$output" | jq -e '.continue == true' >/dev/null +} + +@test "post-write: suggest mode message matches expected format" { + cd "$TEST_REPO" + run_hook_n_times 2 "$TEST_SESSION" "src/b" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$TEST_SESSION"'","tool_input":{"file_path":"src/b3.py"}}' + + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext') + [[ "$ctx" == "[git-pilot] 3 file changes since last commit"* ]] +} + +@test "post-write: suggest mode does not use systemMessage" { + cd "$TEST_REPO" + run_hook_n_times 2 "$TEST_SESSION" "src/c" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$TEST_SESSION"'","tool_input":{"file_path":"src/c3.py"}}' + + [ "$status" -eq 0 ] + local has_sys + has_sys=$(echo "$output" | jq 'has("systemMessage")') + [ "$has_sys" = "false" ] +} + +# ---------- Auto mode ---------- + +@test "post-write: auto mode uses additionalContext at threshold" { + cd "$TEST_REPO" + echo '{"autoCommit":{"enabled":true,"mode":"auto","threshold":2}}' > "$TEST_REPO/.claude/git-pilot.json" + + local session="test-pw-auto-$$-$RANDOM" + run_hook_n_times 1 "$session" "src/d" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"src/d2.py"}}' + + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext') + [[ "$ctx" == "[git-pilot] Auto-commit threshold reached"* ]] + + rm -f "/tmp/git-pilot-${session}.json" +} + +@test "post-write: auto mode does not use systemMessage" { + cd "$TEST_REPO" + echo '{"autoCommit":{"enabled":true,"mode":"auto","threshold":2}}' > "$TEST_REPO/.claude/git-pilot.json" + + local session="test-pw-auto2-$$-$RANDOM" + run_hook_n_times 1 "$session" "src/e" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"src/e2.py"}}' + + [ "$status" -eq 0 ] + local has_sys + has_sys=$(echo "$output" | jq 'has("systemMessage")') + [ "$has_sys" = "false" ] + + rm -f "/tmp/git-pilot-${session}.json" +} + +# ---------- Silent mode ---------- + +@test "post-write: silent mode performs git commit and reports result" { + cd "$TEST_REPO" + echo '{"autoCommit":{"enabled":true,"mode":"silent","threshold":2,"wipPrefix":"wip: "}}' > "$TEST_REPO/.claude/git-pilot.json" + + # Create actual files so git commit works + echo "content1" > "$TEST_REPO/silent1.txt" + git add "$TEST_REPO/silent1.txt" >/dev/null 2>&1 + + local session="test-pw-silent-$$-$RANDOM" + run_hook_n_times 1 "$session" "src/f" >/dev/null 2>&1 + + echo "content2" > "$TEST_REPO/silent2.txt" + git add "$TEST_REPO/silent2.txt" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"src/f2.py"}}' + + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext') + [[ "$ctx" == "[git-pilot] Auto-committed:"* ]] || [[ "$ctx" == "[git-pilot] Auto-commit failed:"* ]] + + rm -f "/tmp/git-pilot-${session}.json" +} + +# ---------- Below threshold ---------- + +@test "post-write: below threshold produces no additionalContext" { + cd "$TEST_REPO" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$TEST_SESSION"'","tool_input":{"file_path":"src/single.py"}}' + + [ "$status" -eq 0 ] + # Below threshold: no output or just exit 0 + if [[ -n "$output" ]]; then + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")' 2>/dev/null || echo "false") + [ "$has_ctx" = "false" ] + fi +} + +# ---------- State tracking ---------- + +@test "post-write: changeCount resets after threshold" { + cd "$TEST_REPO" + source_script "state.sh" + + local session="test-pw-reset-$$-$RANDOM" + + # Reach threshold (3 calls) + run_hook_n_times 3 "$session" "src/g" >/dev/null 2>&1 + + # After threshold, changeCount should be reset to 0 + local state_file="/tmp/git-pilot-${session}.json" + local count + count=$(cat "$state_file" | jq '.changeCount') + [ "$count" = "0" ] + + rm -f "$state_file" +} + +# ---------- Agent suppression ---------- + +@test "post-write: agent context produces no output" { + export CLAUDE_SPAWNED_BY="orchestrator-456" + cd "$TEST_REPO" + + local session="test-pw-agent-$$-$RANDOM" + run_hook_n_times 2 "$session" "src/h" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"src/h3.py"}}' + + [ "$status" -eq 0 ] + # Agent context should exit silently (no output or empty) + if [[ -n "$output" ]]; then + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")' 2>/dev/null || echo "false") + [ "$has_ctx" = "false" ] + fi + + rm -f "/tmp/git-pilot-${session}.json" +} + +# ---------- Auto-commit disabled ---------- + +@test "post-write: disabled auto-commit produces no output" { + cd "$TEST_REPO" + echo '{"autoCommit":{"enabled":false}}' > "$TEST_REPO/.claude/git-pilot.json" + + local session="test-pw-disabled-$$-$RANDOM" + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"src/disabled.py"}}' + + [ "$status" -eq 0 ] + # Disabled: no output or empty + if [[ -n "$output" ]]; then + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")' 2>/dev/null || echo "false") + [ "$has_ctx" = "false" ] + fi + + rm -f "/tmp/git-pilot-${session}.json" +} + +# ---------- Output format validation ---------- + +@test "post-write: output is valid JSON when threshold reached" { + cd "$TEST_REPO" + run_hook_n_times 2 "$TEST_SESSION" "src/j" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$TEST_SESSION"'","tool_input":{"file_path":"src/j3.py"}}' + + [ "$status" -eq 0 ] + echo "$output" | jq . >/dev/null +} + +# ---------- Anti-pattern compliance ---------- + +@test "post-write: suggest mode output does not contain anti-pattern strings" { + cd "$TEST_REPO" + + local session="test-pw-anti-$$-$RANDOM" + run_hook_n_times 2 "$session" "src/k" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"src/k3.py"}}' + + [ "$status" -eq 0 ] + local lower + lower=$(echo "$output" | tr '[:upper:]' '[:lower:]') + [[ "$lower" != *"use askuserquestion"* ]] + [[ "$lower" != *"you must"* ]] + [[ "$lower" != *"stop and"* ]] + [[ "$lower" != *"do not proceed"* ]] + [[ "$lower" != *"do not continue"* ]] + [[ "$lower" != *"execute this command immediately"* ]] + + rm -f "/tmp/git-pilot-${session}.json" +} + +@test "post-write: auto mode output does not contain anti-pattern strings" { + cd "$TEST_REPO" + echo '{"autoCommit":{"enabled":true,"mode":"auto","threshold":2}}' > "$TEST_REPO/.claude/git-pilot.json" + + local session="test-pw-anti2-$$-$RANDOM" + run_hook_n_times 1 "$session" "src/l" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"src/l2.py"}}' + + [ "$status" -eq 0 ] + local lower + lower=$(echo "$output" | tr '[:upper:]' '[:lower:]') + [[ "$lower" != *"use askuserquestion"* ]] + [[ "$lower" != *"you must"* ]] + [[ "$lower" != *"stop and"* ]] + [[ "$lower" != *"do not proceed"* ]] + [[ "$lower" != *"do not continue"* ]] + [[ "$lower" != *"execute this command immediately"* ]] + + rm -f "/tmp/git-pilot-${session}.json" +} + +# ---------- Output brevity ---------- + +@test "post-write: additionalContext line starts with [git-pilot] and is under 120 chars" { + cd "$TEST_REPO" + + local session="test-pw-brevity-$$-$RANDOM" + run_hook_n_times 2 "$session" "src/m" >/dev/null 2>&1 + + run_hook '{"cwd":"'"$TEST_REPO"'","session_id":"'"$session"'","tool_input":{"file_path":"src/m3.py"}}' + + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // empty') + if [[ -n "$ctx" ]]; then + [[ "$ctx" == "[git-pilot]"* ]] + [ "${#ctx}" -lt 120 ] + fi + + rm -f "/tmp/git-pilot-${session}.json" +} diff --git a/plugins/git-pilot/tests/prompt-context.bats b/plugins/git-pilot/tests/prompt-context.bats new file mode 100644 index 0000000..d0f0b59 --- /dev/null +++ b/plugins/git-pilot/tests/prompt-context.bats @@ -0,0 +1,283 @@ +#!/usr/bin/env bats + +# Tests for prompt-context.sh hook output format, skip conditions, +# anti-pattern enforcement, and output brevity rules. + +load test_helper/common + +HOOK_SCRIPT="$PLUGIN_DIR/scripts/prompt-context.sh" + +setup() { + setup_test_repo + export ORIGINAL_HOME="$HOME" + export HOME="$TEST_REPO" + unset CLAUDE_SPAWNED_BY +} + +teardown() { + export HOME="$ORIGINAL_HOME" + unset CLAUDE_SPAWNED_BY + teardown_test_repo +} + +# Helper: run the prompt-context hook with the given cwd and session_id. +run_hook() { + local cwd="${1:-$TEST_REPO}" + local session_id="${2:-test-prompt-ctx-$$}" + run bash "$HOOK_SCRIPT" <<< "{\"cwd\": \"${cwd}\", \"session_id\": \"${session_id}\"}" +} + +# Helper: create a feature branch with commits ahead of default. +setup_feature_branch() { + local branch_name="${1:-feat/test-feature}" + local commit_count="${2:-3}" + cd "$TEST_REPO" + git checkout -b "$branch_name" >/dev/null 2>&1 + for i in $(seq 1 "$commit_count"); do + echo "change $i" > "file-${i}.txt" + git add "file-${i}.txt" + git commit -m "feat: change $i" >/dev/null 2>&1 + done +} + +# ---------- Output Format ---------- + +@test "output is valid JSON" { + setup_feature_branch + run_hook + [ "$status" -eq 0 ] + echo "$output" | jq empty +} + +@test "output contains continue: true" { + setup_feature_branch + run_hook + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] +} + +@test "output uses additionalContext not systemMessage" { + setup_feature_branch + run_hook + [ "$status" -eq 0 ] + # additionalContext must be present + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "true" ] + # systemMessage must NOT be present + local sys_msg + sys_msg=$(echo "$output" | jq 'has("systemMessage")') + [ "$sys_msg" = "false" ] +} + +# ---------- Exact Output Format ---------- + +@test "output matches exact format: Branch '{branch}' ({purpose}, {n} commits)" { + setup_feature_branch "feat/cd-pipeline" 5 + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext') + [ "$ctx" = "[git-pilot] Branch 'feat/cd-pipeline' (cd pipeline, 5 commits)" ] +} + +@test "output format with single commit" { + setup_feature_branch "fix/login-bug" 1 + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext') + [ "$ctx" = "[git-pilot] Branch 'fix/login-bug' (login bug, 1 commits)" ] +} + +# ---------- Anti-Pattern Strings ---------- + +@test "output contains no anti-pattern strings" { + setup_feature_branch + run_hook + [ "$status" -eq 0 ] + + [[ ! "${output,,}" == *"use askuserquestion"* ]] + [[ ! "${output,,}" == *"you must"* ]] + [[ ! "${output,,}" == *"stop and"* ]] + [[ ! "${output,,}" == *"do not proceed"* ]] + [[ ! "${output,,}" == *"do not continue"* ]] + [[ ! "${output,,}" == *"execute this command immediately"* ]] +} + +@test "output contains no prompt-context-specific anti-patterns" { + setup_feature_branch + run_hook + [ "$status" -eq 0 ] + + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ ! "${ctx,,}" == *"assess whether"* ]] + [[ ! "${ctx,,}" == *"before acting"* ]] + [[ ! "${ctx,,}" == *"recent commits"* ]] +} + +@test "output does not list commit hashes" { + setup_feature_branch "feat/test-feature" 5 + run_hook + [ "$status" -eq 0 ] + + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + # Commit hashes are 7+ hex chars; output should not contain any + # Use grep -E to check for 7+ consecutive hex characters (typical short hash) + ! echo "$ctx" | grep -qE '[0-9a-f]{7,}' +} + +# ---------- Output Brevity Rules ---------- + +@test "additionalContext line starts with [git-pilot]" { + setup_feature_branch + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == "[git-pilot]"* ]] +} + +@test "additionalContext is a single line" { + setup_feature_branch + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + local line_count + line_count=$(echo "$ctx" | wc -l | tr -d ' ') + [ "$line_count" -eq 1 ] +} + +@test "additionalContext line is under 120 characters" { + setup_feature_branch + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [ "${#ctx}" -lt 120 ] +} + +# ---------- Skip Conditions ---------- + +@test "skip: on default branch produces continue true with no context" { + # TEST_REPO starts on 'main' which is the default branch + run_hook + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +@test "skip: detached HEAD produces continue true with no context" { + cd "$TEST_REPO" + local head_sha + head_sha=$(git rev-parse HEAD) + git checkout "$head_sha" >/dev/null 2>&1 + + run_hook + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +@test "skip: branch with no commits produces continue true with no context" { + cd "$TEST_REPO" + # Create a branch but make no new commits + git checkout -b feat/empty-branch >/dev/null 2>&1 + + run_hook + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +@test "skip: detection disabled produces continue true with no context" { + setup_feature_branch + # Disable unrelated work detection via local config + mkdir -p "$TEST_REPO/.claude" + echo '{"branch":{"unrelatedWorkDetection":false}}' > "$TEST_REPO/.claude/git-pilot.json" + + run_hook + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +@test "skip: agent context produces continue true with no context" { + setup_feature_branch + export CLAUDE_SPAWNED_BY="orchestrator-session-123" + + run_hook + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +@test "skip: no cwd produces continue true with no context" { + run bash "$HOOK_SCRIPT" <<< '{"session_id": "test-no-cwd"}' + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +@test "skip: not a git repo produces continue true with no context" { + local non_git_dir + non_git_dir="$(mktemp -d "${BATS_TMPDIR:-/tmp}/git-pilot-nongit.XXXXXX")" + + run_hook "$non_git_dir" + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] + + rm -rf "$non_git_dir" +} + +# ---------- Error Resilience ---------- + +@test "empty cwd produces valid JSON with continue true" { + run bash "$HOOK_SCRIPT" <<< '{"cwd": "", "session_id": "test-empty-cwd"}' + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] +} + +@test "missing session_id still produces valid JSON" { + setup_feature_branch + run bash "$HOOK_SCRIPT" <<< "{\"cwd\": \"${TEST_REPO}\"}" + [ "$status" -eq 0 ] + echo "$output" | jq empty +} diff --git a/plugins/git-pilot/tests/session-start.bats b/plugins/git-pilot/tests/session-start.bats new file mode 100644 index 0000000..3508984 --- /dev/null +++ b/plugins/git-pilot/tests/session-start.bats @@ -0,0 +1,384 @@ +#!/usr/bin/env bats + +# Tests for session-start.sh hook output format, condition-to-output mapping, +# anti-pattern enforcement, and error resilience. + +load test_helper/common + +HOOK_SCRIPT="$PLUGIN_DIR/scripts/session-start.sh" + +setup() { + setup_test_repo + # Override HOME to prevent global config from interfering + export ORIGINAL_HOME="$HOME" + export HOME="$TEST_REPO" +} + +teardown() { + export HOME="$ORIGINAL_HOME" + rm -f /tmp/git-pilot-test-session-start-*.json + teardown_test_repo +} + +# Helper: run the session-start hook with the given cwd and session_id. +# Wraps in a subshell to redirect stderr away from bats' $output capture. +run_hook() { + local cwd="${1:-$TEST_REPO}" + local session_id="${2:-test-session-start-$$}" + run bash -c 'bash "$1" 2>/dev/null <<< "$2"' _ \ + "$HOOK_SCRIPT" "{\"cwd\": \"${cwd}\", \"session_id\": \"${session_id}\"}" +} + +# Helper: push a commit to TEST_REMOTE from a temporary clone. +# Sets CLONE_DIR for cleanup. The clone checks out 'main' explicitly. +push_remote_commit() { + local msg="${1:-feat: remote change}" + local filename="${2:-remote-file.txt}" + CLONE_DIR="$(mktemp -d "${BATS_TMPDIR:-/tmp}/git-pilot-clone.XXXXXX")" + git clone "$TEST_REMOTE" "$CLONE_DIR" 2>/dev/null + cd "$CLONE_DIR" + # Ensure we're on main (bare repos may default to a different branch) + git checkout main 2>/dev/null || git checkout -b main origin/main 2>/dev/null + git config user.name "Other" + git config user.email "other@example.com" + echo "$msg" > "$filename" + git add "$filename" + git -c commit.gpgsign=false commit -m "$msg" >/dev/null 2>&1 + git push origin main >/dev/null 2>&1 + cd "$TEST_REPO" +} + +# ---------- Output Format ---------- + +@test "output is valid JSON" { + run_hook + [ "$status" -eq 0 ] + echo "$output" | jq empty +} + +@test "output contains continue: true" { + run_hook + [ "$status" -eq 0 ] + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] +} + +@test "output uses additionalContext not systemMessage" { + run_hook + [ "$status" -eq 0 ] + # systemMessage must NOT be present + local sys_msg + sys_msg=$(echo "$output" | jq 'has("systemMessage")') + [ "$sys_msg" = "false" ] +} + +@test "additionalContext is present when there is context to report" { + # On default branch with autoCreate enabled triggers "[git-pilot] On default branch" + run_hook + [ "$status" -eq 0 ] + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "true" ] +} + +# ---------- Anti-Pattern Strings ---------- + +@test "output contains no anti-pattern strings" { + run_hook + [ "$status" -eq 0 ] + + # Case-insensitive checks for forbidden phrases + [[ ! "${output,,}" == *"use askuserquestion"* ]] + [[ ! "${output,,}" == *"you must"* ]] + [[ ! "${output,,}" == *"stop and"* ]] + [[ ! "${output,,}" == *"do not proceed"* ]] + [[ ! "${output,,}" == *"do not continue"* ]] + [[ ! "${output,,}" == *"execute this command immediately"* ]] +} + +@test "on default branch output contains no anti-pattern strings" { + # This is the scenario that used to be most verbose in v2 + run_hook + [ "$status" -eq 0 ] + + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ ! "${ctx,,}" == *"use askuserquestion"* ]] + [[ ! "${ctx,,}" == *"you must"* ]] + [[ ! "${ctx,,}" == *"stop and"* ]] + [[ ! "${ctx,,}" == *"do not proceed"* ]] + [[ ! "${ctx,,}" == *"do not continue"* ]] + [[ ! "${ctx,,}" == *"execute this command immediately"* ]] +} + +# ---------- Output Brevity Rules ---------- + +@test "each additionalContext line starts with [git-pilot]" { + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + if [[ -n "$ctx" ]]; then + while IFS= read -r line; do + [[ "$line" == "[git-pilot]"* ]] + done <<< "$ctx" + fi +} + +@test "each additionalContext line is under 120 characters" { + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + if [[ -n "$ctx" ]]; then + while IFS= read -r line; do + [ "${#line}" -lt 120 ] + done <<< "$ctx" + fi +} + +# ---------- Condition: On Default Branch ---------- + +@test "on default branch: outputs correct message" { + # TEST_REPO starts on 'main' which is the default branch + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] On default branch 'main'"* ]] +} + +# ---------- Condition: No Remote Configured ---------- + +@test "no remote configured: outputs correct message" { + # TEST_REPO has no remote by default + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] No git remote configured"* ]] +} + +# ---------- Condition: Detached HEAD ---------- + +@test "detached HEAD: outputs correct message with sha" { + local head_sha + head_sha=$(cd "$TEST_REPO" && git rev-parse --short HEAD) + cd "$TEST_REPO" + git checkout "$head_sha" >/dev/null 2>&1 + + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Detached HEAD at ${head_sha}"* ]] +} + +@test "detached HEAD: includes previous branch when available" { + cd "$TEST_REPO" + # Create and checkout a branch, then detach + git checkout -b feat/test-feature >/dev/null 2>&1 + echo "feature work" > feature.txt + git add feature.txt + git commit -m "feat: add feature" >/dev/null 2>&1 + local head_sha + head_sha=$(git rev-parse --short HEAD) + git checkout "$head_sha" >/dev/null 2>&1 + + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Detached HEAD at ${head_sha} (previous branch: feat/test-feature)"* ]] +} + +# ---------- Condition: Branch Behind Remote ---------- + +@test "branch behind remote: outputs correct message" { + setup_remote_repo + push_remote_commit "feat: remote change" "remote-file.txt" + + cd "$TEST_REPO" + git fetch origin >/dev/null 2>&1 + + # The hook will attempt fast-forward; if it succeeds we get ff message, else behind + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Branch 'main' fast-forwarded 1 commit(s)"* ]] || \ + [[ "$ctx" == *"[git-pilot] Branch 'main' is 1 commit(s) behind remote"* ]] + + rm -rf "$CLONE_DIR" +} + +# ---------- Condition: Branch Ahead (Unpushed) ---------- + +@test "branch ahead: outputs unpushed commits message" { + setup_remote_repo + + cd "$TEST_REPO" + echo "local change" > local-file.txt + git add local-file.txt + git commit -m "feat: local change" >/dev/null 2>&1 + + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] 1 unpushed commit(s) on 'main'"* ]] +} + +# ---------- Condition: Branch Diverged ---------- + +@test "branch diverged: outputs diverged message" { + setup_remote_repo + push_remote_commit "feat: remote diverge" "diverge-remote.txt" + + # Make a local commit in test repo + cd "$TEST_REPO" + git fetch origin >/dev/null 2>&1 + echo "local diverge" > diverge-local.txt + git add diverge-local.txt + git -c commit.gpgsign=false commit -m "feat: local diverge" >/dev/null 2>&1 + + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Branch 'main' diverged: 1 local, 1 remote"* ]] + + rm -rf "$CLONE_DIR" +} + +# ---------- Condition: Fast-Forward Success ---------- + +@test "fast-forward success: outputs correct message" { + setup_remote_repo + push_remote_commit "feat: remote ff change" "ff-file.txt" + + cd "$TEST_REPO" + git fetch origin >/dev/null 2>&1 + + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Branch 'main' fast-forwarded 1 commit(s)"* ]] + + rm -rf "$CLONE_DIR" +} + +# ---------- Condition: Fetch Failed ---------- + +@test "fetch failed: outputs correct message" { + # Add a bogus remote + cd "$TEST_REPO" + git remote add origin "file:///nonexistent/path/to/repo" + + # Create a config with zero retries and zero delay for speed + mkdir -p "$TEST_REPO/.claude" + echo '{"git":{"autoFetch":true,"fetchRetries":0,"fetchRetryDelaySec":0}}' > "$TEST_REPO/.claude/git-pilot.json" + + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Remote fetch failed -- proceeding offline"* ]] +} + +# ---------- Condition: Git Repo Auto-Initialized ---------- + +@test "git repo auto-initialized: outputs correct message" { + # Create a non-git directory + local non_git_dir + non_git_dir="$(mktemp -d "${BATS_TMPDIR:-/tmp}/git-pilot-nongit.XXXXXX")" + + run_hook "$non_git_dir" + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Initialized git repo (branch: main)"* ]] + + rm -rf "$non_git_dir" +} + +# ---------- Error Resilience ---------- + +@test "empty cwd defaults gracefully" { + # Pass cwd as "." which defaults to current dir + cd "$TEST_REPO" + run bash -c 'bash "$1" 2>/dev/null <<< "$2"' _ \ + "$HOOK_SCRIPT" '{"cwd": ".", "session_id": "test-empty-cwd"}' + [ "$status" -eq 0 ] + echo "$output" | jq empty +} + +@test "missing session_id still produces valid JSON" { + run bash -c 'bash "$1" 2>/dev/null <<< "$2"' _ \ + "$HOOK_SCRIPT" "{\"cwd\": \"${TEST_REPO}\"}" + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] +} + +@test "empty session_id still produces valid JSON" { + run bash -c 'bash "$1" 2>/dev/null <<< "$2"' _ \ + "$HOOK_SCRIPT" "{\"cwd\": \"${TEST_REPO}\", \"session_id\": \"\"}" + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] +} + +# ---------- Session State Initialization ---------- + +@test "session state file is created with correct session_id" { + local sid="test-session-start-state-$$" + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + + local state_file="/tmp/git-pilot-${sid}.json" + [ -f "$state_file" ] + + local stored_sid + stored_sid=$(jq -r '.sessionId' "$state_file") + [ "$stored_sid" = "$sid" ] + + rm -f "$state_file" +} + +@test "session state records headAtStart" { + local sid="test-session-start-head-$$" + local expected_head + expected_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + + local state_file="/tmp/git-pilot-${sid}.json" + local head_at_start + head_at_start=$(jq -r '.headAtStart' "$state_file") + [ "$head_at_start" = "$expected_head" ] + + rm -f "$state_file" +} + +# ---------- No Messages: Minimal Output ---------- + +@test "up-to-date branch with remote produces no behind/ahead message" { + setup_remote_repo + + run_hook + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + # Should not contain behind or diverged messages + [[ ! "$ctx" == *"commit(s) behind remote"* ]] + [[ ! "$ctx" == *"diverged"* ]] + [[ ! "$ctx" == *"unpushed"* ]] +} diff --git a/plugins/git-pilot/tests/session-stop.bats b/plugins/git-pilot/tests/session-stop.bats new file mode 100644 index 0000000..b7d415d --- /dev/null +++ b/plugins/git-pilot/tests/session-stop.bats @@ -0,0 +1,489 @@ +#!/usr/bin/env bats + +# Tests for session-stop.sh hook output format, condition-to-output mapping, +# anti-pattern enforcement, agent suppression, and error resilience. + +load test_helper/common + +HOOK_SCRIPT="$PLUGIN_DIR/scripts/session-stop.sh" + +setup() { + setup_test_repo + # Override HOME to prevent global config from interfering + export ORIGINAL_HOME="$HOME" + export HOME="$TEST_REPO" + # Ensure no agent context + unset CLAUDE_SPAWNED_BY +} + +teardown() { + export HOME="$ORIGINAL_HOME" + rm -f /tmp/git-pilot-test-session-stop-*.json + teardown_test_repo +} + +# Helper: run the session-stop hook with the given cwd and session_id. +# Wraps in a subshell to redirect stderr away from bats' $output capture. +run_hook() { + local cwd="${1:-$TEST_REPO}" + local session_id="${2:-test-session-stop-$$}" + run bash -c 'bash "$1" 2>/dev/null <<< "$2"' _ \ + "$HOOK_SCRIPT" "{\"cwd\": \"${cwd}\", \"session_id\": \"${session_id}\"}" +} + +# Helper: initialize a session state file with headAtStart set to the given sha. +create_state() { + local session_id="$1" + local head_at_start="${2:-$(cd "$TEST_REPO" && git rev-parse HEAD)}" + local state_file="/tmp/git-pilot-${session_id}.json" + jq -n \ + --arg sid "$session_id" \ + --arg head "$head_at_start" \ + '{sessionId: $sid, headAtStart: $head, changeCount: 0, isAgent: false}' \ + > "$state_file" +} + +# Helper: create a session state with isAgent: true for agent suppression tests. +create_agent_state() { + local session_id="$1" + local head_at_start="${2:-$(cd "$TEST_REPO" && git rev-parse HEAD)}" + local state_file="/tmp/git-pilot-${session_id}.json" + jq -n \ + --arg sid "$session_id" \ + --arg head "$head_at_start" \ + '{sessionId: $sid, headAtStart: $head, changeCount: 0, isAgent: true}' \ + > "$state_file" +} + +# Helper: make a commit in TEST_REPO to simulate session work. +make_commit() { + local msg="${1:-feat: test change}" + local filename="${2:-test-file-$RANDOM.txt}" + cd "$TEST_REPO" + echo "$msg" > "$filename" + git add "$filename" + git -c commit.gpgsign=false commit -m "$msg" >/dev/null 2>&1 +} + +# Helper: push a commit to TEST_REMOTE from a temporary clone. +push_remote_commit() { + local msg="${1:-feat: remote change}" + local filename="${2:-remote-file.txt}" + CLONE_DIR="$(mktemp -d "${BATS_TMPDIR:-/tmp}/git-pilot-clone.XXXXXX")" + git clone "$TEST_REMOTE" "$CLONE_DIR" 2>/dev/null + cd "$CLONE_DIR" + git checkout main 2>/dev/null || git checkout -b main origin/main 2>/dev/null + git config user.name "Other" + git config user.email "other@example.com" + echo "$msg" > "$filename" + git add "$filename" + git -c commit.gpgsign=false commit -m "$msg" >/dev/null 2>&1 + git push origin main >/dev/null 2>&1 + cd "$TEST_REPO" +} + +# ---------- Output Format ---------- + +@test "no changes: output is valid JSON with continue true" { + local sid="test-session-stop-fmt-$$" + create_state "$sid" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] +} + +@test "with changes: output uses additionalContext not systemMessage" { + local sid="test-session-stop-ctx-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + # Create a feature branch and make a commit + cd "$TEST_REPO" + git checkout -b feat/test-ctx >/dev/null 2>&1 + make_commit "feat: context test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + echo "$output" | jq empty + + # systemMessage must NOT be present + local sys_msg + sys_msg=$(echo "$output" | jq 'has("systemMessage")') + [ "$sys_msg" = "false" ] + + # additionalContext should be present + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "true" ] +} + +# ---------- Anti-Pattern Strings ---------- + +@test "output contains no anti-pattern strings" { + local sid="test-session-stop-anti-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-anti >/dev/null 2>&1 + make_commit "feat: anti-pattern test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + + [[ ! "${output,,}" == *"use askuserquestion"* ]] + [[ ! "${output,,}" == *"you must"* ]] + [[ ! "${output,,}" == *"stop and"* ]] + [[ ! "${output,,}" == *"do not proceed"* ]] + [[ ! "${output,,}" == *"do not continue"* ]] + [[ ! "${output,,}" == *"execute this command immediately"* ]] +} + +# ---------- Output Brevity Rules ---------- + +@test "each additionalContext line starts with [git-pilot]" { + local sid="test-session-stop-prefix-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-prefix >/dev/null 2>&1 + make_commit "feat: prefix test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + if [[ -n "$ctx" ]]; then + while IFS= read -r line; do + [[ "$line" == "[git-pilot]"* ]] + done <<< "$ctx" + fi +} + +@test "each additionalContext line is under 120 characters" { + local sid="test-session-stop-len-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-len >/dev/null 2>&1 + make_commit "feat: length test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + if [[ -n "$ctx" ]]; then + while IFS= read -r line; do + [ "${#line}" -lt 120 ] + done <<< "$ctx" + fi +} + +@test "additionalContext has at most 5 lines" { + local sid="test-session-stop-max5-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-max5 >/dev/null 2>&1 + make_commit "feat: max5 test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + if [[ -n "$ctx" ]]; then + local line_count + line_count=$(echo "$ctx" | wc -l | tr -d ' ') + [ "$line_count" -le 5 ] + fi +} + +# ---------- Condition: Session With Commits ---------- + +@test "session with commits: outputs session summary line" { + local sid="test-session-stop-commits-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-commits >/dev/null 2>&1 + make_commit "feat: first change" "file1.txt" + make_commit "feat: second change" "file2.txt" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Session: 2 commit(s),"* ]] + [[ "$ctx" == *"file(s) changed on 'feat/test-commits'"* ]] +} + +@test "session summary uses commit count not commit listing" { + local sid="test-session-stop-nolist-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-nolist >/dev/null 2>&1 + make_commit "feat: listed change" "listed.txt" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + # Must NOT contain commit hashes or per-commit details + [[ ! "$ctx" == *"- "* ]] + [[ ! "$ctx" == *"Commits ("* ]] + [[ ! "$ctx" == *"Session Summary"* ]] +} + +# ---------- Condition: No Commits In Session ---------- + +@test "no commits in session: silent exit with continue true" { + local sid="test-session-stop-nochanges-$$" + # Set headAtStart to current HEAD so no changes detected + create_state "$sid" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + + # No additionalContext when there are no changes + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +# ---------- Condition: Unpushed Commits ---------- + +@test "unpushed commits: outputs remaining count" { + setup_remote_repo + + local sid="test-session-stop-unpushed-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-unpushed >/dev/null 2>&1 + make_commit "feat: unpushed change" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"unpushed commit(s) remaining"* ]] +} + +# ---------- Condition: Push Always Mode ---------- + +@test "push always mode: outputs auto-push line" { + setup_remote_repo + + local sid="test-session-stop-push-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-push >/dev/null 2>&1 + make_commit "feat: push test" + + # Create config with pushOnFinish=always + mkdir -p "$TEST_REPO/.claude" + echo '{"remote":{"pushOnFinish":"always"}}' > "$TEST_REPO/.claude/git-pilot.json" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + local ctx + ctx=$(echo "$output" | jq -r '.additionalContext // ""') + [[ "$ctx" == *"[git-pilot] Auto-push: run 'git push -u origin feat/test-push'"* ]] +} + +# ---------- Non-Interactive Constraint Tests ---------- + +@test "output does not contain diff stat" { + local sid="test-session-stop-nodiff-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-nodiff >/dev/null 2>&1 + make_commit "feat: diff test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + # No diff --stat output, no file-level details, no insertion/deletion counts + [[ ! "$output" == *"insertion"* ]] + [[ ! "$output" == *"deletion"* ]] + [[ ! "$output" == *" | "* ]] +} + +@test "output does not contain MR body content" { + local sid="test-session-stop-nomr-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-nomr >/dev/null 2>&1 + make_commit "feat: mr body test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + # No MR body building artifacts + [[ ! "$output" == *"## Summary"* ]] + [[ ! "$output" == *"## Commits"* ]] + [[ ! "$output" == *"## Files Changed"* ]] + [[ ! "$output" == *"--title"* ]] + [[ ! "$output" == *"--body"* ]] + [[ ! "$output" == *"--description"* ]] +} + +@test "output does not contain merge-fallback references" { + local sid="test-session-stop-nomerge-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-nomerge >/dev/null 2>&1 + make_commit "feat: merge test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + [[ ! "${output,,}" == *"merge-fallback"* ]] + [[ ! "${output,,}" == *"fall back to merge"* ]] +} + +# ---------- Agent Suppression ---------- + +@test "agent context via env var: silent exit with continue true" { + local sid="test-session-stop-agent-env-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-agent-env >/dev/null 2>&1 + make_commit "feat: agent env test" + + # Set agent env var + export CLAUDE_SPAWNED_BY="orchestrator" + run_hook "$TEST_REPO" "$sid" + unset CLAUDE_SPAWNED_BY + + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + + # Agent path should NOT have additionalContext + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +@test "agent context via state file: silent exit with continue true" { + local sid="test-session-stop-agent-state-$$" + local old_head + old_head=$(cd "$TEST_REPO" && git rev-parse HEAD) + create_agent_state "$sid" "$old_head" + + cd "$TEST_REPO" + git checkout -b feat/test-agent-state >/dev/null 2>&1 + make_commit "feat: agent state test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] + + local has_ctx + has_ctx=$(echo "$output" | jq 'has("additionalContext")') + [ "$has_ctx" = "false" ] +} + +# ---------- Error Resilience ---------- + +@test "not a git repo: exits silently with status 0" { + local non_git_dir + non_git_dir="$(mktemp -d "${BATS_TMPDIR:-/tmp}/git-pilot-nongit.XXXXXX")" + + run_hook "$non_git_dir" + [ "$status" -eq 0 ] + # Should produce no output (exit 0 before any JSON) + [ -z "$output" ] + + rm -rf "$non_git_dir" +} + +@test "empty cwd: exits silently with status 0" { + run bash -c 'bash "$1" 2>/dev/null <<< "$2"' _ \ + "$HOOK_SCRIPT" '{"cwd": "", "session_id": "test-empty"}' + [ "$status" -eq 0 ] +} + +@test "missing state file: produces valid JSON" { + # Use a session_id that has no corresponding state file + local sid="test-session-stop-nostate-$$" + # Do NOT create a state file + + cd "$TEST_REPO" + git checkout -b feat/test-nostate >/dev/null 2>&1 + make_commit "feat: no state test" + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] +} + +@test "missing session_id: produces valid JSON" { + run bash -c 'bash "$1" 2>/dev/null <<< "$2"' _ \ + "$HOOK_SCRIPT" "{\"cwd\": \"${TEST_REPO}\"}" + [ "$status" -eq 0 ] + echo "$output" | jq empty + local continue_val + continue_val=$(echo "$output" | jq -r '.continue') + [ "$continue_val" = "true" ] +} + +# ---------- State Cleanup ---------- + +@test "session state file is cleaned up after stop" { + local sid="test-session-stop-cleanup-$$" + create_state "$sid" + + local state_file="/tmp/git-pilot-${sid}.json" + [ -f "$state_file" ] + + run_hook "$TEST_REPO" "$sid" + [ "$status" -eq 0 ] + + # State file should be removed + [ ! -f "$state_file" ] +}