mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
cce097cfce
## Why Cycle 244 of this very session hit the classic long-session git-pull abort: `git pull --ff-only origin main` rejected because `registry.json` had local drift. The recovery dance — `git stash push`, pull, `git stash pop`, possibly resolve — is always the same shape. Mechanizing it removes the friction and the 30-second decision latency every time. ## What `scripts/safe-pull.sh` — `git stash push` (named), `git pull --ff-only`, `git stash pop`, with clear conflict reporting. Behavior: - Detects tracked + staged drift up front; skips stash when clean. - Names the stash `auto-safe-pull-<UTC-timestamp>` so it's recognizable in `git stash list`. - On clean pop: drops the stash automatically (no orphan entries). - On pop conflict: leaves the stash AND lists the conflicting files; emits the exact `git stash pop` command needed to recover. - On pre-pull failure: leaves the stash (if created) and surfaces the underlying git error. Flags: `--remote <name>` `--branch <name>` `--dry-run` `--no-pop` `--verbose` Exit codes: 0 success / 1 pop conflict / 2 pull rejected / 3 usage or env error. Stash name is never silently lost — EXIT trap reports it if non-zero exit fires mid-flight. ## Test 10 bats tests, all passing. Fixture builds a bare origin + two clones; exercises clean / drift-no-overlap / drift-with-conflict / diverged-local / dry-run / --no-pop / detached-HEAD / unknown-flag / unstaged-tracked-edit paths. Closes-scenario: soc-x8pl#safe-pull Bounded-context: BC2-Loop Evidence: scripts/safe-pull.sh Evidence: tests/scripts/safe-pull.bats Co-authored-by: Codex <codex@example.invalid>
176 lines
5.2 KiB
Bash
176 lines
5.2 KiB
Bash
#!/usr/bin/env bats
|
|
# Regression tests for scripts/safe-pull.sh (soc-x8pl).
|
|
#
|
|
# Fixture model: a bare "origin" repo + two clones (`local`, `peer`) that
|
|
# we mutate to simulate "remote has new commits", "local has tracked drift",
|
|
# and the cross-product cases. The script itself only runs inside `local`.
|
|
|
|
setup() {
|
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
SCRIPT="$REPO_ROOT/scripts/safe-pull.sh"
|
|
TMP="$(mktemp -d)"
|
|
ORIG_DIR="$PWD"
|
|
|
|
# Bare origin.
|
|
git -C "$TMP" init --bare --quiet --initial-branch=main origin.git
|
|
|
|
# `peer` makes the initial commit; we'll add follow-up commits from it
|
|
# later to simulate remote moves.
|
|
git clone --quiet "$TMP/origin.git" "$TMP/peer"
|
|
cd "$TMP/peer"
|
|
git config user.email t@t.test
|
|
git config user.name tester
|
|
echo "v1" > base.txt
|
|
git add base.txt
|
|
git commit --quiet -m "v1"
|
|
git push --quiet -u origin main
|
|
|
|
# `local` is where the script runs. Clone after origin has its first commit.
|
|
git clone --quiet "$TMP/origin.git" "$TMP/local"
|
|
git -C "$TMP/local" config user.email t@t.test
|
|
git -C "$TMP/local" config user.name tester
|
|
cd "$ORIG_DIR"
|
|
}
|
|
|
|
teardown() {
|
|
cd "$ORIG_DIR" 2>/dev/null || true
|
|
rm -rf "$TMP"
|
|
}
|
|
|
|
# Helpers ------------------------------------------------------------------
|
|
|
|
advance_remote() {
|
|
# Make a new commit on origin/main via the peer clone.
|
|
local file="$1" content="$2"
|
|
cd "$TMP/peer"
|
|
git pull --quiet --ff-only
|
|
echo "$content" > "$file"
|
|
git add "$file"
|
|
git commit --quiet -m "advance $file"
|
|
git push --quiet
|
|
cd "$ORIG_DIR"
|
|
}
|
|
|
|
run_local() {
|
|
cd "$TMP/local"
|
|
run "$SCRIPT" "$@"
|
|
}
|
|
|
|
# Tests --------------------------------------------------------------------
|
|
|
|
@test "succeeds on a clean tree with no remote changes" {
|
|
run_local
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"no drift"* ]] || [[ "$output" == *"pulled"* ]]
|
|
}
|
|
|
|
@test "fast-forwards a clean tree to new origin commits" {
|
|
advance_remote remote-add.txt "added"
|
|
run_local
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"no drift to restore"* ]]
|
|
[ -f "$TMP/local/remote-add.txt" ]
|
|
}
|
|
|
|
@test "stashes tracked drift, pulls, and pops cleanly when no overlap" {
|
|
# Local edits a NEW file; remote also adds a DIFFERENT new file.
|
|
echo "local-only" > "$TMP/local/local-scratch.txt"
|
|
cd "$TMP/local" && git add local-scratch.txt && cd "$ORIG_DIR"
|
|
advance_remote remote-side.txt "remote-only"
|
|
run_local
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"drift restored cleanly"* ]]
|
|
# Both files present, no orphaned stash.
|
|
[ -f "$TMP/local/local-scratch.txt" ]
|
|
[ -f "$TMP/local/remote-side.txt" ]
|
|
stash_count=$(git -C "$TMP/local" stash list | wc -l | tr -d ' ')
|
|
[ "$stash_count" -eq 0 ]
|
|
}
|
|
|
|
@test "preserves stash and exits 1 when pop produces a conflict" {
|
|
# Local and remote both modify the SAME file at the SAME line → conflict
|
|
# on stash pop.
|
|
cd "$TMP/local"
|
|
echo "local-edit" >> base.txt
|
|
cd "$ORIG_DIR"
|
|
advance_remote base.txt "remote-edit-clobber"
|
|
run_local
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"conflict"* ]]
|
|
[[ "$output" == *"base.txt"* ]] || [[ "$output" == *"CONFLICT"* ]]
|
|
# Stash remains in the list so the operator can recover.
|
|
stash_count=$(git -C "$TMP/local" stash list | wc -l | tr -d ' ')
|
|
[ "$stash_count" -ge 1 ]
|
|
}
|
|
|
|
@test "exit 2 when pull --ff-only is rejected (local diverged)" {
|
|
# Make a local commit that's not on origin, then advance origin too →
|
|
# ff-only refuses.
|
|
cd "$TMP/local"
|
|
git config user.email t@t.test
|
|
git config user.name tester
|
|
echo "local-fork" > fork.txt
|
|
git add fork.txt
|
|
git commit --quiet -m "local-fork"
|
|
cd "$ORIG_DIR"
|
|
advance_remote remote-fork.txt "remote-fork"
|
|
run_local
|
|
[ "$status" -eq 2 ]
|
|
[[ "$output" == *"pull --ff-only failed"* ]]
|
|
}
|
|
|
|
@test "dry-run reports plan without mutating the tree" {
|
|
echo "drift" > "$TMP/local/drift.txt"
|
|
cd "$TMP/local" && git add drift.txt && cd "$ORIG_DIR"
|
|
advance_remote remote-x.txt "x"
|
|
run_local --dry-run
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"DRY-RUN"* ]]
|
|
# Remote file should NOT be present (no pull happened).
|
|
[ ! -f "$TMP/local/remote-x.txt" ]
|
|
# Drift still staged.
|
|
staged=$(git -C "$TMP/local" diff --cached --name-only)
|
|
[ "$staged" = "drift.txt" ]
|
|
}
|
|
|
|
@test "--no-pop leaves stash for manual recovery" {
|
|
echo "drift" > "$TMP/local/drift.txt"
|
|
cd "$TMP/local" && git add drift.txt && cd "$ORIG_DIR"
|
|
advance_remote rxx.txt "x"
|
|
run_local --no-pop
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"left for manual pop"* ]]
|
|
stash_count=$(git -C "$TMP/local" stash list | wc -l | tr -d ' ')
|
|
[ "$stash_count" -eq 1 ]
|
|
}
|
|
|
|
@test "rejects detached HEAD with usage error" {
|
|
cd "$TMP/local"
|
|
local sha
|
|
sha="$(git rev-parse HEAD)"
|
|
git checkout --quiet --detach "$sha"
|
|
cd "$ORIG_DIR"
|
|
run_local
|
|
[ "$status" -eq 3 ]
|
|
[[ "$output" == *"detached"* ]]
|
|
}
|
|
|
|
@test "rejects unknown flag with usage error" {
|
|
run_local --weasel
|
|
[ "$status" -eq 3 ]
|
|
[[ "$output" == *"unknown"* ]]
|
|
}
|
|
|
|
@test "unstaged tracked edits also trigger the stash path" {
|
|
# Modify a tracked file without staging — diff against HEAD should still
|
|
# see the drift.
|
|
cd "$TMP/local"
|
|
echo "unstaged" >> base.txt
|
|
cd "$ORIG_DIR"
|
|
advance_remote sibling.txt "sibling"
|
|
run_local
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"drift restored cleanly"* ]]
|
|
[ -f "$TMP/local/sibling.txt" ]
|
|
}
|