Skip to content
Cockpit0.29.0
A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live

lib/archive.sh

An object of kind implementation

What it is

URI
majordomus://implementation/lib/archive.sh
Kind
implementation
Identity
lib/archive.sh
Path
lib/archive.sh
Provenance
declared
Media type
text/plain
As a capability
implementation.lib/archive.sh

Front matter

{}

Content

#!/usr/bin/env bash
# sourced by several commands; guard against re-sourcing
[ -n "${MJ_LIB_archive:-}" ] && return 0 || MJ_LIB_archive=1
# archive — a snapshot of this repository that can leave the machine.
#
# A tree has to travel: to a language model that will read all of it at once, to a
# reviewer who cannot clone, to an auditor who must run the gates somewhere other than
# the checkout. Every one of those was being done by hand, differently each time, and
# the hand-made version got two things wrong that are not obvious until much later.
#
# The first is what to leave out. A repository this size does not fit in a model's
# context, and the half that does not fit is the half that is generated: the projections
# under docs/generated/, site/data/ and site/content/docs/ are functions of canonical
# files that are already in the archive, so carrying them spends the reader's attention
# on the same statements twice. What is derived is not decided here — .gitattributes
# already marks every one of those paths `merge=derived`, for a different reason, and
# that declaration is read rather than restated. A second list would be the defect that
# `.ai/repo/rules/project/commands-are-projections.v1.md` exists to refuse.
#
# The second is fidelity. `zip -X` drops the extra fields that carry the Unix mode, so
# the unpacked tree has no executable anywhere, and every script in it fails to run for a
# reason that belongs to the archive rather than to the repository. That is the worst
# kind of failure: it is indistinguishable, at the far end, from the repository being
# broken. So the mode of every entry is read from the git index — the authority, not the
# working tree — written into the manifest, and a restore script ships beside it that
# puts the modes back and builds a local git index, because the checks that matter here
# read `git ls-files` and answer nothing without one.
#
# What is archived is the git index and only the git index. Nothing untracked travels:
# not the build output, not the caches, and not `.ai/local/`, which is this checkout's
# own state and is never shared. That is one rule rather than a list of exclusions, and
# it is the rule that makes the command safe to point at a repository nobody has read.
#
# The profiles are share/archive.yaml; a repository may add or replace one in
# .ai/repo/archive.yaml without redefining what an archive is.

# This file is sourced by a dispatcher that sets `-e`, so every pipeline here runs with
# the failure of anything but its last stage discarded. That matters more than usual for a
# command whose whole job is fidelity: a `tar` that could not read a file, piped into a
# `tar` that unpacked what it did get, would report success and produce a short archive.
# `set -o pipefail` is set for this command's run rather than trusted to the caller. Each
# pipeline below that may legitimately fail — a `head` closing a pipe early, an `xargs`
# over an empty list — says so with `|| true` rather than relying on the option's absence.
set -o pipefail

MJ_ARCHIVE_SHIPPED="$MJ_BIN_DIR/../share/archive.yaml"

# The repository's name, which is not this directory's name: a linked worktree is named
# after its branch, and an archive of it must still say which repository it is. The remote
# decides when there is one; the checkout's own directory is the fallback.
mj_archive_repo_name() {
  local url base
  url="$(mj_git config --get remote.origin.url 2>/dev/null || true)"
  if [ -n "$url" ]; then
    base="${url##*/}"; base="${base%.git}"
    [ -n "$base" ] && { printf '%s' "$base"; return 0; }
  fi
  printf '%s' "$(basename "$MJ_ROOT")"
}

mj_archive_usage() {
  cat <<H
usage: majordomus archive [<profile>] [--out <path>] [--format zip|tar.gz]
                          [--dry-run] [--force] [--json]
       majordomus archive --list [--json]

  a snapshot of the tracked tree, for reading or checking somewhere else

  <profile>   which snapshot (default: the registry's own default)
  --out       write here instead of tmp/archives/<repo>-<profile>-<date>.<ext>
  --format    override the profile's container
  --dry-run   report what would be archived and write nothing
  --force     overwrite an existing output file
  --list      the profiles, with what each one is for

  the file set is the git index; nothing untracked is ever archived. Every entry's mode
  is recorded from the index and restored by _ARCHIVE/restore.sh inside the archive.

  exit codes: 0 ok · 2 usage · 12 no such profile, or a tool is missing
              13 internal error · 15 refused (the output exists; --force to replace it)
H
}

mj_cmd_archive() {
  local profile="" out="" format="" dry=0 force=0 list=0
  while [ $# -gt 0 ]; do case "$1" in
    --out) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--out needs a path"; out="$2"; shift 2 ;;
    --out=*) out="${1#--out=}"; shift ;;
    --format) [ $# -ge 2 ] || mj_die "$MJ_EX_USAGE" "--format needs zip or tar.gz"; format="$2"; shift 2 ;;
    --format=*) format="${1#--format=}"; shift ;;
    --dry-run) dry=1; shift ;;
    --force) force=1; shift ;;
    --list) list=1; shift ;;
    --json) shift ;;
    --help|-h) mj_archive_usage; return 0 ;;
    -*) mj_die "$MJ_EX_USAGE" "archive: unknown option $1" ;;
    *) [ -z "$profile" ] || mj_die "$MJ_EX_USAGE" "archive: one profile only"
       case "$1" in *[!A-Za-z0-9._-]*|""|.*) mj_die "$MJ_EX_USAGE" "archive: '$1' is not a profile name" ;; esac
       profile="$1"; shift ;;
  esac; done

  mj_require_installed
  mj_archive_load
  [ "$list" = 1 ] && { mj_archive_list; return 0; }
  [ -n "$profile" ] || profile="$(mj_yget "$MJ_ARCHIVE_FLAT" default)"
  [ -n "$profile" ] || mj_die "$MJ_EX_INTERNAL" "$(mj_rel "$MJ_ARCHIVE_SHIPPED") declares no default profile"
  mj_archive_run "$profile" "$out" "$format" "$dry" "$force"
}

# ---------------------------------------------------------------- the declarations
# The shipped profiles, then the repository's own overlay. A profile the repository
# declares under the same id replaces the shipped one whole: a half-overridden profile
# would be a third thing neither file describes.
mj_archive_load() {
  [ -f "$MJ_ARCHIVE_SHIPPED" ] \
    || mj_die "$MJ_EX_INTERNAL" "no archive registry at $MJ_ARCHIVE_SHIPPED"
  MJ_ARCHIVE_FLAT="$(mktemp "${TMPDIR:-/tmp}/mj.arc.XXXXXX")"
  mj_yaml_flatten "$MJ_ARCHIVE_SHIPPED" > "$MJ_ARCHIVE_FLAT" 2>/dev/null \
    || mj_die "$MJ_EX_INTERNAL" "$(mj_rel "$MJ_ARCHIVE_SHIPPED") does not parse"
  MJ_ARCHIVE_LOCAL_FLAT=""
  local overlay="$MJ_AI_REPO_DIR/archive.yaml"
  if [ -f "$overlay" ]; then
    MJ_ARCHIVE_LOCAL_FLAT="$(mktemp "${TMPDIR:-/tmp}/mj.arcl.XXXXXX")"
    mj_yaml_flatten "$overlay" > "$MJ_ARCHIVE_LOCAL_FLAT" 2>/dev/null \
      || mj_die "$MJ_EX_CONTRACT" "$(mj_rel "$overlay") does not parse"
  fi
}

mj_archive_cleanup() { rm -f "${MJ_ARCHIVE_FLAT:-}" "${MJ_ARCHIVE_LOCAL_FLAT:-}"; }

# the flat file and index of a profile, as "<flat> <index>"; empty when no such profile
mj_archive_find() { # id
  local f i
  for f in "$MJ_ARCHIVE_LOCAL_FLAT" "$MJ_ARCHIVE_FLAT"; do
    [ -n "$f" ] || continue
    i=0
    while :; do
      case "$(mj_yget "$f" "profiles.$i.id")" in
        "") break ;;
        "$1") printf '%s %s\n' "$f" "$i"; return 0 ;;
      esac
      i=$((i + 1))
    done
  done
  return 1
}

mj_archive_ids() { # every profile id, the overlay's first, each one once
  local f i id seen=""
  for f in "$MJ_ARCHIVE_LOCAL_FLAT" "$MJ_ARCHIVE_FLAT"; do
    [ -n "$f" ] || continue
    i=0
    while id="$(mj_yget "$f" "profiles.$i.id")"; [ -n "$id" ]; do
      case " $seen " in *" $id "*) ;; *) seen="$seen $id"; printf '%s\n' "$id" ;; esac
      i=$((i + 1))
    done
  done
}

mj_archive_list() {
  local id spec flat idx n=0 def
  def="$(mj_yget "$MJ_ARCHIVE_FLAT" default)"
  [ "$MJ_JSON" = 1 ] && printf '{"schema":1,"default":"%s","profiles":[' "$(mj_json_esc "$def")"
  for id in $(mj_archive_ids); do
    spec="$(mj_archive_find "$id")" || continue
    flat="${spec%% *}"; idx="${spec##* }"
    if [ "$MJ_JSON" = 1 ]; then
      [ "$n" = 0 ] || printf ','
      printf '{"id":"%s","title":"%s","summary":"%s","format":"%s","source":"%s"}' \
        "$(mj_json_esc "$id")" \
        "$(mj_json_esc "$(mj_yget "$flat" "profiles.$idx.title")")" \
        "$(mj_json_esc "$(mj_yget "$flat" "profiles.$idx.summary")")" \
        "$(mj_json_esc "$(mj_yget "$flat" "profiles.$idx.format")")" \
        "$(mj_json_esc "$(mj_yget "$flat" "profiles.$idx.source")")"
    else
      printf '%-12s %s%s\n             %s\n' "$id" \
        "$(mj_yget "$flat" "profiles.$idx.title")" \
        "$([ "$id" = "$def" ] && printf '  (default)')" \
        "$(mj_yget "$flat" "profiles.$idx.summary")"
    fi
    n=$((n + 1))
  done
  if [ "$MJ_JSON" = 1 ]; then printf '],"count":%s}\n' "$n"
  else printf 'archive: %s profile(s)\n' "$n"; fi
  mj_archive_cleanup
}

# ---------------------------------------------------------------- selection
# The index, then three subtractions and one re-inclusion, in that order. Every decision
# is recorded against the path so that --dry-run can say why a file is not there: a
# selection that can only be believed is not evidence.
mj_archive_select() { # flat idx outfile -> "<mode>\t<path>" lines; sets MJ_ARCHIVE_DROPPED
  local flat="$1" idx="$2" out="$3"
  local derived binary artifacts tmpd

  derived="$(mj_yget "$flat" "profiles.$idx.derived")"
  binary="$(mj_yget "$flat" "profiles.$idx.binary")"
  artifacts="$(mj_yget "$flat" "profiles.$idx.artifacts")"

  # Named here rather than copied into the exported name at the end of the function: the
  # scan that proves no recursive delete leaves a temporary directory trusts a name only
  # when every assignment to it comes from `mktemp` (test/cases/08).
  MJ_ARCHIVE_TMPD="$(mktemp -d "${TMPDIR:-/tmp}/mj.arcsel.XXXXXX")"
  tmpd="$MJ_ARCHIVE_TMPD"
  mj_git ls-files -s > "$tmpd/index" || mj_die "$MJ_EX_INTERNAL" "git ls-files failed"
  # "<mode> <sha> <stage>\t<path>" -> "<mode>\t<path>", gitlinks (160000) dropped: a
  # submodule's content is not in this index and cannot be archived from it
  awk -F"\t" '{ split($1, a, " "); if (a[1] != "160000") printf "%s\t%s\n", a[1], $2 }' \
    "$tmpd/index" > "$tmpd/all"
  MJ_ARCHIVE_TRACKED="$(mj_lines "$tmpd/all")"
  [ "$MJ_ARCHIVE_TRACKED" -gt 0 ] \
    || mj_die "$MJ_EX_CONTRACT" "the git index lists no file; there is nothing to archive"

  # derived: .gitattributes is the declaration, read through git itself
  : > "$tmpd/derived"
  if [ "$derived" = drop ]; then
    cut -f2 "$tmpd/all" | mj_git check-attr --stdin merge 2>/dev/null \
      | sed -n 's/: merge: derived$//p' > "$tmpd/derived" || true
  fi

  # binary: by extension, from the registry's one list, and by content as git itself
  # judges the index's blob (`i/-text`: a NUL byte or a lone CR), so a binary with a text
  # name or none is not archived as text
  : > "$tmpd/binext"
  : > "$tmpd/bincontent"
  if [ "$binary" = drop ]; then
    mj_ylist "$MJ_ARCHIVE_FLAT" binary_extensions > "$tmpd/binext"
    mj_git ls-files --eol 2>/dev/null | awk -F"\t" '$1 ~ /^i\/-text / { print $2 }' > "$tmpd/bincontent" || true
  fi

  # artifacts: build output, caches, worktree containers and links, from the registry's one
  # list; never re-admitted by an include, as `majordomus pack` refuses it too
  : > "$tmpd/artifact"
  [ "$artifacts" = drop ] && mj_ylist "$MJ_ARCHIVE_FLAT" artifact_paths > "$tmpd/artifact"

  mj_ylist "$flat" "profiles.$idx.exclude" > "$tmpd/exclude"
  mj_ylist "$flat" "profiles.$idx.include" > "$tmpd/include"

  MJ_ARCHIVE_DROPPED="$tmpd/dropped"
  # One pass for every decision. The shape this replaced ran two greps per tracked file
  # and took forty seconds on this repository, which is long enough that a person stops
  # running it — and a check nobody runs is not a check.
  awk -F"\t" -v D="$tmpd/derived" -v B="$tmpd/binext" \
             -v E="$tmpd/exclude" -v I="$tmpd/include" \
             -v A="$tmpd/artifact" -v ART="$artifacts" -v BC="$tmpd/bincontent" \
             -v KEEP="$out" -v DROP="$tmpd/dropped" '
    # a shell glob as an anchored regular expression: * and ? only, everything else
    # literal. * matches / as well, so "apps/**" and "apps/*" select the same subtree.
    function globre(p,   r, i, c) {
      r = ""
      for (i = 1; i <= length(p); i++) {
        c = substr(p, i, 1)
        if (c == "*") r = r ".*"
        else if (c == "?") r = r "."
        else if (index(".^$+()[]{}|\\/-", c)) r = r "\\" c
        else r = r c
      }
      return "^" r "$"
    }
    FILENAME == D { der[$0] = 1; next }
    FILENAME == B { bex[$0] = 1; nbex++; next }
    FILENAME == E { exc[++ne] = globre($0); next }
    FILENAME == I { inc[++ni] = globre($0); next }
    FILENAME == A { art[++na] = globre($0); next }
    FILENAME == BC { bcon[$0] = 1; next }
    {
      mode = $1; path = $2; drop = ""; hard = ""
      if (ART == "drop" && mode == "120000") hard = "link"
      if (hard == "") for (i = 1; i <= na; i++) if (path ~ art[i]) { hard = "artifact"; break }
      if (hard != "") { printf "%s\t%s\n", hard, path >> DROP; next }
      if (path in der) drop = "derived"
      if (drop == "" && nbex && match(path, /\.[^.\/]+$/)) {
        if (substr(path, RSTART + 1) in bex) drop = "binary"
      }
      if (drop == "" && (path in bcon)) drop = "binary"
      if (drop == "") {
        for (i = 1; i <= ne; i++) if (path ~ exc[i]) { drop = "excluded"; break }
      }
      if (drop != "") {
        for (i = 1; i <= ni; i++) if (path ~ inc[i]) { drop = ""; break }
      }
      if (drop != "") printf "%s\t%s\n", drop, path >> DROP
      else printf "%s\t%s\n", mode, path >> KEEP
    }' "$tmpd/derived" "$tmpd/binext" "$tmpd/exclude" "$tmpd/include" "$tmpd/artifact" "$tmpd/bincontent" "$tmpd/all"
  # awk creates neither output file when it writes no line to it
  [ -f "$out" ] || : > "$out"
  [ -f "$MJ_ARCHIVE_DROPPED" ] || : > "$MJ_ARCHIVE_DROPPED"
}

# ---------------------------------------------------------------- the run
mj_archive_run() { # profile out format dry force
  local profile="$1" out="$2" format="$3" dry="$4" force="$5"
  local spec flat idx ext stage sel n bytes missing=0 rc=0

  spec="$(mj_archive_find "$profile")" || {
    mj_archive_cleanup
    mj_die "$MJ_EX_MISSING" "archive: no profile '$profile' (run: majordomus archive --list)"; }
  flat="${spec%% *}"; idx="${spec##* }"

  [ -n "$format" ] || format="$(mj_yget "$flat" "profiles.$idx.format")"
  case "$format" in
    zip) ext=zip ;;
    tar.gz) ext=tar.gz ;;
    *) mj_archive_cleanup; mj_die "$MJ_EX_USAGE" "archive: format '$format' is not zip or tar.gz" ;;
  esac

  sel="$(mktemp "${TMPDIR:-/tmp}/mj.arcs.XXXXXX")"
  mj_archive_select "$flat" "$idx" "$sel"
  n="$(mj_lines "$sel")"
  [ "$n" -gt 0 ] || {
    rm -f "$sel"; mj_archive_cleanup
    mj_die "$MJ_EX_CONTRACT" "profile '$profile' selects none of $MJ_ARCHIVE_TRACKED tracked file(s)"; }
  # `|| true`: a path in the index that the working tree no longer has makes `wc` exit
  # non-zero, and the size of the content is a thing to report, never a thing to fail on.
  # The missing paths are counted and reported separately, where they mean something.
  bytes="$( { cut -f2 "$sel" | (cd "$MJ_ROOT" && tr '\n' '\0' | xargs -0 wc -c 2>/dev/null) || true; } \
    | awk '$2 != "total" { s += $1 } END { printf "%d", s + 0 }')"

  if [ "$dry" = 1 ]; then
    mj_archive_report "$profile" "$flat" "$idx" "$n" "$bytes" "" "$ext"
    rm -f "$sel"; rm -rf "$MJ_ARCHIVE_TMPD"; mj_archive_cleanup
    return 0
  fi

  if [ -z "$out" ]; then
    out="$MJ_ROOT/tmp/archives/$(mj_archive_repo_name)-$profile-$(date -u +%Y%m%d).$ext"
  fi
  case "$out" in /*) ;; *) out="$PWD/$out" ;; esac
  if [ -e "$out" ] && [ "$force" != 1 ]; then
    rm -f "$sel"; rm -rf "$MJ_ARCHIVE_TMPD"; mj_archive_cleanup
    mj_die "$MJ_EX_REFUSED" "archive: $out exists (--force to replace it)"
  fi
  mkdir -p "$(dirname "$out")" || mj_die "$MJ_EX_INTERNAL" "cannot create $(dirname "$out")"

  stage="$MJ_ARCHIVE_TMPD/stage/$(mj_archive_repo_name)"
  mkdir -p "$stage"
  # copied through tar so that one process reads the list; a path the index names and the
  # working tree does not have is counted and reported, never silently absent
  local mode path
  : > "$MJ_ARCHIVE_TMPD/copy"
  while IFS="$MJ_TAB" read -r mode path; do
    if [ -f "$MJ_ROOT/$path" ]; then printf '%s\n' "$path" >> "$MJ_ARCHIVE_TMPD/copy"
    else missing=$((missing + 1)); fi
  done < "$sel"
  ( cd "$MJ_ROOT" && tar cf - -T "$MJ_ARCHIVE_TMPD/copy" ) | ( cd "$stage" && tar xf - ) \
    || mj_die "$MJ_EX_INTERNAL" "could not copy the selected files"
  # the index is the authority on the mode, not the working tree. Two batched calls, not
  # one process per file: this runs over every tracked path.
  awk -F"\t" '$1 == "100755" { print $2 }' "$sel" > "$MJ_ARCHIVE_TMPD/x"
  awk -F"\t" '$1 != "100755" { print $2 }' "$sel" > "$MJ_ARCHIVE_TMPD/r"
  ( cd "$stage" && tr '\n' '\0' < "$MJ_ARCHIVE_TMPD/x" | xargs -0 chmod 755 2>/dev/null ) || true
  ( cd "$stage" && tr '\n' '\0' < "$MJ_ARCHIVE_TMPD/r" | xargs -0 chmod 644 2>/dev/null ) || true

  mj_archive_furnish "$stage" "$profile" "$flat" "$idx" "$sel" "$n" "$missing"
  mj_archive_pack "$stage" "$out" "$ext" || rc=$?
  [ "$rc" = 0 ] || { rm -f "$sel"; rm -rf "$MJ_ARCHIVE_TMPD"; mj_archive_cleanup; return "$rc"; }
  mj_archive_verify "$out" "$ext" "$stage" || rc=$?

  mj_archive_report "$profile" "$flat" "$idx" "$n" "$bytes" "$out" "$ext"
  [ "$missing" = 0 ] || mj_warn archive "$profile" \
    "$missing tracked path(s) are in the index and not in the working tree; they are not in the archive" \
    "git status --porcelain"
  rm -f "$sel"; rm -rf "$MJ_ARCHIVE_TMPD"; mj_archive_cleanup
  return "$rc"
}

# ---------------------------------------------------------------- what travels with it
# Three files the reader at the far end needs and cannot reconstruct: where the tree came
# from, what is in it with what mode, and how to make it runnable again.
mj_archive_furnish() { # stage profile flat idx sel n missing
  local stage="$1" profile="$2" flat="$3" idx="$4" sel="$5" n="$6" missing="$7"
  local a="$stage/_ARCHIVE" i line
  mkdir -p "$a"

  { printf 'repository: %s\n' "$(basename "$MJ_ROOT")"
    printf 'commit:     %s\n' "$(mj_git rev-parse HEAD 2>/dev/null || echo unknown)"
    printf 'branch:     %s\n' "$(mj_git_branch)"
    printf 'tree:       %s\n' "$(mj_git_dirty)"
    printf 'remote:     %s\n' "$(mj_url_public "$(mj_git remote get-url origin 2>/dev/null || echo none)")"
    printf 'profile:    %s\n' "$profile"
    printf 'taken:      %s\n' "$(mj_now)"
    printf 'tool:       majordomus %s\n' "$MJ_VERSION"
    printf '\n'
    printf 'tracked in the repository: %s\n' "$MJ_ARCHIVE_TRACKED"
    printf 'in this archive:           %s\n' "$n"
    printf 'left out:                  %s\n' "$(mj_lines "$MJ_ARCHIVE_DROPPED")"
    printf 'named but not on disk:     %s\n' "$missing"
    printf '\n=== the last 80 commits ===\n'
    mj_git log --oneline -n 80 2>/dev/null || true
    printf '\n=== tags, newest first ===\n'
    mj_git tag --sort=-creatordate 2>/dev/null | head -40 || true
  } > "$a/GIT.txt"

  { printf '# %s — %s\n\n' "$(mj_archive_repo_name)" "$(mj_yget "$flat" "profiles.$idx.title")"
    printf '%s\n\n' "$(mj_yget "$flat" "profiles.$idx.summary")"
    i=0
    while line="$(mj_yget "$flat" "profiles.$idx.orientation.$i")"; [ -n "$line" ]; do
      printf '%s\n\n' "$line"; i=$((i + 1))
    done
    printf '## What is here\n\n'
    printf '| | |\n|---|---|\n'
    printf '| Profile | `%s` — %s |\n' "$profile" "$(mj_yget "$flat" "profiles.$idx.note")"
    printf '| Commit | `%s` on `%s` |\n' "$(mj_git rev-parse HEAD 2>/dev/null || echo unknown)" "$(mj_git_branch)"
    printf '| Files | %s of %s tracked |\n' "$n" "$MJ_ARCHIVE_TRACKED"
    printf '| Generated projections | %s |\n' \
      "$( [ "$(mj_yget "$flat" "profiles.$idx.derived")" = drop ] && printf 'left out — they are functions of files that are here' || printf 'kept' )"
    printf '| Binary files | %s |\n' \
      "$( [ "$(mj_yget "$flat" "profiles.$idx.binary")" = drop ] && printf 'left out' || printf 'kept' )"
    printf '\n`_ARCHIVE/GIT.txt` is where this came from, `_ARCHIVE/MANIFEST.txt` every file with its mode and size'
    [ "$(mj_yget "$flat" "profiles.$idx.restore")" = true ] \
      && printf ', and `_ARCHIVE/restore.sh` puts the modes back and creates a local git index'
    printf '.\n\n'
    printf '## What was left out, and why\n\n'
    if [ -s "$MJ_ARCHIVE_DROPPED" ]; then
      printf '| Reason | Files |\n|---|---|\n'
      cut -f1 "$MJ_ARCHIVE_DROPPED" | LC_ALL=C sort | uniq -c \
        | awk '{ printf "| %s | %s |\n", $2, $1 }'
      printf '\n`derived` means the path is marked `merge=derived` in `.gitattributes`'
      printf ' — a projection of a canonical file that is in this archive.\n\n'
    else
      printf 'Nothing tracked was left out.\n\n'
    fi
    printf 'Untracked files are never archived, so the build output, the caches and'
    printf ' `.ai/local/` — this checkout\047s own state — are absent by construction rather than by exclusion.\n'
  } > "$a/README.md"

  if [ "$(mj_yget "$flat" "profiles.$idx.restore")" = true ]; then
    mj_archive_restore_script > "$a/restore.sh"
    chmod 755 "$a/restore.sh"
  fi

  # Last, so that it lists every other file this function wrote — the script that reads
  # it included, whose mode is the one that matters most. It cannot list itself.
  #
  # One find, one wc, one awk: a `wc -c` per file would be another two thousand
  # processes for a file nobody reads twice.
  local t="$MJ_ARCHIVE_TMPD"
  ( cd "$stage" && find . -type f | sed 's|^\./||' | LC_ALL=C sort ) > "$t/mf.files"
  ( cd "$stage" && find . -type f -perm -u+x | sed 's|^\./||' | LC_ALL=C sort ) > "$t/mf.exec"
  ( cd "$stage" && tr '\n' '\0' < "$t/mf.files" | xargs -0 wc -c ) > "$t/mf.sizes"
  awk -v X="$t/mf.exec" -v S="$t/mf.sizes" '
    FILENAME == X { x[$0] = 1; next }
    { size = $1; sub(/^[ \t]*[0-9]+[ \t]+/, ""); if ($0 == "total") next
      printf "%s %9d  %s\n", ($0 in x ? "100755" : "100644"), size, $0 }' \
    "$t/mf.exec" "$t/mf.sizes" | LC_ALL=C sort -k3 > "$a/MANIFEST.txt"
}

# POSIX sh: it runs wherever the archive is opened, which is not this machine
mj_archive_restore_script() {
  cat <<'RESTORE'
#!/bin/sh
# restore.sh — make this unpacked archive behave like a checkout again.
#
# Two things are lost between `zip` and the far end. The file modes go when the reader
# discards the extra fields that carry them, and every script in the tree stops being
# executable. The git index was never there at all, and the repository's own checks read
# it — `git ls-files` is how this project enumerates itself, so without an index they do
# not fail, they examine nothing and report that nothing is wrong.
#
# This restores both, from _ARCHIVE/MANIFEST.txt, which recorded the mode of every entry
# as the original git index held it. Run it once, from the directory it sits in:
#
#     sh _ARCHIVE/restore.sh
#
# It creates one commit with no history and no remote. Anything that reads more than the
# working tree will therefore say so rather than being quietly wrong.
set -eu

here="$(cd "$(dirname "$0")" && pwd)"
root="$(cd "$here/.." && pwd)"
man="$here/MANIFEST.txt"

[ -f "$man" ] || { echo "restore: no MANIFEST.txt beside this script" >&2; exit 1; }
cd "$root"

modes=0
missing=0
while read -r mode size path; do
  [ -n "${path:-}" ] || continue
  if [ ! -f "$path" ]; then missing=$((missing + 1)); continue; fi
  case "$mode" in
    100755) chmod 755 "$path" ;;
    *)      chmod 644 "$path" ;;
  esac
  modes=$((modes + 1))
done < "$man"

echo "restore: modes applied to $modes file(s) from $(basename "$man")"
[ "$missing" = 0 ] || echo "restore: $missing file(s) in the manifest are not in this tree"

if ! command -v git >/dev/null 2>&1; then
  echo "restore: git is not on PATH; the modes are restored, the index is not" >&2
  exit 0
fi
if [ -d .git ]; then
  echo "restore: .git already exists here; leaving it alone"
  exit 0
fi

git init -q .
git add -A
git -c user.email=restore@localhost -c user.name=restore \
    commit -q -m "the archived tree, restored" --no-verify
n="$(git ls-files | wc -l | tr -d ' ')"
echo "restore: a git index of $n file(s), one commit, no history and no remote"
echo "restore: the original commit and branch are in _ARCHIVE/GIT.txt"
RESTORE
}

# ---------------------------------------------------------------- container and proof
mj_archive_pack() { # stage out ext
  local stage="$1" out="$2" ext="$3" parent base
  parent="$(dirname "$stage")"; base="$(basename "$stage")"
  case "$ext" in
    zip)
      mj_has zip || { mj_fail archive container "zip is not on PATH" "majordomus archive --format tar.gz"; return "$MJ_EX_MISSING"; }
      # deliberately not -X: the extra fields it strips are the Unix modes, and an
      # archive whose scripts are not executable fails for reasons of its own
      ( cd "$parent" && zip -q -r -y "$out" "$base" ) \
        || { mj_fail archive container "zip failed writing $out" "cd $parent && zip -r out.zip $base"; return "$MJ_EX_INTERNAL"; } ;;
    tar.gz)
      ( cd "$parent" && tar czf "$out" "$base" ) \
        || { mj_fail archive container "tar failed writing $out" "cd $parent && tar czf out.tar.gz $base"; return "$MJ_EX_INTERNAL"; } ;;
  esac
  return 0
}

# An archive is not written until it has been read back. The count on disk and the count
# in the container must agree, and the mode of a known executable must have survived the
# round trip — that is the failure this whole file exists to answer, so it is proved here
# rather than assumed.
mj_archive_verify() { # out ext stage
  local out="$1" ext="$2" stage="$3" want got probe mode
  want="$(cd "$stage" && find . -type f | wc -l | tr -d ' ')"
  case "$ext" in
    zip)
      got="$(unzip -Z1 "$out" 2>/dev/null | grep -cv '/$' || true)"
      probe="$(unzip -Z "$out" 2>/dev/null | awk '$1 ~ /^-rwx/ { n++ } END { printf "%d", n + 0 }')" ;;
    tar.gz)
      got="$(tar tzf "$out" 2>/dev/null | grep -cv '/$' || true)"
      probe="$(tar tzvf "$out" 2>/dev/null | awk '$1 ~ /^-rwx/ { n++ } END { printf "%d", n + 0 }')" ;;
  esac
  [ -n "$got" ] && [ "$got" -gt 0 ] || {
    mj_fail archive "$(basename "$out")" "the container reads back as empty; $want file(s) were staged" \
      "unzip -l $out"; return "$MJ_EX_INTERNAL"; }
  [ "$got" = "$want" ] || {
    mj_fail archive "$(basename "$out")" "$want file(s) staged, $got in the container" \
      "unzip -l $out"; return "$MJ_EX_INTERNAL"; }
  mode="$(cd "$stage" && find . -type f -perm -u+x | wc -l | tr -d ' ')"
  if [ "$mode" -gt 0 ] && [ "$probe" = 0 ]; then
    mj_fail archive "$(basename "$out")" \
      "$mode executable file(s) went in and none came back out: the container discarded the modes" \
      "unzip -Z $out | head"
    return "$MJ_EX_INTERNAL"
  fi
  mj_ok archive "$(basename "$out")" "$got entr(ies) read back, $probe of them executable"
  return 0
}

# ---------------------------------------------------------------- saying what happened
mj_archive_report() { # profile flat idx n bytes out ext
  local profile="$1" flat="$2" idx="$3" n="$4" bytes="$5" out="$6" ext="$7"
  local dropped size=0
  dropped="$(mj_lines "$MJ_ARCHIVE_DROPPED")"
  [ -n "$out" ] && [ -f "$out" ] && size="$(wc -c < "$out" | tr -d ' ')"
  if [ "$MJ_JSON" = 1 ]; then
    printf '{"schema":1,"profile":"%s","format":"%s","tracked":%s,"archived":%s,"left_out":%s,"content_bytes":%s,"path":"%s","bytes":%s,"reasons":{' \
      "$(mj_json_esc "$profile")" "$(mj_json_esc "$ext")" "$MJ_ARCHIVE_TRACKED" "$n" "$dropped" "$bytes" \
      "$(mj_json_esc "$([ -n "$out" ] && mj_rel "$out")")" "$size"
    cut -f1 "$MJ_ARCHIVE_DROPPED" | LC_ALL=C sort | uniq -c \
      | awk '{ printf "%s\"%s\":%s", (NR > 1 ? "," : ""), $2, $1 }'
    printf '}}\n'
    return 0
  fi
  printf 'archive: profile %s — %s of %s tracked file(s), %s KB of content\n' \
    "$profile" "$n" "$MJ_ARCHIVE_TRACKED" "$((bytes / 1024))"
  if [ "$dropped" -gt 0 ]; then
    cut -f1 "$MJ_ARCHIVE_DROPPED" | LC_ALL=C sort | uniq -c \
      | awk '{ printf "         left out: %s (%s)\n", $1, $2 }'
  fi
  if [ -n "$out" ]; then
    printf '         wrote %s (%s KB)\n' "$(mj_rel "$out")" "$((size / 1024))"
  else
    printf '         nothing written (--dry-run)\n'
  fi
}