@geminix/gxpm 0.1.0
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/AGENTS.md +148 -0
- package/CANON.md +53 -0
- package/CLAUDE.md +60 -0
- package/CONTEXT.md +49 -0
- package/DEBUG.md +59 -0
- package/ISSUE_CONTEXT.md +25 -0
- package/README.md +143 -0
- package/VERSION +1 -0
- package/agents/cleanup-auditor/cleanup-auditor.md +56 -0
- package/agents/grill-master.md +26 -0
- package/agents/implementer.md +32 -0
- package/agents/review-army/accessibility-reviewer.md +54 -0
- package/agents/review-army/code-quality-reviewer.md +54 -0
- package/agents/review-army/security-reviewer.md +56 -0
- package/agents/review-army/spec-compliance-reviewer.md +51 -0
- package/agents/review-army/test-reviewer.md +55 -0
- package/agents/reviewer.md +59 -0
- package/agents/ship-audit-army/docs-auditor.md +53 -0
- package/agents/ship-audit-army/performance-auditor.md +52 -0
- package/agents/ship-audit-army/security-auditor.md +52 -0
- package/agents/specifier.md +55 -0
- package/agents/triage-officer.md +27 -0
- package/bin/gxpm +17 -0
- package/bin/gxpm-browser +17 -0
- package/bin/gxpm-config +15 -0
- package/bin/gxpm-eval +13 -0
- package/bin/gxpm-global-discover +15 -0
- package/bin/gxpm-init +38 -0
- package/bin/gxpm-investigate +194 -0
- package/bin/gxpm-uninstall +15 -0
- package/bin/gxpm-update-check +165 -0
- package/commands/build.md +40 -0
- package/commands/help.md +53 -0
- package/commands/plan.md +34 -0
- package/commands/refine.md +46 -0
- package/commands/review.md +34 -0
- package/commands/ship.md +37 -0
- package/core/ac-check.ts +20 -0
- package/core/agent-runtime.ts +363 -0
- package/core/artifact-validator.ts +151 -0
- package/core/artifacts.ts +313 -0
- package/core/autopilot.ts +250 -0
- package/core/capabilities.ts +779 -0
- package/core/checkpoint.ts +370 -0
- package/core/cleanup.ts +32 -0
- package/core/command-probe.ts +82 -0
- package/core/config.ts +533 -0
- package/core/contracts/behavior-spec.schema.ts +38 -0
- package/core/contracts/converter.ts +61 -0
- package/core/contracts/host.ts +43 -0
- package/core/converters/converter.ts +93 -0
- package/core/converters/index.ts +8 -0
- package/core/converters/managed-artifact.ts +119 -0
- package/core/converters/parser.ts +159 -0
- package/core/converters/template-renderer.ts +35 -0
- package/core/converters/writer.ts +61 -0
- package/core/dag-executor.ts +426 -0
- package/core/dag-loader.ts +292 -0
- package/core/dag-schemas.ts +150 -0
- package/core/dispatch.ts +125 -0
- package/core/evidence.ts +148 -0
- package/core/gate.ts +269 -0
- package/core/hook-engine.ts +566 -0
- package/core/host-probe.ts +64 -0
- package/core/implement.ts +16 -0
- package/core/isolation-errors.ts +174 -0
- package/core/isolation-resolver.ts +921 -0
- package/core/issue-context.ts +381 -0
- package/core/issue-readiness.ts +457 -0
- package/core/issue-sync.ts +427 -0
- package/core/issues.ts +132 -0
- package/core/land.ts +108 -0
- package/core/orchestrator.ts +54 -0
- package/core/phase-artifact.ts +32 -0
- package/core/phase-gates.ts +130 -0
- package/core/phase-rewind.ts +94 -0
- package/core/plan-lint.ts +61 -0
- package/core/plan.ts +77 -0
- package/core/port-allocation.ts +50 -0
- package/core/pr-check.ts +15 -0
- package/core/preset-system/preset-resolver.ts +221 -0
- package/core/project-init-status.ts +127 -0
- package/core/qa.ts +15 -0
- package/core/resilience.ts +165 -0
- package/core/runs.ts +288 -0
- package/core/safe-path.test.ts +80 -0
- package/core/safe-path.ts +60 -0
- package/core/sdd-gate.test.ts +98 -0
- package/core/sdd-gate.ts +134 -0
- package/core/self-review.ts +62 -0
- package/core/session.ts +70 -0
- package/core/ship.ts +86 -0
- package/core/specify.ts +173 -0
- package/core/state.ts +1002 -0
- package/core/template-engine.ts +152 -0
- package/core/template-resolver.test.ts +70 -0
- package/core/template-resolver.ts +156 -0
- package/core/triage.ts +26 -0
- package/core/verify.ts +15 -0
- package/core/wiki-native.ts +2423 -0
- package/core/wiki.ts +27 -0
- package/core/workflow-event-emitter.ts +163 -0
- package/core/workflows/engine.ts +273 -0
- package/core/workflows/expressions.ts +76 -0
- package/core/workflows/index.ts +38 -0
- package/core/workflows/steps/command.ts +43 -0
- package/core/workflows/steps/gate.ts +47 -0
- package/core/workflows/steps/gxpm.ts +44 -0
- package/core/workflows/steps/linear.ts +31 -0
- package/core/workflows/steps/shell.ts +65 -0
- package/core/workflows/types.ts +62 -0
- package/core/workspace-runtime.ts +227 -0
- package/core/worktree-init-steps.ts +647 -0
- package/core/worktree-init.ts +330 -0
- package/core/worktree-owner.ts +143 -0
- package/docs/GXPM_VERIFY.md +98 -0
- package/docs/INSTALL_FOR_AGENTS.md +113 -0
- package/docs/README.md +57 -0
- package/docs/adr/adr-005-multi-platform-skill-converter.md +72 -0
- package/docs/agents/domain.md +30 -0
- package/docs/agents/issue-tracker.md +30 -0
- package/docs/agents/triage-labels.md +32 -0
- package/docs/architecture/gxpm-architecture-diagram.md +265 -0
- package/docs/architecture/gxpm-current-architecture.md +175 -0
- package/docs/architecture/gxpm-current-flow.md +278 -0
- package/docs/architecture/gxpm-replacement-architecture.md +211 -0
- package/docs/architecture/gxpm-target-architecture.md +449 -0
- package/docs/architecture/gxpm-v0-contract.md +311 -0
- package/docs/architecture/layered-workflow-boundaries.md +193 -0
- package/docs/architecture/preset-system.md +126 -0
- package/docs/architecture/scaffold-northstar.md +23 -0
- package/docs/brainstorms/2026-05-14-bdd-then-tdd-design.md +320 -0
- package/docs/brainstorms/README.md +22 -0
- package/docs/brainstorms/docs-knowledge-system-requirements.md +29 -0
- package/docs/governance/beta-skill-promotion.md +39 -0
- package/docs/governance/development-contract.md +144 -0
- package/docs/governance/gherkin-style.md +90 -0
- package/docs/governance/host-adapter.md +56 -0
- package/docs/governance/skill-authoring.md +87 -0
- package/docs/governance/skill-testing.md +356 -0
- package/docs/governance/template-authoring.md +53 -0
- package/docs/migrations/v0.2.md +51 -0
- package/docs/plans/README.md +23 -0
- package/docs/plans/bdd-then-tdd-plan.md +1767 -0
- package/docs/plans/docs-knowledge-system-plan.md +31 -0
- package/docs/plans/spec-kit-sdd-adoption-plan.md +305 -0
- package/docs/research/agents-md-best-practices.md +207 -0
- package/docs/research/archon-study.md +351 -0
- package/docs/research/claude-hooks-study.md +440 -0
- package/docs/research/codex-hooks-study.md +624 -0
- package/docs/research/everything-claude-code-study.md +252 -0
- package/docs/research/from-skills-to-layered-workflow.md +322 -0
- package/docs/research/gsd-study.md +69 -0
- package/docs/research/kimi-hooks-study.md +274 -0
- package/docs/research/mattpocock-skills-comparison.md +429 -0
- package/docs/research/mattpocock-skills-study.md +275 -0
- package/docs/research/oh-my-codex-study.md +279 -0
- package/docs/research/perplexity-agent-skills-design.md +168 -0
- package/docs/research/pmc-gstack-skill-study.md +122 -0
- package/docs/research/spec-kit-study.md +224 -0
- package/docs/research/superpowers-study.md +209 -0
- package/docs/roadmap/initial-roadmap.md +53 -0
- package/docs/solutions/README.md +45 -0
- package/docs/solutions/artifact-nesting-recovery.md +58 -0
- package/docs/solutions/session-context-restore-practice.md +67 -0
- package/docs/solutions/workflow/version-drift-recovery.md +49 -0
- package/docs/solutions/worktree-gate-recovery.md +62 -0
- package/docs/specs/README.md +28 -0
- package/docs/specs/claude.md +45 -0
- package/docs/specs/codex.md +44 -0
- package/docs/specs/cursor.md +44 -0
- package/hosts/adapters/claude.ts +29 -0
- package/hosts/adapters/codex.ts +27 -0
- package/hosts/adapters/cursor.ts +27 -0
- package/hosts/adapters/kimi.ts +27 -0
- package/hosts/claude.ts +23 -0
- package/hosts/codex.ts +26 -0
- package/hosts/cursor.ts +19 -0
- package/hosts/index.ts +33 -0
- package/hosts/registry.test.ts +52 -0
- package/hosts/registry.ts +57 -0
- package/hosts/schema.ts +58 -0
- package/package.json +52 -0
- package/scripts/browser.ts +185 -0
- package/scripts/cleanup.ts +142 -0
- package/scripts/commands/artifact.ts +115 -0
- package/scripts/commands/autopilot.ts +143 -0
- package/scripts/commands/capability.ts +57 -0
- package/scripts/commands/config.ts +69 -0
- package/scripts/commands/dag.ts +126 -0
- package/scripts/commands/feedback.ts +123 -0
- package/scripts/commands/gate.ts +291 -0
- package/scripts/commands/helpers.ts +126 -0
- package/scripts/commands/hook.ts +66 -0
- package/scripts/commands/init.ts +515 -0
- package/scripts/commands/issue.ts +825 -0
- package/scripts/commands/phase.ts +61 -0
- package/scripts/commands/preset.ts +159 -0
- package/scripts/commands/runtime.ts +199 -0
- package/scripts/commands/specify.ts +71 -0
- package/scripts/commands/upgrade.ts +243 -0
- package/scripts/commands/verify.ts +183 -0
- package/scripts/commands/wiki.ts +242 -0
- package/scripts/commands/workflow.ts +131 -0
- package/scripts/dev-skill.ts +55 -0
- package/scripts/discover-skills.ts +116 -0
- package/scripts/doctor.ts +410 -0
- package/scripts/dogfood-check.ts +125 -0
- package/scripts/eval-functional.ts +218 -0
- package/scripts/eval.ts +246 -0
- package/scripts/gen-skill-docs.ts +201 -0
- package/scripts/global-discover.ts +217 -0
- package/scripts/governance-check.ts +75 -0
- package/scripts/gxpm-check.ts +12 -0
- package/scripts/gxpm.ts +216 -0
- package/scripts/host-config.ts +62 -0
- package/scripts/install-claude-hooks.ts +138 -0
- package/scripts/install-codex-hooks.ts +271 -0
- package/scripts/install-hooks.ts +128 -0
- package/scripts/install-kimi-hooks.ts +92 -0
- package/scripts/install-skill.ts +184 -0
- package/scripts/phase-artifact-commands.ts +100 -0
- package/scripts/post-land-sync.ts +46 -0
- package/scripts/scaffold-check.ts +85 -0
- package/scripts/skill-naming-check.ts +78 -0
- package/scripts/skill-structure-check.ts +157 -0
- package/scripts/skills-lock-check.ts +60 -0
- package/scripts/sync-markdown-artifacts.ts +172 -0
- package/scripts/uninstall.ts +162 -0
- package/scripts/version.ts +47 -0
- package/scripts/wait-pr-ready.ts +407 -0
- package/skills/gxpm/SKILL.md +485 -0
- package/skills/gxpm/SKILL.md.tmpl +422 -0
- package/skills/gxpm/references/CANON.md +53 -0
- package/skills/gxpm/references/key-rules.md +130 -0
- package/skills/gxpm-architecture/SKILL.md +106 -0
- package/skills/gxpm-architecture/references/DEEPENING.md +37 -0
- package/skills/gxpm-architecture/references/INTERFACE-DESIGN.md +44 -0
- package/skills/gxpm-autopilot/SKILL.md +116 -0
- package/skills/gxpm-autopilot/SKILL.md.tmpl +107 -0
- package/skills/gxpm-browser/SKILL.md +105 -0
- package/skills/gxpm-browser/SKILL.md.tmpl +41 -0
- package/skills/gxpm-browser/references/commands.md +43 -0
- package/skills/gxpm-browser/references/evidence-path.md +20 -0
- package/skills/gxpm-build/SKILL.md +78 -0
- package/skills/gxpm-cleanup/SKILL.md +76 -0
- package/skills/gxpm-debug-issue/SKILL.md +39 -0
- package/skills/gxpm-diagnose/SKILL.md +220 -0
- package/skills/gxpm-diagnose/SKILL.md.tmpl +31 -0
- package/skills/gxpm-diagnose/references/feedback-loop.md +34 -0
- package/skills/gxpm-diagnose/references/feedback-loops.md +43 -0
- package/skills/gxpm-diagnose/references/phases.md +60 -0
- package/skills/gxpm-eval/SKILL.md +78 -0
- package/skills/gxpm-explore-codebase/SKILL.md +36 -0
- package/skills/gxpm-explore-codebase/scripts/summarize-communities.ts +51 -0
- package/skills/gxpm-feedback/SKILL.md +122 -0
- package/skills/gxpm-grill/SKILL.md +159 -0
- package/skills/gxpm-grill/SKILL.md.tmpl +77 -0
- package/skills/gxpm-grill/references/documentation-templates.md +56 -0
- package/skills/gxpm-grill/references/process.md +25 -0
- package/skills/gxpm-handoff/SKILL.md +112 -0
- package/skills/gxpm-hygiene/SKILL.md +69 -0
- package/skills/gxpm-implementer/SKILL.md +142 -0
- package/skills/gxpm-implementer/SKILL.md.tmpl +141 -0
- package/skills/gxpm-linear/SKILL.md +282 -0
- package/skills/gxpm-linear/SKILL.md.tmpl +86 -0
- package/skills/gxpm-linear/references/commands.md +75 -0
- package/skills/gxpm-linear/references/workflows.md +120 -0
- package/skills/gxpm-planning/SKILL.md +134 -0
- package/skills/gxpm-prototype/SKILL.md +64 -0
- package/skills/gxpm-refactor-safely/SKILL.md +62 -0
- package/skills/gxpm-review-army/SKILL.md +117 -0
- package/skills/gxpm-review-changes/SKILL.md +36 -0
- package/skills/gxpm-setup/SKILL.md +101 -0
- package/skills/gxpm-specifier/SKILL.md +135 -0
- package/skills/gxpm-tdd/SKILL.md +187 -0
- package/skills/gxpm-tdd/references/interface-design.md +23 -0
- package/skills/gxpm-tdd/references/mocking.md +27 -0
- package/skills/gxpm-tdd/references/red-green-refactor.md +61 -0
- package/skills/gxpm-tdd/references/troubleshooting.md +28 -0
- package/skills/gxpm-tdd/references/workflow.md +50 -0
- package/skills/gxpm-tdd/testing-anti-patterns.tmpl +304 -0
- package/skills/gxpm-triage/SKILL.md +160 -0
- package/skills/gxpm-verify/SKILL.md +107 -0
- package/skills/gxpm-write-skill/SKILL.md +131 -0
- package/skills/gxpm-zoom-out/SKILL.md +69 -0
- package/skills/maintain-hygiene-skills-lock/SKILL.md +54 -0
- package/skills/maintain-hygiene-skills-lock/SKILL.md.tmpl +53 -0
- package/templates/constitution-template.md +63 -0
- package/templates/hooks/gxpm-commit-msg +16 -0
- package/templates/hooks/gxpm-post-checkout +19 -0
- package/templates/hooks/gxpm-post-commit +7 -0
- package/templates/hooks/gxpm-post-merge +29 -0
- package/templates/hooks/gxpm-pre-commit +39 -0
- package/templates/hooks/gxpm-pre-push +33 -0
- package/templates/plan-template.md.tmpl +46 -0
- package/templates/spec-template.md.tmpl +63 -0
- package/templates/specify-stub.tmpl +22 -0
- package/templates/tasks-template.md.tmpl +32 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IsolationResolver — automated worktree reuse/create decision for gxpm issues.
|
|
3
|
+
*
|
|
4
|
+
* Six-layer resolution strategy:
|
|
5
|
+
* 1. Existing environment reference (from issue runs or dispatch-handoff)
|
|
6
|
+
* 2. No codebase = skip isolation (plain directory workspace)
|
|
7
|
+
* 3. Workflow reuse (same codebase + workflow identity)
|
|
8
|
+
* 4. Linked issue sharing (cross-issue workspace reuse)
|
|
9
|
+
* 5. PR branch adoption (existing worktree for branch)
|
|
10
|
+
* 6. Create new worktree
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
14
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
15
|
+
import { classifyIsolationError, isKnownIsolationError } from "./isolation-errors";
|
|
16
|
+
import { readArtifact } from "./artifacts";
|
|
17
|
+
import { getIssuePaths, readIssueState } from "./state";
|
|
18
|
+
import { sanitizeWorkspaceKey } from "./workspace-runtime";
|
|
19
|
+
import { listRuns } from "./runs";
|
|
20
|
+
import { runWorktreeInit, type WorktreeInitContext } from "./worktree-init";
|
|
21
|
+
import "./worktree-init-steps"; // side-effect: registers built-in init steps
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface IsolationEnvironment {
|
|
28
|
+
issueId: string;
|
|
29
|
+
workspacePath: string;
|
|
30
|
+
branchName?: string;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
status: "active" | "destroyed";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type IsolationMethod =
|
|
36
|
+
| { type: "existing" }
|
|
37
|
+
| { type: "workflow_reuse" }
|
|
38
|
+
| { type: "linked_issue_reuse"; linkedIssueId: string }
|
|
39
|
+
| { type: "branch_adoption"; branch: string }
|
|
40
|
+
| { type: "created" };
|
|
41
|
+
|
|
42
|
+
export interface IsolationResolution {
|
|
43
|
+
status: "resolved" | "stale_cleaned" | "blocked" | "none";
|
|
44
|
+
cwd: string;
|
|
45
|
+
method?: IsolationMethod;
|
|
46
|
+
env?: IsolationEnvironment;
|
|
47
|
+
warnings?: string[];
|
|
48
|
+
userMessage?: string;
|
|
49
|
+
previousEnvId?: string;
|
|
50
|
+
reason?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IsolationHints {
|
|
54
|
+
baseBranch?: string;
|
|
55
|
+
prBranch?: string;
|
|
56
|
+
linkedIssues?: string[];
|
|
57
|
+
workflowType?: string;
|
|
58
|
+
workflowId?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ResolveIsolationInput {
|
|
62
|
+
issueId: string;
|
|
63
|
+
root?: string;
|
|
64
|
+
hints?: IsolationHints;
|
|
65
|
+
existingEnvId?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface IsolationRequest {
|
|
69
|
+
codebaseId: string;
|
|
70
|
+
codebaseName: string;
|
|
71
|
+
canonicalRepoPath: string;
|
|
72
|
+
identifier: string;
|
|
73
|
+
workflowType: string;
|
|
74
|
+
prBranch?: string;
|
|
75
|
+
fromBranch?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface IsolationCreateResult {
|
|
79
|
+
workingPath: string;
|
|
80
|
+
branchName: string;
|
|
81
|
+
warnings?: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface IsolationDestroyOptions {
|
|
85
|
+
canonicalRepoPath: string;
|
|
86
|
+
branchName: string;
|
|
87
|
+
force?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Lightweight filesystem store backed by gxpm issue state. */
|
|
91
|
+
export interface IIsolationStore {
|
|
92
|
+
getById(envId: string): Promise<IsolationEnvironment | undefined>;
|
|
93
|
+
findActiveByWorkflow(codebaseId: string, workflowType: string, workflowId: string): Promise<IsolationEnvironment | undefined>;
|
|
94
|
+
findActiveByIssueId(issueId: string): Promise<IsolationEnvironment | undefined>;
|
|
95
|
+
create(data: Omit<IsolationEnvironment, "createdAt">): Promise<IsolationEnvironment>;
|
|
96
|
+
updateStatus(envId: string, status: IsolationEnvironment["status"]): Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Provider for creating/destroying actual worktrees. */
|
|
100
|
+
export interface IIsolationProvider {
|
|
101
|
+
create(request: IsolationRequest): Promise<IsolationCreateResult>;
|
|
102
|
+
destroy(workingPath: string, options: IsolationDestroyOptions): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface IsolationResolverDeps {
|
|
106
|
+
store: IIsolationStore;
|
|
107
|
+
provider: IIsolationProvider;
|
|
108
|
+
staleThresholdDays?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Default filesystem store (gxpm native)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
export function createFileSystemStore(root: string): IIsolationStore {
|
|
116
|
+
return {
|
|
117
|
+
async getById(envId: string): Promise<IsolationEnvironment | undefined> {
|
|
118
|
+
try {
|
|
119
|
+
const state = readIssueState({ root, issueId: envId });
|
|
120
|
+
const paths = getIssuePaths(root, envId);
|
|
121
|
+
const handoff = readArtifact({ root, issueId: envId, type: "dispatch-handoff" });
|
|
122
|
+
const payload = (handoff.payload ?? {}) as Record<string, unknown>;
|
|
123
|
+
const workspacePath =
|
|
124
|
+
(payload.worktree as string) ??
|
|
125
|
+
(payload.workspace as string) ??
|
|
126
|
+
(payload.worktreePath as string);
|
|
127
|
+
if (!workspacePath) return undefined;
|
|
128
|
+
return {
|
|
129
|
+
issueId: envId,
|
|
130
|
+
workspacePath,
|
|
131
|
+
branchName: (payload.branch as string) ?? (payload.targetBranch as string) ?? undefined,
|
|
132
|
+
createdAt: state.createdAt,
|
|
133
|
+
status: "active",
|
|
134
|
+
};
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async findActiveByWorkflow(
|
|
141
|
+
_codebaseId: string,
|
|
142
|
+
workflowType: string,
|
|
143
|
+
workflowId: string,
|
|
144
|
+
): Promise<IsolationEnvironment | undefined> {
|
|
145
|
+
// Scan all issues for matching workflow in dispatch-handoff or runs
|
|
146
|
+
const issuesDir = join(root, ".gxpm", "issues");
|
|
147
|
+
if (!existsSync(issuesDir)) return undefined;
|
|
148
|
+
|
|
149
|
+
for (const entry of readdirSync(issuesDir)) {
|
|
150
|
+
try {
|
|
151
|
+
const handoff = readArtifact({ root, issueId: entry, type: "dispatch-handoff" });
|
|
152
|
+
const payload = (handoff.payload ?? {}) as Record<string, unknown>;
|
|
153
|
+
const wtWorkflowType = payload.workflowType as string | undefined;
|
|
154
|
+
const wtWorkflowId = payload.workflowId as string | undefined;
|
|
155
|
+
if (wtWorkflowType === workflowType && wtWorkflowId === workflowId) {
|
|
156
|
+
const env = await this.getById(entry);
|
|
157
|
+
if (env && env.status === "active") return env;
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// ignore missing artifacts
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async findActiveByIssueId(issueId: string): Promise<IsolationEnvironment | undefined> {
|
|
167
|
+
const env = await this.getById(issueId);
|
|
168
|
+
return env && env.status === "active" ? env : undefined;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async create(data: Omit<IsolationEnvironment, "createdAt">): Promise<IsolationEnvironment> {
|
|
172
|
+
const env: IsolationEnvironment = { ...data, createdAt: new Date().toISOString() };
|
|
173
|
+
// In filesystem store, the env is implicitly stored via dispatch-handoff
|
|
174
|
+
return env;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async updateStatus(envId: string, status: IsolationEnvironment["status"]): Promise<void> {
|
|
178
|
+
// Best-effort: no-op for filesystem store; caller logs if needed.
|
|
179
|
+
void envId;
|
|
180
|
+
void status;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Default git provider
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export function createGitProvider(): IIsolationProvider {
|
|
190
|
+
return {
|
|
191
|
+
async create(request: IsolationRequest): Promise<IsolationCreateResult> {
|
|
192
|
+
const branchName = request.prBranch ?? `gxpm-${request.identifier}`;
|
|
193
|
+
const worktreePath = join(request.canonicalRepoPath, ".gxpm", "worktrees", branchName);
|
|
194
|
+
|
|
195
|
+
async function runInit(workingPath: string, baseBranch?: string, baseSha?: string): Promise<string[]> {
|
|
196
|
+
const initCtx: WorktreeInitContext = {
|
|
197
|
+
canonicalRepoPath: request.canonicalRepoPath,
|
|
198
|
+
worktreePath: workingPath,
|
|
199
|
+
branchName,
|
|
200
|
+
issueId: request.identifier,
|
|
201
|
+
baseBranch,
|
|
202
|
+
baseSha,
|
|
203
|
+
};
|
|
204
|
+
const initResult = await runWorktreeInit(initCtx);
|
|
205
|
+
return initResult.warnings;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Check if branch already exists
|
|
209
|
+
const branchExists = Bun.spawnSync({
|
|
210
|
+
cmd: ["git", "branch", "--list", branchName],
|
|
211
|
+
cwd: request.canonicalRepoPath,
|
|
212
|
+
stdout: "pipe",
|
|
213
|
+
stderr: "pipe",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (branchExists.stdout.toString().trim().includes(branchName)) {
|
|
217
|
+
// Branch exists; check if worktree already exists
|
|
218
|
+
const wtList = Bun.spawnSync({
|
|
219
|
+
cmd: ["git", "worktree", "list", "--porcelain"],
|
|
220
|
+
cwd: request.canonicalRepoPath,
|
|
221
|
+
stdout: "pipe",
|
|
222
|
+
stderr: "pipe",
|
|
223
|
+
});
|
|
224
|
+
const lines = wtList.stdout.toString().split("\n");
|
|
225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
226
|
+
if (lines[i].startsWith("worktree ")) {
|
|
227
|
+
const path = lines[i].slice("worktree ".length);
|
|
228
|
+
const branchLine = lines[i + 2]; // typically "branch refs/heads/..."
|
|
229
|
+
if (branchLine && branchLine.includes(`refs/heads/${branchName}`)) {
|
|
230
|
+
const workingPath = resolve(path);
|
|
231
|
+
const warnings = [
|
|
232
|
+
"Reused existing worktree for branch.",
|
|
233
|
+
...ensureSharedGxpmLink({
|
|
234
|
+
canonicalRepoPath: request.canonicalRepoPath,
|
|
235
|
+
worktreePath: workingPath,
|
|
236
|
+
}),
|
|
237
|
+
...ensureNodeModulesSymlinks({
|
|
238
|
+
canonicalRepoPath: request.canonicalRepoPath,
|
|
239
|
+
worktreePath: workingPath,
|
|
240
|
+
}),
|
|
241
|
+
...await runInit(workingPath),
|
|
242
|
+
];
|
|
243
|
+
return { workingPath, branchName, warnings };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Determine base branch for worktree creation. Prefer the configured
|
|
250
|
+
// remote branch, then a local branch, and only fall back to HEAD when
|
|
251
|
+
// neither exists.
|
|
252
|
+
const baseBranch = request.fromBranch ?? "main";
|
|
253
|
+
const remoteRef = `origin/${baseBranch}`;
|
|
254
|
+
|
|
255
|
+
// Try to fetch latest remote state (best-effort; network failures are non-blocking)
|
|
256
|
+
Bun.spawnSync({
|
|
257
|
+
cmd: ["git", "fetch", "origin", baseBranch],
|
|
258
|
+
cwd: request.canonicalRepoPath,
|
|
259
|
+
stdout: "pipe",
|
|
260
|
+
stderr: "pipe",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Check if remote base branch exists
|
|
264
|
+
const remoteCheck = Bun.spawnSync({
|
|
265
|
+
cmd: ["git", "rev-parse", "--verify", remoteRef],
|
|
266
|
+
cwd: request.canonicalRepoPath,
|
|
267
|
+
stdout: "pipe",
|
|
268
|
+
stderr: "pipe",
|
|
269
|
+
});
|
|
270
|
+
const hasRemote = remoteCheck.exitCode === 0;
|
|
271
|
+
|
|
272
|
+
const localCheck = Bun.spawnSync({
|
|
273
|
+
cmd: ["git", "rev-parse", "--verify", baseBranch],
|
|
274
|
+
cwd: request.canonicalRepoPath,
|
|
275
|
+
stdout: "pipe",
|
|
276
|
+
stderr: "pipe",
|
|
277
|
+
});
|
|
278
|
+
const hasLocal = localCheck.exitCode === 0;
|
|
279
|
+
|
|
280
|
+
const startPoint = hasRemote ? remoteRef : hasLocal ? baseBranch : undefined;
|
|
281
|
+
|
|
282
|
+
// Resolve base sha for init context
|
|
283
|
+
let baseSha: string | undefined;
|
|
284
|
+
if (startPoint) {
|
|
285
|
+
const shaResult = Bun.spawnSync({
|
|
286
|
+
cmd: ["git", "rev-parse", startPoint],
|
|
287
|
+
cwd: request.canonicalRepoPath,
|
|
288
|
+
stdout: "pipe",
|
|
289
|
+
stderr: "pipe",
|
|
290
|
+
});
|
|
291
|
+
if (shaResult.exitCode === 0) {
|
|
292
|
+
baseSha = shaResult.stdout.toString().trim();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Create worktree based on configured branch if available, else fall back to local HEAD.
|
|
297
|
+
const createResult = Bun.spawnSync({
|
|
298
|
+
cmd: hasRemote
|
|
299
|
+
? ["git", "worktree", "add", "-b", branchName, "--track", worktreePath, remoteRef]
|
|
300
|
+
: startPoint
|
|
301
|
+
? ["git", "worktree", "add", "-b", branchName, worktreePath, startPoint]
|
|
302
|
+
: ["git", "worktree", "add", "-b", branchName, worktreePath],
|
|
303
|
+
cwd: request.canonicalRepoPath,
|
|
304
|
+
stdout: "pipe",
|
|
305
|
+
stderr: "pipe",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (createResult.exitCode !== 0) {
|
|
309
|
+
const err = new Error(createResult.stderr.toString().trim());
|
|
310
|
+
(err as Error & { stderr?: string }).stderr = createResult.stderr.toString().trim();
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const warnings: string[] = [];
|
|
315
|
+
if (!hasRemote && !hasLocal) {
|
|
316
|
+
warnings.push(`Base branch '${baseBranch}' not found locally or as '${remoteRef}'; worktree created from local HEAD.`);
|
|
317
|
+
} else if (!hasRemote) {
|
|
318
|
+
warnings.push(`Remote '${remoteRef}' not found; worktree created from local '${baseBranch}'.`);
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
warnings.push(
|
|
322
|
+
...ensureSharedGxpmLink({
|
|
323
|
+
canonicalRepoPath: request.canonicalRepoPath,
|
|
324
|
+
worktreePath,
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
warnings.push(
|
|
328
|
+
...ensureNodeModulesSymlinks({
|
|
329
|
+
canonicalRepoPath: request.canonicalRepoPath,
|
|
330
|
+
worktreePath,
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
warnings.push(...await runInit(resolve(worktreePath), baseBranch, baseSha));
|
|
334
|
+
} catch (error) {
|
|
335
|
+
Bun.spawnSync({
|
|
336
|
+
cmd: ["git", "worktree", "remove", "--force", worktreePath],
|
|
337
|
+
cwd: request.canonicalRepoPath,
|
|
338
|
+
stdout: "pipe",
|
|
339
|
+
stderr: "pipe",
|
|
340
|
+
});
|
|
341
|
+
Bun.spawnSync({
|
|
342
|
+
cmd: ["git", "branch", "-D", branchName],
|
|
343
|
+
cwd: request.canonicalRepoPath,
|
|
344
|
+
stdout: "pipe",
|
|
345
|
+
stderr: "pipe",
|
|
346
|
+
});
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { workingPath: resolve(worktreePath), branchName, warnings };
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
async destroy(workingPath: string, options: IsolationDestroyOptions): Promise<void> {
|
|
354
|
+
const result = Bun.spawnSync({
|
|
355
|
+
cmd: ["git", "worktree", "remove", options.force ? "--force" : "", workingPath].filter(Boolean),
|
|
356
|
+
cwd: options.canonicalRepoPath,
|
|
357
|
+
stdout: "pipe",
|
|
358
|
+
stderr: "pipe",
|
|
359
|
+
});
|
|
360
|
+
if (result.exitCode !== 0) {
|
|
361
|
+
throw new Error(result.stderr.toString().trim());
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Resolver
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
const DEFAULT_STALE_THRESHOLD_DAYS = 14;
|
|
372
|
+
|
|
373
|
+
export class IsolationResolver {
|
|
374
|
+
private readonly store: IIsolationStore;
|
|
375
|
+
private readonly provider: IIsolationProvider;
|
|
376
|
+
private readonly staleThresholdDays: number;
|
|
377
|
+
|
|
378
|
+
constructor(deps: IsolationResolverDeps) {
|
|
379
|
+
this.store = deps.store;
|
|
380
|
+
this.provider = deps.provider;
|
|
381
|
+
this.staleThresholdDays = deps.staleThresholdDays ?? DEFAULT_STALE_THRESHOLD_DAYS;
|
|
382
|
+
if (this.staleThresholdDays <= 0) {
|
|
383
|
+
throw new Error(`staleThresholdDays must be positive, got ${String(this.staleThresholdDays)}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async resolve(input: ResolveIsolationInput): Promise<IsolationResolution> {
|
|
388
|
+
const root = input.root ?? process.cwd();
|
|
389
|
+
const issueId = input.issueId;
|
|
390
|
+
const hints = input.hints;
|
|
391
|
+
const baseBranch = hints?.baseBranch;
|
|
392
|
+
|
|
393
|
+
// 1. Check existing isolation reference
|
|
394
|
+
if (input.existingEnvId) {
|
|
395
|
+
const existing = await this.checkExisting(input.existingEnvId, baseBranch);
|
|
396
|
+
if (existing) return existing;
|
|
397
|
+
return { status: "stale_cleaned", previousEnvId: input.existingEnvId, cwd: root };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 2. No codebase = no isolation
|
|
401
|
+
const canonicalPath = await this.getCanonicalRepoPath(root);
|
|
402
|
+
if (!canonicalPath) {
|
|
403
|
+
const workspacePath = join(root, ".gxpm", "local", "workspaces", issueId);
|
|
404
|
+
return { status: "none", cwd: workspacePath };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const codebaseId = canonicalPath;
|
|
408
|
+
const workflowType = hints?.workflowType ?? "issue";
|
|
409
|
+
const workflowId = hints?.workflowId ?? issueId;
|
|
410
|
+
|
|
411
|
+
// 3. Check current directory / branch awareness
|
|
412
|
+
const currentDirMatch = await this.checkCurrentDirectory(codebaseId, issueId, baseBranch);
|
|
413
|
+
if (currentDirMatch) {
|
|
414
|
+
return currentDirMatch;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 4. Check for existing environment with same workflow
|
|
418
|
+
const reusable = await this.findReusable(codebaseId, workflowType, workflowId, baseBranch);
|
|
419
|
+
if (reusable) {
|
|
420
|
+
return {
|
|
421
|
+
status: "resolved",
|
|
422
|
+
env: reusable.env,
|
|
423
|
+
cwd: reusable.env.workspacePath,
|
|
424
|
+
method: { type: "workflow_reuse" },
|
|
425
|
+
...(reusable.warnings.length > 0 ? { warnings: reusable.warnings } : {}),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 4. Check linked issues for sharing
|
|
430
|
+
if (hints?.linkedIssues?.length) {
|
|
431
|
+
const linked = await this.findLinkedIssueEnv(codebaseId, hints.linkedIssues);
|
|
432
|
+
if (linked) return linked;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 5. Try PR branch adoption
|
|
436
|
+
if (hints?.prBranch) {
|
|
437
|
+
const adopted = await this.tryBranchAdoption(codebaseId, canonicalPath, hints, workflowType, workflowId);
|
|
438
|
+
if (adopted) return adopted;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 6. Create new environment
|
|
442
|
+
return this.createNewEnvironment(codebaseId, canonicalPath, workflowType, workflowId, hints);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// -------------------------------------------------------------------------
|
|
446
|
+
// Layer 1: Existing
|
|
447
|
+
// -------------------------------------------------------------------------
|
|
448
|
+
private async checkExisting(envId: string, baseBranch?: string): Promise<IsolationResolution | null> {
|
|
449
|
+
const env = await this.store.getById(envId);
|
|
450
|
+
if (env && existsSync(env.workspacePath)) {
|
|
451
|
+
const warnings = await this.collectBaseBranchWarnings(env, baseBranch, { envId });
|
|
452
|
+
return {
|
|
453
|
+
status: "resolved",
|
|
454
|
+
env,
|
|
455
|
+
cwd: env.workspacePath,
|
|
456
|
+
method: { type: "existing" },
|
|
457
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
if (env) {
|
|
461
|
+
await this.markDestroyedBestEffort(env.issueId);
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// -------------------------------------------------------------------------
|
|
467
|
+
// Layer 2.5: Current directory / branch awareness
|
|
468
|
+
// -------------------------------------------------------------------------
|
|
469
|
+
private async checkCurrentDirectory(
|
|
470
|
+
codebaseId: string,
|
|
471
|
+
issueId: string,
|
|
472
|
+
baseBranch?: string,
|
|
473
|
+
): Promise<IsolationResolution | null> {
|
|
474
|
+
const cwd = process.cwd();
|
|
475
|
+
|
|
476
|
+
// 2.5a: Check .gxpm-worktree-owner.json in current directory
|
|
477
|
+
try {
|
|
478
|
+
const ownerPath = join(cwd, ".gxpm-worktree-owner.json");
|
|
479
|
+
if (existsSync(ownerPath)) {
|
|
480
|
+
const owner = JSON.parse(readFileSync(ownerPath, "utf8")) as Record<string, unknown>;
|
|
481
|
+
const ownerIssueId = typeof owner.ownerIssueId === "string" ? owner.ownerIssueId : "";
|
|
482
|
+
if (ownerIssueId === issueId) {
|
|
483
|
+
const env: IsolationEnvironment = {
|
|
484
|
+
issueId,
|
|
485
|
+
workspacePath: cwd,
|
|
486
|
+
createdAt: new Date().toISOString(),
|
|
487
|
+
status: "active",
|
|
488
|
+
};
|
|
489
|
+
const warnings = await this.collectBaseBranchWarnings(env, baseBranch, { cwd });
|
|
490
|
+
return {
|
|
491
|
+
status: "resolved",
|
|
492
|
+
env,
|
|
493
|
+
cwd,
|
|
494
|
+
method: { type: "existing" },
|
|
495
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
// ignore parse/read errors
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// 2.5b: Check current git branch matches gxpm-<issueId>
|
|
504
|
+
try {
|
|
505
|
+
const branchResult = Bun.spawnSync({
|
|
506
|
+
cmd: ["git", "branch", "--show-current"],
|
|
507
|
+
cwd,
|
|
508
|
+
stdout: "pipe",
|
|
509
|
+
stderr: "pipe",
|
|
510
|
+
});
|
|
511
|
+
if (branchResult.exitCode === 0) {
|
|
512
|
+
const currentBranch = branchResult.stdout.toString().trim();
|
|
513
|
+
const expectedBranches = [`gxpm-${issueId}`, `gxpm-${sanitizeWorkspaceKey(issueId)}`];
|
|
514
|
+
if (expectedBranches.includes(currentBranch)) {
|
|
515
|
+
const env: IsolationEnvironment = {
|
|
516
|
+
issueId,
|
|
517
|
+
workspacePath: cwd,
|
|
518
|
+
branchName: currentBranch,
|
|
519
|
+
createdAt: new Date().toISOString(),
|
|
520
|
+
status: "active",
|
|
521
|
+
};
|
|
522
|
+
const warnings = await this.collectBaseBranchWarnings(env, baseBranch, { cwd });
|
|
523
|
+
return {
|
|
524
|
+
status: "resolved",
|
|
525
|
+
env,
|
|
526
|
+
cwd,
|
|
527
|
+
method: { type: "existing" },
|
|
528
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} catch {
|
|
533
|
+
// ignore git errors
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// -------------------------------------------------------------------------
|
|
540
|
+
// Layer 3: Workflow reuse
|
|
541
|
+
// -------------------------------------------------------------------------
|
|
542
|
+
private async findReusable(
|
|
543
|
+
codebaseId: string,
|
|
544
|
+
workflowType: string,
|
|
545
|
+
workflowId: string,
|
|
546
|
+
baseBranch?: string,
|
|
547
|
+
): Promise<{ env: IsolationEnvironment; warnings: string[] } | null> {
|
|
548
|
+
const existing = await this.store.findActiveByWorkflow(codebaseId, workflowType, workflowId);
|
|
549
|
+
if (!existing) return null;
|
|
550
|
+
|
|
551
|
+
if (existsSync(existing.workspacePath)) {
|
|
552
|
+
const warnings = await this.collectBaseBranchWarnings(existing, baseBranch, {
|
|
553
|
+
workflowType,
|
|
554
|
+
workflowId,
|
|
555
|
+
});
|
|
556
|
+
return { env: existing, warnings };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await this.markDestroyedBestEffort(existing.issueId);
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// -------------------------------------------------------------------------
|
|
564
|
+
// Layer 4: Linked issue sharing
|
|
565
|
+
// -------------------------------------------------------------------------
|
|
566
|
+
private async findLinkedIssueEnv(
|
|
567
|
+
codebaseId: string,
|
|
568
|
+
linkedIssues: string[],
|
|
569
|
+
): Promise<IsolationResolution | null> {
|
|
570
|
+
for (const linkedIssueId of linkedIssues) {
|
|
571
|
+
const linkedEnv = await this.store.findActiveByIssueId(linkedIssueId);
|
|
572
|
+
if (!linkedEnv) continue;
|
|
573
|
+
|
|
574
|
+
if (existsSync(linkedEnv.workspacePath)) {
|
|
575
|
+
return {
|
|
576
|
+
status: "resolved",
|
|
577
|
+
env: linkedEnv,
|
|
578
|
+
cwd: linkedEnv.workspacePath,
|
|
579
|
+
method: { type: "linked_issue_reuse", linkedIssueId },
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
await this.markDestroyedBestEffort(linkedEnv.issueId);
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// -------------------------------------------------------------------------
|
|
589
|
+
// Layer 5: PR branch adoption
|
|
590
|
+
// -------------------------------------------------------------------------
|
|
591
|
+
private async tryBranchAdoption(
|
|
592
|
+
codebaseId: string,
|
|
593
|
+
canonicalRepoPath: string,
|
|
594
|
+
hints: IsolationHints,
|
|
595
|
+
workflowType: string,
|
|
596
|
+
workflowId: string,
|
|
597
|
+
): Promise<IsolationResolution | null> {
|
|
598
|
+
const prBranch = hints.prBranch;
|
|
599
|
+
if (!prBranch) return null;
|
|
600
|
+
|
|
601
|
+
// Find worktree by branch
|
|
602
|
+
const wtList = Bun.spawnSync({
|
|
603
|
+
cmd: ["git", "worktree", "list", "--porcelain"],
|
|
604
|
+
cwd: canonicalRepoPath,
|
|
605
|
+
stdout: "pipe",
|
|
606
|
+
stderr: "pipe",
|
|
607
|
+
});
|
|
608
|
+
if (wtList.exitCode !== 0) return null;
|
|
609
|
+
|
|
610
|
+
const lines = wtList.stdout.toString().split("\n");
|
|
611
|
+
for (let i = 0; i < lines.length; i++) {
|
|
612
|
+
if (lines[i].startsWith("worktree ")) {
|
|
613
|
+
const path = lines[i].slice("worktree ".length);
|
|
614
|
+
// Scan forward to find branch line for this worktree record
|
|
615
|
+
let branchLine: string | undefined;
|
|
616
|
+
for (let j = i + 1; j < lines.length && !lines[j].startsWith("worktree "); j++) {
|
|
617
|
+
if (lines[j].startsWith("branch ")) {
|
|
618
|
+
branchLine = lines[j];
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (branchLine && branchLine.includes(`refs/heads/${prBranch}`)) {
|
|
623
|
+
if (existsSync(path)) {
|
|
624
|
+
const workingPath = resolve(path);
|
|
625
|
+
const warnings = [
|
|
626
|
+
...ensureSharedGxpmLink({
|
|
627
|
+
canonicalRepoPath,
|
|
628
|
+
worktreePath: workingPath,
|
|
629
|
+
}),
|
|
630
|
+
...ensureNodeModulesSymlinks({
|
|
631
|
+
canonicalRepoPath,
|
|
632
|
+
worktreePath: workingPath,
|
|
633
|
+
}),
|
|
634
|
+
];
|
|
635
|
+
const env = await this.store.create({
|
|
636
|
+
issueId: workflowId,
|
|
637
|
+
workspacePath: workingPath,
|
|
638
|
+
branchName: prBranch,
|
|
639
|
+
status: "active",
|
|
640
|
+
});
|
|
641
|
+
return {
|
|
642
|
+
status: "resolved",
|
|
643
|
+
env,
|
|
644
|
+
cwd: env.workspacePath,
|
|
645
|
+
method: { type: "branch_adoption", branch: prBranch },
|
|
646
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// -------------------------------------------------------------------------
|
|
656
|
+
// Layer 6: Create new
|
|
657
|
+
// -------------------------------------------------------------------------
|
|
658
|
+
private async createNewEnvironment(
|
|
659
|
+
codebaseId: string,
|
|
660
|
+
canonicalRepoPath: string,
|
|
661
|
+
workflowType: string,
|
|
662
|
+
workflowId: string,
|
|
663
|
+
hints: IsolationHints | undefined,
|
|
664
|
+
): Promise<IsolationResolution> {
|
|
665
|
+
const request: IsolationRequest = {
|
|
666
|
+
codebaseId,
|
|
667
|
+
codebaseName: codebaseId.split("/").pop() ?? "repo",
|
|
668
|
+
canonicalRepoPath,
|
|
669
|
+
identifier: workflowId,
|
|
670
|
+
workflowType,
|
|
671
|
+
prBranch: hints?.prBranch,
|
|
672
|
+
fromBranch: hints?.baseBranch,
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
let isolatedEnv: IsolationCreateResult;
|
|
676
|
+
try {
|
|
677
|
+
isolatedEnv = await this.provider.create(request);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
680
|
+
if (!isKnownIsolationError(err)) {
|
|
681
|
+
throw err;
|
|
682
|
+
}
|
|
683
|
+
const userMessage = classifyIsolationError(err);
|
|
684
|
+
return {
|
|
685
|
+
status: "blocked",
|
|
686
|
+
reason: "creation_failed",
|
|
687
|
+
userMessage: `${userMessage} Execution blocked to prevent changes to shared codebase. Please resolve the issue and try again.`,
|
|
688
|
+
cwd: canonicalRepoPath,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
let env: IsolationEnvironment;
|
|
693
|
+
try {
|
|
694
|
+
env = await this.store.create({
|
|
695
|
+
issueId: workflowId,
|
|
696
|
+
workspacePath: isolatedEnv.workingPath,
|
|
697
|
+
branchName: isolatedEnv.branchName,
|
|
698
|
+
status: "active",
|
|
699
|
+
});
|
|
700
|
+
} catch (storeError) {
|
|
701
|
+
const err = storeError instanceof Error ? storeError : new Error(String(storeError));
|
|
702
|
+
// Clean up orphaned worktree — best-effort
|
|
703
|
+
try {
|
|
704
|
+
await this.provider.destroy(isolatedEnv.workingPath, {
|
|
705
|
+
canonicalRepoPath,
|
|
706
|
+
branchName: isolatedEnv.branchName,
|
|
707
|
+
force: true,
|
|
708
|
+
});
|
|
709
|
+
} catch {
|
|
710
|
+
// ignore cleanup failure
|
|
711
|
+
}
|
|
712
|
+
throw err;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
status: "resolved",
|
|
717
|
+
env,
|
|
718
|
+
cwd: env.workspacePath,
|
|
719
|
+
method: { type: "created" },
|
|
720
|
+
...(isolatedEnv.warnings?.length ? { warnings: isolatedEnv.warnings } : {}),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// -------------------------------------------------------------------------
|
|
725
|
+
// Helpers
|
|
726
|
+
// -------------------------------------------------------------------------
|
|
727
|
+
private async collectBaseBranchWarnings(
|
|
728
|
+
env: IsolationEnvironment,
|
|
729
|
+
baseBranch: string | undefined,
|
|
730
|
+
_logContext: Record<string, unknown>,
|
|
731
|
+
): Promise<string[]> {
|
|
732
|
+
if (!baseBranch || !env.branchName) return [];
|
|
733
|
+
try {
|
|
734
|
+
const result = Bun.spawnSync({
|
|
735
|
+
cmd: ["git", "merge-base", "--is-ancestor", `origin/${baseBranch}`, env.branchName],
|
|
736
|
+
cwd: env.workspacePath,
|
|
737
|
+
stdout: "pipe",
|
|
738
|
+
stderr: "pipe",
|
|
739
|
+
});
|
|
740
|
+
if (result.exitCode !== 0) {
|
|
741
|
+
return [
|
|
742
|
+
`Worktree branch '${env.branchName}' is not based on '${baseBranch}'. ` +
|
|
743
|
+
`Recreate with: gxpm cleanup land ${env.issueId} --force && retry.`,
|
|
744
|
+
];
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
// non-blocking
|
|
748
|
+
}
|
|
749
|
+
return [];
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private async markDestroyedBestEffort(envId: string): Promise<void> {
|
|
753
|
+
try {
|
|
754
|
+
await this.store.updateStatus(envId, "destroyed");
|
|
755
|
+
} catch {
|
|
756
|
+
// ignore
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private async getCanonicalRepoPath(cwd: string): Promise<string | undefined> {
|
|
761
|
+
const result = Bun.spawnSync({
|
|
762
|
+
cmd: ["git", "rev-parse", "--show-toplevel"],
|
|
763
|
+
cwd,
|
|
764
|
+
stdout: "pipe",
|
|
765
|
+
stderr: "pipe",
|
|
766
|
+
});
|
|
767
|
+
if (result.exitCode !== 0) return undefined;
|
|
768
|
+
const path = result.stdout.toString().trim();
|
|
769
|
+
return path || undefined;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// Node modules symlink sharing
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
function findNodeModulesDirs(basePath: string, depth = 0): string[] {
|
|
778
|
+
const results: string[] = [];
|
|
779
|
+
if (depth > 10) return results;
|
|
780
|
+
|
|
781
|
+
let entries;
|
|
782
|
+
try {
|
|
783
|
+
entries = readdirSync(basePath, { withFileTypes: true });
|
|
784
|
+
} catch {
|
|
785
|
+
return results;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
for (const entry of entries) {
|
|
789
|
+
if (!entry.isDirectory()) continue;
|
|
790
|
+
const fullPath = join(basePath, entry.name);
|
|
791
|
+
if (entry.name === "node_modules") {
|
|
792
|
+
results.push(fullPath);
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
results.push(...findNodeModulesDirs(fullPath, depth + 1));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return results;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export function ensureNodeModulesSymlinks(input: {
|
|
802
|
+
canonicalRepoPath: string;
|
|
803
|
+
worktreePath: string;
|
|
804
|
+
}): string[] {
|
|
805
|
+
const warnings: string[] = [];
|
|
806
|
+
const canonicalNodeModules = findNodeModulesDirs(input.canonicalRepoPath);
|
|
807
|
+
|
|
808
|
+
for (const canonicalNm of canonicalNodeModules) {
|
|
809
|
+
const relativePath = relative(input.canonicalRepoPath, canonicalNm);
|
|
810
|
+
const worktreeNm = resolve(input.worktreePath, relativePath);
|
|
811
|
+
|
|
812
|
+
let current;
|
|
813
|
+
try {
|
|
814
|
+
current = lstatSync(worktreeNm);
|
|
815
|
+
} catch {
|
|
816
|
+
current = undefined;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (current) {
|
|
820
|
+
if (current.isSymbolicLink()) {
|
|
821
|
+
let pointsToCanonical = false;
|
|
822
|
+
try {
|
|
823
|
+
const existingTarget = resolve(input.worktreePath, readlinkSync(worktreeNm));
|
|
824
|
+
pointsToCanonical = realpathSync(existingTarget) === realpathSync(canonicalNm);
|
|
825
|
+
} catch {
|
|
826
|
+
pointsToCanonical = false;
|
|
827
|
+
}
|
|
828
|
+
if (!pointsToCanonical) {
|
|
829
|
+
try {
|
|
830
|
+
rmSync(worktreeNm, { force: true });
|
|
831
|
+
mkdirSync(dirname(worktreeNm), { recursive: true });
|
|
832
|
+
symlinkSync(canonicalNm, worktreeNm, "dir");
|
|
833
|
+
warnings.push(
|
|
834
|
+
`Worktree ${relativePath} symlink pointed elsewhere; rewrote to ${canonicalNm}.`,
|
|
835
|
+
);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
838
|
+
warnings.push(`Worktree ${relativePath} symlink rewrite failed: ${message}`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (current.isDirectory()) {
|
|
845
|
+
warnings.push(
|
|
846
|
+
`Worktree ${relativePath} exists as a real directory; leaving unchanged to avoid data loss. ` +
|
|
847
|
+
`Remove it manually to enable symlink sharing.`,
|
|
848
|
+
);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
try {
|
|
854
|
+
mkdirSync(dirname(worktreeNm), { recursive: true });
|
|
855
|
+
symlinkSync(canonicalNm, worktreeNm, "dir");
|
|
856
|
+
} catch (error) {
|
|
857
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
858
|
+
warnings.push(`Failed to create ${relativePath} symlink: ${message}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return warnings;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function ensureSharedGxpmLink(input: {
|
|
866
|
+
canonicalRepoPath: string;
|
|
867
|
+
worktreePath: string;
|
|
868
|
+
}): string[] {
|
|
869
|
+
const linkPath = resolve(input.worktreePath, ".gxpm");
|
|
870
|
+
const warnings: string[] = [];
|
|
871
|
+
|
|
872
|
+
if (resolve(input.canonicalRepoPath) === resolve(input.worktreePath)) {
|
|
873
|
+
return warnings;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const canonicalGxpmPath = resolveCanonicalGxpmPath(input.canonicalRepoPath);
|
|
877
|
+
|
|
878
|
+
let current: ReturnType<typeof lstatSync> | undefined;
|
|
879
|
+
try {
|
|
880
|
+
current = lstatSync(linkPath);
|
|
881
|
+
} catch {
|
|
882
|
+
current = undefined;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (current) {
|
|
886
|
+
if (!current.isSymbolicLink()) {
|
|
887
|
+
warnings.push(`Worktree .gxpm exists and is not a symlink; leaving unchanged: ${linkPath}`);
|
|
888
|
+
return warnings;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const existingTarget = resolve(input.worktreePath, readlinkSync(linkPath));
|
|
892
|
+
let pointsToCanonical = false;
|
|
893
|
+
try {
|
|
894
|
+
pointsToCanonical = realpathSync(existingTarget) === realpathSync(canonicalGxpmPath);
|
|
895
|
+
} catch {
|
|
896
|
+
pointsToCanonical = false;
|
|
897
|
+
}
|
|
898
|
+
if (!pointsToCanonical) {
|
|
899
|
+
try {
|
|
900
|
+
rmSync(linkPath, { force: true });
|
|
901
|
+
symlinkSync(canonicalGxpmPath, linkPath, "dir");
|
|
902
|
+
warnings.push(`Worktree .gxpm symlink pointed to ${existingTarget}; rewrote to ${canonicalGxpmPath}.`);
|
|
903
|
+
} catch (error) {
|
|
904
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
905
|
+
warnings.push(
|
|
906
|
+
`Worktree .gxpm symlink points to ${existingTarget}; expected ${canonicalGxpmPath}; rewrite failed: ${message}`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return warnings;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
symlinkSync(canonicalGxpmPath, linkPath, "dir");
|
|
914
|
+
return warnings;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function resolveCanonicalGxpmPath(canonicalRepoPath: string): string {
|
|
918
|
+
const statePath = resolve(canonicalRepoPath, ".gxpm");
|
|
919
|
+
mkdirSync(statePath, { recursive: true });
|
|
920
|
+
return realpathSync(statePath);
|
|
921
|
+
}
|