- Added wiki change detection and categorization in sync.sh - Shows articles by type (clients/projects/systems/patterns/meta) - Displays status (added/modified/deleted) and counts - Updated sync.md and save.md documentation
395 lines
16 KiB
Bash
Executable File
395 lines
16 KiB
Bash
Executable File
#!/bin/bash
|
|
# ClaudeTools Bidirectional Sync Script
|
|
# Ensures proper pull BEFORE push on all machines
|
|
# Prints incoming/outgoing change summary with author attribution
|
|
|
|
set -e
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
NC='\033[0m'
|
|
|
|
# --- Guard: strip Windows path-as-filename cruft before staging -------------
|
|
# Windows machines occasionally drop files literally named like "C:\path" or
|
|
# ".claude\current-mode" into the repo. The illegal ':' / '\' chars get stored
|
|
# as Unicode Private-Use-Area substitutes (U+F03A, U+F00A, ...). A blind
|
|
# `git add -A` would stage and propagate them to Gitea. This removes any such
|
|
# UNTRACKED path before staging. Only untracked files are touched — tracked
|
|
# content is never auto-deleted (use `git rm` for that, deliberately).
|
|
purge_garbled_paths() {
|
|
local rec st p removed=0
|
|
while IFS= read -r -d '' rec; do
|
|
st="${rec:0:2}"
|
|
p="${rec:3}"
|
|
[ -z "$p" ] && continue
|
|
[ "$st" = "??" ] || continue # untracked only
|
|
if printf '%s' "$p" | LC_ALL=C grep -qaP '[\x00-\x1f\\:]|\xee[\x80-\xbf]|\xef[\x80-\xa3]'; then
|
|
echo -e "${YELLOW}[WARNING]${NC} Removing garbled untracked path before staging: $(printf '%s' "$p" | cat -v)"
|
|
rm -f -- "$p" 2>/dev/null || true
|
|
removed=1
|
|
fi
|
|
done < <(git status --porcelain -z)
|
|
[ "$removed" = 1 ] && echo -e "${YELLOW}[WARNING]${NC} Garbled Windows path-as-filename cruft removed (not synced)."
|
|
return 0
|
|
}
|
|
|
|
# Machine + timestamp
|
|
if [ -n "$COMPUTERNAME" ]; then
|
|
MACHINE="$COMPUTERNAME"
|
|
else
|
|
MACHINE=$(hostname)
|
|
fi
|
|
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
|
|
|
|
echo -e "${GREEN}[OK]${NC} Starting ClaudeTools sync from $MACHINE at $TIMESTAMP"
|
|
|
|
# Navigate to ClaudeTools directory
|
|
# First check: are we already in the repo (or a subdirectory of it)?
|
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
|
if [ -n "$REPO_ROOT" ]; then
|
|
cd "$REPO_ROOT"
|
|
else
|
|
# Fall back to known candidate paths
|
|
for candidate in "$HOME/ClaudeTools" "/d/ClaudeTools" "D:/ClaudeTools" "/d/claudetools" "D:/claudetools" "C:/claudetools" "/c/claudetools"; do
|
|
if [ -d "$candidate/.git" ]; then
|
|
cd "$candidate"
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [ ! -d ".git" ]; then
|
|
echo -e "${RED}[ERROR]${NC} Not in a git working tree"
|
|
exit 1
|
|
fi
|
|
|
|
echo -e "${GREEN}[OK]${NC} Working directory: $(pwd)"
|
|
|
|
# Detect Python interpreter — verify it actually runs (Windows Store stub passes command -v but fails to execute)
|
|
PYTHON=""
|
|
for candidate in py python3 python; do
|
|
if command -v "$candidate" >/dev/null 2>&1; then
|
|
if "$candidate" -c "import sys; sys.exit(0)" >/dev/null 2>&1; then
|
|
PYTHON="$candidate"
|
|
break
|
|
fi
|
|
fi
|
|
done
|
|
if [ -z "$PYTHON" ]; then
|
|
echo -e "${RED}[ERROR]${NC} No Python interpreter found (tried: py, python3, python)"
|
|
exit 1
|
|
fi
|
|
|
|
# Load user identity
|
|
USER_DISPLAY="unknown"
|
|
USER_GITEA=""
|
|
if [ -f ".claude/identity.json" ]; then
|
|
USER_DISPLAY=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('full_name', d.get('user','unknown')))" 2>/dev/null || echo "unknown")
|
|
USER_GITEA=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "")
|
|
fi
|
|
echo -e "${GREEN}[OK]${NC} Syncing as: $USER_DISPLAY (machine: $MACHINE)"
|
|
|
|
# Phase 1a: Update submodules to latest remote
|
|
echo ""
|
|
echo "=== Phase 1a: Submodule update ==="
|
|
|
|
if [ -f ".gitmodules" ]; then
|
|
# Temporarily disable errexit — submodule ops emit non-fatal warnings that
|
|
# would otherwise kill the script under set -e.
|
|
set +e
|
|
git submodule foreach --quiet '
|
|
git fetch origin --quiet 2>/dev/null
|
|
git checkout main --quiet 2>/dev/null || git checkout master --quiet 2>/dev/null
|
|
git merge --ff-only origin/main --quiet 2>/dev/null || \
|
|
git merge --ff-only origin/master --quiet 2>/dev/null
|
|
' 2>/dev/null
|
|
set -e
|
|
echo -e "${GREEN}[OK]${NC} Submodules updated to latest remote (on branch)."
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} No submodules."
|
|
fi
|
|
|
|
# Phase 1: Local changes
|
|
echo ""
|
|
echo "=== Phase 1: Local changes ==="
|
|
|
|
# Detect any change including untracked-only (git diff-index ignores untracked files,
|
|
# so a brand-new file with no tracked changes would otherwise be silently skipped).
|
|
if [ -n "$(git status --porcelain)" ]; then
|
|
echo -e "${YELLOW}[INFO]${NC} Local changes detected:"
|
|
git status --short
|
|
echo ""
|
|
|
|
echo -e "${GREEN}[OK]${NC} Staging all changes..."
|
|
purge_garbled_paths
|
|
git add -A
|
|
|
|
# Commit message (Co-Authored-By uses local git user if configured)
|
|
COMMIT_MSG="sync: auto-sync from $MACHINE at $TIMESTAMP
|
|
|
|
Author: $USER_DISPLAY
|
|
Machine: $MACHINE
|
|
Timestamp: $TIMESTAMP"
|
|
|
|
if git diff-index --quiet --cached HEAD -- 2>/dev/null; then
|
|
echo -e "${GREEN}[OK]${NC} No stageable changes (submodule internal changes skipped)."
|
|
else
|
|
git commit -m "$COMMIT_MSG"
|
|
echo -e "${GREEN}[OK]${NC} Committed."
|
|
fi
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} No local changes to commit."
|
|
fi
|
|
|
|
# Phase 2: Remote sync
|
|
echo ""
|
|
echo "=== Phase 2: Fetch + inspect ==="
|
|
|
|
LOCAL_BEFORE=$(git rev-parse HEAD)
|
|
|
|
echo -e "${GREEN}[OK]${NC} Fetching from origin..."
|
|
git fetch origin --quiet
|
|
|
|
LOCAL=$(git rev-parse HEAD)
|
|
REMOTE=$(git rev-parse origin/main 2>/dev/null || git rev-parse origin/master 2>/dev/null || echo "$LOCAL")
|
|
REMOTE_BRANCH="origin/main"
|
|
if ! git rev-parse origin/main >/dev/null 2>&1; then
|
|
REMOTE_BRANCH="origin/master"
|
|
fi
|
|
|
|
# Count and show incoming
|
|
INCOMING_COUNT=$(git rev-list --count HEAD..$REMOTE_BRANCH 2>/dev/null || echo 0)
|
|
OUTGOING_COUNT=$(git rev-list --count $REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)
|
|
|
|
if [ "$INCOMING_COUNT" -gt 0 ]; then
|
|
echo ""
|
|
echo -e "${CYAN}--- Incoming: $INCOMING_COUNT commits from remote ---${NC}"
|
|
git log --oneline --format=' %C(yellow)%h%Creset %C(cyan)%an%Creset %s %C(dim)(%ar)%Creset' HEAD..$REMOTE_BRANCH | head -30
|
|
echo ""
|
|
echo -e "${CYAN}--- Files touched by incoming commits ---${NC}"
|
|
git diff --stat HEAD..$REMOTE_BRANCH | tail -20
|
|
|
|
# Wiki summary section
|
|
WIKI_CHANGES=$(git diff --name-status HEAD..$REMOTE_BRANCH -- 'wiki/*.md' 'wiki/*/*.md' 2>/dev/null || true)
|
|
if [ -n "$WIKI_CHANGES" ]; then
|
|
echo ""
|
|
echo -e "${CYAN}--- Wiki knowledge layer updates ---${NC}"
|
|
|
|
# Categorize by type
|
|
WIKI_CLIENTS=$(echo "$WIKI_CHANGES" | grep 'wiki/clients/' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/clients/||' | sed 's|\.md$||')
|
|
WIKI_PROJECTS=$(echo "$WIKI_CHANGES" | grep 'wiki/projects/' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/projects/||' | sed 's|\.md$||')
|
|
WIKI_SYSTEMS=$(echo "$WIKI_CHANGES" | grep 'wiki/systems/' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/systems/||' | sed 's|\.md$||')
|
|
WIKI_PATTERNS=$(echo "$WIKI_CHANGES" | grep 'wiki/patterns/' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/patterns/||' | sed 's|\.md$||')
|
|
WIKI_META=$(echo "$WIKI_CHANGES" | grep -E 'wiki/(index|overview)\.md' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/||' | sed 's|\.md$||')
|
|
|
|
[ -n "$WIKI_CLIENTS" ] && echo -e "${CYAN}Clients:${NC}\n$WIKI_CLIENTS"
|
|
[ -n "$WIKI_PROJECTS" ] && echo -e "${CYAN}Projects:${NC}\n$WIKI_PROJECTS"
|
|
[ -n "$WIKI_SYSTEMS" ] && echo -e "${CYAN}Systems:${NC}\n$WIKI_SYSTEMS"
|
|
[ -n "$WIKI_PATTERNS" ] && echo -e "${CYAN}Patterns:${NC}\n$WIKI_PATTERNS"
|
|
[ -n "$WIKI_META" ] && echo -e "${CYAN}Meta:${NC}\n$WIKI_META"
|
|
|
|
# Count by status
|
|
WIKI_ADDED=$(echo "$WIKI_CHANGES" | grep -c '^A' || echo 0)
|
|
WIKI_MODIFIED=$(echo "$WIKI_CHANGES" | grep -c '^M' || echo 0)
|
|
WIKI_DELETED=$(echo "$WIKI_CHANGES" | grep -c '^D' || echo 0)
|
|
|
|
echo -e "${CYAN}Summary:${NC} $WIKI_ADDED added, $WIKI_MODIFIED modified, $WIKI_DELETED deleted"
|
|
fi
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} No incoming changes."
|
|
fi
|
|
|
|
if [ "$OUTGOING_COUNT" -gt 0 ]; then
|
|
echo ""
|
|
echo -e "${CYAN}--- Outgoing: $OUTGOING_COUNT commits to remote ---${NC}"
|
|
git log --oneline --format=' %C(yellow)%h%Creset %C(cyan)%an%Creset %s %C(dim)(%ar)%Creset' $REMOTE_BRANCH..HEAD | head -30
|
|
fi
|
|
|
|
# Phase 3: Pull (if needed)
|
|
if [ "$INCOMING_COUNT" -gt 0 ]; then
|
|
echo ""
|
|
echo "=== Phase 3: Pull (rebase) ==="
|
|
if git pull origin main --rebase; then
|
|
echo -e "${GREEN}[OK]${NC} Pulled successfully."
|
|
else
|
|
echo -e "${RED}[ERROR]${NC} Pull failed (likely conflicts). Resolve and re-run sync."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Phase 4: Push (if needed)
|
|
OUTGOING_AFTER_PULL=$(git rev-list --count $REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)
|
|
if [ "$OUTGOING_AFTER_PULL" -gt 0 ]; then
|
|
echo ""
|
|
echo "=== Phase 4: Push ==="
|
|
if git push origin main; then
|
|
echo -e "${GREEN}[OK]${NC} Pushed successfully."
|
|
else
|
|
echo -e "${RED}[ERROR]${NC} Push failed. Check auth / network."
|
|
exit 1
|
|
fi
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} Nothing to push."
|
|
fi
|
|
|
|
# Phase 5: Scan pulled session logs for cross-user messages
|
|
# Look for "## Note for" or "## Message for" sections in any session log
|
|
# touched by incoming commits. Print them prominently so they aren't missed.
|
|
if [ "$INCOMING_COUNT" -gt 0 ] && [ -n "$LOCAL_BEFORE" ]; then
|
|
CHANGED_LOGS=$(git diff --name-only "$LOCAL_BEFORE"..HEAD -- '**/session-logs/*.md' 'session-logs/*.md' 2>/dev/null || true)
|
|
if [ -n "$CHANGED_LOGS" ]; then
|
|
NOTES_FOUND=0
|
|
for LOG_FILE in $CHANGED_LOGS; do
|
|
if [ -f "$LOG_FILE" ]; then
|
|
# Extract author from "## User" block and any "## Note for" / "## Message for" sections
|
|
NOTE_CONTENT=$(awk '
|
|
/^## (Note|Message) for /{ in_note=1; header=$0; next }
|
|
in_note && /^## /{ in_note=0 }
|
|
in_note{ buf=buf"\n"$0 }
|
|
END{ if(buf) print header buf }
|
|
' "$LOG_FILE")
|
|
if [ -n "$NOTE_CONTENT" ]; then
|
|
if [ "$NOTES_FOUND" -eq 0 ]; then
|
|
echo ""
|
|
echo -e "${YELLOW}============================================================${NC}"
|
|
echo -e "${YELLOW} MESSAGES FROM OTHER TEAM MEMBERS${NC}"
|
|
echo -e "${YELLOW}============================================================${NC}"
|
|
NOTES_FOUND=1
|
|
fi
|
|
LOG_AUTHOR=$(awk '/^- \*\*User:\*\*/{print; exit}' "$LOG_FILE" | sed 's/.*\*\*User:\*\* //')
|
|
LOG_DATE=$(basename "$LOG_FILE" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)
|
|
echo ""
|
|
echo -e "${YELLOW} From: ${LOG_AUTHOR:-unknown} | ${LOG_DATE:-unknown date} | ${LOG_FILE}${NC}"
|
|
echo -e "${YELLOW}------------------------------------------------------------${NC}"
|
|
echo "$NOTE_CONTENT"
|
|
fi
|
|
fi
|
|
done
|
|
if [ "$NOTES_FOUND" -gt 0 ]; then
|
|
echo ""
|
|
echo -e "${YELLOW}============================================================${NC}"
|
|
echo -e "${YELLOW} Address the above before continuing with other work.${NC}"
|
|
echo -e "${YELLOW}============================================================${NC}"
|
|
echo ""
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Phase 5b: Apply config — sync slash commands to the global Claude dir.
|
|
# The repo's .claude/commands/ is canonical; this keeps ~/.claude/commands current
|
|
# so the CLI loads the latest skills without a manual copy. One-way (repo -> global),
|
|
# idempotent (only copies new/changed files), and soft-fails so it never aborts a sync.
|
|
echo ""
|
|
echo "=== Phase 5b: Apply config (commands -> global) ==="
|
|
GLOBAL_CMD_DIR="$HOME/.claude/commands"
|
|
set +e
|
|
mkdir -p "$GLOBAL_CMD_DIR"
|
|
CMD_UPDATED=0
|
|
CMD_NAMES=""
|
|
for src in .claude/commands/*.md; do
|
|
[ -f "$src" ] || continue
|
|
dst="$GLOBAL_CMD_DIR/$(basename "$src")"
|
|
if [ ! -f "$dst" ] || ! cmp -s "$src" "$dst"; then
|
|
if cp "$src" "$dst"; then
|
|
CMD_UPDATED=$((CMD_UPDATED + 1))
|
|
CMD_NAMES="$CMD_NAMES $(basename "$src")"
|
|
fi
|
|
fi
|
|
done
|
|
set -e
|
|
if [ "$CMD_UPDATED" -gt 0 ]; then
|
|
echo -e "${GREEN}[OK]${NC} Commands synced to global: $CMD_UPDATED updated —$CMD_NAMES"
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} Global commands already current."
|
|
fi
|
|
|
|
# Phase 6: Vault sync
|
|
echo ""
|
|
echo "=== Phase 6: Vault sync ==="
|
|
|
|
VAULT_PATH=""
|
|
if [ -f ".claude/identity.json" ]; then
|
|
VAULT_PATH=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('vault_path',''))" 2>/dev/null || echo "")
|
|
fi
|
|
|
|
if [ -z "$VAULT_PATH" ]; then
|
|
echo -e "${YELLOW}[INFO]${NC} vault_path not set in identity.json — skipping vault sync."
|
|
elif [ ! -d "$VAULT_PATH/.git" ]; then
|
|
echo -e "${YELLOW}[WARNING]${NC} Vault path '$VAULT_PATH' is not a git repo — skipping."
|
|
else
|
|
CLAUDETOOLS_DIR=$(pwd)
|
|
cd "$VAULT_PATH"
|
|
echo -e "${GREEN}[OK]${NC} Vault: $VAULT_PATH"
|
|
|
|
# Commit any local vault changes (porcelain catches untracked-only too)
|
|
if [ -n "$(git status --porcelain)" ]; then
|
|
echo -e "${YELLOW}[INFO]${NC} Local vault changes detected — committing..."
|
|
purge_garbled_paths
|
|
git add -A
|
|
git commit -m "sync: auto-sync vault from $MACHINE at $TIMESTAMP"
|
|
echo -e "${GREEN}[OK]${NC} Vault committed."
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} No local vault changes."
|
|
fi
|
|
|
|
VAULT_LOCAL_BEFORE=$(git rev-parse HEAD)
|
|
git fetch origin --quiet
|
|
|
|
VAULT_REMOTE_BRANCH="origin/main"
|
|
if ! git rev-parse origin/main >/dev/null 2>&1; then
|
|
VAULT_REMOTE_BRANCH="origin/master"
|
|
fi
|
|
|
|
VAULT_INCOMING=$(git rev-list --count HEAD..$VAULT_REMOTE_BRANCH 2>/dev/null || echo 0)
|
|
VAULT_OUTGOING=$(git rev-list --count $VAULT_REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)
|
|
|
|
if [ "$VAULT_INCOMING" -gt 0 ]; then
|
|
echo -e "${CYAN}--- Vault: $VAULT_INCOMING incoming commit(s) ---${NC}"
|
|
git log --oneline --format=' %C(yellow)%h%Creset %C(cyan)%an%Creset %s %C(dim)(%ar)%Creset' HEAD..$VAULT_REMOTE_BRANCH | head -10
|
|
if git pull origin main --rebase; then
|
|
echo -e "${GREEN}[OK]${NC} Vault pulled."
|
|
else
|
|
echo -e "${RED}[ERROR]${NC} Vault pull failed — resolve conflicts manually in $VAULT_PATH."
|
|
fi
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} Vault: no incoming changes."
|
|
fi
|
|
|
|
VAULT_OUTGOING_AFTER=$(git rev-list --count $VAULT_REMOTE_BRANCH..HEAD 2>/dev/null || echo 0)
|
|
if [ "$VAULT_OUTGOING_AFTER" -gt 0 ]; then
|
|
if git push origin main; then
|
|
echo -e "${GREEN}[OK]${NC} Vault pushed ($VAULT_OUTGOING_AFTER commit(s))."
|
|
else
|
|
echo -e "${RED}[ERROR]${NC} Vault push failed."
|
|
fi
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} Vault: nothing to push."
|
|
fi
|
|
|
|
cd "$CLAUDETOOLS_DIR"
|
|
fi
|
|
|
|
# Phase 7: Summary
|
|
echo ""
|
|
echo "=== Sync Summary ==="
|
|
|
|
if [ "$INCOMING_COUNT" -gt 0 ]; then
|
|
INCOMING_AUTHORS=$(git log --format='%an' $LOCAL_BEFORE..HEAD 2>/dev/null | sort | uniq -c | sort -rn | awk '{printf "%s (%s), ", substr($0, index($0,$2)), $1}' | sed 's/, $//')
|
|
echo -e "${CYAN}Pulled in:${NC} $INCOMING_COUNT commit(s) — authors: ${INCOMING_AUTHORS:-unknown}"
|
|
fi
|
|
if [ "$OUTGOING_AFTER_PULL" -gt 0 ]; then
|
|
echo -e "${CYAN}Pushed out:${NC} $OUTGOING_AFTER_PULL commit(s) by $USER_DISPLAY"
|
|
fi
|
|
if [ "$INCOMING_COUNT" -eq 0 ] && [ "$OUTGOING_AFTER_PULL" -eq 0 ]; then
|
|
echo -e "${GREEN}Already in sync — no commits moved in either direction.${NC}"
|
|
fi
|
|
|
|
echo -e "${GREEN}[OK]${NC} HEAD: $(git log -1 --oneline)"
|
|
echo -e "${GREEN}[OK]${NC} Status: $(git status -sb | head -1)"
|
|
|
|
echo ""
|
|
echo -e "${GREEN}[SUCCESS]${NC} Sync complete."
|