diff --git a/bin/git-issue b/bin/git-issue index 0cc9eab..1f8491c 100755 --- a/bin/git-issue +++ b/bin/git-issue @@ -1312,9 +1312,17 @@ setup_sync_enable() { git config git-issue.sync.enabled true echo -e "${GREEN}✓ Enabled automatic notes sync in git config${NC}" - # Configure automatic notes fetching - git config --add remote.origin.fetch '+refs/notes/*:refs/notes/*' 2>/dev/null || true - echo -e "${GREEN}✓ Configured automatic notes fetching${NC}" + # Configure automatic notes fetching. The refspec is deliberately not + # forcing: with a leading '+' a fetch replaces a local issue ref that has + # unpushed edits, and git-issue keeps one linear history per issue, so the + # edits are gone except from the reflog. Without it git rejects that fetch + # as non-fast-forward and the local edit stays. Earlier releases wrote the + # forcing form, so an existing one is rewritten. + git config --unset-all remote.origin.fetch '^\+refs/notes/\*:refs/notes/\*$' 2>/dev/null || true + if ! git config --get-all remote.origin.fetch 2>/dev/null | grep -qx 'refs/notes/\*:refs/notes/\*'; then + git config --add remote.origin.fetch 'refs/notes/*:refs/notes/*' 2>/dev/null || true + fi + echo -e "${GREEN}✓ Configured automatic notes fetching (non-forcing)${NC}" echo "" echo -e "${GREEN}🎉 Automatic git notes sync is now enabled!${NC}" diff --git a/docs/user-guide.md b/docs/user-guide.md index 02d7134..7191bfe 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -220,7 +220,18 @@ git issue setup-sync status This installs a pre-push hook that pushes the notes refs and a post-merge hook that fetches them, and adds the refspec so `git fetch` -brings them down. On a second machine, clone, run `setup-sync enable`, +brings them down. Neither side forces: an issue edited locally and on the +remote since your last push is rejected by both fetch and push, and the +hooks name it, rather than one edit replacing the other. Other issues +still sync. To settle a diverged issue, pick a side: + +```bash +git push --force origin refs/notes/issue- # keep yours +git fetch origin +refs/notes/issue-:refs/notes/issue- # take theirs +``` + +The discarded version stays in the reflog (`git reflog show refs/notes/issue-`) +on the clone that held it. On a second machine, clone, run `setup-sync enable`, and `git fetch` once. To do it by hand instead: ```bash diff --git a/hooks/post-merge b/hooks/post-merge index 5ac345d..f4e29f1 100755 --- a/hooks/post-merge +++ b/hooks/post-merge @@ -29,17 +29,27 @@ REMOTE=$(git remote | head -1) echo -e "${BLUE}🔄 Syncing issue notes from $REMOTE...${NC}" -# Fetch issue notes from remote -if git fetch "$REMOTE" 'refs/notes/issue-*:refs/notes/issue-*' 2>/dev/null; then - # Count synced notes - NOTE_COUNT=$(git for-each-ref --format="%(refname)" refs/notes/issue-* 2>/dev/null | wc -l) - if [[ $NOTE_COUNT -gt 0 ]]; then - echo -e "${GREEN}✓ Synced $NOTE_COUNT issue notes${NC}" - fi -else +# Fetch issue notes from remote. The refspec is not forcing, so an issue +# edited locally and on the remote since the last push is rejected rather +# than replaced; say which ones, because a silent refusal looks like a +# fetch that worked. +FETCH_OUT=$(git fetch "$REMOTE" 'refs/notes/issue-*:refs/notes/issue-*' 2>&1) +FETCH_RC=$? +REJECTED=$(echo "$FETCH_OUT" | grep '\[rejected\]' | grep -o 'refs/notes/issue-[0-9a-f]*' | sort -u) +if [[ -n "$REJECTED" ]]; then + echo -e "${YELLOW}⚠️ Issue notes diverged from $REMOTE (local edits kept, remote edits not fetched):${NC}" + echo "$REJECTED" | sed 's/^/ /' + echo -e "${YELLOW} Keep yours: git push --force $REMOTE refs/notes/issue-${NC}" + echo -e "${YELLOW} Take theirs: git fetch $REMOTE +refs/notes/issue-:refs/notes/issue-${NC}" +fi +if [[ $FETCH_RC -ne 0 && -z "$REJECTED" ]]; then # Silently fail if remote doesn't have notes - this is normal for new repos exit 0 fi +NOTE_COUNT=$(git for-each-ref --format="%(refname)" refs/notes/issue-* 2>/dev/null | wc -l) +if [[ $NOTE_COUNT -gt 0 ]]; then + echo -e "${GREEN}✓ Synced $NOTE_COUNT issue notes${NC}" +fi # Optional: fetch commit notes too if they exist git fetch "$REMOTE" 'refs/notes/commits:refs/notes/commits' 2>/dev/null || true \ No newline at end of file diff --git a/hooks/pre-push b/hooks/pre-push index 5452b36..42edee5 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -66,11 +66,24 @@ while IFS= read -r ref; do REFSPECS+=("$ref") done <<< "$CHANGED_REFS" -# Push with --atomic so all ref updates succeed or fail together -if git push --atomic "$REMOTE" "${REFSPECS[@]}"; then +# Not --atomic: an issue edited here and on the remote since the last push +# is rejected as a non-fast-forward, and an atomic push would hold back every +# other changed issue with it. Name the rejected ones, as post-merge does. +PUSH_OUT=$(git push "$REMOTE" "${REFSPECS[@]}" 2>&1) +PUSH_RC=$? +REJECTED=$(echo "$PUSH_OUT" | grep '\[rejected\]' | grep -o 'refs/notes/issue-[0-9a-f]*' | sort -u) +if [[ -n "$REJECTED" ]]; then + REJECTED_COUNT=$(echo "$REJECTED" | wc -l | tr -d ' ') + echo -e "${GREEN}✓ Pushed $((CHANGED_COUNT - REJECTED_COUNT)) issue notes${NC}" + echo -e "${YELLOW}⚠️ Issue notes diverged from $REMOTE (not pushed):${NC}" + echo "$REJECTED" | sed 's/^/ /' + echo -e "${YELLOW} Keep yours: git push --force $REMOTE refs/notes/issue-${NC}" + echo -e "${YELLOW} Take theirs: git fetch $REMOTE +refs/notes/issue-:refs/notes/issue-${NC}" +elif [[ $PUSH_RC -eq 0 ]]; then echo -e "${GREEN}✓ Successfully pushed $CHANGED_COUNT issue notes${NC}" else - echo -e "${YELLOW}⚠️ Failed to push some issue notes (this may be normal for new remotes)${NC}" + echo "$PUSH_OUT" + echo -e "${YELLOW}⚠️ Failed to push issue notes (this may be normal for new remotes)${NC}" # Don't fail the push - notes sync failure shouldn't block code push exit 0 fi diff --git a/tests/test_hooks_sync.sh b/tests/test_hooks_sync.sh index ad289aa..70a0570 100755 --- a/tests/test_hooks_sync.sh +++ b/tests/test_hooks_sync.sh @@ -190,6 +190,83 @@ TESTS_RUN=$((TESTS_RUN + 1)) # Test 16: Test invalid setup-sync argument run_test "invalid setup-sync argument" "git issue setup-sync invalid" "Usage: git issue setup-sync" +# Test 17: enable writes a non-forcing notes refspec and rewrites a forcing one +echo -n "Verifying non-forcing notes refspec... " +git remote add origin /nonexistent >/dev/null 2>&1 +git config --add remote.origin.fetch '+refs/notes/*:refs/notes/*' +git issue setup-sync enable >/dev/null 2>&1 +REFSPECS=$(git config --get-all remote.origin.fetch) +if echo "$REFSPECS" | grep -qx 'refs/notes/\*:refs/notes/\*' && ! echo "$REFSPECS" | grep -q '^+refs/notes'; then + echo -e "${GREEN}PASS${NC}" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e "${RED}FAIL${NC}" + echo " Refspecs: $REFSPECS" +fi +TESTS_RUN=$((TESTS_RUN + 1)) + +# Test 18: a fetch does not replace an issue edited locally and on the remote +echo -n "Verifying a diverged issue keeps the local edit on fetch... " +SYNC_DIR="/tmp/git-issue-diverge-test-$$" +mkdir -p "$SYNC_DIR" +git init --bare "$SYNC_DIR/remote.git" >/dev/null 2>&1 +for who in alice bob; do + git init "$SYNC_DIR/$who" >/dev/null 2>&1 + git -C "$SYNC_DIR/$who" config user.name "$who" + git -C "$SYNC_DIR/$who" config user.email "$who@example.com" + git -C "$SYNC_DIR/$who" remote add origin "$SYNC_DIR/remote.git" +done +git -C "$SYNC_DIR/alice" commit -q --allow-empty -m init +git -C "$SYNC_DIR/alice" push -q origin HEAD:main >/dev/null 2>&1 +DIV_ID=$(git -C "$SYNC_DIR/alice" issue create "shared issue" 2>&1 | grep -o '#[a-f0-9]\+' | head -1 | cut -c2-) +git -C "$SYNC_DIR/alice" push -q origin "refs/notes/issue-$DIV_ID" >/dev/null 2>&1 +git -C "$SYNC_DIR/bob" fetch -q origin main >/dev/null 2>&1 +git -C "$SYNC_DIR/bob" issue setup-sync enable >/dev/null 2>&1 +git -C "$SYNC_DIR/bob" fetch -q origin >/dev/null 2>&1 +git -C "$SYNC_DIR/alice" issue comment "$DIV_ID" "alice's edit" >/dev/null 2>&1 +git -C "$SYNC_DIR/alice" push -q origin "refs/notes/issue-$DIV_ID" >/dev/null 2>&1 +git -C "$SYNC_DIR/bob" issue comment "$DIV_ID" "bob's unpushed edit" >/dev/null 2>&1 +BOB_BEFORE=$(git -C "$SYNC_DIR/bob" rev-parse "refs/notes/issue-$DIV_ID") +FETCH_MSG=$(git -C "$SYNC_DIR/bob" fetch origin 2>&1) +BOB_AFTER=$(git -C "$SYNC_DIR/bob" rev-parse "refs/notes/issue-$DIV_ID") +if [[ "$BOB_BEFORE" == "$BOB_AFTER" ]] && echo "$FETCH_MSG" | grep -q "rejected"; then + echo -e "${GREEN}PASS${NC}" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e "${RED}FAIL${NC}" + echo " Local ref before: $BOB_BEFORE after: $BOB_AFTER" + echo " Fetch said: $FETCH_MSG" +fi +TESTS_RUN=$((TESTS_RUN + 1)) + +# Test 19: the post-merge hook names the diverged issue instead of staying silent +echo -n "Verifying the post-merge hook reports the diverged issue... " +HOOK_MSG=$(cd "$SYNC_DIR/bob" && .git/hooks/post-merge 2>&1) +if echo "$HOOK_MSG" | grep -q "issue-$DIV_ID" && echo "$HOOK_MSG" | grep -q "diverged"; then + echo -e "${GREEN}PASS${NC}" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e "${RED}FAIL${NC}" + echo " Hook said: $HOOK_MSG" +fi +TESTS_RUN=$((TESTS_RUN + 1)) + +# Test 20: the pre-push hook still pushes other issues past a diverged one +echo -n "Verifying the pre-push hook pushes past a diverged issue... " +NEW_ID=$(git -C "$SYNC_DIR/bob" issue create "bob's new issue" 2>&1 | grep -o '#[a-f0-9]\+' | head -1 | cut -c2-) +HOOK_MSG=$(cd "$SYNC_DIR/bob" && .git/hooks/pre-push origin "$SYNC_DIR/remote.git" 2>&1) +REMOTE_NEW=$(git -C "$SYNC_DIR/remote.git" rev-parse -q --verify "refs/notes/issue-$NEW_ID") +if [[ -n "$REMOTE_NEW" ]] && echo "$HOOK_MSG" | grep -q "issue-$DIV_ID" && echo "$HOOK_MSG" | grep -q "diverged"; then + echo -e "${GREEN}PASS${NC}" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e "${RED}FAIL${NC}" + echo " Remote has new issue: ${REMOTE_NEW:-no}" + echo " Hook said: $HOOK_MSG" +fi +TESTS_RUN=$((TESTS_RUN + 1)) +rm -rf "$SYNC_DIR" + echo "" echo -e "${BLUE}Test Results:${NC}" echo "============="