#!/usr/bin/env bash # The git bridge end to end: importing a repository's history, bringing a # branch in as a change, publishing with the original git history, and the # hosted git view (apps/onset under `wrangler dev`, pushing to a local git # server that stands in for git.coline.app): new versions as commits, # private paths left out, a push from outside pausing it, and resuming. # Usage: tests/git.sh [path/to/target/debug] set -uo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) BIN=${1:-$ROOT/target/debug} VCS=$BIN/on APP=$ROOT/../apps/onset WORK=$(mktemp -d) export ONSET_SAVER=0 ONSET_HOME=$WORK/home ONSET_NAME=Alice ONSET_EMAIL=alice@acme.test # Run from inside an agent, its own session would be kept with the tests' lands. unset CLAUDE_CODE_SESSION_ID CLAUDECODE CLAUDE_CONFIG_DIR ONSET_SESSION_FILE ONSET_SESSIONS VCS_SESSION_FILE VCS_SESSIONS VCS_HOME VCS_TOKEN export CODEX_HOME="$WORK/no-codex" PORT=$((20000 + RANDOM % 20000)) GPORT=$((PORT + 2)) HOST="127.0.0.1:$PORT" WORKER_PID= GIT_PID= cleanup() { [ -n "$WORKER_PID" ] && kill -- "-$WORKER_PID" 2>/dev/null [ -n "$GIT_PID" ] && kill "$GIT_PID" 2>/dev/null wait 2>/dev/null rm -rf "$WORK" } trap cleanup EXIT cd "$WORK" pass=0 fail=0 check() { local name=$1 shift if "$@" >/dev/null 2>&1; then pass=$((pass + 1)); else fail=$((fail + 1)); echo "FAIL: $name"; fi } jq_() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; } g() { git -c user.name=Ada -c user.email=ada@example.com -c init.defaultBranch=main -c commit.gpgsign=false "$@"; } # A git repository with files, an executable, a link, a big file, folders, # a submodule, a merge and a tag. g init -q lib (cd lib && printf 'lib\n' > lib.txt && g add -A && g commit -qm "Lib") g init -q src ( cd src printf '# Shop\n' > README.md && mkdir -p src && printf 'fn main() {}\n' > src/main.rs g add -A && g commit -qm "First commit" -m "With a body." printf '#!/bin/sh\necho hi\n' > run.sh && chmod +x run.sh && ln -s README.md link g add -A && g commit -qm "Script and link" head -c 3000000 /dev/urandom > big.bin && mkdir -p a/b && printf 'deep\n' > a/b/c.txt g -c protocol.file.allow=always submodule add -q ../lib vendor/lib >/dev/null 2>&1 g add -A && g commit -qm "Big file, folders and a submodule" g checkout -qb side && printf 'side\n' > side.txt && g add -A && g commit -qm "Side work" && g checkout -q main printf '# Shop\n\nSells things.\n' > README.md && g rm -q src/main.rs g add -A && g commit -qm "Readme, no main.rs" g merge -q --no-ff side -m "Merge side" g tag v1 g checkout -qb feature && printf 'feature\n' > feature.txt && printf 'deeper\n' > a/b/c.txt g add -A && g commit -qm "Feature" -m "Adds feature.txt." && g checkout -q main ) COMMITS=$(git -C src rev-list --first-parent --count main) check "import" "$VCS" import src shop check "a version per first-parent commit" test "$("$VCS" --json -C shop log -n 1 | jq_ 'd["versions"][0]["n"]')" = "$COMMITS" check "last title" sh -c "'$VCS' -C shop log -n 1 | grep -q 'Merge side'" mkdir -p want && git -C src archive main | tar -x -C want check "files match git" diff -r --exclude=.onset --exclude=lib want shop LIB=$(git -C src rev-parse main:vendor/lib) check "the submodule is an empty folder" sh -c "test -d shop/vendor/lib && test -z \"\$(ls -A shop/vendor/lib)\"" check "and nothing to save" sh -c "'$VCS' --json -C shop status | python3 -c 'import json,sys; d=json.load(sys.stdin); assert not d.get(\"files\"), d.get(\"files\")'" check "executable kept" test -x shop/run.sh check "link kept" test -L shop/link check "big file intact" cmp src/big.bin shop/big.bin check "first version has its body" sh -c "'$VCS' -C shop log 1 | grep -q 'With a body.'" # Native work on top, then a git branch comes in as a change and lands. printf 'native\n' > shop/native.txt check "native land" "$VCS" -C shop land -m "Native work" check "branch comes in as a change" "$VCS" -C shop import ../src --change feature check "it's the workspace's change" sh -c "'$VCS' -C shop status | grep -q Feature" check "workspace has the branch" test -f shop/feature.txt check "branch lands" "$VCS" -C shop land check "sync" "$VCS" -C shop sync check "main has both" sh -c "test -f shop/native.txt && test -f shop/feature.txt && grep -q deeper shop/a/b/c.txt" check "unknown branch refused" sh -c "! '$VCS' -C shop import ../src --change nope" # The hosted git view. mkdir -p gitroot/acme git init -q --bare gitroot/acme/shop.git python3 "$ROOT/tests/git-server.py" "$WORK/gitroot" "$GPORT" & GIT_PID=$! for _ in $(seq 100); do curl -s -o /dev/null "http://127.0.0.1:$GPORT/acme/shop.git/info/refs?service=git-upload-pack" && break; sleep 0.1; done kill -0 "$GIT_PID" 2>/dev/null || { echo "git server didn't start (port $GPORT)"; exit 1; } ALICE=vcs_test_alice_0123456789 USERS="{\"$ALICE\":{\"handle\":\"alice\",\"name\":\"Alice\",\"email\":\"alice@acme.test\"}}" WRANGLER_SEND_METRICS=false setsid "$APP/node_modules/.bin/wrangler" dev --cwd "$APP" --name "vcs-test-$PORT" --port "$PORT" --ip 127.0.0.1 --inspector-port "$((PORT + 1))" \ --persist-to "$WORK/wrangler" --var AUTH_MODE:test --var "TEST_USERS:$USERS" --var "GIT_BASE:http://127.0.0.1:$GPORT" --var "UPSTREAM_API:http://127.0.0.1:$GPORT/api" --var "VCS_ORIGIN:http://$HOST" --var VCS_INTERNAL_SECRET:test-internal >"$WORK/worker.log" 2>&1 & WORKER_PID=$! for _ in $(seq 300); do curl -s "http://$HOST/.well-known/onset.json" 2>/dev/null | grep -q api && break; sleep 0.2; done api() { curl -s -H "authorization: Bearer $ALICE" -H 'content-type: application/json' "$@"; } gv() { git --git-dir="$WORK/gitroot/acme/shop.git" "$@"; } wait_view() { # version number to wait for in the git view for _ in $(seq 100); do gv log -1 --format=%B main 2>/dev/null | grep -q "Onset-Version: $1\$" && return 0; sleep 0.2; done return 1 } check "login" "$VCS" login "$HOST" --token "$ALICE" api -X POST -d '{"owner":"acme","name":"shop","git":"view"}' "http://$HOST/v1/projects" >/dev/null check "import and publish with history" "$VCS" import src hosted --to "$HOST/acme/shop" check "git history copied" test "$(gv rev-parse main)" = "$(git -C src rev-parse main)" check "tags copied" test "$(gv rev-parse v1)" = "$(git -C src rev-parse v1)" check "branches copied" test "$(gv rev-parse feature)" = "$(git -C src rev-parse feature)" N=$(("$COMMITS" + 1)) printf 'from vcs\n' > hosted/fromvcs.txt check "hosted land" "$VCS" -C hosted land -m "From vcs" -m "Landed natively." check "git view gets the version" wait_view "$N" check "commit follows the last git commit" test "$(gv rev-parse main~1)" = "$(git -C src rev-parse main)" check "commit has the file" sh -c "git --git-dir='$WORK/gitroot/acme/shop.git' show main:fromvcs.txt | grep -q 'from vcs'" check "commit has the message" sh -c "git --git-dir='$WORK/gitroot/acme/shop.git' log -1 --format=%B main | grep -q 'Landed natively.'" check "git fsck passes" gv fsck --strict --no-dangling check "the submodule is still in git" sh -c "git --git-dir='$WORK/gitroot/acme/shop.git' ls-tree main vendor/lib | grep -q '^160000 commit $LIB'" check "vcs git shows it" sh -c "'$VCS' -C hosted git | grep -q 'version $N is commit'" # Tags reach the git view as git tags; removing one removes it there. wait_tag() { # tag, commit (empty: gone) for _ in $(seq 100); do [ "$(gv rev-parse -q --verify "refs/tags/$1" 2>/dev/null)" = "$2" ] && return 0; sleep 0.2; done return 1 } check "tag a version" "$VCS" -C hosted tag release-1 "$N" check "the git view has the tag" wait_tag release-1 "$(gv rev-parse main)" check "remove the tag" "$VCS" -C hosted tag release-1 --remove check "the git view drops it" wait_tag release-1 "" # A release line is a git branch of its name: it starts at its version of # main's commit, and each of its versions is a commit on it. wait_branch() { # branch, text in its last commit's message for _ in $(seq 100); do gv log -1 --format=%B "refs/heads/$1" 2>/dev/null | grep -q "$2" && return 0; sleep 0.2; done return 1 } check "start line 1.x" "$VCS" -C hosted line 1.x --from "$N" check "the git view has branch 1.x at main $N" wait_branch 1.x "Onset-Version: $N\$" "$VCS" get "$HOST/acme/shop" hosted-1x --line 1.x >/dev/null 2>&1 printf 'line fix\n' > hosted-1x/linefix.txt check "land on the line" "$VCS" -C hosted-1x land -m "Fix on the line" check "it's a commit on branch 1.x" wait_branch 1.x "Onset-Line: 1.x/1\$" check "on top of main $N's commit" test "$(gv rev-parse 1.x~1)" = "$(gv rev-parse "main")" check "with the file" sh -c "git --git-dir='$WORK/gitroot/acme/shop.git' show 1.x:linefix.txt | grep -q 'line fix'" check "main's branch doesn't have it" sh -c "! git --git-dir='$WORK/gitroot/acme/shop.git' show main:linefix.txt >/dev/null 2>&1" # Purge: git main is written again without the path (a force push); the # commits from before it keep their IDs. BEFORE=$(gv rev-parse main) printf 'not-a-real-key-git\n' > hosted/oops.pem "$VCS" -C hosted land -m "Oops in git" --allow-secrets >/dev/null 2>&1 printf 'after oops\n' > hosted/after-oops.txt "$VCS" -C hosted land -m "After oops" >/dev/null 2>&1 OOPS_N=$("$VCS" --json -C hosted log -n 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["versions"][0]["n"])') check "git main has the key" sh -c "$(declare -f wait_view gv); WORK=$WORK; wait_view $OOPS_N && git --git-dir='$WORK/gitroot/acme/shop.git' show main:oops.pem >/dev/null" check "purge it" "$VCS" -C hosted purge oops.pem --yes wait_rewritten() { for _ in $(seq 100); do [ -z "$(gv log --format=%H main -- oops.pem 2>/dev/null)" ] && gv log -1 --format=%B main 2>/dev/null | grep -q "Onset-Version: $OOPS_N\$" && return 0 sleep 0.2 done return 1 } check "git main's history no longer has it" wait_rewritten check "the commits before it kept their IDs" test "$(gv rev-parse main~2)" = "$BEFORE" check "the later version is there" sh -c "git --git-dir='$WORK/gitroot/acme/shop.git' show main:after-oops.txt | grep -q 'after oops'" check "git fsck passes after the rewrite" gv fsck --strict --no-dangling check "vcs git says it's current" sh -c "'$VCS' -C hosted git | grep -q 'version $OOPS_N is commit'" N=$OOPS_N # A submodule's commit changes from vcs, and the git view has the new one. NEWLIB=$(cd lib && printf 'lib two\n' > lib.txt && g commit -qam "Lib two" && git rev-parse HEAD) check "point the submodule at another commit" "$VCS" -C hosted submodule vendor/lib "$NEWLIB" check "it lands" "$VCS" -C hosted land -m "Lib two in shop" N=$((N + 1)) check "the git view has the new commit" sh -c "$(declare -f wait_view gv); WORK=$WORK; wait_view $N && git --git-dir='$WORK/gitroot/acme/shop.git' ls-tree main vendor/lib | grep -q '^160000 commit $NEWLIB'" check "git fsck still passes" gv fsck --strict --no-dangling # Private paths stay out of the git view. check "make secret/ private" "$VCS" -C hosted access secret/ @alice mkdir -p hosted/secret && printf 'key\n' > hosted/secret/key.txt && printf 'open\n' > hosted/open.txt N=$((N + 1)) check "land a secret" "$VCS" -C hosted land -m "Secret and open" check "git view gets it" wait_view "$N" check "open file in the git view" sh -c "git --git-dir='$WORK/gitroot/acme/shop.git' show main:open.txt | grep -q open" check "secret not in the git view" sh -c "! git --git-dir='$WORK/gitroot/acme/shop.git' cat-file -e main:secret/key.txt" check "secret still in vcs" test -f hosted/secret/key.txt wait_sync() { # a file that should show up in the workspace once vcs has git main's commits for _ in $(seq 60); do "$VCS" -C hosted sync >/dev/null 2>&1; [ -e "hosted/$1" ] && return 0; sleep 0.5; done return 1 } wait_paused() { for _ in $(seq 60); do "$VCS" -C hosted sync >/dev/null 2>&1; "$VCS" -C hosted git | grep -q paused && return 0; sleep 0.5; done return 1 } # Someone pushes to git main: their commits come into vcs, each a version. g clone -q "http://127.0.0.1:$GPORT/acme/shop.git" outside (cd outside && printf 'outside\n' > outside.txt && g add -A && g commit -qm "Pushed with git" -m "From a git user." && printf 'two\n' > outside2.txt && g add -A && g commit -qm "Second git commit" && g push -q origin main) OUTSIDE=$(git -C outside rev-parse main) N=$((N + 2)) check "a git push comes into vcs" wait_sync outside2.txt check "a version per commit" test "$("$VCS" --json -C hosted log -n 2 | jq_ '" | ".join(str(v["n"]) + " " + v["title"] for v in d["versions"])')" = "$N Second git commit | $((N - 1)) Pushed with git" check "with its message" sh -c "'$VCS' -C hosted log $((N - 1)) | grep -q 'From a git user.'" check "git main is still their commit" test "$(gv rev-parse main)" = "$OUTSIDE" check "vcs git says so" sh -c "'$VCS' -C hosted git | grep -q 'version $N is commit ${OUTSIDE:0:12}'" printf 'after\n' > hosted/after.txt N=$((N + 1)) check "landing on top" "$VCS" -C hosted land -m "After the git push" check "goes on from their commit" wait_view "$N" check "their commit is the parent" test "$(gv rev-parse main~1)" = "$OUTSIDE" # A git push and a vcs land at once: both end up in main and in git. (cd outside && g pull -q --rebase origin main && printf 'from git\n' > both-git.txt && g add -A && g commit -qm "Git side" && g push -q origin main) GITSIDE=$(git -C outside rev-parse main) printf 'from vcs\n' > hosted/both-vcs.txt check "vcs lands meanwhile" "$VCS" -C hosted land -m "Vcs side" N=$((N + 2)) check "git's side comes into vcs" wait_sync both-git.txt check "the git view has both" wait_view "$N" check "the git commit stays in git's history" gv merge-base --is-ancestor "$GITSIDE" main check "git main has both files" sh -c "git --git-dir='$WORK/gitroot/acme/shop.git' show main:both-git.txt && git --git-dir='$WORK/gitroot/acme/shop.git' show main:both-vcs.txt" check "git fsck passes after" gv fsck --strict --no-dangling # A git push into a private folder can't land: the view pauses and says why. (cd outside && g pull -q --rebase origin main && mkdir -p secret && printf 'nope\n' > secret/new.txt && g add -A && g commit -qm "Into the private folder" && g push -q origin main) check "the view pauses" wait_paused check "and says why" sh -c "'$VCS' -C hosted git | grep -q 'secret/new.txt, which is private'" check "vcs main is untouched" test ! -e hosted/secret/new.txt check "resume" "$VCS" -C hosted git resume printf 'resumed\n' > hosted/resumed.txt N=$((N + 1)) check "land after resuming" "$VCS" -C hosted land -m "Resumed" check "git view catches up" wait_view "$N" check "the git commit stays in git's history" gv merge-base --is-ancestor "$(git -C outside rev-parse main)" main check "git fsck passes after resuming" gv fsck --strict --no-dangling # A team that's left git behind turns the git view off; on again catches up. check "git view off" sh -c "'$VCS' -C hosted settings git=off | grep -q 'off:'" AT=$(gv rev-parse main) for i in 1 2 3; do printf '%s\n' "$i" > "hosted/off-$i.txt" N=$((N + 1)) check "land $i while off" "$VCS" -C hosted land -m "Off $i" done sleep 2 check "git main didn't move" test "$(gv rev-parse main)" = "$AT" check "git view on" "$VCS" -C hosted settings git=on check "it catches up" wait_view "$N" check "a commit per version" test "$(gv rev-list --count "$AT"..main)" = 3 check "git fsck passes at the end" gv fsck --strict --no-dangling # The repository's rules (Upstream's Rules page) send lands through change # requests. Upstream merges one once it's ready, and the merge comes into # vcs main as the change. code() { # expected exit code, command... local want=$1 shift "$@" >/dev/null 2>&1 [ "$?" = "$want" ] } stand_in() { curl -s -X POST -H 'content-type: application/json' -d "$2" "http://127.0.0.1:$GPORT/api/test/$1" >/dev/null; } cr() { curl -s "http://127.0.0.1:$GPORT/api/repos/acme/shop/changes/$1" | jq_ "d['data']$2"; } wait_main() { # title of main's latest version to wait for for _ in $(seq 60); do "$VCS" -C hosted sync >/dev/null 2>&1; "$VCS" --json -C hosted log -n 1 | jq_ 'd["versions"][0]["title"]' | grep -qF "$1" && return 0; sleep 0.5; done return 1 } "$VCS" -C hosted sync >/dev/null 2>&1 stand_in rules/acme/shop '{"minimumApprovals": 1}' printf 'reviewed\n' > hosted/reviewed.txt AT=$(gv rev-parse main) check "a land the rules hold opens a change request (exit 5)" code 5 "$VCS" -C hosted land -m "Needs review" REVIEWED=$("$VCS" --json -C hosted status | jq_ 'd["change"]["id"]') check "its branch is in git" gv rev-parse --verify "refs/heads/onset/$REVIEWED" check "on top of git main" test "$(gv rev-parse "refs/heads/onset/$REVIEWED~1")" = "$AT" check "main didn't move" test "$(gv rev-parse main)" = "$AT" check "the change request is open" test "$(cr 1 '["state"]')" = open check "status shows it" sh -c "'$VCS' -C hosted status | grep -q 'change request #1'" printf 'more\n' > hosted/reviewed-more.txt check "landing again updates it (exit 5)" code 5 "$VCS" -C hosted land check "still change request #1" test "$(cr 1 '["state"]')" = open check "its branch has both files" sh -c "git --git-dir='$WORK/gitroot/acme/shop.git' show refs/heads/onset/$REVIEWED:reviewed-more.txt | grep -q more" stand_in ready/acme/shop/1 '{}' check "Upstream merged it" gv merge-base --is-ancestor "refs/heads/onset/$REVIEWED" main check "the merge comes into vcs main" wait_main "Needs review (#1)" check "as the change" test "$("$VCS" --json -C hosted log -n 1 | jq_ 'd["versions"][0]["change"]')" = "$REVIEWED" check "which is landed" sh -c "'$VCS' --json -C hosted status $REVIEWED | grep -q '\"landed\"'" stand_in rules/acme/shop '{}' printf 'free\n' > hosted/free.txt check "without rules it lands at once" "$VCS" -C hosted land -m "No rules" # Landing at a set time: the host keeps the change as it is and lands it then. printf 'later\n' > hosted/later.txt check "land in a few seconds" "$VCS" -C hosted land -m "Later" --at "in 3s" check "not yet" sh -c "! '$VCS' -C hosted log -n 1 | grep -q Later" check "the workspace goes on on top of it" test -n "$("$VCS" --json -C hosted status | jq_ 'd["change"]["on"] or ""')" check "it lands at its time" wait_main "Later" check "a time it can't read (exit 2)" code 2 "$VCS" -C hosted land -m "Soon" --at "soonish" # Under rules it's reviewed now and merges from its time. stand_in rules/acme/shop '{"minimumApprovals": 1}' printf 'timed\n' > hosted/timed.txt check "a land at a time under rules opens its change request now (exit 5)" code 5 "$VCS" -C hosted land -m "Timed review" --at "in 4s" TIMED=$("$VCS" --json -C hosted status | jq_ 'd["change"]["id"]') stand_in ready/acme/shop/2 '{}' check "ready, but it doesn't merge before its time" sh -c "! git --git-dir='$WORK/gitroot/acme/shop.git' merge-base --is-ancestor refs/heads/onset/$TIMED main" check "it merges after, and comes into main" wait_main "Timed review (#2)" stand_in rules/acme/shop '{}' # Landing together: changes in two projects land at once, or neither does. mkdir web && printf '