| 1 | #!/usr/bin/env bash | |
| 2 | # | |
| 3 | # Generate the jj/git fixture corpus the indexer is tested against. | |
| 4 | # | |
| 5 | # Spec §12: "a script that drives the real jj and git CLIs to produce repositories | |
| 6 | # exhibiting every state the indexer must handle. Commit the script, generate the | |
| 7 | # fixtures in CI. Never hand-author Git objects." | |
| 8 | # | |
| 9 | # Output: fixtures/repos/<case>.git — bare repositories, exactly what Dogfood | |
| 10 | # stores on disk. The indexer reads these; nothing here is hand-written. | |
| 11 | # | |
| 12 | # Usage: fixtures/gen.sh [output_dir] | |
| 13 | # Env: JJ_VERSION pin (default below). CI must pin so a jj format change fails | |
| 14 | # a test instead of silently corrupting the index. | |
| 15 | ||
| 16 | set -euo pipefail | |
| 17 | ||
| 18 | JJ_VERSION="" | |
| 19 | ROOT="" | |
| 20 | OUT="" | |
| 21 | BIN="$ROOT/bin" | |
| 22 | WORK="" | |
| 23 | trap 'rm -rf "$WORK"' EXIT | |
| 24 | ||
| 25 | # ─── toolchain ─────────────────────────────────────────────────────────────── | |
| 26 | ||
| 27 | export PATH="$BIN:$PATH" | |
| 28 | ||
| 29 | if ! command -v jj >/dev/null 2>&1 || [[ "" != "$JJ_VERSION" ]]; then | |
| 30 | echo "==> fetching jj $JJ_VERSION" | |
| 31 | mkdir -p "$BIN" | |
| 32 | curl -fsSL "https://github.com/jj-vcs/jj/releases/download/v/jj-v-x86_64-unknown-linux-musl.tar.gz" \ | |
| 33 | | tar xz -C "$BIN" jj | |
| 34 | chmod +x "$BIN/jj" | |
| 35 | fi | |
| 36 | ||
| 37 | echo "==> jj: " | |
| 38 | echo "==> git: " | |
| 39 | ||
| 40 | # Deterministic identity and timestamps. Without pinned times the change ids stay | |
| 41 | # stable (they are random) but the commit ids do not, and the tests assert on the | |
| 42 | # relationship between them, not on literal values — so we pin what we can and | |
| 43 | # the corpus manifest records the rest. | |
| 44 | export JJ_CONFIG="$WORK/jjconfig.toml" | |
| 45 | cat > "$JJ_CONFIG" <<'EOF' | |
| 46 | [user] | |
| 47 | name = "Fixture Author" | |
| 48 | email = "fixture@dogfood.sh" | |
| 49 | ||
| 50 | [ui] | |
| 51 | paginate = "never" | |
| 52 | color = "never" | |
| 53 | ||
| 54 | # The `rewritten` case force-pushes the same change five times. jj defaults to | |
| 55 | # treating commits reachable from a remote bookmark as immutable, which is right | |
| 56 | # for humans and wrong for a fixture that must simulate exactly that workflow. | |
| 57 | [revset-aliases] | |
| 58 | "immutable_heads()" = "none()" | |
| 59 | EOF | |
| 60 | ||
| 61 | export GIT_AUTHOR_NAME="Fixture Author" | |
| 62 | export GIT_AUTHOR_EMAIL="fixture@dogfood.sh" | |
| 63 | export GIT_COMMITTER_NAME="Fixture Author" | |
| 64 | export GIT_COMMITTER_EMAIL="fixture@dogfood.sh" | |
| 65 | ||
| 66 | rm -rf "$OUT" | |
| 67 | mkdir -p "$OUT" | |
| 68 | ||
| 69 | MANIFEST="$OUT/manifest.json" | |
| 70 | # Opened here, closed at the bottom. `note` appends ",<key>:<value>" entries, so | |
| 71 | # the object starts with a sentinel key to make the leading comma always valid. | |
| 72 | printf '{"jj_version":"%s","cases":{"_generated_by":"fixtures/gen.sh"' "$JJ_VERSION" > "$MANIFEST.tmp" | |
| 73 | ||
| 74 | # Record a case's interesting revisions into the manifest so Rust tests can assert | |
| 75 | # against named commits without re-deriving them. | |
| 76 | note() { # note <case> <json> | |
| 77 | printf ',"%s":%s' "$1" "$2" >> "$MANIFEST.tmp" | |
| 78 | } | |
| 79 | ||
| 80 | # Publish a working repo as a bare repo under $OUT, the way a push would land it. | |
| 81 | publish() { # publish <case> <workdir> [default_branch] | |
| 82 | local case="$1" work="$2" head="" | |
| 83 | git init --bare -q "$OUT/$case.git" | |
| 84 | git -C "$work" push -q --all "$OUT/$case.git" | |
| 85 | git -C "$work" push -q --tags "$OUT/$case.git" 2>/dev/null || true | |
| 86 | # `git init --bare` leaves HEAD on refs/heads/master, which none of these | |
| 87 | # repos have. Dogfood creates repos with HEAD pointing at the default | |
| 88 | # bookmark, so the corpus must match or every fixture has an unborn HEAD. | |
| 89 | git --git-dir="$OUT/$case.git" symbolic-ref HEAD "refs/heads/$head" | |
| 90 | } | |
| 91 | ||
| 92 | cid() { jj -R "$1" log --no-graph --ignore-working-copy -T 'commit_id' -r "$2"; } | |
| 93 | chid() { jj -R "$1" log --no-graph --ignore-working-copy -T 'change_id' -r "$2"; } | |
| 94 | ||
| 95 | # ───────────────────────────────────────────────────────────────────────────── | |
| 96 | # case: basic — jj-authored linear history, one bookmark | |
| 97 | # | |
| 98 | # The baseline. Every commit carries a `change-id` header. | |
| 99 | # ───────────────────────────────────────────────────────────────────────────── | |
| 100 | echo "==> case: basic" | |
| 101 | W="$WORK/basic" | |
| 102 | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 103 | ( | |
| 104 | cd "$W" | |
| 105 | echo "one" > a.txt | |
| 106 | jj describe -m "add a" >/dev/null | |
| 107 | jj new -m "add b" >/dev/null | |
| 108 | echo "two" > b.txt | |
| 109 | jj new -m "add c" >/dev/null | |
| 110 | echo "three" > c.txt | |
| 111 | jj bookmark create main -r @- >/dev/null | |
| 112 | ) | |
| 113 | note basic "" | |
| 114 | publish basic "$W" | |
| 115 | ||
| 116 | # ───────────────────────────────────────────────────────────────────────────── | |
| 117 | # case: rewritten — the property the whole product rests on | |
| 118 | # | |
| 119 | # One change, rewritten five times (spec §5: "a change that is rewritten five | |
| 120 | # times"). The change id must be identical across all five; the commit ids must | |
| 121 | # all differ. Every intermediate commit is kept reachable via a tag so the bare | |
| 122 | # repo actually contains them and the test can walk the whole rewrite chain. | |
| 123 | # ───────────────────────────────────────────────────────────────────────────── | |
| 124 | echo "==> case: rewritten" | |
| 125 | W="$WORK/rewritten" | |
| 126 | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 127 | git init --bare -q "$OUT/rewritten.git" | |
| 128 | (cd "$W" && jj git remote add origin "$OUT/rewritten.git" >/dev/null 2>&1) | |
| 129 | ( | |
| 130 | cd "$W" | |
| 131 | echo "v1" > f.txt | |
| 132 | jj describe -m "evolving change v1" >/dev/null | |
| 133 | jj bookmark create main -r @ >/dev/null | |
| 134 | jj git push -b main --allow-empty-description >/dev/null 2>&1 | |
| 135 | ) | |
| 136 | TARGET="" | |
| 137 | REWRITES="" | |
| 138 | for i in 2 3 4 5; do | |
| 139 | ( | |
| 140 | cd "$W" | |
| 141 | echo "v$i" > f.txt | |
| 142 | jj describe -r 'bookmarks(main)' -m "evolving change v$i" >/dev/null | |
| 143 | # Force-push each rewrite, exactly as a user amending and re-pushing does. | |
| 144 | # The bare repo accumulates every revision as an unreferenced object, which | |
| 145 | # is precisely the state the indexer sees in production. | |
| 146 | jj git push -b main >/dev/null 2>&1 | |
| 147 | ) | |
| 148 | REWRITES="$REWRITES" | |
| 149 | done | |
| 150 | git --git-dir="$OUT/rewritten.git" symbolic-ref HEAD refs/heads/main | |
| 151 | # All five commit ids must differ; the change id must be identical across them. | |
| 152 | note rewritten " | |
| 153 | " | |
| 154 | ||
| 155 | # ───────────────────────────────────────────────────────────────────────────── | |
| 156 | # case: plain-git — no change-id header anywhere | |
| 157 | # | |
| 158 | # Drives the synthetic-identity fallback (spec §4). extract_change_id must return | |
| 159 | # None for every commit here. | |
| 160 | # ───────────────────────────────────────────────────────────────────────────── | |
| 161 | echo "==> case: plain-git" | |
| 162 | W="$WORK/plaingit" | |
| 163 | git init -q "$W" | |
| 164 | ( | |
| 165 | cd "$W" | |
| 166 | git symbolic-ref HEAD refs/heads/main | |
| 167 | echo "x" > f.txt && git add -A && git commit -qm "plain commit one" | |
| 168 | echo "y" >> f.txt && git add -A && git commit -qm "plain commit two" | |
| 169 | ) | |
| 170 | note plain_git "" | |
| 171 | publish plain-git "$W" | |
| 172 | ||
| 173 | # ───────────────────────────────────────────────────────────────────────────── | |
| 174 | # case: mixed — jj commits on top of plain-git commits in one history | |
| 175 | # | |
| 176 | # The realistic case for a repo migrated to jj. The indexer must produce real | |
| 177 | # changes for the jj commits and synthetic ones for the git commits, in a single | |
| 178 | # connected graph. | |
| 179 | # ───────────────────────────────────────────────────────────────────────────── | |
| 180 | echo "==> case: mixed" | |
| 181 | W="$WORK/mixed" | |
| 182 | git init -q "$W" | |
| 183 | ( | |
| 184 | cd "$W" | |
| 185 | git symbolic-ref HEAD refs/heads/main | |
| 186 | echo "legacy" > old.txt && git add -A && git commit -qm "pre-jj history" | |
| 187 | ) | |
| 188 | ( | |
| 189 | cd "$W" | |
| 190 | jj git init --colocate . >/dev/null 2>&1 | |
| 191 | jj new main -m "first jj change on top" >/dev/null | |
| 192 | echo "new" > new.txt | |
| 193 | jj bookmark set main -r @ >/dev/null 2>&1 || jj bookmark create main -r @ >/dev/null | |
| 194 | ) | |
| 195 | publish mixed "$W" | |
| 196 | ||
| 197 | # ───────────────────────────────────────────────────────────────────────────── | |
| 198 | # case: stack — a chain of changes, none merged | |
| 199 | # | |
| 200 | # Exercises stack edge computation (spec §4). Four changes stacked on main. | |
| 201 | # ───────────────────────────────────────────────────────────────────────────── | |
| 202 | echo "==> case: stack" | |
| 203 | W="$WORK/stack" | |
| 204 | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 205 | ( | |
| 206 | cd "$W" | |
| 207 | echo "base" > base.txt | |
| 208 | jj describe -m "base of stack" >/dev/null | |
| 209 | jj bookmark create main -r @ >/dev/null | |
| 210 | for n in 1 2 3 4; do | |
| 211 | jj new -m "stacked change $n" >/dev/null | |
| 212 | echo "layer $n" > "layer$n.txt" | |
| 213 | done | |
| 214 | jj bookmark create top -r @ >/dev/null | |
| 215 | ) | |
| 216 | note stack "" | |
| 217 | publish stack "$W" | |
| 218 | ||
| 219 | # ───────────────────────────────────────────────────────────────────────────── | |
| 220 | # case: merge — a two-parent jj commit | |
| 221 | # ───────────────────────────────────────────────────────────────────────────── | |
| 222 | echo "==> case: merge" | |
| 223 | W="$WORK/merge" | |
| 224 | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 225 | ( | |
| 226 | cd "$W" | |
| 227 | # NOTE: revisions are addressed by captured change id, never by a | |
| 228 | # description() revset. In jj 0.43 `description("x")` is an EXACT match, not a | |
| 229 | # substring match (substring: needs an explicit `substring:` prefix), which | |
| 230 | # silently returns the empty set and produces a corrupt fixture. | |
| 231 | echo "base" > base.txt | |
| 232 | jj describe -m "merge base" >/dev/null | |
| 233 | BASE="" | |
| 234 | jj new -m "left side" >/dev/null && echo L > l.txt | |
| 235 | LEFT="" | |
| 236 | jj new -m "right side" "$BASE" >/dev/null && echo R > r.txt | |
| 237 | RIGHT="" | |
| 238 | jj new "$LEFT" "$RIGHT" -m "the merge" >/dev/null | |
| 239 | jj bookmark create main -r @ >/dev/null | |
| 240 | ) | |
| 241 | note merge "" | |
| 242 | publish merge "$W" | |
| 243 | ||
| 244 | # ───────────────────────────────────────────────────────────────────────────── | |
| 245 | # case: conflict — a real 2-sided conflict | |
| 246 | # | |
| 247 | # Produces the `jj:trees` + `jj:conflict-labels` headers and the | |
| 248 | # .jjconflict-side-N / .jjconflict-base-N tree layout documented in | |
| 249 | # docs/change-id-format.md §6. Drives conflict detection and the conflict viewer. | |
| 250 | # ───────────────────────────────────────────────────────────────────────────── | |
| 251 | echo "==> case: conflict" | |
| 252 | W="$WORK/conflict" | |
| 253 | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 254 | ( | |
| 255 | cd "$W" | |
| 256 | printf 'line1\nline2\nline3\n' > c.txt | |
| 257 | jj describe -m "conflict base" >/dev/null | |
| 258 | BASE="" | |
| 259 | jj new -m "side A" >/dev/null && printf 'line1\nAAA\nline3\n' > c.txt | |
| 260 | SIDEA="" | |
| 261 | jj new -m "side B" "$BASE" >/dev/null && printf 'line1\nBBB\nline3\n' > c.txt | |
| 262 | SIDEB="" | |
| 263 | # Both sides edit line 2 of c.txt from the same base -> a real 2-sided conflict. | |
| 264 | jj new "$SIDEA" "$SIDEB" -m "conflicted merge" >/dev/null | |
| 265 | jj bookmark create main -r @ >/dev/null | |
| 266 | ) | |
| 267 | note conflict "" | |
| 268 | publish conflict "$W" | |
| 269 | ||
| 270 | # ───────────────────────────────────────────────────────────────────────────── | |
| 271 | # case: signed — change-id alongside a multi-line gpgsig header | |
| 272 | # | |
| 273 | # The parser hazard from docs/change-id-format.md §2: a naive line scan can walk | |
| 274 | # into the base64 signature body. Uses an SSH signing key so CI needs no GPG. | |
| 275 | # ───────────────────────────────────────────────────────────────────────────── | |
| 276 | echo "==> case: signed" | |
| 277 | W="$WORK/signed" | |
| 278 | KEY="$WORK/signkey" | |
| 279 | ssh-keygen -t ed25519 -N "" -f "$KEY" -q | |
| 280 | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 281 | ( | |
| 282 | cd "$W" | |
| 283 | git config gpg.format ssh | |
| 284 | git config user.signingkey "$KEY.pub" | |
| 285 | echo "signed content" > s.txt | |
| 286 | jj describe -m "a signed change" >/dev/null | |
| 287 | jj sign -r @ \ | |
| 288 | --config 'signing.behavior="own"' \ | |
| 289 | --config 'signing.backend="ssh"' \ | |
| 290 | --config "signing.key=\"$KEY\"" >/dev/null 2>&1 | |
| 291 | jj bookmark create main -r @ >/dev/null | |
| 292 | ) | |
| 293 | note signed "" | |
| 294 | publish signed "$W" | |
| 295 | ||
| 296 | # ───────────────────────────────────────────────────────────────────────────── | |
| 297 | # case: renames — for comment anchor rebasing across a file rename (spec §5) | |
| 298 | # ───────────────────────────────────────────────────────────────────────────── | |
| 299 | echo "==> case: renames" | |
| 300 | W="$WORK/renames" | |
| 301 | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 302 | ( | |
| 303 | cd "$W" | |
| 304 | printf 'alpha\nbravo\ncharlie\ndelta\necho\n' > original.txt | |
| 305 | jj describe -m "before rename" >/dev/null | |
| 306 | jj bookmark create main -r @ >/dev/null | |
| 307 | jj new -m "rename and edit" >/dev/null | |
| 308 | git mv original.txt renamed.txt 2>/dev/null || mv original.txt renamed.txt | |
| 309 | printf 'alpha\nbravo\nCHARLIE\ndelta\necho\nfoxtrot\n' > renamed.txt | |
| 310 | jj bookmark create renamed -r @ >/dev/null | |
| 311 | ) | |
| 312 | publish renames "$W" | |
| 313 | ||
| 314 | # ───────────────────────────────────────────────────────────────────────────── | |
| 315 | # case: whitespace — anchor rebasing must not treat reindentation as a content | |
| 316 | # change (spec §5: "whitespace-only changes") | |
| 317 | # ───────────────────────────────────────────────────────────────────────────── | |
| 318 | echo "==> case: whitespace" | |
| 319 | W="$WORK/whitespace" | |
| 320 | jj git init --colocate "$W" >/dev/null 2>&1 | |
| 321 | ( | |
| 322 | cd "$W" | |
| 323 | printf 'fn main() {\nlet x = 1;\nprintln!("{}", x);\n}\n' > m.rs | |
| 324 | jj describe -m "unindented" >/dev/null | |
| 325 | jj bookmark create main -r @ >/dev/null | |
| 326 | jj new -m "reindent only" >/dev/null | |
| 327 | printf 'fn main() {\n let x = 1;\n println!("{}", x);\n}\n' > m.rs | |
| 328 | jj bookmark create reindented -r @ >/dev/null | |
| 329 | ) | |
| 330 | publish whitespace "$W" | |
| 331 | ||
| 332 | # ───────────────────────────────────────────────────────────────────────────── | |
| 333 | # case: hostile — inputs the security tests in spec §9 need | |
| 334 | # | |
| 335 | # A repo containing a symlink escaping the repo root, a path with unusual | |
| 336 | # characters, and a deeply nested tree. Serving any of these naively is a CVE. | |
| 337 | # ───────────────────────────────────────────────────────────────────────────── | |
| 338 | echo "==> case: hostile" | |
| 339 | W="$WORK/hostile" | |
| 340 | git init -q "$W" | |
| 341 | ( | |
| 342 | cd "$W" | |
| 343 | git symbolic-ref HEAD refs/heads/main | |
| 344 | ln -s /etc/passwd escape-absolute | |
| 345 | ln -s ../../../../etc/shadow escape-relative | |
| 346 | mkdir -p a/b/c/d/e/f/g/h | |
| 347 | echo "deep" > a/b/c/d/e/f/g/h/deep.txt | |
| 348 | printf 'no trailing newline' > weird-name$'\t'tab.txt 2>/dev/null || echo skip > normal.txt | |
| 349 | echo "content" > "spaces in name.txt" | |
| 350 | git add -A && git commit -qm "hostile paths" | |
| 351 | ) | |
| 352 | publish hostile "$W" | |
| 353 | ||
| 354 | # ─── finish ────────────────────────────────────────────────────────────────── | |
| 355 | ||
| 356 | printf '}}' >> "$MANIFEST.tmp" | |
| 357 | # Reformat, and fail loudly on malformed JSON rather than shipping a corpus whose | |
| 358 | # manifest the Rust tests cannot parse. | |
| 359 | python3 -c " | |
| 360 | import json | |
| 361 | raw = open('$MANIFEST.tmp').read() | |
| 362 | json.dump(json.loads(raw), open('$MANIFEST','w'), indent=2) | |
| 363 | print('==> manifest ok') | |
| 364 | " | |
| 365 | rm -f "$MANIFEST.tmp" | |
| 366 | ||
| 367 | echo | |
| 368 | echo "==> corpus written to $OUT" | |
| 369 | ls -1 "$OUT" | sed 's/^/ /' |
369 lines · Shell