Files
gstack/bin/gstack-repo-mode
Garry Tanandcapy-ai[bot] 482ffd1440 v1.92.14.0 feat: a .gstack-project file makes a nested repo or umbrella folder its own project (#2649, #1923) (#3777)
* feat: a .gstack-project marker makes a nested repo or umbrella folder its own project (#2649, #1923)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: marker tests ignore an ambient GSTACK_STATE_ROOT

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (per-repo records ignore every marker, worktree binding in remote-slug and under a git HOME)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): a linked worktree finds its main checkout's marker on its first run

The worktree binding was written only when the main checkout resolved, so a
worktree outside an umbrella resolved first kept its old bucket and later
switched. Walk up from the main checkout on a miss instead (under its own
$HOME rule) and drop the binding file.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs/test: pregate user policy names REPO_SLUG; pin greptile consent to REPO_SLUG

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): no-clobber checkpoint recovery, unambiguous marker cache keys, ignore unusable folder names; remote-slug resolves once

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): remote-slug reads a quoted marker path; repo mode stays per repository

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(slug): the review log and /ship spend-cap pointer stay with the repository under a marker; recovered checkpoints keep their repository; a linked worktree's own marker stands for its main checkout

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(captain): approvals live under the repository's own slug

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 05:57:18 +00:00

111 lines
4.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-repo-mode — detect solo vs collaborative repo mode
# Usage: source <(gstack-repo-mode) → sets REPO_MODE variable
# Or: gstack-repo-mode → prints REPO_MODE=... line
#
# Detection heuristic (90-day window):
# Solo: top author >= 80% of commits
# Collaborative: top author < 80%
#
# Override: gstack-config set repo_mode solo|collaborative
# Cache: ~/.gstack/projects/$SLUG/repo-mode.json (7-day TTL)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Keep the no-remote early exit before consulting gstack-slug. gstack-slug falls
# back to the directory basename when origin is missing, which would incorrectly
# make local-only repos eligible for classification.
REMOTE_URL=$(git remote get-url origin 2>/dev/null || true)
if [ -z "$REMOTE_URL" ]; then
echo "REPO_MODE=unknown"
exit 0
fi
# Consume the canonical cached slug from gstack-slug (sed, never eval — branch
# names can contain shell metacharacters). Invoke from the git root so the
# PWD-keyed slug cache matches other gstack project-state writers.
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ -z "${REPO_ROOT:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" --get REPO_SLUG | head -1)
[ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; }
# Validate: only allow known values (prevent shell injection via source <(...))
validate_mode() {
case "$1" in solo|collaborative|unknown) echo "$1" ;; *) echo "unknown" ;; esac
}
# Config override takes precedence
OVERRIDE=$("$SCRIPT_DIR/gstack-config" get repo_mode 2>/dev/null || true)
if [ -n "$OVERRIDE" ] && [ "$OVERRIDE" != "null" ]; then
echo "REPO_MODE=$(validate_mode "$OVERRIDE")"
exit 0
fi
# Check cache (7-day TTL)
. "$SCRIPT_DIR/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SCRIPT_DIR/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
CACHE_DIR="$GSTACK_STATE_ROOT/projects/$SLUG"
CACHE_FILE="$CACHE_DIR/repo-mode.json"
if [ -f "$CACHE_FILE" ]; then
# GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem
# status (not a format string), so the BSD-first fallback chain never fell
# over — it fed multi-word filesystem output into the arithmetic below and
# crashed under set -u on Windows Git Bash. `stat -c` fails cleanly on
# BSD/macOS, making GNU-first the deterministic order. Numeric-validate
# before arithmetic as the last line of defense.
CACHE_MTIME=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0)
case "$CACHE_MTIME" in ''|*[!0-9]*) CACHE_MTIME=0 ;; esac
CACHE_AGE=$(( $(date +%s) - CACHE_MTIME ))
if [ "$CACHE_AGE" -lt 604800 ]; then # 7 days in seconds
MODE=$(grep -o '"mode":"[^"]*"' "$CACHE_FILE" | head -1 | cut -d'"' -f4)
[ -n "$MODE" ] && echo "REPO_MODE=$(validate_mode "$MODE")" && exit 0
fi
fi
# Compute from git history (90-day window)
# Use default branch (not HEAD) to avoid feature-branch sampling bias
DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/||' || true)
# Fallback: try origin/main, then origin/master, then HEAD
if [ -z "$DEFAULT_BRANCH" ]; then
if git rev-parse --verify origin/main &>/dev/null; then
DEFAULT_BRANCH="origin/main"
elif git rev-parse --verify origin/master &>/dev/null; then
DEFAULT_BRANCH="origin/master"
else
DEFAULT_BRANCH="HEAD"
fi
fi
SHORTLOG=$(git shortlog -sn --since="90 days ago" --no-merges "$DEFAULT_BRANCH" 2>/dev/null)
if [ -z "$SHORTLOG" ]; then
echo "REPO_MODE=unknown"
exit 0
fi
# Compute TOTAL from ALL authors (not truncated) to avoid solo bias
TOTAL=$(echo "$SHORTLOG" | awk '{s+=$1} END {print s}')
TOP=$(echo "$SHORTLOG" | head -1 | awk '{print $1}')
AUTHORS=$(echo "$SHORTLOG" | wc -l | tr -d ' ')
# Minimum sample: need at least 5 commits to classify
if [ "$TOTAL" -lt 5 ]; then
echo "REPO_MODE=unknown"
exit 0
fi
TOP_PCT=$(( TOP * 100 / TOTAL ))
# Solo: top author >= 80% of commits (occasional outside PRs don't change mode)
if [ "$TOP_PCT" -ge 80 ]; then
MODE=solo
else
MODE=collaborative
fi
# Cache result atomically (fail silently if ~/.gstack is unwritable)
mkdir -p "$CACHE_DIR" 2>/dev/null || true
CACHE_TMP=$(mktemp "$CACHE_DIR/.repo-mode-XXXXXX" 2>/dev/null || true)
if [ -n "$CACHE_TMP" ]; then
echo "{\"mode\":\"$MODE\",\"top_pct\":$TOP_PCT,\"authors\":$AUTHORS,\"total\":$TOTAL,\"computed\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$CACHE_TMP" 2>/dev/null && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null
fi
echo "REPO_MODE=$MODE"