Git Integration¶
The git module provides:
- Auto-commit + deferred push for write operations (via
on_write) - Periodic pull (ff-only) primitives used by the server to keep the working tree up to date
Quick Start¶
from pathlib import Path
from markdown_vault_mcp.git import GitWriteStrategy
from markdown_vault_mcp.vault import Vault, VaultSettings
strategy = GitWriteStrategy(
token="ghp_your_token",
push_delay_s=30,
)
vault = Vault(
source_dir=Path("/path/to/vault"),
settings=VaultSettings(read_only=False),
on_write=strategy,
)
# Writes are now auto-committed and pushed
vault.writer.write("notes/new.md", "Hello world")
# Clean up on shutdown
vault.close()
Migrating from 4.x¶
GitWriteStrategy no longer accepts commit_name_claim or commit_email_claim. Remove these constructor arguments. The remaining git_lfs and repo_path options must now be passed by keyword, so old positional claim arguments cannot silently become LFS or repository settings:
strategy = GitWriteStrategy(
commit_name="vault-service",
commit_email="vault-service@example.com",
git_lfs=False,
repo_path=Path("/path/to/vault"),
)
Author identity is supplied per write as a resolved Principal, through the callback's principal= argument. Integrations writing through Vault can use markdown_vault_mcp._identity.bound_principal around the write. Claim extraction belongs at the request boundary; the strategy does not read tokens.
Server operators keep using MARKDOWN_VAULT_MCP_GIT_COMMIT_NAME_CLAIM and MARKDOWN_VAULT_MCP_GIT_COMMIT_EMAIL_CLAIM. Configuration assembly registers those keys with the identity layer, which resolves them when the tool call arrives. A configured claim that an authenticated token cannot supply still produces its warning and falls back to the static identity.
The older startup warning about missing git config user.email is removed. The strategy supplies its configured committer name and email, falling back to markdown-vault-mcp <noreply@markdown-vault-mcp>, so an empty checkout identity is supported.
API Reference¶
GitWriteStrategy(token=None, username='x-access-token', repo_url=None, managed=False, enable_pull=True, enable_push=True, push_delay_s=30.0, commit_name=None, commit_email=None, *, git_lfs=True, repo_path=None)
¶
Stateful git strategy: commit per tool call, deferred push.
On each callback invocation:
- Stages the changed file (
git addorgit add -ufor deletes). - Commits with an auto-generated message (
"operation: path"). - Resets the push timer — push fires after
push_delay_sof idle.
Writes fired by one MCP tool call are grouped by the dispatcher (#1264)
and arrive at :meth:on_write_batch, which stages every path the same
scoped way and commits them once as "<tool>: N files". A call that
touched a single file commits under that file's own path instead, so
ordinary writes read in git log exactly as they always have. A write
with no owning tool call takes the per-write path above.
Push is deferred to a background threading.Timer that resets on
each write. When the timer fires (no writes for push_delay_s),
all accumulated local commits are pushed in a single git push.
On startup, any unpushed local commits (from a previous crash) are pushed immediately.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str | None
|
PAT for HTTPS push via |
None
|
username
|
str
|
Username used with token auth. Defaults to
|
'x-access-token'
|
repo_url
|
str | None
|
Remote URL expected in managed mode. |
None
|
managed
|
bool
|
When |
False
|
enable_pull
|
bool
|
Enable fetch + ff-only sync methods. |
True
|
enable_push
|
bool
|
Enable deferred push behavior. |
True
|
push_delay_s
|
float
|
Seconds of idle before pushing. |
30.0
|
commit_name
|
str | None
|
Git committer name; defaults to
:attr: |
None
|
commit_email
|
str | None
|
Git committer email; defaults to
:attr: |
None
|
git_lfs
|
bool
|
When |
True
|
repo_path
|
Path | None
|
Optional repository path used for startup validation.
When set together with |
None
|
Author identity arrives per write as a resolved principal. OIDC
claim-key registration belongs to
:func:markdown_vault_mcp._identity.configure_identity_claims, not this
constructor. The removed claim keywords raise :exc:TypeError;
git_lfs and repo_path must be passed by keyword (#1236).
Example::
strategy = GitWriteStrategy(token="ghp_...", push_delay_s=30)
vault = Vault(on_write=strategy, ...)
# ... writes happen, push deferred ...
strategy.close() # final flush
is_managed
property
¶
Whether this strategy owns a managed clone of a remote repository.
Part of the :class:~markdown_vault_mcp.git.interfaces.Syncer seam
(#1229): the git_sync tool gates on it, and did so by reading the
private attribute before the promotion.
validate_startup(repo_path)
¶
Validate startup git settings for token-authenticated workflows.
on_write_batch(items, tool_name)
¶
Commit every write from one tool call as a single commit.
Mirrors :meth:__call__'s guards and push scheduling; only the commit
boundary differs, and for a call that wrote a single file not even
that — see :func:_stage_and_commit_batch. The author identity is
taken from the first item's principal: one tool call has one acting
principal, so the items cannot legitimately disagree.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
items
|
Sequence[WriteBatchItem]
|
The tool call's writes, in the order they were fired. |
required |
tool_name
|
str
|
The MCP tool that produced them. |
required |
__call__(path, content, operation, *, old_path=None, principal=None)
¶
WriteCallback interface: stage + commit, then schedule push.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Path
|
Absolute path of the file the operation landed on. For a rename this is the new path. |
required |
content
|
str
|
File content at write time; unused here, since staging reads the working tree. |
required |
operation
|
WriteOperation
|
The kind of write that occurred. |
required |
old_path
|
Path | None
|
For a rename, the absolute path the file moved from, so staging can be scoped to it and path (#894). |
None
|
principal
|
Principal | None
|
The identity performing the write, resolved at the MCP
tool edge and snapshotted through the dispatcher queue
(#1160). Its display name / email become the commit's
|
None
|
resolve_force_repo()
¶
Return the working tree path used by force_* methods.
Returns:
| Type | Description |
|---|---|
Path
|
The configured working tree. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When no |
head_sha(git_root)
¶
Return the current HEAD SHA of git_root.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
git_root
|
Path
|
The working tree to read. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The full HEAD SHA. |
branch_name(git_root)
¶
Return the checked-out branch name of git_root.
A detached HEAD yields "HEAD" from git itself. Failures to invoke
git at all propagate; the caller decides whether a fallback is
appropriate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
git_root
|
Path
|
The working tree to read. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The branch name. |
force_pull(*, dry_run=False)
¶
Pull from origin synchronously and return a structured result.
The remote-tracking branch is resolved as origin/<current-branch>
(see :meth:_tracking_ref) so this method works even when branch
tracking (@{upstream}) was never configured on the local clone —
falling back to origin/HEAD for a detached checkout.
Acquires :attr:_lock for the duration so the periodic pull loop
and the per-write commit path cannot race against the fetch /
merge / rebase pipeline. This blocks writes for the network
round-trip; that is acceptable for the interactive git_sync
tool and mirrors what :meth:sync_once already does.
Before the merge it self-quiesces via :meth:_quiesce_writes: new
writes are paused and the deferred-commit queue is drained (best-effort,
time-bounded) so a write that landed just before the pull is committed
first and the merge runs on a clean tree (#571). Skipped under
dry_run (which only fetches and never touches the working tree).
Divergent history is detected before the merge (git merge-base
--is-ancestor, #1292) and goes straight to the same rebase +
Syncthing-style sibling write path used by :meth:sync_once; a
working tree that refuses an otherwise available fast-forward
falls through to it too (see
:func:~markdown_vault_mcp.git.conflict.resolve_rebase_conflicts and
:func:~markdown_vault_mcp.git.conflict.write_conflict_files). When
the conflict-resolution
path produces sibling files HEAD has advanced to the remote and
:attr:PullResult.applied is True with
:attr:PullResult.reason set to
"conflicts_resolved_with_siblings".
After a successful HEAD advance — fast-forward or sibling
resolution — :meth:_lfs_pull runs so any LFS pointers in the
new commits are materialised before the caller sees the working
tree.
A strategy built without remote sync — unmanaged / commit-only
mode, where enable_pull is False — has no remote to pull
from, so this returns applied=False with reason
"pull_disabled" before running any git command (#1128).
Without that gate the pipeline ran git fetch origin on a
remoteless checkout (answering "fetch_failed", which reads as
retryable) and raised CalledProcessError out of head_sha
on a vault that is not a git repository at all. enable_pull
gated only the periodic loop before; it now gates every pull.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dry_run
|
bool
|
When |
False
|
Returns:
| Type | Description |
|---|---|
PullResult
|
class: |
PullResult
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When the strategy was constructed without
|
force_push(*, dry_run=False)
¶
Push local commits to origin, recording what the remote said.
Holds the strategy-wide lock across both the push (:meth:_push_locked)
and the recording of its outcome, so the interactive push feeds the
same sync-health tracker as the deferred one (#1287) and cannot
publish out of order against it (PR #1300). The two observe the same
remote, so a caller warned about stranded writes stops being warned as
soon as either of them lands.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dry_run
|
bool
|
See :meth: |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
The |
PushResult
|
class: |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When the strategy was constructed without
|
sync_health()
¶
Report whether this clone is known not to be reaching its remote.
Read without taking the strategy-wide lock, so a write response never blocks behind an in-flight pull.
Returns:
| Name | Type | Description |
|---|---|---|
A |
SyncHealth | None
|
class: |
SyncHealth | None
|
clone is unsynced, or |
|
SyncHealth | None
|
has never tried — a commit-only vault never pushes, so it never |
|
SyncHealth | None
|
reports anything). |
sync_once(repo_path, *, timer_driven=False)
¶
Fetch and update once, returning True if HEAD advanced.
Thin adapter over :meth:_pull_pipeline (#879) — the periodic
pull loop and the interactive git_sync tool now share one
fetch → classify → ff-only → rebase → sibling implementation, so the loop
gets the pipeline's safe conflict handling: defensive rebase
abort and an upstream restore that drops paths whose restore
failed instead of committing stale local content over them.
The pipeline self-quiesces before the merge via
:meth:_quiesce_writes (pause new writes + drain the
deferred-commit queue, best-effort/time-bounded) so a write
racing the periodic pull is committed first and the merge runs
on a clean tree (#571). The pause is held for the whole fetch +
merge — including the network round-trip — so MCP writes block
for the pull's duration; acceptable for a periodic background
pull (default every 600 s) and a fast fetch.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
repo_path
|
Path
|
The vault directory; its git root is resolved once. |
required |
timer_driven
|
bool
|
|
False
|
set_write_quiescer(pause_writes, drain_writes)
¶
Wire the write-quiescing callables used before a pull (#571).
Called once by the owner (Vault) after the write-callback
dispatcher exists, so both the interactive force_pull and the
periodic sync_once can pause new writes and drain pending commits
before the merge — independent of whether the periodic pull loop is
started.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pause_writes
|
Callable[[], AbstractContextManager[None]]
|
Context manager that blocks new file mutations while held (acquires the shared file-write lock). |
required |
drain_writes
|
Callable[[], bool]
|
Blocks until all already-queued write callbacks have
been committed; returns |
required |
start(*, repo_path, pull_interval_s, on_pull=None)
¶
Start a periodic fetch + ff-only update loop in a daemon thread.
stop()
¶
Stop the pull loop thread if it is running.
flush()
¶
Block until any pending push completes.
Cancels the idle timer and pushes immediately if there are
pending local commits. Thin delegation to
:meth:PushScheduler.flush, which owns the timer/pending-flag
mechanics (#893).
close()
¶
Cancel timer, flush pending push, mark strategy as closed.
Sequencing: mark closed first (new writes become no-ops), stop the periodic pull thread, then flush the push scheduler so the final push happens with no pull tick racing it.
get_file_history(repo_path, path, since, limit, until=None, *, is_dir=False)
¶
Return commits that touched path (or the whole vault).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
repo_path
|
Path
|
Path inside the git repository (used to locate the root). |
required |
path
|
Path | None
|
Absolute path of the file (or directory, when is_dir) to
filter on, or |
required |
is_dir
|
bool
|
When |
False
|
since
|
str | None
|
Passed as |
required |
limit
|
int
|
Maximum number of commits to return (capped at 100). |
required |
until
|
str | None
|
Passed as |
None
|
Returns:
| Type | Description |
|---|---|
list[HistoryEntry]
|
List of :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
get_file_diff(repo_path, path, ref, per_commit, since_timestamp=None, limit=None, *, summarize_binary=False)
¶
Return a unified diff of path from ref to HEAD.
Exactly one of ref or since_timestamp must be supplied. When
since_timestamp is given, it is resolved via
git rev-list --before=<ts> -1 HEAD to the most recent commit at
or before that instant. Boundary is inclusive: a commit whose
committer date equals since_timestamp IS the resolved ref.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
repo_path
|
Path
|
Path inside the git repository. |
required |
path
|
Path
|
Absolute path of the file to diff. |
required |
ref
|
str | None
|
The git ref (SHA or expression) to diff from. Mutually exclusive with since_timestamp. |
required |
per_commit
|
bool
|
When |
required |
since_timestamp
|
str | None
|
ISO 8601 datetime string resolved to a commit SHA
via |
None
|
limit
|
int | None
|
When per_commit is |
None
|
summarize_binary
|
bool
|
When True and the file is binary, return a
|
False
|
Returns:
| Type | Description |
|---|---|
str | list[CommitDiff]
|
A unified diff string when per_commit is |
str | list[CommitDiff]
|
class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If ref is not found in history, since_timestamp cannot be resolved, or a git subprocess exits non-zero. |
get_file_at_ref(query_)
¶
Return a note's content as it stood at a revision (#1137).
Resolution is by note identity, walked out of git's own add and rename records; where those records do not reach the revision asked for, this raises rather than returning another note's content.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query_
|
RevisionQuery
|
The note, revision, and read cap being asked for. |
required |
Returns:
| Type | Description |
|---|---|
RevisionContent
|
The content and the path the note carried at that revision. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the vault is not git-backed, the revision is not an ancestor of HEAD, the note's identity cannot be traced to it, or the content is unreadable as a note. |
committed_revision(repo_path, path)
¶
Return the commit whose stored content for path is what is on disk.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
repo_path
|
Path
|
Path inside the git repository. |
required |
path
|
Path
|
Absolute path of the note. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The commit SHA, or |
str | None
|
working copy has moved on from its newest one, or git cannot be |
str | None
|
consulted. |
git_write_strategy(token=None, push_delay_s=0, git_lfs=True)
¶
Create a :class:GitWriteStrategy callback.
Convenience wrapper around :class:GitWriteStrategy. With the
default push_delay_s=0, commits happen per-write but push only
fires when :meth:~GitWriteStrategy.close or
:meth:~GitWriteStrategy.flush is called.
When used via :class:~markdown_vault_mcp.vault.Vault,
Vault.close() automatically calls the strategy's
close(), so pushes flush on shutdown. Callers using this
as a bare WriteCallback must retain a reference and call
close() explicitly.
.. deprecated::
Prefer :class:GitWriteStrategy directly for access to
:meth:~GitWriteStrategy.flush and :meth:~GitWriteStrategy.close.
.. note::
The default push_delay_s=0 here differs from
:class:GitWriteStrategy's default of 30.0. This preserves
backward compatibility (push on close/flush only).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str | None
|
PAT for HTTPS push. |
None
|
push_delay_s
|
float
|
Push delay in seconds (default 0 = push on close only). |
0
|
git_lfs
|
bool
|
When |
True
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
GitWriteStrategy
|
class: |
GitWriteStrategy
|
data: |