From eaf08ba8fb03603f0409563f907600e94745f26e Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Thu, 23 Apr 2026 17:28:00 -0500 Subject: [PATCH] feat(connect): emit notification when origin/ advances past local HEAD (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two AI peers (or AI + human) are paired in airc on the same git repo, silently working on a stale checkout is a recurring source of duplicated work. The "pull-before-work" discipline rule is fragile — agents forget, or pull at the wrong time. Airc already knows the scope (the repo it's running in) and the connection state (peers); it can detect when the remote has advanced past local HEAD and surface that as a notification on the existing monitor stream the AI is already reading. Implementation (issue #33, design B from the issue): - In cmd_connect, after the heartbeat block, spawn a background subshell that polls the origin's branch HEAD via `git ls-remote --heads origin ` every 60s. ls-remote is a HEAD ref query — no objects fetched, costs ~50ms over a fast network. - Track the most-recently-notified remote SHA in `/last_remote_sha`. Only emit a notification when: 1. Remote SHA differs from local HEAD, AND 2. Remote SHA differs from what we last notified about (don't repeat for the same remote state — fire ONCE per actual change) - Notification format includes ahead-count + truncated SHAs: `[airc] origin/ is at , you have ( commits ahead). Run \`git pull\` before next edit.` - Skipped when: - AIRC_NO_PULL_PROMPT=1 set (opt out for fixed-snapshot work) - git not on PATH - Scope is not inside a git repo - Branch is detached HEAD or has no remote tracking Auto-killed by the existing EXIT/INT/TERM trap that already cleans up the heartbeat subshell and other descendants — no separate cleanup needed. Triggered by Joel on 2026-04-23 in CambrianTech/continuum: paired agents (continuum-a25c on Mac, bigmama-wsl on Linux) had to manually remember the pull-before-work cadence. Joel: "maybe our airc could PROMPT the ais that their branch is behind?" Then refined to "or when new changes are committed really" — single notification per real remote change, not periodic nag. This implementation matches that refinement: poll is silent until the remote SHA actually advances, then fires exactly once per advancement. Closes #33. --- airc | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/airc b/airc index 55658a3..9a1cd7d 100755 --- a/airc +++ b/airc @@ -621,6 +621,58 @@ cmd_connect() { ) & fi + # ── Branch-behind notification (issue #33) ───────────────────────── + # When two AI peers are paired on the same git repo, silently working on a + # stale checkout duplicates fixes (we have a "pull-before-work" discipline + # rule, but discipline is fragile). If the airc scope IS a git repo, poll + # `git ls-remote origin ` periodically and emit a one-shot + # notification when the remote SHA advances past local HEAD. The notification + # surfaces on stdout where Claude Code's Monitor (or any other reader) + # picks it up immediately. + # + # Cheap: ls-remote is a HEAD ref query, no objects fetched. Quiet by + # default: emits ONE line per actual remote change, not per poll. + # Opt out: AIRC_NO_PULL_PROMPT=1 disables the poll entirely. + if [ -z "${AIRC_NO_PULL_PROMPT:-}" ] && command -v git >/dev/null 2>&1; then + local scope_git_dir; scope_git_dir="$(cd "$AIRC_WRITE_DIR/.." 2>/dev/null && git rev-parse --git-dir 2>/dev/null || true)" + if [ -n "$scope_git_dir" ]; then + local scope_repo_dir; scope_repo_dir="$(cd "$AIRC_WRITE_DIR/.." 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$scope_repo_dir" ]; then + # State file: last remote SHA we notified about. Empty on first run. + # Stored INSIDE the airc scope so it follows the pairing identity. + local pull_state_file="$AIRC_WRITE_DIR/last_remote_sha" + ( + # Always work in the scope's git repo + cd "$scope_repo_dir" || exit 0 + while sleep 60; do + local branch; branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")" + [ -z "$branch" ] || [ "$branch" = "HEAD" ] && continue # detached HEAD or no repo + # Cheap remote head check — no object fetch. + local remote_sha; remote_sha="$(git ls-remote --heads origin "$branch" 2>/dev/null | awk '{print $1}')" + [ -z "$remote_sha" ] && continue # no remote tracking, skip + local local_sha; local_sha="$(git rev-parse HEAD 2>/dev/null)" + # Only notify when remote is AHEAD and we haven't already notified + # about THIS exact remote SHA. + if [ "$remote_sha" != "$local_sha" ]; then + local already_notified="" + [ -f "$pull_state_file" ] && already_notified="$(cat "$pull_state_file" 2>/dev/null)" + if [ "$remote_sha" != "$already_notified" ]; then + # Verify remote is actually AHEAD (not just different — could + # be a force-push scenario, still worth flagging but with + # different verbiage). + local ahead_count; ahead_count="$(git rev-list --count HEAD.."$remote_sha" 2>/dev/null || echo "?")" + local short_remote; short_remote="${remote_sha:0:9}" + local short_local; short_local="${local_sha:0:9}" + echo " [airc] origin/$branch is at $short_remote (you have $short_local, $ahead_count commits ahead). Run \`git pull\` before next edit. AIRC_NO_PULL_PROMPT=1 to silence." + echo "$remote_sha" > "$pull_state_file" + fi + fi + done + ) & + fi + fi + fi + # Auto-teardown any stale airc process in this scope before starting fresh. # Previously users had to run `airc teardown` manually before `airc connect` # if a prior monitor was still around — easy to forget, often resulted in