agentweaver 0.1.19 → 0.1.20
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/README.md +47 -7
- package/dist/artifacts.js +9 -0
- package/dist/executors/git-commit-executor.js +24 -6
- package/dist/flow-state.js +3 -8
- package/dist/git/git-diff-parser.js +223 -0
- package/dist/git/git-service.js +562 -0
- package/dist/git/git-stage-selection.js +24 -0
- package/dist/git/git-status-parser.js +171 -0
- package/dist/git/git-types.js +1 -0
- package/dist/index.js +450 -108
- package/dist/interactive/auto-flow.js +644 -0
- package/dist/interactive/controller.js +417 -9
- package/dist/interactive/progress.js +194 -1
- package/dist/interactive/state.js +25 -0
- package/dist/interactive/web/index.js +97 -12
- package/dist/interactive/web/protocol.js +216 -1
- package/dist/interactive/web/server.js +72 -14
- package/dist/interactive/web/static/app.js +1603 -49
- package/dist/interactive/web/static/index.html +76 -11
- package/dist/interactive/web/static/styles.css +1 -1
- package/dist/interactive/web/static/styles.input.css +901 -47
- package/dist/pipeline/auto-flow-blocks.js +307 -0
- package/dist/pipeline/auto-flow-config.js +273 -0
- package/dist/pipeline/auto-flow-identity.js +49 -0
- package/dist/pipeline/auto-flow-presets.js +52 -0
- package/dist/pipeline/auto-flow-resolver.js +830 -0
- package/dist/pipeline/auto-flow-types.js +17 -0
- package/dist/pipeline/context.js +1 -0
- package/dist/pipeline/declarative-flows.js +27 -1
- package/dist/pipeline/flow-specs/auto-common-guided.json +11 -0
- package/dist/pipeline/flow-specs/auto-golang.json +12 -1
- package/dist/pipeline/flow-specs/bugz/bug-analyze.json +54 -1
- package/dist/pipeline/flow-specs/gitlab/gitlab-diff-review.json +19 -1
- package/dist/pipeline/flow-specs/gitlab/gitlab-review.json +33 -1
- package/dist/pipeline/flow-specs/review/review-project.json +19 -1
- package/dist/pipeline/flow-specs/task-source/manual-jira-input.json +70 -0
- package/dist/pipeline/node-registry.js +9 -0
- package/dist/pipeline/nodes/codex-prompt-node.js +8 -1
- package/dist/pipeline/nodes/flow-run-node.js +5 -3
- package/dist/pipeline/nodes/git-status-node.js +2 -168
- package/dist/pipeline/nodes/manual-jira-task-input-node.js +146 -0
- package/dist/pipeline/nodes/opencode-prompt-node.js +8 -1
- package/dist/pipeline/nodes/plan-codex-node.js +8 -1
- package/dist/pipeline/spec-loader.js +14 -4
- package/dist/runtime/artifact-catalog.js +29 -5
- package/dist/runtime/settings.js +114 -0
- package/dist/scope.js +14 -4
- package/package.json +1 -1
- package/dist/pipeline/flow-specs/auto-common.json +0 -179
- package/dist/pipeline/flow-specs/auto-simple.json +0 -141
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createSyntheticAddedDiff, parseGitDiffOutput } from "./git-diff-parser.js";
|
|
5
|
+
import { selectPathsNeedingGitStage, uniqueGitPaths } from "./git-stage-selection.js";
|
|
6
|
+
import { parsePorcelain } from "./git-status-parser.js";
|
|
7
|
+
const DEFAULT_REMOTE_TIMEOUT_MS = 60_000;
|
|
8
|
+
const DEFAULT_MAX_DIFF_BYTES = 1024 * 1024;
|
|
9
|
+
const GIT_DIFF_FLAGS = ["--no-color", "--no-ext-diff", "--find-renames", "--unified=3"];
|
|
10
|
+
export class GitDiffError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function unavailableSnapshot(message) {
|
|
18
|
+
return {
|
|
19
|
+
available: false,
|
|
20
|
+
repositoryRoot: null,
|
|
21
|
+
branch: null,
|
|
22
|
+
detachedHead: false,
|
|
23
|
+
clean: true,
|
|
24
|
+
upstream: null,
|
|
25
|
+
ahead: 0,
|
|
26
|
+
behind: 0,
|
|
27
|
+
lastCommit: null,
|
|
28
|
+
changedFiles: [],
|
|
29
|
+
branches: [],
|
|
30
|
+
remotes: [],
|
|
31
|
+
canPush: false,
|
|
32
|
+
pushDisabledReason: "Git repository is not available.",
|
|
33
|
+
warnings: [],
|
|
34
|
+
error: message,
|
|
35
|
+
refreshedAt: new Date().toISOString(),
|
|
36
|
+
selectedPaths: [],
|
|
37
|
+
commitMessage: "",
|
|
38
|
+
operation: { status: "idle" },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function success(message, commitHash) {
|
|
42
|
+
return { status: "success", message, ...(commitHash !== undefined ? { commitHash } : {}) };
|
|
43
|
+
}
|
|
44
|
+
function errorMessage(error) {
|
|
45
|
+
const err = error;
|
|
46
|
+
const output = typeof err.output === "string" ? err.output.trim() : "";
|
|
47
|
+
return output || err.message || "Git operation failed.";
|
|
48
|
+
}
|
|
49
|
+
function parseBranchLine(output) {
|
|
50
|
+
const line = output.split(/\r?\n/).find((candidate) => candidate.startsWith("## ")) ?? "";
|
|
51
|
+
const raw = line.slice(3).trim();
|
|
52
|
+
if (!raw) {
|
|
53
|
+
return { branch: null, detachedHead: false, upstream: null, ahead: 0, behind: 0 };
|
|
54
|
+
}
|
|
55
|
+
if (raw === "HEAD (no branch)" || raw.includes("no branch")) {
|
|
56
|
+
return { branch: null, detachedHead: true, upstream: null, ahead: 0, behind: 0 };
|
|
57
|
+
}
|
|
58
|
+
const ahead = Number(raw.match(/ahead (\d+)/)?.[1] ?? 0);
|
|
59
|
+
const behind = Number(raw.match(/behind (\d+)/)?.[1] ?? 0);
|
|
60
|
+
const base = raw.split(" [")[0] ?? raw;
|
|
61
|
+
const [branchPart, upstreamPart] = base.split("...");
|
|
62
|
+
const branch = branchPart?.replace(/^No commits yet on /, "").trim() || null;
|
|
63
|
+
const upstream = upstreamPart?.trim() || null;
|
|
64
|
+
return { branch, detachedHead: false, upstream, ahead, behind };
|
|
65
|
+
}
|
|
66
|
+
function parseLastCommit(output) {
|
|
67
|
+
const trimmed = output.trim();
|
|
68
|
+
if (!trimmed) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const [hash, shortHash, subject, authoredAt] = trimmed.split("\0");
|
|
72
|
+
if (!hash || !shortHash) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
hash,
|
|
77
|
+
shortHash,
|
|
78
|
+
subject: subject ?? "",
|
|
79
|
+
authoredAt: authoredAt ?? "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function parseBranches(output) {
|
|
83
|
+
return output
|
|
84
|
+
.split(/\r?\n/)
|
|
85
|
+
.filter((line) => line.trim().length > 0)
|
|
86
|
+
.map((line) => {
|
|
87
|
+
const [name, head, upstream] = line.split("\0");
|
|
88
|
+
return {
|
|
89
|
+
name: name ?? "",
|
|
90
|
+
current: head === "*",
|
|
91
|
+
...(upstream ? { upstream } : {}),
|
|
92
|
+
};
|
|
93
|
+
})
|
|
94
|
+
.filter((branch) => branch.name.length > 0);
|
|
95
|
+
}
|
|
96
|
+
function parseRemotes(output) {
|
|
97
|
+
const remotes = new Map();
|
|
98
|
+
for (const line of output.split(/\r?\n/)) {
|
|
99
|
+
const match = line.match(/^(\S+)\s+(.+)\s+\((fetch|push)\)$/);
|
|
100
|
+
if (!match) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const [, name, url, kind] = match;
|
|
104
|
+
const current = remotes.get(name) ?? { name: name };
|
|
105
|
+
if (kind === "fetch") {
|
|
106
|
+
current.fetchUrl = url;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
current.pushUrl = url;
|
|
110
|
+
}
|
|
111
|
+
remotes.set(name, current);
|
|
112
|
+
}
|
|
113
|
+
return Array.from(remotes.values());
|
|
114
|
+
}
|
|
115
|
+
function nonInteractiveEnv() {
|
|
116
|
+
return {
|
|
117
|
+
...process.env,
|
|
118
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
119
|
+
GIT_ASKPASS: "echo",
|
|
120
|
+
SSH_ASKPASS: "echo",
|
|
121
|
+
GCM_INTERACTIVE: "Never",
|
|
122
|
+
GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function withTimeout(timeoutMs, run) {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
128
|
+
return run(controller.signal).finally(() => clearTimeout(timer));
|
|
129
|
+
}
|
|
130
|
+
function validateLocalBranchName(name) {
|
|
131
|
+
if (name.trim().length === 0) {
|
|
132
|
+
return { ok: false, message: "Branch name must not be empty." };
|
|
133
|
+
}
|
|
134
|
+
if (name !== name.trim()) {
|
|
135
|
+
return { ok: false, message: "Branch name must not include leading or trailing whitespace." };
|
|
136
|
+
}
|
|
137
|
+
if (name.startsWith("-")) {
|
|
138
|
+
return { ok: false, message: "Branch name must not start with a dash." };
|
|
139
|
+
}
|
|
140
|
+
if (/[\x00-\x1f\x7f]/.test(name)) {
|
|
141
|
+
return { ok: false, message: "Branch name must not contain control characters." };
|
|
142
|
+
}
|
|
143
|
+
if (name.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
144
|
+
return { ok: false, message: "Branch name must not contain path traversal segments." };
|
|
145
|
+
}
|
|
146
|
+
return { ok: true };
|
|
147
|
+
}
|
|
148
|
+
function validateCommitMessage(message) {
|
|
149
|
+
if (message.trim().length === 0) {
|
|
150
|
+
return { ok: false, message: "Commit message must not be empty." };
|
|
151
|
+
}
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
function validateChangedPaths(paths, snapshot) {
|
|
155
|
+
if (!Array.isArray(paths) || paths.some((item) => typeof item !== "string" || item.length === 0)) {
|
|
156
|
+
return { ok: false, message: "Git file paths must be non-empty strings." };
|
|
157
|
+
}
|
|
158
|
+
const changed = new Set(snapshot.changedFiles.flatMap((file) => [file.path, file.file]));
|
|
159
|
+
for (const filePath of paths) {
|
|
160
|
+
if (!changed.has(filePath)) {
|
|
161
|
+
return { ok: false, message: `Path is not in the current Git snapshot: ${filePath}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { ok: true };
|
|
165
|
+
}
|
|
166
|
+
function findChangedFile(filePath, snapshot) {
|
|
167
|
+
return snapshot.changedFiles.find((file) => file.path === filePath || file.file === filePath) ?? null;
|
|
168
|
+
}
|
|
169
|
+
function validateChangedPath(filePath, snapshot) {
|
|
170
|
+
if (typeof filePath !== "string" || filePath.length === 0 || filePath.includes("\0")) {
|
|
171
|
+
throw new GitDiffError("invalid_path", "Git file path must be a non-empty string.");
|
|
172
|
+
}
|
|
173
|
+
if (!snapshot.available || !snapshot.repositoryRoot) {
|
|
174
|
+
throw new GitDiffError("repository_unavailable", snapshot.error ?? "Git repository is not available.");
|
|
175
|
+
}
|
|
176
|
+
const file = findChangedFile(filePath, snapshot);
|
|
177
|
+
if (!file) {
|
|
178
|
+
throw new GitDiffError("invalid_path", `Path is not in the current Git snapshot: ${filePath}`);
|
|
179
|
+
}
|
|
180
|
+
return file;
|
|
181
|
+
}
|
|
182
|
+
function isInsideDirectory(parent, candidate) {
|
|
183
|
+
const relative = path.relative(parent, candidate);
|
|
184
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
185
|
+
}
|
|
186
|
+
function isBinaryBuffer(buffer) {
|
|
187
|
+
return buffer.includes(0);
|
|
188
|
+
}
|
|
189
|
+
function emptyDiff(mode, file, message) {
|
|
190
|
+
return {
|
|
191
|
+
mode,
|
|
192
|
+
path: file.path,
|
|
193
|
+
displayPath: file.path,
|
|
194
|
+
...(file.originalPath || file.originalFile ? { originalPath: file.originalPath ?? file.originalFile } : {}),
|
|
195
|
+
binary: false,
|
|
196
|
+
tooLarge: false,
|
|
197
|
+
empty: true,
|
|
198
|
+
hunks: [],
|
|
199
|
+
message,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function tooLargeDiff(mode, file, message) {
|
|
203
|
+
return {
|
|
204
|
+
mode,
|
|
205
|
+
path: file.path,
|
|
206
|
+
displayPath: file.path,
|
|
207
|
+
...(file.originalPath || file.originalFile ? { originalPath: file.originalPath ?? file.originalFile } : {}),
|
|
208
|
+
binary: false,
|
|
209
|
+
tooLarge: true,
|
|
210
|
+
empty: false,
|
|
211
|
+
hunks: [],
|
|
212
|
+
message,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function binaryDiff(mode, file, message) {
|
|
216
|
+
return {
|
|
217
|
+
mode,
|
|
218
|
+
path: file.path,
|
|
219
|
+
displayPath: file.path,
|
|
220
|
+
...(file.originalPath || file.originalFile ? { originalPath: file.originalPath ?? file.originalFile } : {}),
|
|
221
|
+
binary: true,
|
|
222
|
+
tooLarge: false,
|
|
223
|
+
empty: false,
|
|
224
|
+
hunks: [],
|
|
225
|
+
message,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function extractCommitHash(output) {
|
|
229
|
+
return output.match(/\[\S+ ([0-9a-f]{7,40})\]/)?.[1] ?? null;
|
|
230
|
+
}
|
|
231
|
+
export function createGitService(options) {
|
|
232
|
+
const cwdPrefix = options.cwd ? ["-C", options.cwd] : [];
|
|
233
|
+
const remoteTimeoutMs = options.timeoutMs ?? DEFAULT_REMOTE_TIMEOUT_MS;
|
|
234
|
+
const maxDiffBytes = options.maxDiffBytes ?? DEFAULT_MAX_DIFF_BYTES;
|
|
235
|
+
async function git(args, commandOptions = {}) {
|
|
236
|
+
return options.runCommand(["git", ...cwdPrefix, ...args], {
|
|
237
|
+
...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}),
|
|
238
|
+
...(options.verbose !== undefined ? { verbose: options.verbose } : {}),
|
|
239
|
+
...commandOptions,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
async function remoteGit(args, label) {
|
|
243
|
+
return withTimeout(remoteTimeoutMs, (signal) => git(args, {
|
|
244
|
+
label,
|
|
245
|
+
env: nonInteractiveEnv(),
|
|
246
|
+
signal,
|
|
247
|
+
printFailureOutput: false,
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
async function validateBranchName(name) {
|
|
251
|
+
const local = validateLocalBranchName(name);
|
|
252
|
+
if (!local.ok) {
|
|
253
|
+
return local;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
await git(["check-ref-format", "--branch", name], {
|
|
257
|
+
label: "git check-ref-format",
|
|
258
|
+
printFailureOutput: false,
|
|
259
|
+
});
|
|
260
|
+
return { ok: true };
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
return { ok: false, message: errorMessage(error) };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function status() {
|
|
267
|
+
try {
|
|
268
|
+
const repositoryRoot = (await git(["rev-parse", "--show-toplevel"], {
|
|
269
|
+
label: "git rev-parse",
|
|
270
|
+
printFailureOutput: false,
|
|
271
|
+
})).trim();
|
|
272
|
+
const statusOutput = await git(["status", "--porcelain", "--branch"], {
|
|
273
|
+
label: "git status",
|
|
274
|
+
printFailureOutput: false,
|
|
275
|
+
});
|
|
276
|
+
const changedFiles = parsePorcelain(statusOutput);
|
|
277
|
+
const branch = parseBranchLine(statusOutput);
|
|
278
|
+
let branches = [];
|
|
279
|
+
let remotes = [];
|
|
280
|
+
let lastCommit = null;
|
|
281
|
+
const warnings = [];
|
|
282
|
+
try {
|
|
283
|
+
branches = parseBranches(await git(["branch", "--format=%(refname:short)%00%(HEAD)%00%(upstream:short)"], {
|
|
284
|
+
label: "git branch",
|
|
285
|
+
printFailureOutput: false,
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
warnings.push(`Branches could not be listed: ${errorMessage(error)}`);
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
remotes = parseRemotes(await git(["remote", "-v"], {
|
|
293
|
+
label: "git remote",
|
|
294
|
+
printFailureOutput: false,
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
warnings.push(`Remotes could not be listed: ${errorMessage(error)}`);
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
lastCommit = parseLastCommit(await git(["log", "-1", "--format=%H%x00%h%x00%s%x00%ci"], {
|
|
302
|
+
label: "git log",
|
|
303
|
+
printFailureOutput: false,
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
lastCommit = null;
|
|
308
|
+
}
|
|
309
|
+
const hasRemotes = remotes.length > 0;
|
|
310
|
+
const canPush = hasRemotes && !branch.detachedHead && Boolean(branch.branch);
|
|
311
|
+
const pushDisabledReason = canPush
|
|
312
|
+
? null
|
|
313
|
+
: !hasRemotes
|
|
314
|
+
? "No Git remote is configured."
|
|
315
|
+
: branch.detachedHead
|
|
316
|
+
? "Detached HEAD cannot be pushed."
|
|
317
|
+
: "No current branch is available to push.";
|
|
318
|
+
return {
|
|
319
|
+
available: true,
|
|
320
|
+
repositoryRoot,
|
|
321
|
+
branch: branch.branch,
|
|
322
|
+
detachedHead: branch.detachedHead,
|
|
323
|
+
clean: changedFiles.length === 0,
|
|
324
|
+
upstream: branch.upstream,
|
|
325
|
+
ahead: branch.ahead,
|
|
326
|
+
behind: branch.behind,
|
|
327
|
+
lastCommit,
|
|
328
|
+
changedFiles,
|
|
329
|
+
branches,
|
|
330
|
+
remotes,
|
|
331
|
+
canPush,
|
|
332
|
+
pushDisabledReason,
|
|
333
|
+
warnings,
|
|
334
|
+
error: null,
|
|
335
|
+
refreshedAt: new Date().toISOString(),
|
|
336
|
+
selectedPaths: [],
|
|
337
|
+
commitMessage: "",
|
|
338
|
+
operation: { status: "idle" },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
return unavailableSnapshot(errorMessage(error));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async function diffFile(filePath, mode, snapshot) {
|
|
346
|
+
if (!["head", "staged", "worktree"].includes(mode)) {
|
|
347
|
+
throw new GitDiffError("invalid_mode", "Git diff mode must be head, staged, or worktree.");
|
|
348
|
+
}
|
|
349
|
+
const file = validateChangedPath(filePath, snapshot);
|
|
350
|
+
if ((file.type === "untracked" || file.xy === "??") && mode !== "staged") {
|
|
351
|
+
return readUntrackedDiff(file, mode, snapshot);
|
|
352
|
+
}
|
|
353
|
+
if ((file.type === "untracked" || file.xy === "??") && mode === "staged") {
|
|
354
|
+
return emptyDiff(mode, file, "Untracked file has no staged diff.");
|
|
355
|
+
}
|
|
356
|
+
const args = diffArgs(mode, file.path);
|
|
357
|
+
let output;
|
|
358
|
+
try {
|
|
359
|
+
output = await git(args, { label: "git diff", printFailureOutput: false });
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
throw new GitDiffError("git_failed", errorMessage(error));
|
|
363
|
+
}
|
|
364
|
+
if (Buffer.byteLength(output, "utf8") > maxDiffBytes) {
|
|
365
|
+
return tooLargeDiff(mode, file, "Diff is too large to display.");
|
|
366
|
+
}
|
|
367
|
+
return parseGitDiffOutput(output, {
|
|
368
|
+
mode,
|
|
369
|
+
path: file.path,
|
|
370
|
+
displayPath: file.path,
|
|
371
|
+
...(file.originalPath || file.originalFile ? { originalPath: file.originalPath ?? file.originalFile } : {}),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
function diffArgs(mode, filePath) {
|
|
375
|
+
if (mode === "head") {
|
|
376
|
+
return ["diff", ...GIT_DIFF_FLAGS, "HEAD", "--", filePath];
|
|
377
|
+
}
|
|
378
|
+
if (mode === "staged") {
|
|
379
|
+
return ["diff", ...GIT_DIFF_FLAGS, "--cached", "--", filePath];
|
|
380
|
+
}
|
|
381
|
+
return ["diff", ...GIT_DIFF_FLAGS, "--", filePath];
|
|
382
|
+
}
|
|
383
|
+
function readUntrackedDiff(file, mode, snapshot) {
|
|
384
|
+
const repositoryRoot = snapshot.repositoryRoot;
|
|
385
|
+
if (!repositoryRoot) {
|
|
386
|
+
throw new GitDiffError("repository_unavailable", "Git repository is not available.");
|
|
387
|
+
}
|
|
388
|
+
const rootRealPath = realpathSync(repositoryRoot);
|
|
389
|
+
const candidatePath = path.resolve(repositoryRoot, file.path);
|
|
390
|
+
let realPath;
|
|
391
|
+
try {
|
|
392
|
+
realPath = realpathSync(candidatePath);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
throw new GitDiffError("read_failed", "Untracked file could not be read.");
|
|
396
|
+
}
|
|
397
|
+
if (!isInsideDirectory(rootRealPath, realPath)) {
|
|
398
|
+
throw new GitDiffError("forbidden_path", "Untracked file path escapes the repository root.");
|
|
399
|
+
}
|
|
400
|
+
const stats = statSync(realPath);
|
|
401
|
+
if (!stats.isFile()) {
|
|
402
|
+
throw new GitDiffError("forbidden_path", "Untracked path is not a regular file.");
|
|
403
|
+
}
|
|
404
|
+
if (stats.size > maxDiffBytes) {
|
|
405
|
+
return tooLargeDiff(mode, file, "Untracked file is too large to display.");
|
|
406
|
+
}
|
|
407
|
+
const content = readFileSync(realPath);
|
|
408
|
+
if (isBinaryBuffer(content)) {
|
|
409
|
+
return binaryDiff(mode, file, "Binary untracked file diff is not displayed.");
|
|
410
|
+
}
|
|
411
|
+
return createSyntheticAddedDiff({
|
|
412
|
+
mode,
|
|
413
|
+
path: file.path,
|
|
414
|
+
displayPath: file.path,
|
|
415
|
+
content: content.toString("utf8"),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
async function createBranch(branchName) {
|
|
419
|
+
const validation = await validateBranchName(branchName);
|
|
420
|
+
if (!validation.ok) {
|
|
421
|
+
return { status: "error", message: validation.message ?? "Branch name is invalid." };
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
await git(["checkout", "-b", branchName], { label: "git checkout -b", printFailureOutput: false });
|
|
425
|
+
return success(`Created branch ${branchName}.`);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
return { status: "error", message: errorMessage(error) };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async function checkout(branchName) {
|
|
432
|
+
const validation = await validateBranchName(branchName);
|
|
433
|
+
if (!validation.ok) {
|
|
434
|
+
return { status: "error", message: validation.message ?? "Branch name is invalid." };
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
await git(["checkout", branchName], { label: "git checkout", printFailureOutput: false });
|
|
438
|
+
return success(`Checked out ${branchName}.`);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
return { status: "error", message: errorMessage(error) };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async function stage(paths, snapshot) {
|
|
445
|
+
const validation = validateChangedPaths(paths, snapshot);
|
|
446
|
+
if (!validation.ok) {
|
|
447
|
+
return { status: "error", message: validation.message ?? "Selected paths are invalid." };
|
|
448
|
+
}
|
|
449
|
+
const stagePaths = selectPathsNeedingGitStage(paths, snapshot.changedFiles);
|
|
450
|
+
if (stagePaths.length === 0) {
|
|
451
|
+
return success("Selected files are already staged.");
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
await git(["add", "-A", "--", ...stagePaths], { label: "git add", printFailureOutput: false });
|
|
455
|
+
return success(`Staged ${stagePaths.length} file${stagePaths.length === 1 ? "" : "s"}.`);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
return { status: "error", message: errorMessage(error) };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async function unstage(paths, snapshot) {
|
|
462
|
+
const validation = validateChangedPaths(paths, snapshot);
|
|
463
|
+
if (!validation.ok) {
|
|
464
|
+
return { status: "error", message: validation.message ?? "Selected paths are invalid." };
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
await git(["restore", "--staged", "--", ...paths], { label: "git restore --staged", printFailureOutput: false });
|
|
468
|
+
return success(`Unstaged ${paths.length} file${paths.length === 1 ? "" : "s"}.`);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
return { status: "error", message: errorMessage(error) };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async function commit(paths, message, snapshot) {
|
|
475
|
+
const messageValidation = validateCommitMessage(message);
|
|
476
|
+
if (!messageValidation.ok) {
|
|
477
|
+
return { status: "error", message: messageValidation.message ?? "Commit message is invalid." };
|
|
478
|
+
}
|
|
479
|
+
if (paths.length > 0) {
|
|
480
|
+
const pathValidation = validateChangedPaths(paths, snapshot);
|
|
481
|
+
if (!pathValidation.ok) {
|
|
482
|
+
return { status: "error", message: pathValidation.message ?? "Selected paths are invalid." };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const commitPaths = uniqueGitPaths(paths);
|
|
486
|
+
try {
|
|
487
|
+
if (commitPaths.length > 0) {
|
|
488
|
+
const stagePaths = selectPathsNeedingGitStage(commitPaths, snapshot.changedFiles);
|
|
489
|
+
if (stagePaths.length > 0) {
|
|
490
|
+
await git(["add", "-A", "--", ...stagePaths], { label: "git add", printFailureOutput: false });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const commitArgs = commitPaths.length > 0
|
|
494
|
+
? ["commit", "-m", message, "--", ...commitPaths]
|
|
495
|
+
: ["commit", "-m", message];
|
|
496
|
+
const output = await git(commitArgs, { label: "git commit", printFailureOutput: false });
|
|
497
|
+
const commitHash = extractCommitHash(output);
|
|
498
|
+
return success(commitHash ? `Committed ${commitHash}.` : "Commit completed.", commitHash);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
return { status: "error", message: errorMessage(error) };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async function fetch() {
|
|
505
|
+
try {
|
|
506
|
+
await remoteGit(["fetch", "--prune"], "git fetch");
|
|
507
|
+
return success("Fetch completed.");
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
return { status: "error", message: errorMessage(error) };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function pullFfOnly() {
|
|
514
|
+
try {
|
|
515
|
+
await remoteGit(["pull", "--ff-only"], "git pull --ff-only");
|
|
516
|
+
return success("Fast-forward pull completed.");
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
return { status: "error", message: errorMessage(error) };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function push(snapshot) {
|
|
523
|
+
if (!snapshot.canPush) {
|
|
524
|
+
return { status: "error", message: snapshot.pushDisabledReason ?? "Push is not available." };
|
|
525
|
+
}
|
|
526
|
+
const branchName = snapshot.branch;
|
|
527
|
+
if (!branchName) {
|
|
528
|
+
return { status: "error", message: "No current branch is available to push." };
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
if (snapshot.upstream) {
|
|
532
|
+
await remoteGit(["push"], "git push");
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
const remoteName = snapshot.remotes.some((remote) => remote.name === "origin")
|
|
536
|
+
? "origin"
|
|
537
|
+
: snapshot.remotes[0]?.name;
|
|
538
|
+
if (!remoteName) {
|
|
539
|
+
return { status: "error", message: "No Git remote is configured." };
|
|
540
|
+
}
|
|
541
|
+
await remoteGit(["push", "--set-upstream", remoteName, branchName], "git push --set-upstream");
|
|
542
|
+
}
|
|
543
|
+
return success("Push completed.");
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
return { status: "error", message: errorMessage(error) };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
status,
|
|
551
|
+
validateBranchName,
|
|
552
|
+
diffFile,
|
|
553
|
+
createBranch,
|
|
554
|
+
checkout,
|
|
555
|
+
stage,
|
|
556
|
+
unstage,
|
|
557
|
+
commit,
|
|
558
|
+
fetch,
|
|
559
|
+
pullFfOnly,
|
|
560
|
+
push,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function uniqueGitPaths(paths) {
|
|
2
|
+
return paths.filter((filePath, index, allPaths) => allPaths.indexOf(filePath) === index);
|
|
3
|
+
}
|
|
4
|
+
export function needsGitFileStage(file) {
|
|
5
|
+
if (file.type === "untracked" || file.xy === "??") {
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
return file.workTreeStatus !== " ";
|
|
9
|
+
}
|
|
10
|
+
export function selectChangedFilesForPaths(paths, changedFiles) {
|
|
11
|
+
const filesByPath = new Map();
|
|
12
|
+
for (const file of changedFiles) {
|
|
13
|
+
filesByPath.set(file.path, file);
|
|
14
|
+
filesByPath.set(file.file, file);
|
|
15
|
+
}
|
|
16
|
+
return uniqueGitPaths(paths)
|
|
17
|
+
.map((filePath) => filesByPath.get(filePath))
|
|
18
|
+
.filter((file) => file !== undefined);
|
|
19
|
+
}
|
|
20
|
+
export function selectPathsNeedingGitStage(paths, changedFiles) {
|
|
21
|
+
return selectChangedFilesForPaths(paths, changedFiles)
|
|
22
|
+
.filter(needsGitFileStage)
|
|
23
|
+
.map((file) => file.path);
|
|
24
|
+
}
|