From 5e015cf27788023ea6997eabe8bb61e41a8ab117 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 decided which issues to re-parse by comparing each note ref's commit date against a wall-clock marker. That misses changes two ways. The comparison was lexical between incompatible formats. %(committerdate:iso-strict) renders the offset recorded in the commit, so 2026-08-02T22:26:21-07:00 sorts below 2026-08-03T05:26:20Z despite being a second newer. More fundamentally, 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. 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 out-of-band header edit with TZ pinned west of UTC, and an edit backdated with GIT_COMMITTER_DATE. Both fail against the previous implementation in every timezone tested. Co-Authored-By: Claude Opus 5 --- bin/git-issue | 115 +++++++++++++++++++++++++++++---------------- tests/test_deps.sh | 42 +++++++++++++++++ 2 files changed, 117 insertions(+), 40 deletions(-) diff --git a/bin/git-issue b/bin/git-issue index c82aab2..db6a692 100755 --- a/bin/git-issue +++ b/bin/git-issue @@ -1764,16 +1764,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 @@ -1888,7 +1917,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}" } @@ -1900,52 +1929,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" - 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 < <(git for-each-ref --format="%(refname)%09%(committerdate:iso-strict)" '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=$(echo "$data" | grep "^${field}:" | head -1 | cut -d' ' -f2- | xargs) - if [[ -n "$values" ]]; then - for target in $(echo "$values" | tr ',' '\n'); do - target=$(echo "$target" | xargs) - [[ -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=$(echo "$data" | grep "^${field}:" | head -1 | cut -d' ' -f2- | xargs) + if [[ -n "$values" ]]; then + for target in $(echo "$values" | tr ',' '\n'); do + target=$(echo "$target" | xargs) + [[ -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 fae0e77..4234953 100755 --- a/tests/test_deps.sh +++ b/tests/test_deps.sh @@ -650,6 +650,46 @@ 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. +# TZ is pinned west of UTC so the note records a negative offset, which fails +# a lexical date comparison whatever the host timezone. +test_incremental_picks_up_header_edit() { + local a b + a=$(create_test_issue "Incremental pickup A") + b=$(create_test_issue "Incremental pickup B") + + git issue dep rebuild >/dev/null 2>&1 + + # Write the header directly, bypassing dep add + TZ="America/Los_Angeles" 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 an out-of-band header edit" + assert_contains "relates_to" "$output" "deps reports the edited relation type" +} + +# 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" +} + # Main main() { echo -e "${BLUE}Testing Dependency Header Fields${NC}" @@ -743,6 +783,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 header edits" test_incremental_picks_up_header_edit + run_test "incremental index picks up backdated refs" test_incremental_picks_up_backdated_ref echo "" echo "================================="