mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 07:47:27 +01:00
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design, ios-qa daemon, lib and scripts resolve the state root through bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the twin and stop with a reinstall message when it is missing; hooks source it and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts delegate to resolveStateRoot. Analytics writers and readers move together so the usage log stays one file. gstack-uninstall deletes state only at ~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor, the checkout or the git root, and leaves any other resolved root in place with the removal command. Fixtures that copy single bins now copy the twin.
111 lines
4.7 KiB
Bash
Executable File
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" | sed -n 's/^SLUG=//p' | 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"
|