Files
orca/src/main/ipc/worktree-logic.test.ts
T
Neil a781a602a8 test: retire duplicate cases that replay an owner across a re-export or provider shim (#24114)
Resolves 208 candidate pairs where the same case title appears verbatim in two or more
files, produced by a repo-wide scan calibrated against a known positive. 46 case
declarations removed across 32 files, 798 lines gone. No file deleted whole, no
production code touched.

The headline result is the measurement, not the deletions: across the three buckets that
reported in detail, the signal ran roughly 86% false-positive (3/42, 9/42, and the rest).
It has good recall and poor precision, and it reorders a reading queue rather than
replacing one. Calibrating a detector against a known positive proves recall, not
precision.

What the deletions were:

- Duplicate invocation through a re-export shim. `native-chat-tool-summary.ts` is a
  ten-line `export {...} from '../../../../shared/native-chat-tool-summary'`, and
  `agent-status.ts:161` is `export { isExplicitAgentStatusFresh } from
  './pane-agent-evidence'`. Cases on the shim side were byte-equivalent to the owner's
  with no rendering or transport hop.
- Provider-local replays of a shared helper: three `repository-ref` providers that are
  each `createRemoteRefProbeCache(parseXRef)` and contribute nothing to transient
  handling; two `local-pty` and `daemon/session` tables replaying
  `shell-startup-output-scanner`, whose owner additionally checks every split point.
- A reader-side replay of store policy. `runtime-worktree-agent-rows-structured.test.ts`
  asserted an attention-to-blocked mapping; the reader contains zero `attention` or
  `blocked` tokens and copies `state` through. The mapping lives in
  `structuredAgentSessionAgentStatus`. Consistent with
  `docs/reference/agent-status-store.md`: readers keep only presentation policy.
- Constructor-only subclass duplication: the shared capability-cache case is covered by
  `codex-app-server-capability-cache.test.ts`, whose ten cases include the identical
  title plus all four risks `docs/reference/git-compatibility.md` names — first fallback,
  later cached call, concurrent probes, per-host isolation.
- A private predicate duplicated at a real boundary, varying only a path passed straight
  into the shared predicate.

Why most pairs were KEPT, because the false positives are principled rather than noise:

- Two independent execution hosts. `src/relay/git-handler-*` and `src/main/git/*` are
  separate Git implementations that cannot import each other and hold separate capability
  caches, exactly as the compatibility doc requires; the repo already ships
  `status-branch-line-total-relay-parity.test.ts` to pin the duality deliberately. Neither
  side's argv, timeout or cache regression is visible to the other.
- Deliberately duplicated production siblings: Codex vs Claude (different account fields,
  different CLIs, different wire protocols), gitea vs bitbucket (`/pulls/42` vs
  `/pullrequests/42`), gl-utils vs gh-utils (separate in-flight maps). Same contract
  shape, different implementations — an identical title is the correct naming.
- Shared-predicate consumers: one side tests the predicate, the other tests a caller's
  wiring to it. A caller that forgot to call the predicate passes the shared test.

In a codebase with intentional provider and host symmetry, identical test titles are
expected, and the signal cannot distinguish "copied" from "parallel by design" because
both produce the same prose. Only reading both bodies separates them.

Verified: 6,968 desktop test files pass; the three modified mobile files pass (39 cases);
`check-reliability-gates.mjs` 140 gates; nothing under
`mobile/src/test-support/rpc-recording/` or `mobile/rpc-foundation/goldens/` touched.

62 local failures across 12 files were each accounted for and none is caused by this
change: `browser-manager-tab-identity`, `browser-manager-viewport-ownership`,
`session-scanner-codex-workers` and `managed-hook-script-refresh` all fail identically in
a pristine `origin/main` worktree; five `mobile-web-app-*-render` tests need Playwright
browsers this machine lacks; `structured-agent-session-restart-ownership` and
`ssh-remote-commands` pass in isolation and fail only under concurrent load.
2026-09-30 03:30:23 -07:00

799 lines
26 KiB
TypeScript

import { posix, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
sanitizeWorktreeName,
sanitizeWorktreeDisplayName,
resolveWorktreeCreateDisplayName,
resolveWorktreeCreateDisplayNameRequest,
resolveWorktreeCreateDisplayNameMeta,
ensurePathWithinWorkspace,
computeBranchName,
getConfiguredBranchPrefix,
computeValidatedBranchName,
computeWorktreePath,
computeRemoteWorktreePath,
computeWorkspaceRoot,
getWorktreeCreationLayout,
getWorktreePathSettings,
shouldSetDisplayName,
mergeWorktree,
parseWorktreeId,
formatWorktreeRemovalError,
isWindowsLongPathWorktreeRemovalError,
isOrphanCompatiblePreflightError,
isOrphanedWorktreeError,
areWorktreePathsEqual
} from './worktree-logic'
describe('sanitizeWorktreeName', () => {
it('replaces spaces with hyphens', () => {
expect(sanitizeWorktreeName('my feature')).toBe('my-feature')
})
it('collapses multiple spaces to a single hyphen', () => {
expect(sanitizeWorktreeName('my big feature')).toBe('my-big-feature')
})
it('trims leading and trailing whitespace', () => {
expect(sanitizeWorktreeName(' padded name ')).toBe('padded-name')
})
it('returns the name unchanged when there are no spaces', () => {
expect(sanitizeWorktreeName('no-spaces')).toBe('no-spaces')
})
it('strips unsafe characters', () => {
expect(sanitizeWorktreeName('feat@#$ure')).toBe('feat-ure')
})
it('collapses consecutive hyphens', () => {
expect(sanitizeWorktreeName('a---b')).toBe('a-b')
})
it('strips leading/trailing dots and hyphens', () => {
expect(sanitizeWorktreeName('.hidden-')).toBe('hidden')
})
it('collapses internal consecutive dots so git check-ref-format accepts the branch', () => {
// Why: a prompt containing `../../` used to slugify to `..-..-foo` and
// survive sanitization with `..` intact. `git branch` then rejected it
// with "is not a valid branch name", breaking worktree creation from the
// composer's auto-named branches.
expect(sanitizeWorktreeName('for-..-..-feature')).toBe('for-.-.-feature')
expect(sanitizeWorktreeName('a..b...c')).toBe('a.b.c')
})
it('preserves non-ASCII letters and numbers', () => {
// Why: users name workspaces in their own language (CJK, accented Latin,
// Cyrillic, etc.). Stripping these to ASCII left the name empty and threw
// "Invalid worktree name" on every non-Latin keyboard input.
expect(sanitizeWorktreeName('中文')).toBe('中文')
expect(sanitizeWorktreeName('日本語 テスト')).toBe('日本語-テスト')
expect(sanitizeWorktreeName('café-déjà')).toBe('café-déjà')
expect(sanitizeWorktreeName('Привет мир')).toBe('Привет-мир')
})
it('still strips git-unsafe punctuation around Unicode names', () => {
expect(sanitizeWorktreeName('feat: 中文 (v2)')).toBe('feat-中文-v2')
})
it('uses readable git-safe shortcodes for known emoji', () => {
expect(sanitizeWorktreeName('🚀')).toBe('rocket')
expect(sanitizeWorktreeName('👩‍💻✨')).toBe('woman-technologist-sparkles')
expect(sanitizeWorktreeName('🇯🇵')).toBe('japan')
expect(sanitizeWorktreeName('👎')).toBe('thumbsdown')
expect(sanitizeWorktreeName('1️⃣')).toBe('one')
})
it('keeps readable text and emoji shortcodes in branch and path names', () => {
expect(sanitizeWorktreeName('Ship it 🚀')).toBe('Ship-it-rocket')
})
it('uses a git-safe fallback for emoji newer than the shortcode catalog', () => {
// Unassigned in Unicode 17, so no emojibase shortcode can cover it yet.
expect(sanitizeWorktreeName('\u{1faeb}')).toBe('workspace')
})
it('does not treat arbitrary punctuation as a workspace name', () => {
expect(() => sanitizeWorktreeName('!!!')).toThrow('Invalid worktree name')
})
it('throws for empty name', () => {
expect(() => sanitizeWorktreeName('')).toThrow('Invalid worktree name')
})
it('throws for whitespace-only name', () => {
expect(() => sanitizeWorktreeName(' ')).toThrow('Invalid worktree name')
})
})
describe('sanitizeWorktreeDisplayName', () => {
it('preserves emoji in display names', () => {
expect(sanitizeWorktreeDisplayName(' Ship it 🚀 ')).toBe('Ship it 🚀')
expect(sanitizeWorktreeDisplayName('👩‍💻')).toBe('👩‍💻')
})
it('keeps readable punctuation while collapsing unsafe controls and whitespace', () => {
expect(sanitizeWorktreeDisplayName(' Fix: login / callback\n\tregression\u0000 ')).toBe(
'Fix: login / callback regression'
)
})
it('strips bidi override controls from external artifact titles', () => {
expect(sanitizeWorktreeDisplayName('Review \u202eexe.txt')).toBe('Review exe.txt')
})
it('truncates very long titles', () => {
const title = 'a'.repeat(200)
expect(sanitizeWorktreeDisplayName(title)).toHaveLength(120)
})
it('returns undefined when nothing displayable remains', () => {
expect(sanitizeWorktreeDisplayName('\u0000\n\t')).toBeUndefined()
})
it('returns undefined for an unusable user label', () => {
expect(resolveWorktreeCreateDisplayName('\u0000\u202e', 'user')).toBeUndefined()
})
})
describe('worktree create display-name provenance', () => {
it('recovers the name-only contract from an older CLI request', () => {
expect(resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'feature', true)).toEqual({
value: 'feature',
kind: 'user'
})
})
it('recovers a legacy name-only user create without CLI provenance', () => {
expect(resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'feature', false)).toEqual(
{
value: 'feature',
kind: 'user'
}
)
expect(
resolveWorktreeCreateDisplayNameMeta('feature', 'feature', 'user', {
requestedName: 'feature',
sanitizedName: 'feature'
})
).toEqual({ displayName: 'feature', displayNameIsPinned: true })
})
it('keeps a legacy generated name automatic when nameWasGenerated is set', () => {
expect(
resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'nautilus', false, true)
).toEqual({
value: undefined,
kind: 'generated'
})
})
it('keeps a legacy artifact display name generated when its kind is absent', () => {
expect(
resolveWorktreeCreateDisplayNameRequest('Issue title', undefined, 'feature', false)
).toEqual({ value: 'Issue title', kind: 'generated' })
})
it('treats a CLI name as intentional even if a caller supplies generated provenance', () => {
expect(
resolveWorktreeCreateDisplayNameRequest('Agent label', 'generated', 'feature', true)
).toEqual({ value: 'Agent label', kind: 'user' })
})
it('preserves exact user text apart from edge whitespace and controls', () => {
expect(resolveWorktreeCreateDisplayName(' My Label\n', 'user')).toBe('My Label')
})
it('pins user labels without adding collision suffixes to visible text', () => {
expect(
resolveWorktreeCreateDisplayNameMeta('My Label', 'my-label-2', 'user', {
requestedName: 'My Label',
sanitizedName: 'my-label-2'
})
).toEqual({ displayName: 'My Label', displayNameIsPinned: true })
})
it('keeps generated labels automatic only when they equal the branch', () => {
expect(
resolveWorktreeCreateDisplayNameMeta('Issue title', 'feature-2', 'generated', {
requestedName: 'feature-2',
sanitizedName: 'feature-2'
})
).toEqual({ displayName: 'Issue title', displayNameIsPinned: true })
expect(
resolveWorktreeCreateDisplayNameMeta('feature-2', 'feature-2', 'generated', {
requestedName: 'feature-2',
sanitizedName: 'feature-2'
})
).toEqual({})
})
it('keeps a slashy branch label automatic when only its folder is sanitized', () => {
expect(
resolveWorktreeCreateDisplayNameMeta(undefined, 'feature/login', undefined, {
requestedName: 'feature/login',
sanitizedName: 'feature-login'
})
).toEqual({ displayName: 'feature/login', displayNameIsPinned: false })
})
it('keeps a user label that sanitizes away automatic', () => {
expect(
resolveWorktreeCreateDisplayNameMeta(undefined, 'feature-2', 'user', {
requestedName: 'feature',
sanitizedName: 'feature-2'
})
).toEqual({ displayNameIsPinned: false })
})
})
describe('ensurePathWithinWorkspace', () => {
it('returns resolved path when within workspace', () => {
const result = ensurePathWithinWorkspace('/workspace/feature', '/workspace')
expect(result).toBe(resolve('/workspace/feature'))
})
it('throws when path traverses outside workspace', () => {
expect(() => ensurePathWithinWorkspace('/workspace/../outside', '/workspace')).toThrow(
'Invalid worktree path'
)
})
it('allows workspace children whose names start with dot-dot text', () => {
const result = ensurePathWithinWorkspace('/workspace/..repo/feature', '/workspace')
expect(result).toBe(resolve('/workspace/..repo/feature'))
})
})
describe('computeBranchName', () => {
it('prefixes with git username when branchPrefix is git-username and username is present', () => {
expect(computeBranchName('feature', { branchPrefix: 'git-username' }, 'jdoe')).toBe(
'jdoe/feature'
)
})
it('returns bare name when branchPrefix is git-username but username is null', () => {
expect(computeBranchName('feature', { branchPrefix: 'git-username' }, null)).toBe('feature')
})
it('prefixes with custom value when branchPrefix is custom', () => {
expect(
computeBranchName('feature', { branchPrefix: 'custom', branchPrefixCustom: 'team' }, null)
).toBe('team/feature')
})
it('returns bare name when branchPrefix is custom but custom value is empty', () => {
expect(
computeBranchName('feature', { branchPrefix: 'custom', branchPrefixCustom: '' }, null)
).toBe('feature')
})
it('returns bare name when branchPrefix is none', () => {
expect(computeBranchName('feature', { branchPrefix: 'none' }, 'jdoe')).toBe('feature')
})
it('does not double the slash when a custom prefix ends in one', () => {
expect(
computeBranchName('feature', { branchPrefix: 'custom', branchPrefixCustom: 'team/' }, null)
).toBe('team/feature')
})
it('normalizes a trailing slash on a git username prefix', () => {
expect(computeBranchName('feature', { branchPrefix: 'git-username' }, 'jdoe/')).toBe(
'jdoe/feature'
)
})
})
describe('getConfiguredBranchPrefix', () => {
it('returns null for custom strategy with an empty value', () => {
expect(
getConfiguredBranchPrefix({ branchPrefix: 'custom', branchPrefixCustom: '' }, null)
).toBeNull()
})
it('normalizes a trailing slash out of the custom prefix', () => {
expect(
getConfiguredBranchPrefix({ branchPrefix: 'custom', branchPrefixCustom: 'team/' }, null)
).toBe('team')
})
it('returns null when the custom prefix normalizes away to empty', () => {
expect(
getConfiguredBranchPrefix({ branchPrefix: 'custom', branchPrefixCustom: '/' }, null)
).toBeNull()
})
})
describe('computeValidatedBranchName', () => {
it('returns the computed branch name when the prefix is valid', () => {
expect(
computeValidatedBranchName(
'feature',
{ branchPrefix: 'custom', branchPrefixCustom: 'team' },
null
)
).toBe('team/feature')
})
it('throws when the configured prefix is invalid', () => {
expect(() =>
computeValidatedBranchName(
'feature',
{ branchPrefix: 'custom', branchPrefixCustom: 'team x' },
null
)
).toThrow('contains characters git rejects')
})
it('skips an invalid git-username prefix instead of blocking create', () => {
expect(
computeValidatedBranchName(
'feature',
{ branchPrefix: 'git-username' },
'{\n"message": "API rate limit exceeded"}'
)
).toBe('feature')
})
})
describe('computeWorktreePath', () => {
it('nests under repo name when nestWorkspaces is true', () => {
expect(
computeWorktreePath('feature', '/repos/my-project', {
nestWorkspaces: true,
workspaceDir: '/workspaces'
})
).toBe(posix.join('/workspaces', 'my-project', 'feature'))
})
it('uses flat layout when nestWorkspaces is false', () => {
expect(
computeWorktreePath('feature', '/repos/my-project', {
nestWorkspaces: false,
workspaceDir: '/workspaces'
})
).toBe(posix.join('/workspaces', 'feature'))
})
it('strips .git suffix from repo path when nesting', () => {
expect(
computeWorktreePath('feature', '/repos/my-project.git', {
nestWorkspaces: true,
workspaceDir: '/workspaces'
})
).toBe(posix.join('/workspaces', 'my-project', 'feature'))
})
it('resolves relative workspace directories from the repo path', () => {
expect(computeWorkspaceRoot('/projects/app/repo', { workspaceDir: '../worktrees' })).toBe(
posix.resolve('/projects/app/worktrees')
)
expect(
computeWorktreePath('feature', '/projects/app/repo', {
nestWorkspaces: false,
workspaceDir: '../worktrees'
})
).toBe(posix.resolve('/projects/app/worktrees/feature'))
})
it('scopes the same relative repo override to each repo root', () => {
const settings = { nestWorkspaces: false, workspaceDir: '/global/workspaces' }
const repoA = { path: '/projects/a/repo', worktreeBasePath: '../worktrees' }
const repoB = { path: '/projects/b/repo', worktreeBasePath: '../worktrees' }
expect(
computeWorktreePath('feature', repoA.path, getWorktreePathSettings(repoA, settings))
).toBe(posix.resolve('/projects/a/worktrees/feature'))
expect(
computeWorktreePath('feature', repoB.path, getWorktreePathSettings(repoB, settings))
).toBe(posix.resolve('/projects/b/worktrees/feature'))
expect(getWorktreeCreationLayout(repoA, settings)).toEqual({
path: '../worktrees',
nestWorkspaces: false
})
})
it('resolves Windows-style relative workspace directories with Windows separators', () => {
expect(
computeWorktreePath('feature', 'C:\\Projects\\app\\repo', {
nestWorkspaces: false,
workspaceDir: '..\\worktrees'
})
).toBe('C:\\Projects\\app\\worktrees\\feature')
})
it('qualifies SSH sibling paths with the repo name for global absolute workspace directories', () => {
expect(
computeRemoteWorktreePath('main', '/remote/bioinformatist.github.io', {
nestWorkspaces: false,
workspaceDir: '/local/workspaces'
})
).toBe('/remote/bioinformatist.github.io-main')
expect(
computeRemoteWorktreePath('main-2', '/remote/dotfiles', {
nestWorkspaces: false,
workspaceDir: '/local/workspaces'
})
).toBe('/remote/dotfiles-main-2')
})
it('qualifies SSH sibling paths with the repo name on Windows remote paths', () => {
expect(
computeRemoteWorktreePath('main', 'C:\\Remote\\dotfiles', {
nestWorkspaces: false,
workspaceDir: 'C:\\Local\\workspaces'
})
).toBe('C:\\Remote\\dotfiles-main')
})
it('strips .git suffix from qualified SSH sibling paths', () => {
expect(
computeRemoteWorktreePath('main', '/remote/project.git', {
nestWorkspaces: false,
workspaceDir: '/local/workspaces'
})
).toBe('/remote/project-main')
})
it('applies repo-specific SSH workspace directories on the remote path', () => {
expect(
computeRemoteWorktreePath(
'feature',
'/remote/project/repo',
{
nestWorkspaces: false,
workspaceDir: '../worktrees'
},
{ useConfiguredAbsolutePath: true }
)
).toBe('/remote/project/worktrees/feature')
expect(
computeRemoteWorktreePath(
'feature',
'C:\\Remote\\repo',
{
nestWorkspaces: false,
workspaceDir: '..\\worktrees'
},
{ useConfiguredAbsolutePath: true }
)
).toBe('C:\\Remote\\worktrees\\feature')
})
it('keeps repo-specific absolute SSH workspace directories unqualified', () => {
expect(
computeRemoteWorktreePath(
'feature',
'/remote/project/repo',
{
nestWorkspaces: false,
workspaceDir: '/remote/worktrees'
},
{ useConfiguredAbsolutePath: true }
)
).toBe('/remote/worktrees/feature')
})
})
describe('areWorktreePathsEqual', () => {
it('treats Windows slash and casing differences as the same path', () => {
expect(
areWorktreePathsEqual(
'C:\\Workspaces\\Improve-Dashboard',
'c:/workspaces/improve-dashboard',
'win32'
)
).toBe(true)
})
it('keeps POSIX path comparison case-sensitive', () => {
expect(areWorktreePathsEqual('/tmp/Worktree', '/tmp/worktree', 'linux')).toBe(false)
})
it('keeps WSL-owned POSIX paths case-sensitive on Windows', () => {
expect(areWorktreePathsEqual('/home/dev/Repo', '/home/dev/repo', 'win32')).toBe(false)
})
it('does not collapse WSL POSIX paths with Windows drive paths', () => {
expect(areWorktreePathsEqual('/home/dev/repo', 'C:\\home\\dev\\repo', 'win32')).toBe(false)
})
it('treats macOS /private/tmp git paths as matching /tmp workspace paths', () => {
expect(
areWorktreePathsEqual(
'/private/tmp/orca-proof/worktrees/repo/feature',
'/tmp/orca-proof/worktrees/repo/feature',
'darwin'
)
).toBe(true)
})
})
describe('shouldSetDisplayName', () => {
it('returns false when requestedName matches both branchName and sanitizedName', () => {
expect(shouldSetDisplayName('feature', 'feature', 'feature')).toBe(false)
})
it('returns true when requestedName differs from sanitizedName (had spaces)', () => {
expect(shouldSetDisplayName('my feature', 'my-feature', 'my-feature')).toBe(true)
})
it('returns true when branchName differs due to prefix', () => {
expect(shouldSetDisplayName('feature', 'jdoe/feature', 'feature')).toBe(true)
})
})
describe('mergeWorktree', () => {
const baseGit = {
path: '/workspaces/feature',
head: 'abc123',
branch: 'refs/heads/feature-x',
isBare: false,
isMainWorktree: false
}
it('merges with full metadata', () => {
const meta = {
displayName: 'My Feature',
displayNameIsPinned: true,
comment: 'WIP',
linkedIssue: 42,
linkedPR: 10,
linkedLinearIssue: null,
projectId: 'github:stablyai/orca',
hostId: 'ssh:openclaw-2' as const,
projectHostSetupId: 'remote-repo',
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: true,
isUnread: true,
isPinned: true,
sortOrder: 5,
lastActivityAt: 1000,
workspaceStatus: 'in-review',
diffComments: [],
priorWorktreeIds: ['repo1::/workspaces/old-feature'],
automationProvenance: {
kind: 'created-by-automation' as const,
automationId: 'automation-1',
automationNameSnapshot: 'Nightly review',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly review run',
createdAt: 123,
executionTargetType: 'ssh' as const,
executionTargetId: 'openclaw-2',
projectId: 'github:stablyai/orca',
repoId: 'repo1',
hostId: 'ssh:openclaw-2' as const
}
}
const result = mergeWorktree('repo1', baseGit, meta)
expect(result).toEqual({
id: 'repo1::/workspaces/feature',
repoId: 'repo1',
path: '/workspaces/feature',
head: 'abc123',
branch: 'refs/heads/feature-x',
isBare: false,
isMainWorktree: false,
displayName: 'My Feature',
displayNameMode: 'fixed',
comment: 'WIP',
linkedIssue: 42,
linkedPR: 10,
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
linkedBitbucketPR: null,
linkedAzureDevOpsPR: null,
linkedGiteaPR: null,
linkedWorkItem: null,
linkedTaskSourceContext: null,
mobileDiffReview: undefined,
projectId: 'github:stablyai/orca',
hostId: 'ssh:openclaw-2',
projectHostSetupId: 'remote-repo',
isArchived: true,
isUnread: true,
isPinned: true,
sortOrder: 5,
lastActivityAt: 1000,
workspaceStatus: 'in-review',
diffComments: [],
priorWorktreeIds: ['repo1::/workspaces/old-feature'],
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly review',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly review run',
createdAt: 123,
executionTargetType: 'ssh',
executionTargetId: 'openclaw-2',
projectId: 'github:stablyai/orca',
repoId: 'repo1',
hostId: 'ssh:openclaw-2'
}
})
})
it('uses defaults when metadata is undefined', () => {
const result = mergeWorktree('repo1', baseGit, undefined)
expect(result.displayName).toBe('feature-x')
expect(result.comment).toBe('')
expect(result.linkedIssue).toBeNull()
expect(result.linkedPR).toBeNull()
expect(result.isArchived).toBe(false)
expect(result.isUnread).toBe(false)
expect(result.isPinned).toBe(false)
expect(result.sortOrder).toBe(0)
expect(result.lastActivityAt).toBe(0)
expect(result.workspaceStatus).toBe('in-progress')
})
it('strips refs/heads/ prefix from branch for display name', () => {
const result = mergeWorktree('repo1', baseGit, undefined)
expect(result.displayName).toBe('feature-x')
})
it('falls back to basename when bare worktree has no branch', () => {
const bareGit = {
path: '/workspaces/bare-repo',
head: '000000',
branch: '',
isBare: true,
isMainWorktree: false
}
const result = mergeWorktree('repo1', bareGit, undefined)
expect(result.displayName).toBe('bare-repo')
})
})
describe('parseWorktreeId', () => {
it('parses valid "repoId::path" format', () => {
expect(parseWorktreeId('repo1::/workspaces/feature')).toEqual({
repoId: 'repo1',
worktreePath: '/workspaces/feature'
})
})
it('handles paths containing colons', () => {
expect(parseWorktreeId('repo1::C:/Users/test')).toEqual({
repoId: 'repo1',
worktreePath: 'C:/Users/test'
})
})
it('throws on invalid format without ::', () => {
expect(() => parseWorktreeId('invalid-id')).toThrow('Invalid worktreeId: invalid-id')
})
})
describe('formatWorktreeRemovalError', () => {
const path = '/workspaces/feature'
it('returns fallback for non-Error input', () => {
expect(formatWorktreeRemovalError('oops', path, false)).toBe(
`Failed to delete worktree at ${path}.`
)
})
it('includes stderr when present on Error', () => {
const error = Object.assign(new Error('generic'), { stderr: 'branch not clean' })
expect(formatWorktreeRemovalError(error, path, false)).toBe(
`Failed to delete worktree at ${path}. branch not clean`
)
})
it('falls back to message when no stderr/stdout', () => {
const error = new Error('something went wrong')
expect(formatWorktreeRemovalError(error, path, false)).toBe(
`Failed to delete worktree at ${path}. something went wrong`
)
})
it('uses force text when force is true', () => {
expect(formatWorktreeRemovalError('oops', path, true)).toBe(
`Failed to force delete worktree at ${path}.`
)
})
it('returns fallback when Error has empty message and no streams', () => {
const error = new Error(' ')
error.message = ''
expect(formatWorktreeRemovalError(error, path, false)).toBe(
`Failed to delete worktree at ${path}.`
)
})
})
describe('isOrphanedWorktreeError', () => {
it('returns true when stderr contains "is not a working tree"', () => {
const error = Object.assign(new Error('git failed'), {
stderr: "fatal: '/some/path' is not a working tree"
})
expect(isOrphanedWorktreeError(error)).toBe(true)
})
it('returns true when message contains "is not a working tree"', () => {
const error = new Error("fatal: '/some/path' is not a working tree")
expect(isOrphanedWorktreeError(error)).toBe(true)
})
it('returns false for unrelated git errors', () => {
const error = Object.assign(new Error('git failed'), {
stderr: 'fatal: contains modified or untracked files'
})
expect(isOrphanedWorktreeError(error)).toBe(false)
})
it('returns false for non-Error input', () => {
expect(isOrphanedWorktreeError('string error')).toBe(false)
expect(isOrphanedWorktreeError(null)).toBe(false)
})
})
describe('isWindowsLongPathWorktreeRemovalError', () => {
it('matches Git for Windows long-path deletion failures on Windows', () => {
const error = Object.assign(new Error('git worktree remove failed'), {
stderr: 'error: failed to delete some/deep/file: Filename too long'
})
expect(isWindowsLongPathWorktreeRemovalError(error, 'win32')).toBe(true)
})
it('does not match long-path text off Windows', () => {
const error = Object.assign(new Error('file name too long'), {
stderr: 'Filename too long'
})
expect(isWindowsLongPathWorktreeRemovalError(error, 'linux')).toBe(false)
})
it('does not match unrelated Git removal failures on Windows', () => {
const error = Object.assign(new Error('git worktree remove failed'), {
stderr: 'fatal: contains modified or untracked files'
})
expect(isWindowsLongPathWorktreeRemovalError(error, 'win32')).toBe(false)
})
})
describe('isOrphanCompatiblePreflightError', () => {
it('matches not-a-working-tree errors', () => {
const error = Object.assign(new Error('git failed'), {
stderr: "fatal: '/some/path' is not a working tree"
})
expect(isOrphanCompatiblePreflightError(error)).toBe(true)
})
it('matches status failures from non-repo directories', () => {
const error = Object.assign(new Error('status failed'), {
stderr: 'fatal: not a git repository (or any of the parent directories): .git'
})
expect(isOrphanCompatiblePreflightError(error)).toBe(true)
})
it('matches missing directories by error code', () => {
const error = Object.assign(new Error('spawn git'), { code: 'ENOENT' })
expect(isOrphanCompatiblePreflightError(error)).toBe(true)
})
it('does not match unrelated subprocess failures', () => {
const error = Object.assign(new Error('status failed'), {
stderr: 'fatal: unable to read current working directory'
})
expect(isOrphanCompatiblePreflightError(error)).toBe(false)
})
})