@agimon-ai/doompi-git 0.0.1-alpha.3 → 0.0.1-alpha.5
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/hub/hubClient.cjs +1 -1
- package/dist/adapters/hub/hubClient.cjs.map +1 -1
- package/dist/adapters/hub/hubClient.mjs +1 -1
- package/dist/adapters/hub/hubClient.mjs.map +1 -1
- package/dist/adapters/pi/extensions/runWorktreeTool.cjs +2 -2
- package/dist/adapters/pi/extensions/runWorktreeTool.cjs.map +1 -1
- package/dist/adapters/pi/extensions/runWorktreeTool.mjs +2 -2
- package/dist/adapters/pi/extensions/runWorktreeTool.mjs.map +1 -1
- package/dist/adapters/web/worktreesChannel.cjs +1 -1
- package/dist/adapters/web/worktreesChannel.cjs.map +1 -1
- package/dist/adapters/web/worktreesChannel.d.cts.map +1 -1
- 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/compositionMirror.cjs +2 -0
- package/dist/adapters/worktree/compositionMirror.cjs.map +1 -0
- package/dist/adapters/worktree/compositionMirror.mjs +2 -0
- package/dist/adapters/worktree/compositionMirror.mjs.map +1 -0
- package/dist/adapters/worktree/gitCli.cjs +1 -1
- package/dist/adapters/worktree/gitCli.cjs.map +1 -1
- package/dist/adapters/worktree/gitCli.mjs +1 -1
- package/dist/adapters/worktree/gitCli.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.d.cts +8 -2
- package/dist/adapters/worktree/worktreeOperations.d.cts.map +1 -1
- package/dist/adapters/worktree/worktreeOperations.d.mts +8 -2
- package/dist/adapters/worktree/worktreeOperations.d.mts.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/services/worktreeNaming.cjs +1 -1
- package/dist/services/worktreeNaming.cjs.map +1 -1
- package/dist/services/worktreeNaming.d.cts +7 -0
- package/dist/services/worktreeNaming.d.cts.map +1 -1
- package/dist/services/worktreeNaming.d.mts +7 -0
- package/dist/services/worktreeNaming.d.mts.map +1 -1
- package/dist/services/worktreeNaming.mjs +1 -1
- package/dist/services/worktreeNaming.mjs.map +1 -1
- package/package.json +8 -6
- package/src/prompts/doompi-use-git/SKILL.md +5 -2
- package/src/types/worktreeRegistry.ts +9 -0
- package/src/web/components/RunWorktreeToolMessage.tsx +20 -12
- package/src/web/components/WorktreesActivitySection.tsx +25 -10
- package/src/web/components/WorktreesPanel.tsx +94 -45
- package/dist/adapters/worktree/dependencyInstall.cjs +0 -2
- package/dist/adapters/worktree/dependencyInstall.cjs.map +0 -1
- package/dist/adapters/worktree/dependencyInstall.mjs +0 -2
- package/dist/adapters/worktree/dependencyInstall.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktreeNaming.mjs","names":[],"sources":["../../src/services/worktreeNaming.ts"],"sourcesContent":["/**\n * Naming and reconciliation rules for worktrees, as pure functions.\n *\n * Everything here is a function of its arguments so the interesting decisions,\n * which are the ones about destroying work, can be tested without a repository\n * or a filesystem.\n */\n\nimport type { WorktreeRecord } from '../types/worktreeRegistry.ts';\n\nconst MAX_SLUG_LENGTH = 64;\n\n/**\n * A branch name reduced to one safe path segment.\n *\n * Slashes are the point: `wt/fix-auth` is a perfectly good branch and a\n * terrible directory name, because the nested directory it implies is not the\n * one the worktree lives in.\n */\nexport function branchSlug(branch: string): string {\n const slug = branch\n .toLowerCase()\n .replace(/[^a-z0-9]+/gu, '-')\n .replace(/^-+|-+$/gu, '')\n .slice(0, MAX_SLUG_LENGTH)\n .replace(/-+$/gu, '');\n return slug === '' ? 'worktree' : slug;\n}\n\n/**\n * The directory for one worktree.\n *\n * The short id disambiguates two branches whose slugs collide, such as\n * `feature/x` and `feature_x`, which would otherwise want the same directory.\n */\nexport function worktreeDirectory(input: {\n worktreesRoot: string;\n repositoryLabel: string;\n repositoryId: string;\n branch: string;\n shortId: string;\n}): string {\n const repository = `${input.repositoryLabel}--${input.repositoryId}`;\n return `${input.worktreesRoot}/${repository}/${branchSlug(input.branch)}--${input.shortId}`;\n}\n\n/** Why a worktree cannot be created, or undefined when it can. */\nexport function refuseSpawn(input: { branch: string; existing: readonly WorktreeRecord[] }): string | undefined {\n if (input.branch.trim() === '') return 'A branch name is required.';\n // Git refuses a second worktree on one branch, but its message names a path\n // the caller never chose, so this says it in the caller's own terms first.\n const live = input.existing.find((record) => record.branch === input.branch && record.status !== 'orphaned');\n if (live) return `Branch ${input.branch} already has a worktree (${live.id}).`;\n return undefined;\n}\n\n/** Why a worktree cannot be closed, or undefined when it can. */\nexport function refuseClose(input: {\n record: WorktreeRecord | undefined;\n dirtyFiles: readonly string[];\n force: boolean;\n}): string | undefined {\n if (!input.record) return 'No worktree with that id. Call list to see the current ids.';\n if (input.dirtyFiles.length === 0 || input.force) return undefined;\n // Naming the files is the point: \"the tree is dirty\" sends the reader to go\n // look, and the answer is usually a file they forgot rather than one they\n // meant to keep.\n const names = input.dirtyFiles.slice(0, 10).join(', ');\n const more = input.dirtyFiles.length > 10 ? `, and ${String(input.dirtyFiles.length - 10)} more` : '';\n return `${String(input.dirtyFiles.length)} uncommitted file(s): ${names}${more}. Pass force: true to discard them.`;\n}\n\nexport interface Reconciliation {\n records: WorktreeRecord[];\n /** Ids whose status changed, so a caller can report what it noticed. */\n changed: string[];\n}\n\n/**\n * Marks records whose process or directory is gone as orphaned.\n *\n * Nothing is deleted here. A doompi-git orphan owns a directory that may hold\n * uncommitted work, so losing the record would lose the only pointer to it.\n * Deletion is always a separate, named decision.\n */\nexport function reconcile(\n records: readonly WorktreeRecord[],\n probe: { alive: (record: WorktreeRecord) => boolean; exists: (path: string) => boolean },\n): Reconciliation {\n const changed: string[] = [];\n const next = records.map((record) => {\n if (record.status === 'orphaned') return record;\n const gone = !probe.exists(record.path) || !probe.alive(record);\n if (!gone) return record;\n changed.push(record.id);\n return { ...record, status: 'orphaned' as const };\n });\n return { records: next, changed };\n}\n\nexport interface PrunePlan {\n /** Orphans whose directory git still lists, safe to remove. */\n remove: WorktreeRecord[];\n /** Orphans whose directory is already gone; drop the record only. */\n forget: WorktreeRecord[];\n /** Paths git lists under our root that we have no record of; report, never touch. */\n untracked: string[];\n /** Orphans left alone because they still hold uncommitted work. */\n keptDirty: WorktreeRecord[];\n}\n\n/**\n * What a prune would do, decided before anything is destroyed.\n *\n * Separating the plan from the execution is what makes dryRun honest and lets\n * the destructive half be reviewed on its own.\n */\nexport function planPrune(input: {\n records: readonly WorktreeRecord[];\n gitPaths: readonly string[];\n worktreesRoot: string;\n dirtyByPath: ReadonlyMap<string, readonly string[]>;\n}): PrunePlan {\n const plan: PrunePlan = { remove: [], forget: [], untracked: [], keptDirty: [] };\n const known = new Set(input.records.map((record) => record.path));\n const listed = new Set(input.gitPaths);\n for (const record of input.records) {\n if (record.status !== 'orphaned') continue;\n if (!listed.has(record.path)) {\n plan.forget.push(record);\n continue;\n }\n if ((input.dirtyByPath.get(record.path) ?? []).length > 0) {\n plan.keptDirty.push(record);\n continue;\n }\n plan.remove.push(record);\n }\n for (const path of input.gitPaths) {\n // Only ever inside our own root: a worktree the user made by hand is none\n // of this package's business, even when it looks abandoned.\n if (path.startsWith(`${input.worktreesRoot}/`) && !known.has(path)) plan.untracked.push(path);\n }\n return plan;\n}\n"],"mappings":"AAmBA,SAAgB,EAAW,EAAwB,CACjD,IAAM,EAAO,EACV,YAAY,CAAC,CACb,QAAQ,eAAgB,GAAG,CAAC,CAC5B,QAAQ,YAAa,EAAE,CAAC,CACxB,MAAM,EAAG,EAAe,CAAC,CACzB,QAAQ,QAAS,EAAE,EACtB,OAAO,IAAS,GAAK,WAAa,CACpC,CAQA,SAAgB,EAAkB,EAMvB,CACT,IAAM,EAAa,GAAG,EAAM,gBAAgB,IAAI,EAAM,eACtD,MAAO,GAAG,EAAM,cAAc,GAAG,EAAW,GAAG,EAAW,EAAM,MAAM,EAAE,IAAI,EAAM,SACpF,CAGA,SAAgB,EAAY,EAAoF,CAC9G,GAAI,EAAM,OAAO,KAAK,IAAM,GAAI,MAAO,6BAGvC,IAAM,EAAO,EAAM,SAAS,KAAM,GAAW,EAAO,SAAW,EAAM,QAAU,EAAO,SAAW,UAAU,EAC3G,GAAI,EAAM,MAAO,UAAU,EAAM,OAAO,2BAA2B,EAAK,GAAG,GAE7E,CAGA,SAAgB,EAAY,EAIL,CACrB,GAAI,CAAC,EAAM,OAAQ,MAAO,8DAC1B,GAAI,EAAM,WAAW,SAAW,GAAK,EAAM,MAAO,OAIlD,IAAM,EAAQ,EAAM,WAAW,MAAM,EAAG,EAAE,CAAC,CAAC,KAAK,IAAI,EAC/C,EAAO,EAAM,WAAW,OAAS,GAAK,SAAS,OAAO,EAAM,WAAW,OAAS,EAAE,EAAE,OAAS,GACnG,MAAO,GAAG,OAAO,EAAM,WAAW,MAAM,EAAE,wBAAwB,IAAQ,EAAK,oCACjF,CAeA,SAAgB,EACd,EACA,EACgB,CAChB,IAAM,EAAoB,CAAC,EAQ3B,MAAO,CAAE,QAPI,EAAQ,IAAK,GACpB,EAAO,SAAW,YACR,EAAM,OAAO,EAAO,IAAI,GAAM,EAAM,MAAM,CAAM,EAC5C,GAClB,EAAQ,KAAK,EAAO,EAAE,EACf,CAAE,GAAG,EAAQ,OAAQ,UAAoB,EAE7B,EAAG,SAAQ,CAClC,
|
|
1
|
+
{"version":3,"file":"worktreeNaming.mjs","names":[],"sources":["../../src/services/worktreeNaming.ts"],"sourcesContent":["/**\n * Naming and reconciliation rules for worktrees, as pure functions.\n *\n * Everything here is a function of its arguments so the interesting decisions,\n * which are the ones about destroying work, can be tested without a repository\n * or a filesystem.\n */\n\nimport type { WorktreeRecord } from '../types/worktreeRegistry.ts';\n\nconst MAX_SLUG_LENGTH = 64;\n\n/**\n * A branch name reduced to one safe path segment.\n *\n * Slashes are the point: `wt/fix-auth` is a perfectly good branch and a\n * terrible directory name, because the nested directory it implies is not the\n * one the worktree lives in.\n */\nexport function branchSlug(branch: string): string {\n const slug = branch\n .toLowerCase()\n .replace(/[^a-z0-9]+/gu, '-')\n .replace(/^-+|-+$/gu, '')\n .slice(0, MAX_SLUG_LENGTH)\n .replace(/-+$/gu, '');\n return slug === '' ? 'worktree' : slug;\n}\n\n/**\n * The directory for one worktree.\n *\n * The short id disambiguates two branches whose slugs collide, such as\n * `feature/x` and `feature_x`, which would otherwise want the same directory.\n */\nexport function worktreeDirectory(input: {\n worktreesRoot: string;\n repositoryLabel: string;\n repositoryId: string;\n branch: string;\n shortId: string;\n}): string {\n const repository = `${input.repositoryLabel}--${input.repositoryId}`;\n return `${input.worktreesRoot}/${repository}/${branchSlug(input.branch)}--${input.shortId}`;\n}\n\n/** Why a worktree cannot be created, or undefined when it can. */\nexport function refuseSpawn(input: { branch: string; existing: readonly WorktreeRecord[] }): string | undefined {\n if (input.branch.trim() === '') return 'A branch name is required.';\n // Git refuses a second worktree on one branch, but its message names a path\n // the caller never chose, so this says it in the caller's own terms first.\n const live = input.existing.find((record) => record.branch === input.branch && record.status !== 'orphaned');\n if (live) return `Branch ${input.branch} already has a worktree (${live.id}).`;\n return undefined;\n}\n\n/** Why a worktree cannot be closed, or undefined when it can. */\nexport function refuseClose(input: {\n record: WorktreeRecord | undefined;\n dirtyFiles: readonly string[];\n force: boolean;\n}): string | undefined {\n if (!input.record) return 'No worktree with that id. Call list to see the current ids.';\n if (input.dirtyFiles.length === 0 || input.force) return undefined;\n // Naming the files is the point: \"the tree is dirty\" sends the reader to go\n // look, and the answer is usually a file they forgot rather than one they\n // meant to keep.\n const names = input.dirtyFiles.slice(0, 10).join(', ');\n const more = input.dirtyFiles.length > 10 ? `, and ${String(input.dirtyFiles.length - 10)} more` : '';\n return `${String(input.dirtyFiles.length)} uncommitted file(s): ${names}${more}. Pass force: true to discard them.`;\n}\n\nexport interface Reconciliation {\n records: WorktreeRecord[];\n /** Ids whose status changed, so a caller can report what it noticed. */\n changed: string[];\n}\n\n/**\n * Marks records whose process or directory is gone as orphaned.\n *\n * Nothing is deleted here. A doompi-git orphan owns a directory that may hold\n * uncommitted work, so losing the record would lose the only pointer to it.\n * Deletion is always a separate, named decision.\n */\nexport function reconcile(\n records: readonly WorktreeRecord[],\n probe: { alive: (record: WorktreeRecord) => boolean; exists: (path: string) => boolean },\n): Reconciliation {\n const changed: string[] = [];\n const next = records.map((record) => {\n if (record.status === 'orphaned') return record;\n const gone = !probe.exists(record.path) || !probe.alive(record);\n if (!gone) return record;\n changed.push(record.id);\n return { ...record, status: 'orphaned' as const };\n });\n return { records: next, changed };\n}\n\nexport interface PrunePlan {\n /** Orphans whose directory git still lists, safe to remove. */\n remove: WorktreeRecord[];\n /** Orphans whose directory is already gone; drop the record only. */\n forget: WorktreeRecord[];\n /** Paths git lists under our root that we have no record of; report, never touch. */\n untracked: string[];\n /** Orphans left alone because they still hold uncommitted work. */\n keptDirty: WorktreeRecord[];\n /**\n * Branches git refused to delete because they hold commits.\n *\n * Filled by the prune itself, not by the plan: a dry run destroys nothing, so\n * it has nothing to report here and leaves it empty.\n */\n keptBranches: string[];\n}\n\n/**\n * What a prune would do, decided before anything is destroyed.\n *\n * Separating the plan from the execution is what makes dryRun honest and lets\n * the destructive half be reviewed on its own.\n */\nexport function planPrune(input: {\n records: readonly WorktreeRecord[];\n gitPaths: readonly string[];\n worktreesRoot: string;\n dirtyByPath: ReadonlyMap<string, readonly string[]>;\n}): PrunePlan {\n const plan: PrunePlan = { remove: [], forget: [], untracked: [], keptDirty: [], keptBranches: [] };\n const known = new Set(input.records.map((record) => record.path));\n const listed = new Set(input.gitPaths);\n for (const record of input.records) {\n if (record.status !== 'orphaned') continue;\n if (!listed.has(record.path)) {\n plan.forget.push(record);\n continue;\n }\n if ((input.dirtyByPath.get(record.path) ?? []).length > 0) {\n plan.keptDirty.push(record);\n continue;\n }\n plan.remove.push(record);\n }\n for (const path of input.gitPaths) {\n // Only ever inside our own root: a worktree the user made by hand is none\n // of this package's business, even when it looks abandoned.\n if (path.startsWith(`${input.worktreesRoot}/`) && !known.has(path)) plan.untracked.push(path);\n }\n return plan;\n}\n"],"mappings":"AAmBA,SAAgB,EAAW,EAAwB,CACjD,IAAM,EAAO,EACV,YAAY,CAAC,CACb,QAAQ,eAAgB,GAAG,CAAC,CAC5B,QAAQ,YAAa,EAAE,CAAC,CACxB,MAAM,EAAG,EAAe,CAAC,CACzB,QAAQ,QAAS,EAAE,EACtB,OAAO,IAAS,GAAK,WAAa,CACpC,CAQA,SAAgB,EAAkB,EAMvB,CACT,IAAM,EAAa,GAAG,EAAM,gBAAgB,IAAI,EAAM,eACtD,MAAO,GAAG,EAAM,cAAc,GAAG,EAAW,GAAG,EAAW,EAAM,MAAM,EAAE,IAAI,EAAM,SACpF,CAGA,SAAgB,EAAY,EAAoF,CAC9G,GAAI,EAAM,OAAO,KAAK,IAAM,GAAI,MAAO,6BAGvC,IAAM,EAAO,EAAM,SAAS,KAAM,GAAW,EAAO,SAAW,EAAM,QAAU,EAAO,SAAW,UAAU,EAC3G,GAAI,EAAM,MAAO,UAAU,EAAM,OAAO,2BAA2B,EAAK,GAAG,GAE7E,CAGA,SAAgB,EAAY,EAIL,CACrB,GAAI,CAAC,EAAM,OAAQ,MAAO,8DAC1B,GAAI,EAAM,WAAW,SAAW,GAAK,EAAM,MAAO,OAIlD,IAAM,EAAQ,EAAM,WAAW,MAAM,EAAG,EAAE,CAAC,CAAC,KAAK,IAAI,EAC/C,EAAO,EAAM,WAAW,OAAS,GAAK,SAAS,OAAO,EAAM,WAAW,OAAS,EAAE,EAAE,OAAS,GACnG,MAAO,GAAG,OAAO,EAAM,WAAW,MAAM,EAAE,wBAAwB,IAAQ,EAAK,oCACjF,CAeA,SAAgB,EACd,EACA,EACgB,CAChB,IAAM,EAAoB,CAAC,EAQ3B,MAAO,CAAE,QAPI,EAAQ,IAAK,GACpB,EAAO,SAAW,YACR,EAAM,OAAO,EAAO,IAAI,GAAM,EAAM,MAAM,CAAM,EAC5C,GAClB,EAAQ,KAAK,EAAO,EAAE,EACf,CAAE,GAAG,EAAQ,OAAQ,UAAoB,EAE7B,EAAG,SAAQ,CAClC,CA0BA,SAAgB,EAAU,EAKZ,CACZ,IAAM,EAAkB,CAAE,OAAQ,CAAC,EAAG,OAAQ,CAAC,EAAG,UAAW,CAAC,EAAG,UAAW,CAAC,EAAG,aAAc,CAAC,CAAE,EAC3F,EAAQ,IAAI,IAAI,EAAM,QAAQ,IAAK,GAAW,EAAO,IAAI,CAAC,EAC1D,EAAS,IAAI,IAAI,EAAM,QAAQ,EACrC,IAAK,IAAM,KAAU,EAAM,QACrB,KAAO,SAAW,WACtB,IAAI,CAAC,EAAO,IAAI,EAAO,IAAI,EAAG,CAC5B,EAAK,OAAO,KAAK,CAAM,EACvB,QACF,CACA,IAAK,EAAM,YAAY,IAAI,EAAO,IAAI,GAAK,CAAC,EAAA,CAAG,OAAS,EAAG,CACzD,EAAK,UAAU,KAAK,CAAM,EAC1B,QACF,CACA,EAAK,OAAO,KAAK,CAAM,CALvB,CAOF,IAAK,IAAM,KAAQ,EAAM,SAGnB,EAAK,WAAW,GAAG,EAAM,cAAc,EAAE,GAAK,CAAC,EAAM,IAAI,CAAI,GAAG,EAAK,UAAU,KAAK,CAAI,EAE9F,OAAO,CACT"}
|
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.5",
|
|
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",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"src/prompts",
|
|
33
33
|
"src/types/webWorktrees.ts",
|
|
34
34
|
"src/types/worktreeRegistry.ts",
|
|
35
|
-
"src/web"
|
|
35
|
+
"src/web",
|
|
36
|
+
"!src/web/**/*.stories.tsx"
|
|
36
37
|
],
|
|
37
38
|
"type": "module",
|
|
38
39
|
"main": "./dist/index.cjs",
|
|
@@ -64,9 +65,9 @@
|
|
|
64
65
|
"@tanstack/react-store": "0.11.1",
|
|
65
66
|
"@tanstack/store": "0.11.1",
|
|
66
67
|
"typebox": "1.3.19",
|
|
67
|
-
"@agimon-ai/doompi-
|
|
68
|
-
"@agimon-ai/doompi-web-
|
|
69
|
-
"@agimon-ai/doompi-
|
|
68
|
+
"@agimon-ai/doompi-web-components": "0.0.1-alpha.28",
|
|
69
|
+
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.31",
|
|
70
|
+
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.68"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"@earendil-works/pi-agent-core": "0.85.1",
|
|
@@ -75,10 +76,11 @@
|
|
|
75
76
|
"@types/react": "19.2.18",
|
|
76
77
|
"@vitest/coverage-v8": "5.0.0",
|
|
77
78
|
"react": "19.2.8",
|
|
79
|
+
"react-dom": "19.2.8",
|
|
78
80
|
"tsdown": "0.22.14",
|
|
79
81
|
"typescript": "7.0.2",
|
|
80
82
|
"vitest": "5.0.0",
|
|
81
|
-
"@agimon-ai/vibe-lint-plugin-doom-extension": "0.0.1-alpha.
|
|
83
|
+
"@agimon-ai/vibe-lint-plugin-doom-extension": "0.0.1-alpha.65"
|
|
82
84
|
},
|
|
83
85
|
"peerDependencies": {
|
|
84
86
|
"@earendil-works/pi-agent-core": "0.85.1",
|
|
@@ -17,12 +17,15 @@ the parent checkout and silently builds the wrong code.
|
|
|
17
17
|
|
|
18
18
|
Use a worktree when work needs its own branch **and** its own checkout at the
|
|
19
19
|
same time: a long refactor you want to leave half-finished, a risky migration,
|
|
20
|
-
or two features that touch the same files.
|
|
21
|
-
the new directory, so it is the wrong tool for a quick edit; just edit.
|
|
20
|
+
or two features that touch the same files.
|
|
22
21
|
|
|
23
22
|
Do not reach for it to parallelise work that shares a branch. Two sessions on
|
|
24
23
|
one branch in two checkouts will fight, and git refuses the second worktree.
|
|
25
24
|
|
|
25
|
+
The new checkout has no dependencies installed: `spawn_worktree` runs git and
|
|
26
|
+
nothing else, whatever language the repository is in. Install them in the
|
|
27
|
+
worktree before building or testing there.
|
|
28
|
+
|
|
26
29
|
## Actions
|
|
27
30
|
|
|
28
31
|
```
|
|
@@ -55,6 +55,15 @@ export interface WorktreeRegistryStore {
|
|
|
55
55
|
export interface WorktreeGit {
|
|
56
56
|
addWorktree(input: { repositoryRoot: string; path: string; branch: string; baseRef: string }): Promise<void>;
|
|
57
57
|
removeWorktree(input: { repositoryRoot: string; path: string; force: boolean }): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Safe branch delete, reporting refusal rather than throwing.
|
|
60
|
+
*
|
|
61
|
+
* `worktree remove` leaves the branch behind, so undoing a spawn needs this
|
|
62
|
+
* too. It is always the safe form: git refuses a branch holding unmerged
|
|
63
|
+
* work, and false says the branch is still there rather than pretending it
|
|
64
|
+
* went.
|
|
65
|
+
*/
|
|
66
|
+
deleteBranch(input: { repositoryRoot: string; branch: string }): Promise<boolean>;
|
|
58
67
|
pruneWorktrees(repositoryRoot: string): Promise<void>;
|
|
59
68
|
/** Paths git currently lists for the repository, used to find orphans. */
|
|
60
69
|
listWorktreePaths(repositoryRoot: string): Promise<string[]>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MessageItem, MessageItemBody, MessageItemHeader, toolTone } from '@agimon-ai/doompi-web-components';
|
|
1
2
|
import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
|
|
2
3
|
|
|
3
4
|
const ACTION_LABEL: Readonly<Record<string, string>> = {
|
|
@@ -14,24 +15,31 @@ const ACTION_LABEL: Readonly<Record<string, string>> = {
|
|
|
14
15
|
/**
|
|
15
16
|
* The timeline item for `run_worktree`.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* the
|
|
18
|
+
* DESIGN PATTERNS:
|
|
19
|
+
* - Built on MessageItem, like every other tool renderer in the cockpit. That
|
|
20
|
+
* is what gives it the shared collapse toggle and the RUNNING/OK/ERROR badge
|
|
21
|
+
* rather than a hand-rolled ellipsis that never resolves.
|
|
22
|
+
* - The action and its subject go in the heading because that is what a reader
|
|
23
|
+
* scanning a transcript needs: "close_worktree a1b2c3d4" answers the question
|
|
24
|
+
* the raw JSON arguments make them decode.
|
|
25
|
+
* - A create takes as long as the cockpit needs to start a session, and the
|
|
26
|
+
* tool streams each phase into `output`, so the body is where the wait is
|
|
27
|
+
* visible.
|
|
20
28
|
*/
|
|
21
29
|
export function RunWorktreeToolMessage({ args, output, running, isError }: ToolMessageRenderProps) {
|
|
22
30
|
const action = typeof args.action === 'string' ? args.action : '';
|
|
23
31
|
const subject = typeof args.branch === 'string' ? args.branch : typeof args.id === 'string' ? args.id : '';
|
|
24
32
|
|
|
25
33
|
return (
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
<MessageItem tone={toolTone({ running, isError })} expandable={output !== ''}>
|
|
35
|
+
<MessageItemHeader title={ACTION_LABEL[action] ?? 'Worktree'}>
|
|
36
|
+
{subject === '' ? null : <span className="min-w-0 truncate text-doom-dim">{subject}</span>}
|
|
37
|
+
</MessageItemHeader>
|
|
38
|
+
{output === '' ? null : (
|
|
39
|
+
<MessageItemBody>
|
|
40
|
+
<pre className={`whitespace-pre-wrap text-xs ${isError ? 'text-doom-red' : 'text-doom-dim'}`}>{output}</pre>
|
|
41
|
+
</MessageItemBody>
|
|
34
42
|
)}
|
|
35
|
-
</
|
|
43
|
+
</MessageItem>
|
|
36
44
|
);
|
|
37
45
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - Doing work here. This section reports and launches; the panel and the tool
|
|
13
13
|
* own everything else.
|
|
14
14
|
*/
|
|
15
|
-
import { Button } from '@agimon-ai/doompi-web-components';
|
|
15
|
+
import { Button, Dot, Spinner, StatusBadge } from '@agimon-ai/doompi-web-components';
|
|
16
16
|
import type { WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
|
|
17
17
|
import { useStore } from '@tanstack/react-store';
|
|
18
18
|
import type { WorktreeView } from '../../types/webWorktrees.ts';
|
|
@@ -25,7 +25,7 @@ export function WorktreesActivitySection({ sessionId, openTransientTab }: WebPlu
|
|
|
25
25
|
if (session.worktrees.length === 0 && session.pending === undefined) {
|
|
26
26
|
return (
|
|
27
27
|
<div className="flex items-center gap-2 px-1">
|
|
28
|
-
<p data-testid="activity-summary-git" className="px-1 text-
|
|
28
|
+
<p data-testid="activity-summary-git" className="px-1 text-xs text-doom-faint">
|
|
29
29
|
idle
|
|
30
30
|
</p>
|
|
31
31
|
{sessionId === null ? null : (
|
|
@@ -45,19 +45,34 @@ 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 :
|
|
48
|
+
{session.pending === undefined ? null : (
|
|
49
|
+
<span className="flex min-w-0 items-center gap-1.5 px-1">
|
|
50
|
+
<Spinner className="h-3 w-3 shrink-0 text-doom-faint" label={session.pending} />
|
|
51
|
+
<span className="min-w-0 truncate text-xs text-doom-dim">{session.pending}</span>
|
|
52
|
+
</span>
|
|
53
|
+
)}
|
|
49
54
|
{session.worktrees.map((worktree: WorktreeView) => (
|
|
50
|
-
<
|
|
55
|
+
<Button
|
|
51
56
|
key={worktree.id}
|
|
52
|
-
|
|
57
|
+
variant="ghost"
|
|
58
|
+
size="card"
|
|
53
59
|
data-testid={`activity-git-worktree-${worktree.id}`}
|
|
54
|
-
className="
|
|
60
|
+
className="min-w-0 gap-1.5 px-1"
|
|
55
61
|
onClick={() => openTransientTab(worktreesTab())}
|
|
56
62
|
>
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
{worktree.
|
|
60
|
-
|
|
63
|
+
<Dot tone={worktree.orphaned ? 'red' : worktree.sessionId === null ? 'muted' : 'blue'} />
|
|
64
|
+
<span className="min-w-0 flex-1 truncate text-left text-xs font-bold text-doom-hi">{worktree.branch}</span>
|
|
65
|
+
{worktree.orphaned ? (
|
|
66
|
+
<StatusBadge tone="error" size="xs">
|
|
67
|
+
orphaned
|
|
68
|
+
</StatusBadge>
|
|
69
|
+
) : null}
|
|
70
|
+
{worktree.unowned ? (
|
|
71
|
+
<StatusBadge tone="info" size="xs">
|
|
72
|
+
unowned
|
|
73
|
+
</StatusBadge>
|
|
74
|
+
) : null}
|
|
75
|
+
</Button>
|
|
61
76
|
))}
|
|
62
77
|
</div>
|
|
63
78
|
);
|
|
@@ -5,19 +5,25 @@
|
|
|
5
5
|
* - Lives in the activity dock, opened by the group's name. A worktree is work
|
|
6
6
|
* in progress belonging to the session that started it, so it sits beside
|
|
7
7
|
* agents, runners and workflows rather than in a settings screen.
|
|
8
|
+
* - Header, form, then cards, the same rhythm the runners and workflows panels
|
|
9
|
+
* use. A worktree row is a card because it carries three facts and two
|
|
10
|
+
* actions, which a single line cannot hold without becoming a puzzle.
|
|
8
11
|
* - Local form state is never reset by a data refresh. The hub republishes this
|
|
9
12
|
* list on a timer, and a half-typed branch name must survive that.
|
|
10
13
|
* - Acts through the plugin's own hub channel, so a worktree made here and one
|
|
11
14
|
* made by the tool take exactly the same path.
|
|
12
|
-
* - Creating
|
|
13
|
-
* channel and
|
|
14
|
-
* minutes reads as broken.
|
|
15
|
+
* - Creating waits on the cockpit starting a session, so the hub reports each
|
|
16
|
+
* phase on that channel and the header shows it turning. A button that looks
|
|
17
|
+
* idle for two minutes reads as broken.
|
|
15
18
|
*
|
|
16
19
|
* AVOID:
|
|
17
20
|
* - Deriving form state from props or store data. That is what wiped the
|
|
18
21
|
* fields on every poll.
|
|
22
|
+
* - Colour classes outside the token set. `doom-error`, `doom-line` and
|
|
23
|
+
* `doom-bright` are not tokens: they compile to nothing and the row silently
|
|
24
|
+
* loses its border.
|
|
19
25
|
*/
|
|
20
|
-
import { Button, Input } from '@agimon-ai/doompi-web-components';
|
|
26
|
+
import { Button, Dot, EmptyState, Input, Spinner, StatusBadge } from '@agimon-ai/doompi-web-components';
|
|
21
27
|
import type { TransientTab, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
|
|
22
28
|
import { useStore } from '@tanstack/react-store';
|
|
23
29
|
import { useState } from 'react';
|
|
@@ -46,12 +52,28 @@ export function WorktreesPanel({ sessionId }: WebPluginSlotProps) {
|
|
|
46
52
|
};
|
|
47
53
|
|
|
48
54
|
return (
|
|
49
|
-
<div className="flex
|
|
50
|
-
<div className="flex
|
|
55
|
+
<div data-testid="git-worktrees-panel" className="flex min-h-0 flex-1 flex-col overflow-auto">
|
|
56
|
+
<div className="flex min-h-11 shrink-0 items-center gap-2.5 border-b border-doom-border-soft px-3 sm:px-[26px]">
|
|
57
|
+
<span className="text-sm font-bold text-doom-hi">worktrees</span>
|
|
58
|
+
<span data-testid="git-worktrees-count" className="text-xs text-doom-faint">
|
|
59
|
+
{session.worktrees.length === 0 ? 'none yet' : `${String(session.worktrees.length)} open`}
|
|
60
|
+
</span>
|
|
61
|
+
<span className="min-w-0 flex-1" />
|
|
62
|
+
{session.pending === undefined ? null : (
|
|
63
|
+
<span data-testid="git-worktree-pending" className="flex min-w-0 items-center gap-1.5">
|
|
64
|
+
<Spinner className="h-3 w-3 shrink-0 text-doom-faint" label={session.pending} />
|
|
65
|
+
<span className="min-w-0 truncate text-xs text-doom-dim">{session.pending}</span>
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="flex shrink-0 flex-col gap-1.5 border-b border-doom-border-soft px-3 py-2.5 sm:px-[26px]">
|
|
51
71
|
<div className="flex items-center gap-2">
|
|
52
72
|
<Input
|
|
53
73
|
data-testid="git-worktree-branch"
|
|
54
74
|
aria-label="Branch"
|
|
75
|
+
size="sm"
|
|
76
|
+
className="min-w-0 flex-1"
|
|
55
77
|
placeholder="wt/fix-auth"
|
|
56
78
|
value={branch}
|
|
57
79
|
onChange={(event) => setBranch(event.target.value)}
|
|
@@ -62,64 +84,91 @@ export function WorktreesPanel({ sessionId }: WebPluginSlotProps) {
|
|
|
62
84
|
<Input
|
|
63
85
|
data-testid="git-worktree-base"
|
|
64
86
|
aria-label="Base ref"
|
|
87
|
+
size="sm"
|
|
65
88
|
placeholder="base (current)"
|
|
66
89
|
value={baseRef}
|
|
67
90
|
onChange={(event) => setBaseRef(event.target.value)}
|
|
68
|
-
className="w-
|
|
91
|
+
className="w-32 shrink-0"
|
|
69
92
|
/>
|
|
70
|
-
<Button data-testid="git-worktree-create" size="
|
|
93
|
+
<Button data-testid="git-worktree-create" size="sm" disabled={!canCreate} onClick={create}>
|
|
71
94
|
Create
|
|
72
95
|
</Button>
|
|
73
96
|
</div>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
</p>
|
|
78
|
-
{session.pending === undefined ? null : (
|
|
79
|
-
<p data-testid="git-worktree-pending" className="text-[10px] text-doom-dim">
|
|
80
|
-
{session.pending}
|
|
97
|
+
{session.error === undefined ? (
|
|
98
|
+
<p className="text-2xs text-doom-faint">
|
|
99
|
+
Creates the worktree outside the repository and starts a session nested under this one.
|
|
81
100
|
</p>
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
<p data-testid="git-worktree-error" className="text-[10px] text-doom-error">
|
|
101
|
+
) : (
|
|
102
|
+
<p data-testid="git-worktree-error" className="text-2xs text-doom-red">
|
|
85
103
|
{session.error}
|
|
86
104
|
</p>
|
|
87
105
|
)}
|
|
88
106
|
</div>
|
|
89
107
|
|
|
90
108
|
{session.worktrees.length === 0 ? (
|
|
91
|
-
<
|
|
109
|
+
<EmptyState
|
|
110
|
+
data-testid="git-worktrees-empty"
|
|
111
|
+
className="px-4 py-8"
|
|
112
|
+
title="no worktrees yet"
|
|
113
|
+
description="a worktree is its own checkout on its own branch, with its own session nested under this one."
|
|
114
|
+
/>
|
|
92
115
|
) : (
|
|
93
|
-
<ul className="flex flex-col gap-
|
|
116
|
+
<ul data-testid="git-worktrees-list" className="flex flex-col gap-2.5 px-3 py-3 sm:px-[26px]">
|
|
94
117
|
{session.worktrees.map((worktree: WorktreeView) => (
|
|
95
|
-
<
|
|
96
|
-
key={worktree.id}
|
|
97
|
-
data-testid={`git-worktree-${worktree.id}`}
|
|
98
|
-
className="flex items-center justify-between gap-2 border border-doom-line px-2 py-1"
|
|
99
|
-
>
|
|
100
|
-
<div className="flex min-w-0 flex-col">
|
|
101
|
-
<span className="truncate text-[11px] text-doom-bright">{worktree.branch}</span>
|
|
102
|
-
<span className="truncate text-[10px] text-doom-faint">{worktree.path}</span>
|
|
103
|
-
</div>
|
|
104
|
-
<div className="flex items-center gap-2">
|
|
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}
|
|
107
|
-
{sessionId === null ? null : (
|
|
108
|
-
<Button
|
|
109
|
-
variant="link"
|
|
110
|
-
size="xs"
|
|
111
|
-
disabled={busy}
|
|
112
|
-
data-testid={`git-worktree-close-${worktree.id}`}
|
|
113
|
-
onClick={() => requestWorktreeClose(sessionId, worktree.id)}
|
|
114
|
-
>
|
|
115
|
-
close
|
|
116
|
-
</Button>
|
|
117
|
-
)}
|
|
118
|
-
</div>
|
|
119
|
-
</li>
|
|
118
|
+
<WorktreeCard key={worktree.id} worktree={worktree} sessionId={sessionId} busy={busy} />
|
|
120
119
|
))}
|
|
121
120
|
</ul>
|
|
122
121
|
)}
|
|
123
122
|
</div>
|
|
124
123
|
);
|
|
125
124
|
}
|
|
125
|
+
|
|
126
|
+
/** One worktree: what it is, where it is, and the one thing you can do to it. */
|
|
127
|
+
function WorktreeCard({
|
|
128
|
+
worktree,
|
|
129
|
+
sessionId,
|
|
130
|
+
busy,
|
|
131
|
+
}: {
|
|
132
|
+
worktree: WorktreeView;
|
|
133
|
+
busy: boolean;
|
|
134
|
+
} & Pick<WebPluginSlotProps, 'sessionId'>) {
|
|
135
|
+
return (
|
|
136
|
+
<li
|
|
137
|
+
data-testid={`git-worktree-${worktree.id}`}
|
|
138
|
+
className="flex min-w-0 flex-col gap-1.5 rounded-md border border-doom-border-soft bg-doom-panel p-2.5"
|
|
139
|
+
>
|
|
140
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
141
|
+
<Dot tone={worktree.orphaned ? 'red' : worktree.sessionId === null ? 'muted' : 'blue'} />
|
|
142
|
+
<span className="min-w-0 flex-1 truncate text-sm font-bold text-doom-hi">{worktree.branch}</span>
|
|
143
|
+
{worktree.orphaned ? (
|
|
144
|
+
<StatusBadge tone="error" size="xs">
|
|
145
|
+
orphaned
|
|
146
|
+
</StatusBadge>
|
|
147
|
+
) : null}
|
|
148
|
+
{worktree.unowned ? (
|
|
149
|
+
<StatusBadge tone="info" size="xs">
|
|
150
|
+
unowned
|
|
151
|
+
</StatusBadge>
|
|
152
|
+
) : null}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<span className="min-w-0 truncate text-2xs text-doom-faint">{worktree.path}</span>
|
|
156
|
+
|
|
157
|
+
<div className="flex items-center gap-1.5 pt-0.5">
|
|
158
|
+
<span className="min-w-0 flex-1" />
|
|
159
|
+
{sessionId === null ? null : (
|
|
160
|
+
<Button
|
|
161
|
+
variant="danger-outline"
|
|
162
|
+
size="xs"
|
|
163
|
+
disabled={busy}
|
|
164
|
+
data-testid={`git-worktree-close-${worktree.id}`}
|
|
165
|
+
className="px-2 text-2xs font-bold"
|
|
166
|
+
onClick={() => requestWorktreeClose(sessionId, worktree.id)}
|
|
167
|
+
>
|
|
168
|
+
close
|
|
169
|
+
</Button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
</li>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
const e=require("../../_virtual/_rolldown/runtime.cjs");let t=require("node:child_process"),n=require("node:fs");n=e.__toESM(n,1);let r=require("node:path");r=e.__toESM(r,1);const i=[{file:`pnpm-lock.yaml`,command:`pnpm`,args:[`install`,`--prefer-offline`]},{file:`yarn.lock`,command:`yarn`,args:[`install`]},{file:`package-lock.json`,command:`npm`,args:[`install`]},{file:`bun.lockb`,command:`bun`,args:[`install`]}];function a(e){return i.find(t=>n.default.existsSync(r.default.join(e,t.file)))}async function o(e,i=Date.now){if(!n.default.existsSync(r.default.join(e,`package.json`)))return{kind:`skipped`,reason:`no-manifest`};let o=a(e);if(o===void 0)return{kind:`skipped`,reason:`no-lockfile`};let s=i();return await new Promise(n=>{(0,t.execFile)(o.command,[...o.args],{cwd:e,timeout:9e5,env:{...process.env,LC_ALL:`C`}},(e,t,r)=>{if(e===null){n({kind:`installed`,command:o.command,durationMs:i()-s});return}n({kind:`failed`,command:o.command,code:typeof e.code==`number`?e.code:null,stderrBytes:Buffer.byteLength(String(r))})})})}exports.installDependencies=o,exports.installerFor=a;
|
|
2
|
-
//# sourceMappingURL=dependencyInstall.cjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dependencyInstall.cjs","names":["fs","path"],"sources":["../../../src/adapters/worktree/dependencyInstall.ts"],"sourcesContent":["/**\n * Installing a fresh worktree's dependencies.\n *\n * DESIGN PATTERNS:\n * - The package manager is chosen by the lockfile the repository already\n * committed, never guessed and never configured. A worktree shares the\n * parent's history, so the lockfile is always the right answer.\n * - A repository with no manifest installs nothing and reports that plainly.\n * Not every checkout is a Node project.\n * - Failure is reported, not thrown. A worktree whose install failed is still\n * a usable worktree; refusing to create it would be a worse trade than\n * letting the caller decide.\n *\n * AVOID:\n * - Carrying stdout or stderr into the result. Installers print absolute paths\n * and registry URLs; the caller gets a code and a byte length.\n */\nimport { execFile } from 'node:child_process';\nimport fs from 'node:fs';\nimport path from 'node:path';\n\n/**\n * A cold install pulls the whole store. The repository's own measured install\n * is well under a minute when the store is warm, but a first run on a new\n * machine is not, and killing it half way leaves a broken node_modules.\n */\nconst INSTALL_TIMEOUT_MS = 900_000;\n\nconst LOCKFILES: ReadonlyArray<{ file: string; command: string; args: readonly string[] }> = [\n { file: 'pnpm-lock.yaml', command: 'pnpm', args: ['install', '--prefer-offline'] },\n { file: 'yarn.lock', command: 'yarn', args: ['install'] },\n { file: 'package-lock.json', command: 'npm', args: ['install'] },\n { file: 'bun.lockb', command: 'bun', args: ['install'] },\n];\n\nexport type InstallOutcome =\n | { kind: 'skipped'; reason: 'no-manifest' | 'no-lockfile' }\n | { kind: 'installed'; command: string; durationMs: number }\n | { kind: 'failed'; command: string; code: number | null; stderrBytes: number };\n\n/** Chooses the installer this repository already uses, by its committed lockfile. */\nexport function installerFor(root: string): { command: string; args: readonly string[] } | undefined {\n return LOCKFILES.find((entry) => fs.existsSync(path.join(root, entry.file)));\n}\n\n/**\n * Installs into a freshly created worktree.\n *\n * The session that opens in the worktree resolves the repository's own\n * composition, and every workspace package path in it points at a directory\n * that only exists once dependencies are linked. Without this the session\n * falls back to the global bundle and silently loses the repository's packages.\n */\nexport async function installDependencies(root: string, now: () => number = Date.now): Promise<InstallOutcome> {\n if (!fs.existsSync(path.join(root, 'package.json'))) return { kind: 'skipped', reason: 'no-manifest' };\n const installer = installerFor(root);\n if (installer === undefined) return { kind: 'skipped', reason: 'no-lockfile' };\n\n const startedAt = now();\n return await new Promise<InstallOutcome>((resolve) => {\n execFile(\n installer.command,\n [...installer.args],\n { cwd: root, timeout: INSTALL_TIMEOUT_MS, env: { ...process.env, LC_ALL: 'C' } },\n (error, _stdout, stderr) => {\n if (error === null) {\n resolve({ kind: 'installed', command: installer.command, durationMs: now() - startedAt });\n return;\n }\n resolve({\n kind: 'failed',\n command: installer.command,\n code: typeof error.code === 'number' ? error.code : null,\n stderrBytes: Buffer.byteLength(String(stderr)),\n });\n },\n );\n });\n}\n"],"mappings":"8KA0BA,MAEM,EAAuF,CAC3F,CAAE,KAAM,iBAAkB,QAAS,OAAQ,KAAM,CAAC,UAAW,kBAAkB,CAAE,EACjF,CAAE,KAAM,YAAa,QAAS,OAAQ,KAAM,CAAC,SAAS,CAAE,EACxD,CAAE,KAAM,oBAAqB,QAAS,MAAO,KAAM,CAAC,SAAS,CAAE,EAC/D,CAAE,KAAM,YAAa,QAAS,MAAO,KAAM,CAAC,SAAS,CAAE,CACzD,EAQA,SAAgB,EAAa,EAAwE,CACnG,OAAO,EAAU,KAAM,GAAUA,EAAAA,QAAG,WAAWC,EAAAA,QAAK,KAAK,EAAM,EAAM,IAAI,CAAC,CAAC,CAC7E,CAUA,eAAsB,EAAoB,EAAc,EAAoB,KAAK,IAA8B,CAC7G,GAAI,CAACD,EAAAA,QAAG,WAAWC,EAAAA,QAAK,KAAK,EAAM,cAAc,CAAC,EAAG,MAAO,CAAE,KAAM,UAAW,OAAQ,aAAc,EACrG,IAAM,EAAY,EAAa,CAAI,EACnC,GAAI,IAAc,IAAA,GAAW,MAAO,CAAE,KAAM,UAAW,OAAQ,aAAc,EAE7E,IAAM,EAAY,EAAI,EACtB,OAAO,MAAM,IAAI,QAAyB,GAAY,EACpD,EAAA,EAAA,SAAA,CACE,EAAU,QACV,CAAC,GAAG,EAAU,IAAI,EAClB,CAAE,IAAK,EAAM,QAAS,IAAoB,IAAK,CAAE,GAAG,QAAQ,IAAK,OAAQ,GAAI,CAAE,GAC9E,EAAO,EAAS,IAAW,CAC1B,GAAI,IAAU,KAAM,CAClB,EAAQ,CAAE,KAAM,YAAa,QAAS,EAAU,QAAS,WAAY,EAAI,EAAI,CAAU,CAAC,EACxF,MACF,CACA,EAAQ,CACN,KAAM,SACN,QAAS,EAAU,QACnB,KAAM,OAAO,EAAM,MAAS,SAAW,EAAM,KAAO,KACpD,YAAa,OAAO,WAAW,OAAO,CAAM,CAAC,CAC/C,CAAC,CACH,CACF,CACF,CAAC,CACH"}
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{execFile as e}from"node:child_process";import t from"node:fs";import n from"node:path";const r=[{file:`pnpm-lock.yaml`,command:`pnpm`,args:[`install`,`--prefer-offline`]},{file:`yarn.lock`,command:`yarn`,args:[`install`]},{file:`package-lock.json`,command:`npm`,args:[`install`]},{file:`bun.lockb`,command:`bun`,args:[`install`]}];function i(e){return r.find(r=>t.existsSync(n.join(e,r.file)))}async function a(r,a=Date.now){if(!t.existsSync(n.join(r,`package.json`)))return{kind:`skipped`,reason:`no-manifest`};let o=i(r);if(o===void 0)return{kind:`skipped`,reason:`no-lockfile`};let s=a();return await new Promise(t=>{e(o.command,[...o.args],{cwd:r,timeout:9e5,env:{...process.env,LC_ALL:`C`}},(e,n,r)=>{if(e===null){t({kind:`installed`,command:o.command,durationMs:a()-s});return}t({kind:`failed`,command:o.command,code:typeof e.code==`number`?e.code:null,stderrBytes:Buffer.byteLength(String(r))})})})}export{a as installDependencies,i as installerFor};
|
|
2
|
-
//# sourceMappingURL=dependencyInstall.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dependencyInstall.mjs","names":[],"sources":["../../../src/adapters/worktree/dependencyInstall.ts"],"sourcesContent":["/**\n * Installing a fresh worktree's dependencies.\n *\n * DESIGN PATTERNS:\n * - The package manager is chosen by the lockfile the repository already\n * committed, never guessed and never configured. A worktree shares the\n * parent's history, so the lockfile is always the right answer.\n * - A repository with no manifest installs nothing and reports that plainly.\n * Not every checkout is a Node project.\n * - Failure is reported, not thrown. A worktree whose install failed is still\n * a usable worktree; refusing to create it would be a worse trade than\n * letting the caller decide.\n *\n * AVOID:\n * - Carrying stdout or stderr into the result. Installers print absolute paths\n * and registry URLs; the caller gets a code and a byte length.\n */\nimport { execFile } from 'node:child_process';\nimport fs from 'node:fs';\nimport path from 'node:path';\n\n/**\n * A cold install pulls the whole store. The repository's own measured install\n * is well under a minute when the store is warm, but a first run on a new\n * machine is not, and killing it half way leaves a broken node_modules.\n */\nconst INSTALL_TIMEOUT_MS = 900_000;\n\nconst LOCKFILES: ReadonlyArray<{ file: string; command: string; args: readonly string[] }> = [\n { file: 'pnpm-lock.yaml', command: 'pnpm', args: ['install', '--prefer-offline'] },\n { file: 'yarn.lock', command: 'yarn', args: ['install'] },\n { file: 'package-lock.json', command: 'npm', args: ['install'] },\n { file: 'bun.lockb', command: 'bun', args: ['install'] },\n];\n\nexport type InstallOutcome =\n | { kind: 'skipped'; reason: 'no-manifest' | 'no-lockfile' }\n | { kind: 'installed'; command: string; durationMs: number }\n | { kind: 'failed'; command: string; code: number | null; stderrBytes: number };\n\n/** Chooses the installer this repository already uses, by its committed lockfile. */\nexport function installerFor(root: string): { command: string; args: readonly string[] } | undefined {\n return LOCKFILES.find((entry) => fs.existsSync(path.join(root, entry.file)));\n}\n\n/**\n * Installs into a freshly created worktree.\n *\n * The session that opens in the worktree resolves the repository's own\n * composition, and every workspace package path in it points at a directory\n * that only exists once dependencies are linked. Without this the session\n * falls back to the global bundle and silently loses the repository's packages.\n */\nexport async function installDependencies(root: string, now: () => number = Date.now): Promise<InstallOutcome> {\n if (!fs.existsSync(path.join(root, 'package.json'))) return { kind: 'skipped', reason: 'no-manifest' };\n const installer = installerFor(root);\n if (installer === undefined) return { kind: 'skipped', reason: 'no-lockfile' };\n\n const startedAt = now();\n return await new Promise<InstallOutcome>((resolve) => {\n execFile(\n installer.command,\n [...installer.args],\n { cwd: root, timeout: INSTALL_TIMEOUT_MS, env: { ...process.env, LC_ALL: 'C' } },\n (error, _stdout, stderr) => {\n if (error === null) {\n resolve({ kind: 'installed', command: installer.command, durationMs: now() - startedAt });\n return;\n }\n resolve({\n kind: 'failed',\n command: installer.command,\n code: typeof error.code === 'number' ? error.code : null,\n stderrBytes: Buffer.byteLength(String(stderr)),\n });\n },\n );\n });\n}\n"],"mappings":"8FA0BA,MAEM,EAAuF,CAC3F,CAAE,KAAM,iBAAkB,QAAS,OAAQ,KAAM,CAAC,UAAW,kBAAkB,CAAE,EACjF,CAAE,KAAM,YAAa,QAAS,OAAQ,KAAM,CAAC,SAAS,CAAE,EACxD,CAAE,KAAM,oBAAqB,QAAS,MAAO,KAAM,CAAC,SAAS,CAAE,EAC/D,CAAE,KAAM,YAAa,QAAS,MAAO,KAAM,CAAC,SAAS,CAAE,CACzD,EAQA,SAAgB,EAAa,EAAwE,CACnG,OAAO,EAAU,KAAM,GAAU,EAAG,WAAW,EAAK,KAAK,EAAM,EAAM,IAAI,CAAC,CAAC,CAC7E,CAUA,eAAsB,EAAoB,EAAc,EAAoB,KAAK,IAA8B,CAC7G,GAAI,CAAC,EAAG,WAAW,EAAK,KAAK,EAAM,cAAc,CAAC,EAAG,MAAO,CAAE,KAAM,UAAW,OAAQ,aAAc,EACrG,IAAM,EAAY,EAAa,CAAI,EACnC,GAAI,IAAc,IAAA,GAAW,MAAO,CAAE,KAAM,UAAW,OAAQ,aAAc,EAE7E,IAAM,EAAY,EAAI,EACtB,OAAO,MAAM,IAAI,QAAyB,GAAY,CACpD,EACE,EAAU,QACV,CAAC,GAAG,EAAU,IAAI,EAClB,CAAE,IAAK,EAAM,QAAS,IAAoB,IAAK,CAAE,GAAG,QAAQ,IAAK,OAAQ,GAAI,CAAE,GAC9E,EAAO,EAAS,IAAW,CAC1B,GAAI,IAAU,KAAM,CAClB,EAAQ,CAAE,KAAM,YAAa,QAAS,EAAU,QAAS,WAAY,EAAI,EAAI,CAAU,CAAC,EACxF,MACF,CACA,EAAQ,CACN,KAAM,SACN,QAAS,EAAU,QACnB,KAAM,OAAO,EAAM,MAAS,SAAW,EAAM,KAAO,KACpD,YAAa,OAAO,WAAW,OAAO,CAAM,CAAC,CAC/C,CAAC,CACH,CACF,CACF,CAAC,CACH"}
|