mirror of
https://github.com/EveryInc/compound-engineering-plugin.git
synced 2026-06-26 12:23:01 +02:00
7df6dfb252
Renames the skill directory to match the plan's architectural reshape. Stacking is a GitHub feature (gh-stack extension, GitHub's stack UI), not a git feature, and the ce- prefix matches the future convention for plugin skills. Narrows scope to decomposition only: - Removes manage-mode operations. Push and submit are owned by git-commit-push-pr now that it is stack-aware. Rebase, sync, view, and navigation commands are one-line pass-throughs to gh stack with no skill-scale value — invoked directly when needed. - Removes the three-flavor invocation enumeration (manual / delegated / auto-invoked). Consent routing relies on the governing principle that respects prior user decisions within the session, rather than structured caller-side flags. - Drops the delegated / stacking_declined / gh_stack_install_declined signal plumbing from the SKILL.md. Agent context awareness is the primary mechanism; the principle is documented at the bottom. Narrows the CLI surface section: init, add, view, unstack --local for rollback. Ship commands (push, submit) moved to git-commit-push-pr. Rewrites splitting-workflow reference file: four phases collapsed to three. Removes the submit phase entirely; phase 3 ends with a handoff to git-commit-push-pr, which then runs gh stack push + gh stack submit + per-PR description generation via ce-pr-description. Separating decomposition from shipping bounds failure blast radius to local state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
245 lines
7.9 KiB
Bash
Executable File
245 lines
7.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# stack-detect — analyze git state for stacked-PR decision-making.
|
|
#
|
|
# Surfaces three sections of parseable signals about the current branch:
|
|
# === TOOL === availability of gh and gh-stack
|
|
# === STACK_STATE === current branch's position in any existing stack
|
|
# === CHANGE_SUMMARY === size/spread/shape of changes vs. a base branch
|
|
# === COMMIT_LOG === one line per commit vs. base (subject only)
|
|
#
|
|
# The script emits signals only. It does NOT make judgments ("should stack",
|
|
# "too big", etc.) — consuming skills apply any interpretation.
|
|
#
|
|
# Usage:
|
|
# stack-detect # TOOL + STACK_STATE only
|
|
# stack-detect <base-branch> # all four sections
|
|
# stack-detect --mock STATE # force STACK_STATE to STATE for testing
|
|
# STACK_DETECT_MOCK=STATE stack-detect ...
|
|
#
|
|
# Exit codes:
|
|
# 0 normal run (one of the sentinel states was emitted)
|
|
# 1 not inside a git repo (NOT_IN_REPO emitted before exit)
|
|
# 2 invalid base branch argument
|
|
|
|
set -o pipefail
|
|
|
|
# =====================================================
|
|
# Args
|
|
# =====================================================
|
|
|
|
mock_state="${STACK_DETECT_MOCK:-}"
|
|
base_branch=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--mock)
|
|
if [ -z "${2:-}" ]; then
|
|
echo "error: --mock requires a state argument" >&2
|
|
exit 2
|
|
fi
|
|
mock_state="$2"
|
|
shift 2
|
|
;;
|
|
--mock=*)
|
|
mock_state="${1#*=}"
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
|
exit 0
|
|
;;
|
|
--*)
|
|
echo "error: unknown flag: $1" >&2
|
|
exit 2
|
|
;;
|
|
*)
|
|
if [ -n "$base_branch" ]; then
|
|
echo "error: unexpected extra argument: $1" >&2
|
|
exit 2
|
|
fi
|
|
base_branch="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# =====================================================
|
|
# Pre-flight: repo check
|
|
# =====================================================
|
|
|
|
echo "=== TOOL ==="
|
|
|
|
if command -v gh >/dev/null 2>&1; then
|
|
if gh extension list 2>/dev/null | grep -q '\bgh-stack\b\|github/gh-stack\|gh stack'; then
|
|
# Verify access by probing `gh stack view`. Only treat it as an access
|
|
# problem when the extension itself returns an auth/access error — a
|
|
# generic "not in a stack" failure is fine here (state-machine concern).
|
|
stack_probe_output=$(gh stack view 2>&1 || true)
|
|
if printf '%s' "$stack_probe_output" | grep -qiE 'not authenticated|access denied|forbidden|permission denied|403|unauthorized'; then
|
|
echo "GH_STACK_NO_ACCESS"
|
|
# Compact the error to a single line for downstream parseability.
|
|
detail=$(printf '%s' "$stack_probe_output" | tr '\n' ' ' | sed 's/ */ /g' | sed 's/^ *//; s/ *$//')
|
|
echo "detail: $detail"
|
|
gh_stack_usable="no"
|
|
else
|
|
echo "GH_STACK_INSTALLED"
|
|
gh_stack_usable="yes"
|
|
fi
|
|
else
|
|
echo "GH_STACK_NOT_INSTALLED"
|
|
gh_stack_usable="no"
|
|
fi
|
|
else
|
|
echo "GH_NOT_INSTALLED"
|
|
gh_stack_usable="no"
|
|
fi
|
|
|
|
# =====================================================
|
|
# Stack state
|
|
# =====================================================
|
|
|
|
echo "=== STACK_STATE ==="
|
|
|
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
echo "NOT_IN_REPO"
|
|
exit 1
|
|
fi
|
|
|
|
if [ -n "$mock_state" ]; then
|
|
echo "$mock_state"
|
|
elif [ "$gh_stack_usable" != "yes" ]; then
|
|
# Without a working gh-stack, we cannot authoritatively determine stack state.
|
|
echo "UNKNOWN"
|
|
else
|
|
stack_json=$(gh stack view --json 2>/dev/null || true)
|
|
if [ -z "$stack_json" ]; then
|
|
echo "NOT_IN_STACK"
|
|
elif command -v jq >/dev/null 2>&1; then
|
|
# Use jq for robust parsing when available.
|
|
# A "real" stack has 2+ non-trunk branches. A single-branch "stack" is just
|
|
# a plain feature branch against trunk — treat it as NOT_IN_STACK.
|
|
state=$(printf '%s' "$stack_json" | jq -r '
|
|
if (.branches | length) <= 1 then
|
|
"NOT_IN_STACK"
|
|
else
|
|
. as $s
|
|
| ($s.branches | map(select(.isCurrent == true)) | .[0]) as $cur
|
|
| if $cur == null then "NOT_IN_STACK"
|
|
else
|
|
($s.branches | map(.name) | index($cur.name)) as $idx
|
|
| ($s.branches | length) as $n
|
|
| if $idx == 0 then "STACK_BOTTOM"
|
|
elif $idx == ($n - 1) then "STACK_TOP"
|
|
else "STACK_MIDDLE"
|
|
end
|
|
end
|
|
end
|
|
' 2>/dev/null)
|
|
if [ -z "$state" ] || [ "$state" = "null" ]; then
|
|
echo "NOT_IN_STACK"
|
|
else
|
|
echo "$state"
|
|
fi
|
|
else
|
|
# jq not available — fall back to counting branch entries in JSON heuristically.
|
|
branch_count=$(printf '%s' "$stack_json" | grep -c '"name":')
|
|
if [ "$branch_count" -le 1 ]; then
|
|
echo "NOT_IN_STACK"
|
|
else
|
|
# Without jq we cannot reliably determine position; surface a conservative signal.
|
|
echo "UNKNOWN"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# =====================================================
|
|
# Change summary (only when a base branch is given)
|
|
# =====================================================
|
|
|
|
if [ -z "$base_branch" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Validate base branch exists as a ref (local or remote).
|
|
if ! git rev-parse --verify --quiet "$base_branch" >/dev/null 2>&1 \
|
|
&& ! git rev-parse --verify --quiet "refs/remotes/origin/$base_branch" >/dev/null 2>&1; then
|
|
echo "=== CHANGE_SUMMARY ==="
|
|
echo "error: base branch not found: $base_branch"
|
|
exit 2
|
|
fi
|
|
|
|
# Resolve the actual base ref (prefer local branch, else origin/<base>).
|
|
base_ref="$base_branch"
|
|
if ! git rev-parse --verify --quiet "$base_branch" >/dev/null 2>&1; then
|
|
base_ref="refs/remotes/origin/$base_branch"
|
|
fi
|
|
|
|
# Merge-base-style range: commits on HEAD but not on base.
|
|
range="${base_ref}...HEAD"
|
|
|
|
echo "=== CHANGE_SUMMARY ==="
|
|
|
|
# files / insertions / deletions via --numstat across the combined diff.
|
|
# --numstat emits lines like: <ins>\t<del>\t<path>. Binary files appear as "-\t-\t<path>".
|
|
diff_stats=$(git diff --numstat "$base_ref"...HEAD 2>/dev/null || true)
|
|
|
|
files_count=0
|
|
insertions=0
|
|
deletions=0
|
|
if [ -n "$diff_stats" ]; then
|
|
files_count=$(printf '%s\n' "$diff_stats" | grep -c .)
|
|
insertions=$(printf '%s\n' "$diff_stats" | awk '$1 ~ /^[0-9]+$/ { s += $1 } END { print s+0 }')
|
|
deletions=$(printf '%s\n' "$diff_stats" | awk '$2 ~ /^[0-9]+$/ { s += $2 } END { print s+0 }')
|
|
fi
|
|
|
|
commit_count=$(git rev-list --count "${base_ref}..HEAD" 2>/dev/null || echo 0)
|
|
|
|
# Distinct top-level directory prefixes touched (or "." for top-level files).
|
|
directories=""
|
|
if [ -n "$diff_stats" ]; then
|
|
directories=$(printf '%s\n' "$diff_stats" \
|
|
| awk -F'\t' '{ print $3 }' \
|
|
| awk -F'/' '{ if (NF > 1) print $1 "/"; else print "."; }' \
|
|
| sort -u \
|
|
| paste -sd ',' -)
|
|
fi
|
|
|
|
# renames_only_commits: commits whose diff is purely renames/moves.
|
|
# For each commit in the range, check whether there is at least one rename AND
|
|
# no other filter kinds (A/M/D/C/T). We rely on --name-status because
|
|
# --diff-filter on log filters commits, not files within a commit.
|
|
renames_only=0
|
|
if [ "$commit_count" -gt 0 ]; then
|
|
while IFS= read -r sha; do
|
|
[ -z "$sha" ] && continue
|
|
# Lines look like: "R100\tfrom\tto" or "M\tpath".
|
|
status_lines=$(git show --pretty=format: --name-status "$sha" 2>/dev/null | sed '/^$/d')
|
|
if [ -z "$status_lines" ]; then
|
|
continue
|
|
fi
|
|
has_rename=$(printf '%s\n' "$status_lines" | awk '$1 ~ /^R/' | head -n1)
|
|
has_other=$(printf '%s\n' "$status_lines" | awk '$1 !~ /^R/' | head -n1)
|
|
if [ -n "$has_rename" ] && [ -z "$has_other" ]; then
|
|
renames_only=$((renames_only + 1))
|
|
fi
|
|
done < <(git rev-list "${base_ref}..HEAD" 2>/dev/null)
|
|
fi
|
|
|
|
echo "files: $files_count"
|
|
echo "insertions: $insertions"
|
|
echo "deletions: $deletions"
|
|
echo "commits: $commit_count"
|
|
echo "directories: $directories"
|
|
echo "renames_only_commits: $renames_only"
|
|
|
|
# =====================================================
|
|
# Commit log (one line per commit, newest first)
|
|
# =====================================================
|
|
|
|
echo "=== COMMIT_LOG ==="
|
|
git log --pretty=format:'%h %s' "${base_ref}..HEAD" 2>/dev/null || true
|
|
# Ensure a trailing newline even when git log emits none.
|
|
echo ""
|
|
|
|
exit 0
|