Add a root-session action that creates a generated worktree from the session directory's current branch, transfers uncommitted changes, and moves the parent session plus its descendants through OpenCode's control-plane API. Reuse existing project/worktree topology and quick-create behavior, keep the UI non-blocking, reconcile live and global session state across directories, and roll back partial moves and failed worktree creation safely. Split worktree bootstrap readiness into directory-created, git-ready, and setup-ready phases across web and VS Code. Session moves wait for Git readiness while existing setup-aware flows continue waiting for full setup completion, and worktree removal is serialized with active bootstrap tasks. Expose the move only for idle root sessions, show localized progress and explanatory tooltips in the sidebar, and keep pending/ready worktree metadata synchronized with authoritative session attachments to avoid stale setup indicators. Add coverage for control-plane payloads, session-state migration, bootstrap phase ordering and compatibility, removal races, progress metadata, and fast-ready attachment races.
179 lines
6.0 KiB
JavaScript
179 lines
6.0 KiB
JavaScript
import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
|
|
const gitService = {
|
|
stageGitFiles: mock(),
|
|
unstageGitFiles: mock(),
|
|
checkoutCommit: mock(),
|
|
cherryPick: mock(),
|
|
revertCommit: mock(),
|
|
resetToCommit: mock(),
|
|
createWorktree: mock(),
|
|
getWorktreeBootstrapStatus: mock(),
|
|
};
|
|
|
|
mock.module('./gitService', () => gitService);
|
|
|
|
const { handleStandardGitBridgeMessage } = await import('./bridge-git-runtime');
|
|
|
|
describe('bridge git runtime index mutations', () => {
|
|
beforeEach(() => {
|
|
gitService.stageGitFiles.mockReset();
|
|
gitService.unstageGitFiles.mockReset();
|
|
gitService.checkoutCommit.mockReset();
|
|
gitService.cherryPick.mockReset();
|
|
gitService.revertCommit.mockReset();
|
|
gitService.resetToCommit.mockReset();
|
|
gitService.createWorktree.mockReset();
|
|
gitService.getWorktreeBootstrapStatus.mockReset();
|
|
});
|
|
|
|
it('accepts legacy stage path payloads', async () => {
|
|
const response = await handleStandardGitBridgeMessage({
|
|
id: '1',
|
|
type: 'api:git/stage',
|
|
payload: { directory: '/repo', path: 'a.ts' },
|
|
});
|
|
|
|
expect(response).toEqual({ id: '1', type: 'api:git/stage', success: true, data: { success: true } });
|
|
expect(gitService.stageGitFiles).toHaveBeenCalledWith('/repo', ['a.ts']);
|
|
});
|
|
|
|
it('accepts bulk stage paths payloads', async () => {
|
|
const response = await handleStandardGitBridgeMessage({
|
|
id: '1',
|
|
type: 'api:git/stage',
|
|
payload: { directory: '/repo', paths: ['a.ts', 'b.ts'] },
|
|
});
|
|
|
|
expect(response?.success).toBe(true);
|
|
expect(gitService.stageGitFiles).toHaveBeenCalledWith('/repo', ['a.ts', 'b.ts']);
|
|
});
|
|
|
|
it('accepts legacy unstage path payloads', async () => {
|
|
const response = await handleStandardGitBridgeMessage({
|
|
id: '1',
|
|
type: 'api:git/unstage',
|
|
payload: { directory: '/repo', path: 'a.ts' },
|
|
});
|
|
|
|
expect(response).toEqual({ id: '1', type: 'api:git/unstage', success: true, data: { success: true } });
|
|
expect(gitService.unstageGitFiles).toHaveBeenCalledWith('/repo', ['a.ts']);
|
|
});
|
|
|
|
it('accepts bulk unstage paths payloads', async () => {
|
|
const response = await handleStandardGitBridgeMessage({
|
|
id: '1',
|
|
type: 'api:git/unstage',
|
|
payload: { directory: '/repo', paths: ['a.ts', 'b.ts'] },
|
|
});
|
|
|
|
expect(response?.success).toBe(true);
|
|
expect(gitService.unstageGitFiles).toHaveBeenCalledWith('/repo', ['a.ts', 'b.ts']);
|
|
});
|
|
|
|
it('rejects invalid path payloads', async () => {
|
|
const response = await handleStandardGitBridgeMessage({
|
|
id: '1',
|
|
type: 'api:git/stage',
|
|
payload: { directory: '/repo', paths: [' ', null] },
|
|
});
|
|
|
|
expect(response?.success).toBe(false);
|
|
expect(gitService.stageGitFiles).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects invalid commit hashes before commit actions reach git service', async () => {
|
|
const checkoutResponse = await handleStandardGitBridgeMessage({
|
|
id: '1',
|
|
type: 'api:git/checkout-commit',
|
|
payload: { directory: '/repo', hash: 'HEAD' },
|
|
});
|
|
const cherryPickResponse = await handleStandardGitBridgeMessage({
|
|
id: '2',
|
|
type: 'api:git/cherry-pick',
|
|
payload: { directory: '/repo', hash: '--abort' },
|
|
});
|
|
const revertResponse = await handleStandardGitBridgeMessage({
|
|
id: '3',
|
|
type: 'api:git/revert-commit',
|
|
payload: { directory: '/repo', hash: '--continue' },
|
|
});
|
|
const resetResponse = await handleStandardGitBridgeMessage({
|
|
id: '4',
|
|
type: 'api:git/reset-to-commit',
|
|
payload: { directory: '/repo', hash: '--hard', mode: 'mixed' },
|
|
});
|
|
|
|
expect(checkoutResponse).toEqual({ id: '1', type: 'api:git/checkout-commit', success: false, error: 'Invalid commit hash' });
|
|
expect(cherryPickResponse).toEqual({ id: '2', type: 'api:git/cherry-pick', success: false, error: 'Invalid commit hash' });
|
|
expect(revertResponse).toEqual({ id: '3', type: 'api:git/revert-commit', success: false, error: 'Invalid commit hash' });
|
|
expect(resetResponse).toEqual({ id: '4', type: 'api:git/reset-to-commit', success: false, error: 'Invalid commit hash' });
|
|
expect(gitService.checkoutCommit).not.toHaveBeenCalled();
|
|
expect(gitService.cherryPick).not.toHaveBeenCalled();
|
|
expect(gitService.revertCommit).not.toHaveBeenCalled();
|
|
expect(gitService.resetToCommit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('preserves bootstrap phases in status responses', async () => {
|
|
const bootstrapStatus = {
|
|
status: 'pending',
|
|
phase: 'git-ready',
|
|
error: null,
|
|
updatedAt: 123,
|
|
};
|
|
gitService.getWorktreeBootstrapStatus.mockResolvedValue(bootstrapStatus);
|
|
|
|
const response = await handleStandardGitBridgeMessage({
|
|
id: 'bootstrap-status',
|
|
type: 'api:git/worktrees/bootstrap-status',
|
|
payload: { directory: '/repo-worktree' },
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
id: 'bootstrap-status',
|
|
type: 'api:git/worktrees/bootstrap-status',
|
|
success: true,
|
|
data: bootstrapStatus,
|
|
});
|
|
expect(gitService.getWorktreeBootstrapStatus).toHaveBeenCalledWith('/repo-worktree');
|
|
});
|
|
|
|
it('preserves the directory-created phase in fast create responses', async () => {
|
|
const created = {
|
|
head: '',
|
|
name: 'feature',
|
|
branch: 'openchamber/feature',
|
|
path: '/repo-worktree',
|
|
directoryCreated: true,
|
|
bootstrapStatus: {
|
|
status: 'pending',
|
|
phase: 'directory-created',
|
|
error: null,
|
|
updatedAt: 123,
|
|
},
|
|
};
|
|
gitService.createWorktree.mockResolvedValue(created);
|
|
|
|
const response = await handleStandardGitBridgeMessage({
|
|
id: 'create-worktree',
|
|
type: 'api:git/worktrees',
|
|
payload: {
|
|
directory: '/repo',
|
|
method: 'POST',
|
|
worktreeName: 'feature',
|
|
returnAfterDirectoryCreated: true,
|
|
},
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
id: 'create-worktree',
|
|
type: 'api:git/worktrees',
|
|
success: true,
|
|
data: created,
|
|
});
|
|
expect(gitService.createWorktree).toHaveBeenCalledWith('/repo', expect.objectContaining({
|
|
returnAfterDirectoryCreated: true,
|
|
}));
|
|
});
|
|
});
|