mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
+3



![github-actions[bot]](https://secure.gravatar.com/avatar/af2ab225b7c0eec44a8d0eba6b5c869a?d=identicon&s=40)




36277801e4
* Restore the outlined server card for host headers Feedback: the bordered card with the server glyph made it clearer that a host section is a separate machine, not just another group. Bring that back while keeping the recent quieting: no status dot when healthy (marks only for connecting/blocked/error/disconnected), no 'This computer' detail on the local host, and collapse/menu/count behavior unchanged. Co-authored-by: Orca <help@stably.ai> * Anchor host badge to its label, indent rows under host cards Sidebar polish from review: - The count badge sat in dead space between the label and the hover-only chevron/menu; it now hugs the label like repo headers - Rows under a host card get a left inset so projects and workspaces visibly belong to the machine above them - A host whose only visible row is a collapsed repo group counted 0 while the group badge said 9; host counts now fall back to header counts for groups contributing no visible items Co-authored-by: Orca <help@stably.ai> * Two-tier sticky headers: pinned host card above pinned group header When scrolling inside a host section, the host card now stays pinned at the top (z-30) while project/status group headers hand off beneath it (z-20, offset by the pinned card height). The host is the outer hierarchy level, so it is the most persistent context — previously the first repo header replaced it, losing 'which machine am I on' exactly when it mattered. The pinned card keeps its collapse/menu/warning affordances. Handoff rules: the next host card pushes the previous one out at the viewport top; a group pins only once it reaches the slot beneath the host card, and a previous host's group can never pin under the next host. Without host sections the logic degrades to the original single-tier behavior. Co-authored-by: Orca <help@stably.ai> * Revert host-section row indent The two-tier sticky host card now provides continuous 'inside this machine' context at any scroll depth, making the static indent redundant — and it cost 12px of sidebar width on every row while making multi-host layouts misalign with single-host ones. Host cards bracketing their sections plus the pinned header carry the ownership signal on their own. Co-authored-by: Orca <help@stably.ai> * Checkpoint multi-host sidebar and project-first notes Co-authored-by: Orca <help@stably.ai> * Add project-first compatibility persistence Co-authored-by: Orca <help@stably.ai> * Expose project host setup APIs Co-authored-by: Orca <help@stably.ai> * Group sidebar rows by project setup Co-authored-by: Orca <help@stably.ai> * Document project-first host model discussion Co-authored-by: Orca <help@stably.ai> * Resolve workspace creation through project host setups Co-authored-by: Orca <help@stably.ai> * Stamp workspace ownership with project host setup Co-authored-by: Orca <help@stably.ai> * Add project host setup existing folder API Co-authored-by: Orca <help@stably.ai> * Summarize project-first host model discussion Co-authored-by: Orca <help@stably.ai> * Add project host setup CLI commands Co-authored-by: Orca <help@stably.ai> * Allow CLI worktree creation by project host setup Co-authored-by: Orca <help@stably.ai> * Add workspace host setup picker Co-authored-by: Orca <help@stably.ai> * Add project host setup settings summary Co-authored-by: Orca <help@stably.ai> * Make project host setup settings navigable Co-authored-by: Orca <help@stably.ai> * Stabilize project host setup settings selector Co-authored-by: Orca <help@stably.ai> * Add project host existing-folder setup form Co-authored-by: Orca <help@stably.ai> * Update project host model implementation status Co-authored-by: Orca <help@stably.ai> * Keep projects outermost in default sidebar view Co-authored-by: Orca <help@stably.ai> * Update project-first sidebar status Co-authored-by: Orca <help@stably.ai> * Show host context in project sidebar groups Co-authored-by: Orca <help@stably.ai> * Show unavailable hosts in workspace run target Co-authored-by: Orca <help@stably.ai> * Import missing project host from composer Co-authored-by: Orca <help@stably.ai> * Clone project host setup from composer Co-authored-by: Orca <help@stably.ai> * Persist project host setup method Co-authored-by: Orca <help@stably.ai> * Clone project hosts over SSH Co-authored-by: Orca <help@stably.ai> * Improve SSH clone cancellation cleanup Co-authored-by: Orca <help@stably.ai> * Backfill workspace project host ownership Co-authored-by: Orca <help@stably.ai> * Gate project host setup runtime capability Co-authored-by: Orca <help@stably.ai> * Preserve independent project host setups Co-authored-by: Orca <help@stably.ai> * Add project host setup update API Co-authored-by: Orca <help@stably.ai> * Add project host setup delete API Co-authored-by: Orca <help@stably.ai> * Add project host setup create API Co-authored-by: Orca <help@stably.ai> * Expose project host setup lifecycle in renderer store Co-authored-by: Orca <help@stably.ai> * Handle independent project host setups in settings Co-authored-by: Orca <help@stably.ai> * Add pending host setup action in project settings Co-authored-by: Orca <help@stably.ai> * Show pending project host setup status in composer Co-authored-by: Orca <help@stably.ai> * Report pending setup state in workspace target resolution Co-authored-by: Orca <help@stably.ai> * Use shared host registry for project setup choices Co-authored-by: Orca <help@stably.ai> * Add settings clone flow for project host setups Co-authored-by: Orca <help@stably.ai> * Gate unavailable project host setup options Co-authored-by: Orca <help@stably.ai> * Gate unavailable project setup hosts in settings Co-authored-by: Orca <help@stably.ai> * Stream SSH clone progress to renderer Co-authored-by: Orca <help@stably.ai> * Update project host model status notes Co-authored-by: Orca <help@stably.ai> * Add CLI project host setup clone command Co-authored-by: Orca <help@stably.ai> * Make add project host aware Co-authored-by: Orca <help@stably.ai> * Complete project host setup validation Co-authored-by: Orca <help@stably.ai> * Recover floating workspace terminal WebGL atlas on reopen (#5069) Co-authored-by: Orca <help@stably.ai> * Fix stale terminal daemon spawn health (#5064) Co-authored-by: Orca <help@stably.ai> * Suspend floating workspace terminal WebGL while the panel is closed (#5073) Co-authored-by: Orca <help@stably.ai> * Fix source control branch compare base (#5074) Co-authored-by: Orca <help@stably.ai> * Fix workspace-creation tour panel clipped by the Create Worktree dialog (#5078) * Fix workspace-creation tour panel clipped by the composer dialog The tour panel portals into dialog/sheet content that clips overflow, but its position was clamped against the window viewport. With the Project field spanning nearly the dialog's full width, the panel landed past the dialog's right edge and overflow-hidden cut it down to a sliver. Clamp hosted panels within the host's bounds instead, so the panel flips below the target and stays fully visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add JSDoc docstrings to satisfy CodeRabbit docstring coverage check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Test hosted contextual tour overlay positioning --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * release: v1.4.56 * Handle buffer overflows gracefully and truncate diffs fairly (#5083) - Gracefully fall back to file-name summaries when staged diffs exceed node/ssh execution maxBuffer limits, preventing generation failures. - Split oversized diffs by file and allocate budget via water-filling, ensuring single huge files do not starve smaller human changes. - Clip truncated diff sections on line boundaries to avoid half-lines. * Wrap AI generation controls with tooltips and clean i18n dependencies (#5087) - Wrap the AI generation button in a tooltip so users can see the disabled reason or the action description on hover. - Add unit tests verifying tooltip triggers and aria-label safety. - Simplify memo dependencies in settings metadata and worktree palette by using 'useTranslation()' to handle language-change rerenders directly without needing 'i18n.language'. * fix: address review findings (#5088) * Fix localization in repository hooks and base ref suggestion toast (#5089) * Fix localization in base ref toast and custom hook description - Localize the "commit"/"commits" plural nouns in the base ref toast. - Translate missing suggestion toast strings for JA, KO, and ZH locales. - Pass `{{artifact_url}}` as a literal template variable to translate calls to prevent i18next from treating it as a dynamic placeholder. * Fix localization reactivity in RepositoryHooksSection Move static variables containing translation calls into helper functions and subscribe to translation updates using useTranslation. This ensures that localized options, descriptions, and error messages refresh dynamically when the user changes the UI language. * Fix task page labels after language changes (#5086) Co-authored-by: Orca <help@stably.ai> * release: v1.4.57 * Fix automation tabs showing a shell instead of the live agent (#5099) * Fix automation tabs showing a shell instead of the live agent Opening a background automation's terminal tab showed a bare shell while the agent (Claude) kept running headless — the sidebar updated but the pane was attached to the wrong PTY. On first mount the restored ptyId equals the tab ptyId, and isSessionOwnedByWorktree() returns true for it, so connectPanePty routed the still-live eagerly-spawned PTY into the daemon-reattach branch (transport.connect({ sessionId })), which spawns a fresh shell and orphans the live agent PTY instead of adopting it via attach()+replay. Part A: gate the deferred reattach on the absence of a live eager buffer. A live eager buffer means the PTY is a still-running local session to adopt (attach + replay), not a daemon session to re-connect. Daemon reattach and remote PTYs are unaffected (gated on the eager buffer). Part B: publish never-mounted background automation tabs into the runtime graph (gated on a live eager buffer) so the live agent PTY binds to its real tab instead of surfacing as an orphan `pty:<id>` terminal — fixing `orca terminal list`, the CLI, and automation session-reuse. Adds a characterization test (fails on the old code, passes now) and a runtime-graph publish test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden eager PTY tab adoption Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai> * Fix i18n label spacing in menus and settings (#5108) * fix i18n label spacing * Fix localized account runtime labels Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai> * Improve localization catalog sync workflow (#5110) Co-authored-by: Orca <help@stably.ai> * Add Warp terminal theme import (#4714) Co-authored-by: Orca <help@stably.ai> * release: v1.4.58 * Tidy README badge layout * Handle integration credential decrypt failures (#4683) Co-authored-by: Orca <help@stably.ai> * Fix git repo telemetry for repo adds (#5121) Co-authored-by: Orca <help@stably.ai> * Add feature interaction usage bucket telemetry (#5119) Co-authored-by: Orca <help@stably.ai> * Reset WebGL glyph atlases globally to stop cross-terminal glyph corruption (#5122) Co-authored-by: Orca <help@stably.ai> * perf(windows): fix 60s startup ACL walk and OpenCode streaming freeze, with benchmark harnesses (#5124) * release: v1.4.59-rc.0 * Fix packaged shell PATH order (#5125) Co-authored-by: Orca <help@stably.ai> * Add Floating Workspace contextual tour (#5062) * Add floating workspace contextual tour Co-authored-by: Orca <help@stably.ai> * Clarify floating workspace tour intro copy Co-authored-by: Orca <help@stably.ai> * Differentiate floating workspace tour steps instead of repeating examples Co-authored-by: Orca <help@stably.ai> * Lead floating workspace tour with the user benefit Co-authored-by: Orca <help@stably.ai> * Pitch floating workspace tour around cross-repo agents Co-authored-by: Orca <help@stably.ai> * Refine floating workspace tour step 1 copy Co-authored-by: Orca <help@stably.ai> * Anchor floating workspace tour step 2 on the minimize control Co-authored-by: Orca <help@stably.ai> * Restore floating workspace tour step 2 Co-authored-by: Orca <help@stably.ai> * Anchor floating workspace tour steps on New Terminal and New Markdown Note Co-authored-by: Orca <help@stably.ai> * Retitle floating workspace tour step 2 as scratchpad Co-authored-by: Orca <help@stably.ai> * Add why-comments for tour selector fallback and placement flipping Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> * Fix source control compare base ambiguity (#5127) Co-authored-by: Orca <help@stably.ai> * release: v1.4.59-rc.1 [rc-slot:2026-06-10-15] * release: v1.4.59 * Default-driven create-project flow: name-first form with sensible defaults (#5115) Co-authored-by: Orca <help@stably.ai> * Redesign Connect integrations (#4531) Co-authored-by: Orca <help@stably.ai> * Expose E2E store via build mode * File search match counts (#5085) * Add matchCount to SearchFileResult for accurate per-file hit counts Co-authored-by: Orca <help@stably.ai> * Add file search match count design * rm design doc --------- Co-authored-by: Orca <help@stably.ai> * fix: address review findings (#5139) * perf(windows): avoid blocking daemon pid checks (#5137) * release: v1.4.60-rc.0 * release: v1.4.60 * Preserve core workflow terms in English and apply CJK spacing (#5141) * Preserve core workflow and product terms in English across locales Update translation policy to prevent localization of key terms such as "Agent", "Commit", "Markdown", and "Terminal". This ensures consistent jargon and product branding. Introduce CJK-Latin term spacing to keep these Latin terms legible when combined with CJK text, while adjusting Korean particle spacing. Also add overrides to prevent network proxy settings from being mistranslated as "Agent". * Preserve repo terminology in English and localize source control labels Treat "repo" and "repos" (and their capitalized forms) as brand terms that should remain in English/Latin across CJK and Spanish locales. Update translation files and policies to replace translated words like "repositorio" or "リポジトリ" with "repo"/"repos", and fix an issue where latin brand terms could be incorrectly matched as substrings in larger words during cleanup. Additionally, externalize and localize the "Staged Changes", "Changes", and "Untracked Files" section labels in the source control sidebar. * UX (#5143) * UX/copy tweaks (#5142) * UX/copy tweaks * UX/copy tweaks * Fix missed star UI translations (#5148) * fix: make windows ssh relay deploy survive session teardown (#5136) * Add option to remove child projects when deleting repo groups (#4702) Co-authored-by: Orca <help@stably.ai> * fix: remove checks panel response badge (#5147) * Add read-only `orca linear` CLI with trusted launch-prompt pointer (V1) (#5126) Co-authored-by: Orca <help@stably.ai> * Add AI Vault session history ## Summary - add AI Vault session scanning and resume command construction - add the Agents sidebar panel with filtering, grouping, copy/open actions, and local resume launch - support dragging saved sessions onto terminal split panes ## Validation - pnpm run lint - pnpm run typecheck - pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/register-core-handlers.test.ts src/main/ai-vault/session-scanner.test.ts src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts src/renderer/src/lib/ai-vault-session-drag.test.ts src/renderer/src/lib/launch-ai-vault-session.test.ts * Default agent launches to yolo permissions mode (#5145) * Default agent launches to yolo mode * test: update launch default validations * Fix Claude usage refresh error copy (#5155) Co-authored-by: Orca <help@stably.ai> * Move workspace board to sidebar bottom toolbar (#5146) Co-authored-by: Orca <help@stably.ai> * Rebuild contextual tour positioning on floating-ui; fix hosted dialog placement and arrow seam (#5154) Co-authored-by: Orca <help@stably.ai> * Fix missing spaces in cross-repo switch dialog (#5158) * Fix Ctrl+Tab switcher selection on release (#5116) * Fix additional i18n spacing regressions from #4995 (#5159) * Refine add project selection styling (#5160) Co-authored-by: Orca <help@stably.ai> * improve chinese localization (#5162) * Fix floating workspace needing two clicks after app switch (macOS) (#5128) * Autofocus feedback textarea when Send Feedback dialog opens (#5164) * fix: address pr-bug-scan validated finding from #4683 (#5151) Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai> * fix: enable claude agent teams by default (#5168) * Refresh Jira and Linear status after credential errors (#5169) * fix: address pr-bug-scan validated finding from #4683 Isolated CredentialDecryptionError per-item in Linear getClients (client.ts:518) and Jira getClients (client.ts:373) on the 'all' selection so one bad credential no longer collapses healthy workspaces * Refresh Jira and Linear status to clear stale credential errors Ensure stale credential decryption errors are cleared from the store status once a successful API read completes. By updating the check in shouldRefreshStatusAfterRead to trigger when a credentialError is currently set, successful issue or list fetches will trigger a status check and remove stale error flags. --------- Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai> * Hide internal context from AI Vault titles (#5175) * Fix detached HEAD publish actions (#5173) * Keep freshly split terminal pane mounted if newborn PTY exits early (#5171) Prevent a newly split pane from collapsing immediately if its PTY exits during initial setup before any output is received or input is sent. This ensures a failed startup session remains visible to the user. * Route task PR queries by upstream source (#5176) * Route task PR queries by upstream source Implements the routing described in docs/tasks-pr-upstream-source.md so task PR and issue queries stay scoped to the selected source. * rm design doc * Prevent stale PR refreshes from restoring unlinked review state (#5180) - Pass `worktreeId` to `fetchPRForBranch` to track active worktree context - Ignore inflight or queued PR fetches if the worktree has been unlinked - Include linked PR/MR metadata in the checks panel snapshot key to trigger updates immediately on link/unlink events * Fix Claude agents management status detection (#5179) Co-authored-by: Orca <help@stably.ai> * fix: address review findings (#5177) * Allow resolving selected review comments with AI (#5184) * Allow resolving selected PR/MR review comments with AI Users can now select specific unresolved review comments or threads in the Checks panel sidebar, queue them, and trigger an AI agent to address them, marking resolved threads on the host upon agent launch. - Adds checkboxes and action/send buttons to select and queue comments. - Builds a structured, robust prompt with sanitized comment metadata. - Optimistically marks threads resolved on launch with rollback on error. - Supports both GitHub PRs and GitLab MRs. * Consolidate PR comment selection state and eliminate effects Combine independent selection states and context-tracking into a single state object. Derive active selection data and prune ineligible comments during render using useMemo instead of relying on asynchronous useEffect synchronization hooks. * Improve source control action dialog layout and recipe saving UX (#5153) * Improve source control agent action dialog layout and recipe UX - Constrain dialog and scroll area heights to prevent viewport overflow. - Add variable chips to easily insert the base prompt with tooltip previews. - Keep the recipe save controls visible when a recipe is already saved, showing informational status text instead of hiding them. - Update localized copy across multiple languages and reduce textarea rows. - Add unit tests for the variable chip preview and save target visibility. * Fix recipe-saved check in source control action dialog * Evaluate only the selected save target instead of checking all available targets, as the action only writes to the selected target. * Update daemon PTY adapter test fake PID to prevent collision with real host OS processes during runtime directory lookups. * fix: remove unsupported agent launch defaults (#5185) * Update Chinese and Japanese translations for worktrees and fixes (#5187) - Correct awkward Chinese translation of "fix" ("使固定") to "修复" and "基本的" to "主工作树" (main worktree). - Improve Japanese translation of "fix" from physical repair ("修理") to software correction ("修正"). * Embed hosted review creation composer directly in Checks panel (#5140) * Embed hosted review creation composer directly in the Checks panel - Replaces the modal pull request/merge request creation dialog with an inline composer embedded in the empty state of the Checks sidebar. - Extracts and moves pull request generation state to a dedicated store slice so AI-generated details are persisted across sidebar unmounts. * Fix hosted review composer feedback * Combine file search and file explorer right sidebar tabs (#5182) Unifies file discovery and tree navigation under a single Explorer domain, simplifying the right sidebar activity bar and reducing tab clutter. * Replaces the standalone 'search' activity bar tab with a nested 'search' subview inside the File Explorer tab * Introduces 'rightSidebarExplorerView' ('files' | 'search') state to manage the active subview inside the Explorer * Adds a search button to the File Explorer toolbar and a back button to the search subview for seamless transition * Exposes 'showRightSidebarFiles' and 'showRightSidebarSearch' store actions to route and seed search queries/include patterns * Adapts file explorer keybindings, git status polling, and external workspace watchers to respect the active subview * Maps legacy persisted search tab state to the new explorer search view for backward compatibility * release: v1.4.61-rc.1 * Add multi-repo folder workspaces (v1) (#5172) Co-authored-by: Orca <help@stably.ai> * release: v1.4.61-rc.2 * Hide unavailable project hosts in worktree composer Co-authored-by: Orca <help@stably.ai> * Remove inline project host setup from composer Co-authored-by: Orca <help@stably.ai> * Mark imported project host setup methods Co-authored-by: Orca <help@stably.ai> * Fix rebase merge fallout Co-authored-by: Orca <help@stably.ai> * Disable unavailable Add Project hosts Co-authored-by: Orca <help@stably.ai> * Compact Add Project host selector Co-authored-by: Orca <help@stably.ai> * Hide redundant SSH target chooser Co-authored-by: Orca <help@stably.ai> * Browse SSH clone destinations Co-authored-by: Orca <help@stably.ai> * Avoid local clone defaults for SSH hosts Co-authored-by: Orca <help@stably.ai> * Polish host-aware Add Project flows Co-authored-by: Orca <help@stably.ai> * Polish remote host add project flows Co-authored-by: Orca <help@stably.ai> * Remove redundant host kind chips Co-authored-by: Orca <help@stably.ai> * Fix remote project setup UX gaps Co-authored-by: Orca <help@stably.ai> * Fix multihost workspace composer project identity Co-authored-by: Orca <help@stably.ai> * Finish host context merge repair Co-authored-by: Orca <help@stably.ai> * Continue host context checklist implementation Co-authored-by: Orca <help@stably.ai> * Route Linear and Jira tasks by source context Co-authored-by: Orca <help@stably.ai> * Preserve Linear task source context in history Co-authored-by: Orca <help@stably.ai> * Scope task retry state by source context Co-authored-by: Orca <help@stably.ai> * Route GitHub drawer reads by source context Co-authored-by: Orca <help@stably.ai> * Guard GitLab selectors with repo context Co-authored-by: Orca <help@stably.ai> * Guard GitHub metadata selectors Co-authored-by: Orca <help@stably.ai> * Route GitHub task row actions by source context Co-authored-by: Orca <help@stably.ai> * Update GitHub source-context checklist status Co-authored-by: Orca <help@stably.ai> * Show host ownership for CLI provider accounts Co-authored-by: Orca <help@stably.ai> * Persist GitLab task detail source context Co-authored-by: Orca <help@stably.ai> * Show host scope for provider API budgets Co-authored-by: Orca <help@stably.ai> * Preserve Jira task source context Co-authored-by: Orca <help@stably.ai> * Scope Jira optimistic task patches Co-authored-by: Orca <help@stably.ai> * Resolve task PR bases on run host Co-authored-by: Orca <help@stably.ai> * Record Jira task workspace usage Co-authored-by: Orca <help@stably.ai> * Scope Linear optimistic task patches Co-authored-by: Orca <help@stably.ai> * Scope GitHub optimistic task patches Co-authored-by: Orca <help@stably.ai> * Clean host copy in onboarding flows Co-authored-by: Orca <help@stably.ai> * Preserve automation CLI run context Co-authored-by: Orca <help@stably.ai> * Add automation CLI source context selector Co-authored-by: Orca <help@stably.ai> * Clarify unavailable task source hosts Co-authored-by: Orca <help@stably.ai> * Surface host model runtime capability skew Co-authored-by: Orca <help@stably.ai> * Use SSH host copy in reconnect dialog Co-authored-by: Orca <help@stably.ai> * Show host context in task source picker Co-authored-by: Orca <help@stably.ai> * Mark task source display complete Co-authored-by: Orca <help@stably.ai> * Clarify provider account host selection Co-authored-by: Orca <help@stably.ai> * Guard task source switching boundary Co-authored-by: Orca <help@stably.ai> * Mark task source diagnostics persisted Co-authored-by: Orca <help@stably.ai> * Mark base resolution host boundary Co-authored-by: Orca <help@stably.ai> * Clarify external automation source states Co-authored-by: Orca <help@stably.ai> * Harden project host compatibility projection Co-authored-by: Orca <help@stably.ai> * Finish host copy audit Co-authored-by: Orca <help@stably.ai> * Add provider host scope controls Co-authored-by: Orca <help@stably.ai> * Show task source account labels Co-authored-by: Orca <help@stably.ai> * Show automation run context in CLI Co-authored-by: Orca <help@stably.ai> * Scope Jira task cache lookups by source Co-authored-by: Orca <help@stably.ai> * Seed workspace creation from task source context Co-authored-by: Orca <help@stably.ai> * Explain disabled external automation actions Co-authored-by: Orca <help@stably.ai> * Surface task source runtime capability gaps Co-authored-by: Orca <help@stably.ai> * Persist automation run context from UI saves Co-authored-by: Orca <help@stably.ai> * Require workspace run capability for setup hosts Co-authored-by: Orca <help@stably.ai> * Disable automation runs for stale host setup Co-authored-by: Orca <help@stably.ai> * Route GitHub drawer metadata by source host Co-authored-by: Orca <help@stably.ai> * Guard runtime project setup mutations by host model Co-authored-by: Orca <help@stably.ai> * Route PR page metadata by repo host Co-authored-by: Orca <help@stably.ai> * Route PR mention metadata by repo host Co-authored-by: Orca <help@stably.ai> * Route GitHub Project edits by view source Co-authored-by: Orca <help@stably.ai> * Clarify runtime automation disabled states Co-authored-by: Orca <help@stably.ai> * Guard runtime automation backend dispatch Co-authored-by: Orca <help@stably.ai> * Preserve GitLab task source identity Co-authored-by: Orca <help@stably.ai> * Remove redundant SSH target row in add project Co-authored-by: Orca <help@stably.ai> * Add task source provider availability reasons Co-authored-by: Orca <help@stably.ai> * Surface task provider preflight availability Co-authored-by: Orca <help@stably.ai> * Record local GitHub task source verification Co-authored-by: Orca <help@stably.ai> * Record Linear task source verification Co-authored-by: Orca <help@stably.ai> * Show automation source context in details Co-authored-by: Orca <help@stably.ai> * Record remote capability negotiation coverage Co-authored-by: Orca <help@stably.ai> * Record local add project create verification Co-authored-by: Orca <help@stably.ai> * Scope Linear cached task reads by source Co-authored-by: Orca <help@stably.ai> * Preserve PR generation host ownership Co-authored-by: Orca <help@stably.ai> * Route git operations by owner host Co-authored-by: Orca <help@stably.ai> * Route delete warnings by worktree owner Co-authored-by: Orca <help@stably.ai> * Route editor drops by worktree owner Co-authored-by: Orca <help@stably.ai> * Route agent draft paste by tab owner Co-authored-by: Orca <help@stably.ai> * Route file explorer requests by worktree owner Co-authored-by: Orca <help@stably.ai> * Document remaining host context gaps Co-authored-by: Orca <help@stably.ai> * Check runtime task source provider auth Co-authored-by: Orca <help@stably.ai> * Validate automation source availability Co-authored-by: Orca <help@stably.ai> * Route remaining UI requests by owner host Co-authored-by: Orca <help@stably.ai> * Route quick open file listing by worktree owner Co-authored-by: Orca <help@stably.ai> * Route typed GitHub lookups by source host Co-authored-by: Orca <help@stably.ai> * Centralize automation run identity fallback Co-authored-by: Orca <help@stably.ai> * Surface unsupported task source providers Co-authored-by: Orca <help@stably.ai> * Document automation legacy repo compatibility Co-authored-by: Orca <help@stably.ai> * Record live host model verification Co-authored-by: Orca <help@stably.ai> * Quiet disconnected SSH polling Co-authored-by: Orca <help@stably.ai> * Verify task drawer source boundaries Co-authored-by: Orca <help@stably.ai> * Verify GitLab repo source selectors Co-authored-by: Orca <help@stably.ai> * Route automations through owning host Co-authored-by: Orca <help@stably.ai> * Update host context verification checklist Co-authored-by: Orca <help@stably.ai> * Run remote automations headlessly in serve mode Co-authored-by: Orca <help@stably.ai> * Keep setup guide entry stable during refresh Co-authored-by: Orca <help@stably.ai> * Keep setup script prompt stable during host switches Co-authored-by: Orca <help@stably.ai> * Deduplicate Tasks project picker sources Co-authored-by: Orca <help@stably.ai> * Use project identity for Tasks picker dedupe Co-authored-by: Orca <help@stably.ai> * Add Tasks source host switcher Co-authored-by: Orca <help@stably.ai> * Refine Tasks source picker disclosure Co-authored-by: Orca <help@stably.ai> * Polish Tasks source picker hover Co-authored-by: Orca <help@stably.ai> * Open Tasks source menu on hover Co-authored-by: Orca <help@stably.ai> * Match Tasks source submenu hover behavior Co-authored-by: Orca <help@stably.ai> * Open Tasks source submenu from project row hover Co-authored-by: Orca <help@stably.ai> * Group automation project hosts Co-authored-by: Orca <help@stably.ai> * Tighten automation project picker density Co-authored-by: Orca <help@stably.ai> * Show selected host in Tasks project picker Co-authored-by: Orca <help@stably.ai> * Hide host labels for single-host project pickers Co-authored-by: Orca <help@stably.ai> * Use saved remote server names in host pickers Co-authored-by: Orca <help@stably.ai> * Use standard add project start for remote servers Co-authored-by: Orca <help@stably.ai> * Use saved host labels in workspace surfaces Co-authored-by: Orca <help@stably.ai> * Route remote browser tabs through runtime hosts Co-authored-by: Orca <help@stably.ai> * Keep sidebar project-first across grouping modes Co-authored-by: Orca <help@stably.ai> * Polish multi-host remote runtime UX Co-authored-by: Orca <help@stably.ai> * Fix CI lint and remove design notes Co-authored-by: Orca <help@stably.ai> * Fix CI test failures Co-authored-by: Orca <help@stably.ai> * Fix Windows CLI path expectation Co-authored-by: Orca <help@stably.ai> * Fix CI renderer test expectations Co-authored-by: Orca <help@stably.ai> * Fix remaining verify test failures Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: Bryant Ung <bryant.ung@outlook.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Borja <3930245+BorjaLL@users.noreply.github.com> Co-authored-by: Parker Rex <me@parkerrex.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Trevin Chow <trevin@trevinchow.com> Co-authored-by: buf0-bot[bot] <252831055+buf0-bot[bot]@users.noreply.github.com> Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
1797 lines
69 KiB
TypeScript
1797 lines
69 KiB
TypeScript
/* eslint-disable max-lines -- Why: this file covers ~14 distinct relay git
|
|
handlers plus the addWorktree state machine (--no-track + push.autoSetupRemote
|
|
probe/write across four flow branches). Splitting per-handler would scatter
|
|
related coverage without a meaningful boundary. */
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
import { GitHandler } from './git-handler'
|
|
import { RelayContext } from './context'
|
|
import * as fs from 'fs/promises'
|
|
import * as path from 'path'
|
|
import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { execFileSync } from 'child_process'
|
|
import {
|
|
createMockDispatcher,
|
|
gitInit,
|
|
gitCommit,
|
|
type MockDispatcher,
|
|
type RelayDispatcher
|
|
} from './git-handler-test-setup'
|
|
|
|
describe('GitHandler', () => {
|
|
let dispatcher: MockDispatcher
|
|
let handler: GitHandler
|
|
let tmpDir: string
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-'))
|
|
dispatcher = createMockDispatcher()
|
|
const ctx = new RelayContext()
|
|
handler = new GitHandler(dispatcher as unknown as RelayDispatcher, ctx)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
function currentBranch(cwd: string): string {
|
|
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
}
|
|
|
|
function currentBranchFullRef(cwd: string): string {
|
|
return `refs/heads/${currentBranch(cwd)}`
|
|
}
|
|
|
|
function reportedWorktreePath(cwd: string): string {
|
|
return (
|
|
execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
cwd,
|
|
encoding: 'utf-8'
|
|
})
|
|
.split(/\r?\n/)
|
|
.find((line) => line.startsWith('worktree '))
|
|
?.slice('worktree '.length)
|
|
.trim() ?? cwd
|
|
)
|
|
}
|
|
|
|
it('registers all expected handlers', () => {
|
|
const methods = Array.from(dispatcher._requestHandlers.keys())
|
|
expect(methods).toContain('git.status')
|
|
expect(methods).toContain('git.checkIgnored')
|
|
expect(methods).toContain('git.history')
|
|
expect(methods).toContain('git.commit')
|
|
expect(methods).toContain('git.diff')
|
|
expect(methods).toContain('git.stage')
|
|
expect(methods).toContain('git.unstage')
|
|
expect(methods).toContain('git.bulkStage')
|
|
expect(methods).toContain('git.bulkUnstage')
|
|
expect(methods).toContain('git.abortMerge')
|
|
expect(methods).toContain('git.abortRebase')
|
|
expect(methods).toContain('git.discard')
|
|
expect(methods).toContain('git.bulkDiscard')
|
|
expect(methods).toContain('git.conflictOperation')
|
|
expect(methods).toContain('git.branchCompare')
|
|
expect(methods).toContain('git.upstreamStatus')
|
|
expect(methods).toContain('git.fetch')
|
|
expect(methods).toContain('git.fetchRemoteTrackingRef')
|
|
expect(methods).toContain('git.push')
|
|
expect(methods).toContain('git.pull')
|
|
expect(methods).toContain('git.fastForward')
|
|
expect(methods).toContain('git.rebaseFromBase')
|
|
expect(methods).toContain('git.branchDiff')
|
|
expect(methods).toContain('git.listWorktrees')
|
|
expect(methods).toContain('git.addWorktree')
|
|
expect(methods).toContain('git.removeWorktree')
|
|
expect(methods).toContain('git.worktreeIsClean')
|
|
expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate')
|
|
expect(methods).toContain('git.renameCurrentBranch')
|
|
expect(methods).toContain('git.exec')
|
|
expect(methods).toContain('git.clone')
|
|
expect(methods).toContain('git.isGitRepo')
|
|
})
|
|
|
|
describe('abortMerge', () => {
|
|
it('aborts an in-progress merge', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n')
|
|
gitCommit(tmpDir, 'initial')
|
|
const baseBranch = execFileSync('git', ['branch', '--show-current'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe'
|
|
}).trim()
|
|
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n')
|
|
gitCommit(tmpDir, 'feature change')
|
|
execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n')
|
|
gitCommit(tmpDir, 'main change')
|
|
|
|
expect(() =>
|
|
execFileSync('git', ['merge', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
).toThrow()
|
|
await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).resolves.toBeUndefined()
|
|
|
|
await dispatcher.callRequest('git.abortMerge', { worktreePath: tmpDir })
|
|
|
|
await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow()
|
|
await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('main\n')
|
|
})
|
|
})
|
|
|
|
describe('abortRebase', () => {
|
|
it('aborts an in-progress rebase', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n')
|
|
gitCommit(tmpDir, 'initial')
|
|
const baseBranch = execFileSync('git', ['branch', '--show-current'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe'
|
|
}).trim()
|
|
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n')
|
|
gitCommit(tmpDir, 'feature change')
|
|
execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n')
|
|
gitCommit(tmpDir, 'main change')
|
|
execFileSync('git', ['checkout', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
|
|
expect(() =>
|
|
execFileSync('git', ['rebase', baseBranch], { cwd: tmpDir, stdio: 'pipe' })
|
|
).toThrow()
|
|
await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).resolves.toBeUndefined()
|
|
|
|
await dispatcher.callRequest('git.abortRebase', { worktreePath: tmpDir })
|
|
|
|
await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).rejects.toThrow()
|
|
await expect(fs.access(path.join(tmpDir, '.git', 'rebase-apply'))).rejects.toThrow()
|
|
await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('feature\n')
|
|
})
|
|
})
|
|
|
|
describe('renameCurrentBranch', () => {
|
|
it('renames only the checked-out branch through the narrow RPC', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
|
|
gitCommit(tmpDir, 'initial')
|
|
execFileSync('git', ['checkout', '-b', 'you/Nautilus'], { cwd: tmpDir })
|
|
|
|
await dispatcher.callRequest('git.renameCurrentBranch', {
|
|
worktreePath: tmpDir,
|
|
newBranch: 'you/fix-auth'
|
|
})
|
|
|
|
const current = execFileSync('git', ['branch', '--show-current'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
expect(current).toBe('you/fix-auth')
|
|
})
|
|
|
|
it('rejects branch names that look like flags', async () => {
|
|
gitInit(tmpDir)
|
|
await expect(
|
|
dispatcher.callRequest('git.renameCurrentBranch', {
|
|
worktreePath: tmpDir,
|
|
newBranch: '-bad'
|
|
})
|
|
).rejects.toThrow('Branch name must not start with "-"')
|
|
})
|
|
})
|
|
|
|
describe('history', () => {
|
|
it('returns bounded git history for a repo', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'changed')
|
|
gitCommit(tmpDir, 'second')
|
|
|
|
const result = (await dispatcher.callRequest('git.history', {
|
|
worktreePath: tmpDir,
|
|
limit: 10
|
|
})) as {
|
|
items: { subject: string; displayId?: string }[]
|
|
currentRef?: { category?: string; revision?: string }
|
|
hasMore: boolean
|
|
limit: number
|
|
}
|
|
|
|
expect(result.items.map((item) => item.subject)).toEqual(['second', 'initial'])
|
|
expect(result.currentRef?.category).toBe('branches')
|
|
expect(result.currentRef?.revision).toMatch(/^[0-9a-f]{40}$/)
|
|
expect(result.items[0]?.displayId).toHaveLength(7)
|
|
expect(result.hasMore).toBe(false)
|
|
expect(result.limit).toBe(10)
|
|
})
|
|
})
|
|
|
|
describe('status', () => {
|
|
it('returns empty entries for clean repo', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
|
|
gitCommit(tmpDir, 'initial')
|
|
|
|
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
|
|
entries: Record<string, unknown>[]
|
|
conflictOperation: string
|
|
head?: string
|
|
branch?: string
|
|
}
|
|
expect(result.entries).toEqual([])
|
|
expect(result.conflictOperation).toBe('unknown')
|
|
expect(result.branch).toMatch(/^refs\/heads\//)
|
|
expect(typeof result.head).toBe('string')
|
|
})
|
|
|
|
it('detects untracked files', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'tracked.txt'), 'tracked')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'new.txt'), 'new')
|
|
|
|
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
|
|
entries: {
|
|
path?: unknown
|
|
status?: unknown
|
|
area?: unknown
|
|
added?: unknown
|
|
removed?: unknown
|
|
}[]
|
|
}
|
|
const untracked = result.entries.find((e) => e.path === 'new.txt')
|
|
expect(untracked).toBeDefined()
|
|
expect(untracked!.status).toBe('untracked')
|
|
expect(untracked!.area).toBe('untracked')
|
|
expect(untracked!.added).toBe(1)
|
|
expect(untracked!.removed).toBeUndefined()
|
|
})
|
|
|
|
it('returns ignored paths only when requested', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n')
|
|
gitCommit(tmpDir, 'initial')
|
|
mkdirSync(path.join(tmpDir, 'dist'), { recursive: true })
|
|
writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled')
|
|
writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret')
|
|
|
|
const defaultResult = (await dispatcher.callRequest('git.status', {
|
|
worktreePath: tmpDir
|
|
})) as {
|
|
ignoredPaths?: string[]
|
|
}
|
|
const ignoredResult = (await dispatcher.callRequest('git.status', {
|
|
worktreePath: tmpDir,
|
|
includeIgnored: true
|
|
})) as {
|
|
ignoredPaths?: string[]
|
|
}
|
|
|
|
expect('ignoredPaths' in defaultResult).toBe(false)
|
|
expect(ignoredResult.ignoredPaths).toEqual(expect.arrayContaining(['dist/', '.env']))
|
|
})
|
|
|
|
it('checks ignored status for selected paths', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, '.gitignore'), 'dist/\n.env\n')
|
|
gitCommit(tmpDir, 'initial')
|
|
mkdirSync(path.join(tmpDir, 'dist'), { recursive: true })
|
|
writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'compiled')
|
|
writeFileSync(path.join(tmpDir, '.env'), 'TOKEN=secret')
|
|
|
|
const result = (await dispatcher.callRequest('git.checkIgnored', {
|
|
worktreePath: tmpDir,
|
|
paths: ['dist/bundle.js', 'src/index.ts', '.env']
|
|
})) as string[]
|
|
|
|
expect(result).toEqual(expect.arrayContaining(['dist/bundle.js', '.env']))
|
|
expect(result).not.toContain('src/index.ts')
|
|
})
|
|
|
|
it('detects modified files', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'original')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'modified')
|
|
|
|
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
|
|
entries: {
|
|
path?: unknown
|
|
status?: unknown
|
|
area?: unknown
|
|
added?: unknown
|
|
removed?: unknown
|
|
}[]
|
|
}
|
|
const modified = result.entries.find((e) => e.path === 'file.txt')
|
|
expect(modified).toBeDefined()
|
|
expect(modified!.status).toBe('modified')
|
|
expect(modified!.area).toBe('unstaged')
|
|
expect(modified!.added).toBe(1)
|
|
expect(modified!.removed).toBe(1)
|
|
})
|
|
|
|
it('detects staged files', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'original')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'changed')
|
|
execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' })
|
|
|
|
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
|
|
entries: {
|
|
path?: unknown
|
|
status?: unknown
|
|
area?: unknown
|
|
added?: unknown
|
|
removed?: unknown
|
|
}[]
|
|
}
|
|
const staged = result.entries.find((e) => e.area === 'staged')
|
|
expect(staged).toBeDefined()
|
|
expect(staged!.status).toBe('modified')
|
|
expect(staged!.added).toBe(1)
|
|
expect(staged!.removed).toBe(1)
|
|
})
|
|
|
|
// Why: regression for issue #1503 — git's default core.quotePath=true
|
|
// emits non-ASCII paths as octal-escaped, double-quoted strings (e.g.
|
|
// "docs/\346\227\245\346\234\254\350\252\236/sample.md"), which made the
|
|
// sidebar show gibberish and broke downstream blob reads.
|
|
it('preserves UTF-8 paths in status output', async () => {
|
|
gitInit(tmpDir)
|
|
const utf8Dir = path.join(tmpDir, 'docs', '日本語')
|
|
mkdirSync(utf8Dir, { recursive: true })
|
|
writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello')
|
|
|
|
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
|
|
entries: Record<string, unknown>[]
|
|
}
|
|
const entry = result.entries.find((e) =>
|
|
typeof e.path === 'string' ? e.path.endsWith('sample.md') : false
|
|
)
|
|
expect(entry).toBeDefined()
|
|
expect(entry!.path).toBe('docs/日本語/sample.md')
|
|
})
|
|
|
|
// Why: regression for issue #1503 on the porcelain v2 type-1 entry parser
|
|
// branch (tracked + modified). The existing UTF-8 test exercises only the
|
|
// untracked '?' branch; this one exercises the path-reconstruction code in
|
|
// parseStatusOutput that joins parts.slice(8).
|
|
it('preserves UTF-8 paths for tracked-modified entries', async () => {
|
|
gitInit(tmpDir)
|
|
const utf8Dir = path.join(tmpDir, 'docs', '日本語')
|
|
mkdirSync(utf8Dir, { recursive: true })
|
|
const utf8File = path.join(utf8Dir, 'sample.md')
|
|
writeFileSync(utf8File, 'original')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(utf8File, 'modified')
|
|
|
|
const result = (await dispatcher.callRequest('git.status', { worktreePath: tmpDir })) as {
|
|
entries: Record<string, unknown>[]
|
|
}
|
|
const entry = result.entries.find((e) =>
|
|
typeof e.path === 'string' ? e.path.endsWith('sample.md') : false
|
|
)
|
|
expect(entry).toBeDefined()
|
|
expect(entry!.path).toBe('docs/日本語/sample.md')
|
|
expect(entry!.status).toBe('modified')
|
|
expect(entry!.area).toBe('unstaged')
|
|
})
|
|
})
|
|
|
|
describe('stage and unstage', () => {
|
|
it('stages a file', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'content')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'changed')
|
|
|
|
await dispatcher.callRequest('git.stage', { worktreePath: tmpDir, filePath: 'file.txt' })
|
|
|
|
const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
})
|
|
expect(output.trim()).toBe('file.txt')
|
|
})
|
|
|
|
it('unstages a file', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'content')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'changed')
|
|
execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' })
|
|
|
|
await dispatcher.callRequest('git.unstage', { worktreePath: tmpDir, filePath: 'file.txt' })
|
|
|
|
const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
})
|
|
expect(output.trim()).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('diff', () => {
|
|
it('returns text diff for modified file', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'original')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'modified')
|
|
|
|
const result = (await dispatcher.callRequest('git.diff', {
|
|
worktreePath: tmpDir,
|
|
filePath: 'file.txt',
|
|
staged: false
|
|
})) as { kind: string; originalContent: string; modifiedContent: string }
|
|
expect(result.kind).toBe('text')
|
|
expect(result.originalContent).toBe('original')
|
|
expect(result.modifiedContent).toBe('modified')
|
|
})
|
|
|
|
it('returns staged diff', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'original')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'staged-content')
|
|
execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' })
|
|
|
|
const result = (await dispatcher.callRequest('git.diff', {
|
|
worktreePath: tmpDir,
|
|
filePath: 'file.txt',
|
|
staged: true
|
|
})) as { kind: string; originalContent: string; modifiedContent: string }
|
|
expect(result.kind).toBe('text')
|
|
expect(result.originalContent).toBe('original')
|
|
expect(result.modifiedContent).toBe('staged-content')
|
|
})
|
|
|
|
it('returns diff for tracked files in valid dot-dot-prefixed directories', async () => {
|
|
gitInit(tmpDir)
|
|
mkdirSync(path.join(tmpDir, '..fixtures'))
|
|
writeFileSync(path.join(tmpDir, '..fixtures', 'file.txt'), 'original')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, '..fixtures', 'file.txt'), 'modified')
|
|
|
|
const result = (await dispatcher.callRequest('git.diff', {
|
|
worktreePath: tmpDir,
|
|
filePath: '..fixtures/file.txt',
|
|
staged: false
|
|
})) as { kind: string; originalContent: string; modifiedContent: string }
|
|
|
|
expect(result.kind).toBe('text')
|
|
expect(result.originalContent).toBe('original')
|
|
expect(result.modifiedContent).toBe('modified')
|
|
})
|
|
|
|
it('rejects diff paths that traverse outside the worktree', async () => {
|
|
gitInit(tmpDir)
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.diff', {
|
|
worktreePath: tmpDir,
|
|
filePath: '../outside.txt',
|
|
staged: false
|
|
})
|
|
).rejects.toThrow('outside the worktree')
|
|
})
|
|
})
|
|
|
|
describe('discard', () => {
|
|
it('discards changes to tracked file', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'original')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'modified')
|
|
|
|
await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'file.txt' })
|
|
|
|
const content = await fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')
|
|
expect(content).toBe('original')
|
|
})
|
|
|
|
it('deletes untracked file on discard', async () => {
|
|
gitInit(tmpDir)
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'new.txt'), 'untracked')
|
|
|
|
await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: 'new.txt' })
|
|
await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow()
|
|
})
|
|
|
|
it('treats untracked discard paths with Git glob characters as literal paths', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, '.gitignore'), 'ignored.log\n')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, '*.log'), 'selected')
|
|
writeFileSync(path.join(tmpDir, 'keep.log'), 'unrelated')
|
|
writeFileSync(path.join(tmpDir, 'ignored.log'), 'ignored')
|
|
|
|
await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' })
|
|
|
|
await expect(fs.access(path.join(tmpDir, '*.log'))).rejects.toThrow()
|
|
await expect(fs.access(path.join(tmpDir, 'keep.log'))).resolves.toBeUndefined()
|
|
await expect(fs.access(path.join(tmpDir, 'ignored.log'))).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('treats tracked discard paths with Git glob characters as literal paths', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, '*.log'), 'selected')
|
|
writeFileSync(path.join(tmpDir, 'keep.log'), 'keep')
|
|
gitCommit(tmpDir, 'track log fixtures')
|
|
writeFileSync(path.join(tmpDir, '*.log'), 'selected modified')
|
|
writeFileSync(path.join(tmpDir, 'keep.log'), 'keep modified')
|
|
|
|
await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' })
|
|
|
|
await expect(fs.readFile(path.join(tmpDir, '*.log'), 'utf-8')).resolves.toBe('selected')
|
|
await expect(fs.readFile(path.join(tmpDir, 'keep.log'), 'utf-8')).resolves.toBe(
|
|
'keep modified'
|
|
)
|
|
})
|
|
|
|
it('bulk discards tracked and untracked files', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
|
|
writeFileSync(path.join(tmpDir, 'b.txt'), 'b')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified')
|
|
writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified')
|
|
writeFileSync(path.join(tmpDir, 'new.txt'), 'untracked')
|
|
|
|
await dispatcher.callRequest('git.bulkDiscard', {
|
|
worktreePath: tmpDir,
|
|
filePaths: ['a.txt', 'b.txt', 'new.txt']
|
|
})
|
|
|
|
await expect(fs.readFile(path.join(tmpDir, 'a.txt'), 'utf-8')).resolves.toBe('a')
|
|
await expect(fs.readFile(path.join(tmpDir, 'b.txt'), 'utf-8')).resolves.toBe('b')
|
|
await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow()
|
|
})
|
|
|
|
it('handles large tracked path lists during bulk discard classification', async () => {
|
|
const trackedStdout = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}.ts`)
|
|
.join('\0')
|
|
.concat('\0')
|
|
const gitMock = vi
|
|
.spyOn(
|
|
handler as unknown as {
|
|
git: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>
|
|
},
|
|
'git'
|
|
)
|
|
.mockResolvedValueOnce({ stdout: trackedStdout, stderr: '' })
|
|
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
|
|
|
await dispatcher.callRequest('git.bulkDiscard', {
|
|
worktreePath: tmpDir,
|
|
filePaths: ['docs']
|
|
})
|
|
|
|
expect(gitMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
['restore', '--worktree', '--source=HEAD', '--', ':(literal)docs'],
|
|
tmpDir
|
|
)
|
|
})
|
|
|
|
it('rejects path traversal', async () => {
|
|
gitInit(tmpDir)
|
|
await expect(
|
|
dispatcher.callRequest('git.discard', {
|
|
worktreePath: tmpDir,
|
|
filePath: '../../../etc/passwd'
|
|
})
|
|
).rejects.toThrow('outside the worktree')
|
|
})
|
|
|
|
it('rejects bulk discard path traversal', async () => {
|
|
gitInit(tmpDir)
|
|
await expect(
|
|
dispatcher.callRequest('git.bulkDiscard', {
|
|
worktreePath: tmpDir,
|
|
filePaths: ['file.txt', '../../../etc/passwd']
|
|
})
|
|
).rejects.toThrow('outside the worktree')
|
|
})
|
|
|
|
it('rejects untracked child paths through symlinked parents', async () => {
|
|
gitInit(tmpDir)
|
|
gitCommit(tmpDir, 'initial')
|
|
const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-'))
|
|
const outsideFile = path.join(outsideDir, 'keep.txt')
|
|
writeFileSync(outsideFile, 'outside')
|
|
symlinkSync(
|
|
outsideDir,
|
|
path.join(tmpDir, 'link'),
|
|
process.platform === 'win32' ? 'junction' : 'dir'
|
|
)
|
|
|
|
try {
|
|
await expect(
|
|
dispatcher.callRequest('git.discard', {
|
|
worktreePath: tmpDir,
|
|
filePath: 'link/keep.txt'
|
|
})
|
|
).rejects.toThrow('outside the worktree')
|
|
await expect(fs.access(outsideFile)).resolves.toBeUndefined()
|
|
} finally {
|
|
await fs.rm(outsideDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects bulk untracked child paths through symlinked parents before deleting anything', async () => {
|
|
gitInit(tmpDir)
|
|
gitCommit(tmpDir, 'initial')
|
|
const outsideDir = mkdtempSync(path.join(tmpdir(), 'relay-git-outside-'))
|
|
const outsideFile = path.join(outsideDir, 'keep.txt')
|
|
const untrackedFile = path.join(tmpDir, 'new.txt')
|
|
writeFileSync(outsideFile, 'outside')
|
|
writeFileSync(untrackedFile, 'untracked')
|
|
symlinkSync(
|
|
outsideDir,
|
|
path.join(tmpDir, 'link'),
|
|
process.platform === 'win32' ? 'junction' : 'dir'
|
|
)
|
|
|
|
try {
|
|
await expect(
|
|
dispatcher.callRequest('git.bulkDiscard', {
|
|
worktreePath: tmpDir,
|
|
filePaths: ['new.txt', 'link/keep.txt']
|
|
})
|
|
).rejects.toThrow('outside the worktree')
|
|
await expect(fs.access(outsideFile)).resolves.toBeUndefined()
|
|
await expect(fs.access(untrackedFile)).resolves.toBeUndefined()
|
|
} finally {
|
|
await fs.rm(outsideDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('conflictOperation', () => {
|
|
it('returns unknown for normal repo', async () => {
|
|
gitInit(tmpDir)
|
|
gitCommit(tmpDir, 'initial')
|
|
|
|
const result = await dispatcher.callRequest('git.conflictOperation', { worktreePath: tmpDir })
|
|
expect(result).toBe('unknown')
|
|
})
|
|
})
|
|
|
|
describe('branchCompare', () => {
|
|
it('compares branch against base', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
|
|
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature')
|
|
gitCommit(tmpDir, 'feature commit')
|
|
|
|
const result = (await dispatcher.callRequest('git.branchCompare', {
|
|
worktreePath: tmpDir,
|
|
baseRef: 'master'
|
|
})) as { summary: Record<string, unknown>; entries: Record<string, unknown>[] }
|
|
|
|
// May be 'master' or error if default branch is 'main'
|
|
if (result.summary.status === 'ready') {
|
|
expect(result.entries.length).toBeGreaterThan(0)
|
|
expect(result.summary.commitsAhead).toBe(1)
|
|
}
|
|
})
|
|
|
|
// Why: regression for issue #1503 on the branch-diff path. Without
|
|
// -c core.quotePath=false the diff --name-status output is octal-escaped,
|
|
// which broke the "Committed on branch" file list.
|
|
it('preserves UTF-8 paths in branch-compare entries', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
|
|
// Capture the default branch name before switching, so the test works
|
|
// regardless of whether git's init.defaultBranch is master or main.
|
|
const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
|
|
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
const utf8Dir = path.join(tmpDir, 'docs', '日本語')
|
|
mkdirSync(utf8Dir, { recursive: true })
|
|
writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello')
|
|
gitCommit(tmpDir, 'feature commit')
|
|
|
|
const result = (await dispatcher.callRequest('git.branchCompare', {
|
|
worktreePath: tmpDir,
|
|
baseRef
|
|
})) as { summary: Record<string, unknown>; entries: Record<string, unknown>[] }
|
|
|
|
expect(result.summary.status).toBe('ready')
|
|
const entry = result.entries.find((e) =>
|
|
typeof e.path === 'string' ? e.path.endsWith('sample.md') : false
|
|
)
|
|
expect(entry).toBeDefined()
|
|
expect(entry!.path).toBe('docs/日本語/sample.md')
|
|
})
|
|
|
|
it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
|
|
execFileSync('git', ['checkout', '--orphan', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
execFileSync('git', ['rm', '-rf', '.'], { cwd: tmpDir, stdio: 'pipe' })
|
|
|
|
const result = (await dispatcher.callRequest('git.branchCompare', {
|
|
worktreePath: tmpDir,
|
|
baseRef
|
|
})) as { summary: Record<string, unknown>; entries: Record<string, unknown>[] }
|
|
|
|
expect(result.summary).toMatchObject({
|
|
baseRef,
|
|
compareRef: 'feature',
|
|
headOid: null,
|
|
changedFiles: 0,
|
|
commitsAhead: 0,
|
|
status: 'ready'
|
|
})
|
|
expect(result.summary.baseOid).toMatch(/^[0-9a-f]{40}$/)
|
|
expect(result.entries).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('branchDiff', () => {
|
|
// Why: regression for issue #1503 on git.branchDiff. The branchCompare test
|
|
// covers loadBranchChanges in git-handler.ts, but branchDiffEntries in
|
|
// git-handler-ops.ts is a separate code path that also passes
|
|
// -c core.quotePath=false and must round-trip UTF-8.
|
|
it('preserves UTF-8 paths in branch-diff entries', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
|
|
const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
|
|
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
const utf8Dir = path.join(tmpDir, 'docs', '日本語')
|
|
mkdirSync(utf8Dir, { recursive: true })
|
|
writeFileSync(path.join(utf8Dir, 'sample.md'), 'hello')
|
|
gitCommit(tmpDir, 'feature commit')
|
|
|
|
const result = (await dispatcher.callRequest('git.branchDiff', {
|
|
worktreePath: tmpDir,
|
|
baseRef,
|
|
filePath: 'docs/日本語/sample.md'
|
|
})) as Record<string, unknown>[]
|
|
|
|
// Without includePatch, branchDiffEntries returns one stub entry per
|
|
// changed file. Asserting length===1 confirms the filter matched the
|
|
// raw UTF-8 path emitted by `git diff --name-status` — if quotePath
|
|
// were left at default, the entry's path would be the octal-quoted
|
|
// form and the filter at git-handler-ops.ts:230-237 would not match.
|
|
expect(result).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('remote operations', () => {
|
|
it('returns upstream divergence for tracked branches', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
|
|
const result = (await dispatcher.callRequest('git.upstreamStatus', {
|
|
worktreePath: tmpDir
|
|
})) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number }
|
|
|
|
expect(result.hasUpstream).toBe(false)
|
|
expect(result.ahead).toBe(0)
|
|
expect(result.behind).toBe(0)
|
|
})
|
|
|
|
it('reports ahead/behind counts against a real upstream remote', async () => {
|
|
// Why: the upstream branch exists but isn't configured — exercise the
|
|
// full path through `git rev-parse HEAD@{u}` + `rev-list --left-right`
|
|
// so a future refactor can't silently break the happy-path roundtrip
|
|
// the no-upstream test doesn't cover.
|
|
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
|
|
try {
|
|
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
|
|
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
|
|
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
// Add two local commits (ahead=2), then reset behind the remote tip
|
|
// and add one different commit so we end up ahead=1, behind=0 vs.
|
|
// upstream; then reset to first commit to produce behind=1 ahead=0.
|
|
writeFileSync(path.join(tmpDir, 'ahead1.txt'), 'a1')
|
|
gitCommit(tmpDir, 'ahead1')
|
|
writeFileSync(path.join(tmpDir, 'ahead2.txt'), 'a2')
|
|
gitCommit(tmpDir, 'ahead2')
|
|
// Push so remote is at ahead2 (so after we reset below, we are behind).
|
|
execFileSync('git', ['push', 'origin', branch], { cwd: tmpDir, stdio: 'pipe' })
|
|
// Reset local back to the first commit: 0 ahead, 2 behind.
|
|
execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' })
|
|
|
|
const result = (await dispatcher.callRequest('git.upstreamStatus', {
|
|
worktreePath: tmpDir
|
|
})) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number }
|
|
|
|
expect(result.hasUpstream).toBe(true)
|
|
expect(result.upstreamName).toBe(`origin/${branch}`)
|
|
expect(result.ahead).toBe(0)
|
|
expect(result.behind).toBe(2)
|
|
} finally {
|
|
await fs.rm(bareDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('reports ahead/behind counts against a configured local-branch upstream', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
|
|
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
|
|
execFileSync('git', ['branch', '--set-upstream-to', baseRef], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
writeFileSync(path.join(tmpDir, 'feature.txt'), 'feature')
|
|
gitCommit(tmpDir, 'feature commit')
|
|
|
|
const result = (await dispatcher.callRequest('git.upstreamStatus', {
|
|
worktreePath: tmpDir
|
|
})) as { hasUpstream: boolean; upstreamName?: string; ahead: number; behind: number }
|
|
|
|
expect(result.hasUpstream).toBe(true)
|
|
expect(result.upstreamName).toBe(baseRef)
|
|
expect(result.ahead).toBe(1)
|
|
expect(result.behind).toBe(0)
|
|
})
|
|
|
|
it('fetches from a configured remote without throwing', async () => {
|
|
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
|
|
try {
|
|
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
|
|
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.fetch', { worktreePath: tmpDir })
|
|
).resolves.not.toThrow()
|
|
|
|
// FETCH_HEAD is created by any successful fetch, confirming the
|
|
// remote was actually contacted (not just silently no-op'd).
|
|
await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined()
|
|
} finally {
|
|
await fs.rm(bareDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('fetches the explicit publish target remote', async () => {
|
|
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-fork-bare-'))
|
|
try {
|
|
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
|
|
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
execFileSync('git', ['remote', 'add', 'fork', bareDir], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['push', 'fork', 'HEAD:feature/fix'], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.fetch', {
|
|
worktreePath: tmpDir,
|
|
pushTarget: { remoteName: 'fork', branchName: 'feature/fix' }
|
|
})
|
|
).resolves.not.toThrow()
|
|
|
|
await expect(fs.access(path.join(tmpDir, '.git', 'FETCH_HEAD'))).resolves.toBeUndefined()
|
|
} finally {
|
|
await fs.rm(bareDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('fast-forwards the tracked branch with ff-only pull semantics', async () => {
|
|
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
|
|
const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-'))
|
|
const producerDir = path.join(producerParent, 'repo')
|
|
try {
|
|
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
|
|
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' })
|
|
execFileSync('git', ['config', 'user.email', 'test@test.com'], {
|
|
cwd: producerDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['config', 'user.name', 'Test'], {
|
|
cwd: producerDir,
|
|
stdio: 'pipe'
|
|
})
|
|
writeFileSync(path.join(producerDir, 'remote.txt'), 'remote')
|
|
gitCommit(producerDir, 'remote commit')
|
|
execFileSync('git', ['push', 'origin', branch], {
|
|
cwd: producerDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
await dispatcher.callRequest('git.fastForward', { worktreePath: tmpDir })
|
|
|
|
await expect(fs.readFile(path.join(tmpDir, 'remote.txt'), 'utf-8')).resolves.toBe('remote')
|
|
} finally {
|
|
await fs.rm(bareDir, { recursive: true, force: true })
|
|
await fs.rm(producerParent, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('refreshes one remote-tracking ref from a configured remote', async () => {
|
|
const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-'))
|
|
const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-'))
|
|
const producerDir = path.join(producerParent, 'repo')
|
|
try {
|
|
execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' })
|
|
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['remote', 'add', 'origin', bareDir], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['push', '--set-upstream', 'origin', branch], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' })
|
|
execFileSync('git', ['config', 'user.email', 'test@test.com'], {
|
|
cwd: producerDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['config', 'user.name', 'Test'], {
|
|
cwd: producerDir,
|
|
stdio: 'pipe'
|
|
})
|
|
writeFileSync(path.join(producerDir, 'base.txt'), 'updated')
|
|
gitCommit(producerDir, 'remote update')
|
|
execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' })
|
|
const expected = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: producerDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
|
|
await dispatcher.callRequest('git.fetchRemoteTrackingRef', {
|
|
worktreePath: tmpDir,
|
|
remote: 'origin',
|
|
branch,
|
|
ref: `refs/remotes/origin/${branch}`
|
|
})
|
|
|
|
const actual = execFileSync('git', ['rev-parse', `refs/remotes/origin/${branch}`], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
expect(actual).toBe(expected)
|
|
} finally {
|
|
await fs.rm(bareDir, { recursive: true, force: true })
|
|
await fs.rm(producerParent, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects remote-tracking refreshes that target a different ref', async () => {
|
|
gitInit(tmpDir)
|
|
execFileSync('git', ['remote', 'add', 'origin', tmpDir], { cwd: tmpDir, stdio: 'pipe' })
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.fetchRemoteTrackingRef', {
|
|
worktreePath: tmpDir,
|
|
remote: 'origin',
|
|
branch: 'main',
|
|
ref: 'refs/remotes/origin/other'
|
|
})
|
|
).rejects.toThrow('Remote-tracking ref does not match the requested remote and branch.')
|
|
})
|
|
|
|
it('rethrows upstreamStatus failures that are not "no upstream configured"', async () => {
|
|
// Why: the handler's catch is narrowed to only swallow the expected
|
|
// "no upstream" signal. A non-repo path should surface its error rather
|
|
// than silently returning hasUpstream=false, which would mask auth or
|
|
// corruption failures in production.
|
|
const nonRepoDir = path.join(tmpDir, 'not-a-repo')
|
|
await fs.mkdir(nonRepoDir, { recursive: true })
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.upstreamStatus', { worktreePath: nonRepoDir })
|
|
).rejects.toThrow(/not a git repository/i)
|
|
})
|
|
})
|
|
|
|
describe('listWorktrees', () => {
|
|
it('lists worktrees for a repo', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
|
|
gitCommit(tmpDir, 'initial')
|
|
|
|
const result = (await dispatcher.callRequest('git.listWorktrees', {
|
|
repoPath: tmpDir
|
|
})) as Record<string, unknown>[]
|
|
expect(result.length).toBeGreaterThanOrEqual(1)
|
|
expect(result[0].isMainWorktree).toBe(true)
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'lists worktrees whose paths contain newlines',
|
|
async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
|
|
gitCommit(tmpDir, 'initial')
|
|
const worktreePath = path.join(
|
|
path.dirname(tmpDir),
|
|
`${path.basename(tmpDir)}-linked\nremote`
|
|
)
|
|
|
|
try {
|
|
execFileSync(
|
|
'git',
|
|
['worktree', 'add', '--quiet', '-b', 'feature/newline', worktreePath],
|
|
{
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
}
|
|
)
|
|
const realWorktreePath = await fs.realpath(worktreePath)
|
|
|
|
const result = (await dispatcher.callRequest('git.listWorktrees', {
|
|
repoPath: tmpDir
|
|
})) as Record<string, unknown>[]
|
|
|
|
expect(result.map((worktree) => worktree.path)).toContain(realWorktreePath)
|
|
} finally {
|
|
await fs.rm(worktreePath, { recursive: true, force: true })
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('worktreeIsClean', () => {
|
|
it('can ignore untracked files', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'tracked.txt'), 'initial')
|
|
gitCommit(tmpDir, 'initial')
|
|
writeFileSync(path.join(tmpDir, 'scratch.txt'), 'untracked')
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.worktreeIsClean', { worktreePath: tmpDir })
|
|
).resolves.toEqual({
|
|
clean: false,
|
|
stdout: expect.stringContaining('scratch.txt')
|
|
})
|
|
await expect(
|
|
dispatcher.callRequest('git.worktreeIsClean', {
|
|
worktreePath: tmpDir,
|
|
includeUntracked: false
|
|
})
|
|
).resolves.toEqual({ clean: true })
|
|
})
|
|
})
|
|
|
|
describe('refreshLocalBaseRefForWorktreeCreate', () => {
|
|
function setupMockedRefreshHandler() {
|
|
const localDispatcher = createMockDispatcher()
|
|
const localHandler = new GitHandler(
|
|
localDispatcher as unknown as RelayDispatcher,
|
|
new RelayContext()
|
|
)
|
|
const gitMock =
|
|
vi.fn<
|
|
(
|
|
args: string[],
|
|
cwd: string,
|
|
opts?: { maxBuffer?: number }
|
|
) => Promise<{ stdout: string; stderr: string }>
|
|
>()
|
|
;(localHandler as unknown as { git: typeof gitMock }).git = gitMock
|
|
return { localDispatcher, gitMock }
|
|
}
|
|
|
|
it('resets the owning worktree to the remote-tracking ref', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const branchRef = currentBranchFullRef(tmpDir)
|
|
const ownerPath = reportedWorktreePath(tmpDir)
|
|
const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
|
|
gitCommit(tmpDir, 'remote update')
|
|
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' })
|
|
|
|
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: tmpDir,
|
|
fullRef: branchRef,
|
|
remoteTrackingRef: 'refs/remotes/origin/main',
|
|
ownerWorktreePath: ownerPath
|
|
})
|
|
|
|
const actual = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
expect(actual).toBe(remoteSha)
|
|
await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('remote')
|
|
})
|
|
|
|
it('fast-forwards a non-checked-out local branch via update-ref', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
|
|
gitCommit(tmpDir, 'remote update')
|
|
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: tmpDir,
|
|
fullRef: 'refs/heads/main-copy',
|
|
remoteTrackingRef: 'refs/remotes/origin/main'
|
|
})
|
|
|
|
// No working tree owns main-copy, so the bare ref fast-forwards.
|
|
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
expect(actual).toBe(remoteSha)
|
|
})
|
|
|
|
it('does not move a non-checked-out local branch when checkOnly is set', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
|
|
const originalSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
|
|
gitCommit(tmpDir, 'remote update')
|
|
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: tmpDir,
|
|
fullRef: 'refs/heads/main-copy',
|
|
remoteTrackingRef: 'refs/remotes/origin/main',
|
|
checkOnly: true
|
|
})
|
|
|
|
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
expect(actual).toBe(originalSha)
|
|
})
|
|
|
|
it('rejects invalid local base ref refresh refs', async () => {
|
|
gitInit(tmpDir)
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: tmpDir,
|
|
fullRef: 'refs/tags/main',
|
|
remoteTrackingRef: 'refs/remotes/origin/main'
|
|
})
|
|
).rejects.toThrow('Invalid local base ref refresh refs.')
|
|
})
|
|
|
|
it('rejects a dirty owner worktree before resetting', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const branchRef = currentBranchFullRef(tmpDir)
|
|
const ownerPath = reportedWorktreePath(tmpDir)
|
|
const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
|
|
gitCommit(tmpDir, 'remote update')
|
|
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'local dirty')
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: tmpDir,
|
|
fullRef: branchRef,
|
|
remoteTrackingRef: 'refs/remotes/origin/main',
|
|
ownerWorktreePath: ownerPath
|
|
})
|
|
).rejects.toThrow('Local base ref worktree has tracked changes.')
|
|
|
|
const actual = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
expect(actual).toBe(firstSha)
|
|
await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('local dirty')
|
|
})
|
|
|
|
it('rejects when the caller-supplied owner path is not the checked-out branch owner', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
const branchRef = currentBranchFullRef(tmpDir)
|
|
const headSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', headSha], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: tmpDir,
|
|
fullRef: branchRef,
|
|
remoteTrackingRef: 'refs/remotes/origin/main',
|
|
ownerWorktreePath: path.join(path.dirname(tmpDir), 'different-owner')
|
|
})
|
|
).rejects.toThrow('Local base ref is checked out in a different worktree.')
|
|
})
|
|
|
|
it('rejects diverged local refs before mutating', async () => {
|
|
gitInit(tmpDir)
|
|
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
|
|
gitCommit(tmpDir, 'initial')
|
|
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'remote.txt'), 'remote')
|
|
gitCommit(tmpDir, 'remote update')
|
|
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
|
|
cwd: tmpDir,
|
|
stdio: 'pipe'
|
|
})
|
|
execFileSync('git', ['checkout', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
|
|
writeFileSync(path.join(tmpDir, 'local.txt'), 'local')
|
|
gitCommit(tmpDir, 'local update')
|
|
const localSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
|
|
await expect(
|
|
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: tmpDir,
|
|
fullRef: 'refs/heads/main-copy',
|
|
remoteTrackingRef: 'refs/remotes/origin/main',
|
|
ownerWorktreePath: tmpDir
|
|
})
|
|
).rejects.toThrow('Local base ref is not a fast-forward update.')
|
|
|
|
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
|
|
cwd: tmpDir,
|
|
encoding: 'utf-8'
|
|
}).trim()
|
|
expect(actual).toBe(localSha)
|
|
})
|
|
|
|
it('resets owner worktree to captured remote OID without update-ref', async () => {
|
|
const { localDispatcher, gitMock } = setupMockedRefreshHandler()
|
|
gitMock.mockImplementation(async (args: string[]) => {
|
|
if (args[0] === 'check-ref-format') {
|
|
return { stdout: '', stderr: '' }
|
|
}
|
|
if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') {
|
|
return { stdout: 'remote-oid\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: 'old-local-oid\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'merge-base') {
|
|
return { stdout: '', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree') {
|
|
return {
|
|
stdout: 'worktree /repo\nHEAD old-local-oid\nbranch refs/heads/main\n',
|
|
stderr: ''
|
|
}
|
|
}
|
|
if (args[0] === 'status') {
|
|
return { stdout: '', stderr: '' }
|
|
}
|
|
if (args[0] === 'reset') {
|
|
return { stdout: '', stderr: '' }
|
|
}
|
|
throw new Error(`unexpected git call: ${args.join(' ')}`)
|
|
})
|
|
|
|
await expect(
|
|
localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: '/repo',
|
|
fullRef: 'refs/heads/main',
|
|
remoteTrackingRef: 'refs/remotes/origin/main'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(gitMock).toHaveBeenCalledWith(
|
|
['merge-base', '--is-ancestor', 'old-local-oid', 'remote-oid'],
|
|
'/repo'
|
|
)
|
|
expect(gitMock).toHaveBeenCalledWith(['reset', '--hard', 'remote-oid'], '/repo')
|
|
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'update-ref',
|
|
'refs/heads/main',
|
|
'remote-oid',
|
|
'old-local-oid'
|
|
])
|
|
})
|
|
|
|
it('fails closed when worktree ownership cannot be listed', async () => {
|
|
const { localDispatcher, gitMock } = setupMockedRefreshHandler()
|
|
gitMock.mockImplementation(async (args: string[]) => {
|
|
if (args[0] === 'check-ref-format') {
|
|
return { stdout: '', stderr: '' }
|
|
}
|
|
if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') {
|
|
return { stdout: 'remote-oid\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: 'old-local-oid\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'merge-base') {
|
|
return { stdout: '', stderr: '' }
|
|
}
|
|
if (args[0] === 'worktree') {
|
|
throw new Error('worktree list failed')
|
|
}
|
|
throw new Error(`unexpected git call: ${args.join(' ')}`)
|
|
})
|
|
|
|
await expect(
|
|
localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
|
|
repoPath: '/repo',
|
|
fullRef: 'refs/heads/main',
|
|
remoteTrackingRef: 'refs/remotes/origin/main'
|
|
})
|
|
).rejects.toThrow('worktree list failed')
|
|
|
|
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'update-ref',
|
|
'refs/heads/main',
|
|
'refs/remotes/origin/main',
|
|
'old-local-oid'
|
|
])
|
|
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
|
|
'reset',
|
|
'--hard',
|
|
'refs/heads/main'
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('addWorktree', () => {
|
|
// Why: relay handler tests for addWorktree use a mock-injection approach
|
|
// to deterministically control git exit codes (in particular `--get` exit
|
|
// 1 vs other non-zero codes) without relying on the test host's global
|
|
// git config. Mirrors the pattern in src/main/git/worktree.test.ts.
|
|
function setupMockedHandler(roots: string[]) {
|
|
const ctx = new RelayContext()
|
|
for (const r of roots) {
|
|
ctx.registerRoot(r)
|
|
}
|
|
const localDispatcher = createMockDispatcher()
|
|
const handler = new GitHandler(localDispatcher as unknown as RelayDispatcher, ctx)
|
|
const gitMock =
|
|
vi.fn<
|
|
(
|
|
args: string[],
|
|
cwd: string,
|
|
opts?: { maxBuffer?: number }
|
|
) => Promise<{ stdout: string; stderr: string }>
|
|
>()
|
|
;(handler as unknown as { git: typeof gitMock }).git = gitMock
|
|
return { localDispatcher, gitMock }
|
|
}
|
|
|
|
it('passes --no-track and writes push.autoSetupRemote when unset', async () => {
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/remotes/origin/main^{commit}
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch.<branch>.base
|
|
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
|
|
|
|
await localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/test',
|
|
targetDir: '/relay/wt',
|
|
base: 'origin/main'
|
|
})
|
|
|
|
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
|
|
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
|
[
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'-b',
|
|
'feature/test',
|
|
'/relay/wt',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/test.base',
|
|
'refs/remotes/origin/main'
|
|
],
|
|
['config', '--get', 'push.autoSetupRemote'],
|
|
['config', '--local', 'push.autoSetupRemote', 'true']
|
|
])
|
|
// cwd for worktree add is repoPath; cwd for config calls is targetDir.
|
|
expect(gitMock.mock.calls[0]?.[1]).toBe('/relay/repo')
|
|
expect(gitMock.mock.calls[1]?.[1]).toBe('/relay/repo')
|
|
expect(gitMock.mock.calls[2]?.[1]).toBe('/relay/wt')
|
|
expect(gitMock.mock.calls[3]?.[1]).toBe('/relay/wt')
|
|
expect(gitMock.mock.calls[4]?.[1]).toBe('/relay/wt')
|
|
})
|
|
|
|
it('checks out a selected existing local branch without creating a new branch', async () => {
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
|
|
await localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/test',
|
|
targetDir: '/relay/wt',
|
|
base: 'feature/test',
|
|
checkoutExistingBranch: true
|
|
})
|
|
|
|
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
|
|
['worktree', 'add', '/relay/wt', 'feature/test']
|
|
])
|
|
})
|
|
|
|
it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => {
|
|
// Why: repos that fetch with --tags can end up with a local tag named
|
|
// 'main', making `git worktree add ... main` fail with "fatal: Ambiguous
|
|
// object name". Qualifying as refs/heads/main tells git exactly which
|
|
// object to use.
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/main^{commit}
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch.<branch>.base
|
|
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
|
|
|
|
await localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/disambig',
|
|
targetDir: '/relay/wt',
|
|
base: 'main'
|
|
})
|
|
|
|
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
|
|
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
|
|
['worktree', 'add', '--no-track', '-b', 'feature/disambig', '/relay/wt', 'refs/heads/main'],
|
|
['config', '--local', '--replace-all', 'branch.feature/disambig.base', 'refs/heads/main'],
|
|
['config', '--get', 'push.autoSetupRemote'],
|
|
['config', '--local', 'push.autoSetupRemote', 'true']
|
|
])
|
|
})
|
|
|
|
it('qualifies slash-containing local branch names when no remote ref matches', async () => {
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit}
|
|
gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/release/main^{commit}
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch.<branch>.base
|
|
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
|
|
|
|
await localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/release',
|
|
targetDir: '/relay/wt',
|
|
base: 'release/main'
|
|
})
|
|
|
|
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
|
|
['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'],
|
|
['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'],
|
|
[
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'-b',
|
|
'feature/release',
|
|
'/relay/wt',
|
|
'refs/heads/release/main'
|
|
],
|
|
[
|
|
'config',
|
|
'--local',
|
|
'--replace-all',
|
|
'branch.feature/release.base',
|
|
'refs/heads/release/main'
|
|
],
|
|
['config', '--get', 'push.autoSetupRemote'],
|
|
['config', '--local', 'push.autoSetupRemote', 'true']
|
|
])
|
|
})
|
|
|
|
it('passes --no-checkout when sparse setup will checkout after configuration', async () => {
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // rev-parse refs/remotes/origin/main
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch.<branch>.base
|
|
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
|
|
|
|
await localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/sparse',
|
|
targetDir: '/relay/wt',
|
|
base: 'origin/main',
|
|
noCheckout: true
|
|
})
|
|
|
|
expect(gitMock.mock.calls[1]?.[0]).toEqual([
|
|
'worktree',
|
|
'add',
|
|
'--no-track',
|
|
'--no-checkout',
|
|
'-b',
|
|
'feature/sparse',
|
|
'/relay/wt',
|
|
'refs/remotes/origin/main'
|
|
])
|
|
})
|
|
|
|
it('preserves an existing push.autoSetupRemote value (does not overwrite user-set false)', async () => {
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch.<branch>.base
|
|
gitMock.mockResolvedValueOnce({ stdout: 'false\n', stderr: '' }) // --get returns value
|
|
|
|
await localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/preserve',
|
|
targetDir: '/relay/wt',
|
|
base: 'main'
|
|
})
|
|
|
|
// No --local set: --get succeeded so we preserve the user's value.
|
|
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
|
|
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
|
|
['worktree', 'add', '--no-track', '-b', 'feature/preserve', '/relay/wt', 'main'],
|
|
['config', '--local', '--replace-all', 'branch.feature/preserve.base', 'main'],
|
|
['config', '--get', 'push.autoSetupRemote']
|
|
])
|
|
})
|
|
|
|
it('treats --get success with empty stdout as "already set" (key present but blank)', async () => {
|
|
// Why: `git config --get key` exits 0 if the key has any value at any
|
|
// scope, including an explicitly empty string. We must not fall through
|
|
// to `--local set true` and overwrite that. Mirrors the local addWorktree
|
|
// parity case in src/main/git/worktree.test.ts.
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch.<branch>.base
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --get success, empty value
|
|
|
|
await localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/empty',
|
|
targetDir: '/relay/wt',
|
|
base: 'main'
|
|
})
|
|
|
|
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
|
|
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
|
|
['worktree', 'add', '--no-track', '-b', 'feature/empty', '/relay/wt', 'main'],
|
|
['config', '--local', '--replace-all', 'branch.feature/empty.base', 'main'],
|
|
['config', '--get', 'push.autoSetupRemote']
|
|
])
|
|
})
|
|
|
|
it('does not write --local when --get fails with non-unset code (corrupt config)', async () => {
|
|
// Why: exit 1 from `git config --get` means "key unset" — anything else
|
|
// is a real read failure (parse error, locked file). We must NOT fall
|
|
// through to `--local set true`, which would silently overwrite
|
|
// whatever value the user actually has.
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch.<branch>.base
|
|
gitMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get non-unset
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
await expect(
|
|
localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/corrupt',
|
|
targetDir: '/relay/wt',
|
|
base: 'main'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
|
|
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
|
|
['worktree', 'add', '--no-track', '-b', 'feature/corrupt', '/relay/wt', 'main'],
|
|
['config', '--local', '--replace-all', 'branch.feature/corrupt.base', 'main'],
|
|
['config', '--get', 'push.autoSetupRemote']
|
|
])
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'relay addWorktree: failed to set push.autoSetupRemote for /relay/wt',
|
|
expect.any(Error)
|
|
)
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('warns but resolves when --local set fails (write-failure is warn-only)', async () => {
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
|
|
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch.<branch>.base
|
|
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset
|
|
gitMock.mockRejectedValueOnce(new Error('config locked')) // --local set fails
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
await expect(
|
|
localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/writefail',
|
|
targetDir: '/relay/wt',
|
|
base: 'main'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'relay addWorktree: failed to set push.autoSetupRemote for /relay/wt',
|
|
expect.any(Error)
|
|
)
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('does not write config when worktree add itself fails', async () => {
|
|
// Why: a refactor that moves the config block earlier could try to
|
|
// probe config against a worktree directory that was never created. Pin
|
|
// the ordering invariant: config calls happen only after worktree add succeeds.
|
|
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
|
|
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
|
|
gitMock.mockRejectedValueOnce(new Error('worktree add failed'))
|
|
|
|
await expect(
|
|
localDispatcher.callRequest('git.addWorktree', {
|
|
repoPath: '/relay/repo',
|
|
branchName: 'feature/fail',
|
|
targetDir: '/relay/wt',
|
|
base: 'main'
|
|
})
|
|
).rejects.toThrow('worktree add failed')
|
|
|
|
expect(gitMock.mock.calls.map((c) => c[0])).toEqual([
|
|
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
|
|
['worktree', 'add', '--no-track', '-b', 'feature/fail', '/relay/wt', 'main']
|
|
])
|
|
})
|
|
})
|
|
})
|