From 5fd91c9b0249e6196ecee625f292a69b72eaf173 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Mon, 3 Aug 2026 13:19:03 -0700 Subject: [PATCH] fix: detect edge index changes by ref object ID ensure_edge_index_current decides which issues to re-parse by comparing each note ref's commit date against a wall-clock marker. A commit date does not say when a ref changed locally. A note arriving by fetch or import keeps the date it was written with, which can predate the marker, so its dependency headers are skipped permanently. The post-merge hook fetches issue notes on every pull, so this is the ordinary sync path rather than a corner case. Normalizing dates to UTC (e98395e) fixed the offset comparison but cannot fix this. A date cursor also never notices a ref that disappears, so a deleted issue's edges stay in the index until a full rebuild. Snapshot issue ref object IDs in a sibling blob of the edge index and re-parse the refs whose ID moved. Object IDs change whenever a ref does, however it moved and whatever date it carries. Refs that disappeared have their edges dropped. An index with no snapshot reads as "everything changed" and rebuilds in full, so an older index heals on first read with no migration step. Two regression tests cover it: an edit backdated with GIT_COMMITTER_DATE, and a deleted note ref. Both fail against the date cursor. Co-Authored-By: Claude Opus 5.5 --- bin/git-issue | 121 ++++++++++++++++++++++++++++----------------- tests/test_deps.sh | 49 ++++++++++++++++++ 2 files changed, 124 insertions(+), 46 deletions(-) diff --git a/bin/git-issue b/bin/git-issue index 0cc9eab..c61ffb0 100755 --- a/bin/git-issue +++ b/bin/git-issue @@ -2008,16 +2008,45 @@ read_edge_index() { git cat-file -p "$blob_hash" 2>/dev/null || echo "" } -# Write content to the edge index using git plumbing +# Change cursor for the edge index. for-each-ref sorts by refname, so two +# snapshots taken at different times compare as plain strings. +current_ref_snapshot() { + git for-each-ref --format='%(refname) %(objectname)' 'refs/notes/issue-*' 2>/dev/null +} + +# Empty on indexes written before the snapshot existed, which reads as +# "everything changed" and rebuilds them in full. +read_ref_snapshot() { + git cat-file -p "refs/notes/dep-graph:refs" 2>/dev/null || echo "" +} + +# Write content to the edge index using git plumbing. The snapshot goes in a +# sibling blob because consumers read the edges blob line by line and would +# treat metadata as edges. Omitting it carries the stored snapshot forward. write_edge_index() { local data="$1" local ref="refs/notes/dep-graph" + local snapshot + if [[ $# -ge 2 ]]; then + snapshot="$2" + else + snapshot=$(read_ref_snapshot) + fi + local blob_hash blob_hash=$(echo "$data" | git hash-object -w --stdin) + local snap_hash="" + if [[ -n "$snapshot" ]]; then + snap_hash=$(echo "$snapshot" | git hash-object -w --stdin) + fi + local tree_hash - tree_hash=$(printf "100644 blob %s\tedges\n" "$blob_hash" | git mktree) + tree_hash=$({ + printf "100644 blob %s\tedges\n" "$blob_hash" + [[ -n "$snap_hash" ]] && printf "100644 blob %s\trefs\n" "$snap_hash" + } | git mktree) local commit_hash local parent @@ -2132,7 +2161,7 @@ rebuild_edge_index() { fi done - write_edge_index "$edges" + write_edge_index "$edges" "$(current_ref_snapshot)" echo -e "${GREEN}Edge index rebuilt at $timestamp${NC}" } @@ -2144,58 +2173,58 @@ ensure_edge_index_current() { local edge_data edge_data=$(read_edge_index) - # Get the last rebuild timestamp from the index - local last_rebuilt - last_rebuilt=$(echo "$edge_data" | grep "^last_rebuilt_from:" | cut -d' ' -f2-) + # Object IDs rather than commit dates: a note arriving by fetch or import + # keeps the date it was written with, which can predate any local + # watermark, so a date cursor skips it permanently. + local stored current + stored=$(read_ref_snapshot) + current=$(current_ref_snapshot) + + [[ "$stored" == "$current" ]] && return 0 - # Find issue notes modified since last rebuild (single git call) - local needs_rebuild=false local changed_ids=() + while read -r ref oid; do + [[ -z "$ref" ]] && continue + if ! grep -qxF "$ref $oid" <<< "$stored"; then + changed_ids+=("${ref#refs/notes/issue-}") + fi + done <<< "$current" - # Ref dates are rendered as UTC in the same format as the last_rebuilt_from - # marker (written by date -u below), so the two are directly comparable as - # strings. Comparing a local-offset date (iso-strict) against a UTC marker - # silently misses edits by up to the local UTC offset. - # Note the >= comparison: a note committed in the same second as the marker - # must be treated as changed, since second granularity cannot order them. - while IFS=$'\t' read -r ref ref_date; do + # Deleted refs keep no headers to re-parse, so drop their edges here + while read -r ref _oid; do [[ -z "$ref" ]] && continue - if [[ -z "$last_rebuilt" ]] || [[ ! "$ref_date" < "$last_rebuilt" ]]; then - local id="${ref#refs/notes/issue-}" - changed_ids+=("$id") - needs_rebuild=true + if ! grep -q "^${ref} " <<< "$current"; then + local gone="${ref#refs/notes/issue-}" + edge_data=$(echo "$edge_data" | grep -v "^${gone} " || true) fi - done < <(TZ=UTC git for-each-ref --format="%(refname)%09%(committerdate:format-local:%Y-%m-%dT%H:%M:%SZ)" 'refs/notes/issue-*' 2>/dev/null) + done <<< "$stored" - if [[ "$needs_rebuild" == "true" ]]; then - # Re-parse only changed issues and update edges - for id in "${changed_ids[@]}"; do - local data - data=$(read_issue_data "$id" 2>/dev/null) || continue + for id in "${changed_ids[@]}"; do + local data + data=$(read_issue_data "$id" 2>/dev/null) || continue - # Remove old edges where this issue is the source. - # Target-side edges belong to their source issue and are handled - # when that source is processed (avoids ordering issues). - edge_data=$(echo "$edge_data" | grep -v "^${id} " || true) - - # Parse dep fields and add new edges - for field in blocks depends_on parent_of relates_to; do - local values - values=$(trim_ws "$(echo "$data" | grep "^${field}:" | head -1 | cut -d' ' -f2-)") - if [[ -n "$values" ]]; then - for target in $(echo "$values" | tr ',' '\n'); do - target=$(trim_ws "$target") - [[ -n "$target" ]] && edge_data="${edge_data}"$'\n'"${id} ${field} ${target}" - done - fi - done + # Remove old edges where this issue is the source. + # Target-side edges belong to their source issue and are handled + # when that source is processed (avoids ordering issues). + edge_data=$(echo "$edge_data" | grep -v "^${id} " || true) + + # Parse dep fields and add new edges + for field in blocks depends_on parent_of relates_to; do + local values + values=$(trim_ws "$(echo "$data" | grep "^${field}:" | head -1 | cut -d' ' -f2-)") + if [[ -n "$values" ]]; then + for target in $(echo "$values" | tr ',' '\n'); do + target=$(trim_ws "$target") + [[ -n "$target" ]] && edge_data="${edge_data}"$'\n'"${id} ${field} ${target}" + done + fi done + done - # Update marker and write - edge_data=$(echo "$edge_data" | grep -v "^last_rebuilt_from:" | grep -v "^$" || true) - edge_data="last_rebuilt_from: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"$'\n'"${edge_data}" - write_edge_index "$edge_data" - fi + # Update marker and write + edge_data=$(echo "$edge_data" | grep -v "^last_rebuilt_from:" | grep -v "^$" || true) + edge_data="last_rebuilt_from: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"$'\n'"${edge_data}" + write_edge_index "$edge_data" "$current" } # ========================================== diff --git a/tests/test_deps.sh b/tests/test_deps.sh index 5170067..118b631 100755 --- a/tests/test_deps.sh +++ b/tests/test_deps.sh @@ -685,6 +685,53 @@ test_dep_rebuild_matches_incremental() { fi } +# Dep headers edited outside dep add/rm must still reach the graph. Rebuild +# first: with no cursor the index rebuilds in full and hides the bug. +# Backdating stands in for a note arriving by fetch or import, which keeps the +# date it was written with. Fails on a date cursor in every timezone. +test_incremental_picks_up_backdated_ref() { + local a b + a=$(create_test_issue "Backdated pickup A") + b=$(create_test_issue "Backdated pickup B") + + git issue dep rebuild >/dev/null 2>&1 + + GIT_COMMITTER_DATE="2020-01-01T00:00:00Z" GIT_AUTHOR_DATE="2020-01-01T00:00:00Z" \ + git issue update "$a" --relates-to="$b" >/dev/null 2>&1 + + local output + output=$(git issue deps 2>/dev/null) + + assert_contains "$a" "$output" "deps sees a note whose commit date predates the index" + assert_contains "relates_to" "$output" "deps reports the backdated relation type" +} + +# A deleted note ref leaves no headers to re-parse, so its edges must be +# dropped from the index rather than kept until the next full rebuild. +test_incremental_drops_edges_of_deleted_ref() { + local a b + a=$(create_test_issue "Deleted ref A") + b=$(create_test_issue "Deleted ref B") + + git issue update "$a" --relates-to="$b" >/dev/null 2>&1 + git issue dep rebuild >/dev/null 2>&1 + + git update-ref -d "refs/notes/issue-$a" + + local output + output=$(git issue deps 2>/dev/null) + + TESTS_RUN=$((TESTS_RUN + 1)) + if [[ "$output" != *"$a"* ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}✓${NC} deps drops edges of a deleted note ref" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo -e " ${RED}✗${NC} deps still shows edges of a deleted note ref" + echo " Output: $output" + fi +} + # Main main() { echo -e "${BLUE}Testing Dependency Header Fields${NC}" @@ -779,6 +826,8 @@ main() { run_test "topo with no deps shows all issues" test_topo_no_deps_shows_all run_test "dep list with no deps shows clean output" test_dep_list_no_deps run_test "dep rebuild matches incremental index" test_dep_rebuild_matches_incremental + run_test "incremental index picks up backdated refs" test_incremental_picks_up_backdated_ref + run_test "incremental index drops edges of deleted refs" test_incremental_drops_edges_of_deleted_ref echo "" echo "================================="