diff --git a/functions/_agents_repo_sync.fish b/functions/_agents_repo_sync.fish
new file mode 100644
index 0000000..513d888
--- /dev/null
+++ b/functions/_agents_repo_sync.fish
@@ -0,0 +1,55 @@
+# Copyright (C) 2026 Rootiest
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# SYNOPSIS
+# _agents_repo_sync
+#
+# DESCRIPTION
+# Pulls (when an upstream is configured), stages everything, and commits
+# with . Shared by agents-init and agents-vault.
+#
+# A failed rebase is aborted and nothing is committed. Committing blindly
+# after a failed pull would stage conflict markers and record them under a
+# routine-looking message, so the failure is surfaced instead: the repo is
+# left clean at local HEAD for the user to resolve by hand.
+#
+# Commits are made with commit.gpgsign=false so a pinentry prompt can
+# never block a shell or an agent launch.
+#
+# ARGUMENTS
+# dir Absolute path to the git repository
+# message Commit subject used when there is something to commit
+#
+# EXIT STATUS
+# 0 Committed, or nothing needed committing
+# 1 is not a git repository
+# 2 Rebase conflict; aborted, nothing committed
+#
+# RETURNS
+# A single "→ Committed () " line on stdout when it
+# commits; nothing when there was nothing to do.
+#
+# EXAMPLE
+# _agents_repo_sync /path/to/AGENTS "chore: sync AGENTS repository"
+function _agents_repo_sync --argument-names dir msg
+ test -n "$dir" -a -n "$msg"; or return 1
+ test -d "$dir/.git"; or return 1
+
+ if git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
+ if not git -C "$dir" pull --rebase --autostash -q >/dev/null 2>/dev/null
+ git -C "$dir" rebase --abort >/dev/null 2>/dev/null
+ echo "_agents_repo_sync: rebase conflict in $dir; aborted, left at local HEAD" >&2
+ return 2
+ end
+ end
+
+ git -C "$dir" add -A 2>/dev/null
+ set -l status_out (git -C "$dir" status --porcelain 2>/dev/null)
+ test -n "$status_out"; or return 0
+
+ if git -C "$dir" -c commit.gpgsign=false commit -q -m "$msg" 2>/dev/null
+ set -l sha (git -C "$dir" rev-parse --short HEAD 2>/dev/null)
+ set -l subject (git -C "$dir" log -1 --pretty=%s 2>/dev/null)
+ echo "→ Committed ($sha) $subject"
+ end
+end
diff --git a/tests/test-agents-vault.fish b/tests/test-agents-vault.fish
index c59589b..0c50b02 100644
--- a/tests/test-agents-vault.fish
+++ b/tests/test-agents-vault.fish
@@ -136,6 +136,53 @@ ln -s $p/one $p/link
_agents_repo_ensure_symlink $p/link $p/two >/dev/null
check "repins a wrong link" (path resolve $p/two) (path resolve $p/link)
+# ─────────────────────────── sync policy ───────────────────────────────
+echo ""
+echo "== _agents_repo_sync =="
+
+set -l s (new_repo)
+echo hello >$s/a.md
+_agents_repo_sync $s "chore: test" >/dev/null
+check "commits new content" 1 (git -C $s rev-list --count HEAD)
+
+# Nothing to do: no second commit, no output.
+set -l out (_agents_repo_sync $s "chore: test")
+check "idempotent, no new commit" 1 (git -C $s rev-list --count HEAD)
+check "idempotent, silent" "" "$out"
+
+# Conflict: two clones diverge with conflicting commits on the same line.
+# (Merely leaving "ours" uncommitted in the worktree isn't enough to force
+# a rebase conflict -- with nothing local to replay, --autostash's rebase
+# step fast-forwards cleanly and only the stash *pop* would conflict,
+# leaving "theirs" committed on HEAD with "ours" stranded in the stash.
+# Committing "ours" locally first means the rebase itself must replay a
+# real commit over "theirs" on the same line, which is where the intended
+# conflict-and-abort path actually lives.)
+set -l origin (mktemp -d); set -ga TMPDIRS $origin
+git -C $origin init -q --bare
+git -C $s remote add origin $origin
+git -C $s push -q -u origin HEAD:refs/heads/main 2>/dev/null
+
+set -l clone (mktemp -d); set -ga TMPDIRS $clone
+git clone -q $origin $clone
+git -C $clone config user.email t@t
+git -C $clone config user.name t
+git -C $clone config commit.gpgsign false
+git -C $clone config core.hooksPath /dev/null
+echo theirs >$clone/a.md
+git -C $clone commit -qam theirs
+git -C $clone push -q origin HEAD:main
+
+echo ours >$s/a.md
+git -C $s commit -qam ours
+set -l before_count (git -C $s rev-list --count HEAD)
+set -l rc (_agents_repo_sync $s "chore: test" 2>/dev/null; echo $status)
+check "conflict returns 2" 2 "$rc"
+check "conflict leaves no rebase in progress" false (test -d $s/.git/rebase-merge -o -d $s/.git/rebase-apply; and echo true; or echo false)
+check "conflict commits nothing" $before_count (git -C $s rev-list --count HEAD)
+check "conflict content survives" ours (cat $s/a.md)
+check "conflict left no markers" false (grep -q '<<<<<<<' $s/a.md; and echo true; or echo false)
+
cleanup
echo ""
echo (math $TESTS_RUN - $TESTS_FAILED)"/$TESTS_RUN passed"