code-workspace-zhuiyi 0.1.0-beta.1
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/.env.example +3 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/README.zh-CN.md +168 -0
- package/artifacts/manifest.json +183 -0
- package/artifacts/templates/USER_GUIDE.template.md +1 -0
- package/artifacts/templates/agents/WORKSPACE_GUARD.md.template +73 -0
- package/artifacts/templates/agents/skills/code-workspace-resolve-branch/SKILL.md +88 -0
- package/artifacts/templates/agents/skills/code-workspace-resolve-branch/evals/evals.json +45 -0
- package/artifacts/templates/claude/commands/code-workspace/add-projects.md +23 -0
- package/artifacts/templates/codex/hooks.json +86 -0
- package/artifacts/templates/codex/skills/code-workspace-add-projects/SKILL.md +28 -0
- package/artifacts/templates/user-guide/en-US.md +97 -0
- package/artifacts/templates/user-guide/zh-CN.md +97 -0
- package/assets/i18n-icon.svg +1 -0
- package/assets/logo-vector.svg +30 -0
- package/assets/request_tip.mp3 +0 -0
- package/assets/session_finish.mp3 +0 -0
- package/bin/code-workspace.js +11 -0
- package/docs/extension-architecture.zh-CN.md +429 -0
- package/docs/extensions.md +64 -0
- package/docs/extensions.zh-CN.md +64 -0
- package/extensions/openspec-workspace/1.0.0/artifacts/claude/SKILL.md +16 -0
- package/extensions/openspec-workspace/1.0.0/artifacts/codex/SKILL.md +16 -0
- package/extensions/openspec-workspace/1.0.0/init.js +54 -0
- package/extensions/openspec-workspace/1.0.0/manifest.json +27 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/artifacts/claude/server.json +14 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/artifacts/codex/config.toml +11 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/init.js +53 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/lib/archive.js +196 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/manifest.json +38 -0
- package/extensions/zhuiyi-jira-mcp/0.1.0/release.json +15 -0
- package/package.json +58 -0
- package/schemas/extension-init-context-v1.json +39 -0
- package/schemas/extension-init-result-v1.json +42 -0
- package/schemas/extension-manifest-v1.json +55 -0
- package/schemas/extension-manifest-v2.json +84 -0
- package/schemas/extension-manifest-v3.json +84 -0
- package/spec/extension/v1/specification.en-US.md +171 -0
- package/spec/extension/v1/specification.zh-CN.md +170 -0
- package/src/cli/commands/completion.js +203 -0
- package/src/cli/commands/extension.js +193 -0
- package/src/cli/commands/help.js +36 -0
- package/src/cli/commands/init.js +200 -0
- package/src/cli/commands/monitor.js +62 -0
- package/src/cli/commands/permissions.js +33 -0
- package/src/cli/commands/project-branch-update.js +107 -0
- package/src/cli/commands/project-branch.js +433 -0
- package/src/cli/commands/project.js +259 -0
- package/src/cli/commands/update.js +146 -0
- package/src/cli/commands/workspace.js +24 -0
- package/src/cli/confirmation.js +22 -0
- package/src/cli/parser.js +108 -0
- package/src/cli/registry.js +108 -0
- package/src/cli/renderer.js +20 -0
- package/src/cli/result.js +118 -0
- package/src/cli.js +76 -0
- package/src/core/assets.js +83 -0
- package/src/core/config.js +378 -0
- package/src/core/diagnostics.js +20 -0
- package/src/core/directory-digest.js +34 -0
- package/src/core/doctor.js +115 -0
- package/src/core/errors.js +10 -0
- package/src/core/extension-artifacts-legacy.js +180 -0
- package/src/core/extension-artifacts.js +310 -0
- package/src/core/extensions.js +1216 -0
- package/src/core/fs.js +32 -0
- package/src/core/init-lock-holder.js +30 -0
- package/src/core/init-lock.js +159 -0
- package/src/core/init.js +160 -0
- package/src/core/initializer.js +233 -0
- package/src/core/language.js +102 -0
- package/src/core/managed-files.js +334 -0
- package/src/core/migration.js +68 -0
- package/src/core/permissions/claude.js +92 -0
- package/src/core/permissions/codex.js +126 -0
- package/src/core/permissions/common.js +44 -0
- package/src/core/permissions/index.js +163 -0
- package/src/core/project-branch-update.js +424 -0
- package/src/core/project-configuration.js +55 -0
- package/src/core/project.js +674 -0
- package/src/core/tools.js +34 -0
- package/src/core/transaction.js +132 -0
- package/src/core/validation.js +186 -0
- package/src/i18n/index.js +11 -0
- package/src/i18n/interpolate.js +7 -0
- package/src/i18n/locales/en-US.js +10 -0
- package/src/i18n/locales/zh-CN.js +11 -0
- package/src/i18n/registry.js +42 -0
- package/src/index.js +25 -0
- package/src/init/plan.js +12 -0
- package/src/init/ui.js +61 -0
- package/src/init/wizard.js +97 -0
- package/src/monitor/i18n/index.js +33 -0
- package/src/monitor/i18n/locales/en-US.js +85 -0
- package/src/monitor/i18n/locales/zh-CN.js +79 -0
- package/src/monitor/index.js +344 -0
- package/src/monitor/page.js +118 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const { WorkspaceError } = require("./errors");
|
|
5
|
+
const { attachRetainedEffects } = require("./transaction");
|
|
6
|
+
|
|
7
|
+
const MANIFEST_FILES = [
|
|
8
|
+
"package.json",
|
|
9
|
+
"pom.xml",
|
|
10
|
+
"build.gradle",
|
|
11
|
+
"build.gradle.kts",
|
|
12
|
+
"settings.gradle",
|
|
13
|
+
"settings.gradle.kts",
|
|
14
|
+
"go.mod",
|
|
15
|
+
"Cargo.toml",
|
|
16
|
+
"pyproject.toml",
|
|
17
|
+
"requirements.txt",
|
|
18
|
+
"Gemfile",
|
|
19
|
+
"composer.json",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const README_FILES = [
|
|
23
|
+
"README.md",
|
|
24
|
+
"README.zh-CN.md",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function executeGit(location, args, options = {}) {
|
|
28
|
+
const result = spawnSync("git", ["-C", location, ...args], {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
timeout: options.timeout || 5000,
|
|
31
|
+
});
|
|
32
|
+
if (result.error) {
|
|
33
|
+
throw new WorkspaceError("GIT_COMMAND_FAILED", result.error.message, {
|
|
34
|
+
location,
|
|
35
|
+
args,
|
|
36
|
+
cause: result.error.code || result.error.name,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runGit(location, args, options = {}) {
|
|
43
|
+
const result = executeGit(location, args, options);
|
|
44
|
+
if (result.status !== 0) {
|
|
45
|
+
const detail = String(result.stderr || result.stdout || "").trim().split(/\r?\n/)[0];
|
|
46
|
+
throw new WorkspaceError("GIT_COMMAND_FAILED", detail || `git ${args.join(" ")} failed`, {
|
|
47
|
+
location,
|
|
48
|
+
args,
|
|
49
|
+
exitCode: result.status,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return String(result.stdout || "").trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gitWorktreeClean(location) {
|
|
56
|
+
return runGit(location, ["status", "--porcelain"]) === "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function gitLocalBranchExists(location, branch) {
|
|
60
|
+
const args = ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`];
|
|
61
|
+
const result = executeGit(location, args);
|
|
62
|
+
if (result.status === 0) return true;
|
|
63
|
+
if (result.status === 1) return false;
|
|
64
|
+
const detail = String(result.stderr || result.stdout || "").trim().split(/\r?\n/)[0];
|
|
65
|
+
throw new WorkspaceError("GIT_COMMAND_FAILED", detail || `git ${args.join(" ")} failed`, {
|
|
66
|
+
location,
|
|
67
|
+
args,
|
|
68
|
+
exitCode: result.status,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function gitRemoteTrackingBranches(location, branch, options = {}) {
|
|
73
|
+
const prefix = "refs/remotes/";
|
|
74
|
+
const result = executeGit(location, ["for-each-ref", "--format=%(refname)", "refs/remotes"], options);
|
|
75
|
+
if (result.status !== 0) {
|
|
76
|
+
const detail = String(result.stderr || result.stdout || "").trim().split(/\r?\n/)[0];
|
|
77
|
+
throw new WorkspaceError("GIT_COMMAND_FAILED", detail || "git for-each-ref failed", {
|
|
78
|
+
location,
|
|
79
|
+
args: ["for-each-ref", "--format=%(refname)", "refs/remotes"],
|
|
80
|
+
exitCode: result.status,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const suffix = `/${branch}`;
|
|
84
|
+
return String(result.stdout || "").trim().split(/\r?\n/)
|
|
85
|
+
.filter((ref) => ref.startsWith(prefix) && ref.endsWith(suffix))
|
|
86
|
+
.map((ref) => {
|
|
87
|
+
const remoteBranch = ref.slice(prefix.length);
|
|
88
|
+
return {
|
|
89
|
+
remote: remoteBranch.slice(0, -suffix.length),
|
|
90
|
+
remoteBranch,
|
|
91
|
+
};
|
|
92
|
+
})
|
|
93
|
+
.filter((entry) => entry.remote && entry.remoteBranch !== `${entry.remote}/HEAD`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function gitRemotes(location, options = {}) {
|
|
97
|
+
return runGit(location, ["remote"], options).split(/\r?\n/).filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function gitRemoteBranchHead(location, remoteBranch, options = {}) {
|
|
101
|
+
return runGit(location, ["rev-parse", `refs/remotes/${remoteBranch}`], options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fetchRegisteredBranch(location, remote, branch, options = {}) {
|
|
105
|
+
try {
|
|
106
|
+
if (!gitRemotes(location, options).includes(remote)) {
|
|
107
|
+
throw new WorkspaceError("PROJECT_BRANCH_REMOTE_MISSING", `Configured Git remote does not exist: ${remote}.`, {
|
|
108
|
+
location,
|
|
109
|
+
remote,
|
|
110
|
+
branch,
|
|
111
|
+
remediation: "Choose an existing Git remote, then retry.",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
runGit(location, [
|
|
115
|
+
"fetch",
|
|
116
|
+
"--no-tags",
|
|
117
|
+
remote,
|
|
118
|
+
`refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
|
|
119
|
+
], { ...options, timeout: options.fetchTimeout || 30000 });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code === "PROJECT_BRANCH_REMOTE_MISSING") throw error;
|
|
122
|
+
throw new WorkspaceError("PROJECT_BRANCH_FETCH_FAILED", `Could not fetch ${remote}/${branch}: ${error.message}`, {
|
|
123
|
+
location,
|
|
124
|
+
remote,
|
|
125
|
+
branch,
|
|
126
|
+
cause: error.code || error.name,
|
|
127
|
+
remediation: "Check network access and Git credentials, then retry.",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
remote,
|
|
132
|
+
remoteBranch: `${remote}/${branch}`,
|
|
133
|
+
targetHead: gitRemoteBranchHead(location, `${remote}/${branch}`, options),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createTrackingBranch(location, branch, remoteBranch, options = {}) {
|
|
138
|
+
try {
|
|
139
|
+
runGit(location, ["switch", "--track", "-c", branch, remoteBranch], options);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw new WorkspaceError("PROJECT_BRANCH_CREATE_FAILED", `Could not create local branch ${branch} tracking ${remoteBranch}: ${error.message}`, {
|
|
142
|
+
location,
|
|
143
|
+
branch,
|
|
144
|
+
remoteBranch,
|
|
145
|
+
cause: error.code || error.name,
|
|
146
|
+
remediation: "Inspect the local Git branches and resolve the conflict manually before retrying.",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function switchGitBranch(location, branch) {
|
|
152
|
+
runGit(location, ["switch", "--", branch]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function inspectGitWorktree(input) {
|
|
156
|
+
const location = path.resolve(input);
|
|
157
|
+
if (!fs.existsSync(location)) throw new WorkspaceError("PROJECT_LOCATION_MISSING", `Project location does not exist: ${location}`, { location });
|
|
158
|
+
if (!fs.statSync(location).isDirectory()) throw new WorkspaceError("PROJECT_LOCATION_NOT_DIRECTORY", `Project location is not a directory: ${location}`, { location });
|
|
159
|
+
const topLevel = runGit(location, ["rev-parse", "--show-toplevel"]);
|
|
160
|
+
const realPath = fs.realpathSync(location);
|
|
161
|
+
const realTopLevel = fs.realpathSync(topLevel);
|
|
162
|
+
if (realPath !== realTopLevel) {
|
|
163
|
+
throw new WorkspaceError("PROJECT_LOCATION_NOT_WORKTREE_ROOT", `Project location must be the Git worktree root: ${location} (root: ${topLevel})`, { location, worktreeRoot: topLevel });
|
|
164
|
+
}
|
|
165
|
+
const branch = runGit(location, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
166
|
+
return { location, realPath, branch };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function inspectProject(input) {
|
|
170
|
+
const git = inspectGitWorktree(input);
|
|
171
|
+
const topLevelEntries = fs.readdirSync(git.realPath, { withFileTypes: true })
|
|
172
|
+
.map((entry) => entry.name)
|
|
173
|
+
.filter((name) => name !== ".git")
|
|
174
|
+
.sort();
|
|
175
|
+
const manifestFiles = MANIFEST_FILES.filter((file) => fs.existsSync(path.join(git.realPath, file)));
|
|
176
|
+
const readmeFiles = README_FILES.filter((file) => fs.existsSync(path.join(git.realPath, file)));
|
|
177
|
+
return {
|
|
178
|
+
location: git.realPath,
|
|
179
|
+
branch: git.branch,
|
|
180
|
+
facts: {
|
|
181
|
+
gitRoot: git.realPath,
|
|
182
|
+
manifestFiles,
|
|
183
|
+
readmeFiles,
|
|
184
|
+
topLevelEntries,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function canonicalBranchState(value) {
|
|
190
|
+
return {
|
|
191
|
+
registeredBranch: value.registeredBranch,
|
|
192
|
+
actualBranch: value.actualBranch,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function findRegisteredProject(config, name) {
|
|
197
|
+
const project = config.projects.find((entry) => entry?.name === name);
|
|
198
|
+
if (!project) {
|
|
199
|
+
throw new WorkspaceError("PROJECT_NOT_FOUND", `Unknown local project: ${name}`, {
|
|
200
|
+
project: name,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return project;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function inspectProjectBranchState(project, options = {}) {
|
|
207
|
+
try {
|
|
208
|
+
const actual = (options.inspectGitWorktree || inspectGitWorktree)(project.location);
|
|
209
|
+
const worktreeClean = (options.gitWorktreeClean || gitWorktreeClean)(project.location);
|
|
210
|
+
const registeredBranchExists = actual.branch === project.branch
|
|
211
|
+
|| (options.gitLocalBranchExists || gitLocalBranchExists)(project.location, project.branch);
|
|
212
|
+
const result = {
|
|
213
|
+
project: {
|
|
214
|
+
name: project.name,
|
|
215
|
+
location: actual.realPath || project.location,
|
|
216
|
+
},
|
|
217
|
+
registeredBranch: project.branch,
|
|
218
|
+
actualBranch: actual.branch,
|
|
219
|
+
matches: project.branch === actual.branch,
|
|
220
|
+
worktreeClean,
|
|
221
|
+
registeredBranchExists,
|
|
222
|
+
};
|
|
223
|
+
if (options.includeRemoteBranchCandidates) {
|
|
224
|
+
result.remoteBranchCandidates = (options.gitRemoteTrackingBranches || gitRemoteTrackingBranches)(project.location, project.branch, options);
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error.code === "PROJECT_BRANCH_INSPECTION_FAILED") throw error;
|
|
229
|
+
throw new WorkspaceError(
|
|
230
|
+
"PROJECT_BRANCH_INSPECTION_FAILED",
|
|
231
|
+
`Cannot inspect branch state for project ${project.name}: ${error.message}`,
|
|
232
|
+
{
|
|
233
|
+
project: project.name,
|
|
234
|
+
location: project.location,
|
|
235
|
+
cause: error.code || error.name,
|
|
236
|
+
remediation: "Inspect the selected Git worktree and retry the command.",
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function inspectProjectBranch(config, name, options = {}) {
|
|
243
|
+
return inspectProjectBranchState(findRegisteredProject(config, name), options);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function inspectProjectBranchMatch(config, name, options = {}) {
|
|
247
|
+
const project = findRegisteredProject(config, name);
|
|
248
|
+
try {
|
|
249
|
+
const actual = (options.inspectGitWorktree || inspectGitWorktree)(project.location);
|
|
250
|
+
return {
|
|
251
|
+
project: {
|
|
252
|
+
name: project.name,
|
|
253
|
+
location: actual.realPath || project.location,
|
|
254
|
+
},
|
|
255
|
+
registeredBranch: project.branch,
|
|
256
|
+
actualBranch: actual.branch,
|
|
257
|
+
matches: project.branch === actual.branch,
|
|
258
|
+
};
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error.code === "PROJECT_BRANCH_INSPECTION_FAILED") throw error;
|
|
261
|
+
throw new WorkspaceError(
|
|
262
|
+
"PROJECT_BRANCH_INSPECTION_FAILED",
|
|
263
|
+
`Cannot inspect branch state for project ${project.name}: ${error.message}`,
|
|
264
|
+
{
|
|
265
|
+
project: project.name,
|
|
266
|
+
location: project.location,
|
|
267
|
+
cause: error.code || error.name,
|
|
268
|
+
remediation: "Inspect the selected Git worktree and retry the command.",
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function assertRegisteredBranchSwitchAvailable(state) {
|
|
275
|
+
if (!state.worktreeClean) {
|
|
276
|
+
throw new WorkspaceError(
|
|
277
|
+
"PROJECT_WORKTREE_DIRTY",
|
|
278
|
+
`Project ${state.project.name} has uncommitted changes; its registered branch cannot be selected automatically.`,
|
|
279
|
+
{
|
|
280
|
+
project: state.project.name,
|
|
281
|
+
location: state.project.location,
|
|
282
|
+
...canonicalBranchState(state),
|
|
283
|
+
remediation: "Commit, discard, or otherwise resolve the selected worktree changes manually, then retry.",
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (!state.registeredBranchExists) {
|
|
288
|
+
throw new WorkspaceError(
|
|
289
|
+
"PROJECT_REGISTERED_BRANCH_MISSING",
|
|
290
|
+
`Registered branch ${state.registeredBranch} does not exist locally for project ${state.project.name}.`,
|
|
291
|
+
{
|
|
292
|
+
project: state.project.name,
|
|
293
|
+
location: state.project.location,
|
|
294
|
+
...canonicalBranchState(state),
|
|
295
|
+
remediation: "Create or fetch the registered branch manually, then retry; Code Workspace will not create or download it.",
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return state;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function assertRemoteOptions(options = {}) {
|
|
303
|
+
if (options.allowRemote === true && options.remote !== undefined) {
|
|
304
|
+
throw new WorkspaceError("CLI_OPTION_CONFLICT", "--allow-remote and --remote cannot be used together.", {
|
|
305
|
+
options: ["allow-remote", "remote"],
|
|
306
|
+
remediation: "Use --allow-remote for an existing remote-tracking branch, or --remote <name> to fetch.",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function planRegisteredBranchAcquisition(state, options = {}) {
|
|
312
|
+
assertRemoteOptions(options);
|
|
313
|
+
if (state.registeredBranchExists) {
|
|
314
|
+
return {
|
|
315
|
+
mode: "local",
|
|
316
|
+
remote: null,
|
|
317
|
+
remoteBranch: null,
|
|
318
|
+
targetHead: null,
|
|
319
|
+
localBranchCreated: false,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (options.remote !== undefined) {
|
|
323
|
+
const remotes = options.gitRemotes ? options.gitRemotes(state.project.location, options) : gitRemotes(state.project.location, options);
|
|
324
|
+
if (!remotes.includes(options.remote)) {
|
|
325
|
+
throw new WorkspaceError("PROJECT_BRANCH_REMOTE_MISSING", `Configured Git remote does not exist: ${options.remote}.`, {
|
|
326
|
+
project: state.project.name,
|
|
327
|
+
location: state.project.location,
|
|
328
|
+
remote: options.remote,
|
|
329
|
+
registeredBranch: state.registeredBranch,
|
|
330
|
+
remediation: "Choose an existing Git remote, then retry.",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
mode: "fetched",
|
|
335
|
+
remote: options.remote,
|
|
336
|
+
remoteBranch: `${options.remote}/${state.registeredBranch}`,
|
|
337
|
+
targetHead: null,
|
|
338
|
+
localBranchCreated: true,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (options.allowRemote === true) {
|
|
342
|
+
const candidates = options.gitRemoteTrackingBranches
|
|
343
|
+
? options.gitRemoteTrackingBranches(state.project.location, state.registeredBranch, options)
|
|
344
|
+
: (state.remoteBranchCandidates || []);
|
|
345
|
+
if (candidates.length > 1) {
|
|
346
|
+
throw new WorkspaceError("PROJECT_BRANCH_REMOTE_AMBIGUOUS", `Multiple remote-tracking branches match ${state.registeredBranch} for project ${state.project.name}.`, {
|
|
347
|
+
project: state.project.name,
|
|
348
|
+
location: state.project.location,
|
|
349
|
+
registeredBranch: state.registeredBranch,
|
|
350
|
+
candidates,
|
|
351
|
+
remediation: "Specify --remote <name> to select a remote and fetch explicitly.",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (candidates.length === 1) {
|
|
355
|
+
const candidate = candidates[0];
|
|
356
|
+
return {
|
|
357
|
+
mode: "remote-tracking",
|
|
358
|
+
remote: candidate.remote,
|
|
359
|
+
remoteBranch: candidate.remoteBranch,
|
|
360
|
+
targetHead: (options.gitRemoteBranchHead || gitRemoteBranchHead)(state.project.location, candidate.remoteBranch, options),
|
|
361
|
+
localBranchCreated: true,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
throw new WorkspaceError("PROJECT_BRANCH_REMOTE_UNAVAILABLE", `No remote-tracking branch exists locally for ${state.registeredBranch} in project ${state.project.name}.`, {
|
|
365
|
+
project: state.project.name,
|
|
366
|
+
location: state.project.location,
|
|
367
|
+
registeredBranch: state.registeredBranch,
|
|
368
|
+
remediation: "Use --remote <name> to fetch the registered branch, or create/fetch it manually.",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
assertRegisteredBranchSwitchAvailable(state);
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function sameBranchPlan(expected, observed) {
|
|
376
|
+
return expected.registeredBranch === observed.registeredBranch
|
|
377
|
+
&& expected.actualBranch === observed.actualBranch
|
|
378
|
+
&& expected.worktreeClean === observed.worktreeClean
|
|
379
|
+
&& expected.registeredBranchExists === observed.registeredBranchExists;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function staleBranchPlanError(expected, observed) {
|
|
383
|
+
return new WorkspaceError(
|
|
384
|
+
"PROJECT_BRANCH_PLAN_STALE",
|
|
385
|
+
`Branch state changed while preparing to switch project ${expected.project.name}.`,
|
|
386
|
+
{
|
|
387
|
+
project: expected.project.name,
|
|
388
|
+
location: expected.project.location,
|
|
389
|
+
expectedState: canonicalBranchState(expected),
|
|
390
|
+
observedState: canonicalBranchState(observed),
|
|
391
|
+
expectedSafety: {
|
|
392
|
+
worktreeClean: expected.worktreeClean,
|
|
393
|
+
registeredBranchExists: expected.registeredBranchExists,
|
|
394
|
+
},
|
|
395
|
+
observedSafety: {
|
|
396
|
+
worktreeClean: observed.worktreeClean,
|
|
397
|
+
registeredBranchExists: observed.registeredBranchExists,
|
|
398
|
+
},
|
|
399
|
+
remediation: "Inspect the selected project branch state again before choosing a direction.",
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function switchProjectToRegisteredBranch(plan, options = {}) {
|
|
405
|
+
const acquisition = plan.acquisition || planRegisteredBranchAcquisition(plan, options);
|
|
406
|
+
if (acquisition?.mode === "local") assertRegisteredBranchSwitchAvailable(plan);
|
|
407
|
+
else if (!plan.worktreeClean) assertRegisteredBranchSwitchAvailable(plan);
|
|
408
|
+
if (plan.matches) {
|
|
409
|
+
return {
|
|
410
|
+
action: "skip",
|
|
411
|
+
project: plan.project,
|
|
412
|
+
before: canonicalBranchState(plan),
|
|
413
|
+
after: canonicalBranchState(plan),
|
|
414
|
+
worktreeClean: plan.worktreeClean,
|
|
415
|
+
registeredBranchExists: plan.registeredBranchExists,
|
|
416
|
+
acquisition,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const project = {
|
|
420
|
+
name: plan.project.name,
|
|
421
|
+
location: plan.project.location,
|
|
422
|
+
branch: plan.registeredBranch,
|
|
423
|
+
};
|
|
424
|
+
const observe = () => (options.inspectProjectBranchState || inspectProjectBranchState)(project, options);
|
|
425
|
+
const switchBranch = options.switchGitBranch || switchGitBranch;
|
|
426
|
+
let createdLocalBranch = false;
|
|
427
|
+
const observedBefore = observe();
|
|
428
|
+
if (!sameBranchPlan(plan, observedBefore)) throw staleBranchPlanError(plan, observedBefore);
|
|
429
|
+
if (!observedBefore.worktreeClean) assertRegisteredBranchSwitchAvailable(observedBefore);
|
|
430
|
+
|
|
431
|
+
let effectiveAcquisition = { ...acquisition };
|
|
432
|
+
if (effectiveAcquisition.mode === "fetched") {
|
|
433
|
+
const fetched = (options.fetchRegisteredBranch || fetchRegisteredBranch)(project.location, effectiveAcquisition.remote, plan.registeredBranch, options);
|
|
434
|
+
effectiveAcquisition = { ...effectiveAcquisition, ...fetched };
|
|
435
|
+
}
|
|
436
|
+
if (effectiveAcquisition.mode === "remote-tracking" || effectiveAcquisition.mode === "fetched") {
|
|
437
|
+
const remoteBranch = effectiveAcquisition.remoteBranch;
|
|
438
|
+
const targetHead = effectiveAcquisition.targetHead || (options.gitRemoteBranchHead || gitRemoteBranchHead)(project.location, remoteBranch, options);
|
|
439
|
+
effectiveAcquisition = { ...effectiveAcquisition, targetHead };
|
|
440
|
+
const afterFetch = observe();
|
|
441
|
+
if (effectiveAcquisition.mode === "remote-tracking") {
|
|
442
|
+
const candidate = afterFetch.remoteBranchCandidates?.find((entry) => entry.remoteBranch === remoteBranch);
|
|
443
|
+
const observedHead = candidate && (options.gitRemoteBranchHead || gitRemoteBranchHead)(project.location, remoteBranch, options);
|
|
444
|
+
if (!candidate || observedHead !== targetHead) throw staleBranchPlanError(plan, afterFetch);
|
|
445
|
+
}
|
|
446
|
+
if (afterFetch.registeredBranchExists) {
|
|
447
|
+
throw new WorkspaceError("PROJECT_BRANCH_PLAN_STALE", `Project ${project.name} acquired a local branch while preparing to switch.`, {
|
|
448
|
+
project: project.name,
|
|
449
|
+
location: project.location,
|
|
450
|
+
registeredBranch: plan.registeredBranch,
|
|
451
|
+
remediation: "Inspect the selected project branch state again before retrying.",
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let switched = false;
|
|
457
|
+
try {
|
|
458
|
+
options.injectFailure?.("before-switch", observedBefore);
|
|
459
|
+
switched = true;
|
|
460
|
+
try {
|
|
461
|
+
if (effectiveAcquisition.mode === "remote-tracking" || effectiveAcquisition.mode === "fetched") {
|
|
462
|
+
(options.createTrackingBranch || createTrackingBranch)(project.location, plan.registeredBranch, effectiveAcquisition.remoteBranch, options);
|
|
463
|
+
createdLocalBranch = true;
|
|
464
|
+
} else {
|
|
465
|
+
switchBranch(project.location, plan.registeredBranch);
|
|
466
|
+
}
|
|
467
|
+
} catch (switchError) {
|
|
468
|
+
throw new WorkspaceError(
|
|
469
|
+
"PROJECT_BRANCH_SWITCH_FAILED",
|
|
470
|
+
`Could not switch project ${project.name} to its registered branch: ${switchError.message}`,
|
|
471
|
+
{
|
|
472
|
+
project: project.name,
|
|
473
|
+
location: project.location,
|
|
474
|
+
expectedState: {
|
|
475
|
+
registeredBranch: plan.registeredBranch,
|
|
476
|
+
actualBranch: plan.registeredBranch,
|
|
477
|
+
},
|
|
478
|
+
observedState: canonicalBranchState(observedBefore),
|
|
479
|
+
cause: switchError.code || switchError.name,
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
options.injectFailure?.("after-switch", observedBefore);
|
|
484
|
+
const observedAfter = observe();
|
|
485
|
+
const expectedAfter = {
|
|
486
|
+
registeredBranch: plan.registeredBranch,
|
|
487
|
+
actualBranch: plan.registeredBranch,
|
|
488
|
+
};
|
|
489
|
+
if (observedAfter.actualBranch !== expectedAfter.actualBranch || !observedAfter.worktreeClean) {
|
|
490
|
+
throw new WorkspaceError(
|
|
491
|
+
"PROJECT_BRANCH_SWITCH_VERIFY_FAILED",
|
|
492
|
+
`Project ${project.name} did not reach the registered branch in a clean state.`,
|
|
493
|
+
{
|
|
494
|
+
project: project.name,
|
|
495
|
+
location: project.location,
|
|
496
|
+
expectedState: expectedAfter,
|
|
497
|
+
observedState: canonicalBranchState(observedAfter),
|
|
498
|
+
expectedWorktreeClean: true,
|
|
499
|
+
observedWorktreeClean: observedAfter.worktreeClean,
|
|
500
|
+
}
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
options.injectFailure?.("after-verify", observedAfter);
|
|
504
|
+
return {
|
|
505
|
+
action: "switch",
|
|
506
|
+
project: observedAfter.project,
|
|
507
|
+
before: canonicalBranchState(plan),
|
|
508
|
+
after: canonicalBranchState(observedAfter),
|
|
509
|
+
worktreeClean: observedAfter.worktreeClean,
|
|
510
|
+
registeredBranchExists: observedAfter.registeredBranchExists,
|
|
511
|
+
acquisition: effectiveAcquisition,
|
|
512
|
+
};
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (!switched) {
|
|
515
|
+
if (["PROJECT_BRANCH_PLAN_STALE", "PROJECT_WORKTREE_DIRTY", "PROJECT_REGISTERED_BRANCH_MISSING"].includes(error.code)) throw error;
|
|
516
|
+
throw new WorkspaceError(
|
|
517
|
+
"PROJECT_BRANCH_SWITCH_FAILED",
|
|
518
|
+
`Could not switch project ${project.name} to its registered branch: ${error.message}`,
|
|
519
|
+
{
|
|
520
|
+
project: project.name,
|
|
521
|
+
location: project.location,
|
|
522
|
+
expectedState: {
|
|
523
|
+
registeredBranch: plan.registeredBranch,
|
|
524
|
+
actualBranch: plan.registeredBranch,
|
|
525
|
+
},
|
|
526
|
+
observedState: canonicalBranchState(observedBefore),
|
|
527
|
+
cause: error.code || error.name,
|
|
528
|
+
remediation: "Inspect the selected worktree and resolve the Git error manually before retrying.",
|
|
529
|
+
}
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const failure = ["PROJECT_BRANCH_SWITCH_FAILED", "PROJECT_BRANCH_SWITCH_VERIFY_FAILED"].includes(error.code)
|
|
534
|
+
? error
|
|
535
|
+
: new WorkspaceError(
|
|
536
|
+
"PROJECT_BRANCH_SWITCH_VERIFY_FAILED",
|
|
537
|
+
`Project ${project.name} branch switch could not be verified: ${error.message}`,
|
|
538
|
+
{
|
|
539
|
+
project: project.name,
|
|
540
|
+
location: project.location,
|
|
541
|
+
expectedState: {
|
|
542
|
+
registeredBranch: plan.registeredBranch,
|
|
543
|
+
actualBranch: plan.registeredBranch,
|
|
544
|
+
},
|
|
545
|
+
cause: error.code || error.name,
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
try {
|
|
549
|
+
options.injectFailure?.("before-compensation", failure);
|
|
550
|
+
switchBranch(project.location, plan.actualBranch);
|
|
551
|
+
const compensated = observe();
|
|
552
|
+
if (compensated.actualBranch !== plan.actualBranch || !compensated.worktreeClean) {
|
|
553
|
+
throw new Error("Compensation did not restore the original clean branch state");
|
|
554
|
+
}
|
|
555
|
+
options.injectFailure?.("after-compensation", compensated);
|
|
556
|
+
failure.details = {
|
|
557
|
+
...(failure.details || {}),
|
|
558
|
+
observedState: failure.details?.observedState || canonicalBranchState(compensated),
|
|
559
|
+
compensation: {
|
|
560
|
+
attempted: true,
|
|
561
|
+
succeeded: true,
|
|
562
|
+
restoredState: canonicalBranchState(compensated),
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
if (createdLocalBranch) {
|
|
566
|
+
const retainedEffect = {
|
|
567
|
+
kind: "git-branch-created",
|
|
568
|
+
status: "possibly-applied",
|
|
569
|
+
retained: true,
|
|
570
|
+
project: project.name,
|
|
571
|
+
location: project.location,
|
|
572
|
+
branch: plan.registeredBranch,
|
|
573
|
+
remoteBranch: effectiveAcquisition.remoteBranch,
|
|
574
|
+
remediation: `Remove local branch ${plan.registeredBranch} manually only after verifying it is no longer needed.`,
|
|
575
|
+
};
|
|
576
|
+
failure.details = {
|
|
577
|
+
...(failure.details || {}),
|
|
578
|
+
retainedEffects: [retainedEffect],
|
|
579
|
+
};
|
|
580
|
+
attachRetainedEffects(failure, [retainedEffect]);
|
|
581
|
+
}
|
|
582
|
+
throw failure;
|
|
583
|
+
} catch (compensationError) {
|
|
584
|
+
if (compensationError === failure) throw failure;
|
|
585
|
+
let retainedObservation = null;
|
|
586
|
+
try {
|
|
587
|
+
retainedObservation = observe();
|
|
588
|
+
} catch {
|
|
589
|
+
retainedObservation = null;
|
|
590
|
+
}
|
|
591
|
+
if (retainedObservation?.actualBranch === plan.actualBranch && retainedObservation.worktreeClean) {
|
|
592
|
+
failure.details = {
|
|
593
|
+
...(failure.details || {}),
|
|
594
|
+
observedState: failure.details?.observedState || canonicalBranchState(retainedObservation),
|
|
595
|
+
compensation: {
|
|
596
|
+
attempted: true,
|
|
597
|
+
succeeded: true,
|
|
598
|
+
restoredState: canonicalBranchState(retainedObservation),
|
|
599
|
+
verificationCause: compensationError.code || compensationError.name,
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
throw failure;
|
|
603
|
+
}
|
|
604
|
+
const retainedState = retainedObservation ? canonicalBranchState(retainedObservation) : null;
|
|
605
|
+
failure.details = {
|
|
606
|
+
...(failure.details || {}),
|
|
607
|
+
observedState: retainedState || failure.details?.observedState || null,
|
|
608
|
+
compensation: {
|
|
609
|
+
attempted: true,
|
|
610
|
+
succeeded: false,
|
|
611
|
+
cause: compensationError.code || compensationError.name,
|
|
612
|
+
message: compensationError.message,
|
|
613
|
+
},
|
|
614
|
+
remediation: `Restore project ${project.name} to branch ${plan.actualBranch} manually, verify a clean worktree, then retry targeted verification.`,
|
|
615
|
+
};
|
|
616
|
+
if (createdLocalBranch) {
|
|
617
|
+
const retainedEffect = {
|
|
618
|
+
kind: "git-branch-created",
|
|
619
|
+
status: "possibly-applied",
|
|
620
|
+
retained: true,
|
|
621
|
+
project: project.name,
|
|
622
|
+
location: project.location,
|
|
623
|
+
branch: plan.registeredBranch,
|
|
624
|
+
remoteBranch: effectiveAcquisition.remoteBranch,
|
|
625
|
+
remediation: `Remove local branch ${plan.registeredBranch} manually only after verifying it is no longer needed.`,
|
|
626
|
+
};
|
|
627
|
+
failure.details.retainedEffects = [retainedEffect];
|
|
628
|
+
failure.details.effects = {
|
|
629
|
+
...(failure.details.effects || {}),
|
|
630
|
+
retained: [
|
|
631
|
+
...(failure.details.effects?.retained || []),
|
|
632
|
+
retainedEffect,
|
|
633
|
+
],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
throw attachRetainedEffects(failure, [{
|
|
637
|
+
kind: "git-branch-switch",
|
|
638
|
+
status: "possibly-applied",
|
|
639
|
+
retained: true,
|
|
640
|
+
project: project.name,
|
|
641
|
+
location: project.location,
|
|
642
|
+
expectedState: canonicalBranchState(plan),
|
|
643
|
+
observedState: retainedState,
|
|
644
|
+
remediation: failure.details.remediation,
|
|
645
|
+
}]);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
module.exports = {
|
|
651
|
+
MANIFEST_FILES,
|
|
652
|
+
README_FILES,
|
|
653
|
+
assertRegisteredBranchSwitchAvailable,
|
|
654
|
+
canonicalBranchState,
|
|
655
|
+
findRegisteredProject,
|
|
656
|
+
gitLocalBranchExists,
|
|
657
|
+
gitRemoteBranchHead,
|
|
658
|
+
gitRemoteTrackingBranches,
|
|
659
|
+
gitRemotes,
|
|
660
|
+
gitWorktreeClean,
|
|
661
|
+
fetchRegisteredBranch,
|
|
662
|
+
createTrackingBranch,
|
|
663
|
+
inspectGitWorktree,
|
|
664
|
+
inspectProject,
|
|
665
|
+
inspectProjectBranch,
|
|
666
|
+
inspectProjectBranchMatch,
|
|
667
|
+
inspectProjectBranchState,
|
|
668
|
+
planRegisteredBranchAcquisition,
|
|
669
|
+
assertRemoteOptions,
|
|
670
|
+
runGit,
|
|
671
|
+
sameBranchPlan,
|
|
672
|
+
switchGitBranch,
|
|
673
|
+
switchProjectToRegisteredBranch,
|
|
674
|
+
};
|