Skip to content

Commit 3d6b83f

Browse files
committed
feat: add git hook auto-maintenance with quick health check and stale index cleanup
- Add `health:check --quick` CLI flag: skip VectorStore/strategy/snapshot scanning for 260x speedup (0.36s vs 1m37s) - Add `hub:cleanup-stale-indexes` CLI command: remove legacy index directories without current pointer or snapshots (dry-run/json supported) - Add `cleanupStaleIndexes()` export for programmatic cleanup - Add post-checkout/post-merge git hooks with three-layer protection: 60s debounce, --quick health check, 24h cleanup throttle - Hooks auto-install via `pnpm install` (prepare script) - Add 6 new tests covering quick mode, stale cleanup, and CLI integration
1 parent 3e27146 commit 3d6b83f

9 files changed

Lines changed: 633 additions & 11 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dev": "tsup src/index.ts src/mcp/main.ts src/scanner/index.ts --format esm --dts --out-dir dist --sourcemap --watch",
2929
"fmt": "biome check --write ./src",
3030
"test": "node scripts/run-tests.mjs source",
31+
"prepare": "bash scripts/setup-hooks.sh",
3132
"test:mcp-stdio": "pnpm build && node --import tsx --test tests/mcp-stdio.test.ts",
3233
"test:dist": "node scripts/run-tests.mjs dist",
3334
"delivery:all": "pnpm verify:delivery:artifacts && pnpm delivery:manifest && pnpm delivery:bundle && pnpm delivery:pr && pnpm delivery:team-update && pnpm delivery:runbook",

scripts/git-hooks/post-checkout

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env bash
2+
# ContextAtlas post-checkout hook — 后台自动检测并修复当前项目索引健康
3+
#
4+
# 触发条件:git checkout / git switch(分支切换)
5+
# 行为:后台异步执行,不阻塞 git 操作
6+
# 1. 快速健康检查 (--quick, ~300ms)
7+
# 2. 若状态异常,自动修复安全项
8+
# 3. 幽灵清理每 24 小时最多执行一次
9+
# 4. 输出写入 ~/.cache/contextatlas/hooks.log
10+
11+
# 快速路径:只对分支切换触发($3=1 表示分支切换)
12+
if [ "${3:-0}" != "1" ]; then
13+
exit 0
14+
fi
15+
16+
# 快速路径:检查是否有 contextatlas CLI
17+
if ! command -v contextatlas &>/dev/null; then
18+
exit 0
19+
fi
20+
21+
# 防抖:如果最近 60 秒内已触发过,跳过
22+
LOCK_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/contextatlas"
23+
mkdir -p "$LOCK_DIR"
24+
LOCK_FILE="$LOCK_DIR/.hook-debounce"
25+
if [ -f "$LOCK_FILE" ]; then
26+
LAST_RUN=$(stat -c %Y "$LOCK_FILE" 2>/dev/null || echo 0)
27+
NOW=$(date +%s)
28+
ELAPSED=$((NOW - LAST_RUN))
29+
if [ "$ELAPSED" -lt 60 ]; then
30+
exit 0
31+
fi
32+
fi
33+
touch "$LOCK_FILE"
34+
35+
# 后台异步执行,不阻塞 git
36+
nohup bash -c '
37+
LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/contextatlas"
38+
LOG_FILE="$LOG_DIR/hooks.log"
39+
CLEANUP_LOCK="$LOG_DIR/.cleanup-daily"
40+
41+
echo "[$(date -Iseconds)] post-checkout triggered (quick mode)" >> "$LOG_FILE"
42+
43+
HEALTH=$(contextatlas health:check --quick --json 2>>"$LOG_FILE") || {
44+
echo "[$(date -Iseconds)] health:check failed, skipping" >> "$LOG_FILE"
45+
exit 0
46+
}
47+
48+
STATUS=$(echo "$HEALTH" | node -e "
49+
try {
50+
const d = JSON.parse(require('"'"'fs'"'"').readFileSync('"'"'/dev/stdin'"'"','"'"'utf8'"'"'));
51+
process.stdout.write(d.overall?.status || '"'"'unknown'"'"');
52+
} catch { process.stdout.write('"'"'error'"'"'); }
53+
" 2>/dev/null) || STATUS="error"
54+
55+
if [ "$STATUS" = "healthy" ]; then
56+
echo "[$(date -Iseconds)] health: healthy, skipping" >> "$LOG_FILE"
57+
exit 0
58+
fi
59+
60+
echo "[$(date -Iseconds)] health: $STATUS, auto-maintaining..." >> "$LOG_FILE"
61+
62+
# 1. 幽灵清理(每 24 小时最多一次)
63+
NOW=$(date +%s)
64+
RUN_CLEANUP=false
65+
if [ -f "$CLEANUP_LOCK" ]; then
66+
LAST_CLEANUP=$(stat -c %Y "$CLEANUP_LOCK" 2>/dev/null || echo 0)
67+
ELAPSED=$((NOW - LAST_CLEANUP))
68+
if [ "$ELAPSED" -gt 86400 ]; then
69+
RUN_CLEANUP=true
70+
fi
71+
else
72+
RUN_CLEANUP=true
73+
fi
74+
75+
if [ "$RUN_CLEANUP" = true ]; then
76+
touch "$CLEANUP_LOCK"
77+
# 先检查有没有,再清理
78+
STALE_COUNT=$(contextatlas hub:cleanup-stale-indexes --dry-run --json 2>/dev/null | node -e "
79+
try {
80+
const d = JSON.parse(require('"'"'fs'"'"').readFileSync('"'"'/dev/stdin'"'"','"'"'utf8'"'"'));
81+
process.stdout.write(String(d.staleCount || 0));
82+
} catch { process.stdout.write('"'"'0'"'"'); }
83+
" 2>/dev/null || echo "0")
84+
85+
echo "[$(date -Iseconds)] stale check: $STALE_COUNT stale directories" >> "$LOG_FILE"
86+
87+
if [ "$STALE_COUNT" -gt 0 ]; then
88+
contextatlas hub:cleanup-ghost --mode tmp --json &>/dev/null
89+
contextatlas hub:cleanup-stale-indexes --json &>/dev/null
90+
echo "[$(date -Iseconds)] ghost cleanup done ($STALE_COUNT removed)" >> "$LOG_FILE"
91+
else
92+
contextatlas hub:cleanup-ghost --mode tmp --json &>/dev/null
93+
echo "[$(date -Iseconds)] ghost cleanup done (hub only, no stale dirs)" >> "$LOG_FILE"
94+
fi
95+
else
96+
echo "[$(date -Iseconds)] cleanup skipped (daily throttle)" >> "$LOG_FILE"
97+
fi
98+
99+
# 2. 重建当前项目 catalog
100+
contextatlas memory:rebuild-catalog &>/dev/null
101+
echo "[$(date -Iseconds)] catalog rebuild done" >> "$LOG_FILE"
102+
103+
# 3. 记录告警(最多 5 条)
104+
echo "$HEALTH" | node -e "
105+
try {
106+
const d = JSON.parse(require('"'"'fs'"'"').readFileSync('"'"'/dev/stdin'"'"','"'"'utf8'"'"'));
107+
const alerts = d.overall?.issues || [];
108+
alerts.slice(0, 5).forEach(a => console.log('"'"' alert: '"'"' + a));
109+
} catch {}
110+
" 2>/dev/null >> "$LOG_FILE" || true
111+
112+
echo "[$(date -Iseconds)] maintenance complete" >> "$LOG_FILE"
113+
' &>/dev/null &
114+
115+
exit 0

scripts/git-hooks/post-merge

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env bash
2+
# ContextAtlas post-merge hook — 后台自动检测并修复当前项目索引健康
3+
#
4+
# 触发条件:git pull 合并完成
5+
# 行为:后台异步执行,不阻塞 git 操作
6+
# 1. 快速健康检查 (--quick, ~300ms)
7+
# 2. 若状态异常,自动修复安全项
8+
# 3. 幽灵清理每 24 小时最多执行一次
9+
# 4. 输出写入 ~/.cache/contextatlas/hooks.log
10+
11+
# 快速路径:检查是否有 contextatlas CLI
12+
if ! command -v contextatlas &>/dev/null; then
13+
exit 0
14+
fi
15+
16+
# 防抖:如果最近 60 秒内已触发过,跳过
17+
LOCK_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/contextatlas"
18+
mkdir -p "$LOCK_DIR"
19+
LOCK_FILE="$LOCK_DIR/.hook-debounce"
20+
if [ -f "$LOCK_FILE" ]; then
21+
LAST_RUN=$(stat -c %Y "$LOCK_FILE" 2>/dev/null || echo 0)
22+
NOW=$(date +%s)
23+
ELAPSED=$((NOW - LAST_RUN))
24+
if [ "$ELAPSED" -lt 60 ]; then
25+
exit 0
26+
fi
27+
fi
28+
touch "$LOCK_FILE"
29+
30+
# 后台异步执行,不阻塞 git
31+
nohup bash -c '
32+
LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/contextatlas"
33+
LOG_FILE="$LOG_DIR/hooks.log"
34+
CLEANUP_LOCK="$LOG_DIR/.cleanup-daily"
35+
36+
echo "[$(date -Iseconds)] post-merge triggered (quick mode)" >> "$LOG_FILE"
37+
38+
HEALTH=$(contextatlas health:check --quick --json 2>>"$LOG_FILE") || {
39+
echo "[$(date -Iseconds)] health:check failed, skipping" >> "$LOG_FILE"
40+
exit 0
41+
}
42+
43+
STATUS=$(echo "$HEALTH" | node -e "
44+
try {
45+
const d = JSON.parse(require('"'"'fs'"'"').readFileSync('"'"'/dev/stdin'"'"','"'"'utf8'"'"'));
46+
process.stdout.write(d.overall?.status || '"'"'unknown'"'"');
47+
} catch { process.stdout.write('"'"'error'"'"'); }
48+
" 2>/dev/null) || STATUS="error"
49+
50+
if [ "$STATUS" = "healthy" ]; then
51+
echo "[$(date -Iseconds)] health: healthy, skipping" >> "$LOG_FILE"
52+
exit 0
53+
fi
54+
55+
echo "[$(date -Iseconds)] health: $STATUS, auto-maintaining..." >> "$LOG_FILE"
56+
57+
# 1. 幽灵清理(每 24 小时最多一次)
58+
NOW=$(date +%s)
59+
RUN_CLEANUP=false
60+
if [ -f "$CLEANUP_LOCK" ]; then
61+
LAST_CLEANUP=$(stat -c %Y "$CLEANUP_LOCK" 2>/dev/null || echo 0)
62+
ELAPSED=$((NOW - LAST_CLEANUP))
63+
if [ "$ELAPSED" -gt 86400 ]; then
64+
RUN_CLEANUP=true
65+
fi
66+
else
67+
RUN_CLEANUP=true
68+
fi
69+
70+
if [ "$RUN_CLEANUP" = true ]; then
71+
touch "$CLEANUP_LOCK"
72+
STALE_COUNT=$(contextatlas hub:cleanup-stale-indexes --dry-run --json 2>/dev/null | node -e "
73+
try {
74+
const d = JSON.parse(require('"'"'fs'"'"').readFileSync('"'"'/dev/stdin'"'"','"'"'utf8'"'"'));
75+
process.stdout.write(String(d.staleCount || 0));
76+
} catch { process.stdout.write('"'"'0'"'"'); }
77+
" 2>/dev/null || echo "0")
78+
79+
echo "[$(date -Iseconds)] stale check: $STALE_COUNT stale directories" >> "$LOG_FILE"
80+
81+
if [ "$STALE_COUNT" -gt 0 ]; then
82+
contextatlas hub:cleanup-ghost --mode tmp --json &>/dev/null
83+
contextatlas hub:cleanup-stale-indexes --json &>/dev/null
84+
echo "[$(date -Iseconds)] ghost cleanup done ($STALE_COUNT removed)" >> "$LOG_FILE"
85+
else
86+
contextatlas hub:cleanup-ghost --mode tmp --json &>/dev/null
87+
echo "[$(date -Iseconds)] ghost cleanup done (hub only, no stale dirs)" >> "$LOG_FILE"
88+
fi
89+
else
90+
echo "[$(date -Iseconds)] cleanup skipped (daily throttle)" >> "$LOG_FILE"
91+
fi
92+
93+
# 2. 重建当前项目 catalog
94+
contextatlas memory:rebuild-catalog &>/dev/null
95+
echo "[$(date -Iseconds)] catalog rebuild done" >> "$LOG_FILE"
96+
97+
# 3. 记录告警(最多 5 条)
98+
echo "$HEALTH" | node -e "
99+
try {
100+
const d = JSON.parse(require('"'"'fs'"'"').readFileSync('"'"'/dev/stdin'"'"','"'"'utf8'"'"'));
101+
const alerts = d.overall?.issues || [];
102+
alerts.slice(0, 5).forEach(a => console.log('"'"' alert: '"'"' + a));
103+
} catch {}
104+
" 2>/dev/null >> "$LOG_FILE" || true
105+
106+
echo "[$(date -Iseconds)] maintenance complete" >> "$LOG_FILE"
107+
' &>/dev/null &
108+
109+
exit 0

scripts/setup-hooks.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bash
2+
# 安装 ContextAtlas git hooks
3+
# 用法: bash scripts/setup-hooks.sh
4+
# 或通过 pnpm install 自动执行(package.json prepare 脚本)
5+
6+
set -euo pipefail
7+
8+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9+
HOOKS_DIR="$REPO_ROOT/.git/hooks"
10+
SOURCE_DIR="$REPO_ROOT/scripts/git-hooks"
11+
12+
if [ ! -d "$HOOKS_DIR" ]; then
13+
echo "⚠️ .git/hooks 不存在,跳过 hook 安装"
14+
exit 0
15+
fi
16+
17+
for hook in "$SOURCE_DIR"/*; do
18+
hook_name=$(basename "$hook")
19+
target="$HOOKS_DIR/$hook_name"
20+
21+
cp "$hook" "$target"
22+
chmod +x "$target"
23+
echo "✅ 已安装 git hook: $hook_name"
24+
done

src/cli/commands/hubProjects.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,41 @@ export function registerHubProjectCommands(cli: CommandRegistrar): void {
137137
}
138138
});
139139

140+
cli
141+
.command('hub:cleanup-stale-indexes', '清理无 current/snapshots 的遗留索引目录')
142+
.option('--dry-run', '仅预览,不执行删除')
143+
.option('--json', '以 JSON 输出结果')
144+
.action(async (options: { dryRun?: boolean; json?: boolean }) => {
145+
const { cleanupStaleIndexes } = await import('../../monitoring/indexHealth.js');
146+
const result = cleanupStaleIndexes({ dryRun: options.dryRun });
147+
148+
if (options.json) {
149+
writeJson({ mode: options.dryRun ? 'dry-run' : 'apply', ...result });
150+
return;
151+
}
152+
153+
if (options.dryRun) {
154+
logger.info(`遗留索引目录预览:`);
155+
logger.info(` 扫描目录数:${result.scanned}`);
156+
logger.info(` 可清理数:${result.staleCount}`);
157+
logger.info(` 可释放空间:${(result.freedBytes / 1024 / 1024).toFixed(1)} MB`);
158+
for (const p of result.staleProjects.slice(0, 20)) {
159+
logger.info(` - ${p.id} (${(p.sizeBytes / 1024).toFixed(0)} KB)`);
160+
}
161+
if (result.staleProjects.length > 20) {
162+
logger.info(` ... 及其他 ${result.staleProjects.length - 20} 个`);
163+
}
164+
logger.info('');
165+
logger.info('执行清理: contextatlas hub:cleanup-stale-indexes');
166+
return;
167+
}
168+
169+
logger.info(`遗留索引目录清理完成:`);
170+
logger.info(` 扫描目录数:${result.scanned}`);
171+
logger.info(` 删除目录数:${result.removedCount}`);
172+
logger.info(` 释放空间:${(result.freedBytes / 1024 / 1024).toFixed(1)} MB`);
173+
});
174+
140175
cli
141176
.command('hub:repair-project-identities', '修复历史项目 ID 到规范化路径派生 ID')
142177
.option('--dry-run', '仅输出将要执行的修复计划,不修改数据库')

src/cli/commands/opsHealth.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,16 @@ export function registerOpsHealthCommands(cli: CommandRegistrar): void {
3838
cli
3939
.command('health:check', '检查索引系统健康状态(队列/快照/守护进程)')
4040
.option('--project-id <id>', '按项目 ID 过滤')
41+
.option('--quick', '快速模式:跳过 VectorStore 和策略分析')
4142
.option('--json', '以 JSON 输出报告')
42-
.action(async (options: { projectId?: string; json?: boolean }) => {
43+
.action(async (options: { projectId?: string; quick?: boolean; json?: boolean }) => {
4344
const { analyzeIndexHealth, formatIndexHealthReport } = await import(
4445
'../../monitoring/indexHealth.js'
4546
);
4647
try {
4748
const report = await analyzeIndexHealth({
4849
projectIds: options.projectId ? [options.projectId] : undefined,
50+
quick: options.quick,
4951
});
5052
if (options.json) {
5153
writeJson(report);

0 commit comments

Comments
 (0)