@agimon-ai/doompi-git 0.0.1-alpha.0 → 0.0.1-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/web/worktreesChannel.cjs +1 -1
- package/dist/adapters/web/worktreesChannel.cjs.map +1 -1
- package/dist/adapters/web/worktreesChannel.d.cts +11 -2
- package/dist/adapters/web/worktreesChannel.d.cts.map +1 -1
- package/dist/adapters/web/worktreesChannel.d.mts +11 -2
- package/dist/adapters/web/worktreesChannel.d.mts.map +1 -1
- package/dist/adapters/web/worktreesChannel.mjs +1 -1
- package/dist/adapters/web/worktreesChannel.mjs.map +1 -1
- package/dist/adapters/worktree/worktreeOperations.cjs +1 -1
- package/dist/adapters/worktree/worktreeOperations.cjs.map +1 -1
- package/dist/adapters/worktree/worktreeOperations.mjs +1 -1
- package/dist/adapters/worktree/worktreeOperations.mjs.map +1 -1
- package/dist/services/support/errors.cjs.map +1 -1
- package/dist/services/support/errors.mjs.map +1 -1
- package/dist/types/webWorktrees.cjs.map +1 -1
- package/dist/types/webWorktrees.mjs.map +1 -1
- package/package.json +5 -5
- package/src/types/webWorktrees.ts +24 -0
- package/src/web/components/WorktreesActivitySection.tsx +3 -1
- package/src/web/components/WorktreesPanel.tsx +22 -6
- package/src/web/index.ts +4 -1
- package/src/web/stores/worktreesActivityStore.ts +76 -30
- package/src/web/stores/worktreesStore.ts +0 -114
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../filesystem/paths.cjs"),
|
|
1
|
+
const e=require("../worktree/gitCli.cjs"),t=require("../hub/hubClient.cjs"),n=require("../filesystem/paths.cjs"),r=require("../worktree/worktreeRegistry.cjs"),i=require("../../services/support/errors.cjs"),a=require("../worktree/worktreeOperations.cjs"),o=require("../../types/webWorktrees.cjs");function s(e){return{worktrees:e.worktrees,...e.pending===void 0?{}:{pending:e.pending},...e.error===void 0?{}:{error:e.error}}}function c(e,t){try{return r.createWorktreeRegistry(n.registryFile(e.cwd,t.homeDir)).list().flatMap(n=>{let r=n.parentSessionId===e.sessionId,i=!r&&!t.isSessionLive(t.registryDir,n.parentSessionId);return!r&&!i?[]:[{id:n.id,branch:n.branch,path:n.path,sessionId:n.status===`orphaned`?null:n.sessionId,orphaned:n.status===`orphaned`,unowned:i}]})}catch{return[]}}function l(e,t){return e.length===t.length&&e.every((e,n)=>{let r=t[n];return r!==void 0&&e.id===r.id&&e.branch===r.branch&&e.sessionId===r.sessionId&&e.orphaned===r.orphaned&&e.unowned===r.unowned})}function u(e){if(typeof e!=`object`||!e)return;let t=e;if(t.action===`create`)return typeof t.branch!=`string`||t.branch.trim()===``?void 0:{action:`create`,branch:t.branch.trim(),...typeof t.baseRef==`string`&&t.baseRef.trim()!==``?{baseRef:t.baseRef.trim()}:{}};if(t.action===`close`)return typeof t.id!=`string`||t.id===``?void 0:{action:`close`,id:t.id,...t.force===!0?{force:!0}:{}}}function d(e){return e instanceof i.DoomGitExpectedError?e.message:`The worktree operation failed.`}function f(r={}){let i=r.intervalMs??4e3,f;return{frameType:o.GIT_WORKTREES_TYPE,start(o){let p=r.operations??a.createWorktreeOperations({git:e.createWorktreeGit()}),m={registryDir:r.registryDir??n.hubRegistryDir(),homeDir:r.homeDir,isSessionLive:r.isSessionLive??t.sessionIsLive},h=new Map,g=new Map,_=new Map,v=new Set,y=(e,t)=>{let n=h.get(e.sessionId),r={worktrees:c(e,m),pending:n?.pending,error:n?.error};(t||n===void 0||!l(n.worktrees,r.worktrees)||n.pending!==r.pending||n.error!==r.error)&&(h.set(e.sessionId,r),o.publish(e.sessionId,s(r)))},b=(e,t,n)=>{let r=h.get(e.sessionId);h.set(e.sessionId,{worktrees:r?.worktrees??c(e,m),pending:t,error:n}),y(e,!0)},x=async(e,t)=>{let n={cwd:e.cwd,sessionId:e.sessionId};if(t.action===`create`){b(e,`creating ${t.branch}\u2026`,void 0),await p.spawn(n,{branch:t.branch,...t.baseRef===void 0?{}:{baseRef:t.baseRef}});return}b(e,`closing ${t.id}\u2026`,void 0),await p.close(n,t.id,t.force??!1)};return f=(e,t)=>{let n=u(t);n!==void 0&&(v.has(e.sessionId)||(v.add(e.sessionId),x(e,n).then(()=>{b(e,void 0,void 0)}).catch(t=>{o.onNotice(`worktree command failed for ${e.sessionId} (${d(t)})`),b(e,void 0,d(t))}).finally(()=>{v.delete(e.sessionId)})))},{payloadFor(e){let t=h.get(e.sessionId);return t===void 0?void 0:s(t)},sessionAdded(e){g.set(e.sessionId,e),y(e,!1);let t=setInterval(()=>{y(e,!1)},i);t.unref?.(),_.set(e.sessionId,t)},sessionRemoved(e){let t=_.get(e);t!==void 0&&clearInterval(t),_.delete(e),h.delete(e),g.delete(e),v.delete(e)},close(){for(let e of _.values())clearInterval(e);_.clear(),h.clear(),g.clear(),v.clear()}}},receive(e,t){f?.(e,t)}}}const p=[f()];exports.createWorktreesChannel=f,exports.webHubChannels=p;
|
|
2
2
|
//# sourceMappingURL=worktreesChannel.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktreesChannel.cjs","names":["createWorktreeRegistry","registryFile","GIT_WORKTREES_TYPE"],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"sourcesContent":["/**\n * The worktrees data channel.\n *\n * DESIGN PATTERNS:\n * - Hub-scoped: it reads this machine's registry files, which a browser cannot.\n * One entry per session, keyed by the repository that session sits in, so two\n * sessions in the same checkout see the same list.\n * - Poll, do not watch. The registry is a single small JSON file written by\n * this package alone, and a watcher on a path that may not exist yet costs\n * more than re-reading a few hundred bytes.\n * - Publish only what the dock renders. Paths and provenance stay in the\n * registry; the page gets a view.\n *\n * AVOID:\n * - Throwing out of a poll. A registry that cannot be read is an empty list,\n * never a broken channel: the dock degrades to \"no worktrees\", which is the\n * truth as far as the page can tell.\n */\nimport type { HubChannelSource, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { GIT_WORKTREES_TYPE, type WorktreeView } from '../../types/webWorktrees.ts';\nimport { registryFile } from '../filesystem/paths.ts';\nimport { createWorktreeRegistry } from '../worktree/worktreeRegistry.ts';\n\n/** How often a session's registry is re-read. Worktrees change by hand, not by the second. */\nconst POLL_MS = 4000;\n\nfunction viewsFor(cwd: string): WorktreeView[] {\n try {\n return createWorktreeRegistry(registryFile(cwd))\n .list()\n .map((record) => ({\n id: record.id,\n branch: record.branch,\n path: record.path,\n sessionId: record.status === 'orphaned' ? null : record.sessionId,\n orphaned: record.status === 'orphaned',\n }));\n } catch {\n return [];\n }\n}\n\nfunction sameList(left: readonly WorktreeView[], right: readonly WorktreeView[]): boolean {\n if (left.length !== right.length) return false;\n return left.every((entry, index) => {\n const other = right[index];\n return (\n other !== undefined &&\n entry.id === other.id &&\n entry.branch === other.branch &&\n entry.sessionId === other.sessionId &&\n entry.orphaned === other.orphaned\n );\n });\n}\n\n/**\n * One poller per managed session, publishing only when the list actually\n * changed so an idle cockpit stays quiet.\n */\nexport function createWorktreesChannel(intervalMs: number = POLL_MS): WebHubChannel {\n return {\n frameType: GIT_WORKTREES_TYPE,\n start(host) {\n const latest = new Map<string, WorktreeView[]>();\n const timers = new Map<string, ReturnType<typeof setInterval>>();\n const source: HubChannelSource = {\n payloadFor(scope) {\n const worktrees = latest.get(scope.sessionId);\n return worktrees === undefined ? undefined : { worktrees };\n },\n sessionAdded(scope) {\n const publish = (): void => {\n const worktrees = viewsFor(scope.cwd);\n const previous = latest.get(scope.sessionId);\n if (previous !== undefined && sameList(previous, worktrees)) return;\n latest.set(scope.sessionId, worktrees);\n host.publish(scope.sessionId, { worktrees });\n };\n publish();\n const timer = setInterval(publish, intervalMs);\n timer.unref?.();\n timers.set(scope.sessionId, timer);\n },\n sessionRemoved(sessionId) {\n const timer = timers.get(sessionId);\n if (timer !== undefined) clearInterval(timer);\n timers.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const timer of timers.values()) clearInterval(timer);\n timers.clear();\n latest.clear();\n },\n };\n return source;\n },\n };\n}\n\n/** The named export the generated hub channel registry imports. */\nexport const webHubChannels: readonly WebHubChannel[] = [createWorktreesChannel()];\n"],"mappings":"mIA0BA,SAAS,EAAS,EAA6B,CAC7C,GAAI,CACF,OAAOA,EAAAA,uBAAuBC,EAAAA,aAAa,CAAG,CAAC,CAAC,CAC7C,KAAK,CAAC,CACN,IAAK,IAAY,CAChB,GAAI,EAAO,GACX,OAAQ,EAAO,OACf,KAAM,EAAO,KACb,UAAW,EAAO,SAAW,WAAa,KAAO,EAAO,UACxD,SAAU,EAAO,SAAW,UAC9B,EAAE,CACN,MAAQ,CACN,MAAO,CAAC,CACV,CACF,CAEA,SAAS,EAAS,EAA+B,EAAyC,CAExF,OADI,EAAK,SAAW,EAAM,QACnB,EAAK,OAAO,EAAO,IAAU,CAClC,IAAM,EAAQ,EAAM,GACpB,OACE,IAAU,IAAA,IACV,EAAM,KAAO,EAAM,IACnB,EAAM,SAAW,EAAM,QACvB,EAAM,YAAc,EAAM,WAC1B,EAAM,WAAa,EAAM,QAE7B,CAAC,CACH,CAMA,SAAgB,EAAuB,EAAqB,IAAwB,CAClF,MAAO,CACL,UAAWC,EAAAA,mBACX,MAAM,EAAM,CACV,IAAM,EAAS,IAAI,IACb,EAAS,IAAI,IA+BnB,MAAO,CA7BL,WAAW,EAAO,CAChB,IAAM,EAAY,EAAO,IAAI,EAAM,SAAS,EAC5C,OAAO,IAAc,IAAA,GAAY,IAAA,GAAY,CAAE,WAAU,CAC3D,EACA,aAAa,EAAO,CAClB,IAAM,MAAsB,CAC1B,IAAM,EAAY,EAAS,EAAM,GAAG,EAC9B,EAAW,EAAO,IAAI,EAAM,SAAS,EACvC,IAAa,IAAA,IAAa,EAAS,EAAU,CAAS,IAC1D,EAAO,IAAI,EAAM,UAAW,CAAS,EACrC,EAAK,QAAQ,EAAM,UAAW,CAAE,WAAU,CAAC,EAC7C,EACA,EAAQ,EACR,IAAM,EAAQ,YAAY,EAAS,CAAU,EAC7C,EAAM,QAAQ,EACd,EAAO,IAAI,EAAM,UAAW,CAAK,CACnC,EACA,eAAe,EAAW,CACxB,IAAM,EAAQ,EAAO,IAAI,CAAS,EAC9B,IAAU,IAAA,IAAW,cAAc,CAAK,EAC5C,EAAO,OAAO,CAAS,EACvB,EAAO,OAAO,CAAS,CACzB,EACA,OAAQ,CACN,IAAK,IAAM,KAAS,EAAO,OAAO,EAAG,cAAc,CAAK,EACxD,EAAO,MAAM,EACb,EAAO,MAAM,CACf,CAEU,CACd,CACF,CACF,CAGA,MAAa,EAA2C,CAAC,EAAuB,CAAC"}
|
|
1
|
+
{"version":3,"file":"worktreesChannel.cjs","names":["createWorktreeRegistry","registryFile","DoomGitExpectedError","GIT_WORKTREES_TYPE","createWorktreeOperations","createWorktreeGit","hubRegistryDir","sessionIsLive"],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"sourcesContent":["/**\n * The worktrees data channel, and the commands the dock sends back through it.\n *\n * DESIGN PATTERNS:\n * - Hub-scoped: it reads this machine's registry files, which a browser cannot.\n * - Scoped to the session that owns the worktree, not to the repository. Two\n * sessions in one checkout are two pieces of work; showing each the other's\n * worktrees offered a close button for something the reader never made.\n * A worktree whose parent session is gone is the exception: it is shown to\n * everyone in the repository, marked unowned, or nobody could ever close it.\n * - Poll, do not watch. The registry is a single small JSON file written by\n * this package alone, and a watcher on a path that may not exist yet costs\n * more than re-reading a few hundred bytes.\n * - Commands run the same `worktreeOperations` the tool runs, so the panel and\n * the agent cannot drift into two ideas of what creating a worktree means.\n * - Publish only what the dock renders. Paths and provenance stay in the\n * registry; the page gets a view.\n *\n * AVOID:\n * - Throwing out of a poll or a command. A registry that cannot be read is an\n * empty list, and a failed command is an error string on the next frame:\n * the dock degrades to what it can tell the reader, never to a dead channel.\n */\nimport type { HubChannelSource, HubSessionScope, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { GIT_WORKTREES_TYPE, type GitWorktreesCommand, type WorktreeView } from '../../types/webWorktrees.ts';\nimport { DoomGitExpectedError } from '../../services/support/errors.ts';\nimport { hubRegistryDir, registryFile } from '../filesystem/paths.ts';\nimport { sessionIsLive } from '../hub/hubClient.ts';\nimport { createWorktreeGit } from '../worktree/gitCli.ts';\nimport { createWorktreeOperations, type WorktreeOperations } from '../worktree/worktreeOperations.ts';\nimport { createWorktreeRegistry } from '../worktree/worktreeRegistry.ts';\n\n/** How often a session's registry is re-read. Worktrees change by hand, not by the second. */\nconst POLL_MS = 4000;\n\ninterface SessionState {\n worktrees: WorktreeView[];\n pending: string | undefined;\n error: string | undefined;\n}\n\nfunction payloadOf(state: SessionState): Record<string, unknown> {\n return {\n worktrees: state.worktrees,\n ...(state.pending === undefined ? {} : { pending: state.pending }),\n ...(state.error === undefined ? {} : { error: state.error }),\n };\n}\n\n/** What the channel needs from its surroundings, injected so tests stay off this machine. */\nexport interface WorktreesChannelOptions {\n intervalMs?: number;\n operations?: WorktreeOperations;\n homeDir?: string;\n registryDir?: string;\n isSessionLive?: (registryDir: string, sessionId: string) => boolean;\n}\n\ninterface ViewDeps {\n registryDir: string;\n homeDir: string | undefined;\n isSessionLive: (registryDir: string, sessionId: string) => boolean;\n}\n\n/**\n * The worktrees one session may see: its own, plus any whose parent session\n * has gone. Liveness is the registry record's presence, the same signal the\n * rail uses to decide a session exists at all.\n */\nfunction viewsFor(scope: HubSessionScope, deps: ViewDeps): WorktreeView[] {\n try {\n return createWorktreeRegistry(registryFile(scope.cwd, deps.homeDir))\n .list()\n .flatMap((record) => {\n const owned = record.parentSessionId === scope.sessionId;\n const unowned = !owned && !deps.isSessionLive(deps.registryDir, record.parentSessionId);\n if (!owned && !unowned) return [];\n return [\n {\n id: record.id,\n branch: record.branch,\n path: record.path,\n sessionId: record.status === 'orphaned' ? null : record.sessionId,\n orphaned: record.status === 'orphaned',\n unowned,\n },\n ];\n });\n } catch {\n return [];\n }\n}\n\nfunction sameList(left: readonly WorktreeView[], right: readonly WorktreeView[]): boolean {\n if (left.length !== right.length) return false;\n return left.every((entry, index) => {\n const other = right[index];\n return (\n other !== undefined &&\n entry.id === other.id &&\n entry.branch === other.branch &&\n entry.sessionId === other.sessionId &&\n entry.orphaned === other.orphaned &&\n entry.unowned === other.unowned\n );\n });\n}\n\n/** A command the page sent, or undefined when the frame was not one. */\nfunction parseCommand(payload: unknown): GitWorktreesCommand | undefined {\n if (typeof payload !== 'object' || payload === null) return undefined;\n const value = payload as Record<string, unknown>;\n if (value.action === 'create') {\n if (typeof value.branch !== 'string' || value.branch.trim() === '') return undefined;\n return {\n action: 'create',\n branch: value.branch.trim(),\n ...(typeof value.baseRef === 'string' && value.baseRef.trim() !== '' ? { baseRef: value.baseRef.trim() } : {}),\n };\n }\n if (value.action === 'close') {\n if (typeof value.id !== 'string' || value.id === '') return undefined;\n return { action: 'close', id: value.id, ...(value.force === true ? { force: true } : {}) };\n }\n return undefined;\n}\n\n/**\n * What the reader is told when a command fails.\n *\n * An expected failure explains itself. Anything else is summarised, never\n * echoed: a git error can carry a remote URL with a token in it.\n */\nfunction failureText(error: unknown): string {\n return error instanceof DoomGitExpectedError ? error.message : 'The worktree operation failed.';\n}\n\n/**\n * One poller per managed session, publishing only when the list actually\n * changed so an idle cockpit stays quiet.\n */\nexport function createWorktreesChannel(options: WorktreesChannelOptions = {}): WebHubChannel {\n const intervalMs = options.intervalMs ?? POLL_MS;\n // Assigned by start, because receive is reachable only while the channel is\n // running and the command needs the state the poller keeps.\n let receiveCommand: ((scope: HubSessionScope, payload: unknown) => void) | undefined;\n\n return {\n frameType: GIT_WORKTREES_TYPE,\n start(host) {\n const worktrees = options.operations ?? createWorktreeOperations({ git: createWorktreeGit() });\n const views: ViewDeps = {\n registryDir: options.registryDir ?? hubRegistryDir(),\n homeDir: options.homeDir,\n isSessionLive: options.isSessionLive ?? sessionIsLive,\n };\n const latest = new Map<string, SessionState>();\n const scopes = new Map<string, HubSessionScope>();\n const timers = new Map<string, ReturnType<typeof setInterval>>();\n const busy = new Set<string>();\n\n /** Republishes a session, skipping a frame that would say nothing new. */\n const publish = (scope: HubSessionScope, force: boolean): void => {\n const previous = latest.get(scope.sessionId);\n const next: SessionState = {\n worktrees: viewsFor(scope, views),\n pending: previous?.pending,\n error: previous?.error,\n };\n if (\n !force &&\n previous !== undefined &&\n sameList(previous.worktrees, next.worktrees) &&\n previous.pending === next.pending &&\n previous.error === next.error\n ) {\n return;\n }\n latest.set(scope.sessionId, next);\n host.publish(scope.sessionId, payloadOf(next));\n };\n\n /** Sets the operation label and clears the last failure, then publishes. */\n const mark = (scope: HubSessionScope, pending: string | undefined, error: string | undefined): void => {\n const previous = latest.get(scope.sessionId);\n latest.set(scope.sessionId, {\n worktrees: previous?.worktrees ?? viewsFor(scope, views),\n pending,\n error,\n });\n publish(scope, true);\n };\n\n const run = async (scope: HubSessionScope, command: GitWorktreesCommand): Promise<void> => {\n const context = { cwd: scope.cwd, sessionId: scope.sessionId };\n if (command.action === 'create') {\n mark(scope, `creating ${command.branch}\\u2026`, undefined);\n await worktrees.spawn(context, {\n branch: command.branch,\n ...(command.baseRef === undefined ? {} : { baseRef: command.baseRef }),\n });\n return;\n }\n mark(scope, `closing ${command.id}\\u2026`, undefined);\n await worktrees.close(context, command.id, command.force ?? false);\n };\n\n const source: HubChannelSource = {\n payloadFor(scope) {\n const state = latest.get(scope.sessionId);\n return state === undefined ? undefined : payloadOf(state);\n },\n sessionAdded(scope) {\n scopes.set(scope.sessionId, scope);\n publish(scope, false);\n const timer = setInterval(() => {\n publish(scope, false);\n }, intervalMs);\n timer.unref?.();\n timers.set(scope.sessionId, timer);\n },\n sessionRemoved(sessionId) {\n const timer = timers.get(sessionId);\n if (timer !== undefined) clearInterval(timer);\n timers.delete(sessionId);\n latest.delete(sessionId);\n scopes.delete(sessionId);\n busy.delete(sessionId);\n },\n close() {\n for (const timer of timers.values()) clearInterval(timer);\n timers.clear();\n latest.clear();\n scopes.clear();\n busy.clear();\n },\n };\n receiveCommand = (scope, payload) => {\n const command = parseCommand(payload);\n if (command === undefined) return;\n // One operation per session. A create runs for minutes, and a second\n // click must not start a second worktree behind the first.\n if (busy.has(scope.sessionId)) return;\n busy.add(scope.sessionId);\n void run(scope, command)\n .then(() => {\n mark(scope, undefined, undefined);\n })\n .catch((error: unknown) => {\n host.onNotice(`worktree command failed for ${scope.sessionId} (${failureText(error)})`);\n mark(scope, undefined, failureText(error));\n })\n .finally(() => {\n busy.delete(scope.sessionId);\n });\n };\n\n return source;\n },\n receive(scope, payload) {\n receiveCommand?.(scope, payload);\n },\n };\n}\n\n/** The named export the generated hub channel registry imports. */\nexport const webHubChannels: readonly WebHubChannel[] = [createWorktreesChannel()];\n"],"mappings":"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"}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import { WorktreeOperations } from "../worktree/worktreeOperations.cjs";
|
|
1
2
|
import { WebHubChannel } from "@agimon-ai/doompi-web-contracts";
|
|
2
3
|
//#region src/adapters/web/worktreesChannel.d.ts
|
|
4
|
+
/** What the channel needs from its surroundings, injected so tests stay off this machine. */
|
|
5
|
+
interface WorktreesChannelOptions {
|
|
6
|
+
intervalMs?: number;
|
|
7
|
+
operations?: WorktreeOperations;
|
|
8
|
+
homeDir?: string;
|
|
9
|
+
registryDir?: string;
|
|
10
|
+
isSessionLive?: (registryDir: string, sessionId: string) => boolean;
|
|
11
|
+
}
|
|
3
12
|
/**
|
|
4
13
|
* One poller per managed session, publishing only when the list actually
|
|
5
14
|
* changed so an idle cockpit stays quiet.
|
|
6
15
|
*/
|
|
7
|
-
declare function createWorktreesChannel(
|
|
16
|
+
declare function createWorktreesChannel(options?: WorktreesChannelOptions): WebHubChannel;
|
|
8
17
|
/** The named export the generated hub channel registry imports. */
|
|
9
18
|
declare const webHubChannels: readonly WebHubChannel[];
|
|
10
19
|
//#endregion
|
|
11
|
-
export { createWorktreesChannel, webHubChannels };
|
|
20
|
+
export { WorktreesChannelOptions, createWorktreesChannel, webHubChannels };
|
|
12
21
|
//# sourceMappingURL=worktreesChannel.d.cts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktreesChannel.d.cts","names":[],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"worktreesChannel.d.cts","names":[],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"mappings":";;;;UAkDiB;EACf;EACA,aAAa;EACb;EACA;EACA,iBAAiB,qBAAqB;;;;;;iBAsFxB,uBAAuB,UAAS,0BAA+B;;cA6HlE,yBAAyB"}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import { WorktreeOperations } from "../worktree/worktreeOperations.mjs";
|
|
1
2
|
import { WebHubChannel } from "@agimon-ai/doompi-web-contracts";
|
|
2
3
|
//#region src/adapters/web/worktreesChannel.d.ts
|
|
4
|
+
/** What the channel needs from its surroundings, injected so tests stay off this machine. */
|
|
5
|
+
interface WorktreesChannelOptions {
|
|
6
|
+
intervalMs?: number;
|
|
7
|
+
operations?: WorktreeOperations;
|
|
8
|
+
homeDir?: string;
|
|
9
|
+
registryDir?: string;
|
|
10
|
+
isSessionLive?: (registryDir: string, sessionId: string) => boolean;
|
|
11
|
+
}
|
|
3
12
|
/**
|
|
4
13
|
* One poller per managed session, publishing only when the list actually
|
|
5
14
|
* changed so an idle cockpit stays quiet.
|
|
6
15
|
*/
|
|
7
|
-
declare function createWorktreesChannel(
|
|
16
|
+
declare function createWorktreesChannel(options?: WorktreesChannelOptions): WebHubChannel;
|
|
8
17
|
/** The named export the generated hub channel registry imports. */
|
|
9
18
|
declare const webHubChannels: readonly WebHubChannel[];
|
|
10
19
|
//#endregion
|
|
11
|
-
export { createWorktreesChannel, webHubChannels };
|
|
20
|
+
export { WorktreesChannelOptions, createWorktreesChannel, webHubChannels };
|
|
12
21
|
//# sourceMappingURL=worktreesChannel.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktreesChannel.d.mts","names":[],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"worktreesChannel.d.mts","names":[],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"mappings":";;;;UAkDiB;EACf;EACA,aAAa;EACb;EACA;EACA,iBAAiB,qBAAqB;;;;;;iBAsFxB,uBAAuB,UAAS,0BAA+B;;cA6HlE,yBAAyB"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{createWorktreeGit as e}from"../worktree/gitCli.mjs";import{sessionIsLive as t}from"../hub/hubClient.mjs";import{hubRegistryDir as n,registryFile as r}from"../filesystem/paths.mjs";import{createWorktreeRegistry as i}from"../worktree/worktreeRegistry.mjs";import{DoomGitExpectedError as a}from"../../services/support/errors.mjs";import{createWorktreeOperations as o}from"../worktree/worktreeOperations.mjs";import{GIT_WORKTREES_TYPE as s}from"../../types/webWorktrees.mjs";function c(e){return{worktrees:e.worktrees,...e.pending===void 0?{}:{pending:e.pending},...e.error===void 0?{}:{error:e.error}}}function l(e,t){try{return i(r(e.cwd,t.homeDir)).list().flatMap(n=>{let r=n.parentSessionId===e.sessionId,i=!r&&!t.isSessionLive(t.registryDir,n.parentSessionId);return!r&&!i?[]:[{id:n.id,branch:n.branch,path:n.path,sessionId:n.status===`orphaned`?null:n.sessionId,orphaned:n.status===`orphaned`,unowned:i}]})}catch{return[]}}function u(e,t){return e.length===t.length&&e.every((e,n)=>{let r=t[n];return r!==void 0&&e.id===r.id&&e.branch===r.branch&&e.sessionId===r.sessionId&&e.orphaned===r.orphaned&&e.unowned===r.unowned})}function d(e){if(typeof e!=`object`||!e)return;let t=e;if(t.action===`create`)return typeof t.branch!=`string`||t.branch.trim()===``?void 0:{action:`create`,branch:t.branch.trim(),...typeof t.baseRef==`string`&&t.baseRef.trim()!==``?{baseRef:t.baseRef.trim()}:{}};if(t.action===`close`)return typeof t.id!=`string`||t.id===``?void 0:{action:`close`,id:t.id,...t.force===!0?{force:!0}:{}}}function f(e){return e instanceof a?e.message:`The worktree operation failed.`}function p(r={}){let i=r.intervalMs??4e3,a;return{frameType:s,start(s){let p=r.operations??o({git:e()}),m={registryDir:r.registryDir??n(),homeDir:r.homeDir,isSessionLive:r.isSessionLive??t},h=new Map,g=new Map,_=new Map,v=new Set,y=(e,t)=>{let n=h.get(e.sessionId),r={worktrees:l(e,m),pending:n?.pending,error:n?.error};(t||n===void 0||!u(n.worktrees,r.worktrees)||n.pending!==r.pending||n.error!==r.error)&&(h.set(e.sessionId,r),s.publish(e.sessionId,c(r)))},b=(e,t,n)=>{let r=h.get(e.sessionId);h.set(e.sessionId,{worktrees:r?.worktrees??l(e,m),pending:t,error:n}),y(e,!0)},x=async(e,t)=>{let n={cwd:e.cwd,sessionId:e.sessionId};if(t.action===`create`){b(e,`creating ${t.branch}\u2026`,void 0),await p.spawn(n,{branch:t.branch,...t.baseRef===void 0?{}:{baseRef:t.baseRef}});return}b(e,`closing ${t.id}\u2026`,void 0),await p.close(n,t.id,t.force??!1)};return a=(e,t)=>{let n=d(t);n!==void 0&&(v.has(e.sessionId)||(v.add(e.sessionId),x(e,n).then(()=>{b(e,void 0,void 0)}).catch(t=>{s.onNotice(`worktree command failed for ${e.sessionId} (${f(t)})`),b(e,void 0,f(t))}).finally(()=>{v.delete(e.sessionId)})))},{payloadFor(e){let t=h.get(e.sessionId);return t===void 0?void 0:c(t)},sessionAdded(e){g.set(e.sessionId,e),y(e,!1);let t=setInterval(()=>{y(e,!1)},i);t.unref?.(),_.set(e.sessionId,t)},sessionRemoved(e){let t=_.get(e);t!==void 0&&clearInterval(t),_.delete(e),h.delete(e),g.delete(e),v.delete(e)},close(){for(let e of _.values())clearInterval(e);_.clear(),h.clear(),g.clear(),v.clear()}}},receive(e,t){a?.(e,t)}}}const m=[p()];export{p as createWorktreesChannel,m as webHubChannels};
|
|
2
2
|
//# sourceMappingURL=worktreesChannel.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktreesChannel.mjs","names":[],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"sourcesContent":["/**\n * The worktrees data channel.\n *\n * DESIGN PATTERNS:\n * - Hub-scoped: it reads this machine's registry files, which a browser cannot.\n * One entry per session, keyed by the repository that session sits in, so two\n * sessions in the same checkout see the same list.\n * - Poll, do not watch. The registry is a single small JSON file written by\n * this package alone, and a watcher on a path that may not exist yet costs\n * more than re-reading a few hundred bytes.\n * - Publish only what the dock renders. Paths and provenance stay in the\n * registry; the page gets a view.\n *\n * AVOID:\n * - Throwing out of a poll. A registry that cannot be read is an empty list,\n * never a broken channel: the dock degrades to \"no worktrees\", which is the\n * truth as far as the page can tell.\n */\nimport type { HubChannelSource, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { GIT_WORKTREES_TYPE, type WorktreeView } from '../../types/webWorktrees.ts';\nimport { registryFile } from '../filesystem/paths.ts';\nimport { createWorktreeRegistry } from '../worktree/worktreeRegistry.ts';\n\n/** How often a session's registry is re-read. Worktrees change by hand, not by the second. */\nconst POLL_MS = 4000;\n\nfunction viewsFor(cwd: string): WorktreeView[] {\n try {\n return createWorktreeRegistry(registryFile(cwd))\n .list()\n .map((record) => ({\n id: record.id,\n branch: record.branch,\n path: record.path,\n sessionId: record.status === 'orphaned' ? null : record.sessionId,\n orphaned: record.status === 'orphaned',\n }));\n } catch {\n return [];\n }\n}\n\nfunction sameList(left: readonly WorktreeView[], right: readonly WorktreeView[]): boolean {\n if (left.length !== right.length) return false;\n return left.every((entry, index) => {\n const other = right[index];\n return (\n other !== undefined &&\n entry.id === other.id &&\n entry.branch === other.branch &&\n entry.sessionId === other.sessionId &&\n entry.orphaned === other.orphaned\n );\n });\n}\n\n/**\n * One poller per managed session, publishing only when the list actually\n * changed so an idle cockpit stays quiet.\n */\nexport function createWorktreesChannel(intervalMs: number = POLL_MS): WebHubChannel {\n return {\n frameType: GIT_WORKTREES_TYPE,\n start(host) {\n const latest = new Map<string, WorktreeView[]>();\n const timers = new Map<string, ReturnType<typeof setInterval>>();\n const source: HubChannelSource = {\n payloadFor(scope) {\n const worktrees = latest.get(scope.sessionId);\n return worktrees === undefined ? undefined : { worktrees };\n },\n sessionAdded(scope) {\n const publish = (): void => {\n const worktrees = viewsFor(scope.cwd);\n const previous = latest.get(scope.sessionId);\n if (previous !== undefined && sameList(previous, worktrees)) return;\n latest.set(scope.sessionId, worktrees);\n host.publish(scope.sessionId, { worktrees });\n };\n publish();\n const timer = setInterval(publish, intervalMs);\n timer.unref?.();\n timers.set(scope.sessionId, timer);\n },\n sessionRemoved(sessionId) {\n const timer = timers.get(sessionId);\n if (timer !== undefined) clearInterval(timer);\n timers.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const timer of timers.values()) clearInterval(timer);\n timers.clear();\n latest.clear();\n },\n };\n return source;\n },\n };\n}\n\n/** The named export the generated hub channel registry imports. */\nexport const webHubChannels: readonly WebHubChannel[] = [createWorktreesChannel()];\n"],"mappings":"mMA0BA,SAAS,EAAS,EAA6B,CAC7C,GAAI,CACF,OAAO,EAAuB,EAAa,CAAG,CAAC,CAAC,CAC7C,KAAK,CAAC,CACN,IAAK,IAAY,CAChB,GAAI,EAAO,GACX,OAAQ,EAAO,OACf,KAAM,EAAO,KACb,UAAW,EAAO,SAAW,WAAa,KAAO,EAAO,UACxD,SAAU,EAAO,SAAW,UAC9B,EAAE,CACN,MAAQ,CACN,MAAO,CAAC,CACV,CACF,CAEA,SAAS,EAAS,EAA+B,EAAyC,CAExF,OADI,EAAK,SAAW,EAAM,QACnB,EAAK,OAAO,EAAO,IAAU,CAClC,IAAM,EAAQ,EAAM,GACpB,OACE,IAAU,IAAA,IACV,EAAM,KAAO,EAAM,IACnB,EAAM,SAAW,EAAM,QACvB,EAAM,YAAc,EAAM,WAC1B,EAAM,WAAa,EAAM,QAE7B,CAAC,CACH,CAMA,SAAgB,EAAuB,EAAqB,IAAwB,CAClF,MAAO,CACL,UAAW,EACX,MAAM,EAAM,CACV,IAAM,EAAS,IAAI,IACb,EAAS,IAAI,IA+BnB,MAAO,CA7BL,WAAW,EAAO,CAChB,IAAM,EAAY,EAAO,IAAI,EAAM,SAAS,EAC5C,OAAO,IAAc,IAAA,GAAY,IAAA,GAAY,CAAE,WAAU,CAC3D,EACA,aAAa,EAAO,CAClB,IAAM,MAAsB,CAC1B,IAAM,EAAY,EAAS,EAAM,GAAG,EAC9B,EAAW,EAAO,IAAI,EAAM,SAAS,EACvC,IAAa,IAAA,IAAa,EAAS,EAAU,CAAS,IAC1D,EAAO,IAAI,EAAM,UAAW,CAAS,EACrC,EAAK,QAAQ,EAAM,UAAW,CAAE,WAAU,CAAC,EAC7C,EACA,EAAQ,EACR,IAAM,EAAQ,YAAY,EAAS,CAAU,EAC7C,EAAM,QAAQ,EACd,EAAO,IAAI,EAAM,UAAW,CAAK,CACnC,EACA,eAAe,EAAW,CACxB,IAAM,EAAQ,EAAO,IAAI,CAAS,EAC9B,IAAU,IAAA,IAAW,cAAc,CAAK,EAC5C,EAAO,OAAO,CAAS,EACvB,EAAO,OAAO,CAAS,CACzB,EACA,OAAQ,CACN,IAAK,IAAM,KAAS,EAAO,OAAO,EAAG,cAAc,CAAK,EACxD,EAAO,MAAM,EACb,EAAO,MAAM,CACf,CAEU,CACd,CACF,CACF,CAGA,MAAa,EAA2C,CAAC,EAAuB,CAAC"}
|
|
1
|
+
{"version":3,"file":"worktreesChannel.mjs","names":[],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"sourcesContent":["/**\n * The worktrees data channel, and the commands the dock sends back through it.\n *\n * DESIGN PATTERNS:\n * - Hub-scoped: it reads this machine's registry files, which a browser cannot.\n * - Scoped to the session that owns the worktree, not to the repository. Two\n * sessions in one checkout are two pieces of work; showing each the other's\n * worktrees offered a close button for something the reader never made.\n * A worktree whose parent session is gone is the exception: it is shown to\n * everyone in the repository, marked unowned, or nobody could ever close it.\n * - Poll, do not watch. The registry is a single small JSON file written by\n * this package alone, and a watcher on a path that may not exist yet costs\n * more than re-reading a few hundred bytes.\n * - Commands run the same `worktreeOperations` the tool runs, so the panel and\n * the agent cannot drift into two ideas of what creating a worktree means.\n * - Publish only what the dock renders. Paths and provenance stay in the\n * registry; the page gets a view.\n *\n * AVOID:\n * - Throwing out of a poll or a command. A registry that cannot be read is an\n * empty list, and a failed command is an error string on the next frame:\n * the dock degrades to what it can tell the reader, never to a dead channel.\n */\nimport type { HubChannelSource, HubSessionScope, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { GIT_WORKTREES_TYPE, type GitWorktreesCommand, type WorktreeView } from '../../types/webWorktrees.ts';\nimport { DoomGitExpectedError } from '../../services/support/errors.ts';\nimport { hubRegistryDir, registryFile } from '../filesystem/paths.ts';\nimport { sessionIsLive } from '../hub/hubClient.ts';\nimport { createWorktreeGit } from '../worktree/gitCli.ts';\nimport { createWorktreeOperations, type WorktreeOperations } from '../worktree/worktreeOperations.ts';\nimport { createWorktreeRegistry } from '../worktree/worktreeRegistry.ts';\n\n/** How often a session's registry is re-read. Worktrees change by hand, not by the second. */\nconst POLL_MS = 4000;\n\ninterface SessionState {\n worktrees: WorktreeView[];\n pending: string | undefined;\n error: string | undefined;\n}\n\nfunction payloadOf(state: SessionState): Record<string, unknown> {\n return {\n worktrees: state.worktrees,\n ...(state.pending === undefined ? {} : { pending: state.pending }),\n ...(state.error === undefined ? {} : { error: state.error }),\n };\n}\n\n/** What the channel needs from its surroundings, injected so tests stay off this machine. */\nexport interface WorktreesChannelOptions {\n intervalMs?: number;\n operations?: WorktreeOperations;\n homeDir?: string;\n registryDir?: string;\n isSessionLive?: (registryDir: string, sessionId: string) => boolean;\n}\n\ninterface ViewDeps {\n registryDir: string;\n homeDir: string | undefined;\n isSessionLive: (registryDir: string, sessionId: string) => boolean;\n}\n\n/**\n * The worktrees one session may see: its own, plus any whose parent session\n * has gone. Liveness is the registry record's presence, the same signal the\n * rail uses to decide a session exists at all.\n */\nfunction viewsFor(scope: HubSessionScope, deps: ViewDeps): WorktreeView[] {\n try {\n return createWorktreeRegistry(registryFile(scope.cwd, deps.homeDir))\n .list()\n .flatMap((record) => {\n const owned = record.parentSessionId === scope.sessionId;\n const unowned = !owned && !deps.isSessionLive(deps.registryDir, record.parentSessionId);\n if (!owned && !unowned) return [];\n return [\n {\n id: record.id,\n branch: record.branch,\n path: record.path,\n sessionId: record.status === 'orphaned' ? null : record.sessionId,\n orphaned: record.status === 'orphaned',\n unowned,\n },\n ];\n });\n } catch {\n return [];\n }\n}\n\nfunction sameList(left: readonly WorktreeView[], right: readonly WorktreeView[]): boolean {\n if (left.length !== right.length) return false;\n return left.every((entry, index) => {\n const other = right[index];\n return (\n other !== undefined &&\n entry.id === other.id &&\n entry.branch === other.branch &&\n entry.sessionId === other.sessionId &&\n entry.orphaned === other.orphaned &&\n entry.unowned === other.unowned\n );\n });\n}\n\n/** A command the page sent, or undefined when the frame was not one. */\nfunction parseCommand(payload: unknown): GitWorktreesCommand | undefined {\n if (typeof payload !== 'object' || payload === null) return undefined;\n const value = payload as Record<string, unknown>;\n if (value.action === 'create') {\n if (typeof value.branch !== 'string' || value.branch.trim() === '') return undefined;\n return {\n action: 'create',\n branch: value.branch.trim(),\n ...(typeof value.baseRef === 'string' && value.baseRef.trim() !== '' ? { baseRef: value.baseRef.trim() } : {}),\n };\n }\n if (value.action === 'close') {\n if (typeof value.id !== 'string' || value.id === '') return undefined;\n return { action: 'close', id: value.id, ...(value.force === true ? { force: true } : {}) };\n }\n return undefined;\n}\n\n/**\n * What the reader is told when a command fails.\n *\n * An expected failure explains itself. Anything else is summarised, never\n * echoed: a git error can carry a remote URL with a token in it.\n */\nfunction failureText(error: unknown): string {\n return error instanceof DoomGitExpectedError ? error.message : 'The worktree operation failed.';\n}\n\n/**\n * One poller per managed session, publishing only when the list actually\n * changed so an idle cockpit stays quiet.\n */\nexport function createWorktreesChannel(options: WorktreesChannelOptions = {}): WebHubChannel {\n const intervalMs = options.intervalMs ?? POLL_MS;\n // Assigned by start, because receive is reachable only while the channel is\n // running and the command needs the state the poller keeps.\n let receiveCommand: ((scope: HubSessionScope, payload: unknown) => void) | undefined;\n\n return {\n frameType: GIT_WORKTREES_TYPE,\n start(host) {\n const worktrees = options.operations ?? createWorktreeOperations({ git: createWorktreeGit() });\n const views: ViewDeps = {\n registryDir: options.registryDir ?? hubRegistryDir(),\n homeDir: options.homeDir,\n isSessionLive: options.isSessionLive ?? sessionIsLive,\n };\n const latest = new Map<string, SessionState>();\n const scopes = new Map<string, HubSessionScope>();\n const timers = new Map<string, ReturnType<typeof setInterval>>();\n const busy = new Set<string>();\n\n /** Republishes a session, skipping a frame that would say nothing new. */\n const publish = (scope: HubSessionScope, force: boolean): void => {\n const previous = latest.get(scope.sessionId);\n const next: SessionState = {\n worktrees: viewsFor(scope, views),\n pending: previous?.pending,\n error: previous?.error,\n };\n if (\n !force &&\n previous !== undefined &&\n sameList(previous.worktrees, next.worktrees) &&\n previous.pending === next.pending &&\n previous.error === next.error\n ) {\n return;\n }\n latest.set(scope.sessionId, next);\n host.publish(scope.sessionId, payloadOf(next));\n };\n\n /** Sets the operation label and clears the last failure, then publishes. */\n const mark = (scope: HubSessionScope, pending: string | undefined, error: string | undefined): void => {\n const previous = latest.get(scope.sessionId);\n latest.set(scope.sessionId, {\n worktrees: previous?.worktrees ?? viewsFor(scope, views),\n pending,\n error,\n });\n publish(scope, true);\n };\n\n const run = async (scope: HubSessionScope, command: GitWorktreesCommand): Promise<void> => {\n const context = { cwd: scope.cwd, sessionId: scope.sessionId };\n if (command.action === 'create') {\n mark(scope, `creating ${command.branch}\\u2026`, undefined);\n await worktrees.spawn(context, {\n branch: command.branch,\n ...(command.baseRef === undefined ? {} : { baseRef: command.baseRef }),\n });\n return;\n }\n mark(scope, `closing ${command.id}\\u2026`, undefined);\n await worktrees.close(context, command.id, command.force ?? false);\n };\n\n const source: HubChannelSource = {\n payloadFor(scope) {\n const state = latest.get(scope.sessionId);\n return state === undefined ? undefined : payloadOf(state);\n },\n sessionAdded(scope) {\n scopes.set(scope.sessionId, scope);\n publish(scope, false);\n const timer = setInterval(() => {\n publish(scope, false);\n }, intervalMs);\n timer.unref?.();\n timers.set(scope.sessionId, timer);\n },\n sessionRemoved(sessionId) {\n const timer = timers.get(sessionId);\n if (timer !== undefined) clearInterval(timer);\n timers.delete(sessionId);\n latest.delete(sessionId);\n scopes.delete(sessionId);\n busy.delete(sessionId);\n },\n close() {\n for (const timer of timers.values()) clearInterval(timer);\n timers.clear();\n latest.clear();\n scopes.clear();\n busy.clear();\n },\n };\n receiveCommand = (scope, payload) => {\n const command = parseCommand(payload);\n if (command === undefined) return;\n // One operation per session. A create runs for minutes, and a second\n // click must not start a second worktree behind the first.\n if (busy.has(scope.sessionId)) return;\n busy.add(scope.sessionId);\n void run(scope, command)\n .then(() => {\n mark(scope, undefined, undefined);\n })\n .catch((error: unknown) => {\n host.onNotice(`worktree command failed for ${scope.sessionId} (${failureText(error)})`);\n mark(scope, undefined, failureText(error));\n })\n .finally(() => {\n busy.delete(scope.sessionId);\n });\n };\n\n return source;\n },\n receive(scope, payload) {\n receiveCommand?.(scope, payload);\n },\n };\n}\n\n/** The named export the generated hub channel registry imports. */\nexport const webHubChannels: readonly WebHubChannel[] = [createWorktreesChannel()];\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../../_virtual/_rolldown/runtime.cjs"),t=require("./dependencyInstall.cjs"),n=require("../hub/hubClient.cjs"),r=require("./repositoryIdentity.cjs"),i=require("../filesystem/paths.cjs"),a=require("../channel/worktreeChannel.cjs"),o=require("./worktreeRegistry.cjs"),s=require("../../services/worktreeNaming.cjs"),c=require("../../services/support/errors.cjs");let l=require("node:fs");l=e.__toESM(l,1);let u=require("node:crypto");function d(e){let{git:d}=e,f=e.createSession??n.createWorktreeSession,p=e.install??t.installDependencies,m=e.stopSession??n.stopWorktreeSession,h=e.isSessionLive??n.sessionIsLive,g=e.registryDir??i.hubRegistryDir(),_=e.now??(()=>new Date),v={alive:e=>h(g,e.sessionId),exists:e=>l.default.existsSync(e)},y=async t=>{let n=await d.repositoryRoot(t.cwd);if(n===void 0)throw new c.DoomGitExpectedError(`not_a_repository`,`${t.cwd} is not inside a git repository.`,!1,`Run this from a repository checkout.`);let r=o.createWorktreeRegistry(i.registryFile(n,e.homeDir));return{root:n,store:r,records:r.list()}},b=(e,t)=>{let n=e.find(e=>e.id===t);if(n===void 0)throw new c.DoomGitExpectedError(`worktree_not_found`,`No worktree with id ${t}.`,!1,`Call list to see the current ids.`);return n};return{async spawn(t,a){let{root:o,store:l,records:m}=await y(t),h=s.refuseSpawn({branch:a.branch,existing:m});if(h!==void 0)throw new c.DoomGitExpectedError(`worktree_exists`,h,!1,`Pick another branch name, or close it.`);let v=a.baseRef??await d.currentBranch(o)??`HEAD`,b=r.shortId((0,u.randomUUID)()),x=s.worktreeDirectory({worktreesRoot:i.worktreesRoot(e.homeDir),repositoryLabel:r.repositoryLabel(o),repositoryId:r.repositoryId(o),branch:a.branch,shortId:b});await d.addWorktree({repositoryRoot:o,path:x,branch:a.branch,baseRef:v});let S=await p(x);if(S.kind===`failed`)throw await d.removeWorktree({repositoryRoot:o,path:x,force:!0}).catch(()=>void 0),new c.DoomGitExpectedError(`install_failed`,`${S.command} install failed in the new worktree (exit ${String(S.code??`unknown`)}).`,!0,`Run the install by hand in the repository to see what it reports, then try again.`);let C;try{C=await f({cwd:x,name:a.name??a.branch,parentSessionId:t.sessionId,registryDir:g})}catch(e){throw await d.removeWorktree({repositoryRoot:o,path:x,force:!0}).catch(()=>void 0),e instanceof n.HubUnavailableError?new c.DoomGitExpectedError(`hub_unavailable`,e.message,!0,`Start the cockpit and try again.`):e}let w={version:1,id:b,branch:a.branch,baseRef:v,path:x,repositoryRoot:o,sessionId:C,parentSessionId:t.sessionId,status:`running`,createdAt:_().toISOString()};return l.replace([...m,w]),w},async close(e,t,n){let{root:r,store:i,records:a}=await y(e),o=b(a,t),u=l.default.existsSync(o.path)?await d.dirtyFiles(o.path):[],f=s.refuseClose({record:o,dirtyFiles:u,force:n});if(f!==void 0)throw new c.DoomGitExpectedError(`worktree_dirty`,f,!1,`Commit the work, or pass force: true.`);return await m(g,o.sessionId),await d.removeWorktree({repositoryRoot:r,path:o.path,force:n}),i.replace(a.filter(e=>e.id!==t)),o},async list(e){let{store:t,records:n}=await y(e),r=s.reconcile(n,v);return r.changed.length>0&&t.replace(r.records),r.records},async status(e,t){let{records:n}=await y(e),r=b(n,t);return{record:r,dirtyFiles:l.default.existsSync(r.path)?await d.dirtyFiles(r.path):[]}},async merge(e,t,n){let{root:r,records:i}=await y(e),a=b(i,t),o=await d.dirtyFiles(r);if(o.length>0)throw new c.DoomGitExpectedError(`worktree_dirty`,`The parent checkout has ${String(o.length)} uncommitted file(s): ${o.slice(0,10).join(`, `)}.`,!1,`Commit or stash the parent checkout first.`);return await d.mergeBranch({repositoryRoot:r,branch:a.branch,...n===void 0?{}:{message:n}}),a},async send(t,n,r){let{records:o}=await y(t),s=b(o,n),c=t.sessionId===s.sessionId?`parent`:`child`;a.createWorktreeChannel(i.channelRoot(s.id,e.homeDir)).send(c,r)},async messages(t,n){let{records:r}=await y(t),o=b(r,n),s=a.createWorktreeChannel(i.channelRoot(o.id,e.homeDir));s.sweep();let c=t.sessionId===o.sessionId?`child`:`parent`;return s.receive(c)},async prune(t,n){let{root:r,store:a,records:o}=await y(t),c=s.reconcile(o,v),u=await d.listWorktreePaths(r),f=new Map;for(let e of c.records)e.status===`orphaned`&&l.default.existsSync(e.path)&&f.set(e.path,await d.dirtyFiles(e.path));let p=s.planPrune({records:c.records,gitPaths:u,worktreesRoot:i.worktreesRoot(e.homeDir),dirtyByPath:f});if(n)return a.replace(c.records),p;for(let e of p.remove)await d.removeWorktree({repositoryRoot:r,path:e.path,force:!1});let m=new Set([...p.remove,...p.forget].map(e=>e.id));return a.replace(c.records.filter(e=>!m.has(e.id))),p}}}exports.createWorktreeOperations=d;
|
|
1
|
+
const e=require("../../_virtual/_rolldown/runtime.cjs"),t=require("./dependencyInstall.cjs"),n=require("../hub/hubClient.cjs"),r=require("./repositoryIdentity.cjs"),i=require("../filesystem/paths.cjs"),a=require("../channel/worktreeChannel.cjs"),o=require("./worktreeRegistry.cjs"),s=require("../../services/worktreeNaming.cjs"),c=require("../../services/support/errors.cjs");let l=require("node:fs");l=e.__toESM(l,1);let u=require("node:crypto");function d(e){let{git:d}=e,f=e.createSession??n.createWorktreeSession,p=e.install??t.installDependencies,m=e.stopSession??n.stopWorktreeSession,h=e.isSessionLive??n.sessionIsLive,g=e.registryDir??i.hubRegistryDir(),_=e.now??(()=>new Date),v={alive:e=>h(g,e.sessionId),exists:e=>l.default.existsSync(e)},y=async t=>{let n=await d.repositoryRoot(t.cwd);if(n===void 0)throw new c.DoomGitExpectedError(`not_a_repository`,`${t.cwd} is not inside a git repository.`,!1,`Run this from a repository checkout.`);let r=o.createWorktreeRegistry(i.registryFile(n,e.homeDir));return{root:n,store:r,records:r.list()}},b=(e,t)=>{let n=e.find(e=>e.id===t);if(n===void 0)throw new c.DoomGitExpectedError(`worktree_not_found`,`No worktree with id ${t}.`,!1,`Call list to see the current ids.`);return n},x=(e,t,n)=>{if(t.parentSessionId!==e.sessionId&&h(g,t.parentSessionId))throw new c.DoomGitExpectedError(`worktree_not_owned`,`Worktree ${t.id} belongs to another session.`,!1,`Ask the session that created it to ${n} it, or close that session first.`)};return{async spawn(t,a){let{root:o,store:l,records:m}=await y(t),h=s.refuseSpawn({branch:a.branch,existing:m});if(h!==void 0)throw new c.DoomGitExpectedError(`worktree_exists`,h,!1,`Pick another branch name, or close it.`);let v=a.baseRef??await d.currentBranch(o)??`HEAD`,b=r.shortId((0,u.randomUUID)()),x=s.worktreeDirectory({worktreesRoot:i.worktreesRoot(e.homeDir),repositoryLabel:r.repositoryLabel(o),repositoryId:r.repositoryId(o),branch:a.branch,shortId:b});await d.addWorktree({repositoryRoot:o,path:x,branch:a.branch,baseRef:v});let S=await p(x);if(S.kind===`failed`)throw await d.removeWorktree({repositoryRoot:o,path:x,force:!0}).catch(()=>void 0),new c.DoomGitExpectedError(`install_failed`,`${S.command} install failed in the new worktree (exit ${String(S.code??`unknown`)}).`,!0,`Run the install by hand in the repository to see what it reports, then try again.`);let C;try{C=await f({cwd:x,name:a.name??a.branch,parentSessionId:t.sessionId,registryDir:g})}catch(e){throw await d.removeWorktree({repositoryRoot:o,path:x,force:!0}).catch(()=>void 0),e instanceof n.HubUnavailableError?new c.DoomGitExpectedError(`hub_unavailable`,e.message,!0,`Start the cockpit and try again.`):e}let w={version:1,id:b,branch:a.branch,baseRef:v,path:x,repositoryRoot:o,sessionId:C,parentSessionId:t.sessionId,status:`running`,createdAt:_().toISOString()};return l.replace([...m,w]),w},async close(e,t,n){let{root:r,store:i,records:a}=await y(e),o=b(a,t);x(e,o,`close`);let u=l.default.existsSync(o.path)?await d.dirtyFiles(o.path):[],f=s.refuseClose({record:o,dirtyFiles:u,force:n});if(f!==void 0)throw new c.DoomGitExpectedError(`worktree_dirty`,f,!1,`Commit the work, or pass force: true.`);return await m(g,o.sessionId),await d.removeWorktree({repositoryRoot:r,path:o.path,force:n}),i.replace(a.filter(e=>e.id!==t)),o},async list(e){let{store:t,records:n}=await y(e),r=s.reconcile(n,v);return r.changed.length>0&&t.replace(r.records),r.records},async status(e,t){let{records:n}=await y(e),r=b(n,t);return{record:r,dirtyFiles:l.default.existsSync(r.path)?await d.dirtyFiles(r.path):[]}},async merge(e,t,n){let{root:r,records:i}=await y(e),a=b(i,t);x(e,a,`merge`);let o=await d.dirtyFiles(r);if(o.length>0)throw new c.DoomGitExpectedError(`worktree_dirty`,`The parent checkout has ${String(o.length)} uncommitted file(s): ${o.slice(0,10).join(`, `)}.`,!1,`Commit or stash the parent checkout first.`);return await d.mergeBranch({repositoryRoot:r,branch:a.branch,...n===void 0?{}:{message:n}}),a},async send(t,n,r){let{records:o}=await y(t),s=b(o,n),c=t.sessionId===s.sessionId?`parent`:`child`;a.createWorktreeChannel(i.channelRoot(s.id,e.homeDir)).send(c,r)},async messages(t,n){let{records:r}=await y(t),o=b(r,n),s=a.createWorktreeChannel(i.channelRoot(o.id,e.homeDir));s.sweep();let c=t.sessionId===o.sessionId?`child`:`parent`;return s.receive(c)},async prune(t,n){let{root:r,store:a,records:o}=await y(t),c=s.reconcile(o,v),u=await d.listWorktreePaths(r),f=new Map;for(let e of c.records)e.status===`orphaned`&&l.default.existsSync(e.path)&&f.set(e.path,await d.dirtyFiles(e.path));let p=s.planPrune({records:c.records,gitPaths:u,worktreesRoot:i.worktreesRoot(e.homeDir),dirtyByPath:f});if(n)return a.replace(c.records),p;for(let e of p.remove)await d.removeWorktree({repositoryRoot:r,path:e.path,force:!1});let m=new Set([...p.remove,...p.forget].map(e=>e.id));return a.replace(c.records.filter(e=>!m.has(e.id))),p}}}exports.createWorktreeOperations=d;
|
|
2
2
|
//# sourceMappingURL=worktreeOperations.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktreeOperations.cjs","names":["createWorktreeSession","installDependencies","stopWorktreeSession","sessionIsLive","hubRegistryDir","fs","DoomGitExpectedError","createWorktreeRegistry","registryFile","require","refuseSpawn","shortId","randomUUID","worktreeDirectory","worktreesRoot","repositoryLabel","repositoryId","HubUnavailableError","refuseClose","reconcile","channelRoot","createWorktreeChannel","planPrune"],"sources":["../../../src/adapters/worktree/worktreeOperations.ts"],"sourcesContent":["import fs from 'node:fs';\nimport { randomUUID } from 'node:crypto';\nimport { installDependencies } from './dependencyInstall.ts';\nimport { createWorktreeSession, HubUnavailableError, sessionIsLive, stopWorktreeSession } from '../hub/hubClient.ts';\nimport { channelRoot, hubRegistryDir, registryFile, worktreesRoot } from '../filesystem/paths.ts';\nimport { repositoryId, repositoryLabel, shortId } from './repositoryIdentity.ts';\nimport { createWorktreeChannel } from '../channel/worktreeChannel.ts';\nimport type { ChannelMessage, ChannelParty } from '../channel/worktreeChannel.ts';\nimport { createWorktreeRegistry } from './worktreeRegistry.ts';\nimport { WORKTREE_RECORD_VERSION } from '../../types/worktreeRegistry.ts';\nimport type { WorktreeGit, WorktreeRecord } from '../../types/worktreeRegistry.ts';\nimport { planPrune, reconcile, refuseClose, refuseSpawn, worktreeDirectory } from '../../services/worktreeNaming.ts';\nimport { DoomGitExpectedError } from '../../services/support/errors.ts';\n\nexport interface WorktreeContext {\n /** Where the calling session is working, used to find the repository. */\n cwd: string;\n /** The calling session, recorded as the new session's parent. */\n sessionId: string;\n}\n\nexport interface SpawnWorktreeRequest {\n branch: string;\n baseRef?: string;\n name?: string;\n}\n\nexport interface WorktreeOperations {\n spawn(context: WorktreeContext, request: SpawnWorktreeRequest): Promise<WorktreeRecord>;\n close(context: WorktreeContext, id: string, force: boolean): Promise<WorktreeRecord>;\n list(context: WorktreeContext): Promise<WorktreeRecord[]>;\n status(context: WorktreeContext, id: string): Promise<{ record: WorktreeRecord; dirtyFiles: string[] }>;\n merge(context: WorktreeContext, id: string, message?: string): Promise<WorktreeRecord>;\n prune(context: WorktreeContext, dryRun: boolean): Promise<ReturnType<typeof planPrune>>;\n send(context: WorktreeContext, id: string, message: string): Promise<void>;\n messages(context: WorktreeContext, id: string): Promise<ChannelMessage[]>;\n}\n\nexport interface WorktreeOperationsDeps {\n git: WorktreeGit;\n /** Injected so tests never reach the network. */\n createSession?: typeof createWorktreeSession;\n /** Injected so tests never run a package manager. */\n install?: typeof installDependencies;\n stopSession?: typeof stopWorktreeSession;\n isSessionLive?: typeof sessionIsLive;\n homeDir?: string;\n registryDir?: string;\n /** Injected so a record's createdAt is reproducible in a test. */\n now?: () => Date;\n}\n\nexport function createWorktreeOperations(deps: WorktreeOperationsDeps): WorktreeOperations {\n const { git } = deps;\n const createSession = deps.createSession ?? createWorktreeSession;\n const install = deps.install ?? installDependencies;\n const stopSession = deps.stopSession ?? stopWorktreeSession;\n const isSessionLive = deps.isSessionLive ?? sessionIsLive;\n const registryDir = deps.registryDir ?? hubRegistryDir();\n const now = deps.now ?? (() => new Date());\n const probe = {\n alive: (record: WorktreeRecord) => isSessionLive(registryDir, record.sessionId),\n exists: (path: string) => fs.existsSync(path),\n };\n\n /**\n * Everything an action needs about the repository it is acting on.\n *\n * Resolved per call rather than cached: a session's cwd is stable, but the\n * registry file is shared with the cockpit panel and another process may\n * have written it since the last call.\n */\n const resolve = async (context: WorktreeContext) => {\n const root = await git.repositoryRoot(context.cwd);\n if (root === undefined) {\n throw new DoomGitExpectedError(\n 'not_a_repository',\n `${context.cwd} is not inside a git repository.`,\n false,\n 'Run this from a repository checkout.',\n );\n }\n const store = createWorktreeRegistry(registryFile(root, deps.homeDir));\n return { root, store, records: store.list() };\n };\n\n const require = (records: readonly WorktreeRecord[], id: string): WorktreeRecord => {\n const record = records.find((entry) => entry.id === id);\n if (record === undefined) {\n throw new DoomGitExpectedError(\n 'worktree_not_found',\n `No worktree with id ${id}.`,\n false,\n 'Call list to see the current ids.',\n );\n }\n return record;\n };\n\n return {\n async spawn(context, request) {\n const { root, store, records } = await resolve(context);\n const refusal = refuseSpawn({ branch: request.branch, existing: records });\n if (refusal !== undefined) {\n throw new DoomGitExpectedError('worktree_exists', refusal, false, 'Pick another branch name, or close it.');\n }\n const baseRef = request.baseRef ?? (await git.currentBranch(root)) ?? 'HEAD';\n const id = shortId(randomUUID());\n const path = worktreeDirectory({\n worktreesRoot: worktreesRoot(deps.homeDir),\n repositoryLabel: repositoryLabel(root),\n repositoryId: repositoryId(root),\n branch: request.branch,\n shortId: id,\n });\n\n await git.addWorktree({ repositoryRoot: root, path, branch: request.branch, baseRef });\n\n // Before the session, not after. The session resolves this repository's\n // composition, and every workspace package path in it points at a\n // directory that exists only once dependencies are linked. Starting the\n // session first makes it fall back to the global bundle and quietly lose\n // the repository's own packages.\n const installed = await install(path);\n if (installed.kind === 'failed') {\n await git.removeWorktree({ repositoryRoot: root, path, force: true }).catch(() => undefined);\n throw new DoomGitExpectedError(\n 'install_failed',\n `${installed.command} install failed in the new worktree (exit ${String(installed.code ?? 'unknown')}).`,\n true,\n 'Run the install by hand in the repository to see what it reports, then try again.',\n );\n }\n\n // The worktree exists from here on, so every later failure has to leave\n // it removed or recorded. An unrecorded directory on disk is the one\n // outcome with no way back: nothing would ever list it again.\n let sessionId: string;\n try {\n sessionId = await createSession({\n cwd: path,\n name: request.name ?? request.branch,\n parentSessionId: context.sessionId,\n registryDir,\n });\n } catch (error) {\n await git.removeWorktree({ repositoryRoot: root, path, force: true }).catch(() => undefined);\n if (error instanceof HubUnavailableError) {\n throw new DoomGitExpectedError('hub_unavailable', error.message, true, 'Start the cockpit and try again.');\n }\n throw error;\n }\n\n const record: WorktreeRecord = {\n version: WORKTREE_RECORD_VERSION,\n id,\n branch: request.branch,\n baseRef,\n path,\n repositoryRoot: root,\n sessionId,\n parentSessionId: context.sessionId,\n status: 'running',\n createdAt: now().toISOString(),\n };\n store.replace([...records, record]);\n return record;\n },\n\n async close(context, id, force) {\n const { root, store, records } = await resolve(context);\n const record = require(records, id);\n const dirtyFiles = fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [];\n const refusal = refuseClose({ record, dirtyFiles, force });\n if (refusal !== undefined) {\n throw new DoomGitExpectedError('worktree_dirty', refusal, false, 'Commit the work, or pass force: true.');\n }\n // The session is stopped before the directory goes. Removing a checkout\n // while a process still holds files open in it is how a half-removed\n // worktree and a stale git administrative entry are made.\n await stopSession(registryDir, record.sessionId);\n await git.removeWorktree({ repositoryRoot: root, path: record.path, force });\n store.replace(records.filter((entry) => entry.id !== id));\n return record;\n },\n\n async list(context) {\n const { store, records } = await resolve(context);\n const outcome = reconcile(records, probe);\n if (outcome.changed.length > 0) store.replace(outcome.records);\n return outcome.records;\n },\n\n async status(context, id) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const dirtyFiles = fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [];\n return { record, dirtyFiles };\n },\n\n async merge(context, id, message) {\n const { root, records } = await resolve(context);\n const record = require(records, id);\n // The merge lands in the parent, so the parent is what must be clean. A\n // dirty parent would mix the person's in-flight edits into a merge commit\n // they did not intend to make.\n const parentDirty = await git.dirtyFiles(root);\n if (parentDirty.length > 0) {\n throw new DoomGitExpectedError(\n 'worktree_dirty',\n `The parent checkout has ${String(parentDirty.length)} uncommitted file(s): ${parentDirty.slice(0, 10).join(', ')}.`,\n false,\n 'Commit or stash the parent checkout first.',\n );\n }\n await git.mergeBranch({\n repositoryRoot: root,\n branch: record.branch,\n ...(message === undefined ? {} : { message }),\n });\n return record;\n },\n\n /**\n * Which side of the channel the caller is.\n *\n * A worktree session is the child of the record it belongs to, and the\n * parent for everything else. Deciding from the session id rather than a\n * flag means neither side can address itself by mistake.\n */\n async send(context, id, message) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const party: ChannelParty = context.sessionId === record.sessionId ? 'parent' : 'child';\n createWorktreeChannel(channelRoot(record.id, deps.homeDir)).send(party, message);\n },\n\n async messages(context, id) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const channel = createWorktreeChannel(channelRoot(record.id, deps.homeDir));\n channel.sweep();\n const party: ChannelParty = context.sessionId === record.sessionId ? 'child' : 'parent';\n return channel.receive(party);\n },\n\n async prune(context, dryRun) {\n const { root, store, records } = await resolve(context);\n const reconciled = reconcile(records, probe);\n const gitPaths = await git.listWorktreePaths(root);\n const dirtyByPath = new Map<string, readonly string[]>();\n for (const record of reconciled.records) {\n if (record.status === 'orphaned' && fs.existsSync(record.path)) {\n dirtyByPath.set(record.path, await git.dirtyFiles(record.path));\n }\n }\n const plan = planPrune({\n records: reconciled.records,\n gitPaths,\n worktreesRoot: worktreesRoot(deps.homeDir),\n dirtyByPath,\n });\n if (dryRun) {\n store.replace(reconciled.records);\n return plan;\n }\n for (const record of plan.remove) {\n await git.removeWorktree({ repositoryRoot: root, path: record.path, force: false });\n }\n const dropped = new Set([...plan.remove, ...plan.forget].map((record) => record.id));\n store.replace(reconciled.records.filter((record) => !dropped.has(record.id)));\n return plan;\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"worktreeOperations.cjs","names":["createWorktreeSession","installDependencies","stopWorktreeSession","sessionIsLive","hubRegistryDir","fs","DoomGitExpectedError","createWorktreeRegistry","registryFile","require","refuseSpawn","shortId","randomUUID","worktreeDirectory","worktreesRoot","repositoryLabel","repositoryId","HubUnavailableError","refuseClose","reconcile","channelRoot","createWorktreeChannel","planPrune"],"sources":["../../../src/adapters/worktree/worktreeOperations.ts"],"sourcesContent":["import fs from 'node:fs';\nimport { randomUUID } from 'node:crypto';\nimport { installDependencies } from './dependencyInstall.ts';\nimport { createWorktreeSession, HubUnavailableError, sessionIsLive, stopWorktreeSession } from '../hub/hubClient.ts';\nimport { channelRoot, hubRegistryDir, registryFile, worktreesRoot } from '../filesystem/paths.ts';\nimport { repositoryId, repositoryLabel, shortId } from './repositoryIdentity.ts';\nimport { createWorktreeChannel } from '../channel/worktreeChannel.ts';\nimport type { ChannelMessage, ChannelParty } from '../channel/worktreeChannel.ts';\nimport { createWorktreeRegistry } from './worktreeRegistry.ts';\nimport { WORKTREE_RECORD_VERSION } from '../../types/worktreeRegistry.ts';\nimport type { WorktreeGit, WorktreeRecord } from '../../types/worktreeRegistry.ts';\nimport { planPrune, reconcile, refuseClose, refuseSpawn, worktreeDirectory } from '../../services/worktreeNaming.ts';\nimport { DoomGitExpectedError } from '../../services/support/errors.ts';\n\nexport interface WorktreeContext {\n /** Where the calling session is working, used to find the repository. */\n cwd: string;\n /** The calling session, recorded as the new session's parent. */\n sessionId: string;\n}\n\nexport interface SpawnWorktreeRequest {\n branch: string;\n baseRef?: string;\n name?: string;\n}\n\nexport interface WorktreeOperations {\n spawn(context: WorktreeContext, request: SpawnWorktreeRequest): Promise<WorktreeRecord>;\n close(context: WorktreeContext, id: string, force: boolean): Promise<WorktreeRecord>;\n list(context: WorktreeContext): Promise<WorktreeRecord[]>;\n status(context: WorktreeContext, id: string): Promise<{ record: WorktreeRecord; dirtyFiles: string[] }>;\n merge(context: WorktreeContext, id: string, message?: string): Promise<WorktreeRecord>;\n prune(context: WorktreeContext, dryRun: boolean): Promise<ReturnType<typeof planPrune>>;\n send(context: WorktreeContext, id: string, message: string): Promise<void>;\n messages(context: WorktreeContext, id: string): Promise<ChannelMessage[]>;\n}\n\nexport interface WorktreeOperationsDeps {\n git: WorktreeGit;\n /** Injected so tests never reach the network. */\n createSession?: typeof createWorktreeSession;\n /** Injected so tests never run a package manager. */\n install?: typeof installDependencies;\n stopSession?: typeof stopWorktreeSession;\n isSessionLive?: typeof sessionIsLive;\n homeDir?: string;\n registryDir?: string;\n /** Injected so a record's createdAt is reproducible in a test. */\n now?: () => Date;\n}\n\nexport function createWorktreeOperations(deps: WorktreeOperationsDeps): WorktreeOperations {\n const { git } = deps;\n const createSession = deps.createSession ?? createWorktreeSession;\n const install = deps.install ?? installDependencies;\n const stopSession = deps.stopSession ?? stopWorktreeSession;\n const isSessionLive = deps.isSessionLive ?? sessionIsLive;\n const registryDir = deps.registryDir ?? hubRegistryDir();\n const now = deps.now ?? (() => new Date());\n const probe = {\n alive: (record: WorktreeRecord) => isSessionLive(registryDir, record.sessionId),\n exists: (path: string) => fs.existsSync(path),\n };\n\n /**\n * Everything an action needs about the repository it is acting on.\n *\n * Resolved per call rather than cached: a session's cwd is stable, but the\n * registry file is shared with the cockpit panel and another process may\n * have written it since the last call.\n */\n const resolve = async (context: WorktreeContext) => {\n const root = await git.repositoryRoot(context.cwd);\n if (root === undefined) {\n throw new DoomGitExpectedError(\n 'not_a_repository',\n `${context.cwd} is not inside a git repository.`,\n false,\n 'Run this from a repository checkout.',\n );\n }\n const store = createWorktreeRegistry(registryFile(root, deps.homeDir));\n return { root, store, records: store.list() };\n };\n\n const require = (records: readonly WorktreeRecord[], id: string): WorktreeRecord => {\n const record = records.find((entry) => entry.id === id);\n if (record === undefined) {\n throw new DoomGitExpectedError(\n 'worktree_not_found',\n `No worktree with id ${id}.`,\n false,\n 'Call list to see the current ids.',\n );\n }\n return record;\n };\n\n /**\n * Refuses to destroy or merge a worktree another live session is using.\n *\n * `parentSessionId` records who asked for the worktree. Two sessions in one\n * checkout share this registry, so without the check either could close the\n * other's work in progress by id.\n *\n * Once that session is gone there is no owner left to protect, and refusing\n * would strand the worktree: nothing else could ever close it.\n */\n const requireOwner = (context: WorktreeContext, record: WorktreeRecord, action: string): void => {\n if (record.parentSessionId === context.sessionId) return;\n if (!isSessionLive(registryDir, record.parentSessionId)) return;\n throw new DoomGitExpectedError(\n 'worktree_not_owned',\n `Worktree ${record.id} belongs to another session.`,\n false,\n `Ask the session that created it to ${action} it, or close that session first.`,\n );\n };\n\n return {\n async spawn(context, request) {\n const { root, store, records } = await resolve(context);\n const refusal = refuseSpawn({ branch: request.branch, existing: records });\n if (refusal !== undefined) {\n throw new DoomGitExpectedError('worktree_exists', refusal, false, 'Pick another branch name, or close it.');\n }\n const baseRef = request.baseRef ?? (await git.currentBranch(root)) ?? 'HEAD';\n const id = shortId(randomUUID());\n const path = worktreeDirectory({\n worktreesRoot: worktreesRoot(deps.homeDir),\n repositoryLabel: repositoryLabel(root),\n repositoryId: repositoryId(root),\n branch: request.branch,\n shortId: id,\n });\n\n await git.addWorktree({ repositoryRoot: root, path, branch: request.branch, baseRef });\n\n // Before the session, not after. The session resolves this repository's\n // composition, and every workspace package path in it points at a\n // directory that exists only once dependencies are linked. Starting the\n // session first makes it fall back to the global bundle and quietly lose\n // the repository's own packages.\n const installed = await install(path);\n if (installed.kind === 'failed') {\n await git.removeWorktree({ repositoryRoot: root, path, force: true }).catch(() => undefined);\n throw new DoomGitExpectedError(\n 'install_failed',\n `${installed.command} install failed in the new worktree (exit ${String(installed.code ?? 'unknown')}).`,\n true,\n 'Run the install by hand in the repository to see what it reports, then try again.',\n );\n }\n\n // The worktree exists from here on, so every later failure has to leave\n // it removed or recorded. An unrecorded directory on disk is the one\n // outcome with no way back: nothing would ever list it again.\n let sessionId: string;\n try {\n sessionId = await createSession({\n cwd: path,\n name: request.name ?? request.branch,\n parentSessionId: context.sessionId,\n registryDir,\n });\n } catch (error) {\n await git.removeWorktree({ repositoryRoot: root, path, force: true }).catch(() => undefined);\n if (error instanceof HubUnavailableError) {\n throw new DoomGitExpectedError('hub_unavailable', error.message, true, 'Start the cockpit and try again.');\n }\n throw error;\n }\n\n const record: WorktreeRecord = {\n version: WORKTREE_RECORD_VERSION,\n id,\n branch: request.branch,\n baseRef,\n path,\n repositoryRoot: root,\n sessionId,\n parentSessionId: context.sessionId,\n status: 'running',\n createdAt: now().toISOString(),\n };\n store.replace([...records, record]);\n return record;\n },\n\n async close(context, id, force) {\n const { root, store, records } = await resolve(context);\n const record = require(records, id);\n requireOwner(context, record, 'close');\n const dirtyFiles = fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [];\n const refusal = refuseClose({ record, dirtyFiles, force });\n if (refusal !== undefined) {\n throw new DoomGitExpectedError('worktree_dirty', refusal, false, 'Commit the work, or pass force: true.');\n }\n // The session is stopped before the directory goes. Removing a checkout\n // while a process still holds files open in it is how a half-removed\n // worktree and a stale git administrative entry are made.\n await stopSession(registryDir, record.sessionId);\n await git.removeWorktree({ repositoryRoot: root, path: record.path, force });\n store.replace(records.filter((entry) => entry.id !== id));\n return record;\n },\n\n async list(context) {\n const { store, records } = await resolve(context);\n const outcome = reconcile(records, probe);\n if (outcome.changed.length > 0) store.replace(outcome.records);\n return outcome.records;\n },\n\n async status(context, id) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const dirtyFiles = fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [];\n return { record, dirtyFiles };\n },\n\n async merge(context, id, message) {\n const { root, records } = await resolve(context);\n const record = require(records, id);\n requireOwner(context, record, 'merge');\n // The merge lands in the parent, so the parent is what must be clean. A\n // dirty parent would mix the person's in-flight edits into a merge commit\n // they did not intend to make.\n const parentDirty = await git.dirtyFiles(root);\n if (parentDirty.length > 0) {\n throw new DoomGitExpectedError(\n 'worktree_dirty',\n `The parent checkout has ${String(parentDirty.length)} uncommitted file(s): ${parentDirty.slice(0, 10).join(', ')}.`,\n false,\n 'Commit or stash the parent checkout first.',\n );\n }\n await git.mergeBranch({\n repositoryRoot: root,\n branch: record.branch,\n ...(message === undefined ? {} : { message }),\n });\n return record;\n },\n\n /**\n * Which side of the channel the caller is.\n *\n * A worktree session is the child of the record it belongs to, and the\n * parent for everything else. Deciding from the session id rather than a\n * flag means neither side can address itself by mistake.\n */\n async send(context, id, message) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const party: ChannelParty = context.sessionId === record.sessionId ? 'parent' : 'child';\n createWorktreeChannel(channelRoot(record.id, deps.homeDir)).send(party, message);\n },\n\n async messages(context, id) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const channel = createWorktreeChannel(channelRoot(record.id, deps.homeDir));\n channel.sweep();\n const party: ChannelParty = context.sessionId === record.sessionId ? 'child' : 'parent';\n return channel.receive(party);\n },\n\n async prune(context, dryRun) {\n const { root, store, records } = await resolve(context);\n const reconciled = reconcile(records, probe);\n const gitPaths = await git.listWorktreePaths(root);\n const dirtyByPath = new Map<string, readonly string[]>();\n for (const record of reconciled.records) {\n if (record.status === 'orphaned' && fs.existsSync(record.path)) {\n dirtyByPath.set(record.path, await git.dirtyFiles(record.path));\n }\n }\n const plan = planPrune({\n records: reconciled.records,\n gitPaths,\n worktreesRoot: worktreesRoot(deps.homeDir),\n dirtyByPath,\n });\n if (dryRun) {\n store.replace(reconciled.records);\n return plan;\n }\n for (const record of plan.remove) {\n await git.removeWorktree({ repositoryRoot: root, path: record.path, force: false });\n }\n const dropped = new Set([...plan.remove, ...plan.forget].map((record) => record.id));\n store.replace(reconciled.records.filter((record) => !dropped.has(record.id)));\n return plan;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{installDependencies as e}from"./dependencyInstall.mjs";import{HubUnavailableError as t,createWorktreeSession as n,sessionIsLive as r,stopWorktreeSession as i}from"../hub/hubClient.mjs";import{repositoryId as a,repositoryLabel as o,shortId as s}from"./repositoryIdentity.mjs";import{channelRoot as c,hubRegistryDir as l,registryFile as u,worktreesRoot as d}from"../filesystem/paths.mjs";import{createWorktreeChannel as f}from"../channel/worktreeChannel.mjs";import{createWorktreeRegistry as p}from"./worktreeRegistry.mjs";import{planPrune as m,reconcile as h,refuseClose as g,refuseSpawn as _,worktreeDirectory as v}from"../../services/worktreeNaming.mjs";import{DoomGitExpectedError as y}from"../../services/support/errors.mjs";import b from"node:fs";import{randomUUID as x}from"node:crypto";function S(S){let{git:C}=S,w=S.createSession??n,T=S.install??e,E=S.stopSession??i,D=S.isSessionLive??r,O=S.registryDir??l(),k=S.now??(()=>new Date),A={alive:e=>D(O,e.sessionId),exists:e=>b.existsSync(e)},j=async e=>{let t=await C.repositoryRoot(e.cwd);if(t===void 0)throw new y(`not_a_repository`,`${e.cwd} is not inside a git repository.`,!1,`Run this from a repository checkout.`);let n=p(u(t,S.homeDir));return{root:t,store:n,records:n.list()}},M=(e,t)=>{let n=e.find(e=>e.id===t);if(n===void 0)throw new y(`worktree_not_found`,`No worktree with id ${t}.`,!1,`Call list to see the current ids.`);return n};return{async spawn(e,n){let{root:r,store:i,records:c}=await j(e),l=_({branch:n.branch,existing:c});if(l!==void 0)throw new y(`worktree_exists`,l,!1,`Pick another branch name, or close it.`);let u=n.baseRef??await C.currentBranch(r)??`HEAD`,f=s(x()),p=v({worktreesRoot:d(S.homeDir),repositoryLabel:o(r),repositoryId:a(r),branch:n.branch,shortId:f});await C.addWorktree({repositoryRoot:r,path:p,branch:n.branch,baseRef:u});let m=await T(p);if(m.kind===`failed`)throw await C.removeWorktree({repositoryRoot:r,path:p,force:!0}).catch(()=>void 0),new y(`install_failed`,`${m.command} install failed in the new worktree (exit ${String(m.code??`unknown`)}).`,!0,`Run the install by hand in the repository to see what it reports, then try again.`);let h;try{h=await w({cwd:p,name:n.name??n.branch,parentSessionId:e.sessionId,registryDir:O})}catch(e){throw await C.removeWorktree({repositoryRoot:r,path:p,force:!0}).catch(()=>void 0),e instanceof t?new y(`hub_unavailable`,e.message,!0,`Start the cockpit and try again.`):e}let g={version:1,id:f,branch:n.branch,baseRef:u,path:p,repositoryRoot:r,sessionId:h,parentSessionId:e.sessionId,status:`running`,createdAt:k().toISOString()};return i.replace([...c,g]),g},async close(e,t,n){let{root:r,store:i,records:a}=await j(e),o=M(a,t),s=b.existsSync(o.path)?await C.dirtyFiles(o.path):[],c=g({record:o,dirtyFiles:s,force:n});if(c!==void 0)throw new y(`worktree_dirty`,c,!1,`Commit the work, or pass force: true.`);return await E(O,o.sessionId),await C.removeWorktree({repositoryRoot:r,path:o.path,force:n}),i.replace(a.filter(e=>e.id!==t)),o},async list(e){let{store:t,records:n}=await j(e),r=h(n,A);return r.changed.length>0&&t.replace(r.records),r.records},async status(e,t){let{records:n}=await j(e),r=M(n,t);return{record:r,dirtyFiles:b.existsSync(r.path)?await C.dirtyFiles(r.path):[]}},async merge(e,t,n){let{root:r,records:i}=await j(e),a=M(i,t),o=await C.dirtyFiles(r);if(o.length>0)throw new y(`worktree_dirty`,`The parent checkout has ${String(o.length)} uncommitted file(s): ${o.slice(0,10).join(`, `)}.`,!1,`Commit or stash the parent checkout first.`);return await C.mergeBranch({repositoryRoot:r,branch:a.branch,...n===void 0?{}:{message:n}}),a},async send(e,t,n){let{records:r}=await j(e),i=M(r,t),a=e.sessionId===i.sessionId?`parent`:`child`;f(c(i.id,S.homeDir)).send(a,n)},async messages(e,t){let{records:n}=await j(e),r=M(n,t),i=f(c(r.id,S.homeDir));i.sweep();let a=e.sessionId===r.sessionId?`child`:`parent`;return i.receive(a)},async prune(e,t){let{root:n,store:r,records:i}=await j(e),a=h(i,A),o=await C.listWorktreePaths(n),s=new Map;for(let e of a.records)e.status===`orphaned`&&b.existsSync(e.path)&&s.set(e.path,await C.dirtyFiles(e.path));let c=m({records:a.records,gitPaths:o,worktreesRoot:d(S.homeDir),dirtyByPath:s});if(t)return r.replace(a.records),c;for(let e of c.remove)await C.removeWorktree({repositoryRoot:n,path:e.path,force:!1});let l=new Set([...c.remove,...c.forget].map(e=>e.id));return r.replace(a.records.filter(e=>!l.has(e.id))),c}}}export{S as createWorktreeOperations};
|
|
1
|
+
import{installDependencies as e}from"./dependencyInstall.mjs";import{HubUnavailableError as t,createWorktreeSession as n,sessionIsLive as r,stopWorktreeSession as i}from"../hub/hubClient.mjs";import{repositoryId as a,repositoryLabel as o,shortId as s}from"./repositoryIdentity.mjs";import{channelRoot as c,hubRegistryDir as l,registryFile as u,worktreesRoot as d}from"../filesystem/paths.mjs";import{createWorktreeChannel as f}from"../channel/worktreeChannel.mjs";import{createWorktreeRegistry as p}from"./worktreeRegistry.mjs";import{planPrune as m,reconcile as h,refuseClose as g,refuseSpawn as _,worktreeDirectory as v}from"../../services/worktreeNaming.mjs";import{DoomGitExpectedError as y}from"../../services/support/errors.mjs";import b from"node:fs";import{randomUUID as x}from"node:crypto";function S(S){let{git:C}=S,w=S.createSession??n,T=S.install??e,E=S.stopSession??i,D=S.isSessionLive??r,O=S.registryDir??l(),k=S.now??(()=>new Date),A={alive:e=>D(O,e.sessionId),exists:e=>b.existsSync(e)},j=async e=>{let t=await C.repositoryRoot(e.cwd);if(t===void 0)throw new y(`not_a_repository`,`${e.cwd} is not inside a git repository.`,!1,`Run this from a repository checkout.`);let n=p(u(t,S.homeDir));return{root:t,store:n,records:n.list()}},M=(e,t)=>{let n=e.find(e=>e.id===t);if(n===void 0)throw new y(`worktree_not_found`,`No worktree with id ${t}.`,!1,`Call list to see the current ids.`);return n},N=(e,t,n)=>{if(t.parentSessionId!==e.sessionId&&D(O,t.parentSessionId))throw new y(`worktree_not_owned`,`Worktree ${t.id} belongs to another session.`,!1,`Ask the session that created it to ${n} it, or close that session first.`)};return{async spawn(e,n){let{root:r,store:i,records:c}=await j(e),l=_({branch:n.branch,existing:c});if(l!==void 0)throw new y(`worktree_exists`,l,!1,`Pick another branch name, or close it.`);let u=n.baseRef??await C.currentBranch(r)??`HEAD`,f=s(x()),p=v({worktreesRoot:d(S.homeDir),repositoryLabel:o(r),repositoryId:a(r),branch:n.branch,shortId:f});await C.addWorktree({repositoryRoot:r,path:p,branch:n.branch,baseRef:u});let m=await T(p);if(m.kind===`failed`)throw await C.removeWorktree({repositoryRoot:r,path:p,force:!0}).catch(()=>void 0),new y(`install_failed`,`${m.command} install failed in the new worktree (exit ${String(m.code??`unknown`)}).`,!0,`Run the install by hand in the repository to see what it reports, then try again.`);let h;try{h=await w({cwd:p,name:n.name??n.branch,parentSessionId:e.sessionId,registryDir:O})}catch(e){throw await C.removeWorktree({repositoryRoot:r,path:p,force:!0}).catch(()=>void 0),e instanceof t?new y(`hub_unavailable`,e.message,!0,`Start the cockpit and try again.`):e}let g={version:1,id:f,branch:n.branch,baseRef:u,path:p,repositoryRoot:r,sessionId:h,parentSessionId:e.sessionId,status:`running`,createdAt:k().toISOString()};return i.replace([...c,g]),g},async close(e,t,n){let{root:r,store:i,records:a}=await j(e),o=M(a,t);N(e,o,`close`);let s=b.existsSync(o.path)?await C.dirtyFiles(o.path):[],c=g({record:o,dirtyFiles:s,force:n});if(c!==void 0)throw new y(`worktree_dirty`,c,!1,`Commit the work, or pass force: true.`);return await E(O,o.sessionId),await C.removeWorktree({repositoryRoot:r,path:o.path,force:n}),i.replace(a.filter(e=>e.id!==t)),o},async list(e){let{store:t,records:n}=await j(e),r=h(n,A);return r.changed.length>0&&t.replace(r.records),r.records},async status(e,t){let{records:n}=await j(e),r=M(n,t);return{record:r,dirtyFiles:b.existsSync(r.path)?await C.dirtyFiles(r.path):[]}},async merge(e,t,n){let{root:r,records:i}=await j(e),a=M(i,t);N(e,a,`merge`);let o=await C.dirtyFiles(r);if(o.length>0)throw new y(`worktree_dirty`,`The parent checkout has ${String(o.length)} uncommitted file(s): ${o.slice(0,10).join(`, `)}.`,!1,`Commit or stash the parent checkout first.`);return await C.mergeBranch({repositoryRoot:r,branch:a.branch,...n===void 0?{}:{message:n}}),a},async send(e,t,n){let{records:r}=await j(e),i=M(r,t),a=e.sessionId===i.sessionId?`parent`:`child`;f(c(i.id,S.homeDir)).send(a,n)},async messages(e,t){let{records:n}=await j(e),r=M(n,t),i=f(c(r.id,S.homeDir));i.sweep();let a=e.sessionId===r.sessionId?`child`:`parent`;return i.receive(a)},async prune(e,t){let{root:n,store:r,records:i}=await j(e),a=h(i,A),o=await C.listWorktreePaths(n),s=new Map;for(let e of a.records)e.status===`orphaned`&&b.existsSync(e.path)&&s.set(e.path,await C.dirtyFiles(e.path));let c=m({records:a.records,gitPaths:o,worktreesRoot:d(S.homeDir),dirtyByPath:s});if(t)return r.replace(a.records),c;for(let e of c.remove)await C.removeWorktree({repositoryRoot:n,path:e.path,force:!1});let l=new Set([...c.remove,...c.forget].map(e=>e.id));return r.replace(a.records.filter(e=>!l.has(e.id))),c}}}export{S as createWorktreeOperations};
|
|
2
2
|
//# sourceMappingURL=worktreeOperations.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktreeOperations.mjs","names":[],"sources":["../../../src/adapters/worktree/worktreeOperations.ts"],"sourcesContent":["import fs from 'node:fs';\nimport { randomUUID } from 'node:crypto';\nimport { installDependencies } from './dependencyInstall.ts';\nimport { createWorktreeSession, HubUnavailableError, sessionIsLive, stopWorktreeSession } from '../hub/hubClient.ts';\nimport { channelRoot, hubRegistryDir, registryFile, worktreesRoot } from '../filesystem/paths.ts';\nimport { repositoryId, repositoryLabel, shortId } from './repositoryIdentity.ts';\nimport { createWorktreeChannel } from '../channel/worktreeChannel.ts';\nimport type { ChannelMessage, ChannelParty } from '../channel/worktreeChannel.ts';\nimport { createWorktreeRegistry } from './worktreeRegistry.ts';\nimport { WORKTREE_RECORD_VERSION } from '../../types/worktreeRegistry.ts';\nimport type { WorktreeGit, WorktreeRecord } from '../../types/worktreeRegistry.ts';\nimport { planPrune, reconcile, refuseClose, refuseSpawn, worktreeDirectory } from '../../services/worktreeNaming.ts';\nimport { DoomGitExpectedError } from '../../services/support/errors.ts';\n\nexport interface WorktreeContext {\n /** Where the calling session is working, used to find the repository. */\n cwd: string;\n /** The calling session, recorded as the new session's parent. */\n sessionId: string;\n}\n\nexport interface SpawnWorktreeRequest {\n branch: string;\n baseRef?: string;\n name?: string;\n}\n\nexport interface WorktreeOperations {\n spawn(context: WorktreeContext, request: SpawnWorktreeRequest): Promise<WorktreeRecord>;\n close(context: WorktreeContext, id: string, force: boolean): Promise<WorktreeRecord>;\n list(context: WorktreeContext): Promise<WorktreeRecord[]>;\n status(context: WorktreeContext, id: string): Promise<{ record: WorktreeRecord; dirtyFiles: string[] }>;\n merge(context: WorktreeContext, id: string, message?: string): Promise<WorktreeRecord>;\n prune(context: WorktreeContext, dryRun: boolean): Promise<ReturnType<typeof planPrune>>;\n send(context: WorktreeContext, id: string, message: string): Promise<void>;\n messages(context: WorktreeContext, id: string): Promise<ChannelMessage[]>;\n}\n\nexport interface WorktreeOperationsDeps {\n git: WorktreeGit;\n /** Injected so tests never reach the network. */\n createSession?: typeof createWorktreeSession;\n /** Injected so tests never run a package manager. */\n install?: typeof installDependencies;\n stopSession?: typeof stopWorktreeSession;\n isSessionLive?: typeof sessionIsLive;\n homeDir?: string;\n registryDir?: string;\n /** Injected so a record's createdAt is reproducible in a test. */\n now?: () => Date;\n}\n\nexport function createWorktreeOperations(deps: WorktreeOperationsDeps): WorktreeOperations {\n const { git } = deps;\n const createSession = deps.createSession ?? createWorktreeSession;\n const install = deps.install ?? installDependencies;\n const stopSession = deps.stopSession ?? stopWorktreeSession;\n const isSessionLive = deps.isSessionLive ?? sessionIsLive;\n const registryDir = deps.registryDir ?? hubRegistryDir();\n const now = deps.now ?? (() => new Date());\n const probe = {\n alive: (record: WorktreeRecord) => isSessionLive(registryDir, record.sessionId),\n exists: (path: string) => fs.existsSync(path),\n };\n\n /**\n * Everything an action needs about the repository it is acting on.\n *\n * Resolved per call rather than cached: a session's cwd is stable, but the\n * registry file is shared with the cockpit panel and another process may\n * have written it since the last call.\n */\n const resolve = async (context: WorktreeContext) => {\n const root = await git.repositoryRoot(context.cwd);\n if (root === undefined) {\n throw new DoomGitExpectedError(\n 'not_a_repository',\n `${context.cwd} is not inside a git repository.`,\n false,\n 'Run this from a repository checkout.',\n );\n }\n const store = createWorktreeRegistry(registryFile(root, deps.homeDir));\n return { root, store, records: store.list() };\n };\n\n const require = (records: readonly WorktreeRecord[], id: string): WorktreeRecord => {\n const record = records.find((entry) => entry.id === id);\n if (record === undefined) {\n throw new DoomGitExpectedError(\n 'worktree_not_found',\n `No worktree with id ${id}.`,\n false,\n 'Call list to see the current ids.',\n );\n }\n return record;\n };\n\n return {\n async spawn(context, request) {\n const { root, store, records } = await resolve(context);\n const refusal = refuseSpawn({ branch: request.branch, existing: records });\n if (refusal !== undefined) {\n throw new DoomGitExpectedError('worktree_exists', refusal, false, 'Pick another branch name, or close it.');\n }\n const baseRef = request.baseRef ?? (await git.currentBranch(root)) ?? 'HEAD';\n const id = shortId(randomUUID());\n const path = worktreeDirectory({\n worktreesRoot: worktreesRoot(deps.homeDir),\n repositoryLabel: repositoryLabel(root),\n repositoryId: repositoryId(root),\n branch: request.branch,\n shortId: id,\n });\n\n await git.addWorktree({ repositoryRoot: root, path, branch: request.branch, baseRef });\n\n // Before the session, not after. The session resolves this repository's\n // composition, and every workspace package path in it points at a\n // directory that exists only once dependencies are linked. Starting the\n // session first makes it fall back to the global bundle and quietly lose\n // the repository's own packages.\n const installed = await install(path);\n if (installed.kind === 'failed') {\n await git.removeWorktree({ repositoryRoot: root, path, force: true }).catch(() => undefined);\n throw new DoomGitExpectedError(\n 'install_failed',\n `${installed.command} install failed in the new worktree (exit ${String(installed.code ?? 'unknown')}).`,\n true,\n 'Run the install by hand in the repository to see what it reports, then try again.',\n );\n }\n\n // The worktree exists from here on, so every later failure has to leave\n // it removed or recorded. An unrecorded directory on disk is the one\n // outcome with no way back: nothing would ever list it again.\n let sessionId: string;\n try {\n sessionId = await createSession({\n cwd: path,\n name: request.name ?? request.branch,\n parentSessionId: context.sessionId,\n registryDir,\n });\n } catch (error) {\n await git.removeWorktree({ repositoryRoot: root, path, force: true }).catch(() => undefined);\n if (error instanceof HubUnavailableError) {\n throw new DoomGitExpectedError('hub_unavailable', error.message, true, 'Start the cockpit and try again.');\n }\n throw error;\n }\n\n const record: WorktreeRecord = {\n version: WORKTREE_RECORD_VERSION,\n id,\n branch: request.branch,\n baseRef,\n path,\n repositoryRoot: root,\n sessionId,\n parentSessionId: context.sessionId,\n status: 'running',\n createdAt: now().toISOString(),\n };\n store.replace([...records, record]);\n return record;\n },\n\n async close(context, id, force) {\n const { root, store, records } = await resolve(context);\n const record = require(records, id);\n const dirtyFiles = fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [];\n const refusal = refuseClose({ record, dirtyFiles, force });\n if (refusal !== undefined) {\n throw new DoomGitExpectedError('worktree_dirty', refusal, false, 'Commit the work, or pass force: true.');\n }\n // The session is stopped before the directory goes. Removing a checkout\n // while a process still holds files open in it is how a half-removed\n // worktree and a stale git administrative entry are made.\n await stopSession(registryDir, record.sessionId);\n await git.removeWorktree({ repositoryRoot: root, path: record.path, force });\n store.replace(records.filter((entry) => entry.id !== id));\n return record;\n },\n\n async list(context) {\n const { store, records } = await resolve(context);\n const outcome = reconcile(records, probe);\n if (outcome.changed.length > 0) store.replace(outcome.records);\n return outcome.records;\n },\n\n async status(context, id) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const dirtyFiles = fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [];\n return { record, dirtyFiles };\n },\n\n async merge(context, id, message) {\n const { root, records } = await resolve(context);\n const record = require(records, id);\n // The merge lands in the parent, so the parent is what must be clean. A\n // dirty parent would mix the person's in-flight edits into a merge commit\n // they did not intend to make.\n const parentDirty = await git.dirtyFiles(root);\n if (parentDirty.length > 0) {\n throw new DoomGitExpectedError(\n 'worktree_dirty',\n `The parent checkout has ${String(parentDirty.length)} uncommitted file(s): ${parentDirty.slice(0, 10).join(', ')}.`,\n false,\n 'Commit or stash the parent checkout first.',\n );\n }\n await git.mergeBranch({\n repositoryRoot: root,\n branch: record.branch,\n ...(message === undefined ? {} : { message }),\n });\n return record;\n },\n\n /**\n * Which side of the channel the caller is.\n *\n * A worktree session is the child of the record it belongs to, and the\n * parent for everything else. Deciding from the session id rather than a\n * flag means neither side can address itself by mistake.\n */\n async send(context, id, message) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const party: ChannelParty = context.sessionId === record.sessionId ? 'parent' : 'child';\n createWorktreeChannel(channelRoot(record.id, deps.homeDir)).send(party, message);\n },\n\n async messages(context, id) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const channel = createWorktreeChannel(channelRoot(record.id, deps.homeDir));\n channel.sweep();\n const party: ChannelParty = context.sessionId === record.sessionId ? 'child' : 'parent';\n return channel.receive(party);\n },\n\n async prune(context, dryRun) {\n const { root, store, records } = await resolve(context);\n const reconciled = reconcile(records, probe);\n const gitPaths = await git.listWorktreePaths(root);\n const dirtyByPath = new Map<string, readonly string[]>();\n for (const record of reconciled.records) {\n if (record.status === 'orphaned' && fs.existsSync(record.path)) {\n dirtyByPath.set(record.path, await git.dirtyFiles(record.path));\n }\n }\n const plan = planPrune({\n records: reconciled.records,\n gitPaths,\n worktreesRoot: worktreesRoot(deps.homeDir),\n dirtyByPath,\n });\n if (dryRun) {\n store.replace(reconciled.records);\n return plan;\n }\n for (const record of plan.remove) {\n await git.removeWorktree({ repositoryRoot: root, path: record.path, force: false });\n }\n const dropped = new Set([...plan.remove, ...plan.forget].map((record) => record.id));\n store.replace(reconciled.records.filter((record) => !dropped.has(record.id)));\n return plan;\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"worktreeOperations.mjs","names":[],"sources":["../../../src/adapters/worktree/worktreeOperations.ts"],"sourcesContent":["import fs from 'node:fs';\nimport { randomUUID } from 'node:crypto';\nimport { installDependencies } from './dependencyInstall.ts';\nimport { createWorktreeSession, HubUnavailableError, sessionIsLive, stopWorktreeSession } from '../hub/hubClient.ts';\nimport { channelRoot, hubRegistryDir, registryFile, worktreesRoot } from '../filesystem/paths.ts';\nimport { repositoryId, repositoryLabel, shortId } from './repositoryIdentity.ts';\nimport { createWorktreeChannel } from '../channel/worktreeChannel.ts';\nimport type { ChannelMessage, ChannelParty } from '../channel/worktreeChannel.ts';\nimport { createWorktreeRegistry } from './worktreeRegistry.ts';\nimport { WORKTREE_RECORD_VERSION } from '../../types/worktreeRegistry.ts';\nimport type { WorktreeGit, WorktreeRecord } from '../../types/worktreeRegistry.ts';\nimport { planPrune, reconcile, refuseClose, refuseSpawn, worktreeDirectory } from '../../services/worktreeNaming.ts';\nimport { DoomGitExpectedError } from '../../services/support/errors.ts';\n\nexport interface WorktreeContext {\n /** Where the calling session is working, used to find the repository. */\n cwd: string;\n /** The calling session, recorded as the new session's parent. */\n sessionId: string;\n}\n\nexport interface SpawnWorktreeRequest {\n branch: string;\n baseRef?: string;\n name?: string;\n}\n\nexport interface WorktreeOperations {\n spawn(context: WorktreeContext, request: SpawnWorktreeRequest): Promise<WorktreeRecord>;\n close(context: WorktreeContext, id: string, force: boolean): Promise<WorktreeRecord>;\n list(context: WorktreeContext): Promise<WorktreeRecord[]>;\n status(context: WorktreeContext, id: string): Promise<{ record: WorktreeRecord; dirtyFiles: string[] }>;\n merge(context: WorktreeContext, id: string, message?: string): Promise<WorktreeRecord>;\n prune(context: WorktreeContext, dryRun: boolean): Promise<ReturnType<typeof planPrune>>;\n send(context: WorktreeContext, id: string, message: string): Promise<void>;\n messages(context: WorktreeContext, id: string): Promise<ChannelMessage[]>;\n}\n\nexport interface WorktreeOperationsDeps {\n git: WorktreeGit;\n /** Injected so tests never reach the network. */\n createSession?: typeof createWorktreeSession;\n /** Injected so tests never run a package manager. */\n install?: typeof installDependencies;\n stopSession?: typeof stopWorktreeSession;\n isSessionLive?: typeof sessionIsLive;\n homeDir?: string;\n registryDir?: string;\n /** Injected so a record's createdAt is reproducible in a test. */\n now?: () => Date;\n}\n\nexport function createWorktreeOperations(deps: WorktreeOperationsDeps): WorktreeOperations {\n const { git } = deps;\n const createSession = deps.createSession ?? createWorktreeSession;\n const install = deps.install ?? installDependencies;\n const stopSession = deps.stopSession ?? stopWorktreeSession;\n const isSessionLive = deps.isSessionLive ?? sessionIsLive;\n const registryDir = deps.registryDir ?? hubRegistryDir();\n const now = deps.now ?? (() => new Date());\n const probe = {\n alive: (record: WorktreeRecord) => isSessionLive(registryDir, record.sessionId),\n exists: (path: string) => fs.existsSync(path),\n };\n\n /**\n * Everything an action needs about the repository it is acting on.\n *\n * Resolved per call rather than cached: a session's cwd is stable, but the\n * registry file is shared with the cockpit panel and another process may\n * have written it since the last call.\n */\n const resolve = async (context: WorktreeContext) => {\n const root = await git.repositoryRoot(context.cwd);\n if (root === undefined) {\n throw new DoomGitExpectedError(\n 'not_a_repository',\n `${context.cwd} is not inside a git repository.`,\n false,\n 'Run this from a repository checkout.',\n );\n }\n const store = createWorktreeRegistry(registryFile(root, deps.homeDir));\n return { root, store, records: store.list() };\n };\n\n const require = (records: readonly WorktreeRecord[], id: string): WorktreeRecord => {\n const record = records.find((entry) => entry.id === id);\n if (record === undefined) {\n throw new DoomGitExpectedError(\n 'worktree_not_found',\n `No worktree with id ${id}.`,\n false,\n 'Call list to see the current ids.',\n );\n }\n return record;\n };\n\n /**\n * Refuses to destroy or merge a worktree another live session is using.\n *\n * `parentSessionId` records who asked for the worktree. Two sessions in one\n * checkout share this registry, so without the check either could close the\n * other's work in progress by id.\n *\n * Once that session is gone there is no owner left to protect, and refusing\n * would strand the worktree: nothing else could ever close it.\n */\n const requireOwner = (context: WorktreeContext, record: WorktreeRecord, action: string): void => {\n if (record.parentSessionId === context.sessionId) return;\n if (!isSessionLive(registryDir, record.parentSessionId)) return;\n throw new DoomGitExpectedError(\n 'worktree_not_owned',\n `Worktree ${record.id} belongs to another session.`,\n false,\n `Ask the session that created it to ${action} it, or close that session first.`,\n );\n };\n\n return {\n async spawn(context, request) {\n const { root, store, records } = await resolve(context);\n const refusal = refuseSpawn({ branch: request.branch, existing: records });\n if (refusal !== undefined) {\n throw new DoomGitExpectedError('worktree_exists', refusal, false, 'Pick another branch name, or close it.');\n }\n const baseRef = request.baseRef ?? (await git.currentBranch(root)) ?? 'HEAD';\n const id = shortId(randomUUID());\n const path = worktreeDirectory({\n worktreesRoot: worktreesRoot(deps.homeDir),\n repositoryLabel: repositoryLabel(root),\n repositoryId: repositoryId(root),\n branch: request.branch,\n shortId: id,\n });\n\n await git.addWorktree({ repositoryRoot: root, path, branch: request.branch, baseRef });\n\n // Before the session, not after. The session resolves this repository's\n // composition, and every workspace package path in it points at a\n // directory that exists only once dependencies are linked. Starting the\n // session first makes it fall back to the global bundle and quietly lose\n // the repository's own packages.\n const installed = await install(path);\n if (installed.kind === 'failed') {\n await git.removeWorktree({ repositoryRoot: root, path, force: true }).catch(() => undefined);\n throw new DoomGitExpectedError(\n 'install_failed',\n `${installed.command} install failed in the new worktree (exit ${String(installed.code ?? 'unknown')}).`,\n true,\n 'Run the install by hand in the repository to see what it reports, then try again.',\n );\n }\n\n // The worktree exists from here on, so every later failure has to leave\n // it removed or recorded. An unrecorded directory on disk is the one\n // outcome with no way back: nothing would ever list it again.\n let sessionId: string;\n try {\n sessionId = await createSession({\n cwd: path,\n name: request.name ?? request.branch,\n parentSessionId: context.sessionId,\n registryDir,\n });\n } catch (error) {\n await git.removeWorktree({ repositoryRoot: root, path, force: true }).catch(() => undefined);\n if (error instanceof HubUnavailableError) {\n throw new DoomGitExpectedError('hub_unavailable', error.message, true, 'Start the cockpit and try again.');\n }\n throw error;\n }\n\n const record: WorktreeRecord = {\n version: WORKTREE_RECORD_VERSION,\n id,\n branch: request.branch,\n baseRef,\n path,\n repositoryRoot: root,\n sessionId,\n parentSessionId: context.sessionId,\n status: 'running',\n createdAt: now().toISOString(),\n };\n store.replace([...records, record]);\n return record;\n },\n\n async close(context, id, force) {\n const { root, store, records } = await resolve(context);\n const record = require(records, id);\n requireOwner(context, record, 'close');\n const dirtyFiles = fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [];\n const refusal = refuseClose({ record, dirtyFiles, force });\n if (refusal !== undefined) {\n throw new DoomGitExpectedError('worktree_dirty', refusal, false, 'Commit the work, or pass force: true.');\n }\n // The session is stopped before the directory goes. Removing a checkout\n // while a process still holds files open in it is how a half-removed\n // worktree and a stale git administrative entry are made.\n await stopSession(registryDir, record.sessionId);\n await git.removeWorktree({ repositoryRoot: root, path: record.path, force });\n store.replace(records.filter((entry) => entry.id !== id));\n return record;\n },\n\n async list(context) {\n const { store, records } = await resolve(context);\n const outcome = reconcile(records, probe);\n if (outcome.changed.length > 0) store.replace(outcome.records);\n return outcome.records;\n },\n\n async status(context, id) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const dirtyFiles = fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [];\n return { record, dirtyFiles };\n },\n\n async merge(context, id, message) {\n const { root, records } = await resolve(context);\n const record = require(records, id);\n requireOwner(context, record, 'merge');\n // The merge lands in the parent, so the parent is what must be clean. A\n // dirty parent would mix the person's in-flight edits into a merge commit\n // they did not intend to make.\n const parentDirty = await git.dirtyFiles(root);\n if (parentDirty.length > 0) {\n throw new DoomGitExpectedError(\n 'worktree_dirty',\n `The parent checkout has ${String(parentDirty.length)} uncommitted file(s): ${parentDirty.slice(0, 10).join(', ')}.`,\n false,\n 'Commit or stash the parent checkout first.',\n );\n }\n await git.mergeBranch({\n repositoryRoot: root,\n branch: record.branch,\n ...(message === undefined ? {} : { message }),\n });\n return record;\n },\n\n /**\n * Which side of the channel the caller is.\n *\n * A worktree session is the child of the record it belongs to, and the\n * parent for everything else. Deciding from the session id rather than a\n * flag means neither side can address itself by mistake.\n */\n async send(context, id, message) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const party: ChannelParty = context.sessionId === record.sessionId ? 'parent' : 'child';\n createWorktreeChannel(channelRoot(record.id, deps.homeDir)).send(party, message);\n },\n\n async messages(context, id) {\n const { records } = await resolve(context);\n const record = require(records, id);\n const channel = createWorktreeChannel(channelRoot(record.id, deps.homeDir));\n channel.sweep();\n const party: ChannelParty = context.sessionId === record.sessionId ? 'child' : 'parent';\n return channel.receive(party);\n },\n\n async prune(context, dryRun) {\n const { root, store, records } = await resolve(context);\n const reconciled = reconcile(records, probe);\n const gitPaths = await git.listWorktreePaths(root);\n const dirtyByPath = new Map<string, readonly string[]>();\n for (const record of reconciled.records) {\n if (record.status === 'orphaned' && fs.existsSync(record.path)) {\n dirtyByPath.set(record.path, await git.dirtyFiles(record.path));\n }\n }\n const plan = planPrune({\n records: reconciled.records,\n gitPaths,\n worktreesRoot: worktreesRoot(deps.homeDir),\n dirtyByPath,\n });\n if (dryRun) {\n store.replace(reconciled.records);\n return plan;\n }\n for (const record of plan.remove) {\n await git.removeWorktree({ repositoryRoot: root, path: record.path, force: false });\n }\n const dropped = new Set([...plan.remove, ...plan.forget].map((record) => record.id));\n store.replace(reconciled.records.filter((record) => !dropped.has(record.id)));\n return plan;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.cjs","names":[],"sources":["../../../src/services/support/errors.ts"],"sourcesContent":["/**\n * Expected failures, kept private to this package.\n *\n * `@agimon-ai/doompi-extension-contracts` does not export `invalidRequest` or\n * an expected-error base, so this mirrors doompi-team's shape rather than\n * reaching across a layer boundary for it. The duplication is deliberate and\n * small; a shared helper would be the better answer only once a third package\n * needs one.\n */\n\nexport type DoomGitErrorCode =\n | 'invalid_request'\n | 'not_a_repository'\n | 'worktree_exists'\n | 'worktree_not_found'\n | 'worktree_dirty'\n | 'hub_unavailable'\n | 'install_failed'\n | 'git_failed';\n\nexport class DoomGitExpectedError extends Error {\n constructor(\n readonly code: DoomGitErrorCode,\n message: string,\n readonly retryable: boolean,\n readonly recovery: string,\n ) {\n // The recovery line rides in the message because a tool result is read as\n // text: an error the model cannot act on costs a turn to rediscover.\n super(`[${code}] ${message}\\nRecovery: ${recovery}`);\n this.name = 'DoomGitExpectedError';\n }\n}\n\nexport function invalidRequest(message: string, recovery: string): DoomGitExpectedError {\n return new DoomGitExpectedError('invalid_request', message, false, recovery);\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"errors.cjs","names":[],"sources":["../../../src/services/support/errors.ts"],"sourcesContent":["/**\n * Expected failures, kept private to this package.\n *\n * `@agimon-ai/doompi-extension-contracts` does not export `invalidRequest` or\n * an expected-error base, so this mirrors doompi-team's shape rather than\n * reaching across a layer boundary for it. The duplication is deliberate and\n * small; a shared helper would be the better answer only once a third package\n * needs one.\n */\n\nexport type DoomGitErrorCode =\n | 'invalid_request'\n | 'not_a_repository'\n | 'worktree_exists'\n | 'worktree_not_found'\n | 'worktree_not_owned'\n | 'worktree_dirty'\n | 'hub_unavailable'\n | 'install_failed'\n | 'git_failed';\n\nexport class DoomGitExpectedError extends Error {\n constructor(\n readonly code: DoomGitErrorCode,\n message: string,\n readonly retryable: boolean,\n readonly recovery: string,\n ) {\n // The recovery line rides in the message because a tool result is read as\n // text: an error the model cannot act on costs a turn to rediscover.\n super(`[${code}] ${message}\\nRecovery: ${recovery}`);\n this.name = 'DoomGitExpectedError';\n }\n}\n\nexport function invalidRequest(message: string, recovery: string): DoomGitExpectedError {\n return new DoomGitExpectedError('invalid_request', message, false, recovery);\n}\n"],"mappings":"AAqBA,IAAa,EAAb,cAA0C,KAAM,CAEnC,KAEA,UACA,SAJX,YACE,EACA,EACA,EACA,EACA,CAGA,MAAM,IAAI,EAAK,IAAI,EAAQ,cAAc,GAAU,EAP1C,KAAA,KAAA,EAEA,KAAA,UAAA,EACA,KAAA,SAAA,EAKT,KAAK,KAAO,sBACd,CACF,EAEA,SAAgB,EAAe,EAAiB,EAAwC,CACtF,OAAO,IAAI,EAAqB,kBAAmB,EAAS,GAAO,CAAQ,CAC7E"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.mjs","names":[],"sources":["../../../src/services/support/errors.ts"],"sourcesContent":["/**\n * Expected failures, kept private to this package.\n *\n * `@agimon-ai/doompi-extension-contracts` does not export `invalidRequest` or\n * an expected-error base, so this mirrors doompi-team's shape rather than\n * reaching across a layer boundary for it. The duplication is deliberate and\n * small; a shared helper would be the better answer only once a third package\n * needs one.\n */\n\nexport type DoomGitErrorCode =\n | 'invalid_request'\n | 'not_a_repository'\n | 'worktree_exists'\n | 'worktree_not_found'\n | 'worktree_dirty'\n | 'hub_unavailable'\n | 'install_failed'\n | 'git_failed';\n\nexport class DoomGitExpectedError extends Error {\n constructor(\n readonly code: DoomGitErrorCode,\n message: string,\n readonly retryable: boolean,\n readonly recovery: string,\n ) {\n // The recovery line rides in the message because a tool result is read as\n // text: an error the model cannot act on costs a turn to rediscover.\n super(`[${code}] ${message}\\nRecovery: ${recovery}`);\n this.name = 'DoomGitExpectedError';\n }\n}\n\nexport function invalidRequest(message: string, recovery: string): DoomGitExpectedError {\n return new DoomGitExpectedError('invalid_request', message, false, recovery);\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"errors.mjs","names":[],"sources":["../../../src/services/support/errors.ts"],"sourcesContent":["/**\n * Expected failures, kept private to this package.\n *\n * `@agimon-ai/doompi-extension-contracts` does not export `invalidRequest` or\n * an expected-error base, so this mirrors doompi-team's shape rather than\n * reaching across a layer boundary for it. The duplication is deliberate and\n * small; a shared helper would be the better answer only once a third package\n * needs one.\n */\n\nexport type DoomGitErrorCode =\n | 'invalid_request'\n | 'not_a_repository'\n | 'worktree_exists'\n | 'worktree_not_found'\n | 'worktree_not_owned'\n | 'worktree_dirty'\n | 'hub_unavailable'\n | 'install_failed'\n | 'git_failed';\n\nexport class DoomGitExpectedError extends Error {\n constructor(\n readonly code: DoomGitErrorCode,\n message: string,\n readonly retryable: boolean,\n readonly recovery: string,\n ) {\n // The recovery line rides in the message because a tool result is read as\n // text: an error the model cannot act on costs a turn to rediscover.\n super(`[${code}] ${message}\\nRecovery: ${recovery}`);\n this.name = 'DoomGitExpectedError';\n }\n}\n\nexport function invalidRequest(message: string, recovery: string): DoomGitExpectedError {\n return new DoomGitExpectedError('invalid_request', message, false, recovery);\n}\n"],"mappings":"AAqBA,IAAa,EAAb,cAA0C,KAAM,CAEnC,KAEA,UACA,SAJX,YACE,EACA,EACA,EACA,EACA,CAGA,MAAM,IAAI,EAAK,IAAI,EAAQ,cAAc,GAAU,EAP1C,KAAA,KAAA,EAEA,KAAA,UAAA,EACA,KAAA,SAAA,EAKT,KAAK,KAAO,sBACd,CACF,EAEA,SAAgB,EAAe,EAAiB,EAAwC,CACtF,OAAO,IAAI,EAAqB,kBAAmB,EAAS,GAAO,CAAQ,CAC7E"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webWorktrees.cjs","names":[],"sources":["../../src/types/webWorktrees.ts"],"sourcesContent":["/**\n * The cockpit's view of this session's worktrees.\n *\n * DESIGN PATTERNS:\n * - Declarations only. The channel frame type is a constant so the hub and the\n * page cannot drift apart on a string.\n * - A view is the subset of a record the dock needs, not the record. The\n * registry may hold paths and provenance the page has no business rendering.\n *\n * AVOID:\n * - Adding behaviour here. This file is imported by browser code, so it must\n * stay free of node builtins.\n */\n\n/** The data channel carrying one session's worktrees to the page. */\nexport const GIT_WORKTREES_TYPE = 'git_worktrees';\n\n/** One worktree, as the activity dock and its panel render it. */\nexport interface WorktreeView {\n id: string;\n branch: string;\n path: string;\n /** The session the hub started for this worktree, when it is still live. */\n sessionId: string | null;\n /** True once the directory the record names has gone missing. */\n orphaned: boolean;\n}\n\nexport interface GitWorktreesPayload {\n worktrees: WorktreeView[];\n}\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"webWorktrees.cjs","names":[],"sources":["../../src/types/webWorktrees.ts"],"sourcesContent":["/**\n * The cockpit's view of this session's worktrees.\n *\n * DESIGN PATTERNS:\n * - Declarations only. The channel frame type is a constant so the hub and the\n * page cannot drift apart on a string.\n * - A view is the subset of a record the dock needs, not the record. The\n * registry may hold paths and provenance the page has no business rendering.\n *\n * AVOID:\n * - Adding behaviour here. This file is imported by browser code, so it must\n * stay free of node builtins.\n */\n\n/** The data channel carrying one session's worktrees to the page. */\nexport const GIT_WORKTREES_TYPE = 'git_worktrees';\n\n/** One worktree, as the activity dock and its panel render it. */\nexport interface WorktreeView {\n id: string;\n branch: string;\n path: string;\n /** The session the hub started for this worktree, when it is still live. */\n sessionId: string | null;\n /** True once the directory the record names has gone missing. */\n orphaned: boolean;\n /**\n * True when the session that asked for this worktree is gone.\n *\n * An owned worktree is only ever shown to its own session. This flag marks\n * the exception: once the parent is dead nobody would see the worktree at\n * all, so every session in the repository does, and may close it.\n */\n unowned: boolean;\n}\n\n/**\n * What the panel asks the hub channel to do.\n *\n * The dock acts through its own channel rather than through the agent: a\n * worktree made from the panel and one made by the tool take the same\n * `worktreeOperations` path, but the panel no longer has to spend a turn of\n * the conversation to get there.\n */\nexport type GitWorktreesCommand =\n | { action: 'create'; branch: string; baseRef?: string }\n | { action: 'close'; id: string; force?: boolean };\n\nexport interface GitWorktreesPayload {\n worktrees: WorktreeView[];\n /** Label of the operation in flight, absent when the session is idle. */\n pending?: string;\n /** The last failure, cleared when the next command starts. */\n error?: string;\n}\n"],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webWorktrees.mjs","names":[],"sources":["../../src/types/webWorktrees.ts"],"sourcesContent":["/**\n * The cockpit's view of this session's worktrees.\n *\n * DESIGN PATTERNS:\n * - Declarations only. The channel frame type is a constant so the hub and the\n * page cannot drift apart on a string.\n * - A view is the subset of a record the dock needs, not the record. The\n * registry may hold paths and provenance the page has no business rendering.\n *\n * AVOID:\n * - Adding behaviour here. This file is imported by browser code, so it must\n * stay free of node builtins.\n */\n\n/** The data channel carrying one session's worktrees to the page. */\nexport const GIT_WORKTREES_TYPE = 'git_worktrees';\n\n/** One worktree, as the activity dock and its panel render it. */\nexport interface WorktreeView {\n id: string;\n branch: string;\n path: string;\n /** The session the hub started for this worktree, when it is still live. */\n sessionId: string | null;\n /** True once the directory the record names has gone missing. */\n orphaned: boolean;\n}\n\nexport interface GitWorktreesPayload {\n worktrees: WorktreeView[];\n}\n"],"mappings":"AAeA,MAAa,EAAqB"}
|
|
1
|
+
{"version":3,"file":"webWorktrees.mjs","names":[],"sources":["../../src/types/webWorktrees.ts"],"sourcesContent":["/**\n * The cockpit's view of this session's worktrees.\n *\n * DESIGN PATTERNS:\n * - Declarations only. The channel frame type is a constant so the hub and the\n * page cannot drift apart on a string.\n * - A view is the subset of a record the dock needs, not the record. The\n * registry may hold paths and provenance the page has no business rendering.\n *\n * AVOID:\n * - Adding behaviour here. This file is imported by browser code, so it must\n * stay free of node builtins.\n */\n\n/** The data channel carrying one session's worktrees to the page. */\nexport const GIT_WORKTREES_TYPE = 'git_worktrees';\n\n/** One worktree, as the activity dock and its panel render it. */\nexport interface WorktreeView {\n id: string;\n branch: string;\n path: string;\n /** The session the hub started for this worktree, when it is still live. */\n sessionId: string | null;\n /** True once the directory the record names has gone missing. */\n orphaned: boolean;\n /**\n * True when the session that asked for this worktree is gone.\n *\n * An owned worktree is only ever shown to its own session. This flag marks\n * the exception: once the parent is dead nobody would see the worktree at\n * all, so every session in the repository does, and may close it.\n */\n unowned: boolean;\n}\n\n/**\n * What the panel asks the hub channel to do.\n *\n * The dock acts through its own channel rather than through the agent: a\n * worktree made from the panel and one made by the tool take the same\n * `worktreeOperations` path, but the panel no longer has to spend a turn of\n * the conversation to get there.\n */\nexport type GitWorktreesCommand =\n | { action: 'create'; branch: string; baseRef?: string }\n | { action: 'close'; id: string; force?: boolean };\n\nexport interface GitWorktreesPayload {\n worktrees: WorktreeView[];\n /** Label of the operation in flight, absent when the session is idle. */\n pending?: string;\n /** The last failure, cleared when the next command starts. */\n error?: string;\n}\n"],"mappings":"AAeA,MAAa,EAAqB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agimon-ai/doompi-git",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.3",
|
|
4
4
|
"description": "Git worktree sessions for DoomPi: spawn an isolated worktree with its own session and manage it from the parent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"coding-agent",
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
"@tanstack/react-store": "0.11.1",
|
|
65
65
|
"@tanstack/store": "0.11.1",
|
|
66
66
|
"typebox": "1.3.19",
|
|
67
|
-
"@agimon-ai/doompi-
|
|
68
|
-
"@agimon-ai/doompi-
|
|
69
|
-
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.
|
|
67
|
+
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.66",
|
|
68
|
+
"@agimon-ai/doompi-web-components": "0.0.1-alpha.26",
|
|
69
|
+
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.29"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@earendil-works/pi-agent-core": "0.85.1",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"tsdown": "0.22.14",
|
|
79
79
|
"typescript": "7.0.2",
|
|
80
80
|
"vitest": "5.0.0",
|
|
81
|
-
"@agimon-ai/vibe-lint-plugin-doom-extension": "0.0.1-alpha.
|
|
81
|
+
"@agimon-ai/vibe-lint-plugin-doom-extension": "0.0.1-alpha.63"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
84
|
"@earendil-works/pi-agent-core": "0.85.1",
|
|
@@ -24,8 +24,32 @@ export interface WorktreeView {
|
|
|
24
24
|
sessionId: string | null;
|
|
25
25
|
/** True once the directory the record names has gone missing. */
|
|
26
26
|
orphaned: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* True when the session that asked for this worktree is gone.
|
|
29
|
+
*
|
|
30
|
+
* An owned worktree is only ever shown to its own session. This flag marks
|
|
31
|
+
* the exception: once the parent is dead nobody would see the worktree at
|
|
32
|
+
* all, so every session in the repository does, and may close it.
|
|
33
|
+
*/
|
|
34
|
+
unowned: boolean;
|
|
27
35
|
}
|
|
28
36
|
|
|
37
|
+
/**
|
|
38
|
+
* What the panel asks the hub channel to do.
|
|
39
|
+
*
|
|
40
|
+
* The dock acts through its own channel rather than through the agent: a
|
|
41
|
+
* worktree made from the panel and one made by the tool take the same
|
|
42
|
+
* `worktreeOperations` path, but the panel no longer has to spend a turn of
|
|
43
|
+
* the conversation to get there.
|
|
44
|
+
*/
|
|
45
|
+
export type GitWorktreesCommand =
|
|
46
|
+
| { action: 'create'; branch: string; baseRef?: string }
|
|
47
|
+
| { action: 'close'; id: string; force?: boolean };
|
|
48
|
+
|
|
29
49
|
export interface GitWorktreesPayload {
|
|
30
50
|
worktrees: WorktreeView[];
|
|
51
|
+
/** Label of the operation in flight, absent when the session is idle. */
|
|
52
|
+
pending?: string;
|
|
53
|
+
/** The last failure, cleared when the next command starts. */
|
|
54
|
+
error?: string;
|
|
31
55
|
}
|
|
@@ -22,7 +22,7 @@ import { worktreesTab } from './WorktreesPanel.tsx';
|
|
|
22
22
|
export function WorktreesActivitySection({ sessionId, openTransientTab }: WebPluginSlotProps) {
|
|
23
23
|
const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
|
|
24
24
|
|
|
25
|
-
if (session.worktrees.length === 0) {
|
|
25
|
+
if (session.worktrees.length === 0 && session.pending === undefined) {
|
|
26
26
|
return (
|
|
27
27
|
<div className="flex items-center gap-2 px-1">
|
|
28
28
|
<p data-testid="activity-summary-git" className="px-1 text-[10px] text-doom-faint">
|
|
@@ -45,6 +45,7 @@ export function WorktreesActivitySection({ sessionId, openTransientTab }: WebPlu
|
|
|
45
45
|
|
|
46
46
|
return (
|
|
47
47
|
<div data-testid="activity-git-worktrees" className="flex flex-col gap-0.5">
|
|
48
|
+
{session.pending === undefined ? null : <p className="px-1 text-[10px] text-doom-dim">{session.pending}</p>}
|
|
48
49
|
{session.worktrees.map((worktree: WorktreeView) => (
|
|
49
50
|
<button
|
|
50
51
|
key={worktree.id}
|
|
@@ -55,6 +56,7 @@ export function WorktreesActivitySection({ sessionId, openTransientTab }: WebPlu
|
|
|
55
56
|
>
|
|
56
57
|
<span className="truncate">{worktree.branch}</span>
|
|
57
58
|
{worktree.orphaned ? <span className="text-[10px] text-doom-faint">orphaned</span> : null}
|
|
59
|
+
{worktree.unowned ? <span className="text-[10px] text-doom-faint">unowned</span> : null}
|
|
58
60
|
</button>
|
|
59
61
|
))}
|
|
60
62
|
</div>
|
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* agents, runners and workflows rather than in a settings screen.
|
|
8
8
|
* - Local form state is never reset by a data refresh. The hub republishes this
|
|
9
9
|
* list on a timer, and a half-typed branch name must survive that.
|
|
10
|
-
* - Acts
|
|
11
|
-
* take exactly the same path.
|
|
10
|
+
* - Acts through the plugin's own hub channel, so a worktree made here and one
|
|
11
|
+
* made by the tool take exactly the same path.
|
|
12
|
+
* - Creating takes minutes, so the hub reports progress and failure on that
|
|
13
|
+
* channel and this panel renders both. A button that looks idle for two
|
|
14
|
+
* minutes reads as broken.
|
|
12
15
|
*
|
|
13
16
|
* AVOID:
|
|
14
17
|
* - Deriving form state from props or store data. That is what wiped the
|
|
@@ -27,16 +30,17 @@ export function worktreesTab(): TransientTab {
|
|
|
27
30
|
return { id: WORKTREES_TAB_ID, label: 'worktrees', panel: WorktreesPanel };
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
export function WorktreesPanel({ sessionId
|
|
33
|
+
export function WorktreesPanel({ sessionId }: WebPluginSlotProps) {
|
|
31
34
|
const session = useStore(worktreeActivity.store, (state) => worktreeActivity.select(state, sessionId));
|
|
32
35
|
const [branch, setBranch] = useState('');
|
|
33
36
|
const [baseRef, setBaseRef] = useState('');
|
|
34
37
|
|
|
35
|
-
const
|
|
38
|
+
const busy = session.pending !== undefined;
|
|
39
|
+
const canCreate = sessionId !== null && branch.trim() !== '' && !busy;
|
|
36
40
|
|
|
37
41
|
const create = (): void => {
|
|
38
42
|
if (sessionId === null || !canCreate) return;
|
|
39
|
-
requestWorktreeCreate(
|
|
43
|
+
requestWorktreeCreate(sessionId, branch, baseRef);
|
|
40
44
|
setBranch('');
|
|
41
45
|
setBaseRef('');
|
|
42
46
|
};
|
|
@@ -71,6 +75,16 @@ export function WorktreesPanel({ sessionId, sendSessionFrame }: WebPluginSlotPro
|
|
|
71
75
|
Creates the worktree outside the repository, installs its dependencies, then starts a session nested under
|
|
72
76
|
this one. The install is what makes the wait; a warm store is under a minute.
|
|
73
77
|
</p>
|
|
78
|
+
{session.pending === undefined ? null : (
|
|
79
|
+
<p data-testid="git-worktree-pending" className="text-[10px] text-doom-dim">
|
|
80
|
+
{session.pending}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
{session.error === undefined ? null : (
|
|
84
|
+
<p data-testid="git-worktree-error" className="text-[10px] text-doom-error">
|
|
85
|
+
{session.error}
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
74
88
|
</div>
|
|
75
89
|
|
|
76
90
|
{session.worktrees.length === 0 ? (
|
|
@@ -89,12 +103,14 @@ export function WorktreesPanel({ sessionId, sendSessionFrame }: WebPluginSlotPro
|
|
|
89
103
|
</div>
|
|
90
104
|
<div className="flex items-center gap-2">
|
|
91
105
|
{worktree.orphaned ? <span className="text-[10px] text-doom-faint">orphaned</span> : null}
|
|
106
|
+
{worktree.unowned ? <span className="text-[10px] text-doom-faint">unowned</span> : null}
|
|
92
107
|
{sessionId === null ? null : (
|
|
93
108
|
<Button
|
|
94
109
|
variant="link"
|
|
95
110
|
size="xs"
|
|
111
|
+
disabled={busy}
|
|
96
112
|
data-testid={`git-worktree-close-${worktree.id}`}
|
|
97
|
-
onClick={() => requestWorktreeClose(
|
|
113
|
+
onClick={() => requestWorktreeClose(sessionId, worktree.id)}
|
|
98
114
|
>
|
|
99
115
|
close
|
|
100
116
|
</Button>
|
package/src/web/index.ts
CHANGED
|
@@ -2,12 +2,15 @@ import { defineWebPlugin } from '@agimon-ai/doompi-web-contracts';
|
|
|
2
2
|
import { gitToolRenderers } from './components/toolRenderers.ts';
|
|
3
3
|
import { WorktreesActivitySection } from './components/WorktreesActivitySection.tsx';
|
|
4
4
|
import { worktreesTab } from './components/WorktreesPanel.tsx';
|
|
5
|
-
import { worktreeActivitySource, worktreesChannel } from './stores/worktreesActivityStore.ts';
|
|
5
|
+
import { startWorktreeRuntime, worktreeActivitySource, worktreesChannel } from './stores/worktreesActivityStore.ts';
|
|
6
6
|
|
|
7
7
|
/** The named export the generated plugin registry imports. */
|
|
8
8
|
export const webPlugin = defineWebPlugin({
|
|
9
9
|
id: 'git',
|
|
10
10
|
channels: [worktreesChannel],
|
|
11
|
+
// The panel commands the hub over its own channel, which needs the page's
|
|
12
|
+
// hub socket rather than the per-session frame sender the slots carry.
|
|
13
|
+
start: startWorktreeRuntime,
|
|
11
14
|
activityGroups: [
|
|
12
15
|
{
|
|
13
16
|
name: 'git',
|
|
@@ -1,33 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The dock's view of this session's worktrees.
|
|
2
|
+
* The dock's view of this session's worktrees, and the commands it sends.
|
|
3
3
|
*
|
|
4
4
|
* DESIGN PATTERNS:
|
|
5
5
|
* - Session-scoped store fed by one hub channel. The hub is authoritative; the
|
|
6
6
|
* page never reads a registry and never guesses.
|
|
7
|
-
* - Actions are
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* - Actions are channel frames, not prompts. The hub half owns the tool's
|
|
8
|
+
* operations, so the panel names the work and the hub performs it; a
|
|
9
|
+
* worktree made from the dock is the same object the tool makes, without
|
|
10
|
+
* spending a turn of the conversation to ask for it.
|
|
11
|
+
* - The sender lives in a store rather than a module variable, because a web
|
|
12
|
+
* plugin keeps no mutable module state and the page may reconnect.
|
|
11
13
|
*
|
|
12
14
|
* AVOID:
|
|
13
15
|
* - Calling the hub package API from here. A dock slot gets no sealed request
|
|
14
16
|
* transport, and raw fetch breaks the remote cockpit.
|
|
17
|
+
* - `sendSessionFrame` for a command. It envelopes the frame as a session
|
|
18
|
+
* command bound for the agent, which never reaches a channel's `receive`.
|
|
15
19
|
*/
|
|
16
|
-
import { defineSessionStore, type
|
|
17
|
-
import { GIT_WORKTREES_TYPE, type WorktreeView } from '../../types/webWorktrees.ts';
|
|
20
|
+
import { defineGlobalStore, defineSessionStore, type WebPluginRuntime } from '@agimon-ai/doompi-web-contracts';
|
|
21
|
+
import { GIT_WORKTREES_TYPE, type GitWorktreesCommand, type WorktreeView } from '../../types/webWorktrees.ts';
|
|
18
22
|
|
|
19
23
|
export interface WorktreesSession {
|
|
20
24
|
/** The worktrees the hub last reported for this session's repository. */
|
|
21
25
|
worktrees: WorktreeView[];
|
|
26
|
+
/** Label of the operation the hub is running for this session. */
|
|
27
|
+
pending: string | undefined;
|
|
28
|
+
/** The last failure the hub reported, shown until the next command. */
|
|
29
|
+
error: string | undefined;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
export const worktreeActivity = defineSessionStore<WorktreesSession>({
|
|
32
|
+
export const worktreeActivity = defineSessionStore<WorktreesSession>({
|
|
33
|
+
worktrees: [],
|
|
34
|
+
pending: undefined,
|
|
35
|
+
error: undefined,
|
|
36
|
+
});
|
|
25
37
|
|
|
26
38
|
/**
|
|
27
|
-
*
|
|
39
|
+
* The page's hub socket sender, held for as long as the plugin runs.
|
|
28
40
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
41
|
+
* Undefined before `startWorktreeRuntime` and after it disposes; a command
|
|
42
|
+
* sent in that window is dropped rather than queued, because a queue would
|
|
43
|
+
* replay a worktree create against a socket that has since changed sessions.
|
|
44
|
+
*/
|
|
45
|
+
const worktreeSender = defineGlobalStore<WebPluginRuntime['sendHubFrame'] | undefined>(undefined);
|
|
46
|
+
|
|
47
|
+
/** Binds the page's hub socket. The plugin host calls this once per page. */
|
|
48
|
+
export function startWorktreeRuntime(runtime: WebPluginRuntime): () => void {
|
|
49
|
+
// Wrapped rather than referenced: an unbound method would lose the runtime
|
|
50
|
+
// it belongs to the moment the store hands it back.
|
|
51
|
+
const send = (frame: Record<string, unknown>): void => {
|
|
52
|
+
runtime.sendHubFrame(frame);
|
|
53
|
+
};
|
|
54
|
+
worktreeSender.update(() => send);
|
|
55
|
+
return () => {
|
|
56
|
+
worktreeSender.update((current) => (current === send ? undefined : current));
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether this session has worktree work in flight.
|
|
62
|
+
*
|
|
63
|
+
* A standing worktree is state, not work: it sits there until someone closes
|
|
64
|
+
* it, so counting it would leave the session reading as busy forever, with a
|
|
65
|
+
* "background work is still running" notice under a conversation where
|
|
66
|
+
* nothing is running. Only a create or close actually in progress counts.
|
|
67
|
+
*
|
|
68
|
+
* The group stays visible either way. Declaring an active source at all is
|
|
69
|
+
* what keeps it in the dock; this answers how it is drawn, not whether it is
|
|
70
|
+
* there.
|
|
31
71
|
*/
|
|
32
72
|
export const worktreeActivitySource = {
|
|
33
73
|
subscribe(listener: () => void) {
|
|
@@ -35,7 +75,7 @@ export const worktreeActivitySource = {
|
|
|
35
75
|
return () => subscription.unsubscribe();
|
|
36
76
|
},
|
|
37
77
|
isActive(sessionId: string | null) {
|
|
38
|
-
return worktreeActivity.select(worktreeActivity.store.state, sessionId).
|
|
78
|
+
return worktreeActivity.select(worktreeActivity.store.state, sessionId).pending !== undefined;
|
|
39
79
|
},
|
|
40
80
|
};
|
|
41
81
|
|
|
@@ -43,36 +83,42 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
43
83
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
84
|
}
|
|
45
85
|
|
|
86
|
+
function optionalText(value: unknown): string | undefined {
|
|
87
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
46
90
|
/** The plugin's session data channel: 'git_worktrees' payloads into the store. */
|
|
47
91
|
export const worktreesChannel = worktreeActivity.channel<WorktreesSession>({
|
|
48
92
|
channel: GIT_WORKTREES_TYPE,
|
|
49
93
|
parse(input) {
|
|
50
94
|
if (!isRecord(input) || !Array.isArray(input.worktrees)) return null;
|
|
51
|
-
return {
|
|
95
|
+
return {
|
|
96
|
+
worktrees: input.worktrees.filter(isRecord) as unknown as WorktreeView[],
|
|
97
|
+
pending: optionalText(input.pending),
|
|
98
|
+
error: optionalText(input.error),
|
|
99
|
+
};
|
|
52
100
|
},
|
|
53
101
|
reduce(_current, next) {
|
|
54
102
|
return next;
|
|
55
103
|
},
|
|
56
104
|
});
|
|
57
105
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
sessionId: string,
|
|
62
|
-
branch: string,
|
|
63
|
-
baseRef: string,
|
|
64
|
-
): void {
|
|
65
|
-
const base = baseRef.trim() === '' ? '' : ` from ${baseRef.trim()}`;
|
|
66
|
-
send(sessionId, {
|
|
67
|
-
type: 'prompt',
|
|
68
|
-
message: `Create a git worktree on branch ${branch.trim()}${base} using the run_worktree tool.`,
|
|
69
|
-
});
|
|
106
|
+
/** Sends one command to the session's worktrees channel. */
|
|
107
|
+
function command(sessionId: string, payload: GitWorktreesCommand): void {
|
|
108
|
+
worktreeSender.store.state?.({ type: GIT_WORKTREES_TYPE, sessionId, payload });
|
|
70
109
|
}
|
|
71
110
|
|
|
72
|
-
/** Asks the
|
|
73
|
-
export function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
111
|
+
/** Asks the hub for a worktree. The hub answers on the same channel. */
|
|
112
|
+
export function requestWorktreeCreate(sessionId: string, branch: string, baseRef: string): void {
|
|
113
|
+
const trimmedBase = baseRef.trim();
|
|
114
|
+
command(sessionId, {
|
|
115
|
+
action: 'create',
|
|
116
|
+
branch: branch.trim(),
|
|
117
|
+
...(trimmedBase === '' ? {} : { baseRef: trimmedBase }),
|
|
77
118
|
});
|
|
78
119
|
}
|
|
120
|
+
|
|
121
|
+
/** Asks the hub to close one worktree. Refusal on a dirty tree stays the hub's call. */
|
|
122
|
+
export function requestWorktreeClose(sessionId: string, id: string): void {
|
|
123
|
+
command(sessionId, { action: 'close', id });
|
|
124
|
+
}
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { Store } from '@tanstack/store';
|
|
2
|
-
import type { WorktreeRecord } from '../../types/worktreeRegistry.ts';
|
|
3
|
-
|
|
4
|
-
const API_BASE = '/api/plugin/git';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The host's transport, not the global fetch.
|
|
8
|
-
*
|
|
9
|
-
* A cockpit reached through the remote tunnel routes package API calls through
|
|
10
|
-
* a sealed transport; calling `fetch` directly works locally and then fails for
|
|
11
|
-
* exactly the users who are hardest to debug for.
|
|
12
|
-
*/
|
|
13
|
-
export type HostRequest = (input: string, init?: RequestInit) => Promise<Response>;
|
|
14
|
-
|
|
15
|
-
export interface WorktreesState {
|
|
16
|
-
/** Keyed by repository, because the panel follows the focused session. */
|
|
17
|
-
byRepository: Record<string, readonly WorktreeRecord[]>;
|
|
18
|
-
loading: boolean;
|
|
19
|
-
/** The last failure, shown in place of a silent empty list. */
|
|
20
|
-
error: string | undefined;
|
|
21
|
-
busy: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const worktreesStore = new Store<WorktreesState>({
|
|
25
|
-
byRepository: {},
|
|
26
|
-
loading: false,
|
|
27
|
-
error: undefined,
|
|
28
|
-
busy: false,
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
export function selectWorktrees(state: WorktreesState, repositoryId: string | null): readonly WorktreeRecord[] {
|
|
32
|
-
return repositoryId === null ? [] : (state.byRepository[repositoryId] ?? []);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function readError(response: Response): Promise<string> {
|
|
36
|
-
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
37
|
-
return body.error ?? `Request failed (${String(response.status)}).`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Loads the worktrees for a repository, replacing whatever was shown. */
|
|
41
|
-
export async function loadWorktrees(request: HostRequest, repositoryId: string): Promise<void> {
|
|
42
|
-
worktreesStore.setState((state) => ({ ...state, loading: true, error: undefined }));
|
|
43
|
-
try {
|
|
44
|
-
const response = await request(`${API_BASE}/worktrees?repositoryId=${encodeURIComponent(repositoryId)}`);
|
|
45
|
-
if (!response.ok) throw new Error(await readError(response));
|
|
46
|
-
const body = (await response.json()) as { worktrees: WorktreeRecord[] };
|
|
47
|
-
worktreesStore.setState((state) => ({
|
|
48
|
-
...state,
|
|
49
|
-
loading: false,
|
|
50
|
-
byRepository: { ...state.byRepository, [repositoryId]: body.worktrees },
|
|
51
|
-
}));
|
|
52
|
-
} catch (error) {
|
|
53
|
-
worktreesStore.setState((state) => ({ ...state, loading: false, error: (error as Error).message }));
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface CreateWorktreeInput {
|
|
58
|
-
repositoryId: string;
|
|
59
|
-
branch: string;
|
|
60
|
-
baseRef?: string;
|
|
61
|
-
name?: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Creates a worktree and its session through the hub.
|
|
66
|
-
*
|
|
67
|
-
* The same route the `run_worktree` tool calls. One path for both so the panel
|
|
68
|
-
* and the agent cannot drift into two different ideas of what creating a
|
|
69
|
-
* worktree means.
|
|
70
|
-
*/
|
|
71
|
-
export async function createWorktree(request: HostRequest, input: CreateWorktreeInput): Promise<boolean> {
|
|
72
|
-
worktreesStore.setState((state) => ({ ...state, busy: true, error: undefined }));
|
|
73
|
-
try {
|
|
74
|
-
const response = await request(`${API_BASE}/worktrees?repositoryId=${encodeURIComponent(input.repositoryId)}`, {
|
|
75
|
-
method: 'POST',
|
|
76
|
-
headers: { 'content-type': 'application/json' },
|
|
77
|
-
body: JSON.stringify({
|
|
78
|
-
branch: input.branch,
|
|
79
|
-
...(input.baseRef === undefined || input.baseRef === '' ? {} : { baseRef: input.baseRef }),
|
|
80
|
-
...(input.name === undefined || input.name === '' ? {} : { name: input.name }),
|
|
81
|
-
}),
|
|
82
|
-
});
|
|
83
|
-
if (!response.ok) throw new Error(await readError(response));
|
|
84
|
-
await loadWorktrees(request, input.repositoryId);
|
|
85
|
-
worktreesStore.setState((state) => ({ ...state, busy: false }));
|
|
86
|
-
return true;
|
|
87
|
-
} catch (error) {
|
|
88
|
-
worktreesStore.setState((state) => ({ ...state, busy: false, error: (error as Error).message }));
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Closes a worktree. `force` discards uncommitted work and is never implicit. */
|
|
94
|
-
export async function closeWorktree(
|
|
95
|
-
request: HostRequest,
|
|
96
|
-
repositoryId: string,
|
|
97
|
-
id: string,
|
|
98
|
-
force: boolean,
|
|
99
|
-
): Promise<boolean> {
|
|
100
|
-
worktreesStore.setState((state) => ({ ...state, busy: true, error: undefined }));
|
|
101
|
-
try {
|
|
102
|
-
const query = new URLSearchParams({ repositoryId, ...(force ? { force: 'true' } : {}) });
|
|
103
|
-
const response = await request(`${API_BASE}/worktrees/${encodeURIComponent(id)}?${query.toString()}`, {
|
|
104
|
-
method: 'DELETE',
|
|
105
|
-
});
|
|
106
|
-
if (!response.ok) throw new Error(await readError(response));
|
|
107
|
-
await loadWorktrees(request, repositoryId);
|
|
108
|
-
worktreesStore.setState((state) => ({ ...state, busy: false }));
|
|
109
|
-
return true;
|
|
110
|
-
} catch (error) {
|
|
111
|
-
worktreesStore.setState((state) => ({ ...state, busy: false, error: (error as Error).message }));
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
}
|