mixdog 1.1.2 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/package.json +2 -1
- package/scripts/lib/run-node-tests.mjs +9 -0
- package/scripts/prune-desktop-runtime.mjs +3 -0
- package/scripts/test-direct.mjs +4 -2
- package/src/defaults/shipped-history.json +10 -0
- package/src/defaults/skills/pptx/SKILL.md +6 -6
- package/src/defaults/skills/pptx/references/review.md +6 -4
- package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
- package/src/defaults/skills/setup/references/actions.md +18 -5
- package/src/defaults/skills/visualize/SKILL.md +160 -0
- package/src/defaults/skills/visualize/references/craft.md +88 -0
- package/src/headless-command.mjs +56 -3
- package/src/headless-exec.mjs +17 -1
- package/src/help.mjs +2 -0
- package/src/rules/shared/60-verification.md +3 -1
- package/src/rules/shared/70-delivery.md +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
- package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
- package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
- package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
- package/src/runtime/agent/orchestrator/config.mjs +19 -5
- package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
- package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
- package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +133 -15
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +55 -8
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +86 -8
- package/src/runtime/agent/orchestrator/mcp/client.mjs +474 -164
- package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
- package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +108 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -12
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +21 -6
- package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +41 -28
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
- package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
- package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +82 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +556 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +15 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +4 -4
- package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -7
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +15 -11
- package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
- package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +278 -84
- package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
- package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
- package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
- package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +24 -13
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
- package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
- package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
- package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +5 -3
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +7 -4
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +21 -10
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +15 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +22 -12
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +9 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +17 -4
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +33 -22
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +817 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +36 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +23 -12
- package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +202 -374
- package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
- package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +48 -9
- package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +161 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
- package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +203 -33
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -68
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +20 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
- package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
- package/src/runtime/attachments/store.d.mts +3 -0
- package/src/runtime/attachments/store.mjs +38 -0
- package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
- package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
- package/src/runtime/channels/lib/worker-main.mjs +17 -14
- package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
- package/src/runtime/effort-judge/judge-client.mjs +56 -18
- package/src/runtime/memory/lib/memory.mjs +1 -1
- package/src/runtime/memory/lib/session-ingest.mjs +2 -2
- package/src/runtime/shared/atomic-file.mjs +33 -6
- package/src/runtime/shared/best-effort-stderr.mjs +8 -0
- package/src/runtime/shared/code-graph-state.mjs +12 -0
- package/src/runtime/shared/defaults-separation.mjs +2 -0
- package/src/runtime/shared/extension-scopes.mjs +5 -3
- package/src/runtime/shared/file-lock.mjs +37 -11
- package/src/runtime/shared/git-runner-worker.mjs +3 -2
- package/src/runtime/shared/git-runner.mjs +4 -3
- package/src/runtime/shared/llm/anthropic-effort.mjs +14 -0
- package/src/runtime/shared/llm/default-context-window.mjs +6 -0
- package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
- package/src/runtime/shared/llm/model-catalog.mjs +240 -130
- package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +162 -0
- package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
- package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
- package/src/runtime/shared/model-proxy-mode.mjs +19 -0
- package/src/runtime/shared/plugin-manifest.mjs +18 -9
- package/src/runtime/shared/pristine-execution-contract.json +9 -2
- package/src/runtime/shared/pristine-execution.mjs +28 -4
- package/src/runtime/shared/provider-accounts.mjs +125 -21
- package/src/runtime/shared/provider-api-key.mjs +33 -1
- package/src/runtime/shared/schedule-model-ref.mjs +11 -2
- package/src/runtime/shared/session-usage-totals.mjs +19 -0
- package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
- package/src/runtime/shared/shell-display.mjs +9 -3
- package/src/runtime/shared/singleton-owner.mjs +36 -6
- package/src/runtime/shared/skill-mentions.d.mts +10 -0
- package/src/runtime/shared/skill-mentions.mjs +133 -0
- package/src/runtime/shared/skill-script-runtime.mjs +264 -0
- package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
- package/src/runtime/shared/tool-primitives.mjs +35 -2
- package/src/runtime/shared/tool-surface.mjs +7 -0
- package/src/runtime/shared/tool-work-units.mjs +14 -0
- package/src/runtime/shared/transcript-metadata.mjs +17 -1
- package/src/runtime/shared/turn-changes.mjs +791 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
- package/src/runtime/shared/turn-snapshot.mjs +690 -81
- package/src/runtime/shared/turn-worktree-snapshot.mjs +185 -15
- package/src/runtime/shared/worker-requests.mjs +15 -2
- package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
- package/src/runtime/shared/worktrees/contract.mjs +551 -0
- package/src/runtime/shared/worktrees/disk.mjs +57 -0
- package/src/runtime/shared/worktrees/git.mjs +247 -0
- package/src/runtime/shared/worktrees/include.mjs +480 -0
- package/src/runtime/shared/worktrees/lock.mjs +46 -0
- package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
- package/src/runtime/shared/worktrees/manager.mjs +1215 -0
- package/src/runtime/shared/worktrees/merge.mjs +182 -0
- package/src/runtime/shared/worktrees/owner.mjs +46 -0
- package/src/runtime/shared/worktrees/paths.mjs +62 -0
- package/src/runtime/shared/worktrees/registry.mjs +110 -0
- package/src/runtime/shared/worktrees/settings.mjs +84 -0
- package/src/runtime/shared/worktrees/slug.mjs +62 -0
- package/src/runtime/shared/worktrees/test-support.mjs +96 -0
- package/src/runtime/visualize-bridge/client.mjs +181 -0
- package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
- package/src/session-runtime/automation-agents.mjs +8 -2
- package/src/session-runtime/boot/apis.mjs +90 -0
- package/src/session-runtime/boot/config.mjs +7 -2
- package/src/session-runtime/boot/facade.mjs +21 -0
- package/src/session-runtime/boot/infrastructure.mjs +30 -18
- package/src/session-runtime/boot/session.mjs +1 -0
- package/src/session-runtime/boot/tools.mjs +4 -0
- package/src/session-runtime/config-lifecycle.mjs +10 -1
- package/src/session-runtime/conversation-import/discovery.mjs +698 -0
- package/src/session-runtime/conversation-import/service.mjs +369 -0
- package/src/session-runtime/cwd-plugins/cwd-apply.mjs +21 -6
- package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
- package/src/session-runtime/global-extensions.mjs +2 -2
- package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
- package/src/session-runtime/internal-tool-executor.mjs +9 -1
- package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
- package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
- package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
- package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
- package/src/session-runtime/mcp-glue/connect.mjs +68 -16
- package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
- package/src/session-runtime/mcp-glue/server-catalog.mjs +122 -33
- package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
- package/src/session-runtime/mcp-glue.mjs +22 -2
- package/src/session-runtime/model-route/route-persist.mjs +3 -0
- package/src/session-runtime/model-route/set-route.mjs +15 -2
- package/src/session-runtime/new-session-config.mjs +3 -2
- package/src/session-runtime/notification-bus.mjs +18 -3
- package/src/session-runtime/provider-auth/accounts.mjs +33 -15
- package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
- package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
- package/src/session-runtime/provider-readiness.mjs +16 -10
- package/src/session-runtime/resource-extension-sync.mjs +45 -7
- package/src/session-runtime/resource-mcp-api.mjs +121 -84
- package/src/session-runtime/resource-plugins-api.mjs +29 -41
- package/src/session-runtime/resource-scope-decorate.mjs +6 -8
- package/src/session-runtime/resource-skills-api.mjs +23 -2
- package/src/session-runtime/route-resolution.mjs +10 -1
- package/src/session-runtime/route-state.mjs +16 -2
- package/src/session-runtime/runtime-core.mjs +5 -0
- package/src/session-runtime/runtime-facade.mjs +8 -0
- package/src/session-runtime/runtime-review-api.mjs +9 -0
- package/src/session-runtime/schedule-session-run.mjs +1 -1
- package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
- package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
- package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
- package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
- package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
- package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
- package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
- package/src/session-runtime/services/agent-tool.mjs +1 -0
- package/src/session-runtime/services/channel-session-router.mjs +4 -1
- package/src/session-runtime/services/daemon-candidate.mjs +3 -2
- package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
- package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
- package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
- package/src/session-runtime/services/provider-admin.mjs +64 -18
- package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
- package/src/session-runtime/session-create/session-options.mjs +10 -1
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/settings-api.mjs +4 -2
- package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
- package/src/session-runtime/settings-worktree-api.mjs +17 -0
- package/src/session-runtime/setup-tool/executor.mjs +23 -5
- package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
- package/src/session-runtime/setup-tool/settings-contract.mjs +4 -1
- package/src/session-runtime/setup-tool/tool-defs.mjs +38 -4
- package/src/session-runtime/skills-api.mjs +7 -3
- package/src/session-runtime/turn/agent-ops.mjs +12 -1
- package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +14 -3
- package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
- package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
- package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
- package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
- package/src/session-runtime/turn/turn-run.mjs +6 -7
- package/src/session-runtime/webhook-session-run.mjs +16 -4
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
- package/src/session-runtime/workspace/admission-gate.mjs +197 -0
- package/src/session-runtime/workspace/project-branches.mjs +136 -0
- package/src/session-runtime/workspace/transition-steps.mjs +364 -0
- package/src/session-runtime/workspace/transitions.mjs +651 -0
- package/src/session-runtime/workspace/workspace-state.mjs +112 -0
- package/src/skill-script-entry.mjs +36 -0
- package/src/standalone/channel-transport/http-routes.mjs +17 -0
- package/src/standalone/channel-transport.mjs +13 -1
- package/src/standalone/daemon-desktop-runtime.mjs +1 -0
- package/src/standalone/daemon-owner.mjs +372 -0
- package/src/standalone/daemon-stored-session-views.mjs +15 -4
- package/src/standalone/daemon.mjs +179 -8
- package/src/standalone/desktop-service-registry.mjs +20 -0
- package/src/standalone/local-session-runtime.mjs +11 -0
- package/src/standalone/session-client/attachment-calls.mjs +3 -1
- package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
- package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
- package/src/standalone/session-client.mjs +104 -13
- package/src/standalone/session-protocol.mjs +21 -0
- package/src/standalone/session-proxy.mjs +3 -0
- package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
- package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
- package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
- package/src/standalone/session-service/projection/revisions.mjs +2 -0
- package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
- package/src/standalone/session-service/projection/workspace.mjs +14 -0
- package/src/standalone/session-service/session-calls/session-action.mjs +17 -4
- package/src/standalone/session-transport/call-route.mjs +13 -0
- package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
- package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
- package/src/standalone/session-transport/client-registry.mjs +20 -3
- package/src/standalone/session-transport/routes.mjs +79 -2
- package/src/standalone/session-transport/stack.mjs +2 -0
- package/src/standalone/session-transport.mjs +24 -1
- package/src/tui/App.jsx +9 -1
- package/src/tui/app/app-view/floating-panel.jsx +9 -1
- package/src/tui/app/app-view.jsx +6 -0
- package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
- package/src/tui/app/message-selector.mjs +2 -1
- package/src/tui/app/model-options.mjs +37 -5
- package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
- package/src/tui/app/model-picker.mjs +4 -2
- package/src/tui/app/skill-mention-palette.mjs +88 -0
- package/src/tui/app/slash-palette.mjs +23 -7
- package/src/tui/app/transcript-row-estimate.mjs +11 -3
- package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
- package/src/tui/app/use-prompt-state.mjs +5 -0
- package/src/tui/components/Message.jsx +22 -1
- package/src/tui/components/PromptInput.jsx +21 -3
- package/src/tui/components/SlashCommandPalette.jsx +12 -3
- package/src/tui/components/TranscriptItem.jsx +9 -1
- package/src/tui/components/prompt-input/key-handler.mjs +21 -8
- package/src/tui/components/user-message-skills.mjs +38 -0
- package/src/tui/dist/index.mjs +839 -207
- package/src/tui/index.jsx +10 -2
- package/src/tui/session/compaction-view.mjs +147 -0
- package/src/tui/session/context-state.mjs +6 -0
- package/src/tui/session/initial-state.mjs +3 -0
- package/src/tui/session/queue-helpers.mjs +7 -2
- package/src/tui/session/quota-resume.mjs +2 -1
- package/src/tui/session/session-api/commands.mjs +10 -2
- package/src/tui/session/session-api/controls.mjs +4 -0
- package/src/tui/session/session-api/extensions.mjs +4 -2
- package/src/tui/session/session-api/intake/rewind.mjs +2 -1
- package/src/tui/session/session-api/integrations.mjs +17 -0
- package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
- package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
- package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
- package/src/tui/session/session-api/routes.mjs +10 -1
- package/src/tui/session/session-api/settings.mjs +9 -0
- package/src/tui/session/session-api/worktree.mjs +60 -0
- package/src/tui/session/session-api.mjs +2 -0
- package/src/tui/session/session-flow/auto-clear.mjs +31 -12
- package/src/tui/session/session-flow/drain.mjs +27 -3
- package/src/tui/session/session-flow/session-reset.mjs +4 -3
- package/src/tui/session/session-flow.mjs +2 -0
- package/src/tui/session/transcript-intake.mjs +14 -5
- package/src/tui/session/tui-steering-persist.mjs +25 -9
- package/src/tui/session/turn-stream.mjs +32 -4
- package/src/tui/session/turn-tool-cards.mjs +10 -2
- package/src/tui/session/turn.mjs +56 -10
- package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
- package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed git worktrees for Lead sessions.
|
|
3
|
+
*
|
|
4
|
+
* One retained worktree per Lead session, created lazily from the project's
|
|
5
|
+
* current branch on "<branchPrefix><slug>" at managedWorktreePath(). Every
|
|
6
|
+
* mutation runs under withRepoLock (in-process git lock keyed by the common
|
|
7
|
+
* git dir + cross-process file lock) and is recorded in the durable registry
|
|
8
|
+
* (registry.mjs) before it touches anything:
|
|
9
|
+
*
|
|
10
|
+
* creating → ready → deleting → (record removed)
|
|
11
|
+
* ↘ recovery (rollback in progress / interrupted)
|
|
12
|
+
*
|
|
13
|
+
* Creation is a transaction keyed by txId: a retry returns the same entry, a
|
|
14
|
+
* failure rolls back exactly the resources the ledger proves this
|
|
15
|
+
* transaction made, and an interrupted rollback leaves a `recovery` record
|
|
16
|
+
* that reconcile/remove/retry finish. Deletion keeps a `deleting` record until
|
|
17
|
+
* the checkout, registration, owner lease, git lock and root cleanups are all
|
|
18
|
+
* gone, so a sharing violation yields a retryable partial result.
|
|
19
|
+
*
|
|
20
|
+
* Nothing outside a managed checkout is ever deleted: before any recursive
|
|
21
|
+
* delete the record's path must be managedWorktreePath(repoCommonDir, id)
|
|
22
|
+
* and its `.git` must link back to this repository (WORKTREE_PATH_UNSAFE).
|
|
23
|
+
* A forced removal that drops unmerged commits keeps the branch tip
|
|
24
|
+
* reachable at `refs/mixdog/removed/<id>` (returned as `recoveryRef`/`tip`;
|
|
25
|
+
* `git branch <name> refs/mixdog/removed/<id>` restores it, `git update-ref
|
|
26
|
+
* -d` forgets it).
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import { lstat, mkdir, readdir, readFile, rm, rmdir } from 'node:fs/promises';
|
|
30
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
31
|
+
import {
|
|
32
|
+
managedWorktreePath,
|
|
33
|
+
managedWorktreeRoot,
|
|
34
|
+
normalizeWorktreeOwner,
|
|
35
|
+
runRootCleanups,
|
|
36
|
+
runRootReleasers,
|
|
37
|
+
worktreeBranchName,
|
|
38
|
+
worktreeError,
|
|
39
|
+
} from './contract.mjs';
|
|
40
|
+
import { freeBytesAt, requiredBytes, trackedCheckoutBytes } from './disk.mjs';
|
|
41
|
+
import {
|
|
42
|
+
branchCommit,
|
|
43
|
+
currentBranch,
|
|
44
|
+
firstLine,
|
|
45
|
+
gitStatusRunner,
|
|
46
|
+
inspectRepo,
|
|
47
|
+
isDiskFullError,
|
|
48
|
+
listWorktrees,
|
|
49
|
+
localBranches,
|
|
50
|
+
parseStatusPaths,
|
|
51
|
+
runGit,
|
|
52
|
+
throwIfAborted,
|
|
53
|
+
validBranchName,
|
|
54
|
+
} from './git.mjs';
|
|
55
|
+
import {
|
|
56
|
+
applyIncludes,
|
|
57
|
+
coveredByIncludes,
|
|
58
|
+
modifiedIncludeCopies,
|
|
59
|
+
planIncludes,
|
|
60
|
+
readIncludeEntries,
|
|
61
|
+
removeIncludeLinks,
|
|
62
|
+
} from './include.mjs';
|
|
63
|
+
import { withRepoLock as withRepoMutationLock } from './lock.mjs';
|
|
64
|
+
import { mergeBranchIntoCheckout } from './merge.mjs';
|
|
65
|
+
import { readOwnerLease, removeOwnerLease, sameOwner, writeOwnerLease } from './owner.mjs';
|
|
66
|
+
import { canonicalPath, checkoutFolder, isWithin, pathKey, projectSubdirOf, samePath } from './paths.mjs';
|
|
67
|
+
import { deleteRecord, entryView, getRecord, insertRecord, putRecord, readRegistry } from './registry.mjs';
|
|
68
|
+
import { normalizeWorktreeSettings } from './settings.mjs';
|
|
69
|
+
import { slugFromHint, uniqueSlug } from './slug.mjs';
|
|
70
|
+
|
|
71
|
+
const WORKTREE_ADD_TIMEOUT_MS = 30 * 60_000;
|
|
72
|
+
const REMOVE_RETRIES = 6;
|
|
73
|
+
const REMOVE_RETRY_DELAY_MS = 150;
|
|
74
|
+
// The session copies the project identity into each managed worktree; it is
|
|
75
|
+
// Mixdog metadata, not user data, so it alone never makes a worktree dirty.
|
|
76
|
+
const SESSION_PROJECT_ID_PATH = '.mixdog/project.id';
|
|
77
|
+
|
|
78
|
+
export { registerRootReleaser } from './contract.mjs';
|
|
79
|
+
|
|
80
|
+
/** The session execution root inside the record's checkout. */
|
|
81
|
+
function sessionRootOf(record) {
|
|
82
|
+
return checkoutFolder(record.path, record.projectSubdir);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Host configuration (settings source, free-space probe); tests override it.
|
|
87
|
+
|
|
88
|
+
/** pid probe (signal 0); EPERM means the process exists. */
|
|
89
|
+
function processAlive(pid) {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return error?.code === 'EPERM';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DEFAULT_CONFIG = Object.freeze({ settings: null, freeBytes: freeBytesAt, processAlive });
|
|
99
|
+
let config = { ...DEFAULT_CONFIG };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Override host hooks. `settings`: stored worktree settings object or a
|
|
103
|
+
* function returning it (default: agent config `worktree`). `freeBytes(dir)`:
|
|
104
|
+
* free-space probe. `processAlive(pid)`: lease holder liveness. Returns a
|
|
105
|
+
* restore function.
|
|
106
|
+
* @param {{ settings?: unknown, freeBytes?: (dir: string) => Promise<number>, processAlive?: (pid: number) => boolean }} overrides
|
|
107
|
+
*/
|
|
108
|
+
export function configureWorktreeManager(overrides = {}) {
|
|
109
|
+
const previous = config;
|
|
110
|
+
config = { ...config, ...overrides };
|
|
111
|
+
return () => {
|
|
112
|
+
config = previous;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The effective worktree settings (configured source, else the agent config). */
|
|
117
|
+
export async function currentWorktreeSettings() {
|
|
118
|
+
if (config.settings !== null && config.settings !== undefined) {
|
|
119
|
+
const stored = typeof config.settings === 'function' ? await config.settings() : config.settings;
|
|
120
|
+
return normalizeWorktreeSettings(stored);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const { readSection } = await import('../config.mjs');
|
|
124
|
+
return normalizeWorktreeSettings(readSection('agent')?.worktree);
|
|
125
|
+
} catch {
|
|
126
|
+
return normalizeWorktreeSettings(null);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Helpers
|
|
132
|
+
|
|
133
|
+
function requireText(value, name) {
|
|
134
|
+
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${name} must be a non-empty string.`);
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A directory git can run in for this record's repository. */
|
|
139
|
+
function repoCwd(record) {
|
|
140
|
+
return existsSync(record.projectPath) ? record.projectPath : record.repoCommonDir;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function removeTree(path) {
|
|
144
|
+
try {
|
|
145
|
+
await rm(path, { recursive: true, force: true, maxRetries: REMOVE_RETRIES, retryDelay: REMOVE_RETRY_DELAY_MS });
|
|
146
|
+
return null;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function registrationFor(record) {
|
|
153
|
+
const worktrees = await listWorktrees(repoCwd(record));
|
|
154
|
+
return worktrees.find((worktree) => samePath(worktree.path, record.path)) || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* True only when `path` definitely does not exist (ENOENT/ENOTDIR). An
|
|
159
|
+
* unreadable or unmounted location (EACCES, EIO, …) is not "gone".
|
|
160
|
+
*/
|
|
161
|
+
async function isGone(path) {
|
|
162
|
+
try {
|
|
163
|
+
await lstat(path);
|
|
164
|
+
return false;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The repository's linked-worktree admin dirs (<common dir>/worktrees/<name>)
|
|
172
|
+
* with the checkout each one's `gitdir` points at; unreadable ones are skipped.
|
|
173
|
+
* @returns {Promise<Array<{ admin: string, worktreePath: string }>>}
|
|
174
|
+
*/
|
|
175
|
+
async function adminRegistrations(repoCommonDir) {
|
|
176
|
+
const adminRoot = join(repoCommonDir, 'worktrees');
|
|
177
|
+
let names;
|
|
178
|
+
try {
|
|
179
|
+
names = await readdir(adminRoot);
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const registrations = [];
|
|
184
|
+
for (const name of names) {
|
|
185
|
+
let gitdir;
|
|
186
|
+
try {
|
|
187
|
+
gitdir = firstLine(await readFile(join(adminRoot, name, 'gitdir'), 'utf8'));
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (gitdir) registrations.push({ admin: join(adminRoot, name), worktreePath: dirname(gitdir) });
|
|
192
|
+
}
|
|
193
|
+
return registrations;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Drop the git admin dir of a registration whose checkout is definitely gone
|
|
198
|
+
* (targeted `git worktree prune`: unrelated stale registrations stay). A
|
|
199
|
+
* `locked` registration is kept unless `allowLocked` (the record that owns it
|
|
200
|
+
* is being deleted or rolled back).
|
|
201
|
+
*/
|
|
202
|
+
async function pruneRegistration(repoCommonDir, worktreePath, { allowLocked = false } = {}) {
|
|
203
|
+
if (!(await isGone(worktreePath))) return false;
|
|
204
|
+
let pruned = false;
|
|
205
|
+
for (const registration of await adminRegistrations(repoCommonDir)) {
|
|
206
|
+
if (!samePath(registration.worktreePath, worktreePath)) continue;
|
|
207
|
+
if (!allowLocked && !(await isGone(join(registration.admin, 'locked')))) continue;
|
|
208
|
+
await rm(registration.admin, { recursive: true, force: true });
|
|
209
|
+
pruned = true;
|
|
210
|
+
}
|
|
211
|
+
return pruned;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* WORKTREE_PATH_UNSAFE unless `record.path` is exactly the managed path of
|
|
216
|
+
* its id, inside the managed root, and (when present) its `.git` file links
|
|
217
|
+
* back into this repository's worktree admin dir. A checkout whose `.git` is
|
|
218
|
+
* already gone passes only while empty or after a recorded removal attempt
|
|
219
|
+
* (a partly deleted folder).
|
|
220
|
+
*/
|
|
221
|
+
async function assertManagedCheckoutPath(record) {
|
|
222
|
+
const unsafe = (reason) =>
|
|
223
|
+
worktreeError('WORKTREE_PATH_UNSAFE', undefined, { entryId: record.id, path: record.path, reason });
|
|
224
|
+
let expected;
|
|
225
|
+
try {
|
|
226
|
+
expected = managedWorktreePath(record.repoCommonDir, record.id);
|
|
227
|
+
} catch {
|
|
228
|
+
throw unsafe('invalid-id');
|
|
229
|
+
}
|
|
230
|
+
const root = managedWorktreeRoot(record.repoCommonDir);
|
|
231
|
+
if (!samePath(record.path, expected) || samePath(record.path, root) || !isWithin(root, record.path)) {
|
|
232
|
+
throw unsafe('outside-managed-root');
|
|
233
|
+
}
|
|
234
|
+
if (await isGone(record.path)) return;
|
|
235
|
+
const dotGit = join(record.path, '.git');
|
|
236
|
+
let info = null;
|
|
237
|
+
try {
|
|
238
|
+
info = await lstat(dotGit);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error?.code !== 'ENOENT') throw unsafe('unreadable');
|
|
241
|
+
}
|
|
242
|
+
if (!info) {
|
|
243
|
+
const attempted = !!record.deleting?.lastAttemptAt || !!record.recovery?.remaining || record.state === 'recovery';
|
|
244
|
+
if (attempted || (await readdir(record.path)).length === 0) return;
|
|
245
|
+
throw unsafe('no-back-link');
|
|
246
|
+
}
|
|
247
|
+
if (!info.isFile()) throw unsafe('not-a-linked-checkout');
|
|
248
|
+
const gitdir = /^gitdir:\s*(.+)$/m.exec(await readFile(dotGit, 'utf8'))?.[1]?.trim();
|
|
249
|
+
const admin = join(record.repoCommonDir, 'worktrees');
|
|
250
|
+
if (!gitdir || samePath(gitdir, admin) || !isWithin(admin, canonicalPath(gitdir))) {
|
|
251
|
+
throw unsafe('foreign-back-link');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Keep the tip of a branch whose unmerged commits a forced removal drops. */
|
|
256
|
+
async function keepRemovedTip(record) {
|
|
257
|
+
const tip = await branchCommit(repoCwd(record), record.branch);
|
|
258
|
+
if (!tip) return { tip: null, recoveryRef: null };
|
|
259
|
+
const ref = `refs/mixdog/removed/${record.id}`;
|
|
260
|
+
await runGit(repoCwd(record), ['update-ref', '-m', `mixdog: removed worktree ${record.id}`, ref, tip]);
|
|
261
|
+
return { tip, recoveryRef: ref };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Managed checkouts whose registration is stale (directory missing). */
|
|
265
|
+
async function staleManagedRegistrations(repoCommonDir) {
|
|
266
|
+
const root = managedWorktreeRoot(repoCommonDir);
|
|
267
|
+
const stale = [];
|
|
268
|
+
for (const { worktreePath } of await adminRegistrations(repoCommonDir)) {
|
|
269
|
+
if (!(await isGone(worktreePath))) continue;
|
|
270
|
+
const rel = relative(pathKey(root), pathKey(worktreePath));
|
|
271
|
+
if (rel && !rel.startsWith('..') && !rel.includes(sep)) stale.push(worktreePath);
|
|
272
|
+
}
|
|
273
|
+
return stale;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function setResource(record, name, status) {
|
|
277
|
+
record.resources = { ...record.resources, [name]: status };
|
|
278
|
+
await putRecord(record);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function recordInclude(record, ledger) {
|
|
282
|
+
const includes = [...(record.resources.includes || [])];
|
|
283
|
+
const index = includes.findIndex((item) => item.path === ledger.path);
|
|
284
|
+
if (index >= 0) includes[index] = { ...ledger };
|
|
285
|
+
else includes.push({ ...ledger });
|
|
286
|
+
record.resources = { ...record.resources, includes };
|
|
287
|
+
await putRecord(record);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function unmergedCommitCount(record) {
|
|
291
|
+
const cwd = repoCwd(record);
|
|
292
|
+
const tip = await branchCommit(cwd, record.branch);
|
|
293
|
+
if (!tip) return 0;
|
|
294
|
+
const reference = (record.mergeTarget && (await branchCommit(cwd, record.mergeTarget))) || record.baseCommit;
|
|
295
|
+
if (!reference) return 0;
|
|
296
|
+
const result = await runGit(cwd, ['rev-list', '--count', tip, '--not', reference]);
|
|
297
|
+
return Number(firstLine(result.stdout)) || 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Uncommitted/untracked paths plus modified include copies. */
|
|
301
|
+
async function dirtyPaths(record) {
|
|
302
|
+
if (!existsSync(record.path)) return [];
|
|
303
|
+
const includes = record.resources?.includes || [];
|
|
304
|
+
const status = await runGit(record.path, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
|
|
305
|
+
readOnly: true,
|
|
306
|
+
});
|
|
307
|
+
const identity = record.projectSubdir
|
|
308
|
+
? `${record.projectSubdir}/${SESSION_PROJECT_ID_PATH}`
|
|
309
|
+
: SESSION_PROJECT_ID_PATH;
|
|
310
|
+
const dirty = parseStatusPaths(status.stdout).filter(
|
|
311
|
+
(path) => path !== identity && !coveredByIncludes(includes, path)
|
|
312
|
+
);
|
|
313
|
+
return [...dirty, ...(await modifiedIncludeCopies(includes))];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function deleteBranchIfFree(record) {
|
|
317
|
+
const cwd = repoCwd(record);
|
|
318
|
+
if (!(await branchCommit(cwd, record.branch))) return { deleted: false, reason: 'missing' };
|
|
319
|
+
const holder = (await listWorktrees(cwd)).find((worktree) => worktree.branch === record.branch);
|
|
320
|
+
if (holder) return { deleted: false, reason: 'checked-out', path: holder.path };
|
|
321
|
+
const result = await runGit(cwd, ['branch', '-D', record.branch], { allowFailure: true });
|
|
322
|
+
return result.code === 0 ? { deleted: true } : { deleted: false, reason: firstLine(result.stderr) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Remove the managed checkout and its registration. Returns the resources
|
|
327
|
+
* still present (empty when everything is gone).
|
|
328
|
+
*/
|
|
329
|
+
async function removeCheckout(record) {
|
|
330
|
+
await assertManagedCheckoutPath(record);
|
|
331
|
+
const remaining = [];
|
|
332
|
+
for (const path of await removeIncludeLinks(record.resources?.includes)) remaining.push({ kind: 'link', path });
|
|
333
|
+
if (remaining.length) return remaining;
|
|
334
|
+
const cwd = repoCwd(record);
|
|
335
|
+
if (existsSync(record.path)) {
|
|
336
|
+
// A built-in fsmonitor daemon holds the folder open (Windows) until stopped.
|
|
337
|
+
await runGit(record.path, ['fsmonitor--daemon', 'stop'], { allowFailure: true });
|
|
338
|
+
}
|
|
339
|
+
if (await registrationFor(record)) {
|
|
340
|
+
await runGit(cwd, ['worktree', 'unlock', record.path], { allowFailure: true });
|
|
341
|
+
if (existsSync(record.path)) {
|
|
342
|
+
await runGit(cwd, ['worktree', 'remove', '--force', record.path], { allowFailure: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (existsSync(record.path)) {
|
|
346
|
+
const error = await removeTree(record.path);
|
|
347
|
+
if (existsSync(record.path)) {
|
|
348
|
+
remaining.push({ kind: 'directory', path: record.path, error: error?.code || error?.message || 'busy' });
|
|
349
|
+
return remaining;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
await pruneRegistration(record.repoCommonDir, record.path, { allowLocked: true });
|
|
353
|
+
if (await registrationFor(record)) remaining.push({ kind: 'registration', path: record.path });
|
|
354
|
+
return remaining;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* After the last worktree of a repository is gone, drop its now-empty store
|
|
359
|
+
* folder (<dataDir>/worktrees-v1/<repoHash>/ and its .owners/). Runs under the
|
|
360
|
+
* repository lock; never while the registry still has an entry of that
|
|
361
|
+
* repository, and only removes empty directories (rmdir), so another
|
|
362
|
+
* checkout or owner lease keeps them.
|
|
363
|
+
*/
|
|
364
|
+
async function pruneEmptyRepoRoot(repoCommonDir) {
|
|
365
|
+
const key = pathKey(repoCommonDir);
|
|
366
|
+
if (Object.values((await readRegistry()).entries).some((record) => pathKey(record.repoCommonDir) === key)) return;
|
|
367
|
+
const root = managedWorktreeRoot(repoCommonDir);
|
|
368
|
+
for (const dir of [join(root, '.owners'), root]) {
|
|
369
|
+
try {
|
|
370
|
+
await rmdir(dir);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
// Missing, not empty, or held: it stays.
|
|
373
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY', 'EPERM', 'EACCES', 'ENOTDIR'].includes(error?.code)) throw error;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Undo an incomplete creation using only the ledger's proof. The record moves
|
|
380
|
+
* to `recovery` first, so an interruption stays attributable.
|
|
381
|
+
* @returns {Promise<{ complete: boolean, remaining: object[] }>}
|
|
382
|
+
*/
|
|
383
|
+
async function rollbackCreation(record, cause) {
|
|
384
|
+
record.state = 'recovery';
|
|
385
|
+
record.recovery = {
|
|
386
|
+
txId: record.txId,
|
|
387
|
+
reason: cause ? String(cause.message || cause) : 'interrupted creation',
|
|
388
|
+
at: Date.now(),
|
|
389
|
+
};
|
|
390
|
+
// The filesystem cleanup never waits on the registry: with a full disk this
|
|
391
|
+
// write can fail too, and the checkout must still go.
|
|
392
|
+
const remaining = [];
|
|
393
|
+
await putRecord(record).catch((error) =>
|
|
394
|
+
remaining.push({ kind: 'registry', error: String(error?.message || error) })
|
|
395
|
+
);
|
|
396
|
+
const resources = record.resources || {};
|
|
397
|
+
try {
|
|
398
|
+
if (resources.owner) await removeOwnerLease(record);
|
|
399
|
+
if (resources.worktree) {
|
|
400
|
+
const left = await removeCheckout(record);
|
|
401
|
+
remaining.push(...left);
|
|
402
|
+
if (!left.length) {
|
|
403
|
+
// The slug was free when this transaction reserved it, so a branch
|
|
404
|
+
// still at the base commit is the one `worktree add -b` made. A
|
|
405
|
+
// branch that moved holds someone's commits and is kept.
|
|
406
|
+
const tip = await branchCommit(repoCwd(record), record.branch);
|
|
407
|
+
if (tip && tip === record.baseCommit) {
|
|
408
|
+
const result = await deleteBranchIfFree(record);
|
|
409
|
+
if (!result.deleted) remaining.push({ kind: 'branch', branch: record.branch, reason: result.reason });
|
|
410
|
+
} else if (tip) {
|
|
411
|
+
remaining.push({ kind: 'branch', branch: record.branch, reason: 'moved' });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
remaining.push({ kind: 'error', error: String(error?.message || error) });
|
|
417
|
+
}
|
|
418
|
+
if (!remaining.some((item) => item.kind !== 'registry')) {
|
|
419
|
+
try {
|
|
420
|
+
await deleteRecord(record.id);
|
|
421
|
+
await pruneEmptyRepoRoot(record.repoCommonDir);
|
|
422
|
+
return { complete: true, remaining: [] };
|
|
423
|
+
} catch (error) {
|
|
424
|
+
remaining.push({ kind: 'registry', error: String(error?.message || error) });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
record.recovery = { ...record.recovery, remaining };
|
|
428
|
+
await putRecord(record).catch(() => {});
|
|
429
|
+
return { complete: false, remaining };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Finish (or resume) a decided deletion, in order: release the live handles
|
|
434
|
+
* on the folder (desktop terminals), drop the caches keyed by it (the checkout
|
|
435
|
+
* root and the session root of a subfolder Project), then `git worktree
|
|
436
|
+
* remove` and delete the directory. A failed releaser or cleanup is reported
|
|
437
|
+
* and retried with the deletion; it never keeps the checkout.
|
|
438
|
+
*/
|
|
439
|
+
async function finishDeletion(record) {
|
|
440
|
+
const remaining = [];
|
|
441
|
+
try {
|
|
442
|
+
await removeOwnerLease(record);
|
|
443
|
+
const ctx = { reason: 'worktree-removed', entryId: record.id };
|
|
444
|
+
const results = await runRootReleasers(record.path, ctx);
|
|
445
|
+
for (const root of new Set([record.path, sessionRootOf(record)])) {
|
|
446
|
+
results.push(...(await runRootCleanups(root, ctx)));
|
|
447
|
+
}
|
|
448
|
+
for (const result of results) {
|
|
449
|
+
if (!result.ok) remaining.push({ kind: 'cleanup', name: result.name, error: result.error.message });
|
|
450
|
+
}
|
|
451
|
+
remaining.push(...(await removeCheckout(record)));
|
|
452
|
+
} catch (error) {
|
|
453
|
+
remaining.push({ kind: 'error', error: String(error?.message || error) });
|
|
454
|
+
}
|
|
455
|
+
if (remaining.length) {
|
|
456
|
+
record.deleting = { ...record.deleting, remaining, lastAttemptAt: Date.now() };
|
|
457
|
+
await putRecord(record);
|
|
458
|
+
return { removed: false, retryable: true, remaining };
|
|
459
|
+
}
|
|
460
|
+
let branch = { deleted: false, reason: 'kept' };
|
|
461
|
+
let kept = { tip: null, recoveryRef: null };
|
|
462
|
+
const merged = (await unmergedCommitCount(record)) === 0;
|
|
463
|
+
if (record.deleting?.force || merged) {
|
|
464
|
+
// Forced past unmerged commits: the tip stays reachable before `branch -D`.
|
|
465
|
+
if (!merged) kept = await keepRemovedTip(record);
|
|
466
|
+
else kept.tip = await branchCommit(repoCwd(record), record.branch);
|
|
467
|
+
branch = await deleteBranchIfFree(record);
|
|
468
|
+
}
|
|
469
|
+
await deleteRecord(record.id);
|
|
470
|
+
await pruneEmptyRepoRoot(record.repoCommonDir);
|
|
471
|
+
return {
|
|
472
|
+
removed: true,
|
|
473
|
+
branchDeleted: branch.deleted,
|
|
474
|
+
...(branch.deleted && kept.tip ? { tip: kept.tip } : {}),
|
|
475
|
+
...(kept.recoveryRef ? { recoveryRef: kept.recoveryRef } : {}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function findRecord(doc, predicate) {
|
|
480
|
+
return Object.values(doc.entries).find(predicate) || null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Public API
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Serialize managed mutations of one repository (in-process + cross-process).
|
|
488
|
+
* @template T
|
|
489
|
+
* @param {string} repoCommonDir
|
|
490
|
+
* @param {() => Promise<T>|T} fn
|
|
491
|
+
*/
|
|
492
|
+
export function withRepoLock(repoCommonDir, fn) {
|
|
493
|
+
return withRepoMutationLock(repoCommonDir, fn);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The worktree retained by `sessionId` (ready first), or null. */
|
|
497
|
+
export async function getSessionWorktree(sessionId) {
|
|
498
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
499
|
+
const records = Object.values((await readRegistry()).entries).filter((record) => record.ownerSessionId === sessionId);
|
|
500
|
+
const record = records.find((item) => item.state === 'ready') || records[0];
|
|
501
|
+
return record ? entryView(record) : null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The managed worktree at `path` (canonical, case-insensitive on Windows), or null. */
|
|
505
|
+
export async function getWorktreeByPath(path) {
|
|
506
|
+
if (typeof path !== 'string' || !path.trim()) return null;
|
|
507
|
+
const key = pathKey(path);
|
|
508
|
+
const record = Object.values((await readRegistry()).entries).find((item) => pathKey(item.path) === key);
|
|
509
|
+
return record ? entryView(record) : null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The managed worktree whose checkout is `path` or contains it (a subfolder Project's session root), or null. */
|
|
513
|
+
export async function findWorktreeContaining(path) {
|
|
514
|
+
if (typeof path !== 'string' || !path.trim()) return null;
|
|
515
|
+
const record = Object.values((await readRegistry()).entries).find((item) => isWithin(item.path, path));
|
|
516
|
+
return record ? entryView(record) : null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Mark an entry disposable: startup reconcile may remove it once clean and merged. */
|
|
520
|
+
export async function markWorktreeDisposable(entryId, disposable = true) {
|
|
521
|
+
const record = await getRecord(entryId);
|
|
522
|
+
if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
523
|
+
return withRepoLock(record.repoCommonDir, async () => {
|
|
524
|
+
const current = await getRecord(entryId);
|
|
525
|
+
if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
526
|
+
current.disposable = disposable === true;
|
|
527
|
+
await putRecord(current);
|
|
528
|
+
return entryView(current);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Before a creation (under the repository lock): the entry this transaction
|
|
534
|
+
* or session already has ready (its lease re-written), else null once every
|
|
535
|
+
* unfinished earlier attempt of the session in this repository is completed.
|
|
536
|
+
* WORKTREE_REVISION_CONFLICT for another session's transaction, a ready
|
|
537
|
+
* worktree of another repository, or an attempt that is still being cleaned up.
|
|
538
|
+
*/
|
|
539
|
+
async function settleSessionAttempts({ txId, owner, repoCommonDir }) {
|
|
540
|
+
const doc = await readRegistry();
|
|
541
|
+
const sameRepo = (record) => samePath(record.repoCommonDir, repoCommonDir);
|
|
542
|
+
|
|
543
|
+
const prior = findRecord(doc, (record) => record.txId === txId);
|
|
544
|
+
if (prior?.state === 'ready') {
|
|
545
|
+
if (prior.ownerSessionId !== owner.sessionId) {
|
|
546
|
+
throw worktreeError('WORKTREE_REVISION_CONFLICT', 'This transaction belongs to another session.', { txId });
|
|
547
|
+
}
|
|
548
|
+
await writeOwnerLease(prior, owner);
|
|
549
|
+
return entryView(prior);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const record of Object.values(doc.entries).filter((item) => item.ownerSessionId === owner.sessionId)) {
|
|
553
|
+
if (record.state === 'ready') {
|
|
554
|
+
if (!sameRepo(record)) {
|
|
555
|
+
throw worktreeError(
|
|
556
|
+
'WORKTREE_REVISION_CONFLICT',
|
|
557
|
+
'This session already retains a worktree of another repository.',
|
|
558
|
+
{ entryId: record.id }
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
await writeOwnerLease(record, owner);
|
|
562
|
+
return entryView(record);
|
|
563
|
+
}
|
|
564
|
+
if (!sameRepo(record)) continue;
|
|
565
|
+
// An earlier attempt of this session never finished (or its removal
|
|
566
|
+
// was interrupted): complete that first, then create.
|
|
567
|
+
const outcome =
|
|
568
|
+
record.state === 'deleting'
|
|
569
|
+
? await finishDeletion(record)
|
|
570
|
+
: await rollbackCreation(record).then((result) => ({
|
|
571
|
+
removed: result.complete,
|
|
572
|
+
remaining: result.remaining,
|
|
573
|
+
}));
|
|
574
|
+
if (!outcome.removed) {
|
|
575
|
+
throw worktreeError(
|
|
576
|
+
'WORKTREE_REVISION_CONFLICT',
|
|
577
|
+
'A previous worktree of this session is still being cleaned up; try again.',
|
|
578
|
+
{ entryId: record.id, remaining: outcome.remaining }
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* A full disk during `worktree add` or an include copy is the same refusal
|
|
587
|
+
* the preflight gives, with the sizes the user needs.
|
|
588
|
+
*/
|
|
589
|
+
async function diskFullFailure(error, { needed, root }) {
|
|
590
|
+
return worktreeError('WORKTREE_DISK_INSUFFICIENT', undefined, {
|
|
591
|
+
requiredBytes: needed,
|
|
592
|
+
freeBytes: await config.freeBytes(root).catch(() => null),
|
|
593
|
+
path: root,
|
|
594
|
+
reason:
|
|
595
|
+
`${error.stderr || ''}\n${error.message || ''}`
|
|
596
|
+
.split(/\r?\n/)
|
|
597
|
+
.find((line) => /space|ENOSPC|disk/i.test(line))
|
|
598
|
+
?.trim() || firstLine(error.message),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* @param {{ projectPath: string, sessionId: string, runtimeToken: string, baseBranch?: string|null,
|
|
604
|
+
* nameHint?: string, confirmLarge?: boolean, txId: string, signal?: AbortSignal }} input
|
|
605
|
+
*/
|
|
606
|
+
export async function createManagedWorktree({
|
|
607
|
+
projectPath,
|
|
608
|
+
sessionId,
|
|
609
|
+
runtimeToken,
|
|
610
|
+
baseBranch = null,
|
|
611
|
+
nameHint = '',
|
|
612
|
+
confirmLarge = false,
|
|
613
|
+
txId,
|
|
614
|
+
signal,
|
|
615
|
+
} = {}) {
|
|
616
|
+
requireText(projectPath, 'projectPath');
|
|
617
|
+
requireText(txId, 'txId');
|
|
618
|
+
const owner = normalizeWorktreeOwner({ sessionId, runtimeToken, pid: process.pid });
|
|
619
|
+
throwIfAborted(signal);
|
|
620
|
+
const repo = await inspectRepo(projectPath, { signal });
|
|
621
|
+
return withRepoLock(repo.repoCommonDir, async () => {
|
|
622
|
+
const existing = await settleSessionAttempts({ txId, owner, repoCommonDir: repo.repoCommonDir });
|
|
623
|
+
if (existing) return existing;
|
|
624
|
+
throwIfAborted(signal);
|
|
625
|
+
|
|
626
|
+
const settings = await currentWorktreeSettings();
|
|
627
|
+
let baseCommit = repo.headCommit;
|
|
628
|
+
let mergeTarget = repo.currentBranch;
|
|
629
|
+
if (baseBranch) {
|
|
630
|
+
baseCommit = await branchCommit(repo.projectPath, baseBranch, { signal });
|
|
631
|
+
if (!baseCommit) {
|
|
632
|
+
throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Base branch ${baseBranch} does not exist.`, { baseBranch });
|
|
633
|
+
}
|
|
634
|
+
mergeTarget = baseBranch;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const includePlan = await planIncludes(repo.topLevel, await readIncludeEntries(repo.topLevel));
|
|
638
|
+
const tracked = await trackedCheckoutBytes(repo.topLevel, baseCommit, { signal });
|
|
639
|
+
const contentBytes = tracked.bytes + includePlan.copyBytes;
|
|
640
|
+
const root = managedWorktreeRoot(repo.repoCommonDir);
|
|
641
|
+
const needed = requiredBytes(contentBytes);
|
|
642
|
+
const free = await config.freeBytes(root);
|
|
643
|
+
if (free < needed) {
|
|
644
|
+
throw worktreeError('WORKTREE_DISK_INSUFFICIENT', undefined, {
|
|
645
|
+
requiredBytes: needed,
|
|
646
|
+
freeBytes: free,
|
|
647
|
+
path: root,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
if (settings.sizeWarnBytes > 0 && contentBytes > settings.sizeWarnBytes && confirmLarge !== true) {
|
|
651
|
+
throw worktreeError('WORKTREE_LARGE_REPO_CONFIRM', undefined, {
|
|
652
|
+
estimateBytes: contentBytes,
|
|
653
|
+
thresholdBytes: settings.sizeWarnBytes,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
throwIfAborted(signal);
|
|
657
|
+
|
|
658
|
+
const branches = new Set(
|
|
659
|
+
(await localBranches(repo.projectPath, { signal, prefix: settings.branchPrefix })).map((name) =>
|
|
660
|
+
name.toLowerCase()
|
|
661
|
+
)
|
|
662
|
+
);
|
|
663
|
+
const base = slugFromHint(nameHint);
|
|
664
|
+
const reserve = async () => {
|
|
665
|
+
let reserved = null;
|
|
666
|
+
for (let attempt = 0; attempt < 5 && !reserved; attempt++) {
|
|
667
|
+
reserved = await reserveName();
|
|
668
|
+
}
|
|
669
|
+
if (!reserved) throw worktreeError('WORKTREE_REVISION_CONFLICT', 'Could not reserve a worktree name; try again.');
|
|
670
|
+
return reserved;
|
|
671
|
+
};
|
|
672
|
+
const addCheckout = (target) =>
|
|
673
|
+
runGit(repo.projectPath, ['worktree', 'add', '-b', target.branch, target.path, baseCommit], {
|
|
674
|
+
signal,
|
|
675
|
+
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
|
|
676
|
+
});
|
|
677
|
+
let record = await reserve();
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
await mkdir(root, { recursive: true });
|
|
681
|
+
await setResource(record, 'worktree', 'pending');
|
|
682
|
+
try {
|
|
683
|
+
await addCheckout(record);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
// Another process took the branch name (or folder) after the
|
|
686
|
+
// reservation: git refused before making anything, so reserve the
|
|
687
|
+
// next free name once and try again.
|
|
688
|
+
// ("already exists", or a ref directory/file clash: "… exists; cannot create …").
|
|
689
|
+
if (!/already exists|exists; cannot create/i.test(`${error?.stderr || ''}${error?.message || ''}`)) throw error;
|
|
690
|
+
branches.add(record.branch.toLowerCase());
|
|
691
|
+
record.resources = { ...record.resources, worktree: null };
|
|
692
|
+
await deleteRecord(record.id);
|
|
693
|
+
record = await reserve();
|
|
694
|
+
await setResource(record, 'worktree', 'pending');
|
|
695
|
+
await addCheckout(record);
|
|
696
|
+
}
|
|
697
|
+
await setResource(record, 'worktree', 'created');
|
|
698
|
+
throwIfAborted(signal);
|
|
699
|
+
await setResource(record, 'lock', 'pending');
|
|
700
|
+
await runGit(repo.projectPath, [
|
|
701
|
+
'worktree',
|
|
702
|
+
'lock',
|
|
703
|
+
'--reason',
|
|
704
|
+
`mixdog session=${owner.sessionId}`,
|
|
705
|
+
record.path,
|
|
706
|
+
]);
|
|
707
|
+
await setResource(record, 'lock', 'created');
|
|
708
|
+
await setResource(record, 'owner', 'pending');
|
|
709
|
+
await writeOwnerLease(record, owner);
|
|
710
|
+
await setResource(record, 'owner', 'created');
|
|
711
|
+
const applied = await applyIncludes(includePlan, record.path, (ledger) => recordInclude(record, ledger));
|
|
712
|
+
throwIfAborted(signal);
|
|
713
|
+
record.warnings = [...record.warnings, ...applied.warnings];
|
|
714
|
+
record.state = 'ready';
|
|
715
|
+
await putRecord(record);
|
|
716
|
+
return entryView(record);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
const rollback = await rollbackCreation(record, error);
|
|
719
|
+
const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
|
|
720
|
+
if (!rollback.complete) failure.rollback = rollback;
|
|
721
|
+
throw failure;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function reserveName() {
|
|
725
|
+
const ids = new Set(Object.keys((await readRegistry()).entries).map((id) => id.toLowerCase()));
|
|
726
|
+
const slug = uniqueSlug(
|
|
727
|
+
base,
|
|
728
|
+
(candidate) =>
|
|
729
|
+
ids.has(candidate.toLowerCase()) ||
|
|
730
|
+
branches.has(`${settings.branchPrefix}${candidate}`.toLowerCase()) ||
|
|
731
|
+
existsSync(join(root, candidate))
|
|
732
|
+
);
|
|
733
|
+
const branch = worktreeBranchName(slug, settings.branchPrefix);
|
|
734
|
+
if (!(await validBranchName(repo.projectPath, branch))) {
|
|
735
|
+
throw worktreeError('WORKTREE_UNSUPPORTED_REPO', `Branch name ${branch} is not valid.`, { branch });
|
|
736
|
+
}
|
|
737
|
+
const candidate = {
|
|
738
|
+
id: slug,
|
|
739
|
+
txId,
|
|
740
|
+
repoCommonDir: repo.repoCommonDir,
|
|
741
|
+
projectPath: repo.projectPath,
|
|
742
|
+
projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
|
|
743
|
+
path: managedWorktreePath(repo.repoCommonDir, slug),
|
|
744
|
+
branch,
|
|
745
|
+
baseCommit,
|
|
746
|
+
mergeTarget,
|
|
747
|
+
createdAt: Date.now(),
|
|
748
|
+
ownerSessionId: owner.sessionId,
|
|
749
|
+
state: 'creating',
|
|
750
|
+
disposable: false,
|
|
751
|
+
warnings: [...(tracked.submodules ? ['submodules'] : []), ...includePlan.warnings],
|
|
752
|
+
resources: { worktree: null, lock: null, owner: null, includes: [] },
|
|
753
|
+
deleting: null,
|
|
754
|
+
recovery: null,
|
|
755
|
+
};
|
|
756
|
+
return (await insertRecord(candidate)) ? candidate : null;
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Claim the live lease of a retained worktree for one runtime of its session.
|
|
763
|
+
* @param {string} entryId
|
|
764
|
+
* @param {{ sessionId: string, runtimeToken: string, pid?: number|null }} owner
|
|
765
|
+
*/
|
|
766
|
+
export async function claimWorktreeOwner(entryId, owner) {
|
|
767
|
+
const claimant = normalizeWorktreeOwner(owner);
|
|
768
|
+
const record = await getRecord(entryId);
|
|
769
|
+
if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
770
|
+
return withRepoLock(record.repoCommonDir, async () => {
|
|
771
|
+
const current = await getRecord(entryId);
|
|
772
|
+
if (current?.state !== 'ready') throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
773
|
+
if (current.ownerSessionId && current.ownerSessionId !== claimant.sessionId) {
|
|
774
|
+
throw worktreeError('WORKTREE_BRANCH_OCCUPIED', 'The worktree is retained by another session.', {
|
|
775
|
+
entryId,
|
|
776
|
+
sessionId: current.ownerSessionId,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
if (!current.ownerSessionId) {
|
|
780
|
+
const other = findRecord(
|
|
781
|
+
await readRegistry(),
|
|
782
|
+
(item) => item.ownerSessionId === claimant.sessionId && item.id !== entryId
|
|
783
|
+
);
|
|
784
|
+
if (other) {
|
|
785
|
+
throw worktreeError('WORKTREE_REVISION_CONFLICT', 'The session already retains another worktree.', {
|
|
786
|
+
entryId: other.id,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
current.ownerSessionId = claimant.sessionId;
|
|
790
|
+
await putRecord(current);
|
|
791
|
+
}
|
|
792
|
+
const previous = await readOwnerLease(current);
|
|
793
|
+
const lease = await writeOwnerLease(current, claimant);
|
|
794
|
+
return { entry: entryView(current), lease, replaced: previous && !sameOwner(previous, claimant) ? previous : null };
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Release the live lease when (and only when) it is held by exactly `owner`.
|
|
800
|
+
* Durable ownership (the session retaining the worktree) is unchanged.
|
|
801
|
+
*/
|
|
802
|
+
export async function releaseWorktreeOwner(entryId, owner) {
|
|
803
|
+
const claimant = normalizeWorktreeOwner(owner);
|
|
804
|
+
const record = await getRecord(entryId);
|
|
805
|
+
if (!record) return { released: false };
|
|
806
|
+
return withRepoLock(record.repoCommonDir, async () => {
|
|
807
|
+
const current = await getRecord(entryId);
|
|
808
|
+
if (!current) return { released: false };
|
|
809
|
+
const lease = await readOwnerLease(current);
|
|
810
|
+
if (!sameOwner(lease, claimant)) return { released: false };
|
|
811
|
+
await removeOwnerLease(current);
|
|
812
|
+
return { released: true };
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Local branches of the project and who holds each.
|
|
818
|
+
* @param {{ projectPath: string, sessionId?: string }} input
|
|
819
|
+
*/
|
|
820
|
+
export async function listBranchOccupancy({ projectPath, sessionId } = {}) {
|
|
821
|
+
requireText(projectPath, 'projectPath');
|
|
822
|
+
const repo = await inspectRepo(projectPath);
|
|
823
|
+
const [branches, worktrees, doc] = await Promise.all([
|
|
824
|
+
localBranches(repo.projectPath),
|
|
825
|
+
listWorktrees(repo.projectPath),
|
|
826
|
+
readRegistry(),
|
|
827
|
+
]);
|
|
828
|
+
const managed = Object.values(doc.entries).filter((record) => samePath(record.repoCommonDir, repo.repoCommonDir));
|
|
829
|
+
const occupancy = branches.map((branch) => {
|
|
830
|
+
// A prunable registration (its folder is gone) holds nothing.
|
|
831
|
+
const holder = worktrees.find((worktree) => worktree.branch === branch && !worktree.prunable);
|
|
832
|
+
if (!holder) return { branch, kind: 'free' };
|
|
833
|
+
if (samePath(holder.path, repo.topLevel)) return { branch, kind: 'current', path: holder.path };
|
|
834
|
+
const record = managed.find((item) => samePath(item.path, holder.path));
|
|
835
|
+
if (record?.ownerSessionId) return { branch, kind: 'session', sessionId: record.ownerSessionId, path: holder.path };
|
|
836
|
+
return { branch, kind: 'external', path: holder.path };
|
|
837
|
+
});
|
|
838
|
+
const retained = sessionId
|
|
839
|
+
? managed.find((record) => record.ownerSessionId === sessionId && record.state === 'ready')
|
|
840
|
+
: null;
|
|
841
|
+
return { branches, occupancy, retainedWorktree: retained ? entryView(retained) : null };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* The full id of the commit an object id (4–64 hex) names. Object names
|
|
846
|
+
* only (`--disambiguate`): a branch or tag spelled like a hex id never
|
|
847
|
+
* shadows the commit, and nothing parses as an option.
|
|
848
|
+
*/
|
|
849
|
+
async function commitById(cwd, value) {
|
|
850
|
+
const id = String(value).trim();
|
|
851
|
+
if (!/^[0-9a-f]{4,64}$/i.test(id)) throw new TypeError('A commit id is required.');
|
|
852
|
+
const listed = await runGit(cwd, ['rev-parse', `--disambiguate=${id}`], { allowFailure: true });
|
|
853
|
+
const commits = new Set();
|
|
854
|
+
for (const candidate of String(listed.stdout).split(/\s+/).filter(Boolean)) {
|
|
855
|
+
const peeled = await runGit(cwd, ['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], {
|
|
856
|
+
allowFailure: true,
|
|
857
|
+
});
|
|
858
|
+
if (peeled.code === 0) commits.add(firstLine(peeled.stdout));
|
|
859
|
+
}
|
|
860
|
+
if (commits.size !== 1) {
|
|
861
|
+
throw new TypeError(commits.size ? `Commit ${id} is ambiguous.` : `Commit ${id} does not exist.`);
|
|
862
|
+
}
|
|
863
|
+
return [...commits][0];
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Detached checkout of a commit id in the project folder (see checkoutProjectBranch). */
|
|
867
|
+
async function detachProjectFolder(projectPath, commitId) {
|
|
868
|
+
const repo = await inspectRepo(projectPath);
|
|
869
|
+
return withRepoLock(repo.repoCommonDir, async () => {
|
|
870
|
+
const commit = await commitById(repo.projectPath, commitId);
|
|
871
|
+
const head = firstLine((await runGit(repo.projectPath, ['rev-parse', 'HEAD'])).stdout);
|
|
872
|
+
if (head === commit && !(await currentBranch(repo.projectPath))) {
|
|
873
|
+
return { branch: null, changed: false, commit, detached: true };
|
|
874
|
+
}
|
|
875
|
+
await assertProjectFolderClean(repo);
|
|
876
|
+
await runGit(repo.projectPath, ['switch', '--detach', commit]);
|
|
877
|
+
return { branch: null, changed: true, commit, detached: true };
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* WORKTREE_DIRTY (reason `project-dirty`) while the project folder has
|
|
883
|
+
* uncommitted tracked changes. No carry: `git switch` keeps compatible
|
|
884
|
+
* uncommitted changes, which would silently move them onto the other commit.
|
|
885
|
+
*/
|
|
886
|
+
async function assertProjectFolderClean(repo) {
|
|
887
|
+
const status = await runGit(repo.topLevel, ['status', '--porcelain=v1', '-z', '--untracked-files=no'], {
|
|
888
|
+
readOnly: true,
|
|
889
|
+
});
|
|
890
|
+
const dirty = parseStatusPaths(status.stdout);
|
|
891
|
+
if (dirty.length) {
|
|
892
|
+
throw worktreeError(
|
|
893
|
+
'WORKTREE_DIRTY',
|
|
894
|
+
'The project folder has uncommitted changes; commit or stash them before switching branches.',
|
|
895
|
+
{ reason: 'project-dirty', files: dirty.slice(0, 50), count: dirty.length }
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Coordinated checkout in the project folder. Refuses a branch that is
|
|
902
|
+
* checked out in any other worktree (WORKTREE_BRANCH_OCCUPIED) and a project
|
|
903
|
+
* folder with uncommitted tracked changes (WORKTREE_DIRTY, reason
|
|
904
|
+
* `project-dirty`): nothing is carried to the other branch.
|
|
905
|
+
*
|
|
906
|
+
* `remote`: `branch` is a remote-tracking branch ("origin/x"); its local
|
|
907
|
+
* branch ("x") is checked out, created to track the remote branch when it
|
|
908
|
+
* does not exist yet.
|
|
909
|
+
*
|
|
910
|
+
* `startPoint` (with `create`): the new branch starts at that commit (a
|
|
911
|
+
* history entry) instead of the checked-out one.
|
|
912
|
+
*
|
|
913
|
+
* `detach` (alone, no branch): check out that commit id detached (a history
|
|
914
|
+
* entry), resolved by object id only; same lock and no-carry refusal.
|
|
915
|
+
* @param {{ projectPath: string, branch?: string, create?: boolean, remote?: boolean, startPoint?: string|null, detach?: string|null }} input
|
|
916
|
+
* @returns {Promise<{ branch: string|null, changed: boolean, commit: string, tracking?: string, detached?: true }>}
|
|
917
|
+
*/
|
|
918
|
+
export async function checkoutProjectBranch({
|
|
919
|
+
projectPath,
|
|
920
|
+
branch: requested,
|
|
921
|
+
create = false,
|
|
922
|
+
remote = false,
|
|
923
|
+
startPoint = null,
|
|
924
|
+
detach = null,
|
|
925
|
+
} = {}) {
|
|
926
|
+
requireText(projectPath, 'projectPath');
|
|
927
|
+
if (detach !== null) {
|
|
928
|
+
if (requested !== undefined || create || remote || startPoint !== null) {
|
|
929
|
+
throw new TypeError('detach excludes branch, create, remote and startPoint.');
|
|
930
|
+
}
|
|
931
|
+
return detachProjectFolder(projectPath, detach);
|
|
932
|
+
}
|
|
933
|
+
requireText(requested, 'branch');
|
|
934
|
+
if (create && remote) throw new TypeError('create and remote are exclusive.');
|
|
935
|
+
if (startPoint !== null && (!create || typeof startPoint !== 'string' || !startPoint.trim())) {
|
|
936
|
+
throw new TypeError('startPoint needs create and a commit.');
|
|
937
|
+
}
|
|
938
|
+
const repo = await inspectRepo(projectPath);
|
|
939
|
+
return withRepoLock(repo.repoCommonDir, async () => {
|
|
940
|
+
let branch = requested;
|
|
941
|
+
let tracking = null;
|
|
942
|
+
let start = null;
|
|
943
|
+
if (startPoint !== null) start = await commitById(repo.projectPath, startPoint);
|
|
944
|
+
if (remote) {
|
|
945
|
+
const ref = `refs/remotes/${requested}^{commit}`;
|
|
946
|
+
const tip = await runGit(repo.projectPath, ['rev-parse', '--verify', '--quiet', ref], { allowFailure: true });
|
|
947
|
+
if (tip.code !== 0) throw new TypeError(`Remote branch ${requested} does not exist.`);
|
|
948
|
+
branch = requested.replace(/^[^/]+\//, '');
|
|
949
|
+
if (!(await branchCommit(repo.projectPath, branch))) tracking = requested;
|
|
950
|
+
}
|
|
951
|
+
if (!(await validBranchName(repo.projectPath, branch))) throw new TypeError(`Branch name ${branch} is not valid.`);
|
|
952
|
+
const current = await currentBranch(repo.projectPath);
|
|
953
|
+
if (current === branch && !create) return { branch, changed: false, commit: repo.headCommit };
|
|
954
|
+
let holder = (await listWorktrees(repo.projectPath)).find(
|
|
955
|
+
(worktree) => worktree.branch === branch && !samePath(worktree.path, repo.topLevel)
|
|
956
|
+
);
|
|
957
|
+
// A stale (prunable) unmanaged registration: drop exactly that one and go on.
|
|
958
|
+
if (holder?.prunable && !(await getWorktreeByPath(holder.path))) {
|
|
959
|
+
if (await pruneRegistration(repo.repoCommonDir, holder.path)) holder = null;
|
|
960
|
+
}
|
|
961
|
+
if (holder) {
|
|
962
|
+
const record = await getWorktreeByPath(holder.path);
|
|
963
|
+
throw worktreeError('WORKTREE_BRANCH_OCCUPIED', `Branch ${branch} is checked out in ${holder.path}.`, {
|
|
964
|
+
branch,
|
|
965
|
+
path: holder.path,
|
|
966
|
+
kind: record?.ownerSessionId ? 'session' : 'external',
|
|
967
|
+
...(record?.ownerSessionId ? { sessionId: record.ownerSessionId } : {}),
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
await assertProjectFolderClean(repo);
|
|
971
|
+
let args = ['switch', branch];
|
|
972
|
+
if (create) args = ['switch', '-c', branch, ...(start ? [start] : [])];
|
|
973
|
+
else if (tracking) args = ['switch', '-c', branch, '--track', tracking];
|
|
974
|
+
await runGit(repo.projectPath, args);
|
|
975
|
+
const head = await runGit(repo.projectPath, ['rev-parse', 'HEAD']);
|
|
976
|
+
return { branch, changed: true, commit: firstLine(head.stdout), ...(tracking ? { tracking } : {}) };
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Merge a worktree's branch into the project folder's checked-out `target`.
|
|
982
|
+
* `projectPath` (when given) is the destination the caller guarded and the
|
|
983
|
+
* user confirmed: a record of another Project folder is WORKTREE_PROJECT_MISMATCH.
|
|
984
|
+
* @param {{ entryId: string, target: string, confirmed: boolean, projectPath?: string }} input
|
|
985
|
+
* @returns {Promise<{ merged: boolean, commit: string|null, conflicts?: string[], upToDate?: boolean, target: string }>}
|
|
986
|
+
*/
|
|
987
|
+
export async function mergeWorktreeIntoProject({ entryId, target, confirmed, projectPath } = {}) {
|
|
988
|
+
requireText(target, 'target');
|
|
989
|
+
const record = await getRecord(entryId);
|
|
990
|
+
if (record?.state !== 'ready') throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
991
|
+
return withRepoLock(record.repoCommonDir, async () => {
|
|
992
|
+
const current = await getRecord(entryId);
|
|
993
|
+
if (current?.state !== 'ready') throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
994
|
+
if (projectPath !== undefined && !samePath(current.projectPath, projectPath)) {
|
|
995
|
+
throw worktreeError('WORKTREE_PROJECT_MISMATCH', undefined, {
|
|
996
|
+
worktreeId: current.id,
|
|
997
|
+
worktreeProject: current.projectPath,
|
|
998
|
+
projectPath,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
const result = await mergeBranchIntoCheckout({
|
|
1002
|
+
git: gitStatusRunner,
|
|
1003
|
+
cwd: current.projectPath,
|
|
1004
|
+
source: current.branch,
|
|
1005
|
+
target,
|
|
1006
|
+
confirmed,
|
|
1007
|
+
});
|
|
1008
|
+
if (result.merged && current.mergeTarget !== target) {
|
|
1009
|
+
current.mergeTarget = target;
|
|
1010
|
+
await putRecord(current);
|
|
1011
|
+
}
|
|
1012
|
+
return result;
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Merge any local branch `source` into the project folder's checked-out
|
|
1018
|
+
* `target` (the source-control dock), under the repository lock, with the
|
|
1019
|
+
* same preflight/--no-ff backend as a worktree merge. `target` is the branch
|
|
1020
|
+
* the user confirmed: the folder on another branch is WORKTREE_TARGET_MISMATCH.
|
|
1021
|
+
* @param {{ projectPath: string, source: string, target: string, confirmed: boolean }} input
|
|
1022
|
+
*/
|
|
1023
|
+
export async function mergeBranchIntoProject({ projectPath, source, target, confirmed } = {}) {
|
|
1024
|
+
requireText(projectPath, 'projectPath');
|
|
1025
|
+
requireText(source, 'source');
|
|
1026
|
+
requireText(target, 'target');
|
|
1027
|
+
const repo = await inspectRepo(projectPath);
|
|
1028
|
+
return withRepoLock(repo.repoCommonDir, () =>
|
|
1029
|
+
mergeBranchIntoCheckout({ git: gitStatusRunner, cwd: repo.projectPath, source, target, confirmed })
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** WORKTREE_DIRTY / WORKTREE_UNMERGED for a ready worktree removal would refuse. */
|
|
1034
|
+
async function assertRemovable(record) {
|
|
1035
|
+
const dirty = await dirtyPaths(record);
|
|
1036
|
+
if (dirty.length) {
|
|
1037
|
+
throw worktreeError('WORKTREE_DIRTY', undefined, {
|
|
1038
|
+
entryId: record.id,
|
|
1039
|
+
files: dirty.slice(0, 50),
|
|
1040
|
+
count: dirty.length,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
const unmerged = await unmergedCommitCount(record);
|
|
1044
|
+
if (unmerged > 0) {
|
|
1045
|
+
throw worktreeError('WORKTREE_UNMERGED', undefined, {
|
|
1046
|
+
entryId: record.id,
|
|
1047
|
+
branch: record.branch,
|
|
1048
|
+
mergeTarget: record.mergeTarget,
|
|
1049
|
+
commits: unmerged,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* The refusal removeManagedWorktree would raise, checked before anything
|
|
1056
|
+
* moves (a session leaves its worktree only for a removal that can proceed).
|
|
1057
|
+
* Unknown entries are WORKTREE_MISSING; `force` and non-ready entries pass.
|
|
1058
|
+
* @param {{ entryId: string, force?: boolean }} input
|
|
1059
|
+
*/
|
|
1060
|
+
export async function assertWorktreeRemovable({ entryId, force = false } = {}) {
|
|
1061
|
+
const record = await getRecord(entryId);
|
|
1062
|
+
if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
1063
|
+
if (record.state !== 'ready') return;
|
|
1064
|
+
await assertManagedCheckoutPath(record);
|
|
1065
|
+
if (!force) await assertRemovable(record);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Remove a managed worktree. Refuses dirty (incl. untracked and modified
|
|
1070
|
+
* include copies) or unmerged worktrees unless `force`.
|
|
1071
|
+
* @param {{ entryId: string, force?: boolean }} input
|
|
1072
|
+
* @returns {Promise<{ removed: boolean, retryable?: boolean, remaining?: object[], branchDeleted?: boolean }>}
|
|
1073
|
+
*/
|
|
1074
|
+
export async function removeManagedWorktree({ entryId, force = false } = {}) {
|
|
1075
|
+
const record = await getRecord(entryId);
|
|
1076
|
+
if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
1077
|
+
return withRepoLock(record.repoCommonDir, async () => {
|
|
1078
|
+
const current = await getRecord(entryId);
|
|
1079
|
+
if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
|
|
1080
|
+
if (current.state === 'creating' || current.state === 'recovery') {
|
|
1081
|
+
const rollback = await rollbackCreation(current);
|
|
1082
|
+
return rollback.complete ? { removed: true } : { removed: false, retryable: true, remaining: rollback.remaining };
|
|
1083
|
+
}
|
|
1084
|
+
if (current.state === 'ready') {
|
|
1085
|
+
await assertManagedCheckoutPath(current);
|
|
1086
|
+
if (!force) await assertRemovable(current);
|
|
1087
|
+
current.state = 'deleting';
|
|
1088
|
+
current.deleting = { force: force === true, startedAt: Date.now(), remaining: [] };
|
|
1089
|
+
await putRecord(current);
|
|
1090
|
+
}
|
|
1091
|
+
return finishDeletion(current);
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** A lease whose recorded process still runs. */
|
|
1096
|
+
function leaseProcessAlive(lease) {
|
|
1097
|
+
return !!lease?.pid && config.processAlive(lease.pid);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Every managed worktree with who keeps it: `retained` (its session is in
|
|
1102
|
+
* `retainedSessionIds`, i.e. still in the session store), `ownerLive` (a
|
|
1103
|
+
* live session or a running lease holder), and `removable` (what a prune
|
|
1104
|
+
* would remove: neither, or disposable and not live).
|
|
1105
|
+
* @param {{ liveSessionIds?: string[], retainedSessionIds?: string[] }} input
|
|
1106
|
+
*/
|
|
1107
|
+
export async function listManagedWorktrees({ liveSessionIds = [], retainedSessionIds = [] } = {}) {
|
|
1108
|
+
const live = new Set(liveSessionIds);
|
|
1109
|
+
const retained = new Set(retainedSessionIds);
|
|
1110
|
+
const rows = [];
|
|
1111
|
+
for (const record of Object.values((await readRegistry()).entries)) {
|
|
1112
|
+
const ownerLive =
|
|
1113
|
+
(!!record.ownerSessionId && live.has(record.ownerSessionId)) || leaseProcessAlive(await readOwnerLease(record));
|
|
1114
|
+
const isRetained = !!record.ownerSessionId && retained.has(record.ownerSessionId);
|
|
1115
|
+
rows.push({
|
|
1116
|
+
...entryView(record),
|
|
1117
|
+
projectPath: record.projectPath,
|
|
1118
|
+
retained: isRetained,
|
|
1119
|
+
ownerLive,
|
|
1120
|
+
removable: record.state !== 'ready' || (!ownerLive && (record.disposable === true || !isRetained)),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
return rows;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Conservative reconcile (daemon startup, and the manual prune). Always:
|
|
1128
|
+
* resumes interrupted deletions, rolls back interrupted creations of no
|
|
1129
|
+
* retained session, releases leases of owners that are neither live nor
|
|
1130
|
+
* running, prunes stale managed registrations. `removeOrphans` (the
|
|
1131
|
+
* autoCleanup setting at startup; true for a manual prune) also removes
|
|
1132
|
+
* clean, merged worktrees that no stored session retains (or that a deleted
|
|
1133
|
+
* session left marked disposable) and whose owner is not live — never one a
|
|
1134
|
+
* session in the store still references.
|
|
1135
|
+
* @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], removeOrphans?: boolean }} input
|
|
1136
|
+
*/
|
|
1137
|
+
export async function reconcileManagedWorktrees({
|
|
1138
|
+
liveSessionIds = [],
|
|
1139
|
+
retainedSessionIds = [],
|
|
1140
|
+
removeOrphans = false,
|
|
1141
|
+
} = {}) {
|
|
1142
|
+
const live = new Set(liveSessionIds);
|
|
1143
|
+
const retained = new Set(retainedSessionIds);
|
|
1144
|
+
const report = { removed: [], recovered: [], kept: [], leasesReleased: [], pruned: [], missing: [], errors: [] };
|
|
1145
|
+
const reportDeletion = (record, result) => {
|
|
1146
|
+
if (result.removed) report.removed.push(record.id);
|
|
1147
|
+
else report.kept.push({ id: record.id, reason: 'deleting', remaining: result.remaining });
|
|
1148
|
+
};
|
|
1149
|
+
const byRepo = new Map();
|
|
1150
|
+
for (const record of Object.values((await readRegistry()).entries)) {
|
|
1151
|
+
const key = pathKey(record.repoCommonDir);
|
|
1152
|
+
if (!byRepo.has(key)) byRepo.set(key, record.repoCommonDir);
|
|
1153
|
+
}
|
|
1154
|
+
for (const [key, repoCommonDir] of byRepo) {
|
|
1155
|
+
if (!existsSync(repoCommonDir)) {
|
|
1156
|
+
report.errors.push({ repoCommonDir, error: 'repository missing' });
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
try {
|
|
1160
|
+
await withRepoLock(repoCommonDir, async () => {
|
|
1161
|
+
const records = Object.values((await readRegistry()).entries).filter(
|
|
1162
|
+
(record) => pathKey(record.repoCommonDir) === key
|
|
1163
|
+
);
|
|
1164
|
+
for (const record of records) {
|
|
1165
|
+
const isRetained = !!record.ownerSessionId && retained.has(record.ownerSessionId);
|
|
1166
|
+
try {
|
|
1167
|
+
if (record.state === 'creating' || record.state === 'recovery') {
|
|
1168
|
+
if (isRetained) {
|
|
1169
|
+
report.kept.push({ id: record.id, reason: 'retained' });
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
// Holding the repository lock proves no creator is still running.
|
|
1173
|
+
const result = await rollbackCreation(record);
|
|
1174
|
+
if (result.complete) report.recovered.push(record.id);
|
|
1175
|
+
else report.kept.push({ id: record.id, reason: 'recovery', remaining: result.remaining });
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
if (record.state === 'deleting') {
|
|
1179
|
+
// A decided deletion: its session already left the folder.
|
|
1180
|
+
reportDeletion(record, await finishDeletion(record));
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const lease = await readOwnerLease(record);
|
|
1184
|
+
const ownerLive = (!!record.ownerSessionId && live.has(record.ownerSessionId)) || leaseProcessAlive(lease);
|
|
1185
|
+
if (!ownerLive && lease) {
|
|
1186
|
+
await removeOwnerLease(record);
|
|
1187
|
+
report.leasesReleased.push(record.id);
|
|
1188
|
+
}
|
|
1189
|
+
if (!existsSync(record.path)) report.missing.push(record.id);
|
|
1190
|
+
if (removeOrphans === true && !ownerLive && (record.disposable === true || !isRetained)) {
|
|
1191
|
+
const dirty = await dirtyPaths(record);
|
|
1192
|
+
const unmerged = await unmergedCommitCount(record);
|
|
1193
|
+
if (dirty.length || unmerged > 0) {
|
|
1194
|
+
report.kept.push({ id: record.id, reason: dirty.length ? 'dirty' : 'unmerged' });
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
record.state = 'deleting';
|
|
1198
|
+
record.deleting = { force: false, startedAt: Date.now(), remaining: [] };
|
|
1199
|
+
await putRecord(record);
|
|
1200
|
+
reportDeletion(record, await finishDeletion(record));
|
|
1201
|
+
}
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
report.errors.push({ id: record.id, error: String(error?.message || error) });
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
for (const stale of await staleManagedRegistrations(repoCommonDir)) {
|
|
1207
|
+
if (await pruneRegistration(repoCommonDir, stale)) report.pruned.push(canonicalPath(stale));
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
report.errors.push({ repoCommonDir, error: String(error?.message || error) });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return report;
|
|
1215
|
+
}
|