1551 lines
48 KiB
Bash
1551 lines
48 KiB
Bash
#!/usr/bin/env bash
|
|
# commit-metric.sh — Cursor AI Commit Metric Collector (Bash port of commit-metric.go)
|
|
#
|
|
# Architecture: Two-phase execution triggered by post-commit git hook.
|
|
#
|
|
# Phase 1 ("start") — Runs synchronously in the hook (fast):
|
|
# 1. Checks if this is a normal commit (skips rebase/merge).
|
|
# 2. Gets the latest commit hash from git.
|
|
# 3. Writes commit info to a temp file.
|
|
# 4. Spawns itself as "continue" in a detached background process.
|
|
#
|
|
# Phase 2 ("continue") — Runs in background:
|
|
# 1. Polls Cursor DB until commit hash appears (500ms interval, 3 min max).
|
|
# 2. On match: builds request payload, sends to API.
|
|
# 3. On timeout: calls /error endpoint with commit hash.
|
|
# 4. Uploads dangling prompt metrics for repos matching the commit.
|
|
# 5. Retries failed requests from previous runs.
|
|
#
|
|
# Dependencies: bash 4+, sqlite3, jq, curl, git
|
|
|
|
set -euo pipefail
|
|
|
|
# ============================================================
|
|
# Dependency check
|
|
# ============================================================
|
|
|
|
check_dependencies() {
|
|
mkdir -p ~/bin
|
|
|
|
# Detect CPU architecture (Intel vs Apple Silicon)
|
|
ARCH=$(uname -m)
|
|
if [[ "$ARCH" == "arm64" ]]; then
|
|
JQ_URL="https://github.com/stedolan/jq/releases/latest/download/jq-macos-arm64"
|
|
elif [[ "$ARCH" == "x86_64" ]]; then
|
|
JQ_URL="https://github.com/stedolan/jq/releases/latest/download/jq-osx-amd64"
|
|
else
|
|
log_warn "Unsupported architecture: $ARCH"
|
|
return 1
|
|
fi
|
|
|
|
# Download jq if not installed
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
curl -fsSL -o ~/bin/jq "$JQ_URL"
|
|
chmod +x ~/bin/jq
|
|
fi
|
|
|
|
# Add to PATH
|
|
export PATH="$HOME/bin:$PATH"
|
|
|
|
# These should always be present on macOS/Linux
|
|
local missing=()
|
|
for cmd in sqlite3 curl git; do
|
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
|
missing+=("$cmd")
|
|
fi
|
|
done
|
|
if [ ${#missing[@]} -gt 0 ]; then
|
|
log_warn "Missing required dependencies: ${missing[*]}"
|
|
return 1
|
|
fi
|
|
}
|
|
# check_dependencies is called from main after parsing the command
|
|
|
|
# ============================================================
|
|
# Configuration
|
|
# ============================================================
|
|
|
|
# API endpoint to send commit metrics
|
|
API_ENDPOINT="https://cursor-server.meeshogcp.in/api/v1/add-commit-metrics"
|
|
|
|
# Error API endpoint (called on polling timeout)
|
|
ERROR_API_ENDPOINT="https://cursor-server.meeshogcp.in/api/v1/error"
|
|
|
|
# Set to true to skip API call and only save locally (for testing)
|
|
DRY_RUN=false
|
|
|
|
# Database configuration
|
|
DB_RELATIVE_PATH="Library/Application Support/Cursor/User/globalStorage/state.vscdb"
|
|
TABLE_NAME="ItemTable"
|
|
KEY_NAME="aiCodeTracking.recentCommit"
|
|
|
|
# SQLite configuration
|
|
BUSY_TIMEOUT_MS=3000
|
|
MAX_RETRIES=3
|
|
INITIAL_RETRY_DELAY_MS=500
|
|
MAX_RETRY_DELAY_MS=2000
|
|
|
|
# API retry configuration
|
|
API_MAX_ATTEMPTS=3
|
|
API_INITIAL_RETRY_DELAY_MS=1000
|
|
API_MAX_RETRY_DELAY_MS=5000
|
|
|
|
# DB polling configuration (post-commit: wait for Cursor to update DB)
|
|
COMMIT_POLL_INTERVAL_MS=10000
|
|
COMMIT_MAX_WAIT_S=120
|
|
|
|
# Storage paths (relative to $HOME)
|
|
FAILED_COMMITS_FILE=".cursor-metrics/commit-metric/failed.json"
|
|
METRICS_OUTPUT_DIR=".cursor-metrics/commit-metric/data"
|
|
TEMP_DIR=".cursor-metrics/commit-metric/tmp"
|
|
LOG_DIR_RELATIVE=".cursor-metrics/commit-metric/logs"
|
|
REBASE_MAP_FILE=".cursor-metrics/commit-metric/rebase-map.json"
|
|
|
|
# ---- Dangling prompt metrics configuration ----
|
|
PROMPT_API_ENDPOINT="https://cursor-server.meeshogcp.in/api/v1/add-prompt-metrics"
|
|
PROMPT_DB_TABLE="cursorDiskKV"
|
|
PROMPT_PERSISTENT_STORAGE_DIR=".cursor-metrics/prompt-metric/composer-partialDiffFates"
|
|
PROMPT_FAILED_REQUESTS_FILE=".cursor-metrics/prompt-metric/failed.json"
|
|
|
|
# ============================================================
|
|
# Logging
|
|
# ============================================================
|
|
|
|
LOG_FILE=""
|
|
|
|
setup_logging() {
|
|
local log_dir="${HOME}/${LOG_DIR_RELATIVE}"
|
|
mkdir -p "$log_dir" 2>/dev/null || true
|
|
LOG_FILE="${log_dir}/commit-metric.log"
|
|
}
|
|
|
|
# logWarn writes a timestamped warning to the log file with [commit-metric] prefix.
|
|
# Falls back to stderr if the log file is not available.
|
|
log_warn() {
|
|
local fmt_str="$1"; shift
|
|
local msg
|
|
# shellcheck disable=SC2059
|
|
msg=$(printf "$fmt_str" "$@")
|
|
local line
|
|
line="[commit-metric] $(date -u +"%Y-%m-%dT%H:%M:%S%z") ${msg}"
|
|
if [ -n "$LOG_FILE" ]; then
|
|
echo "$line" >> "$LOG_FILE" 2>/dev/null || echo "$line" >&2
|
|
else
|
|
echo "$line" >&2
|
|
fi
|
|
}
|
|
|
|
# ============================================================
|
|
# Utility functions
|
|
# ============================================================
|
|
|
|
# min_val returns the smaller of two integers
|
|
min_val() {
|
|
local a=$1 b=$2
|
|
if [ "$a" -lt "$b" ]; then echo "$a"; else echo "$b"; fi
|
|
}
|
|
|
|
# sleep_ms sleeps for N milliseconds
|
|
sleep_ms() {
|
|
local ms=$1
|
|
local secs
|
|
secs=$(awk "BEGIN { printf \"%.3f\", $ms / 1000 }")
|
|
sleep "$secs"
|
|
}
|
|
|
|
# get_db_path returns the full path to the Cursor state database
|
|
get_db_path() {
|
|
echo "${HOME}/${DB_RELATIVE_PATH}"
|
|
}
|
|
|
|
# ============================================================
|
|
# Value decoding
|
|
# ============================================================
|
|
|
|
# is_hex_string checks if a string is hex-encoded (even length, only hex chars)
|
|
is_hex_string() {
|
|
local s="$1"
|
|
local len=${#s}
|
|
if [ "$len" -eq 0 ] || [ $(( len % 2 )) -ne 0 ]; then
|
|
return 1
|
|
fi
|
|
# Check all characters are hex
|
|
if [[ "$s" =~ ^[0-9a-fA-F]+$ ]]; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# hex_decode reads hex from stdin and outputs raw bytes.
|
|
# Uses xxd, perl, or python3 (whichever is available).
|
|
hex_decode() {
|
|
if command -v xxd >/dev/null 2>&1; then
|
|
xxd -r -p
|
|
elif command -v perl >/dev/null 2>&1; then
|
|
perl -pe 's/(..)/chr(hex($1))/ge'
|
|
elif command -v python3 >/dev/null 2>&1; then
|
|
python3 -c "import sys,binascii; sys.stdout.buffer.write(binascii.unhexlify(sys.stdin.read().strip()))"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# decode_value decodes a raw DB value (could be JSON or hex-encoded) to JSON bytes.
|
|
# Mirrors Go's decodeValue function.
|
|
decode_value() {
|
|
local raw="$1"
|
|
if [ -z "$raw" ]; then
|
|
return 1
|
|
fi
|
|
# Check if valid JSON
|
|
if echo "$raw" | jq empty 2>/dev/null; then
|
|
echo "$raw"
|
|
return 0
|
|
fi
|
|
# Check if hex-encoded
|
|
if is_hex_string "$raw"; then
|
|
local decoded
|
|
if decoded=$(echo "$raw" | hex_decode 2>/dev/null) && [ -n "$decoded" ]; then
|
|
# Verify it's valid JSON (UTF-8 check implicit)
|
|
if echo "$decoded" | jq empty 2>/dev/null; then
|
|
echo "$decoded"
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# ============================================================
|
|
# DB helpers
|
|
# ============================================================
|
|
|
|
# read_db_value reads a single value from the DB by exact key.
|
|
# Uses typeof() to handle BLOB values safely (returns hex for BLOBs).
|
|
# PRAGMAs output is suppressed via .output /dev/null so it doesn't mix with query results.
|
|
read_db_value() {
|
|
local db_path="$1"
|
|
local table="$2"
|
|
local key="$3"
|
|
|
|
sqlite3 -readonly "$db_path" 2>/dev/null <<SQL
|
|
.output /dev/null
|
|
PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};
|
|
.output
|
|
SELECT CASE typeof(value) WHEN 'blob' THEN hex(value) ELSE value END FROM ${table} WHERE key = '${key}';
|
|
SQL
|
|
}
|
|
|
|
# read_db_value_with_retry wraps read_db_value with exponential-backoff retries.
|
|
read_db_value_with_retry() {
|
|
local db_path="$1"
|
|
local table="$2"
|
|
local key="$3"
|
|
local retry_delay=$INITIAL_RETRY_DELAY_MS
|
|
|
|
for attempt in $(seq 1 $MAX_RETRIES); do
|
|
local raw
|
|
raw=$(read_db_value "$db_path" "$table" "$key") && {
|
|
echo "$raw"
|
|
return 0
|
|
}
|
|
|
|
if [ "$attempt" -lt "$MAX_RETRIES" ]; then
|
|
log_warn "DB read attempt %d/%d failed for key '%s', retrying in %dms..." \
|
|
"$attempt" "$MAX_RETRIES" "$key" "$retry_delay"
|
|
sleep_ms "$retry_delay"
|
|
retry_delay=$(min_val $(( retry_delay * 2 )) $MAX_RETRY_DELAY_MS)
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# read_commit_data reads and parses the commit metric from the DB with retries.
|
|
# Returns empty/fails when no commit data exists.
|
|
read_commit_data() {
|
|
local db_path="$1"
|
|
local raw
|
|
raw=$(read_db_value_with_retry "$db_path" "$TABLE_NAME" "$KEY_NAME")
|
|
if [ -z "$raw" ]; then
|
|
return 1
|
|
fi
|
|
decode_value "$raw"
|
|
}
|
|
|
|
# read_repo_tracker_paths reads the repositoryTracker.paths key from the Cursor DB with retries.
|
|
read_repo_tracker_paths() {
|
|
local db_path="$1"
|
|
local raw
|
|
raw=$(read_db_value_with_retry "$db_path" "$TABLE_NAME" "repositoryTracker.paths")
|
|
if [ -z "$raw" ]; then
|
|
echo "{}"
|
|
return 0
|
|
fi
|
|
local decoded
|
|
decoded=$(decode_value "$raw") || { echo "{}"; return 0; }
|
|
echo "$decoded"
|
|
}
|
|
|
|
# ============================================================
|
|
# Temp file helpers
|
|
# ============================================================
|
|
|
|
# write_temp_file writes commit info to a temp JSON file for the background process.
|
|
# Prints the file path.
|
|
write_temp_file() {
|
|
local commit_data="$1"
|
|
local dir="${HOME}/${TEMP_DIR}"
|
|
mkdir -p "$dir"
|
|
|
|
local commit_hash
|
|
commit_hash=$(echo "$commit_data" | jq -r '.commitHash // "unknown"')
|
|
local timestamp_ns
|
|
timestamp_ns=$(date +%s%N 2>/dev/null || echo "$(date +%s)000000000")
|
|
local file_name="${commit_hash}_${timestamp_ns}.json"
|
|
local file_path="${dir}/${file_name}"
|
|
|
|
echo "$commit_data" > "$file_path"
|
|
echo "$file_path"
|
|
}
|
|
|
|
# ============================================================
|
|
# Git helpers
|
|
# ============================================================
|
|
|
|
# getGitEmail retrieves the user's email from git config
|
|
get_git_email() {
|
|
local email
|
|
email=$(git config --get user.email 2>/dev/null || true)
|
|
if [ -z "$email" ]; then
|
|
email=$(git config --global --get user.email 2>/dev/null || true)
|
|
fi
|
|
echo "$email"
|
|
}
|
|
|
|
# epochMsToUTCString converts epoch milliseconds to UTC string with ms precision.
|
|
# Output format: "2006-01-02T15:04:05.000Z"
|
|
epoch_ms_to_utc_string() {
|
|
local epoch_ms="$1"
|
|
if [ -z "$epoch_ms" ] || [ "$epoch_ms" = "0" ] || [ "$epoch_ms" = "null" ]; then
|
|
date -u +"%Y-%m-%dT%H:%M:%S.000Z"
|
|
return
|
|
fi
|
|
|
|
local seconds=$(( epoch_ms / 1000 ))
|
|
local millis=$(( epoch_ms % 1000 ))
|
|
local millis_padded
|
|
millis_padded=$(printf "%03d" "$millis")
|
|
|
|
local formatted
|
|
# GNU date
|
|
if formatted=$(date -u -d "@${seconds}" +"%Y-%m-%dT%H:%M:%S" 2>/dev/null); then
|
|
echo "${formatted}.${millis_padded}Z"
|
|
# BSD/macOS date
|
|
elif formatted=$(date -u -r "${seconds}" +"%Y-%m-%dT%H:%M:%S" 2>/dev/null); then
|
|
echo "${formatted}.${millis_padded}Z"
|
|
else
|
|
date -u +"%Y-%m-%dT%H:%M:%S.000Z"
|
|
fi
|
|
}
|
|
|
|
# toUTCString parses any time string (e.g., git's ISO 8601 with timezone) and
|
|
# converts it to UTC with ms precision. Returns empty string on parse failure.
|
|
to_utc_string() {
|
|
local ts="$1"
|
|
if [ -z "$ts" ]; then
|
|
echo ""
|
|
return
|
|
fi
|
|
|
|
local parsed
|
|
# GNU date: handles "+05:30" colon timezone natively
|
|
if parsed=$(date -u -d "$ts" +"%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null); then
|
|
echo "$parsed"
|
|
return
|
|
fi
|
|
|
|
# BSD/macOS date: %z expects "+0530" not "+05:30", so strip the colon
|
|
# from the timezone offset before parsing.
|
|
# "2026-02-15T01:26:12+05:30" -> "2026-02-15T01:26:12+0530"
|
|
local ts_nocolon="$ts"
|
|
if [[ "$ts" =~ ^(.+)([+-][0-9]{2}):([0-9]{2})$ ]]; then
|
|
ts_nocolon="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${BASH_REMATCH[3]}"
|
|
fi
|
|
if parsed=$(date -u -jf "%Y-%m-%dT%H:%M:%S%z" "$ts_nocolon" +"%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null); then
|
|
echo "$parsed"
|
|
return
|
|
fi
|
|
|
|
# Return as-is if unparseable
|
|
echo "$ts"
|
|
}
|
|
|
|
# is_normal_commit returns 0 for normal commits, 1 for rebase/merge/cherry-pick.
|
|
# For rebase and cherry-pick, records the original→replayed hash mapping before skipping.
|
|
is_normal_commit() {
|
|
local git_dir
|
|
git_dir=$(git rev-parse --git-dir 2>/dev/null) || return 1
|
|
|
|
# Skip during rebase (interactive or non-interactive)
|
|
if [ -d "${git_dir}/rebase-merge" ] || [ -d "${git_dir}/rebase-apply" ]; then
|
|
local original_hash=""
|
|
if [ -d "${git_dir}/rebase-merge" ] && [ -f "${git_dir}/rebase-merge/done" ]; then
|
|
original_hash=$(tail -1 "${git_dir}/rebase-merge/done" 2>/dev/null | awk '{print $2}')
|
|
fi
|
|
if [ -z "$original_hash" ] && [ -f "${git_dir}/rebase-apply/original-commit" ]; then
|
|
original_hash=$(cat "${git_dir}/rebase-apply/original-commit" 2>/dev/null | tr -d '[:space:]')
|
|
fi
|
|
[ -n "$original_hash" ] && record_commit_hash_mapping "$original_hash"
|
|
return 1
|
|
fi
|
|
|
|
# Skip during cherry-pick (CHERRY_PICK_HEAD exists until post-commit cleanup)
|
|
if [ -f "${git_dir}/CHERRY_PICK_HEAD" ]; then
|
|
local original_hash
|
|
original_hash=$(cat "${git_dir}/CHERRY_PICK_HEAD" 2>/dev/null | tr -d '[:space:]')
|
|
[ -n "$original_hash" ] && record_commit_hash_mapping "$original_hash"
|
|
return 1
|
|
fi
|
|
|
|
# Skip merge commits (HEAD has more than 1 parent)
|
|
if git rev-parse HEAD^2 >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# record_commit_hash_mapping saves replayed_hash→original_hash mapping.
|
|
# Used by rebase and cherry-pick to track which original commit was replayed.
|
|
# Stored at ~/<REBASE_MAP_FILE> as a JSON object keyed by replayed hash.
|
|
record_commit_hash_mapping() {
|
|
local original_hash="$1"
|
|
|
|
# Resolve short hash to full hash
|
|
local full_hash
|
|
full_hash=$(git rev-parse "$original_hash" 2>/dev/null) || full_hash="$original_hash"
|
|
original_hash="$full_hash"
|
|
|
|
local replayed_hash
|
|
replayed_hash=$(git rev-parse HEAD 2>/dev/null) || return 0
|
|
|
|
local repo_path
|
|
repo_path=$(git rev-parse --show-toplevel 2>/dev/null) || return 0
|
|
local repo_name
|
|
repo_name=$(get_repo_name_from_path "$repo_path")
|
|
local git_dir
|
|
git_dir=$(git -C "$repo_path" rev-parse --git-dir 2>/dev/null) || return 0
|
|
local branch_name=""
|
|
if [ -f "${git_dir}/rebase-merge/head-name" ]; then
|
|
branch_name=$(cat "${git_dir}/rebase-merge/head-name" 2>/dev/null | sed 's|^refs/heads/||')
|
|
elif [ -f "${git_dir}/rebase-apply/head-name" ]; then
|
|
branch_name=$(cat "${git_dir}/rebase-apply/head-name" 2>/dev/null | sed 's|^refs/heads/||')
|
|
fi
|
|
if [ -z "$branch_name" ]; then
|
|
branch_name=$(git -C "$repo_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
|
fi
|
|
|
|
local map_file="${HOME}/${REBASE_MAP_FILE}"
|
|
mkdir -p "$(dirname "$map_file")" 2>/dev/null || true
|
|
|
|
local current_map="{}"
|
|
if [ -f "$map_file" ]; then
|
|
current_map=$(cat "$map_file" 2>/dev/null) || current_map="{}"
|
|
if ! echo "$current_map" | jq empty 2>/dev/null; then
|
|
current_map="{}"
|
|
fi
|
|
fi
|
|
|
|
current_map=$(echo "$current_map" | jq \
|
|
--arg replayed "$replayed_hash" \
|
|
--arg orig "$original_hash" \
|
|
--arg repo "$repo_name" \
|
|
--arg branch "$branch_name" \
|
|
'. + {($replayed): {original: $orig, repo: $repo, branch: $branch}}')
|
|
|
|
echo "$current_map" | jq '.' > "$map_file" 2>/dev/null || true
|
|
|
|
log_warn "commit mapping recorded: %s → %s (%s)" "$replayed_hash" "$original_hash" "$repo_name"
|
|
}
|
|
|
|
# getRepoNameFromPath tries git remote origin URL first, falls back to basename.
|
|
get_repo_name_from_path() {
|
|
local root_path="$1"
|
|
|
|
local url
|
|
url=$(git -C "$root_path" remote get-url origin 2>/dev/null || true)
|
|
if [ -n "$url" ]; then
|
|
local name
|
|
name=$(parse_repo_name_from_url "$url")
|
|
if [ -n "$name" ]; then
|
|
echo "$name"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
basename "$root_path"
|
|
}
|
|
|
|
# parseRepoNameFromURL extracts "org/repo" from a git remote URL.
|
|
parse_repo_name_from_url() {
|
|
local raw_url="$1"
|
|
|
|
# SSH: git@github.com:org/repo.git
|
|
if [[ "$raw_url" == git@* ]]; then
|
|
local after_colon="${raw_url#*:}"
|
|
after_colon="${after_colon%.git}"
|
|
echo "$after_colon"
|
|
return
|
|
fi
|
|
|
|
# HTTPS: https://github.com/org/repo.git
|
|
raw_url="${raw_url%.git}"
|
|
local second_last last
|
|
last=$(basename "$raw_url")
|
|
second_last=$(basename "$(dirname "$raw_url")")
|
|
if [ -n "$second_last" ] && [ -n "$last" ]; then
|
|
echo "${second_last}/${last}"
|
|
return
|
|
fi
|
|
}
|
|
|
|
# ============================================================
|
|
# Repo path resolution via Cursor's repositoryTracker.paths
|
|
# ============================================================
|
|
|
|
# resolve_repo_local_path finds the local filesystem path for a repo name
|
|
# by searching Cursor's repositoryTracker.paths.
|
|
#
|
|
# Matching: CursorCommitData.RepoName (e.g. "meesho/cursor-metrics-instrumentation")
|
|
# is matched case-insensitively against tracker keys (e.g. "github.com/meesho/cursor-metrics-instrumentation")
|
|
# using suffix matching.
|
|
resolve_repo_local_path() {
|
|
local repo_name="$1"
|
|
local tracker_paths_json="$2"
|
|
|
|
if [ -z "$repo_name" ] || [ "$tracker_paths_json" = "{}" ] || [ -z "$tracker_paths_json" ]; then
|
|
echo ""
|
|
return
|
|
fi
|
|
|
|
local repo_name_lower
|
|
repo_name_lower=$(echo "$repo_name" | tr '[:upper:]' '[:lower:]')
|
|
|
|
# Iterate tracker paths keys and find suffix match
|
|
local result
|
|
result=$(echo "$tracker_paths_json" | jq -r --arg rn "$repo_name_lower" '
|
|
to_entries[] |
|
|
select(
|
|
(.key | ascii_downcase) as $k |
|
|
($k | endswith("/" + $rn)) or ($k == $rn)
|
|
) | .value.localPath // empty
|
|
' 2>/dev/null | head -1)
|
|
|
|
if [ -n "$result" ]; then
|
|
# Remove file:// prefix
|
|
echo "${result#file://}"
|
|
fi
|
|
}
|
|
|
|
# ============================================================
|
|
# Convert to request
|
|
# ============================================================
|
|
|
|
# convertToRequest converts CursorDB data to the server request format.
|
|
# repo_path is passed directly from run_continue (known from the post-commit hook).
|
|
convert_to_request() {
|
|
local commit_data_json="$1"
|
|
local repo_path="$2"
|
|
|
|
# Get user email from git config
|
|
local email
|
|
email=$(get_git_email)
|
|
if [ -z "$email" ]; then
|
|
log_warn "could not determine git user email"
|
|
return 1
|
|
fi
|
|
|
|
# Extract fields from commit data
|
|
local commit_hash repo_name branch_name
|
|
local tab_lines_added tab_lines_deleted composer_lines_added composer_lines_deleted
|
|
local lines_added lines_deleted
|
|
|
|
commit_hash=$(echo "$commit_data_json" | jq -r '.commitHash // ""')
|
|
repo_name=$(echo "$commit_data_json" | jq -r '.repoName // ""')
|
|
branch_name=$(echo "$commit_data_json" | jq -r '.branchName // ""')
|
|
tab_lines_added=$(echo "$commit_data_json" | jq -r '.tabLinesAdded // 0')
|
|
tab_lines_deleted=$(echo "$commit_data_json" | jq -r '.tabLinesDeleted // 0')
|
|
composer_lines_added=$(echo "$commit_data_json" | jq -r '.composerLinesAdded // 0')
|
|
composer_lines_deleted=$(echo "$commit_data_json" | jq -r '.composerLinesDeleted // 0')
|
|
lines_added=$(echo "$commit_data_json" | jq -r '.linesAdded // 0')
|
|
lines_deleted=$(echo "$commit_data_json" | jq -r '.linesDeleted // 0')
|
|
|
|
# Get commit timestamp from git
|
|
local timestamp_str=""
|
|
if [ -n "$commit_hash" ]; then
|
|
local git_ts
|
|
git_ts=$(git -C "$repo_path" log -1 --format="%aI" "$commit_hash" 2>/dev/null || true)
|
|
if [ -n "$git_ts" ]; then
|
|
timestamp_str=$(to_utc_string "$git_ts")
|
|
fi
|
|
fi
|
|
|
|
# Get parent commit timestamp from git
|
|
local parent_timestamp=""
|
|
if [ -n "$commit_hash" ]; then
|
|
local parent_ts
|
|
parent_ts=$(git -C "$repo_path" log -1 --format="%aI" "${commit_hash}~1" 2>/dev/null || true)
|
|
if [ -n "$parent_ts" ]; then
|
|
parent_timestamp=$(to_utc_string "$parent_ts")
|
|
else
|
|
parent_timestamp="$timestamp_str"
|
|
fi
|
|
fi
|
|
|
|
# Build request JSON
|
|
jq -n \
|
|
--arg email "$email" \
|
|
--arg commit_hash "$commit_hash" \
|
|
--arg timestamp "$timestamp_str" \
|
|
--arg parent_commit_timestamp "$parent_timestamp" \
|
|
--arg repo "$repo_name" \
|
|
--arg branch "$branch_name" \
|
|
--argjson tabLinesAdded "$tab_lines_added" \
|
|
--argjson tabLinesDeleted "$tab_lines_deleted" \
|
|
--argjson composerLinesAdded "$composer_lines_added" \
|
|
--argjson composerLinesDeleted "$composer_lines_deleted" \
|
|
--argjson linesAdded "$lines_added" \
|
|
--argjson linesDeleted "$lines_deleted" \
|
|
'{
|
|
email: $email,
|
|
commit_hash: $commit_hash,
|
|
timestamp: $timestamp,
|
|
parent_commit_timestamp: $parent_commit_timestamp,
|
|
repo: $repo,
|
|
branch: $branch,
|
|
tabLinesAdded: $tabLinesAdded,
|
|
tabLinesDeleted: $tabLinesDeleted,
|
|
composerLinesAdded: $composerLinesAdded,
|
|
composerLinesDeleted: $composerLinesDeleted,
|
|
linesAdded: $linesAdded,
|
|
linesDeleted: $linesDeleted,
|
|
metadata: null
|
|
}'
|
|
}
|
|
|
|
# ============================================================
|
|
# Local metrics storage
|
|
# ============================================================
|
|
|
|
# saveMetricsLocally saves the commit metrics to a local JSON file.
|
|
# Path: ~/<metricsOutputDir>/<commitHash>.json
|
|
save_metrics_locally() {
|
|
local request_json="$1"
|
|
|
|
local dir="${HOME}/${METRICS_OUTPUT_DIR}"
|
|
mkdir -p "$dir"
|
|
|
|
local commit_hash
|
|
commit_hash=$(echo "$request_json" | jq -r '.commit_hash // "unknown"')
|
|
local file_path="${dir}/${commit_hash}.json"
|
|
|
|
# Idempotent — skip if already written
|
|
if [ -f "$file_path" ]; then
|
|
printf "Metrics already saved locally: %s\n" "$file_path"
|
|
return 0
|
|
fi
|
|
|
|
echo "$request_json" | jq '.' > "$file_path"
|
|
printf "Metrics saved locally: %s\n" "$file_path"
|
|
}
|
|
|
|
# ============================================================
|
|
# Failed requests persistence
|
|
# ============================================================
|
|
|
|
get_failed_commits_path() {
|
|
echo "${HOME}/${FAILED_COMMITS_FILE}"
|
|
}
|
|
|
|
# load_failed_commits reads previously failed commits from the cache file.
|
|
# Concurrency is handled by the caller via acquire_lock.
|
|
load_failed_commits() {
|
|
local path
|
|
path=$(get_failed_commits_path)
|
|
if [ ! -f "$path" ]; then
|
|
echo "[]"
|
|
return
|
|
fi
|
|
|
|
local data
|
|
data=$(cat "$path" 2>/dev/null || true)
|
|
|
|
if [ -n "$data" ] && echo "$data" | jq empty 2>/dev/null; then
|
|
echo "$data"
|
|
else
|
|
echo "[]"
|
|
fi
|
|
}
|
|
|
|
# saveFailedCommits writes the failed batch to the cache file.
|
|
# Pass empty or "[]" to clear the file (on success).
|
|
save_failed_commits() {
|
|
local commits_json="$1"
|
|
local path
|
|
path=$(get_failed_commits_path)
|
|
|
|
if [ -z "$commits_json" ] || [ "$commits_json" = "[]" ] || [ "$commits_json" = "null" ]; then
|
|
rm -f "$path" 2>/dev/null || true
|
|
return
|
|
fi
|
|
|
|
mkdir -p "$(dirname "$path")" 2>/dev/null || true
|
|
echo "$commits_json" | jq '.' > "$path" 2>/dev/null || true
|
|
}
|
|
|
|
# ============================================================
|
|
# Lock helpers
|
|
# ============================================================
|
|
|
|
CONTINUE_LOCK_DIR="${HOME}/.cursor-metrics/commit-metric/continue.lock"
|
|
DANGLING_LOCK_DIR="${HOME}/.cursor-metrics/prompt-metric/continue.lock"
|
|
|
|
acquire_lock() {
|
|
local lock_dir="$1"
|
|
mkdir -p "$(dirname "$lock_dir")" 2>/dev/null || true
|
|
|
|
local poll_ms=500
|
|
local stale_threshold_s=120
|
|
local max_wait_s=180
|
|
local start_time
|
|
start_time=$(date +%s)
|
|
|
|
while ! mkdir "$lock_dir" 2>/dev/null; do
|
|
local now
|
|
now=$(date +%s)
|
|
|
|
if [ $(( now - start_time )) -gt "$max_wait_s" ]; then
|
|
log_warn "lock wait exceeded %ds, force-removing: %s" "$max_wait_s" "$lock_dir"
|
|
rmdir "$lock_dir" 2>/dev/null || true
|
|
continue
|
|
fi
|
|
|
|
if [ -d "$lock_dir" ]; then
|
|
local lock_mtime
|
|
if lock_mtime=$(stat -f "%m" "$lock_dir" 2>/dev/null) ||
|
|
lock_mtime=$(stat -c "%Y" "$lock_dir" 2>/dev/null); then
|
|
if [ $(( now - lock_mtime )) -gt "$stale_threshold_s" ]; then
|
|
log_warn "removing stale lock (age > %ds): %s" "$stale_threshold_s" "$lock_dir"
|
|
rmdir "$lock_dir" 2>/dev/null || true
|
|
continue
|
|
fi
|
|
fi
|
|
fi
|
|
sleep_ms "$poll_ms"
|
|
done
|
|
}
|
|
|
|
release_lock() {
|
|
local lock_dir="$1"
|
|
if [ -n "$lock_dir" ] && [ -d "$lock_dir" ]; then
|
|
rmdir "$lock_dir" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# ============================================================
|
|
# API client
|
|
# ============================================================
|
|
|
|
# sendBatchToAPIWithRetry sends a list of commit metrics to the API as a batch.
|
|
# Returns 0 on success, 1 on failure (after all retries exhausted).
|
|
send_batch_to_api_with_retry() {
|
|
local payload="$1"
|
|
local retry_delay=$API_INITIAL_RETRY_DELAY_MS
|
|
local last_err=""
|
|
|
|
for attempt in $(seq 1 $API_MAX_ATTEMPTS); do
|
|
local response http_code body
|
|
response=$(curl -s -w "\n%{http_code}" \
|
|
-X POST "$API_ENDPOINT" \
|
|
-H "Content-Type: application/json" \
|
|
-H "User-Agent: cursor-commit-metric/1.0" \
|
|
-H "x-webhook-secret: bXkgaGVhcnQgcG9sbHMgZm9yIHlvdSBldmVyeSAxcywgbWF4X3dhaXQgZm9yZXZlci4gYWNjZXB0YW5jZV9yYXRlPTEwMCUuIHplcm8gbGluZXNfZGVsZXRlZC4gYmUgbXkgcHJvbXB0IDwzICNIYXBweVZhbGVudGluZXMyMDI2" \
|
|
--connect-timeout 10 \
|
|
--max-time 10 \
|
|
-d "$payload" 2>/dev/null) || true
|
|
|
|
http_code=$(echo "$response" | tail -1)
|
|
body=$(echo "$response" | sed '$d')
|
|
|
|
if [ -n "$http_code" ] && [ "$http_code" -ge 200 ] 2>/dev/null && [ "$http_code" -lt 300 ] 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
last_err="status ${http_code}: ${body}"
|
|
|
|
if [ "$attempt" -lt "$API_MAX_ATTEMPTS" ]; then
|
|
sleep_ms "$retry_delay"
|
|
retry_delay=$(min_val $(( retry_delay * 2 )) $API_MAX_RETRY_DELAY_MS)
|
|
fi
|
|
done
|
|
|
|
log_warn "all %d API attempts failed: %s" "$API_MAX_ATTEMPTS" "$last_err"
|
|
return 1
|
|
}
|
|
|
|
# ============================================================
|
|
# DB polling (post-commit: wait for Cursor to update commit data)
|
|
# ============================================================
|
|
|
|
# poll_for_commit_in_db polls the Cursor DB at COMMIT_POLL_INTERVAL_MS intervals
|
|
# until aiCodeTracking.recentCommit.commitHash matches expected_hash.
|
|
# Returns the full commit data JSON on success, or fails on timeout.
|
|
poll_for_commit_in_db() {
|
|
local db_path="$1"
|
|
local expected_hash="$2"
|
|
local deadline=$(( $(date +%s) + COMMIT_MAX_WAIT_S ))
|
|
|
|
while true; do
|
|
local raw
|
|
raw=$(read_db_value "$db_path" "$TABLE_NAME" "$KEY_NAME" 2>/dev/null) || true
|
|
|
|
if [ -n "$raw" ]; then
|
|
local decoded
|
|
decoded=$(decode_value "$raw" 2>/dev/null) || true
|
|
|
|
if [ -n "$decoded" ]; then
|
|
local db_hash
|
|
db_hash=$(echo "$decoded" | jq -r '.commitHash // ""' 2>/dev/null) || true
|
|
|
|
if [ "$db_hash" = "$expected_hash" ]; then
|
|
echo "$decoded"
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
return 1
|
|
fi
|
|
sleep_ms "$COMMIT_POLL_INTERVAL_MS"
|
|
done
|
|
}
|
|
|
|
# ============================================================
|
|
# Error API (called on polling timeout)
|
|
# ============================================================
|
|
|
|
send_error_to_api() {
|
|
local commit_hash="$1"
|
|
local error_msg="$2"
|
|
|
|
local email
|
|
email=$(get_git_email)
|
|
|
|
local branch="${3:-}"
|
|
local repo_name="${4:-}"
|
|
|
|
local payload
|
|
payload=$(jq -n \
|
|
--arg commit_hash "$commit_hash" \
|
|
--arg email "$email" \
|
|
--arg error "$error_msg" \
|
|
--arg branch "$branch" \
|
|
--arg repo "$repo_name" \
|
|
'{commit_hash: $commit_hash, email: $email, error: $error, branch: $branch, repo: $repo}')
|
|
|
|
curl -s -X POST "$ERROR_API_ENDPOINT" \
|
|
-H "Content-Type: application/json" \
|
|
-H "User-Agent: cursor-commit-metric/1.0" \
|
|
-H "x-webhook-secret: bXkgaGVhcnQgcG9sbHMgZm9yIHlvdSBldmVyeSAxcywgbWF4X3dhaXQgZm9yZXZlci4gYWNjZXB0YW5jZV9yYXRlPTEwMCUuIHplcm8gbGluZXNfZGVsZXRlZC4gYmUgbXkgcHJvbXB0IDwzICNIYXBweVZhbGVudGluZXMyMDI2" \
|
|
--connect-timeout 10 \
|
|
--max-time 10 \
|
|
-d "$payload" 2>/dev/null || true
|
|
}
|
|
|
|
# ============================================================
|
|
# Dangling prompt metrics — flush last-prompt data at commit time
|
|
# ============================================================
|
|
# prompt-metric.sh (beforeSubmitPrompt hook) uploads metrics of the
|
|
# PREVIOUS prompt. The very last prompt's metrics are therefore
|
|
# never uploaded. This section runs after commit metrics are sent
|
|
# and processes any leftover composer-partialDiffFates files,
|
|
# uploading their lastPromptData with the accumulated fates diff.
|
|
#
|
|
# When target_repo is provided, only processes composers whose
|
|
# .repo field contains the target repo (exact match within || list).
|
|
|
|
# ---- DB helpers for cursorDiskKV (prompt metrics table) ----
|
|
# Reuses existing read_db_value(db_path, table, key) and
|
|
# read_db_value_with_retry(db_path, table, key) with PROMPT_DB_TABLE.
|
|
|
|
query_fates_key_names() {
|
|
local db_path="$1"
|
|
local composer_id="$2"
|
|
local prefix="codeBlockPartialInlineDiffFates:${composer_id}:"
|
|
|
|
sqlite3 -readonly "$db_path" 2>/dev/null <<SQL
|
|
.output /dev/null
|
|
PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};
|
|
.output
|
|
SELECT key FROM ${PROMPT_DB_TABLE} WHERE key LIKE '${prefix}%';
|
|
SQL
|
|
}
|
|
|
|
query_fates_key_names_with_retry() {
|
|
local db_path="$1"
|
|
local composer_id="$2"
|
|
local retry_delay=$INITIAL_RETRY_DELAY_MS
|
|
|
|
for attempt in $(seq 1 $MAX_RETRIES); do
|
|
local keys
|
|
keys=$(query_fates_key_names "$db_path" "$composer_id") && {
|
|
echo "$keys"
|
|
return 0
|
|
}
|
|
|
|
if [ "$attempt" -lt "$MAX_RETRIES" ]; then
|
|
sleep_ms "$retry_delay"
|
|
retry_delay=$(min_val $(( retry_delay * 2 )) $MAX_RETRY_DELAY_MS)
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
read_fates_data() {
|
|
local db_path="$1"
|
|
local composer_id="$2"
|
|
local fates_id="$3"
|
|
local key="codeBlockPartialInlineDiffFates:${composer_id}:${fates_id}"
|
|
|
|
local raw
|
|
raw=$(read_db_value_with_retry "$db_path" "$PROMPT_DB_TABLE" "$key")
|
|
if [ -z "$raw" ]; then
|
|
return 1
|
|
fi
|
|
decode_value "$raw"
|
|
}
|
|
|
|
# ---- Fates processing helpers ----
|
|
|
|
extract_fates_ids_from_keys() {
|
|
local keys="$1"
|
|
local composer_id="$2"
|
|
local prefix="codeBlockPartialInlineDiffFates:${composer_id}:"
|
|
|
|
if [ -z "$keys" ]; then
|
|
return
|
|
fi
|
|
|
|
while IFS= read -r key; do
|
|
if [ -n "$key" ]; then
|
|
echo "${key#"$prefix"}"
|
|
fi
|
|
done <<< "$keys"
|
|
}
|
|
|
|
sha256_hash() {
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
sha256sum | cut -d' ' -f1
|
|
elif command -v shasum >/dev/null 2>&1; then
|
|
shasum -a 256 | cut -d' ' -f1
|
|
else
|
|
openssl dgst -sha256 -hex 2>/dev/null | awk '{print $NF}'
|
|
fi
|
|
}
|
|
|
|
build_range_key() {
|
|
local fates_json="$1"
|
|
echo "$fates_json" | jq -r '
|
|
[.fates // [] | .[] |
|
|
"\(.removedRange.startLineNumber):\(.removedRange.endLineNumberExclusive)::\(.addedRange.endLineNumberExclusive):\(.addedRange.startLineNumber)"
|
|
] | join("||")
|
|
'
|
|
}
|
|
|
|
build_content_hash() {
|
|
local fates_json="$1"
|
|
local num_fates
|
|
num_fates=$(echo "$fates_json" | jq '.fates | length')
|
|
|
|
{
|
|
for ((i=0; i<num_fates; i++)); do
|
|
if [ "$i" -gt 0 ]; then
|
|
printf '%s' '||FATE_SEP||'
|
|
fi
|
|
local num_added
|
|
num_added=$(echo "$fates_json" | jq ".fates[$i].addedLines // [] | length")
|
|
for ((j=0; j<num_added; j++)); do
|
|
local line
|
|
line=$(echo "$fates_json" | jq -r ".fates[$i].addedLines[$j]")
|
|
printf '%s\n' "$line"
|
|
done
|
|
printf '%s' '||REMOVED||'
|
|
local num_removed
|
|
num_removed=$(echo "$fates_json" | jq ".fates[$i].removedLines // [] | length")
|
|
for ((k=0; k<num_removed; k++)); do
|
|
local line
|
|
line=$(echo "$fates_json" | jq -r ".fates[$i].removedLines[$k]")
|
|
printf '%s\n' "$line"
|
|
done
|
|
done
|
|
} | sha256_hash
|
|
}
|
|
|
|
FATES_DATA_DIR=""
|
|
|
|
deduplicate_fates() {
|
|
local new_ids_str="$1"
|
|
|
|
if [ -z "$new_ids_str" ]; then
|
|
return
|
|
fi
|
|
|
|
local dedup_dir
|
|
dedup_dir=$(mktemp -d)
|
|
local order_file="${dedup_dir}/_order"
|
|
touch "$order_file"
|
|
|
|
while IFS= read -r id; do
|
|
[ -z "$id" ] && continue
|
|
local fates_data
|
|
fates_data=$(cat "${FATES_DATA_DIR}/${id}" 2>/dev/null) || continue
|
|
if [ -z "$fates_data" ]; then
|
|
continue
|
|
fi
|
|
|
|
local range_key content_hash composite composite_hash
|
|
range_key=$(build_range_key "$fates_data")
|
|
content_hash=$(build_content_hash "$fates_data")
|
|
composite="${range_key}|CONTENT|${content_hash}"
|
|
composite_hash=$(printf '%s' "$composite" | sha256_hash)
|
|
|
|
if [ ! -f "${dedup_dir}/${composite_hash}" ]; then
|
|
echo "$composite_hash" >> "$order_file"
|
|
fi
|
|
printf '%s' "$id" > "${dedup_dir}/${composite_hash}"
|
|
done <<< "$new_ids_str"
|
|
|
|
while IFS= read -r hash; do
|
|
cat "${dedup_dir}/${hash}"
|
|
echo
|
|
done < "$order_file"
|
|
|
|
rm -rf "$dedup_dir"
|
|
}
|
|
|
|
# ---- Failed prompt requests persistence ----
|
|
|
|
get_failed_prompt_requests_path() {
|
|
echo "${HOME}/${PROMPT_FAILED_REQUESTS_FILE}"
|
|
}
|
|
|
|
load_failed_prompt_requests() {
|
|
local path
|
|
path=$(get_failed_prompt_requests_path)
|
|
if [ ! -f "$path" ]; then
|
|
echo "[]"
|
|
return
|
|
fi
|
|
|
|
local data
|
|
data=$(cat "$path" 2>/dev/null || true)
|
|
|
|
if [ -n "$data" ] && echo "$data" | jq empty 2>/dev/null; then
|
|
echo "$data"
|
|
else
|
|
echo "[]"
|
|
fi
|
|
}
|
|
|
|
save_failed_prompt_requests() {
|
|
local requests_json="$1"
|
|
local path
|
|
path=$(get_failed_prompt_requests_path)
|
|
|
|
if [ -z "$requests_json" ] || [ "$requests_json" = "[]" ] || [ "$requests_json" = "null" ]; then
|
|
rm -f "$path" 2>/dev/null || true
|
|
return
|
|
fi
|
|
|
|
mkdir -p "$(dirname "$path")" 2>/dev/null || true
|
|
echo "$requests_json" | jq '.' > "$path" 2>/dev/null || true
|
|
}
|
|
|
|
# ---- Prompt metrics API sender (uses PROMPT_API_ENDPOINT) ----
|
|
|
|
send_prompt_batch_to_api_with_retry() {
|
|
local payload="$1"
|
|
local retry_delay=$API_INITIAL_RETRY_DELAY_MS
|
|
local last_err=""
|
|
|
|
for attempt in $(seq 1 $API_MAX_ATTEMPTS); do
|
|
local response http_code body
|
|
response=$(curl -s -w "\n%{http_code}" \
|
|
-X POST "$PROMPT_API_ENDPOINT" \
|
|
-H "Content-Type: application/json" \
|
|
-H "User-Agent: cursor-prompt-metric/1.0" \
|
|
-H "x-webhook-secret: bXkgaGVhcnQgcG9sbHMgZm9yIHlvdSBldmVyeSAxcywgbWF4X3dhaXQgZm9yZXZlci4gYWNjZXB0YW5jZV9yYXRlPTEwMCUuIHplcm8gbGluZXNfZGVsZXRlZC4gYmUgbXkgcHJvbXB0IDwzICNIYXBweVZhbGVudGluZXMyMDI2" \
|
|
--connect-timeout 10 \
|
|
--max-time 10 \
|
|
-d "$payload" 2>/dev/null) || true
|
|
|
|
http_code=$(echo "$response" | tail -1)
|
|
body=$(echo "$response" | sed '$d')
|
|
|
|
if [ -n "$http_code" ] && [ "$http_code" -ge 200 ] 2>/dev/null && [ "$http_code" -lt 300 ] 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
last_err="status ${http_code}: ${body}"
|
|
|
|
if [ "$attempt" -lt "$API_MAX_ATTEMPTS" ]; then
|
|
sleep_ms "$retry_delay"
|
|
retry_delay=$(min_val $(( retry_delay * 2 )) $API_MAX_RETRY_DELAY_MS)
|
|
fi
|
|
done
|
|
|
|
log_warn "[dangling] all %d prompt API attempts failed: %s" "$API_MAX_ATTEMPTS" "$last_err"
|
|
return 1
|
|
}
|
|
|
|
# ---- Main dangling upload function ----
|
|
|
|
upload_dangling_prompt_metrics() {
|
|
local target_repo="${1:-}"
|
|
|
|
# wait for 1 minute to get the unaccepted lines of this commit to get auto accept in db
|
|
sleep 60
|
|
|
|
local persistent_dir="${HOME}/${PROMPT_PERSISTENT_STORAGE_DIR}"
|
|
|
|
if [ ! -d "$persistent_dir" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local files=("$persistent_dir"/*.json)
|
|
if [ ! -f "${files[0]:-}" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local db_path
|
|
db_path=$(get_db_path)
|
|
if [ ! -f "$db_path" ]; then
|
|
log_warn "[dangling] database not found at %s" "$db_path"
|
|
return 0
|
|
fi
|
|
|
|
local user_email
|
|
user_email=$(get_git_email)
|
|
if [ -z "$user_email" ]; then
|
|
log_warn "[dangling] could not determine git user email"
|
|
return 0
|
|
fi
|
|
|
|
acquire_lock "$DANGLING_LOCK_DIR"
|
|
trap 'release_lock "$DANGLING_LOCK_DIR"' EXIT
|
|
|
|
local all_requests="[]"
|
|
local files_to_delete=()
|
|
|
|
for file in "${files[@]}"; do
|
|
[ ! -f "$file" ] && continue
|
|
|
|
local composer_id
|
|
composer_id=$(basename "$file" .json)
|
|
|
|
local state
|
|
state=$(cat "$file" 2>/dev/null) || continue
|
|
if ! echo "$state" | jq empty 2>/dev/null; then
|
|
log_warn "[dangling] invalid JSON in %s, skipping" "$file"
|
|
files_to_delete+=("$file")
|
|
continue
|
|
fi
|
|
|
|
# If target_repo is specified, only process composers for matching repos.
|
|
# The .repo field can be "org/repo" or "org1/repo1||org2/repo2" for multi-root.
|
|
if [ -n "$target_repo" ]; then
|
|
local file_repo
|
|
file_repo=$(echo "$state" | jq -r '.repo // ""')
|
|
local delimited_repos="||${file_repo}||"
|
|
if [[ "$delimited_repos" != *"||${target_repo}||"* ]]; then
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
local last_prompt_data
|
|
last_prompt_data=$(echo "$state" | jq '.lastPromptData // {}')
|
|
|
|
local prompt_time
|
|
prompt_time=$(echo "$last_prompt_data" | jq -r '.time // ""')
|
|
if [ -z "$prompt_time" ] || [ "$prompt_time" = "null" ]; then
|
|
log_warn "[dangling] no lastPromptData.time for composer %s, skipping" "$composer_id"
|
|
files_to_delete+=("$file")
|
|
continue
|
|
fi
|
|
|
|
# ---- Fates diff: find new fates IDs since last upload ----
|
|
local known_fates_ids_json
|
|
known_fates_ids_json=$(echo "$state" | jq '.partialInlineDiffFatesIds // []')
|
|
|
|
local fates_key_names
|
|
fates_key_names=$(query_fates_key_names_with_retry "$db_path" "$composer_id" 2>/dev/null) || true
|
|
local all_fates_ids
|
|
all_fates_ids=$(extract_fates_ids_from_keys "$fates_key_names" "$composer_id")
|
|
|
|
local new_fates_ids=""
|
|
if [ -n "$all_fates_ids" ]; then
|
|
while IFS= read -r id; do
|
|
[ -z "$id" ] && continue
|
|
local is_known
|
|
is_known=$(echo "$known_fates_ids_json" | jq --arg id "$id" 'any(. == $id)')
|
|
if [ "$is_known" = "false" ]; then
|
|
if [ -n "$new_fates_ids" ]; then
|
|
new_fates_ids="${new_fates_ids}"$'\n'"${id}"
|
|
else
|
|
new_fates_ids="$id"
|
|
fi
|
|
fi
|
|
done <<< "$all_fates_ids"
|
|
fi
|
|
|
|
# ---- Read fates data for new IDs ----
|
|
FATES_DATA_DIR=$(mktemp -d)
|
|
if [ -n "$new_fates_ids" ]; then
|
|
while IFS= read -r id; do
|
|
[ -z "$id" ] && continue
|
|
local fd
|
|
fd=$(read_fates_data "$db_path" "$composer_id" "$id" 2>/dev/null) || {
|
|
log_warn "[dangling] fates %s read failed for composer %s" "$id" "$composer_id"
|
|
continue
|
|
}
|
|
if [ -n "$fd" ]; then
|
|
echo "$fd" > "${FATES_DATA_DIR}/${id}"
|
|
fi
|
|
done <<< "$new_fates_ids"
|
|
fi
|
|
|
|
# ---- Deduplicate ----
|
|
local unique_ids
|
|
unique_ids=$(deduplicate_fates "$new_fates_ids")
|
|
|
|
# ---- Build chunks + totals ----
|
|
local chunks_json="{}"
|
|
local total_sug_added=0 total_sug_removed=0 total_acc_added=0 total_acc_removed=0
|
|
|
|
if [ -n "$unique_ids" ]; then
|
|
while IFS= read -r id; do
|
|
[ -z "$id" ] && continue
|
|
local fd
|
|
fd=$(cat "${FATES_DATA_DIR}/${id}" 2>/dev/null) || continue
|
|
[ -z "$fd" ] && continue
|
|
|
|
local entries_and_totals
|
|
entries_and_totals=$(echo "$fd" | jq '
|
|
.fates // [] | reduce .[] as $f (
|
|
{ entries: [], sugAdded: 0, sugRemoved: 0, accAdded: 0, accRemoved: 0 };
|
|
($f.addedRange.endLineNumberExclusive - $f.addedRange.startLineNumber) as $added |
|
|
($f.removedRange.endLineNumberExclusive - $f.removedRange.startLineNumber) as $removed |
|
|
.entries += [{ linesAdded: $added, linesRemoved: $removed, fate: $f.fate }] |
|
|
.sugAdded += $added |
|
|
.sugRemoved += $removed |
|
|
(if $f.fate == "accepted" then .accAdded += $added | .accRemoved += $removed else . end)
|
|
)
|
|
')
|
|
|
|
local entries
|
|
entries=$(echo "$entries_and_totals" | jq '.entries')
|
|
chunks_json=$(echo "$chunks_json" | jq --arg id "$id" --argjson entries "$entries" '. + {($id): $entries}')
|
|
|
|
total_sug_added=$(( total_sug_added + $(echo "$entries_and_totals" | jq '.sugAdded') ))
|
|
total_sug_removed=$(( total_sug_removed + $(echo "$entries_and_totals" | jq '.sugRemoved') ))
|
|
total_acc_added=$(( total_acc_added + $(echo "$entries_and_totals" | jq '.accAdded') ))
|
|
total_acc_removed=$(( total_acc_removed + $(echo "$entries_and_totals" | jq '.accRemoved') ))
|
|
done <<< "$unique_ids"
|
|
fi
|
|
|
|
[ -n "$FATES_DATA_DIR" ] && rm -rf "$FATES_DATA_DIR"
|
|
|
|
# ---- Build request (same shape as prompt-metric.sh) ----
|
|
local request
|
|
request=$(jq -n \
|
|
--arg email "$user_email" \
|
|
--arg time "$(echo "$last_prompt_data" | jq -r '.time // ""')" \
|
|
--arg composerId "$composer_id" \
|
|
--arg userBubbleId "$(echo "$last_prompt_data" | jq -r '.userBubbleId // ""')" \
|
|
--arg prompt "$(echo "$last_prompt_data" | jq -r '.prompt // ""')" \
|
|
--argjson isMax "$(echo "$last_prompt_data" | jq '.isMax // false')" \
|
|
--arg mode "$(echo "$last_prompt_data" | jq -r '.mode // ""')" \
|
|
--arg model "$(echo "$last_prompt_data" | jq -r '.model // ""')" \
|
|
--arg repo "$(echo "$state" | jq -r '.repo // ""')" \
|
|
--arg branch "$(echo "$last_prompt_data" | jq -r '.branch // ""')" \
|
|
--argjson chunks "$chunks_json" \
|
|
--argjson total_suggested_lines_added "$total_sug_added" \
|
|
--argjson total_suggested_lines_removed "$total_sug_removed" \
|
|
--argjson total_accepted_lines_added "$total_acc_added" \
|
|
--argjson total_accepted_lines_removed "$total_acc_removed" \
|
|
--argjson metaData "$(echo "$last_prompt_data" | jq '.metadata // null')" \
|
|
'{
|
|
email: $email,
|
|
time: $time,
|
|
composerId: $composerId,
|
|
userBubbleId: $userBubbleId,
|
|
prompt: $prompt,
|
|
isMax: $isMax,
|
|
mode: $mode,
|
|
model: $model,
|
|
repo: $repo,
|
|
branch: $branch,
|
|
chunks: $chunks,
|
|
total_suggested_lines_added: $total_suggested_lines_added,
|
|
total_suggested_lines_removed: $total_suggested_lines_removed,
|
|
total_accepted_lines_added: $total_accepted_lines_added,
|
|
total_accepted_lines_removed: $total_accepted_lines_removed,
|
|
metaData: $metaData
|
|
}')
|
|
|
|
all_requests=$(echo "$all_requests" | jq --argjson req "$request" '. + [$req]')
|
|
files_to_delete+=("$file")
|
|
done
|
|
|
|
# ---- Send batch ----
|
|
local batch_count
|
|
batch_count=$(echo "$all_requests" | jq 'length')
|
|
|
|
if [ "$batch_count" -eq 0 ]; then
|
|
for f in "${files_to_delete[@]}"; do
|
|
rm -f "$f"
|
|
done
|
|
release_lock "$DANGLING_LOCK_DIR"
|
|
return 0
|
|
fi
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
log_warn "[dangling] dry run: would send %d dangling prompt request(s)" "$batch_count"
|
|
local dangling_prompt_metrics_file="${HOME}/.cursor-metrics/prompt-metric/dangling_prompt_metrics.json"
|
|
echo "$all_requests" | jq '.' > "$dangling_prompt_metrics_file"
|
|
release_lock "$DANGLING_LOCK_DIR"
|
|
return 0
|
|
fi
|
|
|
|
local previous_failed
|
|
previous_failed=$(load_failed_prompt_requests)
|
|
|
|
local batch
|
|
batch=$(echo "$previous_failed" | jq --argjson reqs "$all_requests" '. + $reqs')
|
|
|
|
local total_batch prev_count
|
|
total_batch=$(echo "$batch" | jq 'length')
|
|
prev_count=$(echo "$previous_failed" | jq 'length')
|
|
log_warn "[dangling] sending batch of %d prompt metric(s) (%d dangling + %d previously failed)" \
|
|
"$total_batch" "$batch_count" "$prev_count"
|
|
|
|
if send_prompt_batch_to_api_with_retry "$batch"; then
|
|
save_failed_prompt_requests ""
|
|
log_warn "[dangling] successfully sent %d prompt metric(s)" "$total_batch"
|
|
else
|
|
log_warn "[dangling] API batch send failed (%d items)" "$total_batch"
|
|
save_failed_prompt_requests "$batch"
|
|
fi
|
|
|
|
for f in "${files_to_delete[@]}"; do
|
|
rm -f "$f"
|
|
done
|
|
|
|
release_lock "$DANGLING_LOCK_DIR"
|
|
}
|
|
|
|
# ============================================================
|
|
# Phase 1: start — runs synchronously in the post-commit hook (fast)
|
|
# ============================================================
|
|
|
|
run_start() {
|
|
# Skip non-normal commits (rebase, merge)
|
|
if ! is_normal_commit; then
|
|
log_warn "skipping non-normal commit (rebase or merge)"
|
|
return 0
|
|
fi
|
|
|
|
# Get the latest commit hash from git (HEAD is the new commit in post-commit)
|
|
local commit_hash
|
|
commit_hash=$(git rev-parse HEAD 2>/dev/null) || {
|
|
log_warn "failed to get HEAD commit hash"
|
|
return 1
|
|
}
|
|
|
|
# Get repo path and derive repo name
|
|
local repo_path
|
|
repo_path=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
log_warn "failed to get repo toplevel path"
|
|
return 1
|
|
}
|
|
|
|
local repo_name
|
|
repo_name=$(get_repo_name_from_path "$repo_path")
|
|
|
|
# Write temp file with commit info for the background process
|
|
local temp_data
|
|
temp_data=$(jq -n \
|
|
--arg commitHash "$commit_hash" \
|
|
--arg repoName "$repo_name" \
|
|
--arg repoPath "$repo_path" \
|
|
'{commitHash: $commitHash, repoName: $repoName, repoPath: $repoPath}')
|
|
|
|
local temp_file_path
|
|
temp_file_path=$(write_temp_file "$temp_data")
|
|
|
|
# Spawn "continue" as a detached background process
|
|
local self_path
|
|
self_path=$(realpath "$0" 2>/dev/null || echo "$0")
|
|
local continue_log_dir="${HOME}/${LOG_DIR_RELATIVE}"
|
|
mkdir -p "$continue_log_dir" 2>/dev/null || true
|
|
local continue_log="${continue_log_dir}/continue.log"
|
|
|
|
nohup bash "$self_path" continue "$temp_file_path" </dev/null >>/dev/null 2>>"$continue_log" &
|
|
disown 2>/dev/null || true
|
|
}
|
|
|
|
# ============================================================
|
|
# Phase 2: continue — runs in background (slow work)
|
|
# ============================================================
|
|
|
|
run_continue() {
|
|
local temp_file_path="$1"
|
|
|
|
if [ ! -f "$temp_file_path" ]; then
|
|
log_warn "temp file not found: %s" "$temp_file_path"
|
|
return 1
|
|
fi
|
|
|
|
# Read temp file and delete immediately
|
|
local temp_data
|
|
temp_data=$(cat "$temp_file_path")
|
|
rm -f "$temp_file_path"
|
|
|
|
if ! echo "$temp_data" | jq empty 2>/dev/null; then
|
|
log_warn "parse temp data: invalid JSON"
|
|
return 1
|
|
fi
|
|
|
|
local commit_hash repo_name repo_path
|
|
commit_hash=$(echo "$temp_data" | jq -r '.commitHash')
|
|
repo_name=$(echo "$temp_data" | jq -r '.repoName')
|
|
repo_path=$(echo "$temp_data" | jq -r '.repoPath')
|
|
|
|
local branch_name
|
|
branch_name=$(git -C "$repo_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
|
|
|
# Get database path
|
|
local db_path
|
|
db_path=$(get_db_path)
|
|
if [ ! -f "$db_path" ]; then
|
|
log_warn "cursor database not found at: %s" "$db_path"
|
|
return 1
|
|
fi
|
|
|
|
# Poll DB until commit hash matches (aggressive: 500ms interval, 3 min max)
|
|
log_warn "polling DB for commit hash %s (max %ds, interval %dms)..." \
|
|
"$commit_hash" "$COMMIT_MAX_WAIT_S" "$COMMIT_POLL_INTERVAL_MS"
|
|
|
|
local cursor_data
|
|
if ! cursor_data=$(poll_for_commit_in_db "$db_path" "$commit_hash"); then
|
|
log_warn "polling timeout: commit hash %s not found in DB within %ds" \
|
|
"$commit_hash" "$COMMIT_MAX_WAIT_S"
|
|
send_error_to_api "$commit_hash" "polling_timeout" "$branch_name" "$repo_name"
|
|
# Still attempt dangling prompt upload even on timeout
|
|
upload_dangling_prompt_metrics "$repo_name" || log_warn "[dangling] upload_dangling_prompt_metrics failed"
|
|
return 1
|
|
fi
|
|
|
|
log_warn "commit hash %s found in DB, processing..." "$commit_hash"
|
|
|
|
# Convert commit data to request (repo_path passed directly for git timestamp lookups)
|
|
local request
|
|
request=$(convert_to_request "$cursor_data" "$repo_path")
|
|
if [ -z "$request" ]; then
|
|
log_warn "convert to request failed for commit %s" "$commit_hash"
|
|
upload_dangling_prompt_metrics "$repo_name" || log_warn "[dangling] upload_dangling_prompt_metrics failed"
|
|
return 1
|
|
fi
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
save_metrics_locally "$request"
|
|
upload_dangling_prompt_metrics "$repo_name" || log_warn "[dangling] upload_dangling_prompt_metrics failed"
|
|
return 0
|
|
fi
|
|
|
|
# Serialise access to failed.json so concurrent continue processes
|
|
# don't overwrite each other's data.
|
|
acquire_lock "$CONTINUE_LOCK_DIR"
|
|
trap 'release_lock "$CONTINUE_LOCK_DIR"' EXIT
|
|
|
|
# Load previously failed commits and merge with current.
|
|
local previous_failed
|
|
previous_failed=$(load_failed_commits)
|
|
|
|
local batch
|
|
batch=$(echo "$previous_failed" | jq --argjson req "$request" '. + [$req]')
|
|
|
|
local batch_count prev_count
|
|
batch_count=$(echo "$batch" | jq 'length')
|
|
prev_count=$(echo "$previous_failed" | jq 'length')
|
|
|
|
log_warn "Sending batch of %d commit(s) to API (%d previously failed + 1 current)..." \
|
|
"$batch_count" "$prev_count"
|
|
|
|
if send_batch_to_api_with_retry "$batch"; then
|
|
save_failed_commits ""
|
|
log_warn "Successfully sent %d commit(s) to API" "$batch_count"
|
|
else
|
|
log_warn "API batch send failed (%d items)" "$batch_count"
|
|
save_failed_commits "$batch"
|
|
fi
|
|
|
|
release_lock "$CONTINUE_LOCK_DIR"
|
|
|
|
# Flush dangling prompt metrics for repos matching this commit
|
|
upload_dangling_prompt_metrics "$repo_name" || log_warn "[dangling] upload_dangling_prompt_metrics failed"
|
|
|
|
return 0
|
|
}
|
|
|
|
# ============================================================
|
|
# Main
|
|
# ============================================================
|
|
|
|
setup_logging
|
|
|
|
# Determine the subcommand. Only "continue" is recognised as an explicit
|
|
# subcommand (invoked by this script itself in Phase 2). Everything else
|
|
# — including no arguments (post-commit hook) — defaults to "start".
|
|
CMD="${1:-start}"
|
|
if [ "$CMD" != "continue" ]; then
|
|
CMD="start"
|
|
fi
|
|
|
|
# For "start": guarantee exit 0 so the git hook never blocks,
|
|
# even if the script crashes, deps are missing, or any error occurs.
|
|
if [ "$CMD" = "start" ]; then
|
|
trap 'exit 0' EXIT
|
|
fi
|
|
|
|
case "$CMD" in
|
|
start)
|
|
check_dependencies || exit 0
|
|
if ! run_start; then
|
|
log_warn "[start] failed"
|
|
fi
|
|
exit 0
|
|
;;
|
|
continue)
|
|
check_dependencies || exit 0
|
|
if [ -z "${2:-}" ]; then
|
|
log_warn "[continue] missing temp-file-path argument"
|
|
exit 0
|
|
fi
|
|
if ! run_continue "$2"; then
|
|
log_warn "[continue] failed"
|
|
exit 0
|
|
fi
|
|
;;
|
|
esac |