mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
Merge pull request #625 from Ark0N/feat/git-worktrees
feat(worktrees): create, open and remove git worktrees of a case Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5483811753
26 files changed
+3659
-71
No files matched your search
+30
-4
@@ -1446,6 +1446,7 @@ class CodemanApp {
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
this.closeWorktreeModal?.();
|
||||
this.closeSessionManager();
|
||||
this.closeCommandPalette?.();
|
||||
this.closeShortcutOverlay?.();
|
||||
@@ -10326,9 +10327,10 @@ class CodemanApp {
|
||||
delete this.respawnActionLogs[sessionId];
|
||||
}
|
||||
|
||||
/** Resolves true once the session is gone from the server, false when it is still there. */
|
||||
async closeSession(sessionId, killMux = true) {
|
||||
// Already on its way out (a repeated click, the mux panel racing the tab).
|
||||
if (this._closingSessions.has(sessionId)) return;
|
||||
if (this._closingSessions.has(sessionId)) return false;
|
||||
// The tab goes FIRST and the server is asked after. The kill takes the server
|
||||
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
|
||||
// there that long after "Kill" read as a click that did nothing. A refused
|
||||
@@ -10406,7 +10408,7 @@ class CodemanApp {
|
||||
} else {
|
||||
this.showToast('Tab hidden, tmux still running', 'info');
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Still on the server (or the server is unreachable, in which case the resync
|
||||
@@ -10421,6 +10423,7 @@ class CodemanApp {
|
||||
this.renderSessionTabs();
|
||||
}
|
||||
this.showToast('Failed to close session', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Request confirmation before closing a session
|
||||
@@ -10435,6 +10438,20 @@ class CodemanApp {
|
||||
const sessionNameEl = document.getElementById('closeConfirmSessionName');
|
||||
sessionNameEl.textContent = name;
|
||||
|
||||
// A session in a Codeman-managed git worktree also offers to remove the worktree
|
||||
// (git worktree remove, never forced) once the session is gone. Read now, before the
|
||||
// session leaves this.sessions.
|
||||
const worktree = this._managedWorktreeOfSession?.(session) || null;
|
||||
this.pendingCloseWorktree = worktree;
|
||||
const wtOption = document.getElementById('closeConfirmWorktree');
|
||||
if (wtOption) {
|
||||
wtOption.hidden = !worktree;
|
||||
const wtName = document.getElementById('closeConfirmWorktreeName');
|
||||
if (wtName) wtName.textContent = worktree ? worktree.name : '';
|
||||
const wtBranch = document.getElementById('closeConfirmWorktreeBranch');
|
||||
if (wtBranch) wtBranch.checked = false;
|
||||
}
|
||||
|
||||
// Update kill button text based on session mode
|
||||
const killTitle = document.getElementById('closeConfirmKillTitle');
|
||||
if (killTitle) {
|
||||
@@ -10462,15 +10479,24 @@ class CodemanApp {
|
||||
|
||||
cancelCloseSession() {
|
||||
this.pendingCloseSessionId = null;
|
||||
this.pendingCloseWorktree = null;
|
||||
document.getElementById('closeConfirmModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async confirmCloseSession(killMux = true) {
|
||||
/**
|
||||
* `removeWorktree`: Close Session's "Kill & remove worktree". The worktree is removed only
|
||||
* after the server confirms the session is gone (a live session inside is refused anyway),
|
||||
* and only ever through `git worktree remove` without --force.
|
||||
*/
|
||||
async confirmCloseSession(killMux = true, { removeWorktree = false } = {}) {
|
||||
const sessionId = this.pendingCloseSessionId;
|
||||
const worktree = removeWorktree ? this.pendingCloseWorktree : null;
|
||||
const deleteBranch = !!document.getElementById('closeConfirmWorktreeBranch')?.checked;
|
||||
this.cancelCloseSession();
|
||||
|
||||
if (sessionId) {
|
||||
await this.closeSession(sessionId, killMux);
|
||||
const gone = await this.closeSession(sessionId, killMux);
|
||||
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,50 @@
|
||||
'New or link a case…': '新建或关联案例…',
|
||||
'Case settings…': '案例设置…',
|
||||
'No cases match': '没有匹配的案例',
|
||||
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
|
||||
// phone case sheet and Close Session's worktree option). Worktree and branch names,
|
||||
// paths and git's own messages are user/git text and pass through untranslated.
|
||||
'Git worktrees…': 'Git 工作树…',
|
||||
'Git worktrees': 'Git 工作树',
|
||||
'Close git worktrees': '关闭 Git 工作树',
|
||||
Worktrees: '工作树',
|
||||
'Worktrees…': '工作树…',
|
||||
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
|
||||
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
|
||||
'Start sessions with': '启动会话所用',
|
||||
'New worktree': '新建工作树',
|
||||
Branch: '分支',
|
||||
'Start from': '起点',
|
||||
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
|
||||
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
|
||||
'Branch and start point': '分支和起点',
|
||||
"A branch, tag or commit. Default: the case's current commit (HEAD).":
|
||||
'分支、标签或提交。默认:案例当前提交(HEAD)。',
|
||||
'Create & start session': '创建并启动会话',
|
||||
'Loading worktrees…': '正在加载工作树…',
|
||||
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
|
||||
'detached HEAD': '游离 HEAD',
|
||||
'main checkout': '主检出',
|
||||
'not created by Codeman': '非 {name} 创建',
|
||||
locked: '已锁定',
|
||||
missing: '已丢失',
|
||||
Open: '打开',
|
||||
'Start a session in this worktree': '在此工作树中启动会话',
|
||||
'Remove…': '移除…',
|
||||
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
|
||||
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
|
||||
'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.':
|
||||
'它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。',
|
||||
'Remove worktree': '移除工作树',
|
||||
'Kill & remove worktree': '终止并移除工作树',
|
||||
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
|
||||
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
|
||||
'Also delete its branch (git branch -d keeps a branch that is not merged)':
|
||||
'同时删除其分支(git branch -d 会保留未合并的分支)',
|
||||
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
|
||||
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
|
||||
'A launch is already in progress': '已有启动正在进行',
|
||||
'Codeman did not answer.': '{name} 没有响应。',
|
||||
'Add Case': '添加案例',
|
||||
'Open sessions': '打开会话',
|
||||
'Recent Sessions': '最近会话',
|
||||
@@ -1254,6 +1298,16 @@
|
||||
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
|
||||
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
|
||||
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
|
||||
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
|
||||
// words pass through untranslated.
|
||||
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
|
||||
[
|
||||
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
|
||||
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
|
||||
],
|
||||
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
|
||||
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
|
||||
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
|
||||
@@ -1693,6 +1693,18 @@
|
||||
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
|
||||
<span class="close-option-desc">Terminate the session completely</span>
|
||||
</button>
|
||||
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
|
||||
app.js): kill it, then `git worktree remove` without --force. -->
|
||||
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
|
||||
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
|
||||
<span class="close-option-title">Kill & remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
|
||||
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
|
||||
</button>
|
||||
<label class="close-option-worktree-branch">
|
||||
<input type="checkbox" id="closeConfirmWorktreeBranch">
|
||||
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer-cancel">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
|
||||
@@ -1700,6 +1712,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
|
||||
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
|
||||
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
|
||||
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
|
||||
<div class="modal-content worktree-modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
|
||||
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint worktree-intro">Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.</p>
|
||||
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
|
||||
<div class="worktree-list" id="worktreeList"></div>
|
||||
<div class="form-row worktree-mode-row">
|
||||
<label for="worktreeMode">Start sessions with</label>
|
||||
<select id="worktreeMode" class="form-select"></select>
|
||||
</div>
|
||||
<fieldset class="worktree-new">
|
||||
<legend>New worktree</legend>
|
||||
<div class="form-row">
|
||||
<label for="worktreeName">Name</label>
|
||||
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
|
||||
<span class="form-hint">New branch worktree-<name> from the case's current commit, unless you pick another below.</span>
|
||||
</div>
|
||||
<details class="worktree-advanced">
|
||||
<summary>Branch and start point</summary>
|
||||
<div class="form-row">
|
||||
<label for="worktreeBranch">Branch</label>
|
||||
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="worktreeBase">Start from</label>
|
||||
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
|
||||
</div>
|
||||
</details>
|
||||
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
|
||||
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create & start session</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Settings Modal -->
|
||||
<div class="modal" id="appSettingsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
|
||||
@@ -3663,6 +3721,11 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create New Case
|
||||
</button>
|
||||
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
|
||||
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
|
||||
<span aria-hidden="true">⑂</span>
|
||||
Worktrees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+499
-53
@@ -128,14 +128,37 @@ const CASE_PICKER_REFRESH_MS = 5000;
|
||||
/**
|
||||
* Action rows at the bottom of the toolbar case picker. They replaced the "+"
|
||||
* and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two
|
||||
* actions, one click away, without two extra controls in the toolbar. The arrow
|
||||
* keys reach them after the last case; Enter or a click runs `run`.
|
||||
* actions, one click away, without two extra controls in the toolbar, and the git
|
||||
* worktrees of the selected case joined them. The arrow keys reach them after the
|
||||
* last case; Enter or a click runs `run`.
|
||||
*/
|
||||
const CASE_PICKER_ACTIONS = [
|
||||
{ id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() },
|
||||
{ id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() },
|
||||
// The selected case's git worktrees (openWorktreeModal below): a row here rather than a
|
||||
// toolbar button, for the same reason the two above replaced theirs.
|
||||
{ id: 'worktrees', icon: '⑂', label: 'Git worktrees…', run: (app) => app.openWorktreeModal() },
|
||||
];
|
||||
|
||||
/** A worktree name the server accepts (src/git-worktrees.ts `worktreeNameError`), checked before the POST. */
|
||||
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
/**
|
||||
* The Codeman-managed worktree a working directory IS: `{ caseName, name, path }` when it is
|
||||
* exactly `<case path>/.claude/worktrees/<name>` of a local case in `cases`, else null. Lexical
|
||||
* on purpose (the browser cannot resolve links): the server re-checks every removal against the
|
||||
* case's own repository, so a stale or wrong match can only be refused, never act elsewhere.
|
||||
*/
|
||||
function managedWorktreeOfDir(workingDir, cases) {
|
||||
if (typeof workingDir !== 'string') return null;
|
||||
const m = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/.exec(workingDir);
|
||||
if (!m || !WORKTREE_NAME_RE.test(m[2]) || m[2].includes('..')) return null;
|
||||
const owner = (cases || []).find(
|
||||
(c) => c && c.path === m[1] && c.location !== 'remote' && c.location !== 'docker'
|
||||
);
|
||||
return owner ? { caseName: owner.name, name: m[2], path: workingDir.replace(/\/$/, '') } : null;
|
||||
}
|
||||
|
||||
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
|
||||
|
||||
@@ -1919,12 +1942,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async runClaude(options = {}) {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
|
||||
// that git worktree of the case and its tab is named after the worktree.
|
||||
const wt = options.worktree || null;
|
||||
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
|
||||
const nameBase = wt ? wt.name : caseName;
|
||||
const tabCount = this._readTabCount(options.count);
|
||||
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
||||
`Starting ${tabCount} Claude session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
|
||||
);
|
||||
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
||||
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
||||
@@ -1933,25 +1960,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Get case path first
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
let caseData = caseLookup?.data ?? {};
|
||||
let caseData;
|
||||
if (wt) {
|
||||
// A worktree is local by construction (the server refuses one for a remote or Docker
|
||||
// case) and its folder came from the worktree list: no case lookup, never a scaffold.
|
||||
caseData = { path: wt.path, location: 'local' };
|
||||
} else {
|
||||
// Get case path first
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
caseData = caseLookup?.data ?? {};
|
||||
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
}
|
||||
|
||||
const workingDir = caseData.path;
|
||||
@@ -2024,8 +2058,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
// Find the highest existing w-number for THIS case (or worktree) to avoid duplicates
|
||||
const startNumber = this._nextCaseSessionStartNumber(nameBase);
|
||||
|
||||
// Get global Ralph tracker setting
|
||||
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
||||
@@ -2033,7 +2067,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Create all sessions in parallel for speed
|
||||
const sessionNames = [];
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
sessionNames.push(`w${startNumber + i}-${caseName}`);
|
||||
sessionNames.push(`w${startNumber + i}-${nameBase}`);
|
||||
}
|
||||
|
||||
// Build env overrides from global + case settings (case overrides global)
|
||||
@@ -2140,40 +2174,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async runShell(options = {}) {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// `options.worktree`: see runClaude.
|
||||
const wt = options.worktree || null;
|
||||
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
|
||||
const nameBase = wt ? wt.name : caseName;
|
||||
// Run Shell reads the toolbar's one instance stepper, like every other run*();
|
||||
// its own second `− 1 +` group (#shellCount) was removed (#428).
|
||||
const shellCount = this._readTabCount(options.count);
|
||||
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||
`Starting ${shellCount} Shell session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`,
|
||||
'1;33'
|
||||
);
|
||||
|
||||
try {
|
||||
// Get the case path
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
let caseData = caseLookup?.data ?? {};
|
||||
let caseData;
|
||||
if (wt) {
|
||||
// Local by construction, folder from the worktree list (see runClaude).
|
||||
caseData = { path: wt.path, location: 'local' };
|
||||
} else {
|
||||
// Get the case path
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
caseData = caseLookup?.data ?? {};
|
||||
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCase = (this.cases || []).find(c => c.name === caseName);
|
||||
const selectedCase = wt ? null : (this.cases || []).find(c => c.name === caseName);
|
||||
const isRemoteCase =
|
||||
caseData.location === 'remote' ||
|
||||
caseData.location === 'docker' ||
|
||||
@@ -2206,13 +2249,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
// Find the highest existing s-number for THIS case (or worktree) to avoid duplicates
|
||||
const startNumber = this._nextCaseSessionStartNumber(nameBase, 's');
|
||||
|
||||
// Create all shell sessions in parallel
|
||||
const sessionNames = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
sessionNames.push(`s${startNumber + i}-${caseName}`);
|
||||
sessionNames.push(`s${startNumber + i}-${nameBase}`);
|
||||
}
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
@@ -2331,16 +2374,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
buildConfig: () => null,
|
||||
});
|
||||
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
|
||||
// session starts through the same path as any other, only in the worktree's folder.
|
||||
const wt = options.worktree || null;
|
||||
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run the CLI on the OTHER side — the local status
|
||||
// probe and the local-only config/env below don't apply (quick-start
|
||||
// rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const _runLoc = wt ? 'local' : (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount(options.count);
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
|
||||
`Starting ${tabCount} ${entry.label} session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
|
||||
);
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
@@ -2369,13 +2415,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// into `claude --settings`); none of these CLIs has an /effort. Each of
|
||||
// the eight bodies this launcher replaced carried that rule as a comment.
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
// Only names the tabs (`w<n>-<name>`): a worktree session is named after the worktree.
|
||||
wt ? wt.name : caseName,
|
||||
tabCount,
|
||||
entry.label,
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode,
|
||||
sessionName,
|
||||
...(wt ? { worktree: wt.name } : {}),
|
||||
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
|
||||
...(isRemote ? {} : {
|
||||
...(entry.buildConfig(globalSettings) || {}),
|
||||
@@ -5007,6 +5055,404 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Remove animation class after it plays
|
||||
setTimeout(() => modal.classList.remove('from-mobile'), 300);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Git worktrees of a case (src/git-worktrees.ts, routes/worktree-routes.ts)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// One dialog per case: its worktrees (Open, Remove), and a form for a new one that starts a
|
||||
// session in it at once. Sessions start through run() with `options.worktree`, so every run
|
||||
// mode keeps its own launch path (POST /api/sessions for claude and shell, quick-start with
|
||||
// `worktree` for the rest). Everything git or the user named (worktree and branch names, paths,
|
||||
// git's own refusals) renders through textContent and carries data-i18n-skip.
|
||||
|
||||
/** Open the worktree dialog for `caseName` (default: the case selected in the toolbar). */
|
||||
openWorktreeModal(caseName) {
|
||||
const modal = document.getElementById('worktreeModal');
|
||||
const name = caseName || document.getElementById('quickStartCase')?.value || '';
|
||||
if (!modal || !name) return;
|
||||
this._worktreeCase = name;
|
||||
this._worktreeData = null;
|
||||
this._worktreeRemoveConfirm = null;
|
||||
this._worktreeRemoveError = null;
|
||||
const caseLabel = document.getElementById('worktreeModalCase');
|
||||
if (caseLabel) caseLabel.textContent = name;
|
||||
for (const id of ['worktreeName', 'worktreeBranch', 'worktreeBase']) {
|
||||
const input = document.getElementById(id);
|
||||
if (input) input.value = '';
|
||||
}
|
||||
this._setWorktreeFormError('');
|
||||
this._fillWorktreeModeSelect();
|
||||
this._renderWorktreeList();
|
||||
modal.classList.add('active');
|
||||
void this._loadWorktrees();
|
||||
// Not on a touch device: focusing the field raises the on-screen keyboard over the list.
|
||||
const touch = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.();
|
||||
if (!touch) setTimeout(() => document.getElementById('worktreeName')?.focus?.(), 50);
|
||||
},
|
||||
|
||||
/** The phone case sheet's Worktrees button: the selected case's worktrees. */
|
||||
showWorktreesFromMobile() {
|
||||
this.closeMobileCasePicker();
|
||||
this.openWorktreeModal();
|
||||
},
|
||||
|
||||
closeWorktreeModal() {
|
||||
const modal = document.getElementById('worktreeModal');
|
||||
// Called on every Escape (app.js): a no-op unless the dialog is open.
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
modal.classList.remove('active');
|
||||
this._worktreeCase = null;
|
||||
this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1;
|
||||
},
|
||||
|
||||
_worktreesUrl(caseName, suffix = '') {
|
||||
return `/api/cases/${encodeURIComponent(caseName)}/worktrees${suffix}`;
|
||||
},
|
||||
|
||||
/** A JSON call that keeps the server's error text, which `_apiJson()` drops. */
|
||||
async _worktreeRequest(path, opts = {}) {
|
||||
const res = await this._api(path, opts);
|
||||
if (!res) return { ok: false, error: 'Codeman did not answer.' };
|
||||
let body = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
/* an empty or non-JSON body: the status says enough */
|
||||
}
|
||||
if (res.ok && body?.success !== false) return { ok: true, data: body?.data ?? body };
|
||||
return { ok: false, status: res.status, error: body?.error || `Request failed (${res.status})` };
|
||||
},
|
||||
|
||||
async _loadWorktrees() {
|
||||
const caseName = this._worktreeCase;
|
||||
if (!caseName) return;
|
||||
const seq = (this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1);
|
||||
this._setWorktreeStatus('Loading worktrees…');
|
||||
const r = await this._worktreeRequest(this._worktreesUrl(caseName));
|
||||
if (seq !== this._worktreeLoadSeq || caseName !== this._worktreeCase) return;
|
||||
this._worktreeData = r.ok ? r.data : null;
|
||||
this._setWorktreeStatus(r.ok ? '' : r.error, !r.ok);
|
||||
// A case that cannot list worktrees (not a repository, a Docker or remote case) cannot get one.
|
||||
const createBtn = document.getElementById('worktreeCreateBtn');
|
||||
if (createBtn) createBtn.disabled = !r.ok;
|
||||
this._renderWorktreeList();
|
||||
},
|
||||
|
||||
_setWorktreeStatus(text, isError = false) {
|
||||
const el = document.getElementById('worktreeStatus');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.hidden = !text;
|
||||
el.classList.toggle('worktree-status-error', !!isError);
|
||||
},
|
||||
|
||||
_setWorktreeFormError(text) {
|
||||
const el = document.getElementById('worktreeFormError');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.hidden = !text;
|
||||
},
|
||||
|
||||
/** Every enabled, installed run mode, the shell last; the Run button's mode preselected. */
|
||||
_fillWorktreeModeSelect() {
|
||||
const select = document.getElementById('worktreeMode');
|
||||
if (!select) return;
|
||||
// Every launchable registry entry (agents and the shell), as data: no id is branched on here.
|
||||
const modes = [];
|
||||
for (const cli of registryCliCatalog()) {
|
||||
if (cli.enabled && cli.available !== false) modes.push({ id: cli.id, label: cli.label });
|
||||
}
|
||||
if (modes.length === 0) {
|
||||
// A cached page from before the registry catalogue: the built-in modes.
|
||||
modes.push({ id: 'claude', label: 'Claude Code' });
|
||||
for (const [id, entry] of Object.entries(RUN_MODE_LAUNCH)) modes.push({ id, label: entry.label });
|
||||
modes.push({ id: 'shell', label: 'Terminal / Shell' });
|
||||
}
|
||||
select.replaceChildren(
|
||||
...modes.map((m) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = m.id;
|
||||
option.textContent = m.label;
|
||||
return option;
|
||||
})
|
||||
);
|
||||
const current = this._runMode || 'claude';
|
||||
select.value = modes.some((m) => m.id === current) ? current : modes[0].id;
|
||||
},
|
||||
|
||||
_selectedWorktreeMode() {
|
||||
return document.getElementById('worktreeMode')?.value || this._runMode || 'claude';
|
||||
},
|
||||
|
||||
_renderWorktreeList() {
|
||||
const list = document.getElementById('worktreeList');
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
const data = this._worktreeData;
|
||||
if (!data) return;
|
||||
for (const wt of data.worktrees) list.appendChild(this._buildWorktreeRow(wt));
|
||||
if (!data.worktrees.some((wt) => wt.managed)) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'worktree-empty';
|
||||
empty.textContent = 'No worktrees yet. Create one below.';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
},
|
||||
|
||||
_buildWorktreeRow(wt) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'worktree-row' + (wt.managed ? ' worktree-row--managed' : '');
|
||||
const head = document.createElement('div');
|
||||
head.className = 'worktree-row-head';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'worktree-row-name';
|
||||
label.setAttribute('data-i18n-skip', '');
|
||||
label.textContent = wt.managed ? `⑂ ${wt.name}` : wt.path.split('/').pop() || wt.path;
|
||||
head.appendChild(label);
|
||||
|
||||
const badge = (text, extra = '', skip = false) => {
|
||||
const b = document.createElement('span');
|
||||
b.className = `worktree-badge${extra ? ` ${extra}` : ''}`;
|
||||
if (skip) b.setAttribute('data-i18n-skip', '');
|
||||
b.textContent = text;
|
||||
head.appendChild(b);
|
||||
};
|
||||
if (wt.branch) badge(wt.branch, 'worktree-badge-branch', true);
|
||||
else if (wt.detached) badge('detached HEAD', 'worktree-badge-branch');
|
||||
if (wt.main) badge('main checkout');
|
||||
else if (!wt.managed) badge('not created by Codeman', 'worktree-badge-dim');
|
||||
if (wt.locked) badge('locked', 'worktree-badge-warn');
|
||||
if (wt.prunable) badge('missing', 'worktree-badge-warn');
|
||||
if (wt.sessionCount > 0) {
|
||||
badge(`${wt.sessionCount} session${wt.sessionCount === 1 ? '' : 's'}`, 'worktree-badge-live');
|
||||
}
|
||||
|
||||
if (wt.managed) {
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'worktree-row-actions';
|
||||
// A worktree whose folder is gone (`prunable`) cannot host a session, but Remove still
|
||||
// clears git's record of it, which is what frees its name for a new worktree.
|
||||
if (!wt.prunable) {
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
open.className = 'wt-btn wt-btn-sm';
|
||||
open.textContent = 'Open';
|
||||
open.title = 'Start a session in this worktree';
|
||||
open.addEventListener('click', () => void this.openWorktreeSession(wt.name));
|
||||
actions.appendChild(open);
|
||||
}
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'wt-btn wt-btn-sm wt-btn-danger';
|
||||
remove.textContent = 'Remove…';
|
||||
remove.addEventListener('click', () => this.confirmWorktreeRemoval(wt.name));
|
||||
actions.appendChild(remove);
|
||||
head.appendChild(actions);
|
||||
}
|
||||
row.appendChild(head);
|
||||
|
||||
// A managed worktree's folder is always `.claude/worktrees/<name>` of the case: say just that,
|
||||
// so the long repository prefix (already on the main row) is not repeated on every row.
|
||||
const path = document.createElement('div');
|
||||
path.className = 'worktree-row-path';
|
||||
path.setAttribute('data-i18n-skip', '');
|
||||
path.textContent = wt.managed ? `.claude/worktrees/${wt.name}` : this._shortenHomePath(wt.path);
|
||||
path.title = wt.path;
|
||||
row.appendChild(path);
|
||||
|
||||
if (wt.managed && this._worktreeRemoveConfirm === wt.name) row.appendChild(this._buildWorktreeRemoveConfirm(wt));
|
||||
return row;
|
||||
},
|
||||
|
||||
_buildWorktreeRemoveConfirm(wt) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'worktree-remove-confirm';
|
||||
const note = document.createElement('p');
|
||||
note.className = 'worktree-remove-note';
|
||||
note.textContent = wt.prunable
|
||||
? 'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.'
|
||||
: 'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.';
|
||||
box.appendChild(note);
|
||||
|
||||
let checkbox = null;
|
||||
if (wt.branch) {
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'worktree-remove-branch';
|
||||
checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = 'Also delete its branch (git branch -d keeps a branch that is not merged)';
|
||||
lbl.append(checkbox, text);
|
||||
box.appendChild(lbl);
|
||||
}
|
||||
|
||||
if (this._worktreeRemoveError?.name === wt.name) {
|
||||
const err = document.createElement('pre');
|
||||
err.className = 'worktree-remove-error';
|
||||
err.setAttribute('data-i18n-skip', '');
|
||||
err.textContent = this._worktreeRemoveError.text;
|
||||
box.appendChild(err);
|
||||
}
|
||||
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'worktree-remove-buttons';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'wt-btn wt-btn-sm';
|
||||
cancel.textContent = 'Cancel';
|
||||
cancel.addEventListener('click', () => this.cancelWorktreeRemoval());
|
||||
const confirm = document.createElement('button');
|
||||
confirm.type = 'button';
|
||||
confirm.className = 'wt-btn wt-btn-sm wt-btn-danger';
|
||||
confirm.textContent = 'Remove worktree';
|
||||
confirm.addEventListener('click', async () => {
|
||||
confirm.disabled = true;
|
||||
try {
|
||||
await this.removeWorktreeFromModal(wt.name, !!checkbox?.checked);
|
||||
} finally {
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
buttons.append(cancel, confirm);
|
||||
box.appendChild(buttons);
|
||||
return box;
|
||||
},
|
||||
|
||||
confirmWorktreeRemoval(name) {
|
||||
this._worktreeRemoveConfirm = name;
|
||||
this._worktreeRemoveError = null;
|
||||
this._renderWorktreeList();
|
||||
},
|
||||
|
||||
cancelWorktreeRemoval() {
|
||||
this._worktreeRemoveConfirm = null;
|
||||
this._worktreeRemoveError = null;
|
||||
this._renderWorktreeList();
|
||||
},
|
||||
|
||||
async removeWorktreeFromModal(name, deleteBranch) {
|
||||
const caseName = this._worktreeCase;
|
||||
if (!caseName) return;
|
||||
const r = await this.removeCaseWorktree(caseName, name, deleteBranch);
|
||||
if (caseName !== this._worktreeCase) return;
|
||||
if (!r.ok) {
|
||||
this._worktreeRemoveError = { name, text: r.error };
|
||||
this._renderWorktreeList();
|
||||
return;
|
||||
}
|
||||
this._worktreeRemoveConfirm = null;
|
||||
this._worktreeRemoveError = null;
|
||||
await this._loadWorktrees();
|
||||
},
|
||||
|
||||
/** DELETE one worktree (never forced); shared by the dialog and Close Session. Toasts the outcome. */
|
||||
async removeCaseWorktree(caseName, name, deleteBranch) {
|
||||
const query = deleteBranch ? '?deleteBranch=true' : '';
|
||||
const r = await this._worktreeRequest(this._worktreesUrl(caseName, `/${encodeURIComponent(name)}${query}`), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (r.ok) {
|
||||
const removed = r.data?.removed || { name };
|
||||
if (removed.branchError) {
|
||||
this.showToast?.(
|
||||
`Worktree ${removed.name} removed. Branch ${removed.branch} kept: ${removed.branchError}`,
|
||||
'warning'
|
||||
);
|
||||
} else if (removed.branchDeleted) {
|
||||
this.showToast?.(`Worktree ${removed.name} and branch ${removed.branch} removed`, 'success');
|
||||
} else {
|
||||
this.showToast?.(`Worktree ${removed.name} removed`, 'success');
|
||||
}
|
||||
}
|
||||
return r;
|
||||
},
|
||||
|
||||
async createWorktreeFromModal() {
|
||||
const caseName = this._worktreeCase;
|
||||
if (!caseName) return;
|
||||
const name = (document.getElementById('worktreeName')?.value || '').trim();
|
||||
const branch = (document.getElementById('worktreeBranch')?.value || '').trim();
|
||||
const base = (document.getElementById('worktreeBase')?.value || '').trim();
|
||||
if (!WORKTREE_NAME_RE.test(name) || name.includes('..') || /\.(lock)?$/i.test(name)) {
|
||||
this._setWorktreeFormError(
|
||||
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._setWorktreeFormError('');
|
||||
const mode = this._selectedWorktreeMode();
|
||||
const btn = document.getElementById('worktreeCreateBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await this._worktreeRequest(this._worktreesUrl(caseName), {
|
||||
method: 'POST',
|
||||
body: { name, ...(branch ? { branch } : {}), ...(base ? { base } : {}) },
|
||||
});
|
||||
if (caseName !== this._worktreeCase) return;
|
||||
if (!r.ok) {
|
||||
this._setWorktreeFormError(r.error);
|
||||
return;
|
||||
}
|
||||
const created = r.data.worktree;
|
||||
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
|
||||
this.closeWorktreeModal();
|
||||
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
async openWorktreeSession(name) {
|
||||
const caseName = this._worktreeCase;
|
||||
const wt = this._worktreeData?.worktrees?.find((w) => w.managed && w.name === name);
|
||||
if (!caseName || !wt) return;
|
||||
const mode = this._selectedWorktreeMode();
|
||||
this.closeWorktreeModal();
|
||||
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
|
||||
},
|
||||
|
||||
/**
|
||||
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
|
||||
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
|
||||
* entries use, so the Run button's chosen mode is untouched afterwards.
|
||||
*/
|
||||
async _launchInWorktree(worktree, mode) {
|
||||
if (this._runInFlight) {
|
||||
this.showToast?.('A launch is already in progress', 'info');
|
||||
return;
|
||||
}
|
||||
const previous = this._runMode;
|
||||
this._runMode = mode || previous;
|
||||
try {
|
||||
await this.run({ count: 1, worktree });
|
||||
} finally {
|
||||
this._runMode = previous;
|
||||
}
|
||||
},
|
||||
|
||||
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
|
||||
_managedWorktreeOfSession(session) {
|
||||
if (!session || session.remote || session.docker) return null;
|
||||
return managedWorktreeOfDir(session.workingDir, this.cases);
|
||||
},
|
||||
|
||||
/**
|
||||
* Close Session's "Kill & remove worktree", once the session is gone. git's refusal (a dirty
|
||||
* tree) is the answer: the worktree stays, and the toast says why and opens the dialog.
|
||||
*/
|
||||
async _removeWorktreeAfterClose(wt, deleteBranch) {
|
||||
const r = await this.removeCaseWorktree(wt.caseName, wt.name, deleteBranch);
|
||||
if (r.ok) {
|
||||
if (this._worktreeCase === wt.caseName) void this._loadWorktrees();
|
||||
return;
|
||||
}
|
||||
this.showToast?.(`Worktree ${wt.name} was kept: ${r.error}`, 'error', {
|
||||
duration: 15000,
|
||||
action: { label: 'Worktrees…', onClick: () => this.openWorktreeModal(wt.caseName) },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
|
||||
@@ -7793,6 +7793,9 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.mobile-case-picker-footer {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
/* Create New Case and Worktrees share the row, half each. */
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-case-create-mobile {
|
||||
@@ -8148,6 +8151,249 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Close Session: "Kill & remove worktree" (a session in a Codeman-managed git
|
||||
worktree only). The wrapper re-asserts [hidden]: it lays out as a column. */
|
||||
.close-option-worktree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.close-option-worktree[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.close-option-worktree-branch {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
padding: 0 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-option-worktree-branch input {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Git worktrees dialog (session-ui.js openWorktreeModal). Its own small button
|
||||
set: the skin block greys `.btn-toolbar.btn-primary` on non-OG skins, and this
|
||||
dialog needs a primary action that reads as one on every skin. */
|
||||
.modal-content.worktree-modal {
|
||||
max-width: 640px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.worktree-modal > .modal-body {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.worktree-modal-case {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.worktree-intro {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.worktree-status {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.6rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worktree-status[hidden],
|
||||
.worktree-form-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.worktree-status-error,
|
||||
.worktree-form-error {
|
||||
color: var(--red);
|
||||
}
|
||||
.worktree-form-error {
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.worktree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.worktree-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--control-bg);
|
||||
}
|
||||
.worktree-row--managed {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.worktree-row-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.45rem;
|
||||
}
|
||||
.worktree-row-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worktree-badge {
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.5;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.worktree-badge-branch {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.35);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worktree-badge-dim {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.worktree-badge-warn {
|
||||
color: var(--yellow);
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
.worktree-badge-live {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.worktree-row-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.worktree-row-path {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worktree-empty {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0.1rem;
|
||||
}
|
||||
.worktree-remove-confirm {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.worktree-remove-note {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.worktree-remove-branch {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.worktree-remove-error {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--red);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
background: rgba(224, 99, 99, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
.worktree-remove-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.worktree-mode-row {
|
||||
max-width: 320px;
|
||||
}
|
||||
.worktree-new {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem 0.4rem;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.worktree-new > legend {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
padding: 0 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.worktree-advanced {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.worktree-advanced > summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.wt-btn {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: var(--btn-radius);
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-smooth), border-color var(--transition-smooth);
|
||||
}
|
||||
.wt-btn:hover:not(:disabled) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
}
|
||||
.wt-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.wt-btn-sm {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
.wt-btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wt-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
.wt-btn-danger {
|
||||
color: var(--red);
|
||||
border-color: rgba(224, 99, 99, 0.45);
|
||||
}
|
||||
.wt-btn-danger:hover:not(:disabled) {
|
||||
background: rgba(224, 99, 99, 0.12);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.modal-session-name {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -17,6 +17,7 @@ export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerWorktreeRoutes } from './worktree-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
export { registerRalphRoutes } from './ralph-routes.js';
|
||||
|
||||
@@ -182,6 +182,7 @@ import {
|
||||
readDockerHosts,
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
@@ -3733,6 +3734,7 @@ export function registerSessionRoutes(
|
||||
agentOrigin,
|
||||
customModel,
|
||||
tabGroupId,
|
||||
worktree,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Resolved ONCE here: the same value labels a case directory this request creates
|
||||
@@ -3766,6 +3768,16 @@ export function registerSessionRoutes(
|
||||
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
// Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a
|
||||
// container is touched, since neither branch below could honour it.
|
||||
if (worktree && (remoteCase || dockerCase)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
remoteCase
|
||||
? 'worktree is not available for remote (SSH) cases: the repository is on another machine'
|
||||
: 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write'
|
||||
);
|
||||
}
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
@@ -4007,6 +4019,27 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// Where the session runs: the case folder, or one of its git worktrees. A worktree is only
|
||||
// ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not
|
||||
// exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed
|
||||
// one, every path component a real directory (src/git-worktrees.ts).
|
||||
let workspacePath = resolvedCasePath;
|
||||
if (worktree) {
|
||||
if (localCaseState === 'absent') {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`);
|
||||
}
|
||||
if (localCaseState !== 'directory') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder');
|
||||
}
|
||||
const caseRepo = await resolveCaseRepository(resolvedCasePath, {
|
||||
dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean),
|
||||
});
|
||||
if (!caseRepo.ok) return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message);
|
||||
const managed = await resolveManagedWorktree(caseRepo.repo, worktree);
|
||||
if (!managed.ok) return createErrorResponse(ApiErrorCode[managed.code], managed.message);
|
||||
workspacePath = managed.path;
|
||||
}
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
||||
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
||||
if (localCaseState === 'absent') {
|
||||
@@ -4065,9 +4098,9 @@ export function registerSessionRoutes(
|
||||
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
||||
// doesn't exist on the local filesystem.
|
||||
if (getCli(mode)?.capabilities.hooks === 'always') {
|
||||
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
|
||||
await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled());
|
||||
} else {
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
await refreshStaleCodemanHooks(workspacePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4079,7 +4112,7 @@ export function registerSessionRoutes(
|
||||
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
|
||||
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
|
||||
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
|
||||
await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
|
||||
await injectAgentSkill(workspacePath, quickStartSkillDir);
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
@@ -4112,14 +4145,14 @@ export function registerSessionRoutes(
|
||||
// docker — the docker workspace is a real host dir, so the settings file crosses
|
||||
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
||||
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
|
||||
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
||||
await updateCaseModel(workspacePath, modelOverride || null);
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
// see POST /api/sessions for full rationale).
|
||||
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
|
||||
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
|
||||
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
|
||||
await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides));
|
||||
}
|
||||
|
||||
// Create a new session with the case as working directory
|
||||
@@ -4175,7 +4208,7 @@ export function registerSessionRoutes(
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
||||
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
|
||||
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig);
|
||||
|
||||
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
|
||||
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
|
||||
@@ -4281,7 +4314,7 @@ export function registerSessionRoutes(
|
||||
customModel.modelId,
|
||||
qsCustomModelSessionId,
|
||||
cmContextLength,
|
||||
resolvedCasePath
|
||||
workspacePath
|
||||
);
|
||||
if (!cmApplied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
@@ -4322,7 +4355,7 @@ export function registerSessionRoutes(
|
||||
|
||||
const qsSessionOptions: ConstructorParameters<typeof Session>[0] = {
|
||||
id: qsCustomModelSessionId,
|
||||
workingDir: resolvedCasePath,
|
||||
workingDir: workspacePath,
|
||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
@@ -4380,7 +4413,7 @@ export function registerSessionRoutes(
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
// so the initial state already has the phrase configured (only if globally enabled)
|
||||
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, resolvedCasePath, ctx);
|
||||
autoConfigureRalph(session, workspacePath, ctx);
|
||||
if (!session.ralphTracker.enabled) {
|
||||
session.ralphTracker.enable();
|
||||
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
||||
@@ -4472,6 +4505,7 @@ export function registerSessionRoutes(
|
||||
sessionId: session.id,
|
||||
casePath: resolvedCasePath,
|
||||
caseName,
|
||||
...(worktree ? { worktree, workingDir: workspacePath } : {}),
|
||||
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
|
||||
...(tabGroupId ? { tabLayout } : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @fileoverview Git worktrees of a local case (src/git-worktrees.ts holds the rules):
|
||||
*
|
||||
* GET /api/cases/:name/worktrees the case repository's worktrees, which of them
|
||||
* Codeman manages, and the live sessions in each
|
||||
* POST /api/cases/:name/worktrees create `<case>/.claude/worktrees/<name>` on a
|
||||
* new branch (`{ name, branch?, base? }`)
|
||||
* DELETE /api/cases/:name/worktrees/:worktree `git worktree remove` WITHOUT --force, refused
|
||||
* while a live session runs inside; optional
|
||||
* `?deleteBranch=true` is `git branch -d`
|
||||
*
|
||||
* A session starts in a worktree through the EXISTING create paths, never a new spawn path here:
|
||||
* `POST /api/quick-start` with `worktree: <name>` (validated against this module's rules), or
|
||||
* `POST /api/sessions` with the worktree path as `workingDir`.
|
||||
*
|
||||
* The case is resolved the way quick-start resolves it: an owned remote or Docker case is refused
|
||||
* with a 400 (git must never run on the host over a Docker workspace, and a remote case's folder
|
||||
* is on another machine); a linked case resolves for admins only in multi-user mode; anything else
|
||||
* is the caller's own case space, confined with `isWorkingDirAllowed`. The case folder is probed
|
||||
* BOUNDED (#516): a case on a network mount that stopped answering is a 422, never a 404.
|
||||
*
|
||||
* Statuses are set explicitly from the error code, so the route answers the same with or without
|
||||
* the server's envelope hook.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, httpStatusForErrorCode, type ApiResponse } from '../../types.js';
|
||||
import { readDockerCases } from '../../docker-hosts.js';
|
||||
import { readRemoteCases } from '../../remote-hosts.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { listClis } from '../../config/cli-registry/registry.js';
|
||||
import { agentSkillDir } from '../../hooks-config.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
||||
import {
|
||||
createCaseWorktree,
|
||||
isPathWithin,
|
||||
listCaseWorktrees,
|
||||
managedWorktreesDir,
|
||||
removeCaseWorktree,
|
||||
resolveCaseRepository,
|
||||
respellUnder,
|
||||
worktreeNameError,
|
||||
type CaseRepository,
|
||||
type CaseWorktree,
|
||||
type CreatedWorktree,
|
||||
type RemovedWorktree,
|
||||
type WorktreeFailure,
|
||||
type WorktreeGitRunner,
|
||||
} from '../../git-worktrees.js';
|
||||
import { CreateWorktreeSchema, RemoveWorktreeQuerySchema } from '../schemas.js';
|
||||
import {
|
||||
canAccessOwned,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
resolveCasesDir,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/** Host paths of every Docker case workspace: git never runs for a case at or inside one. */
|
||||
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||
|
||||
/**
|
||||
* Every workspace skills folder a session of some registry CLI gets Codeman's agent skill in
|
||||
* (`capabilities.agentSkillDir`), read from the registry at call time as data, never by CLI id.
|
||||
*/
|
||||
const registryAgentSkillDirs = (): string[] => {
|
||||
const dirs = new Set<string>();
|
||||
for (const cli of listClis()) {
|
||||
const dir = agentSkillDir(cli.capabilities);
|
||||
if (dir) dirs.add(dir);
|
||||
}
|
||||
return [...dirs];
|
||||
};
|
||||
|
||||
/** A live session in a worktree, as the list reports it to a caller who may see it. */
|
||||
export interface WorktreeSessionRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One row of `GET /api/cases/:name/worktrees`. */
|
||||
export interface CaseWorktreeInfo extends CaseWorktree {
|
||||
/** Live sessions running in this worktree (deepest worktree wins), all owners. */
|
||||
sessionCount: number;
|
||||
/** The ones the caller may see (all of them outside multi-user mode). */
|
||||
sessions: WorktreeSessionRef[];
|
||||
}
|
||||
|
||||
export interface CaseWorktreeList {
|
||||
caseName: string;
|
||||
repoRoot: string;
|
||||
/** `<repo>/.claude/worktrees`, where Codeman creates and removes worktrees. */
|
||||
worktreesDir: string;
|
||||
worktrees: CaseWorktreeInfo[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
type Fail = ApiResponse<never>;
|
||||
|
||||
function failWith(reply: FastifyReply, code: ApiErrorCode, message: string): Fail {
|
||||
reply.code(httpStatusForErrorCode(code));
|
||||
return createErrorResponse(code, message);
|
||||
}
|
||||
|
||||
function failFrom(reply: FastifyReply, failure: WorktreeFailure): Fail {
|
||||
return failWith(reply, ApiErrorCode[failure.code], failure.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The LOCAL folder of case `name` for this caller, or the refusal. Mirrors quick-start's
|
||||
* resolution (remote, then Docker, then linked, then the caller's case space) so a name means
|
||||
* the same case here as it does to the Run button.
|
||||
*/
|
||||
async function resolveLocalCaseFolder(
|
||||
req: FastifyRequest,
|
||||
name: string
|
||||
): Promise<{ ok: true; casePath: string } | { ok: false; code: ApiErrorCode; message: string }> {
|
||||
if (!SAFE_CASE_NAME.test(name)) {
|
||||
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case name' };
|
||||
}
|
||||
const user = getAuthUser(req);
|
||||
const configDir = getDataDir();
|
||||
// Ownership folded into the match, as quick-start does: a foreign same-named remote/Docker
|
||||
// case falls through to the caller's own local case instead of shadowing it.
|
||||
if ((await readRemoteCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
|
||||
return {
|
||||
ok: false,
|
||||
code: ApiErrorCode.INVALID_INPUT,
|
||||
message: 'Worktrees are not available for remote (SSH) cases: the repository is on another machine.',
|
||||
};
|
||||
}
|
||||
if ((await readDockerCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
|
||||
return {
|
||||
ok: false,
|
||||
code: ApiErrorCode.INVALID_INPUT,
|
||||
message:
|
||||
'Worktrees are not available for Docker cases: a container can write to the workspace, and git would run its hooks and filters on this machine.',
|
||||
};
|
||||
}
|
||||
const linkedCases = await readJsonConfig<Record<string, string>>(dataPath('linked-cases.json'), 'linked cases', {});
|
||||
// Linked cases carry no owner: only admins resolve them in multi-user mode.
|
||||
const linked = !isMultiUserMode() || isAdmin(req) ? linkedCases[name] : undefined;
|
||||
const casePath = linked || validatePathWithinBase(name, resolveCasesDir(user));
|
||||
if (!casePath) return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case path' };
|
||||
if (!isWorkingDirAllowed(user, casePath)) {
|
||||
return { ok: false, code: ApiErrorCode.FORBIDDEN, message: 'case path is outside your workspace' };
|
||||
}
|
||||
const kind = await probePathKind(casePath, { pastCap: true });
|
||||
if (kind === 'unknown') {
|
||||
return {
|
||||
ok: false,
|
||||
code: ApiErrorCode.OPERATION_FAILED,
|
||||
message: describeUnknownPath('Case folder', casePath, { pastCap: true }),
|
||||
};
|
||||
}
|
||||
if (kind === 'absent') return { ok: false, code: ApiErrorCode.NOT_FOUND, message: `Case "${name}" not found` };
|
||||
if (kind !== 'directory')
|
||||
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Case path is not a folder' };
|
||||
return { ok: true, casePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Live local sessions, with the folder each runs in as the case's repository spells it
|
||||
* (`respellUnder`: a folder named through the case's own path, which can run through a symlink,
|
||||
* is moved onto the repository's real top level, where git lists the worktrees). Remote sessions
|
||||
* have no local folder.
|
||||
*/
|
||||
function liveLocalSessions(
|
||||
ctx: SessionPort,
|
||||
casePath: string,
|
||||
repoRoot: string
|
||||
): Array<{ id: string; name: string; owner?: string; dir: string }> {
|
||||
const out: Array<{ id: string; name: string; owner?: string; dir: string }> = [];
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.remote || !session.workingDir || !session.workingDir.startsWith('/')) continue;
|
||||
const dir = respellUnder(session.workingDir, casePath, repoRoot);
|
||||
out.push({ id: session.id, name: session.name, owner: session.owner, dir });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerWorktreeRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort,
|
||||
deps: { git?: WorktreeGitRunner; dockerWorkspaces?: () => Promise<string[]> } = {}
|
||||
): void {
|
||||
const dockerWorkspaces = deps.dockerWorkspaces ?? defaultDockerWorkspaces;
|
||||
|
||||
/**
|
||||
* Case folder → its repository, or the refusal already rendered. `casePath` is the folder as
|
||||
* the case resolves it (possibly through a symlink); `repo.repoRoot` is its real path.
|
||||
*/
|
||||
async function caseRepository(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
name: string
|
||||
): Promise<{ ok: true; repo: CaseRepository; casePath: string; dockerRoots: string[] } | { ok: false; fail: Fail }> {
|
||||
const folder = await resolveLocalCaseFolder(req, name);
|
||||
if (!folder.ok) return { ok: false, fail: failWith(reply, folder.code, folder.message) };
|
||||
const dockerRoots = await dockerWorkspaces();
|
||||
const repo = await resolveCaseRepository(folder.casePath, { git: deps.git, dockerWorkspaces: dockerRoots });
|
||||
if (!repo.ok) return { ok: false, fail: failFrom(reply, repo) };
|
||||
return { ok: true, repo: repo.repo, casePath: folder.casePath, dockerRoots };
|
||||
}
|
||||
|
||||
app.get('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<CaseWorktreeList>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const resolved = await caseRepository(req, reply, name);
|
||||
if (!resolved.ok) return resolved.fail;
|
||||
const { repo, casePath } = resolved;
|
||||
const listed = await listCaseWorktrees(repo, { git: deps.git });
|
||||
if (!listed.ok) return failFrom(reply, listed);
|
||||
|
||||
// Each live session belongs to the DEEPEST worktree holding its folder, so a session in a
|
||||
// managed worktree is not also counted against the main checkout that contains it.
|
||||
const user = getAuthUser(req);
|
||||
const rows: CaseWorktreeInfo[] = listed.worktrees.map((wt) => ({ ...wt, sessionCount: 0, sessions: [] }));
|
||||
for (const session of liveLocalSessions(ctx, casePath, repo.repoRoot)) {
|
||||
let best: CaseWorktreeInfo | undefined;
|
||||
for (const row of rows) {
|
||||
if (!isPathWithin(session.dir, row.path)) continue;
|
||||
if (!best || row.path.length > best.path.length) best = row;
|
||||
}
|
||||
if (!best) continue;
|
||||
best.sessionCount += 1;
|
||||
if (canAccessOwned(user, session.owner)) best.sessions.push({ id: session.id, name: session.name });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
caseName: name,
|
||||
repoRoot: repo.repoRoot,
|
||||
worktreesDir: managedWorktreesDir(repo.repoRoot),
|
||||
worktrees: rows,
|
||||
truncated: listed.truncated,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<{ worktree: CreatedWorktree }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const body = parseBody(CreateWorktreeSchema, req.body);
|
||||
const nameError = worktreeNameError(body.name);
|
||||
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
|
||||
const resolved = await caseRepository(req, reply, name);
|
||||
if (!resolved.ok) return resolved.fail;
|
||||
const created = await createCaseWorktree(resolved.repo, body, {
|
||||
git: deps.git,
|
||||
agentSkillDirs: registryAgentSkillDirs(),
|
||||
});
|
||||
if (!created.ok) return failFrom(reply, created);
|
||||
reply.code(201);
|
||||
return { success: true, data: { worktree: created.worktree } };
|
||||
});
|
||||
|
||||
app.delete(
|
||||
'/api/cases/:name/worktrees/:worktree',
|
||||
async (req, reply): Promise<ApiResponse<{ removed: RemovedWorktree }>> => {
|
||||
const { name, worktree } = req.params as { name: string; worktree: string };
|
||||
const query = parseBody(RemoveWorktreeQuerySchema, req.query ?? {});
|
||||
const nameError = worktreeNameError(worktree);
|
||||
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
|
||||
const resolved = await caseRepository(req, reply, name);
|
||||
if (!resolved.ok) return resolved.fail;
|
||||
const removed = await removeCaseWorktree(
|
||||
resolved.repo,
|
||||
{
|
||||
name: worktree,
|
||||
deleteBranch: query.deleteBranch === 'true' || query.deleteBranch === '1',
|
||||
// Any owner's session blocks: removing the folder under a running agent is the damage
|
||||
// this refusal exists to prevent, whoever started it.
|
||||
isInUse: (path) =>
|
||||
liveLocalSessions(ctx, resolved.casePath, resolved.repo.repoRoot).some((s) => isPathWithin(s.dir, path)),
|
||||
},
|
||||
// The worktree folder itself must not be (or hold) a Docker case workspace either.
|
||||
{ git: deps.git, dockerWorkspaces: resolved.dockerRoots }
|
||||
);
|
||||
if (!removed.ok) return failFrom(reply, removed);
|
||||
return { success: true, data: { removed: removed.removed } };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -768,6 +768,34 @@ export const ClonePreflightSchema = z.object({
|
||||
repository: z.string().min(1).max(2048),
|
||||
});
|
||||
|
||||
// ========== Git worktrees of a case (src/git-worktrees.ts) ==========
|
||||
|
||||
/**
|
||||
* A worktree name: one segment of letters, digits, `.`, `_`, `-`, starting with a letter or
|
||||
* digit, at most 64 characters (Claude Code's own `--worktree` limit). The finer rules (no `..`,
|
||||
* no trailing `.lock`) are `worktreeNameError()`, which every route applies after this.
|
||||
*/
|
||||
const worktreeNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Invalid worktree name. Use letters, digits, ".", "_" and "-".');
|
||||
|
||||
/** Schema for POST /api/cases/:name/worktrees: a new worktree on a new branch. */
|
||||
export const CreateWorktreeSchema = z.object({
|
||||
name: worktreeNameSchema,
|
||||
/** New branch name; default `worktree-<name>`, Claude Code's convention. */
|
||||
branch: z.string().min(1).max(200).optional(),
|
||||
/** Start point (branch, tag or commit); default the case's current HEAD. */
|
||||
base: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
/** Query of DELETE /api/cases/:name/worktrees/:worktree. */
|
||||
export const RemoveWorktreeQuerySchema = z.object({
|
||||
/** Also `git branch -d` the worktree's branch (never `-D`). */
|
||||
deleteBranch: z.enum(['true', 'false', '1', '0']).optional(),
|
||||
});
|
||||
|
||||
const RemoteCommandOverridesSchema = z
|
||||
.object({
|
||||
shell: z.string().min(1).max(300).optional(),
|
||||
@@ -1148,6 +1176,13 @@ export const QuickStartSchema = z.object({
|
||||
parentSessionId: parentSessionIdSchema,
|
||||
/** Tab group to place the new session in: see tabGroupIdSchema. */
|
||||
tabGroupId: tabGroupIdSchema,
|
||||
/**
|
||||
* Start the session in this git worktree of the case (`<case>/.claude/worktrees/<worktree>`,
|
||||
* created through POST /api/cases/:name/worktrees) instead of the case folder. Local cases
|
||||
* only: refused (400) for remote and Docker cases. The worktree must be a registered worktree
|
||||
* of the case's repository; it is never created here.
|
||||
*/
|
||||
worktree: worktreeNameSchema.optional(),
|
||||
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
|
||||
* Empty string clears. Applied for local AND docker cases (the docker workspace is
|
||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||
|
||||
@@ -192,6 +192,7 @@ import {
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
registerWorktreeRoutes,
|
||||
registerSessionRoutes,
|
||||
registerRespawnRoutes,
|
||||
registerRalphRoutes,
|
||||
@@ -1154,6 +1155,7 @@ export class WebServer extends EventEmitter {
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
registerWorktreeRoutes(this.app, ctx);
|
||||
// The registry's lifetime is the server's: it drops per-session wake state on every
|
||||
// cleanup path and resolves in-flight wakes on shutdown.
|
||||
this.remoteWake = registerSessionRoutes(this.app, ctx);
|
||||
|
||||
Reference in new issue
Block a user