From 51543cb7cac113c231e23f0240b7fe020fcd13da Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 2 Sep 2026 21:15:15 -0400 Subject: [PATCH] feat(agents-vault): add sync helper that refuses to commit conflicts agents-init currently swallows a failed rebase and then stages and commits whatever is in the tree, which records conflict markers under a routine message. No AGENTS repo has a remote today so the pull never runs, but the vault gives these repos remotes and arms it. The shared helper aborts the rebase, commits nothing, and returns 2. It also redirects git's own stdout during the pull/abort: git prints "CONFLICT (content): ..." to stdout, not stderr, so without this the message would leak into the helper's own stdout instead of staying diagnostic-only. The conflict fixture commits "ours" locally before diverging, since an uncommitted worktree change has nothing for --autostash's rebase step to replay -- it fast-forwards cleanly and only the stash pop would conflict. --- functions/_agents_repo_sync.fish | 55 ++++++++++++++++++++++++++++++++ tests/test-agents-vault.fish | 47 +++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 functions/_agents_repo_sync.fish 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"