Multi-repository state tracker and coordinator for managing 18+ Git projects. Core Components: - tools/scan.sh: Bash script to scan all repos, pull updates, detect conflicts - tools/analyze.py: Python generator for human-readable summaries - tools/config.json: Repository tracking configuration - README.md: Complete documentation Features: - State tracking (uncommitted changes, new commits, conflicts) - Activity monitoring (3-week lookback window) - Automatic pulling with conflict detection - Categorization (META, CONSTANT, WIP, CONCEPT, PAUSE, DONE) - Summary generation with prioritization analyze.py created in Session 7 - Cycle 2 (ClaudeSelf external exploration). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
209 lines
6.3 KiB
Bash
209 lines
6.3 KiB
Bash
#!/bin/bash
|
|
|
|
# ProjectTracker - Multi-repo scan script
|
|
# Scans all tracked repos, pulls updates, generates state.json
|
|
|
|
set -euo pipefail
|
|
|
|
# Paths
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_TRACKER="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
|
|
|
# Check jq availability
|
|
if ! command -v jq &> /dev/null; then
|
|
echo "ERROR: jq is required but not installed."
|
|
echo "Install: https://stedolan.github.io/jq/download/"
|
|
exit 1
|
|
fi
|
|
|
|
# Load config
|
|
REPOS_ROOT=$(jq -r '.repos_root' "$CONFIG_FILE")
|
|
LOOKBACK_DAYS=$(jq -r '.scan_options.commits_lookback_days' "$CONFIG_FILE")
|
|
AUTO_PULL=$(jq -r '.scan_options.auto_pull' "$CONFIG_FILE")
|
|
|
|
# Output files
|
|
STATE_FILE="$PROJECT_TRACKER/.planning/state.json"
|
|
LOG_FILE="$PROJECT_TRACKER/.planning/scan.log"
|
|
CONFLICTS_FILE="$PROJECT_TRACKER/.planning/conflicts.txt"
|
|
|
|
# Ensure .planning exists
|
|
mkdir -p "$PROJECT_TRACKER/.planning"
|
|
|
|
# Init log
|
|
echo "===== Scan started: $(date -Iseconds) =====" > "$LOG_FILE"
|
|
> "$CONFLICTS_FILE"
|
|
|
|
echo "🔍 ProjectTracker - Multi-repo scan"
|
|
echo "Repos root: $REPOS_ROOT"
|
|
echo ""
|
|
|
|
# Read tracked repos into array
|
|
mapfile -t TRACKED_REPOS < <(jq -r '.tracked_repos[]' "$CONFIG_FILE")
|
|
|
|
# Start building state.json
|
|
cat > "$STATE_FILE" << 'EOF_HEADER'
|
|
{
|
|
"last_scan": "TIMESTAMP_PLACEHOLDER",
|
|
"repos": {
|
|
EOF_HEADER
|
|
|
|
FIRST_REPO=true
|
|
CONFLICT_COUNT=0
|
|
SCANNED_COUNT=0
|
|
ATTENTION_COUNT=0
|
|
|
|
# Scan each repo
|
|
for repo in "${TRACKED_REPOS[@]}"; do
|
|
REPO_PATH="$REPOS_ROOT/$repo"
|
|
|
|
# Check if repo exists
|
|
if [ ! -d "$REPO_PATH/.git" ]; then
|
|
echo "⚠️ $repo - NOT A GIT REPO (skipping)" | tee -a "$LOG_FILE"
|
|
continue
|
|
fi
|
|
|
|
echo -n "📂 $repo... " | tee -a "$LOG_FILE"
|
|
|
|
cd "$REPO_PATH"
|
|
|
|
# Fetch origin
|
|
git fetch origin &>> "$LOG_FILE" || true
|
|
|
|
# Pull if enabled
|
|
HAS_CONFLICT=false
|
|
if [ "$AUTO_PULL" = "true" ]; then
|
|
PULL_OUTPUT=$(git pull 2>&1) || true
|
|
echo "$PULL_OUTPUT" >> "$LOG_FILE"
|
|
|
|
if echo "$PULL_OUTPUT" | grep -q "CONFLICT\|Automatic merge failed"; then
|
|
HAS_CONFLICT=true
|
|
CONFLICT_COUNT=$((CONFLICT_COUNT + 1))
|
|
echo "⚠️ CONFLICT: $repo" | tee -a "$CONFLICTS_FILE"
|
|
fi
|
|
fi
|
|
|
|
# Collect repo info
|
|
CURRENT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
|
LAST_COMMIT_MSG=$(git log -1 --format="%s" 2>/dev/null || echo "No commits")
|
|
LAST_COMMIT_DATE=$(git log -1 --format="%ar" 2>/dev/null || echo "unknown")
|
|
|
|
# Ahead/behind tracking branch
|
|
TRACKING_BRANCH=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "")
|
|
if [ -n "$TRACKING_BRANCH" ]; then
|
|
AHEAD=$(git rev-list --count HEAD..$TRACKING_BRANCH 2>/dev/null || echo 0)
|
|
BEHIND=$(git rev-list --count $TRACKING_BRANCH..HEAD 2>/dev/null || echo 0)
|
|
else
|
|
AHEAD=0
|
|
BEHIND=0
|
|
fi
|
|
|
|
# Uncommitted changes
|
|
if git diff-index --quiet HEAD -- 2>/dev/null; then
|
|
UNCOMMITTED=false
|
|
UNCOMMITTED_COUNT=0
|
|
else
|
|
UNCOMMITTED=true
|
|
UNCOMMITTED_COUNT=$(git status --short 2>/dev/null | wc -l)
|
|
fi
|
|
|
|
# Recent commits
|
|
COMMITS_3W=$(git rev-list --count --since="$LOOKBACK_DAYS days ago" HEAD 2>/dev/null || echo 0)
|
|
|
|
# Load previous commit from existing state (if exists)
|
|
PREV_COMMIT=$(jq -r ".repos.\"$repo\".last_commit // \"\"" "$STATE_FILE" 2>/dev/null || echo "")
|
|
if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "$CURRENT_COMMIT" ] && [ "$PREV_COMMIT" != "null" ]; then
|
|
NEW_COMMITS=$(git rev-list --count $PREV_COMMIT..$CURRENT_COMMIT 2>/dev/null || echo 0)
|
|
else
|
|
NEW_COMMITS=0
|
|
fi
|
|
|
|
# Determine needs_attention
|
|
NEEDS_ATTENTION=false
|
|
if [ "$NEW_COMMITS" -gt 0 ] || [ "$UNCOMMITTED" = "true" ] || [ "$BEHIND" -gt 0 ] || [ "$HAS_CONFLICT" = "true" ]; then
|
|
NEEDS_ATTENTION=true
|
|
ATTENTION_COUNT=$((ATTENTION_COUNT + 1))
|
|
fi
|
|
|
|
# Project file mapping
|
|
PROJECT_FILE=$(jq -r ".project_mapping.\"$repo\" // \"\"" "$CONFIG_FILE")
|
|
|
|
# Add comma if not first repo
|
|
if [ "$FIRST_REPO" = false ]; then
|
|
echo "," >> "$STATE_FILE"
|
|
fi
|
|
FIRST_REPO=false
|
|
|
|
# Write JSON entry (properly escaped)
|
|
LAST_COMMIT_MSG_ESCAPED=$(echo "$LAST_COMMIT_MSG" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
|
|
cat >> "$STATE_FILE" << EOF
|
|
"$repo": {
|
|
"last_commit": "$CURRENT_COMMIT",
|
|
"branch": "$CURRENT_BRANCH",
|
|
"last_commit_msg": "$LAST_COMMIT_MSG_ESCAPED",
|
|
"last_commit_date": "$LAST_COMMIT_DATE",
|
|
"commits_3w": $COMMITS_3W,
|
|
"new_commits_since_last_scan": $NEW_COMMITS,
|
|
"ahead": $AHEAD,
|
|
"behind": $BEHIND,
|
|
"has_uncommitted_changes": $UNCOMMITTED,
|
|
"uncommitted_count": $UNCOMMITTED_COUNT,
|
|
"has_conflict": $HAS_CONFLICT,
|
|
"needs_attention": $NEEDS_ATTENTION,
|
|
"project_file": "$PROJECT_FILE"
|
|
}
|
|
EOF
|
|
|
|
SCANNED_COUNT=$((SCANNED_COUNT + 1))
|
|
|
|
# Status indicator
|
|
if [ "$HAS_CONFLICT" = "true" ]; then
|
|
echo "❌ CONFLICT"
|
|
elif [ "$NEEDS_ATTENTION" = "true" ]; then
|
|
echo "⚠️ needs attention"
|
|
else
|
|
echo "✅"
|
|
fi
|
|
|
|
done
|
|
|
|
# Close repos object and add stats
|
|
cat >> "$STATE_FILE" << EOF
|
|
},
|
|
"stats": {
|
|
"total_repos": $SCANNED_COUNT,
|
|
"active_3w": 0,
|
|
"needs_attention": $ATTENTION_COUNT,
|
|
"conflicts": $CONFLICT_COUNT
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# Calculate active repos count
|
|
ACTIVE_COUNT=$(jq '[.repos[] | select(.commits_3w > 0)] | length' "$STATE_FILE")
|
|
jq ".stats.active_3w = $ACTIVE_COUNT" "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
|
|
|
|
# Update timestamp
|
|
TIMESTAMP=$(date -Iseconds)
|
|
jq ".last_scan = \"$TIMESTAMP\"" "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
|
|
|
|
# Summary
|
|
echo ""
|
|
echo "===== Scan completed: $(date -Iseconds) =====" | tee -a "$LOG_FILE"
|
|
echo "✅ Scanned: $SCANNED_COUNT repos" | tee -a "$LOG_FILE"
|
|
echo "🔥 Active (3w): $ACTIVE_COUNT repos" | tee -a "$LOG_FILE"
|
|
echo "⚠️ Needs attention: $ATTENTION_COUNT repos" | tee -a "$LOG_FILE"
|
|
echo "❌ Conflicts: $CONFLICT_COUNT repos" | tee -a "$LOG_FILE"
|
|
echo ""
|
|
echo "📊 State saved to: $STATE_FILE"
|
|
echo "📝 Log saved to: $LOG_FILE"
|
|
|
|
# Generate summary
|
|
if [ -f "$SCRIPT_DIR/analyze.py" ]; then
|
|
echo ""
|
|
echo "Generating summary..."
|
|
python3 "$SCRIPT_DIR/analyze.py"
|
|
fi
|