@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,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worktree listing, against real repositories.
|
|
8
|
+
*
|
|
9
|
+
* Three answers here cannot be faked by a hand-written porcelain string, and
|
|
10
|
+
* each is a case that has to keep working:
|
|
11
|
+
*
|
|
12
|
+
* - a linked worktree is NOT the primary, and a deleted directory is `missing`
|
|
13
|
+
* while its entry stays in the list (the sessions that lived there must keep
|
|
14
|
+
* their row);
|
|
15
|
+
* - two spellings of one directory reconcile — on macOS git prints
|
|
16
|
+
* `/private/tmp/...` for a path the caller typed as `/tmp/...`, and a
|
|
17
|
+
* comparison that ignored that decided a worktree was not inside itself;
|
|
18
|
+
* - the session → worktree lookup picks the INNER worktree, because that is
|
|
19
|
+
* the directory the agent writes to.
|
|
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, pathHolds } from '@/server/lib/fs/canonical';
|
|
27
|
+
import { findBranchInUse, listWorktrees, parseWorktreePorcelain } from '@/server/lib/fs/worktree/list';
|
|
28
|
+
import { invalidateWorktreeTopology, readProjectWorktrees, worktreeForCwd } from '@/server/lib/fs/worktree/topology';
|
|
29
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
30
|
+
|
|
31
|
+
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-list-'));
|
|
32
|
+
|
|
33
|
+
function git(cwd: string, ...args: string[]): string {
|
|
34
|
+
const result = Bun.spawnSync({
|
|
35
|
+
cmd: ['git', ...args],
|
|
36
|
+
cwd,
|
|
37
|
+
env: { ...Bun.env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' },
|
|
38
|
+
});
|
|
39
|
+
return result.stdout.toString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeRepo(name: string): string {
|
|
43
|
+
const dir = path.join(FIXTURE_ROOT, name);
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
46
|
+
git(dir, 'config', 'user.email', 'test@example.invalid');
|
|
47
|
+
git(dir, 'config', 'user.name', 'Test');
|
|
48
|
+
fs.writeFileSync(path.join(dir, 'f.txt'), 'a\n');
|
|
49
|
+
git(dir, 'add', '-A');
|
|
50
|
+
git(dir, 'commit', '-q', '-m', 'base');
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await invalidateWorktreeTopology();
|
|
56
|
+
removeDir(FIXTURE_ROOT);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('parseWorktreePorcelain', () => {
|
|
60
|
+
const PORCELAIN = [
|
|
61
|
+
'worktree /repo',
|
|
62
|
+
'HEAD aaa',
|
|
63
|
+
'branch refs/heads/main',
|
|
64
|
+
'',
|
|
65
|
+
'worktree /wt/detached',
|
|
66
|
+
'HEAD bbb',
|
|
67
|
+
'detached',
|
|
68
|
+
'',
|
|
69
|
+
'worktree /wt/gone',
|
|
70
|
+
'HEAD ccc',
|
|
71
|
+
'branch refs/heads/feat',
|
|
72
|
+
'prunable gitdir file points to non-existent location',
|
|
73
|
+
'',
|
|
74
|
+
'worktree /bare',
|
|
75
|
+
'bare',
|
|
76
|
+
'',
|
|
77
|
+
].join('\n');
|
|
78
|
+
|
|
79
|
+
test('reads the branch, a detached HEAD, a prunable entry, and drops a bare repo', () => {
|
|
80
|
+
const entries = parseWorktreePorcelain(PORCELAIN);
|
|
81
|
+
expect(entries.map((entry) => entry.path)).toEqual(['/repo', '/wt/detached', '/wt/gone']);
|
|
82
|
+
expect(entries[0]?.branch).toBe('main');
|
|
83
|
+
expect(entries[1]?.detached).toBe(true);
|
|
84
|
+
expect(entries[1]?.branch).toBe('');
|
|
85
|
+
expect(entries[2]?.missing).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a bare repository is not a checkout anything can run in', () => {
|
|
89
|
+
expect(parseWorktreePorcelain(PORCELAIN).some((entry) => entry.path === '/bare')).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('listWorktrees', () => {
|
|
94
|
+
test('flags the primary and a linked worktree apart', async () => {
|
|
95
|
+
const repo = makeRepo('list-basic');
|
|
96
|
+
const linked = path.join(FIXTURE_ROOT, 'list-basic-wt');
|
|
97
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'feat', linked);
|
|
98
|
+
|
|
99
|
+
const entries = await listWorktrees(repo);
|
|
100
|
+
const primary = entries.find((entry) => entry.isPrimary);
|
|
101
|
+
const other = entries.find((entry) => !entry.isPrimary);
|
|
102
|
+
expect(primary?.path).toBe(await canonicalPath(repo));
|
|
103
|
+
expect(other?.branch).toBe('feat');
|
|
104
|
+
expect(other?.missing).toBe(false);
|
|
105
|
+
// Paths come back canonical, whichever spelling git was handed.
|
|
106
|
+
expect(other?.path).toBe(await canonicalPath(linked));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a directory deleted outside git stays listed as missing', async () => {
|
|
110
|
+
const repo = makeRepo('list-missing');
|
|
111
|
+
const linked = path.join(FIXTURE_ROOT, 'list-missing-wt');
|
|
112
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'gone', linked);
|
|
113
|
+
fs.rmSync(linked, { recursive: true, force: true });
|
|
114
|
+
|
|
115
|
+
const entries = await listWorktrees(repo);
|
|
116
|
+
const entry = entries.find((candidate) => !candidate.isPrimary);
|
|
117
|
+
expect(entry?.missing).toBe(true);
|
|
118
|
+
// And the missing path still canonicalizes, so it can be matched at all.
|
|
119
|
+
expect(entry?.path).toBe(await canonicalPath(linked));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('findBranchInUse names the worktree that holds a branch', async () => {
|
|
123
|
+
const repo = makeRepo('list-in-use');
|
|
124
|
+
const linked = path.join(FIXTURE_ROOT, 'list-in-use-wt');
|
|
125
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'held', linked);
|
|
126
|
+
|
|
127
|
+
const hit = await findBranchInUse(repo, 'held');
|
|
128
|
+
expect(hit?.path).toBe(await canonicalPath(linked));
|
|
129
|
+
expect(await findBranchInUse(repo, 'free')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('canonical paths', () => {
|
|
134
|
+
test('a sibling sharing a prefix is not inside', async () => {
|
|
135
|
+
const base = path.join(FIXTURE_ROOT, 'holds');
|
|
136
|
+
fs.mkdirSync(path.join(base, 'repo', 'src'), { recursive: true });
|
|
137
|
+
fs.mkdirSync(path.join(base, 'repo-other'), { recursive: true });
|
|
138
|
+
|
|
139
|
+
expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo'))).toBe(true);
|
|
140
|
+
expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo', 'src'))).toBe(true);
|
|
141
|
+
expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo-other'))).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a MISSING path resolves through its nearest existing ancestor', async () => {
|
|
145
|
+
const base = path.join(FIXTURE_ROOT, 'canonical-missing');
|
|
146
|
+
fs.mkdirSync(base, { recursive: true });
|
|
147
|
+
const missing = path.join(base, 'gone', 'deeper');
|
|
148
|
+
expect(await canonicalPath(missing)).toBe(path.join(await canonicalPath(base), 'gone', 'deeper'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('`..` cannot escape', async () => {
|
|
152
|
+
const base = path.join(FIXTURE_ROOT, 'canonical-escape');
|
|
153
|
+
fs.mkdirSync(base, { recursive: true });
|
|
154
|
+
expect(await pathHolds(base, path.join(base, '..', 'elsewhere'))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('session → worktree', () => {
|
|
159
|
+
test('an inner worktree wins over the checkout that contains it', async () => {
|
|
160
|
+
const repo = makeRepo('topology');
|
|
161
|
+
const outer = path.join(FIXTURE_ROOT, 'topology-outer');
|
|
162
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'outer', outer);
|
|
163
|
+
fs.mkdirSync(path.join(outer, 'sub'), { recursive: true });
|
|
164
|
+
|
|
165
|
+
const entries = await listWorktrees(repo);
|
|
166
|
+
const primary = entries.find((entry) => entry.isPrimary)?.path as string;
|
|
167
|
+
|
|
168
|
+
const inner = await worktreeForCwd(entries, path.join(outer, 'sub'), primary);
|
|
169
|
+
expect(inner?.path).toBe(await canonicalPath(outer));
|
|
170
|
+
expect(inner?.branch).toBe('outer');
|
|
171
|
+
expect(inner?.status).toBe('ready');
|
|
172
|
+
|
|
173
|
+
// A session in the checkout itself has no badge — it is not a worktree session.
|
|
174
|
+
expect(await worktreeForCwd(entries, primary, primary)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a missing worktree still answers, with status `missing`', async () => {
|
|
178
|
+
const repo = makeRepo('topology-missing');
|
|
179
|
+
const linked = path.join(FIXTURE_ROOT, 'topology-missing-wt');
|
|
180
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'vanish', linked);
|
|
181
|
+
fs.rmSync(linked, { recursive: true, force: true });
|
|
182
|
+
|
|
183
|
+
const entries = await listWorktrees(repo);
|
|
184
|
+
const primary = entries.find((entry) => entry.isPrimary)?.path as string;
|
|
185
|
+
const badge = await worktreeForCwd(entries, linked, primary);
|
|
186
|
+
expect(badge?.status).toBe('missing');
|
|
187
|
+
expect(badge?.branch).toBe('vanish');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('the topology read is cached, and invalidation drops it', async () => {
|
|
191
|
+
const repo = makeRepo('topology-cache');
|
|
192
|
+
const first = await readProjectWorktrees(repo);
|
|
193
|
+
const second = await readProjectWorktrees(repo);
|
|
194
|
+
expect(second).toBe(first); // same array identity — a real cache hit
|
|
195
|
+
|
|
196
|
+
const linked = path.join(FIXTURE_ROOT, 'topology-cache-wt');
|
|
197
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'new-one', linked);
|
|
198
|
+
// Cached: the new worktree is not visible yet.
|
|
199
|
+
expect((await readProjectWorktrees(repo)).length).toBe(1);
|
|
200
|
+
|
|
201
|
+
// AWAITED: the invalidation resolves the canonical key asynchronously, so an
|
|
202
|
+
// un-awaited call races the read below — measured on both CI legs, where the
|
|
203
|
+
// read won and the cache still answered one entry.
|
|
204
|
+
await invalidateWorktreeTopology(await canonicalPath(repo));
|
|
205
|
+
expect((await readProjectWorktrees(repo)).length).toBe(2);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `git worktree list --porcelain`, parsed into rows.
|
|
8
|
+
*
|
|
9
|
+
* The two markers a naive parse loses, and both matter to a reader:
|
|
10
|
+
*
|
|
11
|
+
* - **no `branch` line** → detached HEAD, which the row shows as `detached @
|
|
12
|
+
* <sha>` and the delete dialog treats as not-a-branch;
|
|
13
|
+
* - **`prunable`** → the directory is gone or the gitdir pointer is broken. The
|
|
14
|
+
* entry STAYS in the list: the sessions that lived there must keep their row,
|
|
15
|
+
* and dropping it would hide them with no way back.
|
|
16
|
+
*
|
|
17
|
+
* Paths are canonicalized here, once, for every consumer — git echoes the
|
|
18
|
+
* spelling it was given while the repository root resolves through symlinks, and
|
|
19
|
+
* two spellings of one directory must not read as two worktrees.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync } from 'fs';
|
|
23
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
24
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
25
|
+
import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
|
|
26
|
+
|
|
27
|
+
export interface WorktreeEntry {
|
|
28
|
+
path: string;
|
|
29
|
+
/** `refs/heads/` stripped; `''` when detached. */
|
|
30
|
+
branch: string;
|
|
31
|
+
head: string;
|
|
32
|
+
detached: boolean;
|
|
33
|
+
/** git reports it prunable, or the directory is not there. */
|
|
34
|
+
missing: boolean;
|
|
35
|
+
/** The checkout the worktree was created from — never removable. */
|
|
36
|
+
isPrimary: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Split on blank lines; a bare repository is not a checkout anything can run in. */
|
|
40
|
+
export function parseWorktreePorcelain(stdout: string): Omit<WorktreeEntry, 'isPrimary'>[] {
|
|
41
|
+
interface Pending {
|
|
42
|
+
path: string;
|
|
43
|
+
branch?: string;
|
|
44
|
+
head?: string;
|
|
45
|
+
missing: boolean;
|
|
46
|
+
bare: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const entries: Omit<WorktreeEntry, 'isPrimary'>[] = [];
|
|
50
|
+
let current: Pending | null = null;
|
|
51
|
+
|
|
52
|
+
const flush = () => {
|
|
53
|
+
if (!current?.path || current.bare) {
|
|
54
|
+
current = null;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const branch = current.branch ?? '';
|
|
58
|
+
const head = current.head ?? '';
|
|
59
|
+
entries.push({
|
|
60
|
+
path: current.path,
|
|
61
|
+
branch,
|
|
62
|
+
head,
|
|
63
|
+
detached: !branch && Boolean(head),
|
|
64
|
+
missing: current.missing,
|
|
65
|
+
});
|
|
66
|
+
current = null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
for (const rawLine of stdout.split('\n')) {
|
|
70
|
+
const line = rawLine.trimEnd();
|
|
71
|
+
if (!line) {
|
|
72
|
+
flush();
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (line.startsWith('worktree ')) {
|
|
76
|
+
flush();
|
|
77
|
+
current = { path: line.slice('worktree '.length).trim(), missing: false, bare: false };
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!current) continue;
|
|
81
|
+
if (line.startsWith('HEAD ')) {
|
|
82
|
+
current.head = line.slice('HEAD '.length).trim();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (line.startsWith('branch ')) {
|
|
86
|
+
current.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (line === 'bare') current.bare = true;
|
|
90
|
+
if (line === 'prunable' || line.startsWith('prunable ')) current.missing = true;
|
|
91
|
+
}
|
|
92
|
+
flush();
|
|
93
|
+
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every worktree of the repository `cwd` belongs to, primary first. */
|
|
98
|
+
export async function listWorktrees(cwd: string): Promise<WorktreeEntry[]> {
|
|
99
|
+
const primaryRoot = await resolveProjectRoot(cwd);
|
|
100
|
+
const listed = await runGit(['worktree', 'list', '--porcelain'], { cwd: primaryRoot, maxBuffer: 1024 * 1024 });
|
|
101
|
+
if (listed.exitCode !== 0) throw new Error(listed.stderr.trim() || 'Failed to list git worktrees');
|
|
102
|
+
|
|
103
|
+
const canonicalPrimary = await canonicalPath(primaryRoot);
|
|
104
|
+
const entries: WorktreeEntry[] = [];
|
|
105
|
+
for (const entry of parseWorktreePorcelain(listed.stdout)) {
|
|
106
|
+
const canonical = await canonicalPath(entry.path);
|
|
107
|
+
entries.push({
|
|
108
|
+
...entry,
|
|
109
|
+
path: canonical,
|
|
110
|
+
isPrimary: canonical === canonicalPrimary,
|
|
111
|
+
// A directory removed without `prune` answers `existsSync` false, and both
|
|
112
|
+
// signals mean the same thing to a reader.
|
|
113
|
+
missing: entry.missing || !existsSync(canonical),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether `cwd` is inside a work tree at all.
|
|
121
|
+
*
|
|
122
|
+
* The check a plain directory fails, and the one every caller here needs before
|
|
123
|
+
* it may talk about worktrees: `git worktree list` in a non-repository exits
|
|
124
|
+
* non-zero, so without this a notes folder bound as a workspace reads as a
|
|
125
|
+
* BROKEN repository rather than as one that has none.
|
|
126
|
+
*/
|
|
127
|
+
export async function isWorkTree(cwd: string): Promise<boolean> {
|
|
128
|
+
const probe = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd });
|
|
129
|
+
return probe.exitCode === 0 && probe.stdout.trim() === 'true';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether `branch` is checked out in any worktree of this repository, and where. */
|
|
133
|
+
export async function findBranchInUse(cwd: string, branch: string): Promise<{ path: string } | null> {
|
|
134
|
+
const entries = await listWorktrees(cwd);
|
|
135
|
+
const hit = entries.find((entry) => entry.branch === branch);
|
|
136
|
+
return hit ? { path: hit.path } : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function refExists(cwd: string, ref: string): Promise<boolean> {
|
|
140
|
+
const probe = await runGit(['show-ref', '--verify', '--quiet', ref], { cwd });
|
|
141
|
+
return probe.exitCode === 0;
|
|
142
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Repository identity, and the settings-driven location rule.
|
|
8
|
+
*
|
|
9
|
+
* Identity has one property that is easy to get wrong and expensive when it is:
|
|
10
|
+
* the derivation (`rev-list --max-parents=0 --all`, sorted, first) is NOT stable.
|
|
11
|
+
* Measured: a repository that gains a branch with unrelated history inserts a
|
|
12
|
+
* second root and the sorted-first value MOVES, so a repository would start
|
|
13
|
+
* looking for its worktrees in a different folder. That is why a stored value
|
|
14
|
+
* always wins — the behavior this file pins.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
22
|
+
import { deriveProjectId, resolveProjectId } from '@/server/lib/fs/worktree/project-id';
|
|
23
|
+
import {
|
|
24
|
+
defaultWorktreeRoot,
|
|
25
|
+
generateAvailableName,
|
|
26
|
+
resolveConfiguredWorktreeRoot,
|
|
27
|
+
resolveTargetDirectory,
|
|
28
|
+
worktreeNameCandidates,
|
|
29
|
+
} from '@/server/lib/fs/worktree/location';
|
|
30
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
31
|
+
|
|
32
|
+
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-location-'));
|
|
33
|
+
|
|
34
|
+
const GIT_ENV_FOR_TESTS = {
|
|
35
|
+
...Bun.env,
|
|
36
|
+
GIT_EDITOR: 'true',
|
|
37
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
38
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
39
|
+
GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
|
40
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
41
|
+
GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function git(cwd: string, ...args: string[]): string {
|
|
45
|
+
return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV_FOR_TESTS }).stdout.toString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeRepo(name: string): string {
|
|
49
|
+
const dir = path.join(FIXTURE_ROOT, name);
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
52
|
+
fs.writeFileSync(path.join(dir, 'f.txt'), 'a\n');
|
|
53
|
+
git(dir, 'add', '-A');
|
|
54
|
+
git(dir, 'commit', '-q', '-m', 'base');
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
removeDir(FIXTURE_ROOT);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('deriveProjectId', () => {
|
|
63
|
+
test('two clones of one repository share the identity', async () => {
|
|
64
|
+
const bare = path.join(FIXTURE_ROOT, 'origin.git');
|
|
65
|
+
fs.mkdirSync(bare, { recursive: true });
|
|
66
|
+
git(bare, 'init', '-q', '--bare');
|
|
67
|
+
|
|
68
|
+
const first = makeRepo('clone-a');
|
|
69
|
+
git(first, 'remote', 'add', 'origin', bare);
|
|
70
|
+
const head = git(first, 'rev-parse', 'HEAD').trim();
|
|
71
|
+
git(first, 'push', '-q', 'origin', `${head}:refs/heads/main`);
|
|
72
|
+
|
|
73
|
+
const second = path.join(FIXTURE_ROOT, 'clone-b');
|
|
74
|
+
git(FIXTURE_ROOT, 'clone', '-q', bare, second);
|
|
75
|
+
|
|
76
|
+
const a = await deriveProjectId(first);
|
|
77
|
+
const b = await deriveProjectId(second);
|
|
78
|
+
expect(a).not.toBeNull();
|
|
79
|
+
expect(a).toBe(b);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an unborn repository has no identity — the `no_commits` refusal', async () => {
|
|
83
|
+
const empty = path.join(FIXTURE_ROOT, 'unborn');
|
|
84
|
+
fs.mkdirSync(empty, { recursive: true });
|
|
85
|
+
git(empty, 'init', '-q', '-b', 'main', '.');
|
|
86
|
+
expect(await deriveProjectId(empty)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a stored identity wins over a re-derivation', async () => {
|
|
90
|
+
const repo = makeRepo('identity-stored');
|
|
91
|
+
const stored = 'stored-project-id';
|
|
92
|
+
const identityFor = async () => stored;
|
|
93
|
+
expect(await resolveProjectId(await canonicalPath(repo), identityFor)).toBe(stored);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an added orphan branch changes the derivation — hence the cache', async () => {
|
|
97
|
+
const repo = makeRepo('identity-orphan');
|
|
98
|
+
const derived = await deriveProjectId(repo);
|
|
99
|
+
expect(derived).not.toBeNull();
|
|
100
|
+
|
|
101
|
+
git(repo, 'checkout', '-q', '--orphan', 'other');
|
|
102
|
+
git(repo, 'rm', '-rq', '--cached', '.');
|
|
103
|
+
git(repo, 'add', '-A');
|
|
104
|
+
git(repo, 'commit', '-q', '-m', 'other root');
|
|
105
|
+
|
|
106
|
+
// Two roots now exist, so the sorted-first value is a different question
|
|
107
|
+
// than it was — which is exactly why the stored value is consulted first.
|
|
108
|
+
const roots = git(repo, 'rev-list', '--max-parents=0', '--all')
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((line) => line.trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
expect(roots.length).toBe(2);
|
|
113
|
+
|
|
114
|
+
// The stored value is consulted first, whatever the derivation now answers.
|
|
115
|
+
const stored = derived as string;
|
|
116
|
+
const later = await resolveProjectId(await canonicalPath(repo), async () => stored);
|
|
117
|
+
expect(later).toBe(stored);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('worktree location', () => {
|
|
122
|
+
// A literal POSIX spelling is not portable: `path.resolve('/repo', '.worktrees')`
|
|
123
|
+
// answers `D:\repo\.worktrees` on a Windows runner, which is the CORRECT
|
|
124
|
+
// answer for that platform and not a product bug. Every expectation is
|
|
125
|
+
// therefore built through `path`, so the rule is asserted rather than the
|
|
126
|
+
// separator.
|
|
127
|
+
const checkout = path.resolve('/repo');
|
|
128
|
+
const home = path.resolve('/home/u');
|
|
129
|
+
|
|
130
|
+
test('three spellings, and the resolved value is always the parent', () => {
|
|
131
|
+
expect(resolveConfiguredWorktreeRoot('', checkout)).toBeNull();
|
|
132
|
+
expect(resolveConfiguredWorktreeRoot(null, checkout)).toBeNull();
|
|
133
|
+
expect(resolveConfiguredWorktreeRoot(' ', checkout)).toBeNull();
|
|
134
|
+
expect(resolveConfiguredWorktreeRoot('../wt', checkout)).toBe(path.resolve(checkout, '..', 'wt'));
|
|
135
|
+
expect(resolveConfiguredWorktreeRoot('.worktrees', checkout)).toBe(path.join(checkout, '.worktrees'));
|
|
136
|
+
expect(resolveConfiguredWorktreeRoot('/abs/wt', checkout)).toBe(path.resolve(checkout, '/abs/wt'));
|
|
137
|
+
expect(resolveConfiguredWorktreeRoot('~/wt', checkout, home)).toBe(path.join(home, 'wt'));
|
|
138
|
+
expect(resolveConfiguredWorktreeRoot('~', checkout, home)).toBe(home);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the default root is keyed per repository, outside every checkout', () => {
|
|
142
|
+
expect(defaultWorktreeRoot('/data', 'abc123')).toBe(path.join('/data', 'worktrees', 'abc123'));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('the slug is tried first, then slug-word-word', () => {
|
|
146
|
+
const candidates = worktreeNameCandidates('quick-otter');
|
|
147
|
+
expect(candidates[0]).toBe('quick-otter');
|
|
148
|
+
expect(candidates[1]).toMatch(/^quick-otter-[a-z]+-[a-z]+$/);
|
|
149
|
+
expect(candidates.length).toBe(26);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a taken folder moves to the next candidate rather than refusing', async () => {
|
|
153
|
+
const root = path.join(FIXTURE_ROOT, 'collide');
|
|
154
|
+
fs.mkdirSync(path.join(root, 'quick-otter'), { recursive: true });
|
|
155
|
+
|
|
156
|
+
const target = await resolveTargetDirectory({
|
|
157
|
+
root,
|
|
158
|
+
preferredName: 'quick-otter',
|
|
159
|
+
generatedBranch: (name) => `wt/${name}`,
|
|
160
|
+
branchExists: async () => false,
|
|
161
|
+
});
|
|
162
|
+
expect(target.directory).not.toBe(path.join(root, 'quick-otter'));
|
|
163
|
+
expect(target.name.startsWith('quick-otter-')).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a taken BRANCH is skipped too, so folder and branch never disagree', async () => {
|
|
167
|
+
const root = path.join(FIXTURE_ROOT, 'branch-collide');
|
|
168
|
+
fs.mkdirSync(root, { recursive: true });
|
|
169
|
+
|
|
170
|
+
const target = await resolveTargetDirectory({
|
|
171
|
+
root,
|
|
172
|
+
preferredName: 'quick-otter',
|
|
173
|
+
generatedBranch: (name) => `wt/${name}`,
|
|
174
|
+
branchExists: async (branch) => branch === 'wt/quick-otter',
|
|
175
|
+
});
|
|
176
|
+
expect(target.branch).not.toBe('wt/quick-otter');
|
|
177
|
+
expect(target.branch).toBe(`wt/${target.name}`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a typed branch is used verbatim in both modes', async () => {
|
|
181
|
+
const root = path.join(FIXTURE_ROOT, 'verbatim');
|
|
182
|
+
fs.mkdirSync(root, { recursive: true });
|
|
183
|
+
const target = await resolveTargetDirectory({
|
|
184
|
+
root,
|
|
185
|
+
preferredName: 'payments',
|
|
186
|
+
explicitBranch: 'feat/payments',
|
|
187
|
+
generatedBranch: (name) => `wt/${name}`,
|
|
188
|
+
// Even when every generated name is taken, the typed branch is used.
|
|
189
|
+
branchExists: async () => true,
|
|
190
|
+
});
|
|
191
|
+
expect(target.branch).toBe('feat/payments');
|
|
192
|
+
expect(target.name).toBe('payments');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a generated name avoids both a folder and a branch', async () => {
|
|
196
|
+
const root = path.join(FIXTURE_ROOT, 'generated');
|
|
197
|
+
fs.mkdirSync(root, { recursive: true });
|
|
198
|
+
const name = await generateAvailableName({
|
|
199
|
+
root,
|
|
200
|
+
branchExists: async (branch) => branch === 'wt/blocked',
|
|
201
|
+
attempts: 4,
|
|
202
|
+
});
|
|
203
|
+
expect(name).not.toBe('blocked');
|
|
204
|
+
expect(name).toMatch(/^[a-z]+-[a-z]+$/);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a worktree lands — OpenChamber's rule, adopted as-is:
|
|
8
|
+
*
|
|
9
|
+
* root = configured directory (resolved) || <data dir>/worktrees/<projectID>
|
|
10
|
+
* path = <root>/<name>
|
|
11
|
+
*
|
|
12
|
+
* Three spellings for the setting, unchanged from theirs: a relative path
|
|
13
|
+
* resolves against the CANONICAL checkout (so a linked worktree still reads the
|
|
14
|
+
* project's own setting), an absolute path is used as-is, and `~` means the home
|
|
15
|
+
* directory. The resolved value is always the PARENT — the name is appended.
|
|
16
|
+
*
|
|
17
|
+
* A name is never refused because it is taken: candidates are tried in order, so
|
|
18
|
+
* "quick otter" in a repository that already has one becomes
|
|
19
|
+
* `quick-otter-cosmic-dolphin` rather than an error the user has to read.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
25
|
+
import {
|
|
26
|
+
generateBranchSlug,
|
|
27
|
+
slugWorktreeName,
|
|
28
|
+
WORKTREE_BRANCH_PREFIX,
|
|
29
|
+
} from '@/shared/lib/workspace/worktree/branch-name';
|
|
30
|
+
|
|
31
|
+
/** How many names to try before giving up. OpenChamber's 26, kept. */
|
|
32
|
+
export const WORKTREE_NAME_ATTEMPTS = 26;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the setting into a parent directory. `null` when unset, so the caller
|
|
36
|
+
* keeps its own default rather than resolving an empty string to the checkout.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveConfiguredWorktreeRoot(
|
|
39
|
+
configured: unknown,
|
|
40
|
+
primaryCheckout: string,
|
|
41
|
+
home: string = homedir(),
|
|
42
|
+
): string | null {
|
|
43
|
+
const trimmed = String(configured ?? '').trim();
|
|
44
|
+
if (!trimmed) return null;
|
|
45
|
+
if (trimmed === '~') return path.resolve(home);
|
|
46
|
+
if (/^~[\\/]/.test(trimmed)) return path.resolve(home, trimmed.slice(2));
|
|
47
|
+
return path.resolve(primaryCheckout, trimmed);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `<data dir>/worktrees/<projectID>` — outside every checkout, keyed per repository. */
|
|
51
|
+
export function defaultWorktreeRoot(dataDir: string, projectId: string): string {
|
|
52
|
+
return path.join(dataDir, 'worktrees', projectId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `<slug>`, then `<slug>-<adjective>-<noun>` × attempts-1. */
|
|
56
|
+
export function worktreeNameCandidates(preferredName: string, attempts = WORKTREE_NAME_ATTEMPTS): string[] {
|
|
57
|
+
const base = slugWorktreeName(preferredName);
|
|
58
|
+
if (!base) {
|
|
59
|
+
return Array.from({ length: attempts }, () => generateBranchSlug());
|
|
60
|
+
}
|
|
61
|
+
return Array.from({ length: attempts }, (_, index) =>
|
|
62
|
+
index === 0 ? base : `${base}-${generateBranchSlug()}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ResolvedWorktreeTarget {
|
|
67
|
+
name: string;
|
|
68
|
+
directory: string;
|
|
69
|
+
branch: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ResolveTargetInput {
|
|
73
|
+
root: string;
|
|
74
|
+
preferredName: string;
|
|
75
|
+
/** The dialog's branch, used verbatim and only the directory is checked. */
|
|
76
|
+
explicitBranch?: string | null;
|
|
77
|
+
/** The branch a generated candidate would get. */
|
|
78
|
+
generatedBranch: (name: string) => string;
|
|
79
|
+
branchExists: (branch: string) => Promise<boolean>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pick a free name. A candidate is rejected when its directory exists OR — for a
|
|
84
|
+
* generated branch — when the branch it would get already exists, so the folder
|
|
85
|
+
* and the branch can never disagree about which one is "the name".
|
|
86
|
+
*/
|
|
87
|
+
export async function resolveTargetDirectory(input: ResolveTargetInput): Promise<ResolvedWorktreeTarget> {
|
|
88
|
+
const explicit = input.explicitBranch?.trim() || null;
|
|
89
|
+
for (const name of worktreeNameCandidates(input.preferredName)) {
|
|
90
|
+
const directory = path.join(input.root, name);
|
|
91
|
+
if (await pathExists(directory)) continue;
|
|
92
|
+
if (explicit) return { name, directory, branch: explicit };
|
|
93
|
+
const branch = input.generatedBranch(name);
|
|
94
|
+
if (await input.branchExists(branch)) continue;
|
|
95
|
+
return { name, directory, branch };
|
|
96
|
+
}
|
|
97
|
+
throw new Error('Failed to generate a unique worktree name');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A generated slug that collides with neither a folder nor a branch. */
|
|
101
|
+
export async function generateAvailableName(input: {
|
|
102
|
+
root: string;
|
|
103
|
+
branchExists: (branch: string) => Promise<boolean>;
|
|
104
|
+
attempts?: number;
|
|
105
|
+
}): Promise<string> {
|
|
106
|
+
const attempts = input.attempts ?? WORKTREE_NAME_ATTEMPTS;
|
|
107
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
108
|
+
const slug = generateBranchSlug();
|
|
109
|
+
if (await pathExists(path.join(input.root, slug))) continue;
|
|
110
|
+
if (await input.branchExists(`${WORKTREE_BRANCH_PREFIX}/${slug}`)) continue;
|
|
111
|
+
return slug;
|
|
112
|
+
}
|
|
113
|
+
throw new Error('Failed to generate a unique worktree name');
|
|
114
|
+
}
|