mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
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:
1 parent
1ea86923a8
commit
ab2852fb40
8 files changed
+280
-6
No files matched your search
@@ -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,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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user