Jump to…
snowinitial commitqoxwzsukwmkx1mo
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
16set -euo pipefail
17
18JJ_VERSION="${JJ_VERSION:-0.43.0}"
19ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20OUT="${1:-$ROOT/repos}"
21BIN="$ROOT/bin"
22WORK="$(mktemp -d)"
23trap 'rm -rf "$WORK"' EXIT
24
25# ─── toolchain ───────────────────────────────────────────────────────────────
26
27export PATH="$BIN:$PATH"
28
29if ! command -v jj >/dev/null 2>&1 || [[ "$(jj --version | awk '{print $2}' | cut -d- -f1)" != "$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_VERSION}/jj-v${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
33 | tar xz -C "$BIN" jj
34 chmod +x "$BIN/jj"
35fi
36
37echo "==> jj: $(jj --version)"
38echo "==> git: $(git --version)"
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.
44export JJ_CONFIG="$WORK/jjconfig.toml"
45cat > "$JJ_CONFIG" <<'EOF'
46[user]
47name = "Fixture Author"
48email = "fixture@dogfood.sh"
49
50[ui]
51paginate = "never"
52color = "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()"
59EOF
60
61export GIT_AUTHOR_NAME="Fixture Author"
62export GIT_AUTHOR_EMAIL="fixture@dogfood.sh"
63export GIT_COMMITTER_NAME="Fixture Author"
64export GIT_COMMITTER_EMAIL="fixture@dogfood.sh"
65
66rm -rf "$OUT"
67mkdir -p "$OUT"
68
69MANIFEST="$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.
72printf '{"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.
76note() { # 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.
81publish() { # publish <case> <workdir> [default_branch]
82 local case="$1" work="$2" head="${3:-main}"
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
92cid() { jj -R "$1" log --no-graph --ignore-working-copy -T 'commit_id' -r "$2"; }
93chid() { 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# ─────────────────────────────────────────────────────────────────────────────
100echo "==> case: basic"
101W="$WORK/basic"
102jj 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)
113note basic "$(printf '{"head_change":"%s","head_commit":"%s"}' "$(chid "$W" 'bookmarks(main)')" "$(cid "$W" 'bookmarks(main)')")"
114publish 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# ─────────────────────────────────────────────────────────────────────────────
124echo "==> case: rewritten"
125W="$WORK/rewritten"
126jj git init --colocate "$W" >/dev/null 2>&1
127git 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)
136TARGET="$(chid "$W" 'bookmarks(main)')"
137REWRITES="$(cid "$W" 'bookmarks(main)')"
138for 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 $(cid "$W" 'bookmarks(main)')"
149done
150git --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.
152note rewritten "$(printf '{"stable_change":"%s","revisions":["%s"]}' \
153 "$TARGET" "$(echo "$REWRITES" | sed 's/ /","/g')")"
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# ─────────────────────────────────────────────────────────────────────────────
161echo "==> case: plain-git"
162W="$WORK/plaingit"
163git 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)
170note plain_git "$(printf '{"head_commit":"%s"}' "$(git -C "$W" rev-parse HEAD)")"
171publish 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# ─────────────────────────────────────────────────────────────────────────────
180echo "==> case: mixed"
181W="$WORK/mixed"
182git 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)
195publish 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# ─────────────────────────────────────────────────────────────────────────────
202echo "==> case: stack"
203W="$WORK/stack"
204jj 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)
216note stack "$(printf '{"bottom":"%s","top":"%s"}' "$(chid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(top)')")"
217publish stack "$W"
218
219# ─────────────────────────────────────────────────────────────────────────────
220# case: merge — a two-parent jj commit
221# ─────────────────────────────────────────────────────────────────────────────
222echo "==> case: merge"
223W="$WORK/merge"
224jj 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="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
234 jj new -m "left side" >/dev/null && echo L > l.txt
235 LEFT="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
236 jj new -m "right side" "$BASE" >/dev/null && echo R > r.txt
237 RIGHT="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
238 jj new "$LEFT" "$RIGHT" -m "the merge" >/dev/null
239 jj bookmark create main -r @ >/dev/null
240)
241note merge "$(printf '{"merge_commit":"%s","merge_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")"
242publish 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# ─────────────────────────────────────────────────────────────────────────────
251echo "==> case: conflict"
252W="$WORK/conflict"
253jj 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="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
259 jj new -m "side A" >/dev/null && printf 'line1\nAAA\nline3\n' > c.txt
260 SIDEA="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
261 jj new -m "side B" "$BASE" >/dev/null && printf 'line1\nBBB\nline3\n' > c.txt
262 SIDEB="$(jj log --no-graph --ignore-working-copy -T change_id -r @)"
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)
267note conflict "$(printf '{"conflicted_commit":"%s","conflicted_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")"
268publish 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# ─────────────────────────────────────────────────────────────────────────────
276echo "==> case: signed"
277W="$WORK/signed"
278KEY="$WORK/signkey"
279ssh-keygen -t ed25519 -N "" -f "$KEY" -q
280jj 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)
293note signed "$(printf '{"signed_commit":"%s","signed_change":"%s"}' "$(cid "$W" 'bookmarks(main)')" "$(chid "$W" 'bookmarks(main)')")"
294publish signed "$W"
295
296# ─────────────────────────────────────────────────────────────────────────────
297# case: renames — for comment anchor rebasing across a file rename (spec §5)
298# ─────────────────────────────────────────────────────────────────────────────
299echo "==> case: renames"
300W="$WORK/renames"
301jj 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)
312publish renames "$W"
313
314# ─────────────────────────────────────────────────────────────────────────────
315# case: whitespace — anchor rebasing must not treat reindentation as a content
316# change (spec §5: "whitespace-only changes")
317# ─────────────────────────────────────────────────────────────────────────────
318echo "==> case: whitespace"
319W="$WORK/whitespace"
320jj 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)
330publish 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# ─────────────────────────────────────────────────────────────────────────────
338echo "==> case: hostile"
339W="$WORK/hostile"
340git 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)
352publish hostile "$W"
353
354# ─── finish ──────────────────────────────────────────────────────────────────
355
356printf '}}' >> "$MANIFEST.tmp"
357# Reformat, and fail loudly on malformed JSON rather than shipping a corpus whose
358# manifest the Rust tests cannot parse.
359python3 -c "
360import json
361raw = open('$MANIFEST.tmp').read()
362json.dump(json.loads(raw), open('$MANIFEST','w'), indent=2)
363print('==> manifest ok')
364"
365rm -f "$MANIFEST.tmp"
366
367echo
368echo "==> corpus written to $OUT"
369ls -1 "$OUT" | sed 's/^/ /'

369 lines · Shell