Extract the per-machine concurrency lock from sync.sh into a sourceable
lib (.claude/scripts/sync-lock.sh) plus a `run <cmd>` wrapper that locks
the current repo (same lock-dir basename, so it mutually excludes with
sync.sh in the ClaudeTools repo and self-scopes in any project repo).
sync.sh now sources it (behavior identical — verified by review). /scc
routes its commit+push through the locked, rebase-safe sync.sh (and drops
the bare YYYY-MM-DD-session.md filename for the per-session-unique one).
/checkpoint now stages+commits atomically under the repo lock so a
concurrent session in a shared worktree can't be swept in. Closes the
remaining commit paths that bypassed the lock shipped in 6b0ce9a.
674 lines
29 KiB
Bash
Executable File
674 lines
29 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'
|
|
|
|
# --- Arg parsing -------------------------------------------------------------
|
|
# Submodule advance (fetch + checkout main + merge --ff-only origin/main) is
|
|
# expensive (full network fetch of every submodule) and is normally wasted work:
|
|
# per CLAUDE.md the guru-rmm pinned commit lagging `main` is EXPECTED, not stale.
|
|
# So it is opt-in. Default OFF. Enable with `--with-submodules` / `--submodules`
|
|
# on the command line, or `SYNC_SUBMODULES=1` in the environment. Fresh-clone
|
|
# init/populate (Phase 1a) always runs regardless — only the advance is gated.
|
|
ADVANCE_SUBMODULES=0
|
|
[ "${SYNC_SUBMODULES:-0}" = "1" ] && ADVANCE_SUBMODULES=1
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--with-submodules|--submodules) ADVANCE_SUBMODULES=1 ;;
|
|
esac
|
|
done
|
|
|
|
# --- 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
|
|
# POSIX ERE (not PCRE): `grep -P` refuses to run under non-UTF-8/unibyte locales on
|
|
# some platforms (Git Bash printed "grep: -P supports only unibyte and UTF-8 locales",
|
|
# silently disabling this guard). ERE has no such restriction; LC_ALL=C makes the match
|
|
# byte-wise. Same byte set as before: control chars / backslash / colon, plus the MSYS2
|
|
# Private-Use-Area substitutes (0xEE 0x80-0xBF = U+E0xx, 0xEF 0x80-0xA3 = U+F0xx) that
|
|
# stand in for : \ newline etc. in Windows path-as-filename cruft.
|
|
local garble_re=$'[\001-\037\\:]|\356[\200-\277]|\357[\200-\243]'
|
|
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 -qaE "$garble_re"; 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
|
|
}
|
|
|
|
# --- Attribution: force commit authorship to match identity.json -------------
|
|
# Commit author is the per-person source of truth and must NEVER ride on stale
|
|
# local `git config`. If config disagrees with identity.json, correct the LOCAL
|
|
# repo config (not --global) so this machine's commits are always attributed to
|
|
# the human who actually owns this identity. Call once per repo (claudetools,
|
|
# then vault) before any commit happens.
|
|
reconcile_git_identity() {
|
|
local want_name="$1" want_email="$2" cur
|
|
if [ -n "$want_name" ]; then
|
|
cur=$(git config user.name 2>/dev/null || true)
|
|
if [ "$cur" != "$want_name" ]; then
|
|
echo -e "${YELLOW}[WARNING]${NC} git user.name '${cur}' -> '${want_name}' (corrected to match identity.json)"
|
|
git config user.name "$want_name"
|
|
fi
|
|
fi
|
|
if [ -n "$want_email" ]; then
|
|
cur=$(git config user.email 2>/dev/null || true)
|
|
if [ "$cur" != "$want_email" ]; then
|
|
echo -e "${YELLOW}[WARNING]${NC} git user.email '${cur}' -> '${want_email}' (corrected to match identity.json)"
|
|
git config user.email "$want_email"
|
|
fi
|
|
fi
|
|
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
|
|
# Read from identity.json (machine-specific, set during onboarding)
|
|
IDENTITY_PATH=""
|
|
for candidate in "$HOME/.claude/identity.json" ".claude/identity.json"; do
|
|
if [ -f "$candidate" ]; then
|
|
IDENTITY_PATH="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [ -n "$IDENTITY_PATH" ] && command -v jq >/dev/null 2>&1; then
|
|
REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH" 2>/dev/null)
|
|
fi
|
|
|
|
# Fallback: git detection if identity.json doesn't have claudetools_root yet
|
|
if [ -z "$REPO_ROOT" ]; then
|
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
|
fi
|
|
|
|
if [ -z "$REPO_ROOT" ] || [ ! -d "$REPO_ROOT/.git" ]; then
|
|
echo -e "${RED}[ERROR]${NC} Cannot locate ClaudeTools repo. Add 'claudetools_root' to identity.json"
|
|
exit 1
|
|
fi
|
|
|
|
cd "$REPO_ROOT"
|
|
|
|
echo -e "${GREEN}[OK]${NC} Working directory: $(pwd)"
|
|
|
|
# --- Concurrency lock --------------------------------------------------------
|
|
# WHY: multiple sync runs on ONE machine must NOT overlap. An interactive /sync
|
|
# or /save can collide with the scheduled-task sync, or two concurrent Claude
|
|
# sessions can each stage + commit + fetch + rebase + push and interleave their
|
|
# git state — corrupting an in-progress rebase, orphaning commits, or pushing a
|
|
# half-built tree. We serialize the whole claudetools critical section (Phase 1a
|
|
# submodule update, staging, commit, fetch, rebase, push — and by extension the
|
|
# vault phase) behind a single per-machine lock.
|
|
#
|
|
# The lock primitive (mkdir-atomic lock, stale detection, ownership-checked
|
|
# release, exit-75-on-contention) lives in the SHAREABLE library sync-lock.sh so
|
|
# other commit paths (/scc, /checkpoint) can contend on the SAME lock dir. We
|
|
# set SYNC_LOCK_DIR explicitly, source the library (which defines the vars +
|
|
# functions but installs NO trap and acquires NOTHING on source), then install
|
|
# our own EXIT trap and acquire — exactly as before. We are already cd'd into
|
|
# REPO_ROOT, and the path is absolute, so the source resolves from any CWD.
|
|
SYNC_LOCK_DIR="$REPO_ROOT/.git/claudetools-sync.lock"
|
|
# shellcheck source=./sync-lock.sh
|
|
source "$REPO_ROOT/.claude/scripts/sync-lock.sh"
|
|
|
|
trap release_sync_lock EXIT INT TERM
|
|
acquire_sync_lock
|
|
echo -e "${GREEN}[OK]${NC} Acquired sync lock ($SYNC_LOCK_DIR)"
|
|
# --- end concurrency lock ----------------------------------------------------
|
|
|
|
# Detect Python interpreter — read from identity.json first, fall back to detection
|
|
PYTHON=""
|
|
if [ -f ".claude/identity.json" ] && command -v jq >/dev/null 2>&1; then
|
|
PYTHON=$(jq -r '.python.command // empty' ".claude/identity.json" 2>/dev/null)
|
|
fi
|
|
|
|
# Fallback: auto-detect if not in identity.json
|
|
if [ -z "$PYTHON" ]; then
|
|
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
|
|
fi
|
|
|
|
if [ -z "$PYTHON" ]; then
|
|
echo -e "${RED}[ERROR]${NC} No Python interpreter found. Run .claude/scripts/migrate-identity.sh to populate identity.json."
|
|
exit 1
|
|
fi
|
|
|
|
# Load user identity
|
|
USER_DISPLAY="unknown"
|
|
USER_GITEA=""
|
|
USER_EMAIL=""
|
|
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 "")
|
|
USER_EMAIL=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('email',''))" 2>/dev/null || echo "")
|
|
fi
|
|
echo -e "${GREEN}[OK]${NC} Syncing as: $USER_DISPLAY (machine: $MACHINE)"
|
|
|
|
# Force this repo's commit authorship to match identity.json before any commit.
|
|
# Skip if identity.json was unreadable (USER_DISPLAY left at the "unknown"
|
|
# sentinel) — overwriting correct config with "unknown" would be worse than
|
|
# leaving it alone, and is the exact mis-attribution this guard prevents.
|
|
if [ "$USER_DISPLAY" != "unknown" ]; then
|
|
reconcile_git_identity "$USER_DISPLAY" "$USER_EMAIL"
|
|
else
|
|
echo -e "${YELLOW}[WARNING]${NC} identity.json present but unreadable — leaving existing git config untouched (not reconciling)."
|
|
fi
|
|
|
|
# Warn (don't block) if identity.json's machine field disagrees with the real
|
|
# hostname — the classic "stale identity.json copied to a new box" failure that
|
|
# stamps logs/commits with the wrong machine. Compare case-insensitively and
|
|
# ignore a trailing ".local" (macOS).
|
|
if [ -f ".claude/identity.json" ]; then
|
|
ID_MACHINE=$($PYTHON -c "import json; print(json.load(open('.claude/identity.json')).get('machine',''))" 2>/dev/null || echo "")
|
|
norm() { printf '%s' "$1" | tr 'A-Z' 'a-z' | sed 's/\.local$//'; }
|
|
if [ -n "$ID_MACHINE" ] && [ "$(norm "$ID_MACHINE")" != "$(norm "$MACHINE")" ]; then
|
|
echo -e "${YELLOW}[WARNING]${NC} identity.json machine '${ID_MACHINE}' != hostname '${MACHINE}' — identity.json may be stale on this box. Fix it before attributing work."
|
|
fi
|
|
fi
|
|
|
|
# Phase 1a: Update submodules to latest remote
|
|
# `git submodule foreach` only visits *initialized* submodules, so a fresh clone
|
|
# would silently skip population and the old "[OK] updated" message lied. We now
|
|
# explicitly init+populate each submodule declared in .gitmodules, then advance
|
|
# it to its remote branch tip. Iterating .gitmodules (not the index) sidesteps
|
|
# any orphaned gitlink that lacks a .gitmodules mapping. Credentials are inherited
|
|
# from the parent origin URL so non-interactive init works without a credential
|
|
# helper; .gitmodules itself stays credential-free.
|
|
echo ""
|
|
echo "=== Phase 1a: Submodule update ==="
|
|
|
|
if [ -f ".gitmodules" ] && git config --file .gitmodules --get-regexp '^submodule\..*\.path$' >/dev/null 2>&1; then
|
|
# Temporarily disable errexit — submodule ops emit non-fatal warnings that
|
|
# would otherwise kill the script under set -e.
|
|
set +e
|
|
|
|
# If origin embeds credentials (https://user:pass@host/...), reuse its
|
|
# scheme+userinfo+host for submodule clones.
|
|
PARENT_URL="$(git config --get remote.origin.url)"
|
|
CRED_HOST=""
|
|
case "$PARENT_URL" in
|
|
https://*@*) CRED_HOST="$(printf '%s' "$PARENT_URL" | sed -E 's#^(https://[^/]+)/.*#\1#')" ;;
|
|
esac
|
|
|
|
SUB_COUNT=0
|
|
while read -r pkey ppath; do
|
|
[ -z "$ppath" ] && continue
|
|
name="$(printf '%s' "$pkey" | sed -E 's#^submodule\.(.*)\.path$#\1#')"
|
|
SUB_COUNT=$((SUB_COUNT + 1))
|
|
# Register in .git/config (no-op if already initialized).
|
|
git submodule init -- "$ppath" >/dev/null 2>&1
|
|
# Override the local clone URL with credentials when available.
|
|
if [ -n "$CRED_HOST" ]; then
|
|
gm_url="$(git config --file .gitmodules --get "submodule.${name}.url")"
|
|
case "$gm_url" in
|
|
https://*)
|
|
sub_path="$(printf '%s' "$gm_url" | sed -E 's#^https://[^/]+(/.*)#\1#')"
|
|
git config "submodule.${name}.url" "${CRED_HOST}${sub_path}"
|
|
;;
|
|
esac
|
|
fi
|
|
# Populate if missing (fresh clone) at the pinned commit.
|
|
git submodule update --init -- "$ppath" >/dev/null 2>&1
|
|
# Reconcile this submodule's git identity to match identity.json — same
|
|
# guarantee the parent repo gets at sync.sh:157-158. Without this, commits
|
|
# in newly-cloned submodules land under the system default (e.g.
|
|
# "<unix-user> @<hostname>") instead of the actual human. Skip if
|
|
# identity.json was unreadable (USER_DISPLAY left at "unknown").
|
|
# Drift incident that motivated this: youtube-sync-docker commits
|
|
# ef903c8 + fdff0a7 from GURU-KALI 2026-05-31.
|
|
if [ "$USER_DISPLAY" != "unknown" ]; then
|
|
(cd "$ppath" && reconcile_git_identity "$USER_DISPLAY" "$USER_EMAIL") || true
|
|
fi
|
|
done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$')
|
|
|
|
# Advance each initialized submodule to its remote branch tip. Opt-in only
|
|
# (see arg-parsing block near the top): a full network fetch + ff-merge of
|
|
# every submodule on every sync is normally wasted work and churns the
|
|
# pinned-commit pointer, which CLAUDE.md says SHOULD lag `main`.
|
|
if [ "$ADVANCE_SUBMODULES" = "1" ]; then
|
|
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 init+advanced (${SUB_COUNT} configured)."
|
|
else
|
|
set -e
|
|
echo -e "${YELLOW}[INFO]${NC} Submodule advance skipped (pinned commit lagging main is expected; pass --with-submodules to fetch+advance). ${SUB_COUNT} configured."
|
|
fi
|
|
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..."
|
|
# --no-recurse-submodules: parent fetch must NOT implicitly recurse. A transient
|
|
# dead gitlink in incoming parent history (force-pushed-out submodule commit)
|
|
# would otherwise abort the whole sync under `set -e`. Phase 1a already advanced
|
|
# submodules to their remote tips; a post-rebase `git submodule update` reconciles
|
|
# them to the new parent pointers.
|
|
git fetch origin --quiet --no-recurse-submodules
|
|
|
|
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) ==="
|
|
# --no-recurse-submodules: same reason as the fetch above — don't let a
|
|
# dead submodule ref in incoming history kill the parent rebase.
|
|
if git pull origin main --rebase --no-recurse-submodules; 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
|
|
|
|
# Submodules may have advanced via the parent's gitlinks. Update them to match,
|
|
# but tolerate per-submodule failures (e.g., transient dead refs in history) —
|
|
# the working tree is still useful even if one submodule can't catch up.
|
|
set +e
|
|
git submodule update --init --recursive --quiet 2>&1 | grep -v '^$' || true
|
|
SUB_RC=${PIPESTATUS[0]}
|
|
set -e
|
|
if [ "$SUB_RC" -ne 0 ]; then
|
|
echo -e "${YELLOW}[WARNING]${NC} One or more submodules failed to update — likely a transient dead ref. Parent repo is current; investigate the submodule manually if a pointer is wrong."
|
|
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"
|
|
|
|
# Same attribution guard for the vault repo (it has its own git config).
|
|
# Same "unknown" skip as the main repo — the main-repo warning already fired.
|
|
if [ "$USER_DISPLAY" != "unknown" ]; then
|
|
reconcile_git_identity "$USER_DISPLAY" "$USER_EMAIL"
|
|
fi
|
|
|
|
# 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)
|
|
# --no-recurse-submodules: vault doesn't use submodules today, but stay
|
|
# consistent with the main-repo fetch so this script remains robust if that
|
|
# ever changes.
|
|
git fetch origin --quiet --no-recurse-submodules
|
|
|
|
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 --no-recurse-submodules; 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: Pending To-Do Review
|
|
echo ""
|
|
echo "=== Phase 7: Pending To-Dos ==="
|
|
|
|
# Coord API endpoint — per-machine override in identity.json so off-network/VPN/
|
|
# Tailscale boxes can point elsewhere. Default to the on-LAN address for backward
|
|
# compat with existing machines that haven't been re-migrated yet.
|
|
COORD_API=""
|
|
if [ -f ".claude/identity.json" ]; then
|
|
COORD_API=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('coord_api',''))" 2>/dev/null || echo "")
|
|
fi
|
|
[ -z "$COORD_API" ] && COORD_API="http://172.16.3.30:8001" # default when identity.json doesn't define coord_api
|
|
TODO_USER=""
|
|
TODO_MACHINE="$MACHINE"
|
|
if [ -f ".claude/identity.json" ]; then
|
|
TODO_USER=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "")
|
|
fi
|
|
|
|
if [ -n "$TODO_USER" ]; then
|
|
TODO_JSON=$(curl -s --max-time 5 \
|
|
"${COORD_API}/api/coord/todos?for_user=${TODO_USER}&for_machine=${TODO_MACHINE}&status_filter=pending&limit=200" \
|
|
2>/dev/null || echo "[]")
|
|
|
|
TODO_COUNT=$($PYTHON -c "
|
|
import json, sys
|
|
try:
|
|
data = json.loads('''$TODO_JSON''')
|
|
print(len(data))
|
|
except:
|
|
print(0)
|
|
" 2>/dev/null || echo 0)
|
|
|
|
if [ "$TODO_COUNT" -gt 0 ]; then
|
|
echo -e "${YELLOW} $TODO_COUNT pending item(s) for ${TODO_USER}/${TODO_MACHINE}:${NC}"
|
|
$PYTHON - <<PYEOF
|
|
import json, sys
|
|
|
|
raw = """$TODO_JSON"""
|
|
try:
|
|
todos = json.loads(raw)
|
|
except Exception as e:
|
|
print(f" [WARNING] Could not parse todos: {e}")
|
|
sys.exit(0)
|
|
|
|
# Group by project_key; None -> "Personal"
|
|
groups = {}
|
|
for t in todos:
|
|
if t.get("parent_id"):
|
|
continue # sub-tasks shown under parent
|
|
key = t.get("project_key") or "Personal"
|
|
groups.setdefault(key, []).append(t)
|
|
|
|
# Build sub-task lookup
|
|
subtask_map = {}
|
|
for t in todos:
|
|
pid = t.get("parent_id")
|
|
if pid:
|
|
subtask_map.setdefault(pid, []).append(t)
|
|
|
|
CYAN = "\033[0;36m"
|
|
RESET = "\033[0m"
|
|
YELLOW= "\033[1;33m"
|
|
|
|
for group_name in sorted(groups.keys(), key=lambda x: (x == "Personal", x)):
|
|
print(f"\n {CYAN}[{group_name}]{RESET}")
|
|
for t in groups[group_name]:
|
|
assigned = ""
|
|
if t.get("assigned_to_user") and t["assigned_to_user"] != "$TODO_USER":
|
|
assigned = f" -> {t['assigned_to_user']}"
|
|
if t.get("assigned_to_machine") and t["assigned_to_machine"].upper() != "$TODO_MACHINE".upper():
|
|
assigned += f"/{t['assigned_to_machine']}"
|
|
auto = " [auto]" if t.get("auto_created") else ""
|
|
due = f" due:{t['due_at'][:16]}" if t.get("due_at") else ""
|
|
print(f" [ ] {t['text']}{auto}{due}{assigned} (id:{t['id'][:8]})")
|
|
for st in subtask_map.get(t["id"], []):
|
|
st_due = f" due:{st['due_at'][:16]}" if st.get("due_at") else ""
|
|
print(f" [ ] {st['text']}{st_due} (id:{st['id'][:8]})")
|
|
PYEOF
|
|
echo ""
|
|
else
|
|
echo -e "${GREEN}[OK]${NC} No pending to-dos."
|
|
fi
|
|
else
|
|
echo -e "${YELLOW}[INFO]${NC} No identity — skipping todo check."
|
|
fi
|
|
|
|
# Phase 8: 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."
|