Skip to content

Multi-worktree notes conflicts not prevented by auto-sync hooks #28

@zircote

Description

@zircote

Description

With HOOK_SESSION_START_FETCH_REMOTE=true and HOOK_STOP_PUSH_REMOTE=true enabled, concurrent Claude sessions in different worktrees still experience notes ref conflicts that require manual resolution via /memory:sync --remote.

Expected Behavior

Auto-sync hooks should prevent conflicts by:

  1. SessionStart: Fetch and merge latest notes from remote
  2. SessionStop: Push local notes to remote

In multi-worktree environments, this should keep all worktrees in sync without manual intervention.

Actual Behavior

Notes refs diverged between worktrees, requiring manual sync. After merging code changes in issue-10-observability worktree, refs/notes/mem/* and refs/notes/origin/mem/* had different SHAs across the 4 active worktrees:

$ git worktree list
/Users/.../git-notes-memory                              c9f358e [main]
/Users/.../worktrees/git-notes-memory/issue-10-observability     c8e87ce
/Users/.../worktrees/git-notes-memory/issue-11-subconsciousness  eb611ec
/Users/.../worktrees/git-notes-memory/issue-13-multi-domain      d22ecbd

$ git for-each-ref refs/notes/ --format='%(refname) %(objectname:short)'
refs/notes/mem/decisions 171da3a
refs/notes/origin/mem/decisions 9bdb4b8  # ← Diverged!

Manual sync_with_remote(push=True) was required to resolve.

Investigation Findings

Root Cause

The push_notes_to_remote() method pushes without fetch-merge first, causing conflicts in multi-worktree scenarios where notes refs are shared across all worktrees.

Race condition timeline:

Worktree A              Worktree B              Worktree C
───────────────────────────────────────────────────────────────
SessionStart: fetch ──►
                        SessionStart: fetch ──►
Capture memory ──►
                        Capture memory ──►
                                                SessionStart ──►
                        SessionStop: PUSH ──►   (remote updated)
SessionStop: PUSH ──►   CONFLICT!              Capture ──►
                        (local refs stale)
───────────────────────────────────────────────────────────────

Between Worktree A's SessionStart (fetch) and SessionStop (push), Worktree B pushed, making A's local refs stale.

Affected Files

  • src/git_notes_memory/git_ops.py:1071-1085 - push_notes_to_remote() missing fetch-before-push
  • src/git_notes_memory/hooks/stop_handler.py:530 - Calls push_notes_to_remote() without pre-push sync
  • src/git_notes_memory/hooks/session_start_handler.py:199-214 - Fetch only happens at session start

Code Context

File: src/git_notes_memory/git_ops.py:1071-1085

def push_notes_to_remote(self) -> bool:
    """Push all notes to origin.
    
    Pushes local notes to the remote repository. Uses the configured
    push refspec (refs/notes/mem/*:refs/notes/mem/*).
    
    Returns:
        True if push succeeded, False otherwise.
    """
    base = get_git_namespace()
    result = self._run_git(
        ["push", "origin", f"{base}/*:{base}/*"],  # ← Direct push, no fetch first
        check=False,
    )
    return result.returncode == 0

Issue: Pushes directly without fetching and merging remote changes first.

File: src/git_notes_memory/hooks/stop_handler.py:523-537

if config.stop_push_remote:
    cwd = input_data.get("cwd")
    if cwd:
        try:
            from git_notes_memory.git_ops import GitOps
            
            git_ops = GitOps(repo_path=cwd)
            if git_ops.push_notes_to_remote():  # ← No pre-push fetch
                logger.debug("Pushed notes to remote on session stop")

Related Code

A proper sync workflow already exists in the codebase:

File: src/git_notes_memory/git_ops.py:1087-1127

def sync_notes_with_remote(
    self,
    namespaces: list[str] | None = None,
    *,
    push: bool = True,
) -> dict[str, bool]:
    """Sync notes with remote using fetchmergepush workflow.
    
    This is the primary method for synchronizing notes between local
    and remote repositories. It:
    1. Fetches remote notes to tracking refs
    2. Merges tracking refs into local notes using cat_sort_uniq
    3. Pushes merged notes back to remote (optional)
    ...

This method implements the correct fetch→merge→push workflow but is not used by the Stop hook.

Worktree Environment

All worktrees share the same refs/notes/mem/* storage location in the main repo's .git/:

$ ls -la /Users/.../git-notes-memory/.git/refs/notes/
drwxr-xr-x 7 staff 224 Dec 25 22:34 mem
drwxr-xr-x 3 staff  96 Dec 25 20:00 origin

This is standard git behavior—notes refs are not per-worktree, so concurrent sessions can race.

Suggested Fix

Replace the push_notes_to_remote() call in stop_handler.py:530 with sync_notes_with_remote(push=True):

if config.stop_push_remote:
    cwd = input_data.get("cwd")
    if cwd:
        try:
            from git_notes_memory.git_ops import GitOps
            
            git_ops = GitOps(repo_path=cwd)
            # Use full sync workflow instead of direct push
            results = git_ops.sync_notes_with_remote(push=True)
            if any(results.values()):
                logger.debug("Synced notes with remote on session stop")
            else:
                logger.debug("Sync to remote failed (will retry next session)")
        except Exception as e:
            logger.debug("Remote sync on stop skipped: %s", e)

This ensures that before pushing, the Stop hook fetches and merges any remote changes, preventing conflicts.

Environment

  • OS: Darwin 24.6.0 (macOS)
  • Git Worktrees: 4 concurrent worktrees active
  • Auto-sync enabled: HOOK_SESSION_START_FETCH_REMOTE=true, HOOK_STOP_PUSH_REMOTE=true

Related


Investigated and reported via /claude-spec:report-issue
AI-actionable: This issue contains detailed context for automated resolution

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions