When I moved my own portfolio site from Next.js to Astro, the condition I set was simple: the Next.js site that was already running must not be overwritten or changed. The Astro site was built in a new folder next to the old site's folder, in a new private git repo, not a branch of the old repo and not a rewrite in place. The blog content, 238 MDX pairs, along with the blog covers, the videos, the content graph, and the env values, was planned to be copied, not moved.
Copying sounds like the safest step in the whole plan. The real question was not whether to copy or move but where to copy from. Measured on 8 September 2026, the day of the move, copying from disk would have left 189 files byte-different from what production actually served.
Two files that had just passed sha256
The first stage covered the design system, site constants, robots.txt, and assets. Its commit records that the assets were copied with per-file sha256 verification, 52 files compared and zero different. The fix commit for the same stage, recorded less than four minutes later, notes that Git warned the CRLF in favicon.svg and site.webmanifest would be normalized, and that this changed bytes that had just been verified. In Git, that kind of warning reads CRLF will be replaced by LF.
The order of events is a clue on its own. Two CRLF files passed a sha256 comparison with zero differences, so whatever they were compared against was CRLF as well. Of the three places holding those two files, disk, git, and production, only disk had them as CRLF. In other words, the verification compared the copy with the disk it had been copied from, and a check like that is all but guaranteed to pass.
The stale local copy
Tracing it reversed the initial assumption. The Next project's working tree did hold both files with CRLF, but git stored them as LF, and production served the LF version because the hosting platform builds from git. The CRLF form never reached a visitor. What reached visitors was the content of git.
The step I nearly took was to lock public/** -text so the disk bytes would be preserved. The -text attribute tells git to stop normalizing line endings and store a file exactly as it sits on disk. Had that gone in, CRs that only lived on the old disk would have entered the new repo as its legitimate content. That step would have frozen a stale artifact.
The migration plan itself pointed the same way. It said to copy with rsync -a so bytes and timestamps stay intact, to normalize nothing, and to add a .gitattributes containing * -text to the new repo to keep git from normalizing line endings. The plan sided with the disk bytes.
Two days earlier, the spec that dissected the old site had already seen CRLF spread across 85 Indonesian MDX files, 102 English MDX files, and 22 files in src/. Its conclusion came from a clean git status: the repo was taken to store those files as they were. That conclusion does not line up with what was measured on the day of the move, because git stored the MDX files and both assets as LF.
git status is simply not the tool for that question. Git judges changes through the stat data recorded in the index and through its own line ending conversion, so a CRLF file on disk whose content matches an LF blob can be reported as clean. With core.autocrlf=input, the value the same spec recorded for that repo, git turns CRLF into LF before comparing a file's content with the blob. To see both sides separately, git ls-files --eol prints the line endings in the index and in the working tree for each file:
# general example with a neutral file name, not output from this site's repo
git ls-files --eol example.svg
# i/lf w/crlf attr/ example.svgThe i/ column is the index and w/ is the working tree. A file shaped like those two assets shows up as i/lf w/crlf.
From two files to 189
Once git was the reference, those two files turned out to be just the tip. The measurements, in three lines:
public/favicon.svg disk CRLF git LF production LF
public/site.webmanifest disk CRLF git LF production LF
content/blog/**/*.mdx 187 of 476 disk CRLF, git all LF, production LFTwo assets plus 187 MDX files make 189. The 187 also agrees with the spec from two days earlier, since 85 Indonesian plus 102 English MDX files is 187.
Another verification note from the same day closes the count from the size side. The article corpus size of 3,168,943 bytes turned out to be a disk figure, while the git blobs total 3,149,927 bytes. The difference is 19,016 bytes, exactly the number of CR characters in the 187 CRLF files. The whole size gap between disk and git is accounted for by CR, with nothing left over.
Those CRs are not entirely silent either. The 6 September spec recorded that gray-matter tolerates CRLF while tools built on grep or a ^---$ regex do not. A check in that spec had counted only 153 of 238 cover: entries before \r was stripped. The gap is 85, the same as the number of Indonesian MDX files with CRLF in that note.
Without .gitattributes, the machine decides
The structural cause sat in the source repo. It had no .gitattributes at all, so the line endings on disk depended on the core.autocrlf of whichever machine happened to be in use. Without attributes, git leaves the matter to local configuration: true writes CRLF on checkout and turns it back into LF on commit, input only normalizes on commit, and false changes nothing. The git content can be identical on two machines while the bytes on their disks differ. Where the CRs on my disk came from is not recorded. What is recorded is that nothing locked the form down.
For the two assets, the fix had two parts. Both files were rewritten from the bytes production actually served, and the new repo got a .gitattributes that locks svg and webmanifest to LF while marking binary assets so normalization never touches them. The rules section looks like this:
*.svg text eol=lf
*.webmanifest text eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ico binary
*.woff2 binary
*.webm binary
*.mp4 binary
*.pdf binary
*.astro text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.css text eol=lf
*.md text eol=lf
*.mdx text eol=lf
*.json text eol=lfA comment in the file gives the reason: the two text files in public/ are locked to LF so they do not depend on the core.autocrlf of anyone's machine. text eol=lf makes git store LF in the repo and write LF on checkout whatever the machine's setting, while binary turns conversion off entirely. Astro, TypeScript, CSS, Markdown, MDX, and JSON sources are locked to LF as well. That .gitattributes went in with the same fix commit on 8 September and has not changed since.
The final verification ran against the Next project's git blobs: 50 assets matched, 1 differed on purpose (og-image.png), and 0 were unexpected.
The blob as the reference
The first rule recorded from this move is that the reference for file parity is the git blob, not the working tree. The check, per file:
git -C <repo> cat-file blob HEAD:<path> | shasum -a 256The result is compared with the sha256 of the new copy, not with the file in the source repo's working tree. git cat-file blob prints the blob exactly as stored, with no line ending conversion, so what gets compared is the content of git itself.
In the articles stage, the same rule was applied to the content. The project note for that stage puts it in capitals: content must be taken from the git blob, not the working tree. The note adds that this is the same rule as for assets and had already happened twice, so it is treated as a default. The content commit wrote the articles from the git blob and records 714 content and cover files verified as matching the git blob.
On the same day, 21 webm videos weighing 83 MB that had never been copied were also taken from the git blob, each hash checked one by one, and 48 of 48 assets (21 webm plus 27 webp) have a content-length that matches production.
In the CV and email flow stage, the rule became the working method for files that must stay faithful: derive the file from the git blob, then change it through targeted replacements with the number of replacements counted, never by retyping. The first-stage checklist in the migration plan states it as a requirement: sha256 of every copied asset compared against the Next project's git blob, not against the file in its working tree. The checklist adds one short line, this is not fussiness.
This rule caught a real mistake more than once in a single session, so I treat it as a default rather than as caution.
The limits of the verification
One limit needs to be stated plainly. For those 187 MDX files, what is recorded is verification against the git blob. My notes say production served the LF version, but a per-file byte comparison of the MDX against production was never recorded. For assets there are two references: the git blobs for the 50 assets in the final verification, and production content-length for the 48 webm and webp files.
The domain was moved that same day, and the Astro site started serving visitors.
What I have held to since is simple. When production is built from git, the working tree is only one copy, and it can be a stale one. The reference for copying and verifying is the blob, and the destination repo locks its line endings explicitly so the bytes on disk no longer depend on whose machine happens to be in use.