Skip to content

feat: per-author commits via attributed-content endpoint - #9

Draft
petergaultney wants to merge 6 commits into
No-Instructions:mainfrom
TrilliantHealth:git-blame/per-author-commits
Draft

feat: per-author commits via attributed-content endpoint#9
petergaultney wants to merge 6 commits into
No-Instructions:mainfrom
TrilliantHealth:git-blame/per-author-commits

Conversation

@petergaultney

Copy link
Copy Markdown
Contributor

Companion to relay-server PR: No-Instructions/relay-server#25 (the attributed-content endpoint this feature consumes).

Summary

When an [authors] table is configured in git_connectors.toml, sync commits are authored by the person who wrote the majority of the changed content, so git blame shows real people instead of the sync bot.

The attribution flow:

  1. After materializing a file change, compute the changed character ranges using git diff against the previously committed version.
  2. Fetch per-span authorship from the relay server's /d/:doc_id/attributed-content endpoint (server-auth required).
  3. Intersect the changed ranges with the attributed spans to find who wrote what.
  4. The dominant author (most changed characters) gets the commit; other contributors appear as Co-authored-by trailers.

Changes that can't be attributed (deletions, unmapped users, binary files, attribution fetch failures) fall back to the default bot identity.

Backward compatible

This feature is fully backward compatible with relay servers that don't have the attributed-content endpoint:

  • No [authors] config: attribution is completely inert - no endpoint calls, no behavior change. The AuthorResolver short-circuits before any fetch when authors is empty.
  • [authors] configured but endpoint missing: fetch_attributed_spans catches all exceptions and returns None, so the resolver returns an empty author list and the commit falls back to the default bot identity.
  • Endpoint exists but returns no spans: same graceful fallback.

In all three cases, sync behavior is identical to unmodified git-sync.

Deletion handling

File deletions and renames require special handling:

  • A rename appears as a deletion + addition in separate webhook events. Deletions are deferred for a short window; if a matching addition arrives (same content), the pair is committed atomically under the adder's authorship.
  • Unpaired deletions (real deletes) are released after the deferral window expires.
  • The commit timer stays alive while deferred deletions are pending, so they don't get stuck waiting for the next edit.

Configuration

[authors]
"relay_user_id_1" = "Ada Lovelace <ada@example.com>"
"relay_user_id_2" = "Bob Bobson <bob@example.com>"

The relay user ids are the same opaque ids that appear in relay server connection logs and the PUD "users" map.

New modules

  • attribution.py - span intersection, author resolution, config parsing
  • tests/test_attribution.py - 16 unit tests
  • tests/test_per_author_commits.py - 7 integration tests

Test plan

  • Verify single-author edit produces a commit authored by that person
  • Verify multi-author edit credits the dominant author with co-author trailers
  • Verify deletion-only changes fall back to bot identity
  • Verify rename (delete + add) commits atomically under the adder
  • Verify unpaired deletion is released after deferral window
  • Verify missing [authors] config disables attribution (no endpoint calls)
  • Verify attribution fetch failure falls back gracefully
  • 23 new tests pass

When an [authors] table (relay user id -> 'Name <email>') is configured and
the relay server supports the /d/:doc_id/attributed-content endpoint, each
sync cycle groups changed markdown files by the dominant author of their
changed characters and makes one commit per author (committer stays the
sync identity). Deletion-only changes, unmapped users, mismatched spans,
binary files, and endpoint failures all fall through to the default
single-identity commit, so behavior without config is unchanged.

Attribution math lives in attribution.py: the changed character ranges of
the new content (difflib opcodes vs the HEAD version) are mapped onto the
server's per-author spans, and the author with the most changed characters
wins the file.
_group_changes_by_author stripped the connector prefix to get vault_path
but never restored the leading slash that update_local_file_state's
callers always store paths with (Relay's own filemeta path convention).
Every lookup silently missed, doc_id was always falsy, and every commit
fell through to the default bot identity - confirmed live on ml-notes:
attribution never fired despite the relay-server endpoint returning
correct spans.

The test's mocked local_file_state used the same unprefixed keys as the
buggy lookup, so it never caught this.
Pair a deleted .md path with a new file of byte-identical content (a
rename's two halves) so both land in the author's commit and git's own
rename detection connects the history. Attribution now returns all
contributing users ordered by changed characters; the dominant one
authors the commit and the rest become Co-authored-by trailers.
difflib.SequenceMatcher at character level is quadratic in the worst
case: a 1.1MB machine-generated frontmatter.md in product-dev pinned the
sync process's GIL for 25+ minutes, silently freezing all commits, sync,
and logging (2026-08-04, rolled back to ddf3633).

git diff -U0 (Myers with heuristics, in C) produces new-side line
ranges in milliseconds on the same file; attribution converts those to
char ranges and overlaps them with authorship spans via a two-pointer
sweep, so the whole path is linear. Line granularity is equivalent in
practice for picking a dominant author. Also drops the git show of the
old content - the diff already carries everything needed.
An in-app Obsidian rename keeps the doc_id (a filemeta move), so
git-sync moves the local file and the delete + add pair in the same
commit cycle. But a disk-level mv reaches Obsidian as file-deleted +
file-created: the plugin deletes one doc and creates a new one, and the
two halves can arrive across commit cycles (observed 52s apart live).
The delete was committed alone by the bot before the re-add existed,
splitting the rename and breaking git's rename detection.

Hold an unpaired .md deletion out of all commits for up to
DELETION_PAIRING_WINDOW_S (90s); when a byte-identical new file
arrives, both halves commit together. An unpaired deletion older than
the window is a real deletion and releases to the default-identity
sweep as before.
Two compounding flaws starved deferred deletions of their release pass,
leaving files unlinked in the worktree but never committed (observed
live: 27 pure deletions stranded for 20+ minutes, surviving a pod
restart):

- has_changes is a single global flag, reset when ANY repo commits
  ANYTHING. A commit of unrelated work cleared it while young unpaired
  deletions were deliberately held back, and with no later sync events
  the timer never called commit_changes again to release them.
- the startup sync bypasses the operations queue, so sweep work done at
  boot (deleting local files for remotely deleted docs) never set
  has_changes at all - a restarted pod re-deleted the files and then
  went idle without committing.

commit_changes now records deferred_deletions_pending; the timer gate
honors it alongside has_changes, and app startup forces one commit pass
after the initial sync.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant