feat: per-author commits via attributed-content endpoint - #9
Draft
petergaultney wants to merge 6 commits into
Draft
feat: per-author commits via attributed-content endpoint#9petergaultney wants to merge 6 commits into
petergaultney wants to merge 6 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Companion to relay-server PR: No-Instructions/relay-server#25 (the
attributed-contentendpoint this feature consumes).Summary
When an
[authors]table is configured ingit_connectors.toml, sync commits are authored by the person who wrote the majority of the changed content, sogit blameshows real people instead of the sync bot.The attribution flow:
git diffagainst the previously committed version./d/:doc_id/attributed-contentendpoint (server-auth required).Co-authored-bytrailers.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-contentendpoint:[authors]config: attribution is completely inert - no endpoint calls, no behavior change. TheAuthorResolvershort-circuits before any fetch whenauthorsis empty.[authors]configured but endpoint missing:fetch_attributed_spanscatches all exceptions and returnsNone, so the resolver returns an empty author list and the commit falls back to the default bot identity.In all three cases, sync behavior is identical to unmodified git-sync.
Deletion handling
File deletions and renames require special handling:
Configuration
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 parsingtests/test_attribution.py- 16 unit teststests/test_per_author_commits.py- 7 integration testsTest plan
[authors]config disables attribution (no endpoint calls)