From cb56a551b72e94f44f5534c3e244f54b47fc8c5c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 17 Aug 2026 19:45:57 +0700 Subject: [PATCH] fix(deploy): say what a diverged branch means, and how to fix it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A force-push on main left the server's checkout on the old SHAs, so the deploy failed with git's diverging-branches advice and the word "aborting" — which reads as a broken script rather than as a checkout one command away from fine. `git pull --ff-only` stays. A deploy must never merge or rebase on its own, and nothing here resets the checkout automatically: that would silently discard a commit someone made on the server, which is the thing the cleanliness check two steps earlier exists to prevent. The script now recognises the case, prints both SHAs and the exact recovery command, and stops. Third cryptic deploy failure in a row, so it comes with a test. --- scripts/deploy.sh | 17 +++++++++++++++++ tests/Feature/DeployScriptTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f78a456..48231a9 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -114,6 +114,23 @@ fi step "Pulling $BRANCH" git fetch --prune origin + +# `git pull --ff-only` is right — a deploy must never merge or rebase on its own +# — but when the branch has been rewritten upstream it fails with a wall of git +# hints and the word "aborting", which reads like the script is broken rather +# than like the server is one command from fine. Name the situation instead. +if ! git merge-base --is-ancestor HEAD "origin/$BRANCH" 2>/dev/null; then + printf '\033[31mLocal %s has diverged from origin/%s.\033[0m\n' "$BRANCH" "$BRANCH" >&2 + printf ' local %s %s\n' "$(git rev-parse --short HEAD)" "$(git log -1 --format=%s)" >&2 + printf ' origin %s %s\n' \ + "$(git rev-parse --short "origin/$BRANCH")" \ + "$(git log -1 --format=%s "origin/$BRANCH")" >&2 + printf '\nUsually this means the branch was force-pushed. If this checkout has no\n' >&2 + printf 'commits of its own worth keeping — it should not — take the remote as truth:\n\n' >&2 + printf ' cd %s && git fetch origin && git reset --hard origin/%s\n\n' "$APP_PATH" "$BRANCH" >&2 + fail "refusing to merge or rebase during a deploy" +fi + git pull --ff-only origin "$BRANCH" CURR_COMMIT="$(git rev-parse HEAD)" echo " now at $(git rev-parse --short HEAD) $(git log -1 --format=%s)" diff --git a/tests/Feature/DeployScriptTest.php b/tests/Feature/DeployScriptTest.php index 5a39949..086c92b 100644 --- a/tests/Feature/DeployScriptTest.php +++ b/tests/Feature/DeployScriptTest.php @@ -185,3 +185,30 @@ function deployMatches(string $flag, string $path): bool 'blocks a stray untracked file' => ['?? .env.backup', true], 'blocks an untracked build directory that is not one of ours' => ['?? public/uploads-old/', true], ]); + +it('explains a diverged branch instead of dumping git hints', function (): void { + /* + * `git pull --ff-only` is the right call — a deploy must never merge or + * rebase on its own — but on a force-pushed branch it fails with a wall of + * git advice ending in "aborting", which reads as a broken script rather + * than as a checkout one command from fine. That cost a round trip the + * first time it happened. + */ + $script = file_get_contents(base_path('scripts/deploy.sh')); + + expect($script)->toContain('git merge-base --is-ancestor HEAD'); + + // And it has to name the recovery, not just the diagnosis. + expect($script)->toContain('git reset --hard origin/'); + + /* + * The check has to come before the pull, or the raw git failure wins the + * race. Compared on the executable lines only — the comment above the check + * names `git pull --ff-only` too, and matching that instead put the guard + * "after" the pull it precedes by twelve lines. + */ + $code = preg_replace('/^\s*#.*$/m', '', $script); + + expect(strpos($code, 'git merge-base --is-ancestor HEAD')) + ->toBeLessThan(strpos($code, 'git pull --ff-only')); +});