Skip to content

feat(install): upgrade an install without clobbering what the project changed - #4

Merged
ruslan-korneev merged 1 commit into
masterfrom
install-upgrade
Jul 27, 2026
Merged

feat(install): upgrade an install without clobbering what the project changed#4
ruslan-korneev merged 1 commit into
masterfrom
install-upgrade

Conversation

@ruslan-korneev

Copy link
Copy Markdown
Owner

Stacked on #3. Closes RUS-192.

The problem

The core is copied into each project byte-for-byte, so a change here reached them only through a manual
--force per project. And --force cannot tell a stale copy from one the project edited, so the choice
was between overwriting local fixes and never updating at all.

With one project that is invisible. At five it means five versions of the loop running under one name,
and a harness-improver proposal applied here reaching none of them.

The change

install.sh upgrade <target> [--dry-run] [--force]
install.sh upgrade --all [--dry-run]
install.sh installs

upgrade re-applies the shape the project was installed with — stack, tools, skills, agents, scripts,
hooks — read back from .ai-tooling.json. Changing that shape stays a re-install, deliberately.

What makes it safe is a hash per installed file, recorded in the manifest at write time. Each
destination is then one of four things:

current identical to canonical — nothing to do
stale differs from canonical, matches the recorded hash → nobody here touched it → replaced
modified differs from both → this project edited it → reported and kept
unknown no recorded hash (pre-2.3.0 manifest, or a file install skipped) → treated as modified

Exit is non-zero while any modified file is outstanding — an upgrade that left files behind is not fully
applied. --force overwrites them and exits 0.

The bug this nearly shipped with

A kept file has to carry its old hash into the new manifest. Re-hashing a file we deliberately did
not write records the local edit as ours, and the next upgrade reads it as merely stale and overwrites
it — losing the change the previous run protected.

Found by running upgrade three times against an edited file and watching the classification flip from
modified to stale on the second. The test now asserts the edit survives all three runs.

Also

  • A registry (~/.config/ai-tooling/installs, ADW_REGISTRY to relocate) is appended at install time so
    --all has something to iterate. A path that has gone away is reported and skipped, never removed
    behind the operator's back.
  • doctor reports installed vs canonical version and says BEHIND with the command to fix it.
  • file_hash degrades shasumsha256sumopenssl; with none of them every differing file is
    reported as modified rather than guessed at.
  • The manifest is written by python3 now — a hash map is not worth hand-rolling JSON for.
  • VERSION 2.2.0 → 2.3.0, and the discipline with it: the version moves with every core change, because
    that is what lets doctor and installs tell which projects are behind.

How to verify

bash tests/run.sh install-upgrade    # 27 assertions
bash tests/run.sh                    # 143 passed, 0 failed, 1 known gap
ADW_TEST_SHELL=/bin/bash bash tests/run.sh

The case drives a real install into a throwaway repo from a throwaway copy of this checkout, then moves
that copy ahead — no mocking of the thing under test. By hand:

export ADW_REGISTRY=/tmp/adw-installs
bash install.sh install /tmp/proj --tools claude --no-hooks
printf '\n# local fix\n' >> /tmp/proj/scripts/ai/guard.sh
bash install.sh upgrade /tmp/proj --dry-run    # MODIFIED guard.sh (kept)

Not verified

  • --all has only ever run over a one-entry registry. The loop is trivial, but "trivial" is what the
    rest of this PR is about.
  • No project has been upgraded across a real version gap yet — the canonical copy in the test is moved
    ahead artificially, one file at a time.
  • Pre-2.3.0 manifests have no files map, so the first upgrade of an existing install classifies
    everything as unknown → modified → kept, and reports a long list. That is the safe direction,
    but it means the first upgrade of a real project will need --force after you have read the list.
    Worth knowing before running it on something you care about.
  • doctor, uninstall and hook registration remain untested, as the runner's footer now says.

… changed

The core is copied into each project byte-for-byte, so a change here reached them only through a manual
`--force` per project — and `--force` cannot tell a stale copy from one the project edited, so the
choice was between overwriting local fixes and never updating at all. With one project that is
invisible. At five it means five versions of the loop running under one name, and a harness-improver
proposal applied here reaching none of them.

    install.sh upgrade <target> [--dry-run] [--force]
    install.sh upgrade --all [--dry-run]
    install.sh installs

upgrade re-applies the shape the project was installed with — stack, tools, skills, agents, scripts,
hooks — read back from .ai-tooling.json. Changing that shape stays a re-install, deliberately.

The distinction that makes it safe is a hash per installed file, recorded in the manifest at write
time. Each destination is then one of four things:

  current   identical to canonical — nothing to do
  stale     differs from canonical, matches the recorded hash → nobody here touched it → replaced
  modified  differs from both → this project edited it → reported and KEPT
  unknown   no recorded hash (pre-2.3.0 manifest, or a file install skipped) → treated as modified

A kept file carries its OLD hash into the new manifest. Re-hashing a file we deliberately did not write
would record the local edit as ours, and the next upgrade would read it as merely stale and overwrite
it — losing the change the previous run protected. Caught by running upgrade three times in a row
against an edited file; the test asserts the edit survives all three.

Exit is non-zero while any modified file is outstanding: an upgrade that left files behind is not fully
applied. --force overwrites them and exits 0.

- A registry (~/.config/ai-tooling/installs, ADW_REGISTRY to relocate) is appended at install time so
  `--all` has something to iterate. A path that has gone away is reported and skipped, never removed
  behind the operator's back.
- The manifest gains files{} plus the agents/scripts/rules flags, and is written by python3 rather than
  printf — a hash map is not worth hand-rolling JSON for.
- doctor reports installed vs canonical version and says BEHIND with the command to fix it.
- file_hash degrades across shasum → sha256sum → openssl; a machine with none of them reports every
  differing file as modified rather than guessing.

VERSION 2.2.0 → 2.3.0, and the discipline that goes with it: the version moves with every core change,
because that is what lets doctor and installs tell which projects are behind.

Tests: 27 new assertions in tests/cases/install-upgrade.sh, driving a real install into a throwaway
repo from a throwaway copy of this checkout, then moving that copy ahead. Covers the manifest shape,
the registry, the four classifications, the dry run writing nothing, the three-upgrade regression
above, --force, and four misuse paths. ADW_REGISTRY keeps the operator's real registry out of it.

Also widens the portability rule: comments and double-quoted strings are stripped before scanning for
GNU-only tools, and a line guarded by `command -v` is compliant — `file_hash` names sha256sum in both
a guard and a warning string, and neither is a portability defect. The blind spot this accepts (a call
hidden inside a quoted command string) is written down in the test.

Suite: 143 passed, 0 failed, 1 known gap, under bash 5.2 and 3.2.

Refs RUS-192
@ruslan-korneev
ruslan-korneev changed the base branch from profile-honoured to master July 27, 2026 17:48
@ruslan-korneev
ruslan-korneev merged commit 0bc3421 into master Jul 27, 2026
2 checks passed
@ruslan-korneev
ruslan-korneev deleted the install-upgrade branch July 27, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant