@kabmo-labs/kabmo 0.7.0 → 0.9.0
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 +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +10 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +55 -69
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +27 -28
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
- package/src/client/hooks/ui/updates/shell-download.ts +153 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +165 -0
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +56 -8
- package/src/server/index.ts +7 -2
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +262 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +142 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +200 -0
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +187 -0
- package/src/server/lib/updates/shell-pages.ts +157 -0
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +59 -71
- package/src/server/lib/updates/shell-release.ts +178 -198
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +59 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +134 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +326 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Carry-over: the one capability lost by not calling `omp worktree add`.
|
|
8
|
+
*
|
|
9
|
+
* Plain git does NOT copy ignored files — measured: a repository with
|
|
10
|
+
* `node_modules` and a `.gitignore` yields a worktree holding only the tracked
|
|
11
|
+
* files, so the agent's first `bun run dev` fails on a project that was working
|
|
12
|
+
* a second earlier. This copies the ignored build folders with a copy-on-write
|
|
13
|
+
* clone where the filesystem has one.
|
|
14
|
+
*
|
|
15
|
+
* Every path is checked with `git check-ignore`: the allow-list only narrows
|
|
16
|
+
* WHAT to ask about, it never substitutes for git's answer. A candidate that is
|
|
17
|
+
* tracked, or that does not exist, is skipped rather than guessed at.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, statSync } from 'fs';
|
|
21
|
+
import { copyFile, mkdir } from 'fs/promises';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
24
|
+
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
25
|
+
|
|
26
|
+
/** Folders worth asking git about. A name here that git does not ignore is skipped. */
|
|
27
|
+
export const CARRY_CANDIDATES = [
|
|
28
|
+
'node_modules',
|
|
29
|
+
'.venv',
|
|
30
|
+
'venv',
|
|
31
|
+
'target',
|
|
32
|
+
'dist',
|
|
33
|
+
'build',
|
|
34
|
+
'out',
|
|
35
|
+
'.next',
|
|
36
|
+
'.nuxt',
|
|
37
|
+
'.turbo',
|
|
38
|
+
'.parcel-cache',
|
|
39
|
+
'.cache',
|
|
40
|
+
'vendor',
|
|
41
|
+
'__pycache__',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Files whose absence breaks a run and whose contents are not in git. */
|
|
45
|
+
export const CARRY_FILE_CANDIDATES = ['.env', '.env.local', '.env.development', '.env.production'];
|
|
46
|
+
|
|
47
|
+
export type CopyStrategy = 'clonefile' | 'reflink' | 'plain';
|
|
48
|
+
|
|
49
|
+
export function copyStrategy(platform: NodeJS.Platform = process.platform): CopyStrategy {
|
|
50
|
+
if (platform === 'darwin') return 'clonefile';
|
|
51
|
+
if (platform === 'linux') return 'reflink';
|
|
52
|
+
return 'plain';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CarryResult {
|
|
56
|
+
copied: string[];
|
|
57
|
+
/** Candidates git does not call ignored, or that are not there. */
|
|
58
|
+
skipped: string[];
|
|
59
|
+
strategy: CopyStrategy;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function isIgnored(repo: string, relative: string): Promise<boolean> {
|
|
63
|
+
const probe = await runGit(['check-ignore', '--quiet', '--', relative], { cwd: repo });
|
|
64
|
+
return probe.exitCode === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `cp -c` (APFS clonefile) / `cp --reflink=auto` / `cp -R`, plus a Windows branch. */
|
|
68
|
+
async function copyTree(source: string, destination: string, strategy: CopyStrategy): Promise<void> {
|
|
69
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
70
|
+
// A regular FILE (`.env`) is not a tree, and every branch below is a tree
|
|
71
|
+
// copy. `robocopy` takes directories: measured on a Windows runner, a `.env`
|
|
72
|
+
// source produced no file at the destination and no exit code the caller
|
|
73
|
+
// reads as a failure, so the carry reported success and the worktree had no
|
|
74
|
+
// env file — the one thing that path exists to prevent.
|
|
75
|
+
if (!statSync(source).isDirectory()) {
|
|
76
|
+
await copyFile(source, destination);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (process.platform === 'win32') {
|
|
80
|
+
const result = Bun.spawnSync({
|
|
81
|
+
cmd: ['robocopy', source, destination, '/MIR', '/NFL', '/NDL', '/NJH', '/NJS', '/NC', '/NS', '/NP'],
|
|
82
|
+
stdout: 'ignore',
|
|
83
|
+
stderr: 'pipe',
|
|
84
|
+
});
|
|
85
|
+
// robocopy reports 0–7 as success codes; anything from 8 up is a real failure.
|
|
86
|
+
if (result.exitCode !== null && result.exitCode >= 8) {
|
|
87
|
+
throw new Error(`Failed to copy ${path.basename(source)}: ${result.stderr.toString().trim()}`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const args = strategy === 'clonefile' ? ['-cR'] : strategy === 'reflink' ? ['--reflink=auto', '-R'] : ['-R'];
|
|
92
|
+
const result = Bun.spawnSync({ cmd: ['cp', ...args, source, destination], stdout: 'ignore', stderr: 'pipe' });
|
|
93
|
+
if (result.exitCode !== 0) {
|
|
94
|
+
throw new Error(`Failed to copy ${path.basename(source)}: ${result.stderr.toString().trim()}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Copy the project's ignored build folders and env files into a fresh worktree.
|
|
100
|
+
*
|
|
101
|
+
* A failure here is not a failed worktree: the caller reports what could not be
|
|
102
|
+
* carried and keeps the checkout, because a worktree without `node_modules` is
|
|
103
|
+
* still a worktree.
|
|
104
|
+
*/
|
|
105
|
+
export async function carryIgnoredInto(input: {
|
|
106
|
+
primaryRoot: string;
|
|
107
|
+
worktreePath: string;
|
|
108
|
+
candidates?: string[];
|
|
109
|
+
}): Promise<CarryResult> {
|
|
110
|
+
const strategy = copyStrategy();
|
|
111
|
+
const copied: string[] = [];
|
|
112
|
+
const skipped: string[] = [];
|
|
113
|
+
|
|
114
|
+
const candidates = [...(input.candidates ?? CARRY_CANDIDATES), ...CARRY_FILE_CANDIDATES];
|
|
115
|
+
for (const candidate of candidates) {
|
|
116
|
+
const source = path.join(input.primaryRoot, candidate);
|
|
117
|
+
if (!existsSync(source)) continue;
|
|
118
|
+
if (!(await isIgnored(input.primaryRoot, candidate))) {
|
|
119
|
+
skipped.push(candidate);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const destination = path.join(input.worktreePath, candidate);
|
|
123
|
+
// A tracked path already checked out in the worktree, or a directory that
|
|
124
|
+
// appeared meanwhile, is not ours to overwrite.
|
|
125
|
+
if (await pathExists(destination)) {
|
|
126
|
+
skipped.push(candidate);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
await copyTree(source, destination, strategy);
|
|
130
|
+
copied.push(candidate);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { copied, skipped, strategy };
|
|
134
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creating a worktree, against real repositories.
|
|
8
|
+
*
|
|
9
|
+
* The refusals are the point of this file. git creates the branch BEFORE it
|
|
10
|
+
* discovers the problem — measured on 2.56: `Preparing worktree` is printed,
|
|
11
|
+
* the branch exists, and only then does the command fail with "already checked
|
|
12
|
+
* out", "already exists", or (unborn) `object not found: HEAD`. A create that
|
|
13
|
+
* relied on git's own ordering would leave a branch behind for an operation the
|
|
14
|
+
* user was told had failed, so every check runs first and this file proves
|
|
15
|
+
* nothing is left over.
|
|
16
|
+
*
|
|
17
|
+
* The ignored-file carry is the second half: plain `git worktree add` does NOT
|
|
18
|
+
* copy `node_modules`, so a fresh worktree cannot run a project that worked a
|
|
19
|
+
* second earlier in the checkout.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
27
|
+
import { createWorktree, planWorktree, type CreateWorktreeDeps } from '@/server/lib/fs/worktree/create';
|
|
28
|
+
import { WorktreeError } from '@/server/lib/fs/worktree/errors';
|
|
29
|
+
import { listWorktrees } from '@/server/lib/fs/worktree/list';
|
|
30
|
+
import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
|
|
31
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
32
|
+
|
|
33
|
+
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-create-'));
|
|
34
|
+
const DATA_DIR = path.join(FIXTURE_ROOT, 'data');
|
|
35
|
+
|
|
36
|
+
function git(cwd: string, ...args: string[]): string {
|
|
37
|
+
const result = Bun.spawnSync({
|
|
38
|
+
cmd: ['git', ...args],
|
|
39
|
+
cwd,
|
|
40
|
+
env: { ...GIT_ENV_FOR_TESTS },
|
|
41
|
+
});
|
|
42
|
+
return result.stdout.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const GIT_ENV_FOR_TESTS = {
|
|
46
|
+
...Bun.env,
|
|
47
|
+
GIT_EDITOR: 'true',
|
|
48
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
49
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
50
|
+
GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
|
51
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
52
|
+
GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A repository with one commit, a gitignored `node_modules` and a `.env`. */
|
|
56
|
+
function makeRepo(name: string): string {
|
|
57
|
+
const dir = path.join(FIXTURE_ROOT, name);
|
|
58
|
+
fs.mkdirSync(path.join(dir, 'node_modules', 'dep'), { recursive: true });
|
|
59
|
+
fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules\n.env\n');
|
|
60
|
+
fs.writeFileSync(path.join(dir, 'node_modules', 'dep', 'index.js'), 'module.exports = {};\n');
|
|
61
|
+
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1\n');
|
|
62
|
+
fs.writeFileSync(path.join(dir, 'readme.md'), 'hello\n');
|
|
63
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
64
|
+
git(dir, 'add', '-A');
|
|
65
|
+
git(dir, 'commit', '-q', '-m', 'base');
|
|
66
|
+
return dir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeEmptyRepo(name: string): string {
|
|
70
|
+
const dir = path.join(FIXTURE_ROOT, name);
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
72
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The registry, as a Map — the production one is a SQLite row write. */
|
|
77
|
+
function depsFor(records: Map<string, { startRef: string; projectId: string }>): CreateWorktreeDeps {
|
|
78
|
+
return {
|
|
79
|
+
identityFor: async () => null,
|
|
80
|
+
carryIgnored: true,
|
|
81
|
+
dataDir: DATA_DIR,
|
|
82
|
+
configuredDirectory: null,
|
|
83
|
+
record: async (row) => {
|
|
84
|
+
records.set(await canonicalPath(row.path), { startRef: row.startRef, projectId: row.projectId });
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await invalidateWorktreeTopology();
|
|
91
|
+
removeDir(FIXTURE_ROOT);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('createWorktree', () => {
|
|
95
|
+
test('a new branch, a folder that follows it, and our prefix stripped', async () => {
|
|
96
|
+
const repo = makeRepo('create-basic');
|
|
97
|
+
const records = new Map<string, { startRef: string; projectId: string }>();
|
|
98
|
+
const created = await createWorktree(
|
|
99
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/cosmic-dolphin' },
|
|
100
|
+
depsFor(records),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(created.branch).toBe('wt/cosmic-dolphin');
|
|
104
|
+
expect(path.basename(created.path)).toBe('cosmic-dolphin');
|
|
105
|
+
expect(fs.existsSync(created.path)).toBe(true);
|
|
106
|
+
expect(git(created.path, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('wt/cosmic-dolphin');
|
|
107
|
+
// `start_ref` is the one value git cannot answer later, so it is recorded.
|
|
108
|
+
expect(records.get(await canonicalPath(created.path))?.startRef).toBe('HEAD');
|
|
109
|
+
// The default root is keyed by the repository's identity. Canonicalized
|
|
110
|
+
// because the fixture root itself resolves through a symlink on macOS.
|
|
111
|
+
expect(created.path.startsWith(await canonicalPath(path.join(DATA_DIR, 'worktrees')))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('ignored build folders and .env come along, and the worktree stays clean', async () => {
|
|
115
|
+
const repo = makeRepo('create-carry');
|
|
116
|
+
const created = await createWorktree(
|
|
117
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/carry' },
|
|
118
|
+
depsFor(new Map()),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(fs.existsSync(path.join(created.path, 'node_modules', 'dep', 'index.js'))).toBe(true);
|
|
122
|
+
expect(fs.readFileSync(path.join(created.path, '.env'), 'utf8')).toContain('SECRET');
|
|
123
|
+
expect(created.carried?.copied).toContain('node_modules');
|
|
124
|
+
expect(created.carried?.strategy).toBe(
|
|
125
|
+
process.platform === 'darwin' ? 'clonefile' : process.platform === 'linux' ? 'reflink' : 'plain',
|
|
126
|
+
);
|
|
127
|
+
// A carry must not make the new worktree dirty: `git status` there is what a
|
|
128
|
+
// commit from that tree would be built on.
|
|
129
|
+
const status = git(created.path, 'status', '--porcelain').trim();
|
|
130
|
+
expect(status).toBe('');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the carry can be turned off, and turns nothing else off', async () => {
|
|
134
|
+
const repo = makeRepo('create-no-carry');
|
|
135
|
+
const created = await createWorktree(
|
|
136
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/no-carry', carryIgnored: false },
|
|
137
|
+
depsFor(new Map()),
|
|
138
|
+
);
|
|
139
|
+
expect(fs.existsSync(path.join(created.path, 'node_modules'))).toBe(false);
|
|
140
|
+
expect(created.carried).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('an existing branch is checked out as it is, and gets no start ref', async () => {
|
|
144
|
+
const repo = makeRepo('create-existing');
|
|
145
|
+
git(repo, 'branch', 'free-branch');
|
|
146
|
+
const created = await createWorktree(
|
|
147
|
+
{ projectPath: repo, mode: 'existing', branch: 'free-branch' },
|
|
148
|
+
depsFor(new Map()),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(created.branch).toBe('free-branch');
|
|
152
|
+
expect(created.createdBranch).toBe(false);
|
|
153
|
+
expect(created.startRef).toBe('');
|
|
154
|
+
expect(git(created.path, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('free-branch');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('an existing branch already checked out is refused before anything is created', async () => {
|
|
158
|
+
const repo = makeRepo('create-in-use');
|
|
159
|
+
const failure = createWorktree(
|
|
160
|
+
{ projectPath: repo, mode: 'existing', branch: 'main' },
|
|
161
|
+
depsFor(new Map()),
|
|
162
|
+
);
|
|
163
|
+
await expect(failure).rejects.toThrow(/already checked out/i);
|
|
164
|
+
expect((await listWorktrees(repo)).filter((entry) => !entry.isPrimary).length).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('an unborn repository is refused with its own code', async () => {
|
|
168
|
+
const empty = makeEmptyRepo('create-unborn');
|
|
169
|
+
const failure = createWorktree({ projectPath: empty, mode: 'new', branch: 'wt/x' }, depsFor(new Map()));
|
|
170
|
+
await expect(failure).rejects.toBeInstanceOf(WorktreeError);
|
|
171
|
+
await failure.catch((error: WorktreeError) => expect(error.code).toBe('no_commits'));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a branch that already exists is refused, and leaves no second worktree', async () => {
|
|
175
|
+
const repo = makeRepo('create-dup');
|
|
176
|
+
const records = new Map<string, { startRef: string; projectId: string }>();
|
|
177
|
+
const first = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/dup' }, depsFor(records));
|
|
178
|
+
|
|
179
|
+
const failure = createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/dup' }, depsFor(records));
|
|
180
|
+
await failure.catch((error: WorktreeError) => expect(error.code).toBe('branch_exists'));
|
|
181
|
+
|
|
182
|
+
const entries = await listWorktrees(repo);
|
|
183
|
+
expect(entries.filter((entry) => !entry.isPrimary).length).toBe(1);
|
|
184
|
+
expect(entries.some((entry) => entry.path === first.path)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('an invalid branch name never reaches git', async () => {
|
|
188
|
+
const repo = makeRepo('create-bad-name');
|
|
189
|
+
const failure = createWorktree({ projectPath: repo, mode: 'new', branch: 'has space' }, depsFor(new Map()));
|
|
190
|
+
await failure.catch((error: WorktreeError) => expect(error.code).toBe('invalid_name'));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('a taken folder name is suffixed, never refused', async () => {
|
|
194
|
+
const repo = makeRepo('create-collide');
|
|
195
|
+
const records = new Map<string, { startRef: string; projectId: string }>();
|
|
196
|
+
const first = await createWorktree(
|
|
197
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/one', worktreeName: 'same' },
|
|
198
|
+
depsFor(records),
|
|
199
|
+
);
|
|
200
|
+
const second = await createWorktree(
|
|
201
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/two', worktreeName: 'same' },
|
|
202
|
+
depsFor(records),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(path.basename(first.path)).toBe('same');
|
|
206
|
+
expect(path.basename(second.path)).not.toBe('same');
|
|
207
|
+
expect(path.basename(second.path).startsWith('same-')).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a non-Latin branch gets a generated folder instead of an empty one', async () => {
|
|
211
|
+
const repo = makeRepo('create-cjk');
|
|
212
|
+
const created = await createWorktree(
|
|
213
|
+
{ projectPath: repo, mode: 'new', branch: '分支/测试' },
|
|
214
|
+
depsFor(new Map()),
|
|
215
|
+
);
|
|
216
|
+
expect(path.basename(created.path).length).toBeGreaterThan(0);
|
|
217
|
+
// The branch is what the caller typed, verbatim — only the FOLDER needed a
|
|
218
|
+
// generated name because the branch left no slug.
|
|
219
|
+
expect(created.branch).toBe('分支/测试');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('planWorktree', () => {
|
|
224
|
+
test('answers the same refusals without touching disk', async () => {
|
|
225
|
+
const repo = makeRepo('plan-none');
|
|
226
|
+
const plan = await planWorktree(
|
|
227
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/planned' },
|
|
228
|
+
{ identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: null },
|
|
229
|
+
);
|
|
230
|
+
expect(plan.branch).toBe('wt/planned');
|
|
231
|
+
expect(fs.existsSync(plan.directory)).toBe(false);
|
|
232
|
+
|
|
233
|
+
const dup = planWorktree(
|
|
234
|
+
{ projectPath: repo, mode: 'existing', branch: 'main' },
|
|
235
|
+
{ identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: null },
|
|
236
|
+
);
|
|
237
|
+
await dup.catch((error: WorktreeError) => expect(error.code).toBe('branch_in_use'));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('a configured directory wins over the data-dir default', async () => {
|
|
241
|
+
const repo = makeRepo('plan-configured');
|
|
242
|
+
const configured = path.join(FIXTURE_ROOT, 'elsewhere');
|
|
243
|
+
const plan = await planWorktree(
|
|
244
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/configured' },
|
|
245
|
+
{ identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: configured },
|
|
246
|
+
);
|
|
247
|
+
expect(plan.directory.startsWith(await canonicalPath(configured))).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a worktree, in the order that matters.
|
|
8
|
+
*
|
|
9
|
+
* Every refusal git would only raise mid-way is checked FIRST, because git
|
|
10
|
+
* creates the branch before it discovers the problem. Measured on git 2.56:
|
|
11
|
+
* `git worktree add` prints `Preparing worktree`, creates the branch, and only
|
|
12
|
+
* then fails with "already checked out", "already exists", or for an unborn
|
|
13
|
+
* branch `object not found: HEAD` — each leaving a branch behind for a create
|
|
14
|
+
* the user was told had failed.
|
|
15
|
+
*
|
|
16
|
+
* Two deliberate absences:
|
|
17
|
+
*
|
|
18
|
+
* - **no `--force`**, so git never overwrites a directory or steals a branch
|
|
19
|
+
* another worktree holds;
|
|
20
|
+
* - **no carry of uncommitted changes.** Their edits are indistinguishable from
|
|
21
|
+
* another session's in the same checkout, and a `stash`+`apply` is a
|
|
22
|
+
* repository-wide operation that can take them. The dialog says the changes
|
|
23
|
+
* stay put, and this is what makes that true.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { mkdir } from 'fs/promises';
|
|
28
|
+
import { getDataDir } from '@/server/lib/lifecycle/paths';
|
|
29
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
30
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
31
|
+
import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
|
|
32
|
+
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
33
|
+
import {
|
|
34
|
+
folderNameForBranch,
|
|
35
|
+
isPlausibleBranchName,
|
|
36
|
+
normalizeBranchName,
|
|
37
|
+
slugWorktreeName,
|
|
38
|
+
WORKTREE_BRANCH_PREFIX,
|
|
39
|
+
} from '@/shared/lib/workspace/worktree/branch-name';
|
|
40
|
+
import type { WorktreeInfo } from '@/shared/lib/workspace/worktree/types';
|
|
41
|
+
import { carryIgnoredInto, type CarryResult } from '@/server/lib/fs/worktree/carry';
|
|
42
|
+
import { WorktreeError } from '@/server/lib/fs/worktree/errors';
|
|
43
|
+
import {
|
|
44
|
+
defaultWorktreeRoot,
|
|
45
|
+
resolveConfiguredWorktreeRoot,
|
|
46
|
+
resolveTargetDirectory,
|
|
47
|
+
} from '@/server/lib/fs/worktree/location';
|
|
48
|
+
import { findBranchInUse, isWorkTree, refExists } from '@/server/lib/fs/worktree/list';
|
|
49
|
+
import { resolveProjectId } from '@/server/lib/fs/worktree/project-id';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The `-c` flags a create carries.
|
|
53
|
+
*
|
|
54
|
+
* `core.longpaths` is not cosmetic: the data-dir prefix plus a nested repository
|
|
55
|
+
* path exceeds Windows' 260-character MAX_PATH, and git then fails during the
|
|
56
|
+
* checkout with "Filename too long" and leaves a half-populated worktree.
|
|
57
|
+
* `core.hooksPath` keeps a repository's hooks out of a checkout Kabmo makes.
|
|
58
|
+
*/
|
|
59
|
+
export const WORKTREE_ADD_CONFIG = [
|
|
60
|
+
'core.longpaths=true',
|
|
61
|
+
`core.hooksPath=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export interface CreateWorktreeInput {
|
|
65
|
+
/** Absolute path of the project (or any directory inside the repository). */
|
|
66
|
+
projectPath: string;
|
|
67
|
+
mode: 'new' | 'existing';
|
|
68
|
+
/** The branch: free text in `new`, a pick in `existing`. Empty generates one. */
|
|
69
|
+
branch?: string;
|
|
70
|
+
/** The folder name from the dialog; empty follows the branch. */
|
|
71
|
+
worktreeName?: string;
|
|
72
|
+
startRef?: string;
|
|
73
|
+
/** Overrides the stored setting, which is what the dialog's Quick path does. */
|
|
74
|
+
carryIgnored?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CreatedWorktree extends WorktreeInfo {
|
|
78
|
+
projectId: string;
|
|
79
|
+
primaryRoot: string;
|
|
80
|
+
carried: CarryResult | null;
|
|
81
|
+
/** False for `existing`, where the branch already belonged to the repository. */
|
|
82
|
+
createdBranch: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CreateWorktreeDeps {
|
|
86
|
+
/** The identity cache, in production `worktreeIdentityFor(getDb(), …)`. */
|
|
87
|
+
identityFor: (primaryRoot: string) => Promise<string | null>;
|
|
88
|
+
/** The stored carry default; the request may override it. */
|
|
89
|
+
carryIgnored: boolean;
|
|
90
|
+
dataDir?: string;
|
|
91
|
+
configuredDirectory?: string | null;
|
|
92
|
+
/** Called with the finished row — the registry write, injected for tests. */
|
|
93
|
+
record: (row: {
|
|
94
|
+
path: string;
|
|
95
|
+
primaryRoot: string;
|
|
96
|
+
projectId: string;
|
|
97
|
+
branch: string;
|
|
98
|
+
startRef: string;
|
|
99
|
+
createdAt: string;
|
|
100
|
+
}) => Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function gitOk(args: string[], cwd: string): Promise<string> {
|
|
104
|
+
const result = await runGit(args, { cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
105
|
+
if (result.error) throw result.error;
|
|
106
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `git exited ${result.exitCode}`);
|
|
107
|
+
return result.stdout;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve the destination the create would use, without touching disk. */
|
|
111
|
+
export async function planWorktree(
|
|
112
|
+
input: CreateWorktreeInput,
|
|
113
|
+
deps: Pick<CreateWorktreeDeps, 'identityFor' | 'dataDir' | 'configuredDirectory'>,
|
|
114
|
+
): Promise<{ primaryRoot: string; projectId: string; root: string; name: string; directory: string; branch: string; startRef: string }> {
|
|
115
|
+
const projectPath = path.resolve(input.projectPath);
|
|
116
|
+
if (!(await isWorkTree(projectPath))) {
|
|
117
|
+
throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const primaryRoot = await resolveProjectRoot(projectPath);
|
|
121
|
+
const head = await runGit(['rev-parse', '--verify', 'HEAD'], { cwd: primaryRoot });
|
|
122
|
+
if (head.exitCode !== 0) {
|
|
123
|
+
throw new WorktreeError('no_commits', 'This repository has no commits yet.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const existing = input.mode === 'existing';
|
|
127
|
+
// The RAW name is validated, then normalized only by stripping a ref prefix.
|
|
128
|
+
// Normalizing first was a silent rewrite: `normalizeBranchName` turns spaces
|
|
129
|
+
// into dashes, so a typed `has space` became the valid `has-space` and the
|
|
130
|
+
// dialog created a branch the user never asked for.
|
|
131
|
+
const typed = (input.branch ?? '').trim();
|
|
132
|
+
const requested = normalizeBranchName(typed);
|
|
133
|
+
if (existing && !requested) {
|
|
134
|
+
throw new WorktreeError('invalid_name', 'Pick a branch to check out.', { field: 'branch' });
|
|
135
|
+
}
|
|
136
|
+
if (typed && !isPlausibleBranchName(typed)) {
|
|
137
|
+
throw new WorktreeError('invalid_name', `"${typed}" is not a valid branch name.`, { field: 'branch' });
|
|
138
|
+
}
|
|
139
|
+
if (requested) {
|
|
140
|
+
const exists = await refExists(primaryRoot, `refs/heads/${requested}`);
|
|
141
|
+
if (existing && !exists) {
|
|
142
|
+
throw new WorktreeError('branch_exists', `Branch ${requested} does not exist.`, { field: 'branch' });
|
|
143
|
+
}
|
|
144
|
+
if (!existing && exists) {
|
|
145
|
+
throw new WorktreeError('branch_exists', `Branch ${requested} already exists.`, { field: 'branch' });
|
|
146
|
+
}
|
|
147
|
+
const inUse = await findBranchInUse(primaryRoot, requested);
|
|
148
|
+
if (inUse) {
|
|
149
|
+
throw new WorktreeError('branch_in_use', `${requested} is already checked out in ${inUse.path}.`, {
|
|
150
|
+
field: 'branch',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const projectId = await resolveProjectId(primaryRoot, deps.identityFor);
|
|
156
|
+
const dataDir = deps.dataDir ?? getDataDir();
|
|
157
|
+
// Canonical from the start: the directory is compared against git's own paths
|
|
158
|
+
// and stored as a primary key, and two spellings of one folder must be one
|
|
159
|
+
// row (macOS: `/tmp` typed, `/private/tmp` reported).
|
|
160
|
+
const root = await canonicalPath(
|
|
161
|
+
resolveConfiguredWorktreeRoot(deps.configuredDirectory ?? null, primaryRoot)
|
|
162
|
+
?? defaultWorktreeRoot(dataDir, projectId),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// The folder follows the branch unless the dialog named one; a non-Latin
|
|
166
|
+
// branch leaves no slug, so a generated name stands in rather than an empty
|
|
167
|
+
// folder.
|
|
168
|
+
const candidateName = input.worktreeName?.trim() || folderNameForBranch(requested) || slugWorktreeName(requested);
|
|
169
|
+
const target = await resolveTargetDirectory({
|
|
170
|
+
root,
|
|
171
|
+
preferredName: candidateName,
|
|
172
|
+
// The branch the user typed is used VERBATIM in both modes; only an empty
|
|
173
|
+
// one generates. Passing it as generated for `new` renamed a typed
|
|
174
|
+
// `分支/测试` to `wt/lucid-toucan` — measured, and the caller never sees the
|
|
175
|
+
// branch it asked for.
|
|
176
|
+
explicitBranch: requested || null,
|
|
177
|
+
generatedBranch: (name) => `${WORKTREE_BRANCH_PREFIX}/${name}`,
|
|
178
|
+
branchExists: async (branch) => Boolean(await findBranchInUse(primaryRoot, branch)) || refExists(primaryRoot, `refs/heads/${branch}`),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const startRef = input.startRef?.trim() || 'HEAD';
|
|
182
|
+
return { primaryRoot, projectId, root, name: target.name, directory: target.directory, branch: target.branch, startRef };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Create the worktree, carry the ignored build folders, and record the row. */
|
|
186
|
+
export async function createWorktree(
|
|
187
|
+
input: CreateWorktreeInput,
|
|
188
|
+
deps: CreateWorktreeDeps,
|
|
189
|
+
): Promise<CreatedWorktree> {
|
|
190
|
+
const plan = await planWorktree(input, deps);
|
|
191
|
+
const existing = input.mode === 'existing';
|
|
192
|
+
const carry = input.carryIgnored ?? deps.carryIgnored;
|
|
193
|
+
|
|
194
|
+
await mkdir(plan.root, { recursive: true });
|
|
195
|
+
|
|
196
|
+
// git's grammar is `worktree add [--no-checkout] [-b <new>] <path> [<commit-ish>]`:
|
|
197
|
+
// an EXISTING branch is the commit-ish and therefore follows the path.
|
|
198
|
+
// Measured: putting the branch first is read as the path and git answers
|
|
199
|
+
// `fatal: invalid reference: <the path we meant to use>`.
|
|
200
|
+
// ONE `-c` per pair: `['-c', a, b, 'worktree']` makes git read `b` as the
|
|
201
|
+
// SUBCOMMAND, and it answers `git: 'core.hooksPath=/dev/null' is not a git
|
|
202
|
+
// command` — measured, and every create failed on it.
|
|
203
|
+
const configArgs = WORKTREE_ADD_CONFIG.flatMap((pair) => ['-c', pair]);
|
|
204
|
+
const args = [...configArgs, 'worktree', 'add', '--no-checkout'];
|
|
205
|
+
if (existing) {
|
|
206
|
+
args.push(plan.directory, plan.branch);
|
|
207
|
+
} else {
|
|
208
|
+
args.push('-b', plan.branch, plan.directory);
|
|
209
|
+
if (plan.startRef !== 'HEAD') args.push(plan.startRef);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
await gitOk(args, plan.primaryRoot);
|
|
214
|
+
// `--no-checkout` keeps the add cheap; the same flags then populate it, so a
|
|
215
|
+
// path that cannot be checked out fails HERE and is reported.
|
|
216
|
+
await gitOk([...configArgs, 'checkout'], plan.directory);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// A failed create leaves nothing behind: the branch was ours, so it goes.
|
|
219
|
+
if (!existing) {
|
|
220
|
+
await runGit(['branch', '-D', plan.branch], { cwd: plan.primaryRoot });
|
|
221
|
+
}
|
|
222
|
+
throw new WorktreeError('create_failed', error instanceof Error ? error.message : String(error));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let carried: CarryResult | null = null;
|
|
226
|
+
if (carry) {
|
|
227
|
+
try {
|
|
228
|
+
carried = await carryIgnoredInto({ primaryRoot: plan.primaryRoot, worktreePath: plan.directory });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
// Reported, not fatal: a worktree without node_modules is still a worktree.
|
|
231
|
+
carried = { copied: [], skipped: [], strategy: 'plain' };
|
|
232
|
+
void error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const createdAt = new Date().toISOString();
|
|
237
|
+
await deps.record({
|
|
238
|
+
path: plan.directory,
|
|
239
|
+
primaryRoot: plan.primaryRoot,
|
|
240
|
+
projectId: plan.projectId,
|
|
241
|
+
branch: plan.branch,
|
|
242
|
+
startRef: existing ? '' : plan.startRef,
|
|
243
|
+
createdAt,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
path: plan.directory,
|
|
248
|
+
branch: plan.branch,
|
|
249
|
+
head: (await runGit(['rev-parse', 'HEAD'], { cwd: plan.directory })).stdout.trim(),
|
|
250
|
+
detached: false,
|
|
251
|
+
missing: !(await pathExists(plan.directory)),
|
|
252
|
+
isPrimary: false,
|
|
253
|
+
dirty: 0,
|
|
254
|
+
aheadOfStart: 0,
|
|
255
|
+
startRef: existing ? '' : plan.startRef,
|
|
256
|
+
sessions: 0,
|
|
257
|
+
projectId: plan.projectId,
|
|
258
|
+
primaryRoot: plan.primaryRoot,
|
|
259
|
+
carried,
|
|
260
|
+
createdBranch: !existing,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A worktree refusal, carrying the code and the field it belongs to.
|
|
8
|
+
*
|
|
9
|
+
* The route answers `{ error, code, field }` from this, so the dialog can mark
|
|
10
|
+
* the input that caused it instead of printing one sentence under the buttons.
|
|
11
|
+
* Codes are stable; messages are for people.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { WorktreeErrorCode, WorktreeFailure } from '@/shared/lib/workspace/worktree/types';
|
|
15
|
+
|
|
16
|
+
export class WorktreeError extends Error {
|
|
17
|
+
readonly code: WorktreeErrorCode;
|
|
18
|
+
readonly field?: WorktreeFailure['field'];
|
|
19
|
+
readonly count?: number;
|
|
20
|
+
|
|
21
|
+
constructor(code: WorktreeErrorCode, message: string, options: { field?: WorktreeFailure['field']; count?: number } = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'WorktreeError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.field = options.field;
|
|
26
|
+
this.count = options.count;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
toFailure(): WorktreeFailure {
|
|
30
|
+
return {
|
|
31
|
+
code: this.code,
|
|
32
|
+
message: this.message,
|
|
33
|
+
...(this.field ? { field: this.field } : {}),
|
|
34
|
+
...(this.count !== undefined ? { count: this.count } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Turn anything thrown into a failure, so an unexpected error is still typed. */
|
|
40
|
+
export function asWorktreeFailure(error: unknown): WorktreeFailure {
|
|
41
|
+
if (error instanceof WorktreeError) return error.toFailure();
|
|
42
|
+
return { code: 'create_failed', message: error instanceof Error ? error.message : String(error) };
|
|
43
|
+
}
|