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 "================================="