You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(integrations): roll back a built-in upgrade or init that fails before its manifest commit - #5402
Prerequisite for #5117, but the bug is independent of it: it affects every built-in integration.
With only built-in integrations installed, specify integration upgrade and specify init --here (or init <existing-dir> --force) run with no journal: external_lifecycle calls the handler directly when there are no external records. Both write generated files, in place where they already exist, before they save the new manifest. So a failure in between (in setup(), the upgrade's shared-infrastructure refresh, or the manifest or integration.json write) leaves files no manifest describes. Later upgrades, option changes and uninstall then treat them as the user's files.
This PR journals both operations until their manifest and integration state are saved, and restores the project if anything in that window fails.
It reuses the external transaction's file journal. The journalling part of _transaction is factored out as _journalled(), so the external path behaves as before.
builtin_journal() covers .specify and the integration's own output (its config folder plus IntegrationBase.upgrade_output_folders(), for a generic--commands-dir). It takes no project lock, does not touch the trust store, and reads no other integration's manifest or state.
Upgrade journals from its first shared-infrastructure write to its state write. Init journals from setup() to its integration.json write, and only when the target directory already existed; its later shared-infrastructure install and optional steps (workflow, presets, extensions) run after that commit, as before. A failed init into a new directory still removes that directory, as before.
If the journal cannot cover the operation, it runs unjournalled, as it does today, rather than failing. That covers an output-folder hook that raises, a symlinked or aliased project root, a symlinked or unsafe scope or manifest entry, a symlink inside a scope, and an unreadable directory.
Inside an external transaction it does nothing, because that journal already covers the operation.
Extension and preset re-registration still runs after the commit, as best effort, so the ordering documented in _register_extensions_for_agent still holds.
Behavior changes for built-in-only upgrades and inits into an existing directory:
A successful run now takes a metadata census of .specify and the integration's folder before it writes.
The journal's snapshot limits (128 MiB, 4,096 entries) now apply to that window.
init --force now re-registers extensions and presets after writing integration.json instead of before, matching upgrade. Registration reads integration.json for the generic command directory (registration_directory()), so a re-init that changes --commands-dir now registers extension artifacts in the new directory; before, they went to the old one. For other integrations what it registers is unchanged.
Removing the snapshot directory after a successful run ignores errors; it is a temporary directory and must not fail an operation that has committed. This also applies to the external path.
Evidence:
test_builtin_upgrade_rolls_back_when_it_fails_before_committing injects a failure at three points: after setup(), at the manifest save, and at the integration.json write. Each time it compares every file and directory with the state before the upgrade. All three fail on main and pass here.
test_init_here_rolls_back_when_it_fails_before_committing does the same for init --here --force, into both an existing Spec Kit project with edited and deleted skills and a plain directory with only user files, and adds a failure inside the real manifest save (at its temporary-file creation). All eight cases fail on a9a144da and pass here; the plain-directory case of the in-save failure also failed on the previous head, 322dda7f, which left the new .specify/integrations tree behind.
test_init_force_registers_extensions_in_the_new_generic_commands_dir checks that init --force with a changed generic --commands-dir registers extension artifacts in the new directory and not the old one. It fails with the pre-change registration order and passes here.
test_init_here_rolls_back_a_failed_setup_for_every_builtin runs a failing setup() for every built-in integration, into both an existing project (with a user edit to a generated file) and a plain directory, and checks the tree is unchanged. 83 of the 86 cases fail without this change; the other three (none in both layouts, and hermes re-init) pass either way.
test_builtin_journal_removes_directories_a_failed_init_created and test_builtin_journal_removes_a_new_directory_whose_pending_write_never_landed check directly that rollback removes .specify and agent directories it created, including one whose file write had started but not landed (the second fails on 318fb9f8).
test_builtin_upgrade_journal_needs_no_trust_store_or_other_manifests, test_builtin_upgrade_runs_unjournalled_when_a_scope_is_a_symlink and test_init_here_runs_unjournalled_when_a_scope_is_a_symlink pin the absence of new preconditions. The init one also asserts the journal is not entered; by nature it passes without this change too. test_init_into_a_new_directory_removes_it_when_it_fails pins the existing new-directory cleanup.
tests/specify_cli/test_command_init.py points HOME and USERPROFILE at a temporary directory for every test, so the Hermes cases never write to the real ~/.hermes/skills.
Full suite on macOS: 16,398 passed, 520 skipped, 1 failed. The failure is test_non_windows_uv_tool_dry_run_still_previews_installer, where Rich wraps the long worktree path; it fails the same way without this change. On Linux (python:3.12-slim), tests/specify_cli/integrations and tests/specify_cli/test_command_init.py: 1,415 passed. Both runs used a throwaway HOME, which stayed empty. Not run on Windows.
AI Disclosure
I did not use AI assistance for this contribution
I did use AI assistance (fill in the disclosure below)
AI disclosure: Written for @echarrod by Claude Code (claude-opus-5-5) running autonomously: it traced the upgrade and init paths, wrote the code, tests and design note, ran the test suite on macOS and Linux, and ran automated /code-review medium rounds, which led to narrowing an earlier version that wrapped the whole upgrade in the external transaction. The init extension was added in a later round, in response to review feedback on #5117, and a further round in response to review feedback on this PR; each went through /code-review medium. No human has reviewed it yet.
… manifest commit
With only built-in integrations installed, a failed upgrade had no journal:
it rewrites shared infrastructure and generated files in place before the
new manifest is saved, so a failure in between (in setup(), the
shared-infrastructure refresh, or the manifest or state write) left files
the old manifest did not describe, and later upgrades, option changes and
uninstall treated them as the user's.
The upgrade now journals from its first shared-infrastructure write until
its manifest and integration state are saved, reusing the external
transaction's file journal, now factored out as _journalled(). It covers
.specify and the upgraded integration's output, takes no project lock and
reads no other integration's state. When the journal cannot cover the
upgrade (a symlinked or unsafe scope, an unreadable directory) the upgrade
runs unjournalled as before rather than failing. It does nothing inside an
external transaction, whose journal already covers the operation.
Re-registration still runs after the commit, as best effort.
Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015w5WioVbap6YfztcWixm6g
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
The snapshot cleanup callback can change permissions on live symlink targets or the system temporary directory, creating a critical filesystem-safety risk.
… and modes
- Snapshot cleanup never chmods through a snapshotted symlink or outside the
backup tree (its parent is the system temporary directory), retries only
removals, and makes a second pass for directories rmtree could not open.
- A project root reached through a symlink or junction runs the upgrade
unjournalled, since manifests record resolved paths.
- Untracked merge targets from stale_cleanup_exclusions() (Copilot's
.vscode/settings.json) pass the same eligibility check as manifest files.
- A new IntegrationBase.upgrade_output_folders() hook lets an integration
name output directories its config cannot. The generic integration returns
a new, normalised --commands-dir, so rollback removes the directories it
created.
- ensure_executable_scripts() journals its chmod, so rollback restores the
old mode of a script no other pass rewrote, and a journal refusal fails
the operation instead of becoming a per-script warning.
Tests cover each case and fail without the fix; the rollback snapshot now
compares file modes.
Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015w5WiphdATEh3YkiqF4xSR
Disclosure: posted on behalf of @echarrod by Claude Code (model: claude-opus-5-5), running autonomously. The agent wrote the code, the tests, and this comment; it was not reviewed line by line by a human before pushing.
Changes:
Snapshot cleanup no longer changes permissions through a snapshotted symlink or outside the backup tree, so neither a live link target nor the system temporary directory is touched. Only removal callbacks are retried, so an os.open failure no longer raises TypeError, and a second pass removes snapshot directories that could not be opened the first time.
A project root whose absolute path differs from its resolved path (a symlink, junction or alias) now runs the upgrade unjournalled, as it did before this PR. I chose this over resolving the root inside the journal, because writers that still pass the alias path would then hit "escapes trusted scopes" instead.
stale_cleanup_exclusions() paths (Copilot's .vscode/settings.json) now go through the same eligibility check as manifest files, so a symlinked merge target takes the unjournalled fallback instead of aborting.
New IntegrationBase.upgrade_output_folders() hook (default: none). The generic integration returns a new --commands-dir, normalized and relative to the project, so rollback removes the directories it created. An existing destination is left out, which avoids walking a large user directory.
ensure_executable_scripts() now journals its chmod, so rollback restores the previous mode, and a journal refusal fails the operation instead of being reported as a per-script warning.
Tests: the corrupt-manifest test asserts _journalled was entered, and the symlink test skips when symlinks cannot be created. The rollback snapshot now compares file modes, and there is a regression test for each case above.
Evidence: each new test fails on 666766c and passes on bdfd982. The full suite on macOS (Python 3.13) gives 16297 passed, 520 skipped, 1 failed. The one failure, test_version_execution.py::TestWindowsUvToolEnvironment::test_non_windows_uv_tool_dry_run_still_previews_installer, also fails on 666766c, before this round's changes, so it is not caused by them. The Windows-specific paths have not been run locally; upstream CI will cover them.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Rollback leaves newly created command directories behind on early failures, and existing destinations with symlinked outputs incorrectly enter journaling instead of using the intended fallback.
An existing destination is omitted from the eligibility census, so changing --commands-dir to a directory with a symlinked generated file activates the journal and then fails with Symlinked integration path. Include existing destinations in the census, or check their prospective output paths, so these layouts take the intended unjournalled fallback; add a regression for this option change.
AI disclosure: GitHub Copilot (model unknown), automated review for the requester; analysis and feedback are AI-generated.
GenericIntegration.setup() creates a new --commands-dir before reading the first template, so a read or render failure there leaves the new empty directory tree behind despite rollback. Observe creation of the missing destination with before_file_change()/after_file_change() and add a regression that fails before the first generated-file write; the census alone does not record directory creation.
AI disclosure: GitHub Copilot (model unknown), automated review for the requester; analysis and feedback are AI-generated.
Clarify the built-in upgrade journal window
design/integration.md:353
This paragraph gives two different start points for the built-in upgrade journal. The first sentence says it journals "from setup()", and the next says it "starts before the first shared-infrastructure refresh". In command_upgrade.py, builtin_upgrade_journal is entered before _install_shared_infra_or_exit, so the second statement is the accurate one, and it also matches the PR description. Please state the window once, from the first shared-infrastructure refresh until the manifest and integration state are saved, so readers don't think the first refresh runs unjournalled. (Optional wording fix.)
The snapshot-cleanup tests and the symlinked-root journal test call the
shared lifecycle journal directly, so they belong with the lifecycle tests;
the CLI-driven rollback tests stay with the upgrade command.
Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015w5WiphdATEh3YkiqF4xSR
Disclosure: posted on behalf of @echarrod by Claude Code (model: claude-opus-5-5), running autonomously. The agent made the change and wrote this comment, with no line-by-line human review before pushing.
Change: the three snapshot-cleanup tests and the symlinked-root journal test call the shared lifecycle journal directly, so they now live in tests/specify_cli/integrations/test_lifecycle.py, per design/cli.md ("Test structure"). The CLI-driven rollback tests stay in test_command_upgrade.py. _symlink_or_skip moved to _helpers.py because both files use it. No test logic changed: 62 tests are collected in test_command_upgrade.py and 10 in test_lifecycle.py (previously 66 and 6), and all 72 pass.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Rollback can leave newly created directories behind, while incomplete journal eligibility checks can abort upgrades that previously succeeded.
No open issues 🎉
3 previously missed issues
In code that hasn't changed since last review
Census accepts paths rejected by the journal
src/specify_cli/integrations/_lifecycle.py:507
The eligibility check accepts regular files whose names the journal later rejects. On POSIX, an untracked .specify/extensions/demo/build:local.sh with mode 0644 passes this census, but ensure_executable_scripts() now observes its chmod and safe_project_path() rejects the colon, aborting an upgrade that previously succeeded. Validate census paths against the journal's path rules before enabling it, so these unsupported layouts also take the unjournalled fallback.
AI-generated review for the requester by GitHub Copilot (model: unknown; autonomous analysis and drafting).
Changing Generic's --commands-dir to an existing directory skips that destination's eligibility check. If it contains a new output symlink such as .new/speckit.plan.md -> ../custom.md, the journal is enabled and later rejects the write, so an upgrade that previously succeeded now fails instead of using the documented fallback. Include existing destinations in the census, or preflight their planned output paths before enabling the journal.
AI-generated review for the requester by GitHub Copilot (model: unknown; autonomous analysis and drafting).
Rollback leaves eagerly created directories behind
GenericIntegration.setup() creates .new-commands/nested before reading its first template, so a read or render failure occurs before any observed output write and rollback leaves the new directories behind. Remove the eager mkdir calls and let write_file_and_record() create parents, or journal directory creation; the format-base setup methods have the same ordering. Add a regression that fails before the first generated file is written.
AI-generated review for the requester by GitHub Copilot (model: unknown; autonomous analysis and drafting).
…ry that fails before its commit
A built-in `init --here` (or `init <existing> --force`) with no installed
records reaches its handler without a lifecycle transaction, so a failure in
setup(), the manifest save or the integration.json write left generated files
no manifest describes. Later runs then treat them as the user's.
Generalize the upgrade journal as `builtin_journal()` and wrap init's
setup() through its state write in it when the target directory already
existed. A new directory keeps its existing remove-on-failure path. Extension
and preset re-registration under --force now runs after the state write, as it
does for upgrade, so it stays best effort and outside the journal.
Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015w5WiphdATEh3YkiqF4xSR
…in its window
Skip the manifest read and the output-folder hook inside an external
transaction, reuse _MANIFEST_READ_ERRORS, and run init unjournalled rather
than fail when the hook raises. State in the design note that init's journal
ends at its integration.json write. Give the init rollback fixtures a user
.vscode/settings.json so Copilot's settings merge is exercised.
Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015w5WiphdATEh3YkiqF4xSR
echarrod
changed the title
fix(integrations): roll back a built-in upgrade that fails before its manifest commit
fix(integrations): roll back a built-in upgrade or init that fails before its manifest commit
Nov 12, 2026
Extended this PR so built-in init into an existing directory (init --here, or init <existing-dir> --force) is journalled too, closing the gap noted on #5117 (#5117 (comment)).
builtin_upgrade_journal() is now builtin_journal(), shared by upgrade and init. There is still one journal mechanism, with the same fallback to unjournalled for layouts it cannot cover.
Init journals from setup() through its manifest save and integration.json write, only when the target directory already existed. A failed init into a new directory still removes that directory.
Under --force, extension and preset re-registration now runs after the integration.json write, as in upgrade, so it stays best effort and outside the journal. Neither step reads integration.json.
New tests in tests/specify_cli/test_command_init.py: setup, manifest-save and state-write failures for init --here --force into an existing project and into a plain directory (6 cases, all fail without this change), a failing setup() for every built-in integration in both layouts (83 of 86 cases fail without it), a symlinked-scope fallback, and new-directory cleanup. One direct journal test is in test_lifecycle.py.
macOS full suite: 16,393 passed, 520 skipped, 1 failed (the pre-existing test_non_windows_uv_tool_dry_run_still_previews_installer). Linux (python:3.12-slim), tests/specify_cli/integrations and test_command_init.py: 1,410 passed.
The PR title, description and the design note in design/integration.md now describe both operations.
Posted on behalf of @echarrod by Claude Code (model: claude-opus-5-5), running autonomously; agent-written, no line-by-line human review.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Init tests can permanently overwrite real Hermes user skills without home-directory isolation, and failed manifest saves can leave newly created integration directories behind.
…late init tests' home
A first init into a plain directory that failed inside the real manifest
save (before the temp file existed) left the new .specify/integrations tree
behind: _restore_snapshots skipped the unchanged pending path before pruning
the directories created for it. Prune them on that branch too.
test_command_init.py now points HOME and USERPROFILE at a temporary
directory for every test, so the every-builtin sweep no longer writes
Hermes skills into the real ~/.hermes/skills. Add a regression test that
init --force re-registers extensions into a changed generic --commands-dir,
which depends on the state write preceding registration, and share _tree()
through tests/specify_cli/integrations/_helpers.py.
Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015w5WiphdATEh3YkiqF4xSR
…anded
Rollback reported a new, journal-tracked directory as a conflict when a
write inside it had started but not landed, leaving it behind. Treat it as
unchanged when it still matches its recorded identity. Parent pruning no
longer raises KeyError for a parent it has no recorded state for. The init
tests' home isolation also redirects the XDG directories, and the in-save
failure injection accepts a positional prefix.
Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015w5WiphdATEh3YkiqF4xSR
Home isolation: tests/specify_cli/test_command_init.py now has an autouse fixture that points HOME, USERPROFILE and the XDG directories at a temporary directory, so the Hermes cases no longer write into the real ~/.hermes/skills. A full-suite run with a throwaway HOME left that directory empty, so no other test module writes to the home directory without its own isolation. The other modules that touch Hermes already patch Path.home.
Unlanded writes: rollback now removes the new directories above a file whose write had started but not landed. This covers a failure at the temporary-file creation inside the real IntegrationManifest.save() (new manifest-write case; the plain-directory variant fails on 322dda7f) and a new directory whose child write never landed (new direct test in test_lifecycle.py; fails on 318fb9f8). Parent pruning also no longer raises KeyError for an unrecorded parent.
Registration order: the reviewer is right. registration_directory() reads integration.json for generic. Correction to my previous summary and the earlier PR description, which said neither registration step reads integration.json: it does for generic. Because the state write now comes before re-registration, init --force with a changed --commands-dir registers extension artifacts in the new directory instead of the old one. test_init_force_registers_extensions_in_the_new_generic_commands_dir pins this. It fails with the earlier registration order and passes here. The PR description now states this.
_tree() now lives in tests/specify_cli/integrations/_helpers.py, and both test_command_upgrade.py and test_command_init.py import it. test_command_init.py already imported from that module.
All eight init --here rollback cases fail on a9a144da and pass here. macOS full suite: 16,398 passed, 520 skipped, 1 failed (the pre-existing test_non_windows_uv_tool_dry_run_still_previews_installer). Linux (python:3.12-slim), tests/specify_cli/integrations and test_command_init.py: 1,415 passed. Both runs used a throwaway HOME.
Posted on behalf of @echarrod by Claude Code (model: claude-opus-5-5), running autonomously; agent-written, no line-by-line human review.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because intertwined symlink eligibility, fallback handling, and rollback edge cases can cause init or upgrade failures and leave newly created directories behind.
Whitespace fix in 2845bf5. The "Lint & dependency audit" job's git diff --check step flagged a new blank line at the end of tests/specify_cli/integrations/test_lifecycle.py and tests/specify_cli/test_command_init.py; both are removed. No code change: git diff --check against the merge base and uvx ruff@0.15.0 check src tests are both clean.
Disclosure: posted on behalf of @echarrod by Claude Code (model: claude-opus-5-5), running autonomously; agent-written, no line-by-line human review.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because rollback safety and journal fallback behavior have unresolved edge cases that the current regression tests do not fully cover.
No open issues 🎉
Review effort: Max (auto)
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
triage-must-haveVerdict: high-value, important work for Spec Kit — do first
3 participants
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Prerequisite for #5117, but the bug is independent of it: it affects every built-in integration.
With only built-in integrations installed,
specify integration upgradeandspecify init --here(orinit <existing-dir> --force) run with no journal:external_lifecyclecalls the handler directly when there are no external records. Both write generated files, in place where they already exist, before they save the new manifest. So a failure in between (insetup(), the upgrade's shared-infrastructure refresh, or the manifest orintegration.jsonwrite) leaves files no manifest describes. Later upgrades, option changes and uninstall then treat them as the user's files.This PR journals both operations until their manifest and integration state are saved, and restores the project if anything in that window fails.
_transactionis factored out as_journalled(), so the external path behaves as before.builtin_journal()covers.specifyand the integration's own output (its config folder plusIntegrationBase.upgrade_output_folders(), for ageneric--commands-dir). It takes no project lock, does not touch the trust store, and reads no other integration's manifest or state.setup()to itsintegration.jsonwrite, and only when the target directory already existed; its later shared-infrastructure install and optional steps (workflow, presets, extensions) run after that commit, as before. A failed init into a new directory still removes that directory, as before._register_extensions_for_agentstill holds.Behavior changes for built-in-only upgrades and inits into an existing directory:
.specifyand the integration's folder before it writes.init --forcenow re-registers extensions and presets after writingintegration.jsoninstead of before, matching upgrade. Registration readsintegration.jsonfor thegenericcommand directory (registration_directory()), so a re-init that changes--commands-dirnow registers extension artifacts in the new directory; before, they went to the old one. For other integrations what it registers is unchanged.Evidence:
test_builtin_upgrade_rolls_back_when_it_fails_before_committinginjects a failure at three points: aftersetup(), at the manifest save, and at theintegration.jsonwrite. Each time it compares every file and directory with the state before the upgrade. All three fail onmainand pass here.test_init_here_rolls_back_when_it_fails_before_committingdoes the same forinit --here --force, into both an existing Spec Kit project with edited and deleted skills and a plain directory with only user files, and adds a failure inside the real manifest save (at its temporary-file creation). All eight cases fail ona9a144daand pass here; the plain-directory case of the in-save failure also failed on the previous head,322dda7f, which left the new.specify/integrationstree behind.test_init_force_registers_extensions_in_the_new_generic_commands_dirchecks thatinit --forcewith a changed generic--commands-dirregisters extension artifacts in the new directory and not the old one. It fails with the pre-change registration order and passes here.test_init_here_rolls_back_a_failed_setup_for_every_builtinruns a failingsetup()for every built-in integration, into both an existing project (with a user edit to a generated file) and a plain directory, and checks the tree is unchanged. 83 of the 86 cases fail without this change; the other three (nonein both layouts, andhermesre-init) pass either way.test_builtin_journal_removes_directories_a_failed_init_createdandtest_builtin_journal_removes_a_new_directory_whose_pending_write_never_landedcheck directly that rollback removes.specifyand agent directories it created, including one whose file write had started but not landed (the second fails on318fb9f8).test_builtin_upgrade_journal_needs_no_trust_store_or_other_manifests,test_builtin_upgrade_runs_unjournalled_when_a_scope_is_a_symlinkandtest_init_here_runs_unjournalled_when_a_scope_is_a_symlinkpin the absence of new preconditions. The init one also asserts the journal is not entered; by nature it passes without this change too.test_init_into_a_new_directory_removes_it_when_it_failspins the existing new-directory cleanup.tests/specify_cli/test_command_init.pypointsHOMEandUSERPROFILEat a temporary directory for every test, so the Hermes cases never write to the real~/.hermes/skills.test_non_windows_uv_tool_dry_run_still_previews_installer, where Rich wraps the long worktree path; it fails the same way without this change. On Linux (python:3.12-slim),tests/specify_cli/integrationsandtests/specify_cli/test_command_init.py: 1,415 passed. Both runs used a throwawayHOME, which stayed empty. Not run on Windows.AI Disclosure
AI disclosure: Written for @echarrod by Claude Code (claude-opus-5-5) running autonomously: it traced the upgrade and init paths, wrote the code, tests and design note, ran the test suite on macOS and Linux, and ran automated
/code-review mediumrounds, which led to narrowing an earlier version that wrapped the whole upgrade in the external transaction. Theinitextension was added in a later round, in response to review feedback on #5117, and a further round in response to review feedback on this PR; each went through/code-review medium. No human has reviewed it yet.🤖 Generated with Claude Code
https://claude.ai/code/session_015w5WiphdATEh3YkiqF4xSR