Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions bin/git-issue
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
13 changes: 12 additions & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id> # keep yours
git fetch origin +refs/notes/issue-<id>:refs/notes/issue-<id> # take theirs
```

The discarded version stays in the reflog (`git reflog show refs/notes/issue-<id>`)
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
Expand Down
26 changes: 18 additions & 8 deletions hooks/post-merge
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>${NC}"
echo -e "${YELLOW} Take theirs: git fetch $REMOTE +refs/notes/issue-<id>:refs/notes/issue-<id>${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
19 changes: 16 additions & 3 deletions hooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>${NC}"
echo -e "${YELLOW} Take theirs: git fetch $REMOTE +refs/notes/issue-<id>:refs/notes/issue-<id>${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
Expand Down
77 changes: 77 additions & 0 deletions tests/test_hooks_sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "============="
Expand Down