v1.92.38.0 fix(brain-sync): set local edits aside around the retry merge and say why it failed (#3704) (#3728)

* test(brain-sync): a rejected push's retry merge must not wedge on local edits (#3704)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(brain-sync): set local edits aside around the retry merge and say why it failed (#3704)

After a rejected push, git refused the fetch-and-merge whenever a file it would update had uncommitted local edits, and brain-sync discarded git's error, so every later run hit the same refusal and stayed push_failed. The merge now stashes tracked edits first and restores them after; a failed merge is aborted; a conflicting restore keeps the merged version in the working tree and names the stash and the restore command in the status. push_failed carries git's merge reason and the real push error line.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(brain-sync): set aside only the paths the retry merge changes, read them literally, and pop only our own stash entry (#3704)

Pre-landing review fixes: a pattern-like conflicting file name no longer resets unrelated local edits, a stash push that saves nothing no longer pops an older entry, and a file that never checks out clean no longer strands every other local edit in the stash.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(brain-sync): describe the retry merge, the kept stash and the push_failed reason (#3704)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-01 20:15:10 +00:00
1 parent 1ea86923a8
commit ab2852fb40
8 files changed
+280 -6

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [1.92.38.0] - 2026-11-01
**brain-sync no longer gets stuck in `push_failed` when another machine changed a file you have edited locally.** After a rejected push, brain-sync fetches and merges the other machine's commits. Git refuses that merge when a file it would update has uncommitted local edits. brain-sync hid git's error and hit the same refusal on every run, so cross-machine sync stopped for good (#3704).
### Fixed
- **The retry merge sets aside local edits to the files it changes and puts them back** (`bin/gstack-brain-sync`). Other local edits stay where they are, file names are never read as patterns, and only the stash entry this run made is put back. A failed merge is aborted, so it never leaves conflict markers or a half-finished merge in `~/.gstack`.
- **When a local edit conflicts with the other machine's change, the other machine's version stays.** The local edit is kept in a git stash. The status names the stash and the exact `git -C ~/.gstack --literal-pathspecs restore --source=<stash> -- <path>` command that brings it back. A later sync never pushes the old local copy over the other machine's change. Files with a merge driver (`*.jsonl`, the union-merged plans) merge as before.
- **`push_failed` now says why.** It carries git's merge reason (for example `CONFLICT (content): Merge conflict in developer-profile.json`) and the real push error line instead of git's `To <remote>` banner.
### Tests
- `test/brain-sync.test.ts`: a dirty file the remote didn't touch, a dirty file the remote also changed (including a second sync and the restore command), local-delete/remote-edit, local-edit/remote-delete, a conflicting file whose name looks like a pattern, a file that never checks out clean, a stash push that saves nothing, and a committed conflict. Five of the eight fail on main.
## [1.92.37.0] - 2026-11-01
- **A Red Team finding that quotes `origin/<base>` no longer stops `/ship`'s review** (`lib/review-brief.ts`, GST-797). The renderer checked each finished brief for unfilled slots like `<base>`, `<diff-base>` and `<exclude>` after the reviewers' findings and past learnings had been pasted in, so a finding that quoted one failed the render with `BRIEF_RENDER_FAILED: red-team: unfilled placeholder <base>` (seen on #3795). A quoted `<diff-base>` was also quietly swapped for the commit sha. The check now runs on the brief before that text goes in, and the quoted text reaches the reader exactly as written. A slot the section really leaves open still fails the render. A brief whose findings and learnings quote no slot name renders the same bytes as before.
+1 -1
View File
@@ -1 +1 @@
1.92.37.0
1.92.38.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.92.37.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.92.38.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+59 -2
View File
@@ -585,6 +585,60 @@ transcripts_consented() {
transcript_policy --check 2>/dev/null
}
# After a rejected push: merge origin/<branch> into HEAD (#3704). git refuses
# a merge that would overwrite uncommitted edits to tracked files, and that
# refusal used to repeat on every run, so local edits to the paths the merge
# changes are set aside first and put back after; other local edits stay in
# place, and only a stash entry this call created is put back. Paths are
# literal, never patterns. A failed merge is aborted, so no half-finished merge
# or conflict markers block the next commit; git's reason lands in MERGE_ERR.
# When putting an edit back conflicts with what the merge brought in, the
# merged version stays in the working tree, so a later sync never publishes
# the stale local side over the other machine's change, and the local edit
# stays in the kept stash entry, which the status names with the command
# that restores it.
MERGE_ERR=""
merge_origin_keeping_local_edits() {
local branch="$1" out rc=0 p stash_before="" stash_sha=""
local -a lit=(git --literal-pathspecs -C "$GSTACK_HOME") blocking=()
while IFS= read -r -d '' p; do
"${lit[@]}" diff --quiet --no-renames HEAD "origin/$branch" -- "$p" 2>/dev/null || blocking+=("$p")
done < <("${lit[@]}" diff -z --no-renames --name-only HEAD 2>/dev/null)
if [ "${#blocking[@]}" -gt 0 ]; then
stash_before=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null)
if ! out=$("${lit[@]}" stash push -q -m "gstack-brain-sync: local edits before merging origin/$branch" -- "${blocking[@]}" 2>&1); then
MERGE_ERR="could not set local edits aside: $(printf '%s\n' "$out" | head -1)"
return 1
fi
stash_sha=$("${lit[@]}" rev-parse -q --verify refs/stash 2>/dev/null)
[ "$stash_sha" != "$stash_before" ] || stash_sha=""
fi
out=$("${lit[@]}" merge --no-edit "origin/$branch" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
MERGE_ERR=$(printf '%s\n' "$out" | grep -m1 -E '^(CONFLICT|error|fatal)' || printf '%s\n' "$out" | head -1)
"${lit[@]}" merge --abort >/dev/null 2>&1 || true
fi
[ -n "$stash_sha" ] || return "$rc"
out=$("${lit[@]}" stash pop -q 2>&1) && return "$rc"
local paths="" args="" failed=0
while IFS= read -r -d '' p; do
if "${lit[@]}" cat-file -e "HEAD:$p" 2>/dev/null; then
"${lit[@]}" checkout -q HEAD -- "$p" >/dev/null 2>&1 || failed=1
else
{ "${lit[@]}" rm -q --cached -- "$p" >/dev/null 2>&1 && rm -f -- "$GSTACK_HOME/$p"; } || failed=1
fi
paths="$paths ${p}"
args="$args $(printf '%q' "$p")"
done < <("${lit[@]}" diff -z --name-only --diff-filter=U 2>/dev/null)
"${lit[@]}" reset -q >/dev/null 2>&1 || failed=1
if [ -z "$paths" ] || [ "$failed" = 1 ]; then
MERGE_ERR="local edits could not be put back after merging origin/$branch ($(printf '%s\n' "$out" | head -1)); they are saved in git -C $GSTACK_HOME stash list"
return 1
fi
ST_NOTE="$ST_NOTE; local edits to$paths conflicted with the merged remote version, which is kept; the local edits are saved in stash $stash_sha (get them back: git -C $GSTACK_HOME --literal-pathspecs restore --source=$stash_sha --$args)"
return "$rc"
}
# Before every push: scan the unpublished range for transcript pages outside
# the current consent; when some exist, the helper rewrites gstack-brain-sync's
# own unpublished commits without them, or refuses (exit 3) when it cannot.
@@ -1379,7 +1433,7 @@ subcmd_once() {
bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then
local branch
branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)
if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1 && guard_unpublished_range; then
if merge_origin_keeping_local_edits "$branch" && guard_unpublished_range; then
validate_outgoing_paths || exit 0
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
@@ -1391,7 +1445,10 @@ subcmd_once() {
fi
fi
# Commit exists locally; the run-start detector re-pushes it next time.
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run"
local push_reason
push_reason=$(printf '%s\n' "$push_err" | grep -v -E '^(To |hint:)' | head -1)
[ -n "$push_reason" ] || push_reason=$(printf '%s\n' "$push_err" | head -1)
write_status "push_failed" "push failed: $push_reason${MERGE_ERR:+; merging origin failed: $MERGE_ERR}; commit retained locally, will retry next run"
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
exit 0
}
+5 -1
View File
@@ -107,7 +107,11 @@ After fixing auth, run any skill to retry sync automatically.
git's error appears after the colon.
**Cause.** Could be network issue, rejected push (remote ahead), server 500,
or repo access revoked.
or repo access revoked. The line is git's own error, not its `To <remote>`
banner or `hint:` lines. After a rejected push, brain-sync fetches and merges
the other machine's commits; when that merge fails, the status adds
`; merging origin failed: <reason>` (for example `CONFLICT (content): Merge
conflict in developer-profile.json`).
**Fix.** Look at `~/.gstack/.brain-sync-status.json` for more detail, or run:
```bash
+9
View File
@@ -191,6 +191,15 @@ append commits. Git's default would conflict at the file tail, but the
You shouldn't see conflict prompts. If you do (a real semantic conflict,
like two machines editing the same plan), git will stop and prompt.
When a push is rejected, brain-sync fetches and merges the other machine's
commits itself. Local edits to files that merge would change are set aside
and put back afterward, and a failed merge is aborted, so `~/.gstack` never
holds conflict markers. If a local edit conflicts with the other machine's
change, the other machine's version stays in the working tree and your edit
is kept in a git stash; `gstack-brain-sync --status` names the stash and the
`git -C ~/.gstack --literal-pathspecs restore --source=<stash> -- <path>`
command that brings it back.
## Cross-machine pull cadence
The preamble runs `git fetch` + `git merge --ff-only` once per 24 hours
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.92.37",
"version": "1.92.38",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+192
View File
@@ -825,6 +825,198 @@ describe('gstack-brain-sync per-file hold', () => {
expect(remoteFiles()).not.toContain(CLEAN);
});
describe('a rejected push merges the remote without wedging (#3704)', () => {
const SHARED = 'developer-profile.json';
const OTHER = 'projects/p/ceo-plans/other.md';
function peerCommit(p: string, body: string) {
const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-'));
try {
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
fs.mkdirSync(path.dirname(path.join(peer, p)), { recursive: true });
fs.writeFileSync(path.join(peer, p), body);
expect(git(['add', '-f', '--', p], peer).status).toBe(0);
expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', `peer ${p}`], peer).status).toBe(0);
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
} finally {
fs.rmSync(peer, { recursive: true, force: true });
}
}
function setup() {
init();
write(SHARED, '# shared v1\n');
write(OTHER, '# other v1\n');
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('ok');
// The retry merge commits with the ambient identity; a CI runner has none.
expect(git(['config', 'user.name', 'fixture']).status).toBe(0);
expect(git(['config', 'user.email', 'fixture@example.com']).status).toBe(0);
}
const unmerged = () => git(['ls-files', '--unmerged']).stdout.trim();
const mergeHead = () => fs.existsSync(path.join(tmpHome, '.git', 'MERGE_HEAD'));
test('a dirty tracked file the remote did not touch rides through the merge', () => {
setup();
peerCommit(SHARED, '# shared from the peer\n');
write(OTHER, '# other, local edit not queued\n', false);
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('ok');
expect(remoteFiles()).toContain(CLEAN);
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n');
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
});
/** The restore command the status names, run as printed. */
function recover(message: string) {
const m = /get them back: (git -C \S+ --literal-pathspecs restore --source=([0-9a-f]{40}) -- .+?)\)/.exec(message);
expect(m).not.toBeNull();
const r = spawnSync('bash', ['-c', m![1]], { encoding: 'utf-8', timeout: 30_000 });
expect(r.status).toBe(0);
return m![2];
}
test('a dirty tracked file the remote also changed no longer leaves the push failed', () => {
setup();
peerCommit(SHARED, '# shared from the peer\n');
write(SHARED, '# shared, local edit not queued\n', false);
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('ok');
expect(remoteFiles()).toContain(CLEAN);
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
// The merged version stays in the working tree: no conflict markers,
// nothing staged, and nothing stale for a later sync to publish.
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
expect(unmerged()).toBe('');
expect(staged()).toBe('');
expect(s.message).toContain(`local edits to ${SHARED} conflicted`);
const sha = /saved in stash ([0-9a-f]{40})/.exec(s.message)?.[1];
expect(sha).toBeTruthy();
// A second sync run (same file re-queued, plus another) keeps the peer's
// edit on the remote and the stashed local edit intact.
run(['gstack-brain-enqueue', SHARED]);
write(OTHER, '# other v2\n');
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('ok');
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
expect(git(['show', `${sha}:${SHARED}`]).stdout).toBe('# shared, local edit not queued\n');
// The printed command brings the local edit back.
expect(recover(s.message)).toBe(sha!);
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
});
test('a local deletion of a file the remote changed keeps the remote file and stashes the deletion', () => {
setup();
peerCommit(SHARED, '# shared from the peer\n');
fs.rmSync(path.join(tmpHome, SHARED));
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('ok');
expect(remoteShow(SHARED).stdout).toBe('# shared from the peer\n');
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared from the peer\n');
expect(unmerged()).toBe('');
expect(s.message).toContain(`local edits to ${SHARED} conflicted`);
recover(s.message);
expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false);
});
test('a local edit of a file the remote deleted keeps the deletion and stashes the edit', () => {
setup();
const peer = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-sync-peer-'));
try {
expect(git(['clone', '-q', bareRemote, peer]).status).toBe(0);
expect(git(['rm', '-q', '--', SHARED], peer).status).toBe(0);
expect(git(['-c', 'user.name=peer', '-c', 'user.email=peer@example.com', 'commit', '-qm', 'peer rm'], peer).status).toBe(0);
expect(git(['push', '-q', 'origin', 'HEAD'], peer).status).toBe(0);
} finally {
fs.rmSync(peer, { recursive: true, force: true });
}
write(SHARED, '# shared, local edit not queued\n', false);
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('ok');
expect(remoteFiles()).not.toContain(SHARED);
expect(fs.existsSync(path.join(tmpHome, SHARED))).toBe(false);
expect(unmerged()).toBe('');
recover(s.message);
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
});
test('a conflicting file whose name looks like a pattern resets only itself', () => {
setup();
const GLOB = '[d]eveloper-profile.json';
peerCommit(GLOB, '# glob v1\n');
expect(git(['fetch', '-q', 'origin']).status).toBe(0);
expect(git(['merge', '-q', '--ff-only', 'origin/main']).status).toBe(0);
peerCommit(GLOB, '# glob from the peer\n');
write(GLOB, '# glob, local edit not queued\n', false);
write(SHARED, '# shared, local edit not queued\n', false);
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('ok');
expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob from the peer\n');
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
recover(s.message);
expect(fs.readFileSync(path.join(tmpHome, GLOB), 'utf-8')).toBe('# glob, local edit not queued\n');
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
});
test('a file that never checks out clean does not strand the other local edits', () => {
setup();
const CRLF = 'projects/p/ceo-plans/crlf.md';
write(CRLF, '# crlf\r\nline\r\n');
expect(drain().status).toBe(0);
fs.mkdirSync(path.join(tmpHome, '.git', 'info'), { recursive: true });
fs.writeFileSync(path.join(tmpHome, '.git', 'info', 'attributes'), '*.md text eol=lf\n');
peerCommit(SHARED, '# shared from the peer\n');
write(OTHER, '# other, local edit not queued\n', false);
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
expect(statusJson().status).toBe('ok');
expect(remoteFiles()).toContain(CLEAN);
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other, local edit not queued\n');
expect(git(['stash', 'list']).stdout.trim()).toBe('');
});
test('a stash push that saves nothing never pops an older stash entry', () => {
setup();
write(OTHER, '# other, older stashed work\n', false);
expect(git(['stash', 'push', '-q', '-m', 'older work']).status).toBe(0);
peerCommit(SHARED, '# shared from the peer\n');
write(SHARED, '# shared, local edit not queued\n', false);
write(CLEAN, '# a plan\n');
const shim = gitShim('case " $* " in *" stash push "*) exit 0;; esac');
expect(drain(shim.env).status).toBe(0);
expect(statusJson().status).toBe('push_failed');
expect(git(['stash', 'list']).stdout).toContain('older work');
expect(fs.readFileSync(path.join(tmpHome, OTHER), 'utf-8')).toBe('# other v1\n');
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local edit not queued\n');
});
test('a conflicting merge is aborted and git\'s reason reaches the status', () => {
setup();
peerCommit(SHARED, '# shared from the peer\n');
write(SHARED, '# shared, local commit\n');
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('push_failed');
expect(s.message).toContain(`Merge conflict in ${SHARED}`);
expect(mergeHead()).toBe(false);
expect(unmerged()).toBe('');
expect(fs.readFileSync(path.join(tmpHome, SHARED), 'utf-8')).toBe('# shared, local commit\n');
// The next drain is not blocked by a half-finished merge.
write(OTHER, '# other v2\n');
expect(drain().status).toBe(0);
expect(statusJson().message).not.toContain('unmerged');
expect(statusJson().message).toContain(`Merge conflict in ${SHARED}`);
});
});
test('a git add failure is reported, never swallowed', () => {
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root)
init();