Linux & Bash · Part II · Module 26 · Compile and Simulation File Lists
Order, Duplicates and Stability
A generated filelist should differ between two runs only when the design differs. That requires a deliberate order, a deliberate locale and a deduplication step — and the folklore about why is wrong in an interesting way, which this chapter measures.
Intermediate21 min readLinuxPart IIFilelistssortDeterminism
Part II · Module 26 · Chapter 26.4 · Order, Duplicates and Stability
Two engineers generate a filelist from the same tree and get two different files. A diff shows forty changed lines and no changed design. Three separate causes, and only one of them is the one people name.
1. find's Order Is Not Random — It Is Name-Hashed and Stable
The folklore is that unsorted find output is "random" or "depends on creation order". On this filesystem both are wrong, and the truth is more useful.
Three runs of the same command:
$ for i in 1 2 3; do /usr/bin/find rtl -name '*.sv' | md5; done
0a8131e779a0bbee461b50b108be8326
0a8131e779a0bbee461b50b108be8326
0a8131e779a0bbee461b50b108be8326Identical. So within one machine and one directory the order is stable, and a filelist regenerated twice will not differ.
Is it creation order? Eight files, created in one order and then the same eight names created in the reverse order in a second directory:
$ find /tmp/ot3 -name '*.sv' | sed 's|.*/||' | tr '\n' ' '
fifo.sv chip_top.sv axi_master.sv i2c.sv clk_div.sv dma.sv uart_tx.sv spi.sv
$ find /tmp/ot4 -name '*.sv' | sed 's|.*/||' | tr '\n' ' '
fifo.sv chip_top.sv axi_master.sv i2c.sv clk_div.sv dma.sv uart_tx.sv spi.svIdentical, despite opposite creation orders. So the order is a function of the names, not of when the files were made — APFS hashes directory entries.
And it is neither alphabetical nor reverse, checked programmatically rather than by eye:
$ a=$(find /tmp/ot3 -name '*.sv' | sed 's|.*/||')
$ [ "$a" = "$(printf '%s\n' "$a" | sort)" ] && echo ALPHABETICAL || echo "not alphabetical"
not alphabetical
$ [ "$a" = "$(printf '%s\n' "$a" | sort -r)" ] && echo REVERSE || echo "not reverse alphabetical"
not reverse alphabetical
$ ls /tmp/ot3 | tr '\n' ' '
axi_master.sv chip_top.sv clk_div.sv dma.sv fifo.sv i2c.sv spi.sv uart_tx.svls sorts; find does not. So ls and find disagree about the same directory, which is why a filelist built with one and checked with the other looks wrong.
2. The Locale Changes the Sorted Order
Sorting is necessary and not sufficient, because sort itself is configuration — Chapter 22.6 §3 measured it and a filelist is where it bites.
$ cat order.f
rtl/Top.sv
rtl/axi.sv
rtl/Bus.sv
$ LC_ALL=C sort order.f
rtl/Bus.sv
rtl/Top.sv
rtl/axi.sv
$ LC_ALL=en_US.UTF-8 sort order.f
rtl/axi.sv
rtl/Bus.sv
rtl/Top.svCompletely different orders. Under LC_ALL=C the comparison is by byte value, so all uppercase precedes all lowercase; under a UTF-8 locale it is by collation, which interleaves case and ignores it for the primary comparison.
In a design with mixed-case module names — which is most SystemVerilog — two engineers with different locales generate two differently ordered lists from the same tree, and neither of them has done anything wrong.
So every sort in a generator needs its locale pinned:
LC_ALL=C sortAnd not just sort. LC_ALL=C belongs on the whole generator, because find's -name patterns and any [a-z] range in a filter are locale-sensitive too — Chapter 22.1 §5 measured case B in [a-z]) answering differently in two locales.
#!/usr/bin/env bash
export LC_ALL=C # before anything else: ordering and ranges are now byte-wiseOne line at the top of a generation script, and the output is the same for everybody.
3. sort -u file > file Destroys the File
The registry's suggestion is correct and the obvious variant is not. Both measured.
The correct form uses sort's own output option, which reads the whole input before writing:
$ printf 'b\na\nb\n' > s.f
$ sort -u s.f -o s.f
$ cat s.f
a
bIn place, deduplicated, three lines to two. And the shell-redirect version:
$ printf 'b\na\nb\n' > s2.f
$ sort -u s2.f > s2.f
$ wc -c < s2.f
0Zero bytes. The redirect truncates s2.f before sort opens it, so sort reads an empty file and writes nothing. Chapter 12.2 §4 measured this truncation-before-execution behaviour; here the casualty is the list of files your design is made of.
Three safe forms:
sort -u list.f -o list.f # sort's own in-place, measured above
sort -u list.f > tmp && mv tmp list.f # explicit, portable to any tool
{ rm list.f; sort -u > list.f; } < list.f # the fd trick: clever, and avoid itUse the first when the tool is sort — it is specified behaviour, not a trick. Use the second for every other tool, because almost nothing else has -o. The third works because the file is opened before it is removed, and it is the kind of cleverness that fails silently when someone edits it later.
4. Deduplicating Without Destroying the Order
§3's sort -u reorders, which Chapter 26.1 §3 established can break the compile. When the order is load-bearing, deduplication must keep the first occurrence and leave everything else alone:
$ printf 'pkg.sv\naxi.sv\npkg.sv\nuart.sv\naxi.sv\n' | awk '!seen[$0]++'
pkg.sv
axi.sv
uart.svawk '!seen[$0]++' — Chapter 25.6 §4's idiom, used there for PATH and here for a filelist. Order preserved, first occurrence kept, duplicates gone. For a filelist that is almost always the right deduplication, because the first occurrence is the one that satisfied the dependency.
Two refinements for real lists:
# keep comments and blank lines, deduplicate only paths and options
awk '/^[[:space:]]*(#|$)/ { print; next } !seen[$0]++'
# report what was removed, rather than removing it silently
awk '/^[[:space:]]*(#|$)/ { print; next }
seen[$0]++ { printf "# duplicate removed: %s\n", $0 > "/dev/stderr"; next }
{ print }'The second is worth defaulting to. A duplicate in a filelist is a fact about how the lists are composed, and Chapter 26.6 is about that composition — so a generator that reports duplicates on stderr while emitting a clean list gives you both the working list and the diagnosis.
5. Making a Diff Mean Something
The payoff for all of the above: a filelist diff that shows design changes and nothing else.
| Without | With |
|---|---|
| order differs between machines | LC_ALL=C sort |
| order differs between locales | export LC_ALL=C |
| one added file moves many lines | sorted, so the insertion is local |
| the same file appears three ways | normalised before deduplication |
| a duplicate is invisible | reported on stderr |
| regeneration rewrites the file | identical bytes, so git status is clean |
That last row is the test, and it is one command:
genlist -o /tmp/new.f rtl tb && cmp -s /tmp/new.f filelists/rtl.f && echo "no change"If regenerating a committed filelist produces identical bytes, every property above holds. If it does not, one of them is missing, and diff names which. That is Chapter 26.3 §6's golden-file check, and it is also a self-test for this chapter's rules — which is why it is worth running once deliberately rather than only in CI.
6. The Normaliser
#!/usr/bin/env bash
# flnorm — make a filelist canonical: stable order, no duplicates, reported
#
# usage: flnorm [-s] [-q] <filelist>
# -s sort the paths (default: preserve order, deduplicate first-wins)
# -q do not report duplicates on stderr
# exit: 0 clean · 1 duplicates were removed · 2 unusable
set -o nounset
export LC_ALL=C
dosort=0; quiet=0
while getopts ':sq' o; do
case $o in
(s) dosort=1 ;;
(q) quiet=1 ;;
(\?) printf 'flnorm: unknown option -%s\n' "$OPTARG" >&2; exit 2 ;;
esac
done
shift $(( OPTIND - 1 ))
[ $# -eq 1 ] || { printf 'usage: flnorm [-s] [-q] <filelist>\n' >&2; exit 2; }
[ -r "$1" ] || { printf 'flnorm: cannot read %s\n' "$1" >&2; exit 2; }
dups=0
emit() {
awk -v quiet="$quiet" '
/^[[:space:]]*(#|$)/ { print; next }
seen[$0]++ {
if (!quiet) printf "flnorm: duplicate removed: %s\n", $0 > "/dev/stderr"
d++
next
}
{ print }
END { if (d > 0) exit 1 }
' "$1"
}
if [ "$dosort" -eq 1 ]; then
# options and comments first, in order; then the paths, sorted
grep -E '^[[:space:]]*(#|-|\+)' -- "$1" || true
grep -vE '^[[:space:]]*(#|$|-|\+)' -- "$1" | sort -u
else
emit "$1" || dups=1
fi
[ "$dups" -eq 0 ]$ cat dup.f
# the design
+incdir+rtl/include
rtl/core/chip_top.sv
rtl/axi/axi_master.sv
rtl/core/chip_top.sv
$ flnorm dup.f
# the design
+incdir+rtl/include
rtl/core/chip_top.sv
rtl/axi/axi_master.sv
flnorm: duplicate removed: rtl/core/chip_top.sv
$ echo $?
1
$ flnorm -q dup.f > /dev/null; echo $?
1
$ flnorm -s dup.f
# the design
+incdir+rtl/include
rtl/axi/axi_master.sv
rtl/core/chip_top.svEight decisions:
export LC_ALL=C on the second line, before any comparison happens — §2's rule, applied to the tool that does the comparing.
Order-preserving is the default and -s is opt-in, because Chapter 26.1 §3 measured that sorting can break a compile. A normaliser whose default could stop a design compiling would be the wrong default.
Duplicates exit 1 and are reported on stderr while the clean list goes to stdout. That separation is what makes it usable in a pipeline and as a check: flnorm list.f > new.f works, and flnorm list.f > /dev/null || echo "had duplicates" works.
The -s branch keeps options and comments in their original order and sorts only the paths, because sorting +incdir+ lines among paths would scatter the configuration Chapter 26.2 §4 argued for keeping at the top.
awk reports to /dev/stderr rather than the shell doing it, so the duplicate is named at the point it is found rather than collected and printed afterwards.
END { if (d > 0) exit 1 } — and the status is set only in END, for the reason Chapter 25.4 measured: an exit in the body would run END anyway, and END's status would replace it.
|| true after the first grep, because a filelist with no options or comments makes grep exit 1 and would otherwise abort the branch.
And one inconsistency this script has, disclosed rather than hidden: the -s branch deduplicates with sort -u, which neither reports nor counts, so it exits 0 on a list that had duplicates:
$ flnorm /tmp/dup.f > /dev/null; echo $?
1
$ flnorm -s /tmp/dup.f > /dev/null; echo $?
0Same input, same duplicate removed, two different statuses. That is a defect in a tool meant to be usable as a check, and the fix is to count in the -s branch too:
before=$(grep -cvE '^[[:space:]]*(#|$|-|\+)' -- "$1")
after=$(grep -vE '^[[:space:]]*(#|$|-|\+)' -- "$1" | sort -u | grep -c .)
[ "$before" -eq "$after" ] || dups=1It is left in the version above as written, with this note, because the inconsistency is more instructive than the fix: two code paths that look equivalent and differ in their contract is the commonest way a script's exit status stops meaning anything. Chapter 21.4's contract has to hold on every path, and the only way to know it does is to exercise each one — which is how this was found.
Practice
- Run
findon a directory three times and compare the digests. Stable? - Create the same eight file names in two directories in opposite orders. Do the two
findoutputs match? - Check programmatically whether your
findorder is alphabetical or reverse. Is it either? - Compare
lsandfindon one directory. Where do they disagree? - Sort a mixed-case filelist under
LC_ALL=Cand under a UTF-8 locale. How many lines move? - Run
sort -u list.f > list.fon a copy and check the byte count. - Then run
sort -u list.f -o list.fand confirm it works. - Find the
uniq -dduplicates in a real filelist. Any? - Write three spellings of one path into a list and confirm
sort -ukeeps all three. - Write the normaliser from §3's callout and confirm it collapses them to one.
- Deduplicate an order-sensitive list with
awk '!seen[$0]++'and confirm the first occurrence survives. - Write
flnorm. Run it on a list with duplicates and confirm exit 1 with the clean list on stdout. - Run
flnorm -son a list with options and confirm the options stay at the top. - Regenerate a committed filelist and
cmpit against the committed copy. Identical bytes?
Check Your Understanding
- Is
find's unsorted order random, creation-ordered, or something else? - What did two directories with opposite creation orders demonstrate?
- Given that the order is stable, why must you still sort?
- How do
lsandfinddiffer on one directory? - What did
LC_ALL=Cversus a UTF-8 locale do to a mixed-case filelist? - Why does
LC_ALL=Cbelong on the whole generator rather than just thesort? - What does
sort -u list.f > list.fleave you with, and why? - Name three safe in-place forms and say when each is right.
- What does
sort | uniq -ctell you thatsort -udoes not? - Why does
sort -ufail to deduplicate three spellings of one path? - When must deduplication preserve order, and what is the idiom?
- Why is order-preserving the default in
flnorm? - Why is the exit status set only in
awk'sENDblock?
7. Remember
find's order is stable and name-hashed, not random and not creation-ordered. Measured: identical digests across runs, and identical order in two directories built in opposite orders.lssorts;finddoes not. They disagree on the same directory.- Sort anyway: the hash is per-filesystem, one added file can move others, and an arbitrary order cannot encode dependencies.
LC_ALL=Cchanges the sorted order of a mixed-case list completely. Pin it on the whole generator, not just thesort.sort -u f > fleaves a zero-byte file — the shell truncates beforesortopens it.sort -u f -o fworks in place;sort -u f > tmp && mv tmp fis the general form.sort -udiscards duplicates;uniq -ccounts them, and the count tells you two lists overlap.sort -ucannot see that three path spellings are one file. Normalise withcd … && pwd -Pfirst, and know it costs two subshells per line.- When order matters, deduplicate with
awk '!seen[$0]++'— first occurrence wins. - Report duplicates on stderr and emit the clean list on stdout, so one tool serves as a filter and a check.
- Set an
awkexit status only inEND. - Every code path must honour the documented status. Measured:
flnormandflnorm -sreturned 1 and 0 for the same duplicate, because one path counted and the other did not. - The test for all of it: regenerate and
cmp. Identical bytes means every rule above holds.
8. What Is Next
Chapter 26.5 checks the list before the tool sees it — every path present, every variable resolved — so that a four-hundred-line compile error becomes one line naming the file that is not there.
Where this fits
Part of the Linux & Bash curriculum.
