Baobaobaolin.com
date
entry
032
topic
infrastructure
rev
—

Deploying from a clean worktree, sync --delete removed 32 images that weren't in git

I deploy from a clean worktree so that only committed content ships. The cost: --delete treated 32 images that lived only in my working directory as things to remove.

When I deploy the company website, I run the deploy script from a clean git worktree rather than the working directory where I write. The reason is simple: the working directory may hold unfinished work, while a worktree freshly created from a given commit brings none of the working directory's untracked files, so the build uses exactly that commit's source.

The log of the October 11, 2026 deploy had 32 extra delete: lines: 24 images starting with vocus-, 5 with medium- and 3 with threads-, all artwork for social posts. They existed only in my working directory, never in git, and posts already published may reference them by URL. I copied them back one by one with aws s3 cp, and the next deploy (the two deploy logs are about six and a half minutes apart) deleted the same 32 again. This note is about what sync --delete actually compares against. Four questions, in order: why it deletes, how to reproduce it without AWS, what the fixes are, and which one I lean towards.

Why it deletes

aws s3 sync help explains --delete in two sentences: files that exist in the destination but not in the source are deleted during sync; files excluded by filters are excluded from deletion. "Source" here is the local directory you give it. It knows nothing about git, or about which files you deliberately left uncommitted.

That site's build copies the repo's assets/ into dist/assets/, and the last step of the deploy script is:

aws s3 sync dist/assets/ "s3://$BUCKET/assets/" --delete \
  --cache-control "public, max-age=31536000, immutable"

That step exists to clear out old hashed JS and CSS. 005 covered how deleting old bundles mid-deploy blanks open pages, and its fix was not to delete; this script's compromise is to leave deletion to the very last step. Deploying from the working directory, the 32 images sit in assets/, get copied into dist/assets/, exist both on S3 and locally, and aren't deleted. From a clean worktree, assets/ holds only tracked files, dist/assets/ is 32 images short, and those 32 on S3 become "in the destination, not in the source".

In other words, the clean worktree settles which version ships, and in the same move makes "present locally but uncommitted" files nonexistent as far as --delete is concerned. Those are two sides of one action.

Reproducing it without AWS

For this local-directory-to-general-purpose-bucket case, --dryrun reads the local files, lists the destination's objects, and sends no uploads or deletes, so a fake S3 that answers ListObjectsV2 is enough. Twenty lines of Python:

# fake_s3.py: which objects the bucket "already has" comes from keys.txt (key and size per line)
import http.server, urllib.parse, sys
KEYS = sorted(tuple(l.split()) for l in open(sys.argv[2]) if l.strip())
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
        prefix = q.get("prefix", [""])[0]
        items = "".join(
            f"<Contents><Key>{k}</Key><LastModified>2030-01-01T00:00:00.000Z</LastModified>"
            f"<ETag>\"x\"</ETag><Size>{size}</Size><StorageClass>STANDARD</StorageClass></Contents>"
            for k, size in KEYS if k.startswith(prefix))
        body = ('<?xml version="1.0" encoding="UTF-8"?>'
                '<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
                f'<Name>site</Name><Prefix>{prefix}</Prefix><IsTruncated>false</IsTruncated>'
                f'{items}</ListBucketResult>').encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/xml")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers(); self.wfile.write(body)
http.server.HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()

Then a minimal repo: one tracked app-3f9c.js, two uncommitted images, and a worktree created from it. The fake S3 has all three files, with the same sizes as local and a 2030 modification time so nothing is planned for upload. This uses bash (and git commit needs user.name and user.email set). The CLI is isolated the way 026 did it: env -i to clear the environment, config files pointed at /dev/null, the documentation's example keys, and the endpoint pointed at localhost:

$ mkdir -p demo/repo/assets && cd demo/repo && git init -q
$ echo a > assets/app-3f9c.js && git add . && git commit -qm init
$ echo img > assets/vocus-cover.png; echo img > assets/threads-cover.png
$ git status --short
?? assets/threads-cover.png
?? assets/vocus-cover.png
$ git worktree add -q ../wt HEAD && ls ../wt/assets
app-3f9c.js
$ cd ..
$ printf 'assets/app-3f9c.js 2\nassets/threads-cover.png 4\nassets/vocus-cover.png 4\n' > keys.txt
$ python3 fake_s3.py 9555 keys.txt &      # fake_s3.py saved in demo/
$ aws() { env -i PATH="$PATH" HOME="$HOME" \
    AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null \
    AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE \
    AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
    AWS_DEFAULT_REGION=ap-northeast-1 \
    aws --endpoint-url http://127.0.0.1:9555 "$@"; }

$ aws s3 sync repo/assets/ s3://site/assets/ --delete --dryrun   # working directory
$ aws s3 sync wt/assets/ s3://site/assets/ --delete --dryrun     # clean worktree
(dryrun) delete: s3://site/assets/threads-cover.png
(dryrun) delete: s3://site/assets/vocus-cover.png
$ aws s3 sync wt/assets/ s3://site/assets/ --delete --dryrun \
    --exclude "vocus-*" --exclude "threads-*"                     # with excludes

The working-directory run prints nothing, the worktree run lists two planned deletions (a dry run; nothing is actually deleted), and with --exclude it prints nothing again, consistent with the help text's "excluded from deletion".

I hit a snag of my own building the fake. The first version didn't sort the keys, and the same command's dry run from the working directory listed threads-cover.png both as a planned upload and as a planned delete. aws s3api list-objects-v2 help says general purpose buckets return objects in lexicographical order by key; sync walks the two sorted lists side by side, and with the wrong order the comparison falls apart. With sorting, the output was sane.

Four fixes

  • Commit the files. The most direct. The cost: the social artwork was never meant to be versioned with the website, and once in the repo it follows the site's versions, so deleting it later becomes its own decision.
  • Block specific prefixes with --exclude. Shown above to work. The cost is a list to maintain: the next kind of image, say linkedin-, gets deleted too as long as it sits in assets/, uncommitted and not excluded.
  • Keep them out of the prefix --delete manages. Put non-site files under another prefix (for example social/) and keep --delete on assets/, so the two never overlap. A structural fix, though URLs already shared won't move with it.
  • Dry-run first and stop if the deletion list looks wrong. Doesn't change where files live; just looks before deleting for real.

I lean towards the fourth because it blocks the whole class of problem, not just these 32 (at the time of writing it isn't in the deploy script yet; what follows is the version tested against the fake S3). With this project's Vite config, the JS and CSS file names end in - plus an 8-character hash, such as blog-DlAXBFnW.css (the naming is configurable and differs between projects). If the deletion list contains any name that doesn't look like that, stop. The filter uses awk rather than grep -v, because when the list is fine grep -v outputs nothing and returns 1, which ends the whole script early under set -e:

set -euo pipefail
src="$1"; dest="$2"; shift 2
case " $* " in *" --quiet "*|*" --only-show-errors "*)
  echo "--quiet hides the deletion list; not supported" >&2; exit 1;; esac
plan=$(aws s3 sync "$src" "$dest" --delete --dryrun "$@")
unexpected=$(printf '%s\n' "$plan" | awk '
  /^\(dryrun\) delete: / {
    sub(/^\(dryrun\) delete: /, "")
    if ($0 !~ /-[A-Za-z0-9_-]{8}\.(js|css)$/) print
  }')
if [ -n "$unexpected" ]; then
  echo "sync --delete would remove files that aren't old bundles; stopping:" >&2
  printf '%s\n' "$unexpected" >&2
  exit 1
fi
aws s3 sync "$src" "$dest" --delete "$@"

Save it as demo/guard.sh. Tested with an extra old bundle, index-Bq3xY9aZ.js, on the fake S3: first export -f aws so the isolating function reaches the child shell, then run bash guard.sh wt/assets/ s3://site/assets/ --dryrun (the extra --dryrun is passed through to the last line too, since the fake S3 can't process real deletes): run from the worktree, it lists the two images, exits 1 and doesn't sync; run from the working directory, the deletion list holds only the old bundle and it passes; with nothing to delete it also passes.

This check guesses from file names. A non-bundle file that happens to be called cover-12345678.css would pass as an old bundle, and if the hash length changes in the build config, the pattern has to follow. The dry run and the real sync each build their own list, so a change in between goes unseen. It catches "didn't expect that deletion", not every wrong deletion.

If you remember one thing

sync --delete compares against the directory you hand it, not git, and not what you remember putting there. The 32 images at the top were deleted twice, and both deploy scripts ran to completion; the most direct signal was those 32 delete: lines, in a log with 1,468 upload lines. Having the script check the deletion list means relying less on someone spotting 32 lines among more than a thousand.

Revision history

  1. First published