diff --git a/docs/design/2026-07-22-webshell-session-git-mode.md b/docs/design/2026-07-22-webshell-session-git-mode.md new file mode 100644 index 00000000000..25ce8a094b4 --- /dev/null +++ b/docs/design/2026-07-22-webshell-session-git-mode.md @@ -0,0 +1,235 @@ +# Web Shell 新建会话 Git 模式选择 + +## 背景 + +日常开发中,用户新建会话时有三种 Git 工作流: + +1. **当前分支** — 直接在当前分支上开发(默认行为) +2. **Worktree 隔离** — 创建独立 worktree + 分支,主目录不受影响 +3. **新建分支** — 在同一工作目录创建并切换到新分支 + +场景 1 和 2 已有完整支持(场景 2 见 +[2026-07-19-webshell-worktree-sessions.md](./2026-07-19-webshell-worktree-sessions.md) +和 +[2026-07-20-worktree-empty-state-toggle.md](./2026-07-20-worktree-empty-state-toggle.md))。 +场景 3 缺失——用户想"开个新分支做这个任务"时,只能先手动 `git checkout -b` +再建会话,或者被迫使用 worktree(引入不必要的目录隔离)。 + +## 目标 + +- 在聊天空状态提供统一的 **Git 模式选择器**,覆盖三种场景。 +- "新建分支"模式:daemon 在 `POST /session` 时自动 `git checkout -b`, + session 直接在新分支上启动。 +- 复用现有 worktree 创建链路,不改变 worktree 行为。 +- 向后兼容:不传新参数时行为完全不变。 + +## 非目标 + +- 不支持 checkout 已有分支(v1 只做新建;已有分支切换可后续增量)。 +- 不做会话结束时自动切回原分支(避免丢失用户状态)。 +- 不做 merge-back UI。 +- 不改变 `enter_worktree` / `exit_worktree` 工具行为。 + +## 设计 + +### 空状态 UI:Composer 内的 Git Chip + +模式选择器不做成独立区块,而是**内嵌到 composer 底部工具栏**——复用 +现有 git chip 的位置(输入框下方、发送按钮左侧)。chip 默认显示当前 +分支 `⎇ main`,点击弹出 popover 选择模式: + +```text +┌─ composer ───────────────────────────────────────────┐ +│ 描述你的任务… │ +│ │ +│ 📎 @ 🎙 [⎇ main ▾] [发送] │ +└──────────────────────────────────────────────────────┘ + │ 点击 + ▼ + ┌─ Git 模式 popover ─────────────┐ + │ ● 当前分支 直接在 main 上 │ + │ ○ 新建分支 从 main 创建 │ + │ [分支名输入框 — 选中时展开] │ + │ ○ Worktree 独立副本,可并行 │ + │ ───────────────────────────── │ + │ $ git checkout -b feat/x ← main│ + │ [创建分支] │ + └─────────────────────────────────┘ +``` + +- **当前分支**(默认):chip 显示 `⎇ main`(绿色),等同于现有行为。 + 选中后 popover 自动关闭。 +- **新建分支**:popover 内展开分支名输入框 + 并发提示,实时校验 + (合法 git 分支名、不与现有分支冲突)。确认后 chip 变为 + `⎇ → feat/xxx`(橙色),带 ✕ 可一键恢复默认。 +- **Worktree 隔离**:显示自动生成的 slug 预览。确认后 chip 变为 + `⎇ worktree 隔离`(紫色),带 ✕ 可一键恢复默认。 + +popover 底部实时预览将执行的 git 命令(`git checkout -b …` / +`git worktree add …`),让用户明确知道会发生什么。 + +chip 方案的优势:不占用 welcome 区垂直空间;入口在用户注意力所在的 +composer 内;非空状态(已有会话)下 chip 依然可见,语义一致。 + +可见性条件与现有 worktree toggle 一致:workspace 已信任 + 是 git 仓库。 +不满足时 chip 退化为只读分支指示器(现有行为)。 + +#### 状态机 + +将 `pendingWorktreeRef` / `worktreePending` 扩展为统一的 pending 意图: + +```typescript +type SessionGitIntent = + | { mode: 'current' } + | { mode: 'branch'; name: string } + | { mode: 'worktree'; slug?: string }; +``` + +- 选择"当前分支"→ `{ mode: 'current' }`(等同于 `undefined`,不传参)。 +- 选择"新建分支"→ `{ mode: 'branch', name }`。 +- 选择"Worktree"→ `{ mode: 'worktree', slug? }`(复用现有逻辑)。 +- 发送首条消息 → `ensureSessionForPrompt` 根据 intent 携带对应参数。 +- 创建成功后清除 intent;失败保留供重试。 + +### API 变化 + +#### `CreateSessionRequest`(SDK) + +```typescript +export interface CreateSessionRequest { + // ... existing fields ... + worktree?: { slug?: string }; + /** + * Create a new git branch and check it out before starting the + * session. The session runs in the same working directory but on + * the new branch. Mutually exclusive with `worktree`. + */ + branch?: { name: string }; +} +``` + +`branch` 与 `worktree` 互斥,同时传入返回 400。 + +#### `DaemonSession` / `DaemonSessionSummary` 响应 + +```typescript +export interface DaemonBranchInfo { + name: string; // 新建的分支名 + baseBranch: string; // 创建时的基础分支 +} + +export interface DaemonSession { + // ... existing fields ... + worktree?: DaemonWorktreeInfo; + branch?: DaemonBranchInfo; +} +``` + +#### `POST /session` 路由处理(`routes/session.ts`) + +在现有 worktree 处理逻辑之前,增加 branch 处理: + +```text +1. 校验 branch / worktree 互斥 +2. 校验 branch.name 是合法 git 分支名 +3. 检查分支名不与现有分支冲突(git rev-parse --verify) +4. 检测 dirty tree(git status --porcelain),有改动则 409 branch_dirty_tree +5. 记录 baseBranch = 当前分支(git rev-parse --abbrev-ref HEAD) +6. git checkout -b +7. branchMeta = { name, baseBranch } +8. 强制 sessionScope = 'thread' +9. 正常 spawnOrAttach(cwd 不变) +10. 失败回滚:git checkout && git branch -D +``` + +不需要 `changeSessionCwd`(工作目录不变),不需要 worktree marker。 + +#### 错误码 + +| 错误码 | 含义 | +| ------------------------------ | --------------------------------------------------------------------- | +| `branch_and_worktree_conflict` | 同时传了 `branch` 和 `worktree` | +| `invalid_branch` | `branch` 字段不是对象(需为 `{"name":"..."}`) | +| `branch_invalid_name` | 分支名不合法 | +| `branch_session_conflict` | 该 workspace 已有分支 session,或共享 checkout 上已有其他活跃 session | +| `branch_init_failed` | 初始化 git 服务失败 | +| `branch_not_git_repo` | workspace 不是 git 仓库 | +| `branch_already_exists` | 分支名已存在 | +| `branch_status_failed` | 检查工作目录状态失败 | +| `branch_dirty_tree` | 工作目录有未提交改动,需先 commit 或 stash | +| `branch_checkout_failed` | `git checkout -b` 失败(其他原因) | + +### 前端传参链路 + +```text +App.tsx (gitIntent state) + → sessionPreparation.ts createAndAttachSessionForPrompt({ branch }) + → actions.ts createSession({ branch }) + → DaemonClient.createOrAttachSession({ branch }) + → POST /session { branch: { name } } +``` + +与 worktree 链路完全对称,每层增加 `branch` 透传。 + +### Sidebar 展示 + +- Worktree session:现有 `GitForkIcon` badge,不变。 +- Branch session:显示 `GitBranchIcon` + 分支名 badge。 +- 普通 session:无 badge,不变。 + +### 并发限制 + +同一 workspace 的"新建分支"session 会改变共享工作目录的 HEAD,多个 +branch session 会互相冲突。限制策略: + +- **服务端**:`POST /session` 带 `branch` 时,检查同一 workspace 是否已有 + 活跃的 branch session(通过 bridge 的 session 列表 + `branchMeta`)。 + 如有,返回 409 `branch_session_conflict`。 +- **前端**:空状态选择"新建分支"时,如已有活跃 branch session,显示提示 + 并禁用。 + +Worktree session 不受此限制(各自独立目录)。 + +### 文件改动 + +| 文件 | 改动 | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| `packages/sdk-typescript/src/daemon/DaemonClient.ts` | `CreateSessionRequest` 增加 `branch` 字段 | +| `packages/sdk-typescript/src/daemon/types.ts` | `DaemonBranchInfo`、`DaemonSession.branch`、`DaemonSessionSummary.branch` | +| `packages/cli/src/serve/routes/session.ts` | `POST /session` branch 创建逻辑 + 回滚 | +| `packages/webui/src/daemon/session/actions.ts` | `createSession` 透传 `branch` | +| `packages/webui/src/daemon/session/types.ts` | `createSession` 签名增加 `branch` | +| `packages/web-shell/client/App.tsx` | `SessionGitIntent` 状态机、模式选择器 UI、并发检查 | +| `packages/web-shell/client/App.module.css` | 选择器样式 | +| `packages/web-shell/client/utils/sessionPreparation.ts` | 透传 `branch` | +| `packages/web-shell/client/i18n.tsx` | 新增 i18n keys(en/zh) | +| `packages/web-shell/client/components/sidebar/WebShellSidebar.tsx` | branch session badge | + +### i18n + +| Key | EN | ZH | +| -------------------------------- | ------------------------------------------------------ | ---------------------------------------- | +| `gitMode.current` | `Current branch` | `当前分支` | +| `gitMode.branch` | `New branch` | `新建分支` | +| `gitMode.worktree` | `Worktree` | `Worktree 隔离` | +| `gitMode.branch.placeholder` | `Branch name` | `分支名` | +| `gitMode.branch.hint` | `Switches the working directory to a new branch` | `在工作目录中切换到新分支` | +| `gitMode.branch.conflictWarning` | `Only one branch session per workspace at a time` | `同一 workspace 同时只能有一个分支会话` | +| `gitMode.branch.invalidName` | `Invalid branch name` | `分支名不合法` | +| `gitMode.branch.exists` | `Branch already exists` | `分支已存在` | +| `gitMode.branch.dirtyTree` | `Uncommitted changes detected. Commit or stash first.` | `检测到未提交改动,请先 commit 或 stash` | + +## 已决问题 + +1. **分支名默认值**:不自动生成,由用户输入。输入框留空 + placeholder + 提示(如 `feat/my-feature`),减少预设。 +2. **dirty working tree**:服务端在 `git checkout -b` 前检测 dirty 状态 + (`git status --porcelain`)。如有未提交改动,返回 409 + `branch_dirty_tree`,前端提示用户先 commit 或 stash 后再创建分支会话。 + 不在 UI 层预检测(避免与 git 实际行为脱节),统一由服务端判定。 +3. **会话恢复(resume)**:不需要 sidecar。Worktree 需要 sidecar 是因为 + 工作目录与主仓库分离,resume 时必须知道 worktree 路径。Branch 会话的 + 工作目录就是原目录,`git branch` 即可知当前分支,无需额外记录。 + 注意:`DaemonSessionSummary.branch` 目前仅保存在内存中(bridge 映射), + daemon 重启后会丢失,因此 sidebar badge 与并发守卫不会跨重启保留; + 持久化属于后续工作。 diff --git a/docs/design/assets/git-mode-branch-input.png b/docs/design/assets/git-mode-branch-input.png new file mode 100644 index 00000000000..fe7a18ff924 Binary files /dev/null and b/docs/design/assets/git-mode-branch-input.png differ diff --git a/docs/design/assets/git-mode-branch-selected.png b/docs/design/assets/git-mode-branch-selected.png new file mode 100644 index 00000000000..70ae0adf716 Binary files /dev/null and b/docs/design/assets/git-mode-branch-selected.png differ diff --git a/docs/design/assets/git-mode-default.png b/docs/design/assets/git-mode-default.png new file mode 100644 index 00000000000..a9a2b578a24 Binary files /dev/null and b/docs/design/assets/git-mode-default.png differ diff --git a/docs/design/assets/git-mode-popover.png b/docs/design/assets/git-mode-popover.png new file mode 100644 index 00000000000..4aa2678aa3b Binary files /dev/null and b/docs/design/assets/git-mode-popover.png differ diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index b11b1103537..ea040e4148f 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -460,6 +460,8 @@ interface SessionEntry { sourceId?: string; /** Worktree isolation metadata, when created with worktree param. */ worktree?: { slug: string; path: string; branch: string }; + /** Branch metadata, when created with branch param. */ + branch?: { name: string; baseBranch: string }; channel: AcpChannel; connection: ClientSideConnection; /** Per-session event bus drives `GET /session/:id/events`. */ @@ -1912,6 +1914,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(entry.turnError !== undefined ? { turnError: entry.turnError } : {}), pendingInteractions: [...entry.pendingInteractions.values()], ...(entry.worktree ? { worktree: entry.worktree } : {}), + ...(entry.branch ? { branch: entry.branch } : {}), }; }; // Pending + resolved permission state lives in @@ -2527,6 +2530,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { sourceType?: string, sourceId?: string, worktree?: { slug: string; path: string; branch: string }, + branch?: { name: string; baseBranch: string }, ): Promise { // Get-or-create the daemon's single channel, then call // `connection.newSession()` on it. Sessions share the child's @@ -2631,7 +2635,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { newSessionResp.sessionId, boundWorkspace, undefined, - { parentSessionId, sourceType, sourceId, worktree }, + { parentSessionId, sourceType, sourceId, worktree, branch }, ); initializedSessionId = entry.sessionId; sessionRegistered = true; @@ -2823,6 +2827,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ? { parentSessionPersisted: parentSessionPersisted === true } : {}), ...(entry.worktree ? { worktree: entry.worktree } : {}), + ...(entry.branch ? { branch: entry.branch } : {}), }; } finally { ci.sessionSpawnsInFlight = Math.max(0, ci.sessionSpawnsInFlight - 1); @@ -3799,6 +3804,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { sourceType?: string; sourceId?: string; worktree?: { slug: string; path: string; branch: string }; + branch?: { name: string; baseBranch: string }; } = {}, ): SessionEntry => { const entry: SessionEntry = { @@ -3811,6 +3817,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(options.sourceType ? { sourceType: options.sourceType } : {}), ...(options.sourceId !== undefined ? { sourceId: options.sourceId } : {}), ...(options.worktree ? { worktree: options.worktree } : {}), + ...(options.branch ? { branch: options.branch } : {}), channel: ci.channel, connection: ci.connection, events, @@ -5128,6 +5135,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { source.sourceType, source.sourceId, req.worktree, + req.branch, ); // Track in-flight spawns regardless of scope. Under `single` // this also serves the coalescing path above (a parallel diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 6fbdf822dfd..cc24f0d670e 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -96,6 +96,8 @@ export interface BridgeSpawnRequest { approvalMode?: ApprovalMode; /** Worktree isolation metadata, set by the daemon route before spawn. */ worktree?: { slug: string; path: string; branch: string }; + /** Branch metadata, set by the daemon route before spawn. */ + branch?: { name: string; baseBranch: string }; } export interface BridgeSession { @@ -130,6 +132,8 @@ export interface BridgeSession { sourcePersisted?: boolean; /** Present when the session was created with worktree isolation. */ worktree?: { slug: string; path: string; branch: string }; + /** Present when the session was created with a new branch. */ + branch?: { name: string; baseBranch: string }; } export interface BridgeRestoreSessionRequest { @@ -435,6 +439,8 @@ export interface BridgeSessionSummary { color?: SessionGroupPresetColor | null; /** Present when the session was created with worktree isolation. */ worktree?: { slug: string; path: string; branch: string }; + /** Present when the session was created with a new branch. */ + branch?: { name: string; baseBranch: string }; } /** diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index bfe2502edf0..a48a60d89aa 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -89,6 +89,14 @@ import { writeGenerationSseChunk, } from '../generation-sse.js'; import { requireSessionRuntime } from './session-runtime.js'; +import { + branchExists, + isDirtyTree, + getHeadCommit, + createBranch, + checkoutRef, + deleteBranch, +} from '../server/git-branch-ops.js'; import { parseVirtualSubagentSessionId, type VirtualSubagentSessions, @@ -103,6 +111,23 @@ import type { } from '../workspace-registry.js'; import type { ChannelDeliveryAuthorizationStore } from '../channel-delivery-authorization.js'; +// `HEAD` is the most prominent ref name git rejects as a branch name. +// The surrounding predicate covers the remaining reserved forms (`@`, `-`, +// `..`, `.lock` suffixes, etc.). Compared case-insensitively because ref +// storage is case-folding on macOS/Windows. +const GIT_RESERVED_BRANCH = 'HEAD'; + +// Byte-length caps for branch names. git creates loose refs as files under +// `.git/refs/heads/`, so each `/`-separated component is bounded by the +// filesystem's per-component name limit (255 bytes on Linux/macOS, minus the +// `.lock` suffix git appends while writing). A component over that limit fails +// inside `git checkout -b` with a raw error that embeds the absolute workspace +// path; rejecting here keeps every bad name a clean 400. Unicode is allowed, so +// count UTF-8 bytes, not code points. Mirrors validateBranchName in +// GitModePopover.tsx; keep the two in sync. +const MAX_BRANCH_NAME_BYTES = 1000; +const MAX_BRANCH_COMPONENT_BYTES = 200; + interface RegisterSessionRoutesDeps { boundWorkspace: string; bridge: AcpSessionBridge; @@ -385,6 +410,62 @@ export function registerSessionRoutes( SessionTranscriptCursorCodec >(); + // Tracks workspaces with an active branch session (workspaceCwd → sessionId). + // Prevents concurrent branch sessions that would conflict on HEAD. The + // POST /session branch block additionally rejects branch creation while any + // other live (client-attached) non-worktree session shares the workspace, so + // a concurrent current-branch session is not silently moved onto the new + // branch. A detached session is not blocked; the dirty-tree check still + // covers the most common dirty-tree case. + const activeBranchSessions = new Map(); + // Workspaces with a branch creation currently in flight (reserved between + // the conflict guard and `activeBranchSessions.set`, which only happens + // after spawn). Closes the TOCTOU where two concurrent requests both pass + // the guard before either populates `activeBranchSessions`. + const inFlightBranchWorkspaces = new Set(); + + /** Remove the branch-session tracking entry when a session ends. */ + const clearBranchSessionEntry = (sessionId: string): void => { + for (const [cwd, sid] of activeBranchSessions) { + if (sid === sessionId) { + activeBranchSessions.delete(cwd); + } + } + }; + + /** Roll back a branch creation: restore the base ref and delete the branch. */ + const rollbackBranchCreation = async ( + cwd: string, + meta: { name: string; baseBranch: string }, + baseCommit: string | undefined, + log: typeof daemonLog, + ): Promise => { + activeBranchSessions.delete(cwd); + inFlightBranchWorkspaces.delete(cwd); + // Restore the base ref first and only delete the new branch once the + // workspace is off it: `git branch -D` refuses to delete the checked-out + // branch, so deleting unconditionally after a failed checkout would just + // fail and relies on that git-specific protection. An orphaned branch left + // behind here is harmless and cleanable with `git branch -D`. + const baseRestored = await checkoutRef( + cwd, + meta.baseBranch === 'HEAD' && baseCommit ? baseCommit : meta.baseBranch, + ) + .then(() => true) + .catch((rollbackErr) => { + log?.warn('branch rollback checkout failed', { + error: rollbackErr, + }); + return false; + }); + if (!baseRestored) return; + await deleteBranch(cwd, meta.name).catch((rollbackErr) => { + log?.warn('branch rollback delete failed', { + error: rollbackErr, + }); + }); + }; + const getTranscriptCursorCodec = ( runtime: WorkspaceRuntime, ): SessionTranscriptCursorCodec => { @@ -1170,6 +1251,208 @@ export function registerSessionRoutes( const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + // ── Branch creation ──────────────────────────────────────────── + // When `branch` is present, create and checkout a new git branch + // before spawning. The session runs in the same working directory + // but on the new branch. Mutually exclusive with `worktree`. + let branchMeta: { name: string; baseBranch: string } | undefined; + let branchBaseCommit: string | undefined; + const rawBranch = body['branch']; + if (rawBranch !== undefined && rawBranch !== null) { + if (body['worktree'] !== undefined && body['worktree'] !== null) { + res.status(400).json({ + error: '`branch` and `worktree` are mutually exclusive', + code: 'branch_and_worktree_conflict', + }); + return; + } + if (typeof rawBranch !== 'object' || Array.isArray(rawBranch)) { + res.status(400).json({ + error: + '`branch` must be an object (e.g. `{"name":"feat/my-feature"}`)', + code: 'invalid_branch', + }); + return; + } + const branchReq = rawBranch as Record; + const branchName = branchReq['name']; + if (typeof branchName !== 'string' || branchName.length === 0) { + res.status(400).json({ + error: '`branch.name` must be a non-empty string', + code: 'branch_invalid_name', + }); + return; + } + // Validate git branch name characters and reserved names. + // Mirrors validateBranchName in GitModePopover.tsx; keep in sync. + if ( + /[^\p{L}\p{N}._/-]/u.test(branchName) || + branchName.includes('..') || + branchName.includes('//') || + branchName.startsWith('.') || + branchName.startsWith('-') || + branchName.startsWith('/') || + branchName.endsWith('/') || + branchName.endsWith('.') || + branchName.endsWith('.git') || + branchName.includes('@{') || + branchName + .split('/') + .some((c) => c.startsWith('.') || c.endsWith('.lock')) || + branchName.toUpperCase() === GIT_RESERVED_BRANCH || + Buffer.byteLength(branchName, 'utf8') > MAX_BRANCH_NAME_BYTES || + branchName + .split('/') + .some( + (c) => Buffer.byteLength(c, 'utf8') > MAX_BRANCH_COMPONENT_BYTES, + ) + ) { + res.status(400).json({ + error: `Invalid branch name: ${branchName}`, + code: 'branch_invalid_name', + }); + return; + } + // Reject when another branch session is already active for this + // workspace — concurrent branch sessions conflict on HEAD. Runs after + // shape/name validation so a malformed body gets 400, not 409. + const existingBranchSession = activeBranchSessions.get(workspaceCwd); + if (existingBranchSession) { + try { + // Throws if the session is gone, letting us clean up the stale entry. + runtime.bridge.getSessionSummary(existingBranchSession); + res.status(409).json({ + error: 'A branch session is already active for this workspace', + code: 'branch_session_conflict', + existingSessionId: existingBranchSession, + }); + return; + } catch { + activeBranchSessions.delete(workspaceCwd); + } + } + // Reject when any other live (client-attached) non-worktree session + // already runs in this workspace. `git checkout -b` moves the shared + // HEAD, so a concurrent current-branch session with a clean tree would + // be silently relocated onto the new branch and commit to the wrong + // ref. Worktree sessions are exempt (they run in their own cwd). Scoped + // to sessions with an attached client so a detached session left behind + // by a "new chat" does not block a fresh branch session. + const sharedCheckoutSession = runtime.bridge + .listWorkspaceSessions(workspaceCwd) + .find((session) => !session.worktree && session.clientCount > 0); + if (sharedCheckoutSession) { + res.status(409).json({ + error: + 'Another session is already active in this workspace; creating a branch would move its shared checkout', + code: 'branch_session_conflict', + existingSessionId: sharedCheckoutSession.sessionId, + }); + return; + } + let wtService: GitWorktreeService; + try { + wtService = new GitWorktreeService(workspaceCwd); + } catch { + res.status(500).json({ + error: 'Failed to initialize git service', + code: 'branch_init_failed', + }); + return; + } + if (!(await wtService.isGitRepository())) { + res.status(400).json({ + error: 'Branch creation requires a git repository', + code: 'branch_not_git_repo', + }); + return; + } + // Check the branch doesn't already exist. + if (await branchExists(workspaceCwd, branchName)) { + res.status(409).json({ + error: `Branch "${branchName}" already exists`, + code: 'branch_already_exists', + }); + return; + } + // Gate on a dirty tree as surprise-prevention: `git checkout -b` carries + // uncommitted tracked changes onto the new branch, which would silently + // mix the user's WIP with a fresh branch. Untracked files are excluded + // (`--untracked-files=no`) because they survive any checkout unchanged. + let dirty: boolean; + try { + dirty = await isDirtyTree(workspaceCwd); + } catch { + res.status(500).json({ + error: 'Failed to check working tree status', + code: 'branch_status_failed', + }); + return; + } + if (dirty) { + res.status(409).json({ + error: 'Uncommitted changes detected. Commit or stash first.', + code: 'branch_dirty_tree', + }); + return; + } + const baseCommit = await getHeadCommit(workspaceCwd); + const baseBranch = await wtService.getCurrentBranch().catch(() => 'HEAD'); + // Reserve the workspace before mutating HEAD. The conflict guard above + // runs before several awaits (rev-parse, status, checkout), so two + // concurrent `POST /session { branch }` can both pass it and race on + // `git checkout -b`. This synchronous check-and-add (no await between) + // serializes the checkout; every exit path below clears the reservation + // (transferred to `activeBranchSessions` on success). Re-check + // `activeBranchSessions` here too: a request that passed the early guard + // before a concurrent request registered can still be in flight while + // the first request has already completed and populated the map. + if ( + inFlightBranchWorkspaces.has(workspaceCwd) || + activeBranchSessions.has(workspaceCwd) + ) { + res.status(409).json({ + error: 'A branch session is already being created for this workspace', + code: 'branch_session_conflict', + }); + return; + } + inFlightBranchWorkspaces.add(workspaceCwd); + try { + await createBranch(workspaceCwd, branchName); + } catch (checkoutErr) { + // `git checkout -b` can reject AFTER git already created the ref and + // moved HEAD — a failing post-checkout hook or a timeout past the ref + // update both leave the workspace on the new branch while the command + // exits nonzero. Roll back transactionally (restore the base ref, then + // delete the partial branch) so the shared workspace is never silently + // left on the new branch; when nothing was created the rollback is a + // harmless no-op. Log the full git error but return a generic detail — + // git stderr can embed the absolute workspace path, which must not + // reach the caller in the 500 body. + daemonLog?.warn('branch checkout failed', { + error: + checkoutErr instanceof Error + ? checkoutErr.message + : String(checkoutErr), + }); + await rollbackBranchCreation( + workspaceCwd, + { name: branchName, baseBranch }, + baseCommit, + daemonLog, + ); + res.status(500).json({ + error: 'Failed to create branch', + code: 'branch_checkout_failed', + }); + return; + } + branchMeta = { name: branchName, baseBranch }; + branchBaseCommit = baseCommit; + sessionScope = 'thread'; + } + // ── Worktree isolation ────────────────────────────────────────── // When `worktree` is present, create a git worktree before spawning // and relocate the session into it immediately after. The workspace @@ -1259,6 +1542,7 @@ export function registerSessionRoutes( : {}), ...(source.sourceId !== undefined ? { sourceId: source.sourceId } : {}), ...(worktreeMeta ? { worktree: worktreeMeta } : {}), + ...(branchMeta ? { branch: branchMeta } : {}), }); // Client may have disconnected during the 1–3s spawn window. If // so, the response can't be delivered. The session is otherwise @@ -1319,9 +1603,31 @@ export function registerSessionRoutes( .removeUserWorktree(worktreeMeta.slug, { deleteBranch: true }) .catch(() => {}); } + // Roll back the branch if one was created for this session. + if (branchMeta) { + await rollbackBranchCreation( + workspaceCwd, + branchMeta, + branchBaseCommit, + daemonLog, + ); + } + } else if (branchMeta) { + // Another client attached before we could reap — the session + // is alive. Transfer the in-flight reservation to the active + // map so the workspace is tracked, not permanently blocked. + activeBranchSessions.set(workspaceCwd, session.sessionId); + inFlightBranchWorkspaces.delete(workspaceCwd); } } catch { - // Best-effort cleanup; channel.exited will eventually reap. + // Best-effort cleanup; channel.exited will eventually reap the + // session, but it has no awareness of this route-local in-flight + // reservation. Pessimistically track the session so the workspace + // stays blocked until the stale-entry detection self-heals. + if (branchMeta) { + activeBranchSessions.set(workspaceCwd, session.sessionId); + inFlightBranchWorkspaces.delete(workspaceCwd); + } } } else { // When an attaching client disconnects @@ -1338,6 +1644,12 @@ export function registerSessionRoutes( .catch(() => { // Best-effort cleanup; channel.exited will eventually reap. }); + // Unreachable for branch sessions (sessionScope='thread' forces a + // fresh spawn, never attach), but kept as a safety net: release the + // in-flight reservation so the workspace is not permanently blocked. + if (branchMeta) { + inFlightBranchWorkspaces.delete(workspaceCwd); + } } return; } @@ -1423,6 +1735,11 @@ export function registerSessionRoutes( } } + if (branchMeta) { + activeBranchSessions.set(workspaceCwd, session.sessionId); + inFlightBranchWorkspaces.delete(workspaceCwd); + } + res.status(200).json(session); } catch (err) { // Roll back the worktree if spawn failed — otherwise the directory @@ -1433,6 +1750,16 @@ export function registerSessionRoutes( .removeUserWorktree(worktreeMeta.slug, { deleteBranch: true }) .catch(() => {}); } + // Roll back the branch if spawn failed — switch back to the base + // branch and delete the newly created one. + if (branchMeta) { + await rollbackBranchCreation( + workspaceCwd, + branchMeta, + branchBaseCommit, + daemonLog, + ); + } sendBridgeError(res, err, { route: 'POST /session' }); } }); @@ -2869,6 +3196,7 @@ export function registerSessionRoutes( clientId !== undefined ? { clientId } : undefined, ), ); + clearBranchSessionEntry(sessionId); res.status(204).end(); } catch (err) { sendBridgeError(res, err, { @@ -2896,6 +3224,9 @@ export function registerSessionRoutes( ); }, }); + for (const removedId of result.removed) { + clearBranchSessionEntry(removedId); + } res.status(200).json(result); } catch (err) { sendBridgeError(res, err, { route: 'POST /sessions/delete' }); @@ -2977,6 +3308,9 @@ export function registerSessionRoutes( ); }, }); + for (const removedId of result.removed) { + clearBranchSessionEntry(removedId); + } res.status(200).json(result); } catch (err) { sendBridgeError(res, err, { route }); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 7d3b38d1fc8..b9bd30fd543 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -203,6 +203,44 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); +// ── Branch git-ops mock infrastructure ───────────────────────────── +// The branch creation route delegates git mutations to +// ../server/git-branch-ops.js. Mock the module so the git-mutation +// paths (exists, dirty, checkout -b, rollback) are testable without a +// real repository. +const mockBranchOps = vi.hoisted(() => ({ + branchExists: undefined as (() => Promise) | undefined, + isDirtyTree: undefined as (() => Promise) | undefined, + getHeadCommit: undefined as (() => Promise) | undefined, + createBranch: undefined as (() => Promise) | undefined, + checkoutRef: undefined as (() => Promise) | undefined, + deleteBranch: undefined as (() => Promise) | undefined, +})); +vi.mock('./server/git-branch-ops.js', () => ({ + branchExists: () => + mockBranchOps.branchExists + ? mockBranchOps.branchExists() + : Promise.resolve(false), + isDirtyTree: () => + mockBranchOps.isDirtyTree + ? mockBranchOps.isDirtyTree() + : Promise.resolve(false), + getHeadCommit: () => + mockBranchOps.getHeadCommit + ? mockBranchOps.getHeadCommit() + : Promise.resolve(undefined), + createBranch: () => + mockBranchOps.createBranch + ? mockBranchOps.createBranch() + : Promise.resolve(), + checkoutRef: () => + mockBranchOps.checkoutRef ? mockBranchOps.checkoutRef() : Promise.resolve(), + deleteBranch: () => + mockBranchOps.deleteBranch + ? mockBranchOps.deleteBranch() + : Promise.resolve(), +})); + const baseOpts: ServeOptions = { hostname: '127.0.0.1', port: 4170, @@ -8575,6 +8613,124 @@ describe('createServeApp', () => { expect(bridge.calls).toHaveLength(0); }); + it('400 when branch name ends with .git', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feature.git' } }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('branch_invalid_name'); + expect(bridge.calls).toHaveLength(0); + }); + + it('400 when branch and worktree are both requested', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' }, worktree: {} }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('branch_and_worktree_conflict'); + expect(bridge.calls).toHaveLength(0); + }); + + it('400 when branch is not an object', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: 'feat/x' }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_branch'); + expect(bridge.calls).toHaveLength(0); + }); + + it('400 when branch name is empty', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: '' } }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('branch_invalid_name'); + expect(bridge.calls).toHaveLength(0); + }); + + it.each([ + 'feat/../x', + 'feat//x', + 'feat@{1}', + 'feat.lock', + '.hidden', + '-feat', + 'HEAD', + ])('400 when branch name %s is rejected by validation', async (name) => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name } }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('branch_invalid_name'); + expect(bridge.calls).toHaveLength(0); + }); + + it('400 when branch is requested on a non-git workspace', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(false), + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('branch_not_git_repo'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + } + }); + it('500 when worktree creation fails', async () => { const bridge = fakeBridge(); const app = createServeApp( @@ -8602,140 +8758,809 @@ describe('createServeApp', () => { mockWt.impl = undefined; } }); - }); - describe('POST /session/:id/load and /resume', () => { - it('reports resume as unsupported for virtual subagent sessions', async () => { + // ── Branch git-mutation path tests ────────────────────────────── + // These exercise the paths that actually mutate git state + // (exists, dirty, checkout -b, rollback) by mocking + // ../server/git-branch-ops.js via mockBranchOps. + + it('409 when branch already exists', async () => { const bridge = fakeBridge(); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, { bridge }, ); - const sessionId = createVirtualSubagentSessionId('parent-1', 'agent-1'); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.branchExists = () => Promise.resolve(true); - const res = await request(app) - .post(`/session/${sessionId}/resume`) - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({}); + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); - expect(res.status).toBe(400); - expect(res.body).toEqual({ - error: 'Virtual subagent sessions do not support resume', - code: 'unsupported_action', - sessionId, - }); - expect(bridge.resumeCalls).toEqual([]); + expect(res.status).toBe(409); + expect(res.body.code).toBe('branch_already_exists'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + mockBranchOps.branchExists = undefined; + } }); - it('passes the requested initial history page size to load', async () => { + it('409 when working tree is dirty', async () => { const bridge = fakeBridge(); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, { bridge }, ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.isDirtyTree = () => Promise.resolve(true); - const res = await request(app) - .post('/session/persisted-page/load') - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({ historyPageSize: 100 }); + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); - expect(res.status).toBe(200); - expect(bridge.loadCalls).toEqual([ - { - sessionId: 'persisted-page', - workspaceCwd: WS_BOUND, - historyReplay: 'response', - historyPageSize: 100, - }, - ]); + expect(res.status).toBe(409); + expect(res.body.code).toBe('branch_dirty_tree'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + mockBranchOps.isDirtyTree = undefined; + } }); - it('falls back to bound workspace and uses the route session id', async () => { - for (const action of ['load', 'resume'] as const) { - const bridge = fakeBridge(); - const app = createServeApp( - { ...baseOpts, workspace: WS_BOUND }, - undefined, - { bridge }, - ); + it('500 when git checkout -b fails', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + mockBranchOps.createBranch = () => + Promise.reject(new Error('fatal: cannot lock ref')); + + try { const res = await request(app) - .post(`/session/persisted-1/${action}`) + .post('/session') .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({ sessionId: 'spoofed-body-id' }); + .send({ branch: { name: 'feat/x' } }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ - sessionId: 'persisted-1', - workspaceCwd: WS_BOUND, - attached: false, - clientId: action === 'load' ? 'client-load' : 'client-resume', - state: {}, - hasActivePrompt: false, - }); - const calls = action === 'load' ? bridge.loadCalls : bridge.resumeCalls; - expect(calls).toEqual([ - { - sessionId: 'persisted-1', - workspaceCwd: WS_BOUND, - ...(action === 'load' ? { historyReplay: 'response' } : {}), - }, - ]); + expect(res.status).toBe(500); + expect(res.body.code).toBe('branch_checkout_failed'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + mockBranchOps.createBranch = undefined; } }); - it('releases restore ownership after invalid approvalMode', async () => { + it('rolls back the partial branch when checkout fails after HEAD moved', async () => { + // `git checkout -b` can reject AFTER git already created the branch and + // moved HEAD (e.g. a failing post-checkout hook). The route must roll + // back: restore the base ref and delete the partially-created branch, + // rather than leaving the shared workspace on the new branch. + const rollbackCalls: string[] = []; const bridge = fakeBridge(); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, { bridge }, ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + mockBranchOps.createBranch = () => + Promise.reject(new Error('fatal: post-checkout hook failed')); + mockBranchOps.checkoutRef = () => { + rollbackCalls.push('checkout'); + return Promise.resolve(); + }; + mockBranchOps.deleteBranch = () => { + rollbackCalls.push('delete'); + return Promise.resolve(); + }; - const invalid = await request(app) - .post('/session/persisted-approval/load') - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({ approvalMode: 'YOLO' }); - expect(invalid.status).toBe(400); - expect(invalid.body.code).toBe('invalid_approval_mode'); + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); - const valid = await request(app) - .post('/session/persisted-approval/load') - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({}); - expect(valid.status).toBe(200); - expect(bridge.loadCalls).toEqual([ - { - sessionId: 'persisted-approval', - workspaceCwd: WS_BOUND, - historyReplay: 'response', - }, - ]); + expect(res.status).toBe(500); + expect(res.body.code).toBe('branch_checkout_failed'); + // The raw git error (which can embed the workspace path) is not + // surfaced to the caller. + expect(res.body.error).toBe('Failed to create branch'); + expect(rollbackCalls).toContain('checkout'); + expect(rollbackCalls).toContain('delete'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + mockBranchOps.createBranch = undefined; + mockBranchOps.checkoutRef = undefined; + mockBranchOps.deleteBranch = undefined; + } }); - it('passes explicit primary cwd through to the bridge', async () => { + it('409 when a live non-worktree session already runs in the workspace', async () => { + // A concurrent current-branch session shares the working tree; creating + // a branch would move the shared HEAD out from under it. const bridge = fakeBridge({ - loadImpl: async (req) => ({ - sessionId: req.sessionId, - workspaceCwd: req.workspaceCwd, - attached: false, - clientId: 'client-load', - state: { configOptions: [] }, - }), + listImpl: () => [ + { + sessionId: 'live-session', + workspaceCwd: WS_BOUND, + createdAt: '2026-01-01T00:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }, + ], }); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, { bridge }, ); - const res = await request(app) - .post('/session/persisted-2/load') - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({ cwd: WS_BOUND }); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); - expect(res.status).toBe(200); - expect(res.body.state).toEqual({ configOptions: [] }); + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('branch_session_conflict'); + expect(res.body.existingSessionId).toBe('live-session'); + expect(bridge.calls).toHaveLength(0); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + } + }); + + it('allows branch creation when only a worktree session shares the workspace', async () => { + // Worktree sessions run in their own checkout, so moving the main HEAD + // does not affect them. + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: 'wt-session', + workspaceCwd: WS_BOUND, + createdAt: '2026-01-01T00:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + worktree: { slug: 'task', path: '/tmp/wt', branch: 'wt-task' }, + }, + ], + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); + + expect(res.status).toBe(200); + expect(bridge.calls).toHaveLength(1); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + } + }); + + it('allows branch creation when the sharing session has no attached client', async () => { + // A detached session (e.g. left behind by a "new chat") is not actively + // running, so it must not block a fresh branch session. + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: 'detached-session', + workspaceCwd: WS_BOUND, + createdAt: '2026-01-01T00:00:00.000Z', + clientCount: 0, + hasActivePrompt: false, + }, + ], + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); + + expect(res.status).toBe(200); + expect(bridge.calls).toHaveLength(1); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + } + }); + + it('400 on branch names exceeding the byte-length caps', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const names = [ + 'a'.repeat(201), // single component over the 200-byte cap + `feat/${'a'.repeat(201)}`, // a nested component over the cap + 'a'.repeat(1001), // total over the 1000-byte cap + // ~86 CJK chars in one component exceed 255 UTF-8 bytes (3 bytes + // each) — the realistic pasted-name trigger now that Unicode is + // allowed, which previously surfaced a path-leaking 500. + '功'.repeat(86), + ]; + for (const name of names) { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name } }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('branch_invalid_name'); + } + // Validation rejects before any spawn. + expect(bridge.calls).toHaveLength(0); + }); + + it('accepts a branch name at the byte-length boundary', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + // A 200-byte component is exactly at the cap; it must pass name + // validation and only fail the later git-repo check. + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(false), + getCurrentBranch: () => Promise.resolve('main'), + }); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'a'.repeat(200) } }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('branch_not_git_repo'); + } finally { + mockWt.impl = undefined; + } + }); + + it('200 with branch metadata on successful branch creation', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); + + expect(res.status).toBe(200); + expect(bridge.calls).toHaveLength(1); + expect(bridge.calls[0].branch).toEqual({ + name: 'feat/x', + baseBranch: 'main', + }); + expect(bridge.calls[0]?.sessionScope).toBe('thread'); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + } + }); + + it('rolls back the branch when spawn fails', async () => { + const rollbackCalls: string[] = []; + const bridge = fakeBridge({ + spawnImpl: async () => { + throw new Error('spawn failed'); + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + mockBranchOps.checkoutRef = () => { + rollbackCalls.push('checkout'); + return Promise.resolve(); + }; + mockBranchOps.deleteBranch = () => { + rollbackCalls.push('delete'); + return Promise.resolve(); + }; + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); + + expect(res.status).toBe(500); + expect(rollbackCalls).toContain('checkout'); + expect(rollbackCalls).toContain('delete'); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + mockBranchOps.checkoutRef = undefined; + mockBranchOps.deleteBranch = undefined; + } + }); + + it('rollback skips branch deletion when the base-ref checkout fails', async () => { + const rollbackCalls: string[] = []; + const bridge = fakeBridge({ + spawnImpl: async () => { + throw new Error('spawn failed'); + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + mockBranchOps.checkoutRef = () => { + rollbackCalls.push('checkout'); + return Promise.reject(new Error('checkout failed')); + }; + mockBranchOps.deleteBranch = () => { + rollbackCalls.push('delete'); + return Promise.resolve(); + }; + + try { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/x' } }); + + expect(res.status).toBe(500); + expect(rollbackCalls).toContain('checkout'); + // The workspace is still on the new branch, so deleting it is + // skipped; an orphaned branch is preferred over relying on git's + // checked-out-branch protection. + expect(rollbackCalls).not.toContain('delete'); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + mockBranchOps.checkoutRef = undefined; + mockBranchOps.deleteBranch = undefined; + } + }); + + it('400 on branch names containing whitespace or control characters', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + // The server-side whitelist only permits Unicode letters/numbers and + // `._/-`; everything git forbids (whitespace, control chars, DEL, and + // `~^:?*[`) is rejected before any git operation runs. + const names = [ + 'foo bar', + `foo${String.fromCharCode(9)}bar`, // tab + `foo${String.fromCharCode(10)}bar`, // newline + `foo${String.fromCharCode(0)}bar`, // NUL + `foo${String.fromCharCode(31)}bar`, // control + `foo${String.fromCharCode(127)}bar`, // DEL + 'foo~bar', + 'foo^bar', + 'foo:bar', + 'foo?bar', + 'foo*bar', + 'foo[bar', + ]; + for (const name of names) { + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name } }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('branch_invalid_name'); + } + // Validation rejects before any spawn, so the raw git error (which can + // include workspace paths) never reaches the caller as a 500. + expect(bridge.calls).toHaveLength(0); + }); + + it('self-heals a stale branch entry when the prior session is gone (no spurious 409)', async () => { + const reaped = new Set(); + const bridge = fakeBridge({ + summaryImpl: (sessionId: string) => { + if (reaped.has(sessionId)) { + throw new SessionNotFoundError(sessionId); + } + return { + sessionId, + workspaceCwd: WS_BOUND, + attached: true, + clientId: 'client-0', + }; + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + + try { + const first = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/first' } }); + expect(first.status).toBe(200); + + // Simulate the branch session being reaped without an explicit + // DELETE (channel exited -> the bridge drops it from its map), so + // getSessionSummary now throws for it. + reaped.add(first.body.sessionId); + + // The next branch POST must transparently clean the stale entry and + // proceed, not surface a spurious 409 that needs a client retry. + const second = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/second' } }); + expect(second.status).toBe(200); + expect(second.body.code).toBeUndefined(); + expect(second.body.sessionId).not.toBe(first.body.sessionId); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + } + }); + + it('409 when a branch session is already active for the workspace', async () => { + const bridge = fakeBridge({ + summaryImpl: (sessionId: string) => ({ + sessionId, + workspaceCwd: WS_BOUND, + attached: true, + clientId: 'client-0', + }), + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + + try { + // First request succeeds and registers the branch session. + const first = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/first' } }); + expect(first.status).toBe(200); + + // Second request for the same workspace should conflict. + const second = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/second' } }); + expect(second.status).toBe(409); + expect(second.body.code).toBe('branch_session_conflict'); + expect(second.body.existingSessionId).toBe(first.body.sessionId); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + } + }); + + it('409 on concurrent branch creation race (reserve re-checks activeBranchSessions)', async () => { + const bridge = fakeBridge({ + summaryImpl: (sessionId: string) => ({ + sessionId, + workspaceCwd: WS_BOUND, + attached: true, + clientId: 'client-0', + }), + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + + // A blocks in createBranch; B blocks in getHeadCommit (after + // passing the early guard). A completes and registers; B + // resumes and the reserve re-check rejects it. + let releaseCreate!: () => void; + let signalCreate!: () => void; + const createEntered = new Promise((r) => { + signalCreate = r; + }); + const createGate = new Promise((r) => { + releaseCreate = r; + }); + let createCalls = 0; + mockBranchOps.createBranch = () => { + createCalls++; + if (createCalls === 1) { + signalCreate(); + return createGate; + } + return Promise.resolve(); + }; + + let releaseHead!: () => void; + let signalHead!: () => void; + const headEntered = new Promise((r) => { + signalHead = r; + }); + const headGate = new Promise((r) => { + releaseHead = () => r('abc123'); + }); + let headCalls = 0; + mockBranchOps.getHeadCommit = () => { + headCalls++; + if (headCalls === 1) return Promise.resolve('abc123'); + signalHead(); + return headGate; + }; + + try { + // supertest lazily sends the request on .then()/.end(), so + // wrap in a real promise that starts the request immediately. + const firstPromise = new Promise( + (resolve, reject) => { + request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/first' } }) + .end((err, res) => (err ? reject(err) : resolve(res))); + }, + ); + await createEntered; + + const secondPromise = new Promise( + (resolve, reject) => { + request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/second' } }) + .end((err, res) => (err ? reject(err) : resolve(res))); + }, + ); + await headEntered; + + releaseCreate(); + const first = await firstPromise; + expect(first.status).toBe(200); + + releaseHead(); + const second = await secondPromise; + expect(second.status).toBe(409); + expect(second.body.code).toBe('branch_session_conflict'); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + mockBranchOps.createBranch = undefined; + } + }); + }); + + describe('POST /session/:id/load and /resume', () => { + it('reports resume as unsupported for virtual subagent sessions', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const sessionId = createVirtualSubagentSessionId('parent-1', 'agent-1'); + + const res = await request(app) + .post(`/session/${sessionId}/resume`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: 'Virtual subagent sessions do not support resume', + code: 'unsupported_action', + sessionId, + }); + expect(bridge.resumeCalls).toEqual([]); + }); + + it('passes the requested initial history page size to load', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/session/persisted-page/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ historyPageSize: 100 }); + + expect(res.status).toBe(200); + expect(bridge.loadCalls).toEqual([ + { + sessionId: 'persisted-page', + workspaceCwd: WS_BOUND, + historyReplay: 'response', + historyPageSize: 100, + }, + ]); + }); + + it('falls back to bound workspace and uses the route session id', async () => { + for (const action of ['load', 'resume'] as const) { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post(`/session/persisted-1/${action}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionId: 'spoofed-body-id' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + sessionId: 'persisted-1', + workspaceCwd: WS_BOUND, + attached: false, + clientId: action === 'load' ? 'client-load' : 'client-resume', + state: {}, + hasActivePrompt: false, + }); + const calls = action === 'load' ? bridge.loadCalls : bridge.resumeCalls; + expect(calls).toEqual([ + { + sessionId: 'persisted-1', + workspaceCwd: WS_BOUND, + ...(action === 'load' ? { historyReplay: 'response' } : {}), + }, + ]); + } + }); + + it('releases restore ownership after invalid approvalMode', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const invalid = await request(app) + .post('/session/persisted-approval/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ approvalMode: 'YOLO' }); + expect(invalid.status).toBe(400); + expect(invalid.body.code).toBe('invalid_approval_mode'); + + const valid = await request(app) + .post('/session/persisted-approval/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + expect(valid.status).toBe(200); + expect(bridge.loadCalls).toEqual([ + { + sessionId: 'persisted-approval', + workspaceCwd: WS_BOUND, + historyReplay: 'response', + }, + ]); + }); + + it('passes explicit primary cwd through to the bridge', async () => { + const bridge = fakeBridge({ + loadImpl: async (req) => ({ + sessionId: req.sessionId, + workspaceCwd: req.workspaceCwd, + attached: false, + clientId: 'client-load', + state: { configOptions: [] }, + }), + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/session/persisted-2/load') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: WS_BOUND }); + + expect(res.status).toBe(200); + expect(res.body.state).toEqual({ configOptions: [] }); expect(bridge.loadCalls).toEqual([ { sessionId: 'persisted-2', @@ -15800,6 +16625,54 @@ describe('createServeApp', () => { expect(res.status).toBe(400); expect(res.body.code).toBe('invalid_client_id'); }); + + it('clears the branch session entry on delete so a new branch session can be created', async () => { + const bridge = fakeBridge({ + summaryImpl: (sessionId: string) => ({ + sessionId, + workspaceCwd: WS_BOUND, + attached: true, + clientId: 'client-0', + }), + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + mockWt.impl = () => ({ + isGitRepository: () => Promise.resolve(true), + getCurrentBranch: () => Promise.resolve('main'), + }); + mockBranchOps.getHeadCommit = () => Promise.resolve('abc123'); + + try { + // Create a branch session. + const first = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/first' } }); + expect(first.status).toBe(200); + const sessionId = first.body.sessionId as string; + + // Delete the session — should clear the branch session entry. + const del = await request(app) + .delete(`/session/${sessionId}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send(); + expect(del.status).toBe(204); + + // Creating another branch session for the same workspace succeeds. + const second = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ branch: { name: 'feat/second' } }); + expect(second.status).toBe(200); + } finally { + mockWt.impl = undefined; + mockBranchOps.getHeadCommit = undefined; + } + }); }); describe('GET /session/:id/export', () => { diff --git a/packages/cli/src/serve/server/git-branch-ops.test.ts b/packages/cli/src/serve/server/git-branch-ops.test.ts new file mode 100644 index 00000000000..3a7bfab1c46 --- /dev/null +++ b/packages/cli/src/serve/server/git-branch-ops.test.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + branchExists, + checkoutRef, + createBranch, + deleteBranch, + getHeadCommit, + isDirtyTree, +} from './git-branch-ops.js'; + +// `git-branch-ops.ts` captures `promisify(execFile)` at module load, which +// `vi.mock('node:child_process')` cannot intercept, so these tests exercise the +// real git binary against a throwaway repository. This verifies the actual +// command strings and argument ordering (e.g. the `refs/heads/` prefix, +// `--untracked-files=no`, and `-D`) rather than a mocked stand-in. +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }); +} + +let repo: string; + +beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-git-branch-ops-test-')); + git(repo, 'init', '-q', '-b', 'main'); + git(repo, 'config', 'user.email', 'test@example.com'); + git(repo, 'config', 'user.name', 'Test'); + git(repo, 'config', 'commit.gpgsign', 'false'); + fs.writeFileSync(path.join(repo, 'README.md'), 'hello\n'); + git(repo, 'add', 'README.md'); + git(repo, 'commit', '-qm', 'init'); +}); + +afterEach(() => { + fs.rmSync(repo, { recursive: true, force: true }); +}); + +describe('branchExists', () => { + it('returns true for an existing branch', async () => { + await expect(branchExists(repo, 'main')).resolves.toBe(true); + }); + + it('returns false for a missing branch', async () => { + await expect(branchExists(repo, 'no-such-branch')).resolves.toBe(false); + }); + + it('matches only a local branch ref, not a tag of the same name', async () => { + git(repo, 'tag', 'v1.0.0'); + await expect(branchExists(repo, 'v1.0.0')).resolves.toBe(false); + }); +}); + +describe('isDirtyTree', () => { + it('returns false for a clean tree', async () => { + await expect(isDirtyTree(repo)).resolves.toBe(false); + }); + + it('returns true when a tracked file is modified', async () => { + fs.writeFileSync(path.join(repo, 'README.md'), 'changed\n'); + await expect(isDirtyTree(repo)).resolves.toBe(true); + }); + + it('ignores untracked files', async () => { + fs.writeFileSync(path.join(repo, 'untracked.txt'), 'new\n'); + await expect(isDirtyTree(repo)).resolves.toBe(false); + }); +}); + +describe('getHeadCommit', () => { + it('returns the current HEAD commit sha', async () => { + const expected = git(repo, 'rev-parse', 'HEAD').trim(); + await expect(getHeadCommit(repo)).resolves.toBe(expected); + }); + + it('returns undefined outside a git repository', async () => { + const notARepo = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-not-repo-')); + try { + await expect(getHeadCommit(notARepo)).resolves.toBeUndefined(); + } finally { + fs.rmSync(notARepo, { recursive: true, force: true }); + } + }); +}); + +describe('createBranch', () => { + it('creates and checks out a new branch at the current commit', async () => { + const before = git(repo, 'rev-parse', 'HEAD').trim(); + await createBranch(repo, 'feature'); + expect(git(repo, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe( + 'feature', + ); + expect(git(repo, 'rev-parse', 'HEAD').trim()).toBe(before); + await expect(branchExists(repo, 'feature')).resolves.toBe(true); + }); + + it('rejects when the branch already exists', async () => { + await expect(createBranch(repo, 'main')).rejects.toThrow(); + }); + + it('rejects but leaves the branch created and checked out when a post-checkout hook fails', async () => { + // A failing post-checkout hook makes `git checkout -b` exit nonzero AFTER + // git has already created the ref and moved HEAD (verified against real + // git). The route must treat this as a partial success and roll back; this + // test locks in the hazard shape that rollback guards against. + const hooksDir = path.join(repo, '.git', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, 'post-checkout'); + fs.writeFileSync(hookPath, '#!/bin/sh\nexit 1\n'); + fs.chmodSync(hookPath, 0o755); + + const before = git(repo, 'rev-parse', 'HEAD').trim(); + await expect(createBranch(repo, 'feature')).rejects.toThrow(); + // The branch exists and HEAD moved despite the nonzero exit. + await expect(branchExists(repo, 'feature')).resolves.toBe(true); + expect(git(repo, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe( + 'feature', + ); + expect(git(repo, 'rev-parse', 'HEAD').trim()).toBe(before); + }); +}); + +describe('checkoutRef', () => { + it('switches to an existing branch', async () => { + git(repo, 'branch', 'other'); + await checkoutRef(repo, 'other'); + expect(git(repo, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('other'); + }); + + it('rejects for an unknown ref', async () => { + await expect(checkoutRef(repo, 'no-such-ref')).rejects.toThrow(); + }); +}); + +describe('deleteBranch', () => { + it('deletes an existing branch', async () => { + git(repo, 'branch', 'doomed'); + await deleteBranch(repo, 'doomed'); + await expect(branchExists(repo, 'doomed')).resolves.toBe(false); + }); + + it('force-deletes an unmerged branch', async () => { + git(repo, 'checkout', '-qb', 'unmerged'); + fs.writeFileSync(path.join(repo, 'file.txt'), 'x\n'); + git(repo, 'add', 'file.txt'); + git(repo, 'commit', '-qm', 'unmerged commit'); + git(repo, 'checkout', '-q', 'main'); + await deleteBranch(repo, 'unmerged'); + await expect(branchExists(repo, 'unmerged')).resolves.toBe(false); + }); + + it('rejects when deleting the currently checked-out branch', async () => { + await expect(deleteBranch(repo, 'main')).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/serve/server/git-branch-ops.ts b/packages/cli/src/serve/server/git-branch-ops.ts new file mode 100644 index 00000000000..6d1efa73aff --- /dev/null +++ b/packages/cli/src/serve/server/git-branch-ops.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// Hard timeout for branch git operations. `git checkout -b` takes the +// repository lock; without a bound, a stuck lock or slow hook would hang the +// request and leave the workspace permanently reserved in +// `inFlightBranchWorkspaces`. Mirrors GitWorktreeService's 30s bound. +const GIT_BRANCH_TIMEOUT_MS = 30_000; + +export async function branchExists( + cwd: string, + name: string, +): Promise { + try { + await execFileAsync( + 'git', + ['rev-parse', '--verify', `refs/heads/${name}`], + { cwd, timeout: GIT_BRANCH_TIMEOUT_MS }, + ); + return true; + } catch { + return false; + } +} + +export async function isDirtyTree(cwd: string): Promise { + const { stdout } = await execFileAsync( + 'git', + ['status', '--porcelain', '--untracked-files=no'], + { + cwd, + maxBuffer: 10 * 1024 * 1024, + timeout: GIT_BRANCH_TIMEOUT_MS, + }, + ); + return stdout.trim().length > 0; +} + +export async function getHeadCommit(cwd: string): Promise { + return execFileAsync('git', ['rev-parse', 'HEAD'], { + cwd, + timeout: GIT_BRANCH_TIMEOUT_MS, + }) + .then(({ stdout }) => stdout.trim()) + .catch(() => undefined); +} + +export async function createBranch(cwd: string, name: string): Promise { + await execFileAsync('git', ['checkout', '-b', name], { + cwd, + timeout: GIT_BRANCH_TIMEOUT_MS, + }); +} + +export async function checkoutRef(cwd: string, ref: string): Promise { + await execFileAsync('git', ['checkout', ref], { + cwd, + timeout: GIT_BRANCH_TIMEOUT_MS, + }); +} + +export async function deleteBranch(cwd: string, name: string): Promise { + await execFileAsync('git', ['branch', '-D', name], { + cwd, + timeout: GIT_BRANCH_TIMEOUT_MS, + }); +} diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index d747a8a7d60..9478f714061 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -483,6 +483,13 @@ export interface CreateSessionRequest { * are always created with `sessionScope: 'thread'`. */ worktree?: { slug?: string }; + /** + * Create a new git branch and check it out before starting the + * session. The session runs in the same working directory but on + * the new branch. Mutually exclusive with `worktree`. Branch + * sessions are always created with `sessionScope: 'thread'`. + */ + branch?: { name: string }; } export interface RestoreSessionRequest { @@ -2135,6 +2142,7 @@ export class DaemonClient { : {}), ...(req.sourceId !== undefined ? { sourceId: req.sourceId } : {}), ...(req.worktree !== undefined ? { worktree: req.worktree } : {}), + ...(req.branch !== undefined ? { branch: req.branch } : {}), }), }, async (res) => { diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 6c4a0224984..6bbae62e905 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -307,6 +307,10 @@ export class DaemonSessionClient { return this.session.worktree; } + get branch(): DaemonSession['branch'] { + return this.session.branch; + } + get lastEventId(): number | undefined { return this.lastSeenEventId; } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index f5b696e1002..4b19d11c422 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -440,6 +440,7 @@ export type { DaemonSession, DaemonSessionArchiveState, DaemonWorktreeInfo, + DaemonBranchInfo, DaemonSessionExportFormat, DaemonSessionExportResult, DaemonSessionTranscriptPage, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 8d79effd22f..ddd09cdcb7d 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -622,6 +622,12 @@ export interface DaemonWorktreeInfo { branch: string; } +/** Branch metadata returned when a session is created with a new branch. */ +export interface DaemonBranchInfo { + name: string; + baseBranch: string; +} + /** Returned from `POST /session`. */ export interface DaemonSession { sessionId: string; @@ -651,6 +657,8 @@ export interface DaemonSession { sourcePersisted?: boolean; /** Present when the session was created with worktree isolation. */ worktree?: DaemonWorktreeInfo; + /** Present when the session was created with a new branch. */ + branch?: DaemonBranchInfo; } /** @@ -811,6 +819,8 @@ export interface DaemonSessionSummary { color?: DaemonSessionGroupPresetColor | null; /** Present when the session was created with worktree isolation. */ worktree?: DaemonWorktreeInfo; + /** Present when the session was created with a new branch. */ + branch?: DaemonBranchInfo; } export type DaemonSessionExportFormat = 'html' | 'md' | 'json' | 'jsonl'; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index a9840b7722a..d509bcc8e3d 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -164,6 +164,7 @@ export { type DaemonSession, type DaemonSessionClosedReason, type DaemonWorktreeInfo, + type DaemonBranchInfo, type DaemonSessionClientOptions, type DaemonSessionContextStatus, type DaemonLspServerStatus, diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 8af9e3f07db..410a9b090b7 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -404,6 +404,11 @@ --scrollbar-track: transparent; --scrollbar-thumb: #3a3a3a; --scrollbar-thumb-hover: #525252; + --git-mode-current: #3ddc97; + --git-mode-branch: #f5a623; + --git-mode-worktree: #b48eff; + --git-mode-valid: #48bb78; + --git-mode-invalid: #fc8181; } .themeLight { @@ -472,6 +477,11 @@ --scrollbar-track: transparent; --scrollbar-thumb: #c7c9cc; --scrollbar-thumb-hover: #aeb1b5; + --git-mode-current: #1a7a4a; + --git-mode-branch: #9a6a00; + --git-mode-worktree: #6b3fa0; + --git-mode-valid: #1f7a3a; + --git-mode-invalid: #c0362c; } .content { @@ -491,173 +501,6 @@ overflow: visible; } -.worktreeWelcomeToggle { - display: flex; - align-items: center; - gap: 12px; - width: fit-content; - max-width: 100%; - margin: 18px auto 0; - padding: 9px 18px 9px 10px; - border: 1px solid var(--border); - border-radius: 14px; - background: var(--card, transparent); - color: var(--foreground); - font-family: inherit; - text-align: left; - cursor: pointer; - box-shadow: 0 1px 2px rgb(0 0 0 / 0.04); - transition: - border-color 160ms ease, - box-shadow 160ms ease, - transform 160ms ease; - animation: worktreeWelcomeIn 220ms ease-out; -} - -.worktreeWelcomeToggle:hover { - border-color: var(--color-accent-border, rgba(139, 92, 246, 0.5)); - box-shadow: 0 6px 20px var(--color-accent-bg, rgba(139, 92, 246, 0.16)); - transform: translateY(-1px); -} - -.worktreeWelcomeToggle:active { - transform: translateY(0) scale(0.99); - transition-duration: 60ms; -} - -.worktreeToggleIcon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 34px; - height: 34px; - border-radius: 10px; - background: var(--color-accent-bg, rgba(139, 92, 246, 0.1)); - color: var(--color-accent-fg, #8b5cf6); - transition: - transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1), - background 160ms ease; -} - -.worktreeWelcomeToggle:hover .worktreeToggleIcon { - transform: rotate(-10deg) scale(1.08); - background: var(--color-accent-bg, rgba(139, 92, 246, 0.16)); -} - -.worktreeToggleText { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; -} - -.worktreeToggleLabel { - font-size: 13px; - font-weight: 600; - line-height: 1.35; - color: var(--foreground); -} - -.worktreeToggleHint { - font-size: 11.5px; - line-height: 1.35; - color: var(--muted-foreground); -} - -.worktreeWelcomeBadge { - position: relative; - display: flex; - align-items: center; - gap: 12px; - width: fit-content; - max-width: 100%; - margin: 18px auto 0; - padding: 10px 40px 10px 10px; - border: 1px solid var(--color-accent-border, rgba(139, 92, 246, 0.25)); - border-radius: 14px; - background: var(--color-accent-bg, rgba(139, 92, 246, 0.06)); - color: var(--foreground); - text-align: left; - box-shadow: 0 2px 14px var(--color-accent-bg, rgba(139, 92, 246, 0.12)); - animation: worktreeWelcomeIn 220ms ease-out; -} - -.worktreeBadgeIcon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 36px; - height: 36px; - border-radius: 10px; - background: var(--color-accent-fg, #8b5cf6); - color: #fff; - box-shadow: 0 3px 10px var(--color-accent-border, rgba(139, 92, 246, 0.45)); -} - -.worktreeBadgeText { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; -} - -.worktreeWelcomeTitle { - font-size: 13px; - font-weight: 600; - line-height: 1.35; - color: var(--color-accent-fg, #8b5cf6); -} - -.worktreeWelcomeDesc { - font-size: 11.5px; - line-height: 1.35; - color: var(--muted-foreground); -} - -.worktreeWelcomeCancel { - position: absolute; - top: 7px; - right: 7px; - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - border: none; - border-radius: 8px; - background: transparent; - color: var(--muted-foreground); - cursor: pointer; - opacity: 0.55; - transition: - opacity 140ms ease, - background 140ms ease, - color 140ms ease, - transform 180ms ease; -} - -.worktreeWelcomeCancel:hover { - opacity: 1; - background: var(--color-accent-border, rgba(139, 92, 246, 0.2)); - color: var(--color-accent-fg, #8b5cf6); - transform: rotate(90deg); -} - -@keyframes worktreeWelcomeIn { - from { - opacity: 0; - transform: translateY(5px) scale(0.97); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - .missingSessionState { flex: 1 1 auto; min-height: 0; diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 08545e1ef85..62bd590c4a9 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -51,6 +51,12 @@ type ChatEditorTestProps = { onOpenExistingWorkspace?: () => void; scratchWorkspaceSupported?: boolean; existingFolderWorkspaceSupported?: boolean; + gitModeIntent?: { mode: string; name?: string; slug?: string }; + onGitModeIntentChange?: (intent: { + mode: string; + name?: string; + slug?: string; + }) => void; }; type AddWorkspaceDialogTestProps = { @@ -1879,137 +1885,137 @@ describe('App session callbacks', () => { ); }); - describe('worktree welcome toggle', () => { - beforeEach(() => { - mockConnection.sessionId = undefined; - mockWorkspace.capabilities = { - workspaces: [ - { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, - ], - }; - mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ - workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), - workspaceSkills: mockWorkspaceActions.loadSkillsStatus, - })); + it('clears the git mode intent when starting a new session from the sidebar', async () => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + const { container } = renderApp(); + await flush(); + await flush(); + + // Set branch intent via the ChatEditor prop. + const intentChange = testState.latestChatEditorProps?.onGitModeIntentChange; + expect(intentChange).toBeDefined(); + act(() => { + intentChange?.({ mode: 'branch', name: 'feat/test' }); }); + await flush(); - const toggleSelector = '[data-testid="worktree-welcome-toggle"]'; - const cancelSelector = '[data-testid="worktree-welcome-cancel"]'; - const badgeDesc = 'Changes happen'; + // Click "New session" from the sidebar — should reset the intent. + await act(async () => { + container + .querySelector('[data-testid="new-session"]') + ?.click(); + await Promise.resolve(); + }); - async function waitForToggle(container: HTMLElement): Promise { + // Submit a message — createSession should NOT include branch. + await act(async () => { + testState.latestChatEditorProps?.onSubmit('regular session'); await vi.waitFor(() => { - expect(container.querySelector(toggleSelector)).not.toBeNull(); - }); - } - - async function clickButton( - container: HTMLElement, - selector: string, - ): Promise { - await act(async () => { - container.querySelector(selector)?.click(); + expect(mockSessionActions.createSession).toHaveBeenCalled(); }); - } - - it('shows the toggle in the empty state for a trusted git workspace', async () => { - const { container } = renderApp({ showWorktreeToggle: true }); - await waitForToggle(container); - }); - - it('hides the toggle for an untrusted workspace', async () => { - mockWorkspace.capabilities = { - workspaces: [ - { id: 'primary', cwd: '/workspace', primary: true, trusted: false }, - ], - }; - const { container } = renderApp({ showWorktreeToggle: true }); - await flush(); - await flush(); - expect(container.querySelector(toggleSelector)).toBeNull(); }); + const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as + | Record + | undefined; + expect(arg?.['branch']).toBeUndefined(); + }); - it('hides the toggle when the workspace is not a git repository', async () => { - mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ - workspaceGit: vi.fn().mockRejectedValue(new Error('not a git repo')), - workspaceSkills: mockWorkspaceActions.loadSkillsStatus, - })); - const { container } = renderApp({ showWorktreeToggle: true }); - await flush(); - await flush(); - expect(container.querySelector(toggleSelector)).toBeNull(); - }); + it('hides the git mode chip when the workspace is not trusted', async () => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: false }, + ], + }; + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + renderApp(); + await flush(); + await flush(); - it('toggles the pending badge on and off', async () => { - const { container } = renderApp({ showWorktreeToggle: true }); - await waitForToggle(container); + expect(testState.latestChatEditorProps?.gitModeIntent).toBeUndefined(); + expect( + testState.latestChatEditorProps?.onGitModeIntentChange, + ).toBeUndefined(); + }); - await clickButton(container, toggleSelector); - expect(container.textContent).toContain(badgeDesc); - expect(container.querySelector(toggleSelector)).toBeNull(); + it('forwards the branch intent to createSession when submitting a prompt', async () => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + renderApp(); + await flush(); + await flush(); - await clickButton(container, cancelSelector); - expect(container.textContent).not.toContain(badgeDesc); - expect(container.querySelector(toggleSelector)).not.toBeNull(); + const intentChange = testState.latestChatEditorProps?.onGitModeIntentChange; + expect(intentChange).toBeDefined(); + act(() => { + intentChange?.({ mode: 'branch', name: 'feat/test' }); }); + await flush(); - it('creates the session with worktree when the toggle is enabled', async () => { - const { container } = renderApp({ showWorktreeToggle: true }); - await waitForToggle(container); - await clickButton(container, toggleSelector); - - await act(async () => { - testState.latestChatEditorProps?.onSubmit('work in isolation'); - await vi.waitFor(() => { - expect(mockSessionActions.createSession).toHaveBeenCalled(); - }); + await act(async () => { + testState.latestChatEditorProps?.onSubmit('branch session'); + await vi.waitFor(() => { + expect(mockSessionActions.createSession).toHaveBeenCalled(); }); - const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as - | Record - | undefined; - expect(arg?.['worktree']).toEqual({}); }); - it('creates the session without worktree when the toggle is off', async () => { - renderApp({ showWorktreeToggle: true }); - await flush(); - - await act(async () => { - testState.latestChatEditorProps?.onSubmit('regular session'); - await vi.waitFor(() => { - expect(mockSessionActions.createSession).toHaveBeenCalled(); - }); - }); - const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as - | Record - | undefined; - expect(arg?.['worktree']).toBeUndefined(); - }); + expect(mockSessionActions.createSession).toHaveBeenCalledWith( + expect.objectContaining({ branch: { name: 'feat/test' } }), + ); + }); - it('clears the pending worktree intent when starting a new session from the sidebar', async () => { - const { container } = renderApp({ showWorktreeToggle: true }); - await waitForToggle(container); - await clickButton(container, toggleSelector); - expect(container.textContent).toContain(badgeDesc); + it('forwards the worktree intent to createSession when submitting a prompt', async () => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + renderApp(); + await flush(); + await flush(); - await act(async () => { - container - .querySelector('[data-testid="new-session"]') - ?.click(); - await Promise.resolve(); - }); + const intentChange = testState.latestChatEditorProps?.onGitModeIntentChange; + expect(intentChange).toBeDefined(); + act(() => { + intentChange?.({ mode: 'worktree', slug: 'feat-a' }); + }); + await flush(); - await act(async () => { - testState.latestChatEditorProps?.onSubmit('regular session'); - await vi.waitFor(() => { - expect(mockSessionActions.createSession).toHaveBeenCalled(); - }); + await act(async () => { + testState.latestChatEditorProps?.onSubmit('worktree session'); + await vi.waitFor(() => { + expect(mockSessionActions.createSession).toHaveBeenCalled(); }); - const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as - | Record - | undefined; - expect(arg?.['worktree']).toBeUndefined(); }); + + expect(mockSessionActions.createSession).toHaveBeenCalledWith( + expect.objectContaining({ worktree: { slug: 'feat-a' } }), + ); }); it('reloads skills from the target workspace when starting a new session', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 94e1dac7188..e74aeeb2467 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -39,7 +39,8 @@ import type { DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; -import { GitForkIcon, XIcon } from 'lucide-react'; + +import { type SessionGitIntent } from './components/GitModePopover'; import { SESSION_TRANSCRIPT_PAGINATION_FEATURE } from './constants/sessions'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; @@ -576,8 +577,6 @@ export interface WebShellProps { renderToolHeaderExtra?: ToolHeaderExtraRenderer; /** Custom renderer for the welcome header. Receives version, cwd, model, and mode. */ renderWelcomeHeader?: WelcomeHeaderRenderer; - /** Show the worktree-isolation action in the empty welcome state. Defaults to false. */ - showWorktreeToggle?: boolean; /** Custom renderer shown below the chat composer in the empty welcome state. */ renderWelcomeFooter?: WelcomeFooterRenderer; /** @@ -669,9 +668,11 @@ type SessionActionsWithCreate = { approvalMode?: string; sourceType?: string; worktree?: { slug?: string }; + branch?: { name: string }; }) => Promise<{ sessionId: string; worktree?: { slug: string; path: string; branch: string }; + branch?: { name: string; baseBranch: string }; }>; attachSession: () => Promise; clearSession: () => Promise; @@ -1072,7 +1073,6 @@ export function App({ composerTagIcons, renderToolHeaderExtra, renderWelcomeHeader, - showWorktreeToggle = false, renderWelcomeFooter, mobileWelcomeFooterMiddle = false, parseUserMessageContent, @@ -1419,6 +1419,10 @@ export function App({ const [sessionWorktree, setSessionWorktree] = useState< { slug: string; path: string; branch: string } | undefined >(undefined); + /** Branch metadata for the current session (set after creation with branch mode). */ + const [sessionBranch, setSessionBranch] = useState< + { name: string; baseBranch: string } | undefined + >(undefined); // Tracks the session id from the latest effect run. In-flight fetches // compare their captured sid against this ref on resolve: a match means // the response is still relevant and may set OR clear the worktree state; @@ -1435,6 +1439,7 @@ export function App({ worktreeSessionIdRef.current = sid; if (!sid) { setSessionWorktree(undefined); + setSessionBranch(undefined); return; } workspace.client @@ -1442,11 +1447,13 @@ export function App({ .then((summary) => { if (worktreeSessionIdRef.current === sid) { setSessionWorktree(summary.worktree); + setSessionBranch(summary.branch); } }) .catch(() => { if (worktreeSessionIdRef.current === sid) { setSessionWorktree(undefined); + setSessionBranch(undefined); } }); }, [connection.sessionId, workspace.client]); @@ -2972,10 +2979,14 @@ export function App({ const [isPreparingPrompt, setIsPreparingPrompt] = useState(false); const createSessionPromiseRef = useRef | null>(null); const preparingSessionIdRef = useRef(null); - /** Worktree request for the next lazily-created session. */ - const pendingWorktreeRef = useRef<{ slug?: string } | undefined>(undefined); - /** Render-visible mirror of pendingWorktreeRef for the empty-state badge. */ - const [worktreePending, setWorktreePending] = useState(false); + /** Git mode intent for the next lazily-created session (branch or worktree). */ + const [gitModeIntent, setGitModeIntent] = useState({ + mode: 'current', + }); + const gitModeIntentRef = useRef(gitModeIntent); + useEffect(() => { + gitModeIntentRef.current = gitModeIntent; + }, [gitModeIntent]); const newSessionSuggestionSubmitTokenRef = useRef(0); const pendingNewSessionSuggestionSubmitRef = useRef<{ token: number; @@ -3043,7 +3054,14 @@ export function App({ modeId, workspaceCwd: lockedWorkspaceCwd ?? acceptedWorkspaceCwd ?? primaryWorkspaceCwd, - worktree: pendingWorktreeRef.current, + worktree: + gitModeIntentRef.current.mode === 'worktree' + ? { slug: gitModeIntentRef.current.slug } + : undefined, + branch: + gitModeIntentRef.current.mode === 'branch' + ? { name: gitModeIntentRef.current.name } + : undefined, onSessionCreated: onSessionCreatedRef.current, onSessionAllocated: (sessionId) => { preparingSessionIdRef.current = sessionId; @@ -3053,11 +3071,13 @@ export function App({ if (result.worktree) { setSessionWorktree(result.worktree); } + if (result.branch) { + setSessionBranch(result.branch); + } // Clear the pending intent only on success. On failure the - // welcome badge stays visible so the user knows the isolation - // intent was not fulfilled and can retry. - pendingWorktreeRef.current = undefined; - setWorktreePending(false); + // composer chip stays in the selected mode so the user knows + // the intent was not fulfilled and can retry. + setGitModeIntent({ mode: 'current' }); }); // One-shot: the picker targets only the *next* new session, so clear // it after creation. The next new chat defaults back to the primary @@ -3218,6 +3238,16 @@ export function App({ // git-status effect targets (computed once above), so the chip and the // dialog always target the same repo. const gitDiffWorkspaceCwd = activeWorkspaceCwd; + const gitModeEligible = Boolean( + !connection.sessionId && + workspaces.find((entry) => entry.cwd === activeWorkspaceCwd)?.trusted && + selectedWorkspaceGitStatus?.branch, + ); + useEffect(() => { + if (!gitModeEligible) { + setGitModeIntent({ mode: 'current' }); + } + }, [gitModeEligible]); const dialogOpen = showResumeDialog || showDeleteDialog || @@ -4148,10 +4178,9 @@ export function App({ const targetWorkspaceCwd = lockedWorkspaceCwd ?? workspaceCwd; selectedWorkspaceCwdRef.current = targetWorkspaceCwd; setSelectedWorkspaceCwd(targetWorkspaceCwd); - // Starting a fresh chat drops any pending worktree intent set from the - // empty-state toggle, so it never leaks into the next created session. - pendingWorktreeRef.current = undefined; - setWorktreePending(false); + // Starting a fresh chat drops any pending git mode intent so it never + // leaks into the next created session. + setGitModeIntent({ mode: 'current' }); // Close the drawer before awaiting so a failed createSession() doesn't leave // it stuck open with the page scroll still locked, matching loadSidebarSession. closeMobileDrawer(); @@ -4170,8 +4199,9 @@ export function App({ reloadLoadedSkills(targetWorkspaceCwd), ]); // Clear after successful clearSession — if it rejects, the old - // session's worktree state is preserved. + // session's worktree/branch state is preserved. setSessionWorktree(undefined); + setSessionBranch(undefined); return true; } catch (error) { if (composerFocusRequestRef.current === focusRequest) { @@ -4563,9 +4593,9 @@ export function App({ async (sessionId: string, workspaceCwd?: string) => { composerFocusRequestRef.current += 1; setSidebarSwitchingSessionId(sessionId); - pendingWorktreeRef.current = undefined; - setWorktreePending(false); + setGitModeIntent({ mode: 'current' }); setSessionWorktree(undefined); + setSessionBranch(undefined); // Close the drawer before awaiting the load; the transcript clears // immediately and shows its loading skeleton for the selected session. closeMobileDrawer(); @@ -6383,42 +6413,6 @@ export function App({ ], ); - // The empty-state toggle is offered only when the workspace the next - // session would land in is trusted and is a git repository — the daemon - // rejects worktree creation otherwise. Mirrors the sidebar entry's gating. - const worktreeToggleEligible = Boolean( - showWorktreeToggle && - workspaces.find((entry) => entry.cwd === activeWorkspaceCwd)?.trusted && - selectedWorkspaceGitStatus?.branch, - ); - const worktreeToggleRef = useRef(null); - const worktreeCancelRef = useRef(null); - const worktreeFocusTarget = useRef<'cancel' | 'toggle' | null>(null); - const handleEnableWorktree = useCallback(() => { - pendingWorktreeRef.current = {}; - setWorktreePending(true); - worktreeFocusTarget.current = 'cancel'; - }, []); - const handleCancelWorktree = useCallback(() => { - pendingWorktreeRef.current = undefined; - setWorktreePending(false); - worktreeFocusTarget.current = 'toggle'; - }, []); - useEffect(() => { - if (showWorktreeToggle) return; - pendingWorktreeRef.current = undefined; - setWorktreePending(false); - }, [showWorktreeToggle]); - useEffect(() => { - if (!worktreeFocusTarget.current) return; - const target = worktreeFocusTarget.current; - worktreeFocusTarget.current = null; - if (target === 'cancel') { - worktreeCancelRef.current?.focus(); - } else { - worktreeToggleRef.current?.focus(); - } - }, [worktreePending]); const welcomeHeader = useMemo( () => ( <> @@ -6427,65 +6421,9 @@ export function App({ ) : ( )} - {showWorktreeToggle && worktreePending ? ( -
- - - - - - {t('worktree.welcomeTitle')} - - - {t('worktree.welcomeDesc')} - - - -
- ) : ( - worktreeToggleEligible && ( - - ) - )} ), - [ - renderWelcomeHeader, - showWorktreeToggle, - welcomeHeaderProps, - worktreePending, - worktreeToggleEligible, - handleEnableWorktree, - handleCancelWorktree, - t, - ], + [renderWelcomeHeader, welcomeHeaderProps], ); const welcomeFooter = useMemo( () => renderWelcomeFooter?.(welcomeHeaderProps), @@ -7923,12 +7861,17 @@ export function App({ sessionWorktree ? (selectedWorkspaceGitStatus?.branch ?? sessionWorktree.branch) - : (connection.sessionId - ? connection.gitBranch - : (selectedWorkspaceGitStatus?.branch ?? - undefined)) + : sessionBranch + ? (selectedWorkspaceGitStatus?.branch ?? + sessionBranch.name) + : (connection.sessionId + ? connection.gitBranch + : (selectedWorkspaceGitStatus?.branch ?? + undefined)) } gitWorktree={Boolean(sessionWorktree)} + gitModeIntent={gitModeEligible ? gitModeIntent : undefined} + onGitModeIntentChange={gitModeEligible ? setGitModeIntent : undefined} gitStatus={selectedWorkspaceGitStatus} onOpenGitDiff={ gitDiffWorkspaceCwd && !sessionWorktree diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index a3e457a1387..f578e736e34 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -51,6 +51,7 @@ import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; import { VoiceButton } from '../voice/VoiceButton'; import { GitBranchChipContent, GitBranchIndicator } from './GitBranchIndicator'; +import { GitModePopover, type SessionGitIntent } from './GitModePopover'; import { WorkspaceIndicator } from './WorkspaceIndicator'; import { ChevronDownIcon, FolderClosedIcon } from 'lucide-react'; import { WorkspaceSelector } from './WorkspaceSelector'; @@ -126,6 +127,10 @@ interface ChatEditorProps { gitBranch?: string; /** Whether the session is in a worktree (styles the git chip purple). */ gitWorktree?: boolean; + /** Git mode intent for the empty-state composer chip (branch/worktree selection). */ + gitModeIntent?: SessionGitIntent; + /** Callback when the user changes the git mode intent via the composer chip popover. */ + onGitModeIntentChange?: (intent: SessionGitIntent) => void; /** Enriched working-tree summary (dirty / ahead-behind / stash / operation). */ gitStatus?: DaemonWorkspaceGitStatus; /** Opens the working-tree Changes dialog; makes the git chip clickable. */ @@ -1157,6 +1162,8 @@ export const ChatEditor = memo( currentModel = '', gitBranch, gitWorktree, + gitModeIntent, + onGitModeIntentChange, gitStatus, onOpenGitDiff, workspaceName, @@ -2101,15 +2108,24 @@ export const ChatEditor = memo( })} /> )} - {gitBranchVisible && gitBranch && ( - - )} + {gitBranchVisible && + gitBranch && + (gitModeIntent && onGitModeIntentChange ? ( + + ) : ( + + ))} {showModeAction && (
{ + it.each([ + 'feat/../x', + 'feat//x', + 'feat@{1}', + 'feat.lock', + '.hidden', + '-feat', + 'HEAD', + 'feature.git', + '', + ])('rejects %s', (name) => { + expect(validateBranchName(name)).toBe(false); + }); + + it.each(['feat/x', 'fix/bug-123', 'my-branch', 'release/v1.0.0', 'a'])( + 'accepts %s', + (name) => { + expect(validateBranchName(name)).toBe(true); + }, + ); + + it('rejects names exceeding the byte-length caps', () => { + // Mirrors the server-side caps (200 bytes per `/`-separated component, + // 1000 bytes total). Counted in UTF-8 bytes, so CJK chars (3 bytes each) + // trip the component cap at ~86 chars. + expect(validateBranchName('a'.repeat(201))).toBe(false); + expect(validateBranchName(`feat/${'a'.repeat(201)}`)).toBe(false); + expect(validateBranchName('a'.repeat(1001))).toBe(false); + expect(validateBranchName('功'.repeat(86))).toBe(false); + }); + + it('accepts a name at the byte-length boundary', () => { + expect(validateBranchName('a'.repeat(200))).toBe(true); + }); +}); diff --git a/packages/web-shell/client/components/GitModePopover.tsx b/packages/web-shell/client/components/GitModePopover.tsx new file mode 100644 index 00000000000..5cb5b660a96 --- /dev/null +++ b/packages/web-shell/client/components/GitModePopover.tsx @@ -0,0 +1,345 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useMemo, useRef, useState } from 'react'; +import { CircleDotIcon, GitBranchIcon, GitForkIcon } from 'lucide-react'; +import { useI18n } from '../i18n'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import styles from './GitModePopover.module.css'; + +export type SessionGitIntent = + | { mode: 'current' } + | { mode: 'branch'; name: string } + | { mode: 'worktree'; slug?: string }; + +// Byte-length caps mirroring the server predicate in session.ts; keep in sync. +// git creates loose refs as files, so each `/`-separated component is bounded +// by the filesystem's per-component name limit (~255 bytes minus git's `.lock` +// suffix). Count UTF-8 bytes, not code points, since Unicode is allowed. +const MAX_BRANCH_NAME_BYTES = 1000; +const MAX_BRANCH_COMPONENT_BYTES = 200; + +const branchNameEncoder = new TextEncoder(); + +// UX-only validation; the server re-validates in POST /session (session.ts). +// Keep the two predicates in sync. +export function validateBranchName(name: string): boolean { + if (!name) return false; + return !( + /[^\p{L}\p{N}._/-]/u.test(name) || + name.includes('..') || + name.includes('//') || + name.startsWith('.') || + name.startsWith('-') || + name.startsWith('/') || + name.endsWith('/') || + name.endsWith('.') || + name.endsWith('.git') || + name.includes('@{') || + name.split('/').some((c) => c.startsWith('.') || c.endsWith('.lock')) || + name.toUpperCase() === 'HEAD' || + branchNameEncoder.encode(name).length > MAX_BRANCH_NAME_BYTES || + name + .split('/') + .some( + (c) => branchNameEncoder.encode(c).length > MAX_BRANCH_COMPONENT_BYTES, + ) + ); +} + +interface GitModePopoverProps { + branch: string; + compact?: boolean; + intent: SessionGitIntent; + onIntentChange: (intent: SessionGitIntent) => void; +} + +export function GitModePopover({ + branch, + compact = false, + intent, + onIntentChange, +}: GitModePopoverProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [selectedMode, setSelectedMode] = useState< + 'current' | 'branch' | 'worktree' + >(intent.mode); + const [branchName, setBranchName] = useState( + intent.mode === 'branch' ? intent.name : '', + ); + + const contentRef = useRef(null); + + const branchValid = useMemo( + () => validateBranchName(branchName), + [branchName], + ); + + const handleOpenChange = useCallback( + (v: boolean) => { + setOpen(v); + if (v) { + setSelectedMode(intent.mode); + setBranchName(intent.mode === 'branch' ? intent.name : ''); + } + }, + [intent], + ); + + const handleSelectCurrent = useCallback(() => { + onIntentChange({ mode: 'current' }); + setOpen(false); + }, [onIntentChange]); + + const handleConfirmBranch = useCallback(() => { + if (!branchName || !branchValid) return; + onIntentChange({ mode: 'branch', name: branchName }); + setOpen(false); + }, [branchName, branchValid, onIntentChange]); + + const handleConfirmWorktree = useCallback(() => { + onIntentChange({ mode: 'worktree' }); + setOpen(false); + }, [onIntentChange]); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onIntentChange({ mode: 'current' }); + setOpen(false); + }, + [onIntentChange], + ); + + const isBranch = intent.mode === 'branch'; + const isWorktree = intent.mode === 'worktree'; + const chipLabel = isBranch + ? `→ ${intent.name}` + : isWorktree + ? t('gitMode.worktree') + : branch; + + return ( + + + + + + e.preventDefault()} + onInteractOutside={(e) => { + // The portal container fools Radix's dismissable-layer into + // thinking clicks inside the popover are "outside". Only + // prevent dismissal when the target is genuinely inside. + if (contentRef.current?.contains(e.target as Node)) { + e.preventDefault(); + } + }} + > +
{t('gitMode.title')}
+ + + + + + {selectedMode === 'branch' && ( +
+
+ + + setBranchName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && branchName && branchValid) + handleConfirmBranch(); + }} + placeholder={t('gitMode.branchPlaceholder')} + autoFocus + spellCheck={false} + autoComplete="off" + id="git-mode-branch-input" + data-testid="git-mode-branch-input" + /> + {branchName && ( + + )} + +
+
+ {branchName && !branchValid + ? t('gitMode.branchInvalidName') + : t('gitMode.branchHint')} +
+ {branchName && branchValid && ( +
+ {t('gitMode.branchConflictWarning')} +
+ )} +
+ )} + + + +
+ + {selectedMode === 'branch' + ? `$ git checkout -b ${branchName || '…'} ← ${branch}` + : selectedMode === 'worktree' + ? '$ git worktree add .qwen/worktrees/' + : `$ git checkout ${branch}`} + + {selectedMode === 'branch' && ( + + )} + {selectedMode === 'worktree' && ( + + )} +
+
+
+ {(isBranch || isWorktree) && ( + + )} +
+ ); +} diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 0a3f461c70e..21f338010a7 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -41,6 +41,7 @@ import { ArchiveRestoreIcon, DownloadIcon, FolderInputIcon, + GitBranchIcon, GitForkIcon, PencilIcon, PinIcon, @@ -2616,6 +2617,14 @@ export function WebShellSidebar({ aria-label={t('sidebar.newWorktreeTask')} /> )} + {session.branch && ( + + )} {label}
diff --git a/packages/web-shell/client/e2e/capture-git-mode-screenshots.ts b/packages/web-shell/client/e2e/capture-git-mode-screenshots.ts new file mode 100644 index 00000000000..43dba6cba25 --- /dev/null +++ b/packages/web-shell/client/e2e/capture-git-mode-screenshots.ts @@ -0,0 +1,145 @@ +/** + * Standalone Playwright script to capture git mode selector screenshots. + * Usage: npx tsx client/e2e/capture-git-mode-screenshots.ts + * Requires: Vite dev server running on port 5174 + */ +import { mkdirSync } from 'node:fs'; +import { chromium } from '@playwright/test'; +import { + createWebShellDaemonScenario, + installMockDaemon, +} from './utils/mockDaemon'; + +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:5174'; +const OUT_DIR = 'client/e2e/test-results'; +mkdirSync(OUT_DIR, { recursive: true }); +const WORKSPACE_CWD = '/tmp/qwen-web-shell-e2e'; + +async function main() { + const scenario = createWebShellDaemonScenario({ + capabilities: { + workspaces: [ + { id: 'primary', cwd: WORKSPACE_CWD, primary: true, trusted: true }, + ], + }, + gitStatus: { v: 2, workspaceCwd: WORKSPACE_CWD, branch: 'main' }, + }); + + const browser = await chromium.launch(); + try { + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }); + + await installMockDaemon(page, scenario, { baseURL: BASE_URL }); + + console.log('Navigating to', BASE_URL); + await page.goto(BASE_URL, { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + // Screenshot 1: default state with git chip + const chip = page.locator('[data-testid="git-mode-chip"]'); + try { + await chip.waitFor({ state: 'visible', timeout: 10_000 }); + console.log('✓ Git mode chip visible'); + } catch { + console.log('✗ Git mode chip not found, taking screenshot anyway'); + await page.screenshot({ + path: `${OUT_DIR}/git-mode-1-default.png`, + animations: 'disabled', + }); + return; + } + await page.screenshot({ + path: `${OUT_DIR}/git-mode-1-default.png`, + animations: 'disabled', + }); + console.log('✓ Screenshot 1: default state'); + + // Click chip to open popover + await chip.click(); + await page.waitForTimeout(500); + + const popover = page.locator('[data-slot="popover-content"]'); + try { + await popover.waitFor({ state: 'visible', timeout: 5_000 }); + console.log('✓ Popover visible'); + } catch { + console.log('✗ Popover not found'); + await page.screenshot({ + path: `${OUT_DIR}/git-mode-2-popover.png`, + animations: 'disabled', + }); + return; + } + await page.screenshot({ + path: `${OUT_DIR}/git-mode-2-popover.png`, + animations: 'disabled', + }); + console.log('✓ Screenshot 2: popover open'); + + // Prevent Radix DismissableLayer from closing the popover when + // interacting inside the portal. Radix uses both pointerdown and + // focusin heuristics; the portal container fools both. + await page.evaluate(` + (() => { + const guard = (e) => { + const popover = document.querySelector('[data-slot="popover-content"]'); + if (popover && popover.contains(e.target)) { + e.stopImmediatePropagation(); + } + }; + document.addEventListener('pointerdown', guard, true); + document.addEventListener('focusin', guard, true); + })() + `); + + // Click "New branch" option + const branchOption = popover + .getByRole('button', { name: /New branch/ }) + .first(); + await branchOption.click(); + await page.waitForTimeout(500); + + const branchInput = page.locator('[data-testid="git-mode-branch-input"]'); + try { + await branchInput.waitFor({ state: 'visible', timeout: 5_000 }); + console.log('✓ Branch input visible'); + } catch { + console.log('✗ Branch input not found'); + await page.screenshot({ + path: `${OUT_DIR}/git-mode-debug-no-branch-input.png`, + animations: 'disabled', + }); + return; + } + await branchInput.fill('feat/git-mode-selector'); + await page.waitForTimeout(300); + + await page.screenshot({ + path: `${OUT_DIR}/git-mode-3-branch-input.png`, + animations: 'disabled', + }); + console.log('✓ Screenshot 3: branch input with name'); + + // Confirm branch + const confirmBtn = page.locator('[data-testid="git-mode-confirm-branch"]'); + await confirmBtn.click(); + await page.waitForTimeout(500); + + await page.screenshot({ + path: `${OUT_DIR}/git-mode-4-branch-selected.png`, + animations: 'disabled', + }); + console.log('✓ Screenshot 4: branch selected, chip updated'); + + console.log('\nDone! Screenshots saved to', OUT_DIR); + } finally { + await browser.close(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index 59979927b45..cb400f69947 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -394,12 +394,14 @@ for (const theme of THEMES) { await captureScreenshot(page, `workspace-sidebar-${theme}`); }); - test(`worktree empty state`, async ({ page }, testInfo) => { - // The new-session empty state offers worktree isolation only when the - // workspace the next session would use is trusted AND a git repo (the - // daemon rejects worktree creation otherwise). Every other scenario lands - // on /session/:id, so this is the suite's only view of the empty state — - // without it the toggle is invisible to the before/after preview. + test(`git mode selector`, async ({ page }, testInfo) => { + // The new-session composer offers a git-mode selector (current branch / + // new branch / worktree) only when the workspace the next session would + // use is trusted AND a git repo, and App.tsx wires the intent props only + // while no session is loaded. So this empty state is the suite's only + // view of the popover — without a scenario the whole selector (and the + // empty-state composer around it) is invisible to the before/after + // preview. const workspaceCwd = '/tmp/qwen-web-shell-e2e'; const scenario = createWebShellDaemonScenario({ workspaceCwd, @@ -413,19 +415,24 @@ for (const theme of THEMES) { await installScenario(page, scenario, resolveBaseURL(testInfo)); await gotoNewSession(page, theme); - const toggle = page.locator('[data-testid="worktree-welcome-toggle"]'); - await expect(toggle).toBeVisible(); - await captureScreenshot(page, `worktree-empty-state-${theme}`); + // Closed: the composer chip advertising the current git mode. + const chip = page.locator('[data-testid="git-mode-chip"]'); + await expect(chip).toBeVisible(); + await captureScreenshot(page, `git-mode-chip-${theme}`); - // Enabling it swaps the toggle for the pending-worktree badge — the state - // the next session would be created in — with a cancel affordance. Assert - // the swap so a regression that drops the enabled state fails here, not - // only in the (visually reviewed) screenshot. - await toggle.click(); + // Open: the three-mode popover (current / new branch / worktree). Assert + // an option is visible (not just the chip's aria-label) so a regression + // that fails to open the popover fails here, not only in the visually + // reviewed screenshot. The branch-name sub-state is intentionally not + // captured: its input autoFocuses, and the popover then dismisses on the + // idle frame captureScreenshot waits for — so it can't be shot stably + // through this pipeline (the functional web-shell.git-mode.spec.ts drives + // that path). The chip + open popover already show the new UI head-only. + await chip.click(); await expect( - page.locator('[data-testid="worktree-welcome-cancel"]'), + page.getByText('Current branch', { exact: true }), ).toBeVisible(); - await captureScreenshot(page, `worktree-empty-state-enabled-${theme}`); + await captureScreenshot(page, `git-mode-popover-${theme}`); }); test(`slash menu`, async ({ page }, testInfo) => { diff --git a/packages/web-shell/client/e2e/web-shell.git-mode.spec.ts b/packages/web-shell/client/e2e/web-shell.git-mode.spec.ts new file mode 100644 index 00000000000..d7e89ba5832 --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.git-mode.spec.ts @@ -0,0 +1,259 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + createWebShellDaemonScenario, + installMockDaemon, + type MockDaemonController, + type WebShellDaemonScenario, +} from './utils/mockDaemon'; + +const WORKSPACE_CWD = '/tmp/qwen-web-shell-e2e'; + +function createGitWorkspaceScenario( + overrides: Parameters[0] = {}, +): WebShellDaemonScenario { + return createWebShellDaemonScenario({ + capabilities: { + workspaces: [ + { id: 'primary', cwd: WORKSPACE_CWD, primary: true, trusted: true }, + ], + }, + gitStatus: { v: 2, workspaceCwd: WORKSPACE_CWD, branch: 'main' }, + ...overrides, + }); +} + +async function installScenario( + page: Page, + scenario: WebShellDaemonScenario, + baseURL: string, +): Promise { + return installMockDaemon(page, scenario, { baseURL }); +} + +async function fillComposer(page: Page, text: string): Promise { + const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); + await editor.click(); + await page.keyboard.press( + process.platform === 'darwin' ? 'Meta+A' : 'Control+A', + ); + await page.keyboard.type(text); +} + +function sessionCreateBody( + daemon: MockDaemonController, +): Record | undefined { + const record = daemon.requests.find( + (r) => r.method === 'POST' && r.path === '/session', + ); + return record?.body as Record | undefined; +} + +test('git mode chip shows popover with three modes and captures screenshots', async ({ + page, +}, testInfo) => { + const scenario = createGitWorkspaceScenario(); + const daemon = await installScenario( + page, + scenario, + String(testInfo.project.use.baseURL), + ); + + await page.goto('/'); + + // Wait for the git mode chip to appear in the composer toolbar + const chip = page.locator('[data-testid="git-mode-chip"]'); + await expect(chip).toBeVisible({ timeout: 10_000 }); + + // Screenshot 1: default state with git chip + await page.screenshot({ + path: 'client/e2e/test-results/git-mode-1-default.png', + animations: 'disabled', + }); + + // Click the chip to open the popover + await chip.click(); + + // Wait for the popover to appear + const popover = page.locator('[data-slot="popover-content"]'); + await expect(popover).toBeVisible({ timeout: 5_000 }); + + // Screenshot 2: popover open showing three modes + await page.screenshot({ + path: 'client/e2e/test-results/git-mode-2-popover.png', + animations: 'disabled', + }); + + // Click "New branch" option + const branchOption = popover.getByText('New branch', { exact: false }); + await branchOption.click(); + + // Wait for the branch input to appear + const branchInput = page.locator('[data-testid="git-mode-branch-input"]'); + await expect(branchInput).toBeVisible({ timeout: 5_000 }); + + // Type a branch name + await branchInput.fill('feat/git-mode-selector'); + + // Screenshot 3: branch input with valid name + await page.screenshot({ + path: 'client/e2e/test-results/git-mode-3-branch-input.png', + animations: 'disabled', + }); + + // Confirm the branch selection + const confirmBtn = page.locator('[data-testid="git-mode-confirm-branch"]'); + await expect(confirmBtn).toBeEnabled(); + await confirmBtn.click(); + + // Popover should close, chip should show the branch name + await expect(popover).not.toBeVisible(); + await expect(chip).toContainText('feat/git-mode-selector'); + + // Screenshot 4: chip showing selected branch + await page.screenshot({ + path: 'client/e2e/test-results/git-mode-4-branch-selected.png', + animations: 'disabled', + }); + + // Send a message and verify the branch is passed to the daemon + await fillComposer(page, 'implement the feature'); + await page.locator('[data-web-shell-composer-submit]').click(); + + await expect.poll(() => sessionCreateBody(daemon) !== undefined).toBe(true); + expect(sessionCreateBody(daemon)?.['branch']).toEqual({ + name: 'feat/git-mode-selector', + }); + expect(sessionCreateBody(daemon)?.['worktree']).toBeUndefined(); +}); + +test('git mode chip worktree mode sends worktree intent', async ({ + page, +}, testInfo) => { + const scenario = createGitWorkspaceScenario(); + const daemon = await installScenario( + page, + scenario, + String(testInfo.project.use.baseURL), + ); + + await page.goto('/'); + + const chip = page.locator('[data-testid="git-mode-chip"]'); + await expect(chip).toBeVisible({ timeout: 10_000 }); + await chip.click(); + + const popover = page.locator('[data-slot="popover-content"]'); + await expect(popover).toBeVisible({ timeout: 5_000 }); + + // Click "Worktree" option + const worktreeOption = popover.getByText('Worktree', { exact: false }); + await worktreeOption.click(); + + // Confirm worktree selection + const confirmBtn = page.locator('[data-testid="git-mode-confirm-worktree"]'); + await expect(confirmBtn).toBeVisible(); + await confirmBtn.click(); + + await expect(popover).not.toBeVisible(); + + // Send a message and verify worktree is passed + await fillComposer(page, 'worktree task'); + await page.locator('[data-web-shell-composer-submit]').click(); + + await expect.poll(() => sessionCreateBody(daemon) !== undefined).toBe(true); + expect(sessionCreateBody(daemon)?.['worktree']).toEqual({}); + expect(sessionCreateBody(daemon)?.['branch']).toBeUndefined(); +}); + +test('git mode chip default current-branch mode sends neither branch nor worktree', async ({ + page, +}, testInfo) => { + const scenario = createGitWorkspaceScenario(); + const daemon = await installScenario( + page, + scenario, + String(testInfo.project.use.baseURL), + ); + + await page.goto('/'); + + const chip = page.locator('[data-testid="git-mode-chip"]'); + await expect(chip).toBeVisible({ timeout: 10_000 }); + // Leave the git mode intent at its default (current branch): do not open + // the popover or select branch/worktree before submitting. + + await fillComposer(page, 'plain task on current branch'); + await page.locator('[data-web-shell-composer-submit]').click(); + + await expect.poll(() => sessionCreateBody(daemon) !== undefined).toBe(true); + expect(sessionCreateBody(daemon)?.['branch']).toBeUndefined(); + expect(sessionCreateBody(daemon)?.['worktree']).toBeUndefined(); +}); + +test('git mode chip clear button resets to current branch', async ({ + page, +}, testInfo) => { + const scenario = createGitWorkspaceScenario(); + const daemon = await installScenario( + page, + scenario, + String(testInfo.project.use.baseURL), + ); + + await page.goto('/'); + + const chip = page.locator('[data-testid="git-mode-chip"]'); + await expect(chip).toBeVisible({ timeout: 10_000 }); + await chip.click(); + + const popover = page.locator('[data-slot="popover-content"]'); + await expect(popover).toBeVisible({ timeout: 5_000 }); + + // Select branch mode + await popover.getByText('New branch', { exact: false }).click(); + const branchInput = page.locator('[data-testid="git-mode-branch-input"]'); + await branchInput.fill('feat/temp'); + await page.locator('[data-testid="git-mode-confirm-branch"]').click(); + await expect(popover).not.toBeVisible(); + + // Chip should show the branch and have a clear button + await expect(chip).toContainText('feat/temp'); + const clearBtn = page.locator('[data-testid="git-mode-clear"]'); + await expect(clearBtn).toBeVisible(); + + // Click clear to reset + await clearBtn.click(); + await expect(chip).toContainText('main'); + await expect(clearBtn).not.toBeVisible(); + + // Submit a message and verify neither branch nor worktree is sent + await fillComposer(page, 'task after clear'); + await page.locator('[data-web-shell-composer-submit]').click(); + + await expect.poll(() => sessionCreateBody(daemon) !== undefined).toBe(true); + expect(sessionCreateBody(daemon)?.['branch']).toBeUndefined(); + expect(sessionCreateBody(daemon)?.['worktree']).toBeUndefined(); +}); + +test('git mode chip is hidden when workspace is not a git repo', async ({ + page, +}, testInfo) => { + const scenario = createGitWorkspaceScenario({ gitStatus: undefined }); + const daemon = await installScenario( + page, + scenario, + String(testInfo.project.use.baseURL), + ); + + await page.goto('/'); + + // Wait for git status request to complete + await expect + .poll(() => + daemon.requests.some((r) => /^\/workspaces\/.+\/git/.test(r.path)), + ) + .toBe(true); + + // Git mode chip should not be visible (falls back to regular branch indicator) + await expect(page.locator('[data-testid="git-mode-chip"]')).toHaveCount(0); +}); diff --git a/packages/web-shell/client/e2e/web-shell.worktree-toggle.spec.ts b/packages/web-shell/client/e2e/web-shell.worktree-toggle.spec.ts deleted file mode 100644 index 30705920140..00000000000 --- a/packages/web-shell/client/e2e/web-shell.worktree-toggle.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; -import { - createWebShellDaemonScenario, - installMockDaemon, - type MockDaemonController, - type WebShellDaemonScenario, -} from './utils/mockDaemon'; - -const WORKSPACE_CWD = '/tmp/qwen-web-shell-e2e'; - -function createGitWorkspaceScenario( - overrides: Parameters[0] = {}, -): WebShellDaemonScenario { - return createWebShellDaemonScenario({ - capabilities: { - workspaces: [ - { id: 'primary', cwd: WORKSPACE_CWD, primary: true, trusted: true }, - ], - }, - gitStatus: { v: 2, workspaceCwd: WORKSPACE_CWD, branch: 'main' }, - ...overrides, - }); -} - -async function installScenario( - page: Page, - scenario: WebShellDaemonScenario, - baseURL: string, -): Promise { - return installMockDaemon(page, scenario, { baseURL }); -} - -async function fillComposer(page: Page, text: string): Promise { - const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); - await editor.click(); - await page.keyboard.press( - process.platform === 'darwin' ? 'Meta+A' : 'Control+A', - ); - await page.keyboard.type(text); -} - -function sessionCreateBody( - daemon: MockDaemonController, -): Record | undefined { - const record = daemon.requests.find( - (r) => r.method === 'POST' && r.path === '/session', - ); - return record?.body as Record | undefined; -} - -test('enabling the worktree toggle sends worktree intent on session creation', async ({ - page, -}, testInfo) => { - const scenario = createGitWorkspaceScenario(); - const daemon = await installScenario( - page, - scenario, - String(testInfo.project.use.baseURL), - ); - - await page.goto('/'); - const toggle = page.locator('[data-testid="worktree-welcome-toggle"]'); - await expect(toggle).toBeVisible(); - await toggle.click(); - - await expect( - page.locator('[data-testid="worktree-welcome-cancel"]'), - ).toBeVisible(); - await expect(toggle).toHaveCount(0); - - await fillComposer(page, 'ping from worktree toggle'); - await page.locator('[data-web-shell-composer-submit]').click(); - - await expect.poll(() => sessionCreateBody(daemon) !== undefined).toBe(true); - expect(sessionCreateBody(daemon)?.['worktree']).toEqual({}); -}); - -test('cancelling the toggle omits worktree on session creation', async ({ - page, -}, testInfo) => { - const scenario = createGitWorkspaceScenario(); - const daemon = await installScenario( - page, - scenario, - String(testInfo.project.use.baseURL), - ); - - await page.goto('/'); - const toggle = page.locator('[data-testid="worktree-welcome-toggle"]'); - await expect(toggle).toBeVisible(); - await toggle.click(); - await page.locator('[data-testid="worktree-welcome-cancel"]').click(); - await expect(toggle).toBeVisible(); - - await fillComposer(page, 'ping after cancel'); - await page.locator('[data-web-shell-composer-submit]').click(); - - await expect.poll(() => sessionCreateBody(daemon) !== undefined).toBe(true); - expect(sessionCreateBody(daemon)?.['worktree']).toBeUndefined(); -}); - -test('toggle is hidden when the workspace is not a git repository', async ({ - page, -}, testInfo) => { - const scenario = createGitWorkspaceScenario({ gitStatus: undefined }); - const daemon = await installScenario( - page, - scenario, - String(testInfo.project.use.baseURL), - ); - - await page.goto('/'); - // Wait until the git status round-trip settles before asserting absence. - await expect - .poll(() => - daemon.requests.some((r) => /^\/workspaces\/.+\/git/.test(r.path)), - ) - .toBe(true); - await expect( - page.locator('[data-testid="worktree-welcome-toggle"]'), - ).toHaveCount(0); -}); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 78f95604d0f..8476d531d19 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -47,10 +47,23 @@ const EN: Messages = { 'gitDiff.hidden': (v) => `${v?.count ?? 0} more file(s) not shown`, 'gitDiff.expand': (v) => `Show changes for ${v?.path ?? 'file'}`, 'gitDiff.collapse': (v) => `Hide changes for ${v?.path ?? 'file'}`, - 'worktree.welcomeTitle': 'Worktree isolated session', - 'worktree.toggleHint': 'Isolated copy · main branch untouched', - 'worktree.welcomeDesc': 'Changes happen in an isolated copy', - 'worktree.cancel': 'Cancel worktree isolation', + 'gitMode.title': 'Git mode', + 'gitMode.current': 'Current branch', + 'gitMode.currentDesc': (v) => `Develop directly on ${v?.branch ?? 'main'}`, + 'gitMode.branch': 'New branch', + 'gitMode.branchDesc': (v) => + `Create a new branch from ${v?.branch ?? 'main'}`, + 'gitMode.branchPlaceholder': 'feat/my-feature', + 'gitMode.branchLabel': 'Branch name', + 'gitMode.branchConflictWarning': + 'Only one branch session per workspace at a time', + 'gitMode.branchHint': 'Switches the working directory to a new branch', + 'gitMode.branchInvalidName': 'Invalid branch name', + 'gitMode.worktree': 'Worktree', + 'gitMode.worktreeDesc': 'Isolated copy · can run in parallel', + 'gitMode.confirmBranch': 'Create branch', + 'gitMode.confirmWorktree': 'Create worktree', + 'gitMode.resetToCurrent': 'Reset to current branch', 'gitLog.title': 'History', 'gitLog.subtitle': (v) => `${v?.count ?? 0} commits`, 'gitLog.loading': 'Loading history…', @@ -2294,10 +2307,21 @@ const ZH: Messages = { 'gitDiff.hidden': (v) => `还有 ${v?.count ?? 0} 个文件未显示`, 'gitDiff.expand': (v) => `显示 ${v?.path ?? '文件'} 的变更`, 'gitDiff.collapse': (v) => `隐藏 ${v?.path ?? '文件'} 的变更`, - 'worktree.welcomeTitle': 'Worktree 隔离会话', - 'worktree.toggleHint': '独立副本 · 不影响主分支', - 'worktree.welcomeDesc': '变更在独立副本中进行', - 'worktree.cancel': '取消 Worktree 隔离', + 'gitMode.title': 'Git 模式', + 'gitMode.current': '当前分支', + 'gitMode.currentDesc': (v) => `直接在 ${v?.branch ?? 'main'} 上开发`, + 'gitMode.branch': '新建分支', + 'gitMode.branchDesc': (v) => `从 ${v?.branch ?? 'main'} 创建新分支`, + 'gitMode.branchPlaceholder': 'feat/my-feature', + 'gitMode.branchLabel': '分支名', + 'gitMode.branchConflictWarning': '同一 workspace 同时只能有一个分支会话', + 'gitMode.branchHint': '在工作目录中切换到新分支', + 'gitMode.branchInvalidName': '分支名不合法', + 'gitMode.worktree': 'Worktree 隔离', + 'gitMode.worktreeDesc': '独立副本 · 可并行', + 'gitMode.confirmBranch': '创建分支', + 'gitMode.confirmWorktree': '创建 Worktree', + 'gitMode.resetToCurrent': '恢复当前分支', 'gitLog.title': '提交历史', 'gitLog.subtitle': (v) => `${v?.count ?? 0} 条提交`, 'gitLog.loading': '加载历史中…', diff --git a/packages/web-shell/client/main.tsx b/packages/web-shell/client/main.tsx index d047f723c2a..2273e3a8525 100644 --- a/packages/web-shell/client/main.tsx +++ b/packages/web-shell/client/main.tsx @@ -172,7 +172,6 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) { onLanguageChange: handleLanguageChange, onSessionIdChange: handleSessionIdChange, sidebar: true, - showWorktreeToggle: true, compactThinking: true, markdownTableMode: 'advanced', }} diff --git a/packages/web-shell/client/utils/sessionPreparation.test.ts b/packages/web-shell/client/utils/sessionPreparation.test.ts index 2e764bebcaf..4d6af9bbeec 100644 --- a/packages/web-shell/client/utils/sessionPreparation.test.ts +++ b/packages/web-shell/client/utils/sessionPreparation.test.ts @@ -257,6 +257,28 @@ describe('createAndAttachSessionForPrompt', () => { }); }); + it('forwards branch to createSession and returns the created branch', async () => { + const actions = createActions({ + createSession: vi.fn(async () => ({ + ...sessionResult, + branch: { name: 'feat/x', baseBranch: 'main' }, + })), + }); + + await expect( + prepareSession({ + sessionActions: actions, + branch: { name: 'feat/x' }, + }), + ).resolves.toEqual({ branch: { name: 'feat/x', baseBranch: 'main' } }); + + expect(actions.createSession).toHaveBeenCalledWith({ + workspaceCwd: undefined, + sourceType: 'default', + branch: { name: 'feat/x' }, + }); + }); + it('waits for onSessionCreated before attaching the session', async () => { const order: string[] = []; const callbackFinished = createDeferred(); diff --git a/packages/web-shell/client/utils/sessionPreparation.ts b/packages/web-shell/client/utils/sessionPreparation.ts index a638d6e81a7..5f80a6831e7 100644 --- a/packages/web-shell/client/utils/sessionPreparation.ts +++ b/packages/web-shell/client/utils/sessionPreparation.ts @@ -12,9 +12,11 @@ type PromptSessionActions = { approvalMode?: DaemonApprovalMode; sourceType?: string; worktree?: { slug?: string }; + branch?: { name: string }; }) => Promise<{ sessionId: string; worktree?: { slug: string; path: string; branch: string }; + branch?: { name: string; baseBranch: string }; }>; attachSession: () => Promise; clearSession: () => Promise; @@ -32,6 +34,7 @@ export async function createAndAttachSessionForPrompt({ modeId, workspaceCwd, worktree, + branch, onSessionCreated, onSessionAllocated, getCurrentSessionId, @@ -42,11 +45,15 @@ export async function createAndAttachSessionForPrompt({ modeId?: string; workspaceCwd?: string; worktree?: { slug?: string }; + branch?: { name: string }; onSessionCreated?: (sessionId: string) => Promise | void; onSessionAllocated?: (sessionId: string) => void; getCurrentSessionId: () => string | undefined; warn?: (message?: unknown, ...optionalParams: unknown[]) => void; -}): Promise<{ worktree?: { slug: string; path: string; branch: string } }> { +}): Promise<{ + worktree?: { slug: string; path: string; branch: string }; + branch?: { name: string; baseBranch: string }; +}> { // Seed the approval mode in the create request itself so the daemon applies // it atomically at spawn (`POST /session` → `spawnOrAttach({ approvalMode })`), // saving a follow-up round-trip. Approval mode is fail-closed at spawn: if the @@ -55,13 +62,17 @@ export async function createAndAttachSessionForPrompt({ // The model, by contrast, stays a best-effort follow-up below. const approvalMode = modeId && isDaemonApprovalMode(modeId) ? modeId : undefined; - const { sessionId, worktree: worktreeInfo } = - await sessionActions.createSession({ - workspaceCwd, - sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, - ...(approvalMode ? { approvalMode } : {}), - ...(worktree ? { worktree } : {}), - }); + const { + sessionId, + worktree: worktreeInfo, + branch: branchInfo, + } = await sessionActions.createSession({ + workspaceCwd, + sourceType: WEB_SHELL_SESSION_SOURCE_TYPE, + ...(approvalMode ? { approvalMode } : {}), + ...(worktree ? { worktree } : {}), + ...(branch ? { branch } : {}), + }); onSessionAllocated?.(sessionId); let preparationStep = 'prepare new session'; try { @@ -132,5 +143,8 @@ export async function createAndAttachSessionForPrompt({ warn('[WebShell] failed to set model for new session:', error); }); } - return worktreeInfo ? { worktree: worktreeInfo } : {}; + return { + ...(worktreeInfo ? { worktree: worktreeInfo } : {}), + ...(branchInfo ? { branch: branchInfo } : {}), + }; } diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 65c7649c69e..bbfb99d4f5d 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2281,7 +2281,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { workspaceCwd?: string, overrides?: Pick< CreateSessionRequest, - 'approvalMode' | 'sourceType' | 'worktree' + 'approvalMode' | 'sourceType' | 'worktree' | 'branch' >, ) => { const client = @@ -2306,6 +2306,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ...(overrides?.worktree !== undefined ? { worktree: overrides.worktree } : {}), + ...(overrides?.branch !== undefined + ? { branch: overrides.branch } + : {}), }; const requestClientId = clientId ? clientIdRef.current diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index b72ec0549ef..6a609428d4c 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -63,7 +63,7 @@ export interface CreateDaemonSessionActionsArgs { workspaceCwd?: string, overrides?: Pick< CreateSessionRequest, - 'approvalMode' | 'sourceType' | 'worktree' + 'approvalMode' | 'sourceType' | 'worktree' | 'branch' >, ) => Promise; getConnection: () => DaemonConnectionState; @@ -657,6 +657,7 @@ export function createDaemonSessionActions({ approvalMode?: DaemonApprovalMode; sourceType?: string; worktree?: { slug?: string }; + branch?: { name: string }; }) { try { manualSessionClearRef.current = false; @@ -676,6 +677,7 @@ export function createDaemonSessionActions({ ...(options?.worktree !== undefined ? { worktree: options.worktree } : {}), + ...(options?.branch !== undefined ? { branch: options.branch } : {}), }; const session = sessionRef.current; const activeSession = diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 4570fb8b99b..31405e4ed39 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -364,6 +364,8 @@ export interface DaemonSessionActions { workspaceCwd?: string; approvalMode?: DaemonApprovalMode; sourceType?: string; + worktree?: { slug?: string }; + branch?: { name: string }; }): Promise; attachSession(): Promise; clearSession(): Promise;