| 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