@mcrescenzo/opencode-workflows 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/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { git, gitCapture, gitSucceeds } from "./git-util.js";
|
|
3
|
+
import {
|
|
4
|
+
assertContainedRealPath,
|
|
5
|
+
createRawWorktree,
|
|
6
|
+
hasWorkingTreeChanges,
|
|
7
|
+
isPathInside,
|
|
8
|
+
listWorktrees,
|
|
9
|
+
parseWorktreeList,
|
|
10
|
+
realpathPartial,
|
|
11
|
+
requireGitRepo,
|
|
12
|
+
worktreeStatus,
|
|
13
|
+
} from "./worktree-git.js";
|
|
14
|
+
|
|
15
|
+
function safeSlug(value, label) {
|
|
16
|
+
const slug = String(value ?? "")
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
19
|
+
.replace(/^-+|-+$/g, "")
|
|
20
|
+
.slice(0, 80);
|
|
21
|
+
if (!slug) throw new Error(`${label} must contain at least one safe path character`);
|
|
22
|
+
return slug;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class CommitHookError extends Error {
|
|
26
|
+
constructor({ directory, command, stderr, stdout, hookModifiedFiles, retried }) {
|
|
27
|
+
const detail = (stderr || stdout || "").trim();
|
|
28
|
+
super(
|
|
29
|
+
`git ${command} failed in ${directory}${retried ? " after restage retry" : ""}: ${detail || "commit hook failure"}`,
|
|
30
|
+
);
|
|
31
|
+
this.name = "CommitHookError";
|
|
32
|
+
this.directory = directory;
|
|
33
|
+
this.command = command;
|
|
34
|
+
this.stderr = stderr || "";
|
|
35
|
+
this.stdout = stdout || "";
|
|
36
|
+
this.hookModifiedFiles = !!hookModifiedFiles;
|
|
37
|
+
this.retried = !!retried;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeCreatedWorktree(input, data, fallback) {
|
|
42
|
+
const worktreePath = path.resolve(data?.path || data?.directory || data?.dir || fallback.targetPath);
|
|
43
|
+
return {
|
|
44
|
+
role: fallback.role,
|
|
45
|
+
runId: fallback.runId,
|
|
46
|
+
laneId: fallback.laneId,
|
|
47
|
+
path: worktreePath,
|
|
48
|
+
branch: data?.branch || fallback.branch,
|
|
49
|
+
baseRef: fallback.baseRef,
|
|
50
|
+
id: data?.id,
|
|
51
|
+
name: data?.name || input?.name || path.basename(worktreePath),
|
|
52
|
+
native: Boolean(data),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function createWorktreeAdapter(options = {}) {
|
|
57
|
+
const root = await requireGitRepo(options.directory || process.cwd());
|
|
58
|
+
// Realpath the worktree root so a symlinked ancestor (e.g. a symlinked
|
|
59
|
+
// worktreeRoot, or /home -> /Users on macOS) does not cause containment checks
|
|
60
|
+
// against git's realpath-canonical worktree records to fail — which would leak
|
|
61
|
+
// worktrees by refusing to remove/recover them.
|
|
62
|
+
const worktreeRoot = await realpathPartial(options.worktreeRoot || path.join(path.dirname(root), `${path.basename(root)}.worktrees`));
|
|
63
|
+
const nativeWorktreeClient = options.nativeWorktreeClient;
|
|
64
|
+
const integrationValidator = options.integrationValidator;
|
|
65
|
+
const execOptions = { signal: options.signal };
|
|
66
|
+
|
|
67
|
+
async function createManagedWorktree(input, role) {
|
|
68
|
+
const runId = safeSlug(input.runId ?? "run", "runId");
|
|
69
|
+
const laneId = role === "lane" ? safeSlug(input.laneId, "laneId") : undefined;
|
|
70
|
+
const baseRef = input.baseRef || "HEAD";
|
|
71
|
+
const branch = input.branch || (role === "lane" ? `workflow/${runId}/lane/${laneId}` : `workflow/${runId}/integration`);
|
|
72
|
+
const targetPath = path.resolve(input.path || (role === "lane"
|
|
73
|
+
? path.join(worktreeRoot, runId, "lanes", laneId)
|
|
74
|
+
: path.join(worktreeRoot, runId, "integration")));
|
|
75
|
+
const fallback = { role, runId, laneId, baseRef, branch, targetPath };
|
|
76
|
+
|
|
77
|
+
if (nativeWorktreeClient?.create) {
|
|
78
|
+
const result = await nativeWorktreeClient.create({ body: { name: input.name || path.basename(targetPath), path: targetPath, branch }, query: { directory: root } });
|
|
79
|
+
const data = result?.data ?? result;
|
|
80
|
+
return normalizeCreatedWorktree(input, data, fallback);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await createRawWorktree({ root, targetPath, branch, baseRef, execOptions });
|
|
84
|
+
return normalizeCreatedWorktree(input, undefined, fallback);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function findLinkedWorktree(targetPath) {
|
|
88
|
+
// git worktree records are realpath-canonical; realpath the caller's target
|
|
89
|
+
// (which may carry a symlinked ancestor) before matching so the record is
|
|
90
|
+
// found instead of being misclassified as not-linked and leaked.
|
|
91
|
+
const target = await realpathPartial(targetPath);
|
|
92
|
+
const records = await listWorktrees(root);
|
|
93
|
+
let record;
|
|
94
|
+
for (const item of records) {
|
|
95
|
+
if ((await realpathPartial(item.path)) === target) {
|
|
96
|
+
record = item;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { target, records, record };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
root,
|
|
105
|
+
worktreeRoot,
|
|
106
|
+
|
|
107
|
+
async createLaneWorktree(input = {}) {
|
|
108
|
+
return await createManagedWorktree(input, "lane");
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async createIntegrationWorktree(input = {}) {
|
|
112
|
+
return await createManagedWorktree(input, "integration");
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
...(typeof integrationValidator === "function" ? {
|
|
116
|
+
async validateIntegrationWorktree(input = {}) {
|
|
117
|
+
return await integrationValidator(input);
|
|
118
|
+
},
|
|
119
|
+
} : {}),
|
|
120
|
+
|
|
121
|
+
async status(input = {}) {
|
|
122
|
+
return await worktreeStatus(input.directory || input.path);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async diff(input = {}) {
|
|
126
|
+
return (await git(input.directory || input.path, ["diff", "HEAD", "--"], execOptions)).stdout;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async commit(input = {}) {
|
|
130
|
+
const directory = input.directory || input.path;
|
|
131
|
+
const message = input.message || "workflow worktree commit";
|
|
132
|
+
const command = `commit -m ${JSON.stringify(message)}`;
|
|
133
|
+
await git(directory, ["add", "-A"], execOptions);
|
|
134
|
+
if (await gitSucceeds(directory, ["diff", "--cached", "--quiet"], execOptions)) {
|
|
135
|
+
return { committed: false, reason: "nothing-to-commit" };
|
|
136
|
+
}
|
|
137
|
+
const first = await gitCapture(directory, ["commit", "-m", message], execOptions);
|
|
138
|
+
if (first.ok) {
|
|
139
|
+
const commit = (await git(directory, ["rev-parse", "HEAD"], execOptions)).stdout.trim();
|
|
140
|
+
return { committed: true, commit };
|
|
141
|
+
}
|
|
142
|
+
// A commit hook may have modified files (formatter) and left unstaged changes, or
|
|
143
|
+
// rejected the commit (linter). Restage once and retry; never bypass hooks (--no-verify).
|
|
144
|
+
const hookModified = await hasWorkingTreeChanges(directory, execOptions);
|
|
145
|
+
if (hookModified) {
|
|
146
|
+
await git(directory, ["add", "-A"], execOptions);
|
|
147
|
+
const retry = await gitCapture(directory, ["commit", "-m", message], execOptions);
|
|
148
|
+
if (retry.ok) {
|
|
149
|
+
const commit = (await git(directory, ["rev-parse", "HEAD"], execOptions)).stdout.trim();
|
|
150
|
+
return { committed: true, commit, hookModifiedFiles: true, retried: true };
|
|
151
|
+
}
|
|
152
|
+
// Dirty worktree is intentionally preserved so the hook output is reviewable.
|
|
153
|
+
throw new CommitHookError({ directory, command, stderr: retry.stderr, stdout: retry.stdout, hookModifiedFiles: true, retried: true });
|
|
154
|
+
}
|
|
155
|
+
throw new CommitHookError({ directory, command, stderr: first.stderr, stdout: first.stdout, hookModifiedFiles: false, retried: false });
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async merge(input = {}) {
|
|
159
|
+
const args = input.message ? ["merge", "--no-ff", "-m", input.message, input.ref] : ["merge", "--no-ff", "--no-edit", input.ref];
|
|
160
|
+
await git(input.directory || input.path, args, execOptions);
|
|
161
|
+
return { merged: true, ref: input.ref, head: (await git(input.directory || input.path, ["rev-parse", "HEAD"], execOptions)).stdout.trim() };
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async cherryPick(input = {}) {
|
|
165
|
+
await git(input.directory || input.path, ["cherry-pick", input.ref], execOptions);
|
|
166
|
+
return { cherryPicked: true, ref: input.ref, head: (await git(input.directory || input.path, ["rev-parse", "HEAD"], execOptions)).stdout.trim() };
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async rebase(input = {}) {
|
|
170
|
+
await git(input.directory || input.path, ["rebase", input.baseRef], execOptions);
|
|
171
|
+
return { rebased: true, baseRef: input.baseRef, head: (await git(input.directory || input.path, ["rev-parse", "HEAD"], execOptions)).stdout.trim() };
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async remove(input = {}) {
|
|
175
|
+
const targetPath = input.path || input.directory;
|
|
176
|
+
const { target, record } = await findLinkedWorktree(targetPath);
|
|
177
|
+
if (!record) return { removed: false, preserved: true, reason: "not-linked-worktree", path: target };
|
|
178
|
+
if (target === root) return { removed: false, preserved: true, reason: "main-worktree", path: target };
|
|
179
|
+
if (!(await assertContainedRealPath(worktreeRoot, target)).inside) return { removed: false, preserved: true, reason: "outside-worktree-root", path: target };
|
|
180
|
+
if (record.locked) return { removed: false, preserved: true, reason: "locked", path: target, record };
|
|
181
|
+
const status = await worktreeStatus(target);
|
|
182
|
+
if (!status.clean) return { removed: false, preserved: true, reason: "dirty", path: target, status };
|
|
183
|
+
if (input.native && nativeWorktreeClient?.remove) {
|
|
184
|
+
await nativeWorktreeClient.remove({ body: input.id ? { id: input.id } : {}, query: { directory: target } });
|
|
185
|
+
return { removed: true, path: target };
|
|
186
|
+
}
|
|
187
|
+
await git(root, ["worktree", "remove", target], execOptions);
|
|
188
|
+
// `git worktree remove` deletes the directory + admin record but leaves the
|
|
189
|
+
// lane branch behind; over many autonomous-drain runs those orphaned
|
|
190
|
+
// branches accumulate unbounded. Delete it with a capture helper that
|
|
191
|
+
// swallows the "branch not found" error (detached worktree, branch already
|
|
192
|
+
// gone, or a non-refs/heads ref). `git branch -D` wants the short name, so
|
|
193
|
+
// strip the refs/heads/ prefix git reports in the porcelain record.
|
|
194
|
+
const branchRef = typeof record.branch === "string" ? record.branch : "";
|
|
195
|
+
const branchName = branchRef.startsWith("refs/heads/") ? branchRef.slice("refs/heads/".length) : branchRef;
|
|
196
|
+
let branchDeleted = false;
|
|
197
|
+
if (branchName) {
|
|
198
|
+
const deletion = await gitCapture(root, ["branch", "-D", branchName], execOptions);
|
|
199
|
+
branchDeleted = deletion.ok;
|
|
200
|
+
}
|
|
201
|
+
return { removed: true, path: target, branch: branchName || undefined, branchDeleted };
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async recover(input = {}) {
|
|
205
|
+
const known = Array.isArray(input.records) ? input.records : [];
|
|
206
|
+
const records = await listWorktrees(root);
|
|
207
|
+
// Key git records by their realpath-canonical path, and realpath the
|
|
208
|
+
// caller-supplied known paths too, so a symlinked ancestor does not split
|
|
209
|
+
// a single real worktree into a "missing" known entry plus an
|
|
210
|
+
// "outside-worktree-root" git record (both of which would leak it).
|
|
211
|
+
const byPath = new Map();
|
|
212
|
+
for (const record of records) {
|
|
213
|
+
byPath.set(await realpathPartial(record.path), record);
|
|
214
|
+
}
|
|
215
|
+
const knownReal = [];
|
|
216
|
+
for (const record of known) {
|
|
217
|
+
const raw = record.path || record.directory || "";
|
|
218
|
+
if (raw) knownReal.push(await realpathPartial(raw));
|
|
219
|
+
}
|
|
220
|
+
const paths = [...new Set([...byPath.keys(), ...knownReal])];
|
|
221
|
+
const worktrees = [];
|
|
222
|
+
for (const itemPath of paths) {
|
|
223
|
+
const record = byPath.get(itemPath);
|
|
224
|
+
if (!record) {
|
|
225
|
+
worktrees.push({ path: itemPath, state: "missing", preserved: true });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (itemPath === root) {
|
|
229
|
+
worktrees.push({ path: itemPath, state: "main-worktree", preserved: true, record });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!(await assertContainedRealPath(worktreeRoot, itemPath)).inside) {
|
|
233
|
+
worktrees.push({ path: itemPath, state: "outside-worktree-root", preserved: true, record });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (record.locked) {
|
|
237
|
+
worktrees.push({ path: itemPath, state: "locked", preserved: true, record });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const status = await worktreeStatus(itemPath);
|
|
241
|
+
worktrees.push({ path: itemPath, state: status.clean ? "clean" : "dirty", preserved: !status.clean, status, record });
|
|
242
|
+
}
|
|
243
|
+
return { root, worktreeRoot, worktrees };
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export {
|
|
249
|
+
CommitHookError,
|
|
250
|
+
createWorktreeAdapter,
|
|
251
|
+
isPathInside,
|
|
252
|
+
parseWorktreeList,
|
|
253
|
+
requireGitRepo,
|
|
254
|
+
safeSlug,
|
|
255
|
+
worktreeStatus,
|
|
256
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { git, gitCapture, gitSucceeds } from "./git-util.js";
|
|
4
|
+
import { pathContains } from "./path-policy.js";
|
|
5
|
+
|
|
6
|
+
async function hasWorkingTreeChanges(directory, options = {}) {
|
|
7
|
+
// Detect UNSTAGED modifications (working tree differs from index), which is the
|
|
8
|
+
// signal that a commit hook (e.g. a formatter) rewrote files. Pre-existing staged
|
|
9
|
+
// changes do not count — those are already in the index and are not hook output.
|
|
10
|
+
const result = await gitCapture(directory, ["diff", "--name-only"], options);
|
|
11
|
+
return result.ok && result.stdout.trim().length > 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function pathExists(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
await fs.access(filePath);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Resolve symlinks in an absolute path even when the leaf (or some trailing
|
|
24
|
+
// segments) do not exist on disk yet — git worktree records can point at paths
|
|
25
|
+
// that have already been removed during recovery. We realpath the deepest
|
|
26
|
+
// existing ancestor (canonicalizing any symlinked ancestor such as macOS
|
|
27
|
+
// /tmp -> /private/tmp or a /home -> /Users bind mount) and re-append the
|
|
28
|
+
// missing tail. fs.realpath alone throws ENOENT for a missing leaf, which is
|
|
29
|
+
// why this walks up to the nearest existing directory.
|
|
30
|
+
async function realpathPartial(targetPath) {
|
|
31
|
+
let current = path.resolve(targetPath);
|
|
32
|
+
const tail = [];
|
|
33
|
+
// Walk up until we find an existing ancestor (or hit the filesystem root).
|
|
34
|
+
// eslint-disable-next-line no-constant-condition
|
|
35
|
+
while (true) {
|
|
36
|
+
try {
|
|
37
|
+
const resolved = await fs.realpath(current);
|
|
38
|
+
return tail.length ? path.join(resolved, ...tail) : resolved;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code !== "ENOENT") {
|
|
41
|
+
// Permission or other errors: fall back to the lexical resolution so we
|
|
42
|
+
// never throw out of a cleanup path.
|
|
43
|
+
return path.resolve(targetPath);
|
|
44
|
+
}
|
|
45
|
+
const parent = path.dirname(current);
|
|
46
|
+
if (parent === current) return path.resolve(targetPath);
|
|
47
|
+
tail.unshift(path.basename(current));
|
|
48
|
+
current = parent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPathInside(parent, child) {
|
|
54
|
+
return pathContains(path.resolve(parent), path.resolve(child));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Containment check whose basis is realpath-canonical on both sides, so a
|
|
58
|
+
// symlinked ancestor on either the worktree root or the candidate does not
|
|
59
|
+
// produce a false "outside" verdict.
|
|
60
|
+
async function assertContainedRealPath(parent, child) {
|
|
61
|
+
const parentReal = await realpathPartial(parent);
|
|
62
|
+
const childReal = await realpathPartial(child);
|
|
63
|
+
return { inside: isPathInside(parentReal, childReal), parentReal, childReal };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function requireGitRepo(directory) {
|
|
67
|
+
const inside = (await git(directory, ["rev-parse", "--is-inside-work-tree"])).stdout.trim();
|
|
68
|
+
if (inside !== "true") throw new Error(`Worktree adapter requires a Git repository: ${directory}`);
|
|
69
|
+
const root = (await git(directory, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
|
70
|
+
await git(root, ["rev-parse", "--verify", "HEAD"]);
|
|
71
|
+
// git --show-toplevel is realpath-canonical; realpath defensively so every
|
|
72
|
+
// containment/match basis in the adapter is on the same canonical footing.
|
|
73
|
+
return await realpathPartial(root);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseWorktreeList(text) {
|
|
77
|
+
const records = [];
|
|
78
|
+
let current;
|
|
79
|
+
for (const token of text.split("\0")) {
|
|
80
|
+
if (!token) continue;
|
|
81
|
+
const [key, ...rest] = token.split(" ");
|
|
82
|
+
const value = rest.join(" ");
|
|
83
|
+
if (key === "worktree") {
|
|
84
|
+
if (current) records.push(current);
|
|
85
|
+
current = { path: path.resolve(value), fields: {}, raw: [] };
|
|
86
|
+
} else if (current) {
|
|
87
|
+
current.raw.push(token);
|
|
88
|
+
current.fields[key] = value || true;
|
|
89
|
+
if (key === "HEAD") current.head = value;
|
|
90
|
+
if (key === "branch") current.branch = value;
|
|
91
|
+
if (key === "detached") current.detached = true;
|
|
92
|
+
if (key === "locked") current.locked = value || true;
|
|
93
|
+
if (key === "prunable") current.prunable = value || true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (current) records.push(current);
|
|
97
|
+
return records;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function listWorktrees(root) {
|
|
101
|
+
const { stdout } = await git(root, ["worktree", "list", "--porcelain", "-z"]);
|
|
102
|
+
return parseWorktreeList(stdout);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function worktreeStatus(directory) {
|
|
106
|
+
const porcelain = (await git(directory, ["status", "--porcelain=v1", "--untracked-files=all"])).stdout;
|
|
107
|
+
const lines = porcelain.split(/\r?\n/).filter(Boolean);
|
|
108
|
+
const untracked = lines.filter((line) => line.startsWith("??"));
|
|
109
|
+
const tracked = lines.filter((line) => !line.startsWith("??"));
|
|
110
|
+
return {
|
|
111
|
+
directory: path.resolve(directory),
|
|
112
|
+
porcelain,
|
|
113
|
+
clean: lines.length === 0,
|
|
114
|
+
dirty: lines.length > 0,
|
|
115
|
+
trackedDirty: tracked.length > 0,
|
|
116
|
+
untrackedDirty: untracked.length > 0,
|
|
117
|
+
entries: lines,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function assertBranchAvailable(root, branch, options = {}) {
|
|
122
|
+
await git(root, ["check-ref-format", "--branch", branch], options);
|
|
123
|
+
if (await gitSucceeds(root, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], options)) {
|
|
124
|
+
throw new Error(`Worktree branch already exists: ${branch}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function createRawWorktree({ root, targetPath, branch, baseRef, execOptions }) {
|
|
129
|
+
await assertBranchAvailable(root, branch, execOptions);
|
|
130
|
+
if (await pathExists(targetPath)) throw new Error(`Worktree path already exists: ${targetPath}`);
|
|
131
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
132
|
+
await git(root, ["worktree", "add", "-b", branch, targetPath, baseRef], execOptions);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export {
|
|
136
|
+
assertBranchAvailable,
|
|
137
|
+
assertContainedRealPath,
|
|
138
|
+
createRawWorktree,
|
|
139
|
+
hasWorkingTreeChanges,
|
|
140
|
+
isPathInside,
|
|
141
|
+
listWorktrees,
|
|
142
|
+
parseWorktreeList,
|
|
143
|
+
pathExists,
|
|
144
|
+
realpathPartial,
|
|
145
|
+
requireGitRepo,
|
|
146
|
+
worktreeStatus,
|
|
147
|
+
};
|