diff --git a/CHANGELOG.md b/CHANGELOG.md index bacd0020f..5dcbe0188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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= -- ` 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 ` 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/` no longer stops `/ship`'s review** (`lib/review-brief.ts`, GST-797). The renderer checked each finished brief for unfilled slots like ``, `` and `` 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 ` (seen on #3795). A quoted `` 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. diff --git a/VERSION b/VERSION index 1df70114c..d99ed55f4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.37.0 +1.92.38.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index c41ccdba5..f10b4a111 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 0c5d07afb..25625c2bb 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -585,6 +585,60 @@ transcripts_consented() { transcript_policy --check 2>/dev/null } +# After a rejected push: merge origin/ 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 } diff --git a/docs/gbrain-sync-errors.md b/docs/gbrain-sync-errors.md index 1d6f47990..cd81aa9dd 100644 --- a/docs/gbrain-sync-errors.md +++ b/docs/gbrain-sync-errors.md @@ -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 ` +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: ` (for example `CONFLICT (content): Merge +conflict in developer-profile.json`). **Fix.** Look at `~/.gstack/.brain-sync-status.json` for more detail, or run: ```bash diff --git a/docs/gbrain-sync.md b/docs/gbrain-sync.md index d53b57b7b..43d7f2e00 100644 --- a/docs/gbrain-sync.md +++ b/docs/gbrain-sync.md @@ -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= -- ` +command that brings it back. + ## Cross-machine pull cadence The preamble runs `git fetch` + `git merge --ff-only` once per 24 hours diff --git a/package.json b/package.json index 161b5cc27..87f13cf66 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/brain-sync.test.ts b/test/brain-sync.test.ts index 7bffd7c45..ff7ee1432 100644 --- a/test/brain-sync.test.ts +++ b/test/brain-sync.test.ts @@ -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();