@aigne/afs-git 1.0.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 +23 -0
- package/LICENSE.md +93 -0
- package/README.md +227 -0
- package/lib/cjs/index.d.ts +129 -0
- package/lib/cjs/index.js +601 -0
- package/lib/cjs/package.json +3 -0
- package/lib/dts/index.d.ts +129 -0
- package/lib/esm/index.d.ts +129 -0
- package/lib/esm/index.js +597 -0
- package/lib/esm/package.json +3 -0
- package/package.json +70 -0
package/lib/cjs/index.js
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AFSGit = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const promises_1 = require("node:fs/promises");
|
|
7
|
+
const node_os_1 = require("node:os");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
const node_util_1 = require("node:util");
|
|
10
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
11
|
+
const schema_js_1 = require("@aigne/core/loader/schema.js");
|
|
12
|
+
const type_utils_js_1 = require("@aigne/core/utils/type-utils.js");
|
|
13
|
+
const simple_git_1 = require("simple-git");
|
|
14
|
+
const zod_1 = require("zod");
|
|
15
|
+
const LIST_MAX_LIMIT = 1000;
|
|
16
|
+
const afsGitOptionsSchema = (0, schema_js_1.camelizeSchema)(zod_1.z.object({
|
|
17
|
+
name: (0, schema_js_1.optionalize)(zod_1.z.string()),
|
|
18
|
+
repoPath: zod_1.z.string().describe("The path to the git repository"),
|
|
19
|
+
description: (0, schema_js_1.optionalize)(zod_1.z.string().describe("A description of the repository")),
|
|
20
|
+
branches: (0, schema_js_1.optionalize)(zod_1.z.array(zod_1.z.string()).describe("List of branches to expose")),
|
|
21
|
+
accessMode: (0, schema_js_1.optionalize)(zod_1.z.enum(["readonly", "readwrite"]).describe("Access mode for this module")),
|
|
22
|
+
autoCommit: (0, schema_js_1.optionalize)(zod_1.z.boolean().describe("Automatically commit changes after write operations")),
|
|
23
|
+
commitAuthor: (0, schema_js_1.optionalize)(zod_1.z.object({
|
|
24
|
+
name: zod_1.z.string(),
|
|
25
|
+
email: zod_1.z.string(),
|
|
26
|
+
})),
|
|
27
|
+
}));
|
|
28
|
+
class AFSGit {
|
|
29
|
+
options;
|
|
30
|
+
static schema() {
|
|
31
|
+
return afsGitOptionsSchema;
|
|
32
|
+
}
|
|
33
|
+
static async load({ filepath, parsed }) {
|
|
34
|
+
const valid = await AFSGit.schema().parseAsync(parsed);
|
|
35
|
+
return new AFSGit({ ...valid, cwd: (0, node_path_1.dirname)(filepath) });
|
|
36
|
+
}
|
|
37
|
+
git;
|
|
38
|
+
tempBase;
|
|
39
|
+
worktrees = new Map();
|
|
40
|
+
repoHash;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
(0, type_utils_js_1.checkArguments)("AFSGit", afsGitOptionsSchema, options);
|
|
44
|
+
let repoPath;
|
|
45
|
+
if ((0, node_path_1.isAbsolute)(options.repoPath)) {
|
|
46
|
+
repoPath = options.repoPath;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
repoPath = (0, node_path_1.join)(options.cwd || process.cwd(), options.repoPath);
|
|
50
|
+
}
|
|
51
|
+
this.options.repoPath = repoPath;
|
|
52
|
+
this.name = options.name || (0, node_path_1.basename)(repoPath) || "git";
|
|
53
|
+
this.description = options.description;
|
|
54
|
+
this.accessMode = options.accessMode ?? "readonly";
|
|
55
|
+
this.git = (0, simple_git_1.simpleGit)(repoPath);
|
|
56
|
+
// Create a hash of the repo path for unique temp directory
|
|
57
|
+
this.repoHash = (0, node_crypto_1.createHash)("md5").update(repoPath).digest("hex").substring(0, 8);
|
|
58
|
+
this.tempBase = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `afs-git-${this.repoHash}`);
|
|
59
|
+
}
|
|
60
|
+
name;
|
|
61
|
+
description;
|
|
62
|
+
accessMode;
|
|
63
|
+
/**
|
|
64
|
+
* Parse AFS path into branch and file path
|
|
65
|
+
* Branch names may contain slashes and are encoded with ~ in paths
|
|
66
|
+
* Examples:
|
|
67
|
+
* "/" -> { branch: undefined, filePath: "" }
|
|
68
|
+
* "/main" -> { branch: "main", filePath: "" }
|
|
69
|
+
* "/feature~new-feature" -> { branch: "feature/new-feature", filePath: "" }
|
|
70
|
+
* "/main/src/index.ts" -> { branch: "main", filePath: "src/index.ts" }
|
|
71
|
+
*/
|
|
72
|
+
parsePath(path) {
|
|
73
|
+
const normalized = (0, node_path_1.join)("/", path); // Ensure leading slash
|
|
74
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
75
|
+
if (segments.length === 0) {
|
|
76
|
+
return { branch: undefined, filePath: "" };
|
|
77
|
+
}
|
|
78
|
+
// Decode branch name (first segment): replace ~ with /
|
|
79
|
+
const branch = segments[0].replace(/~/g, "/");
|
|
80
|
+
const filePath = segments.slice(1).join("/");
|
|
81
|
+
return { branch, filePath };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Detect MIME type based on file extension
|
|
85
|
+
*/
|
|
86
|
+
getMimeType(filePath) {
|
|
87
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
88
|
+
const mimeTypes = {
|
|
89
|
+
// Images
|
|
90
|
+
png: "image/png",
|
|
91
|
+
jpg: "image/jpeg",
|
|
92
|
+
jpeg: "image/jpeg",
|
|
93
|
+
gif: "image/gif",
|
|
94
|
+
bmp: "image/bmp",
|
|
95
|
+
webp: "image/webp",
|
|
96
|
+
svg: "image/svg+xml",
|
|
97
|
+
ico: "image/x-icon",
|
|
98
|
+
// Documents
|
|
99
|
+
pdf: "application/pdf",
|
|
100
|
+
txt: "text/plain",
|
|
101
|
+
md: "text/markdown",
|
|
102
|
+
// Code
|
|
103
|
+
js: "text/javascript",
|
|
104
|
+
ts: "text/typescript",
|
|
105
|
+
json: "application/json",
|
|
106
|
+
html: "text/html",
|
|
107
|
+
css: "text/css",
|
|
108
|
+
xml: "text/xml",
|
|
109
|
+
};
|
|
110
|
+
return mimeTypes[ext || ""] || "application/octet-stream";
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Check if file is likely binary based on extension
|
|
114
|
+
*/
|
|
115
|
+
isBinaryFile(filePath) {
|
|
116
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
117
|
+
const binaryExtensions = [
|
|
118
|
+
"png",
|
|
119
|
+
"jpg",
|
|
120
|
+
"jpeg",
|
|
121
|
+
"gif",
|
|
122
|
+
"bmp",
|
|
123
|
+
"webp",
|
|
124
|
+
"ico",
|
|
125
|
+
"pdf",
|
|
126
|
+
"zip",
|
|
127
|
+
"tar",
|
|
128
|
+
"gz",
|
|
129
|
+
"exe",
|
|
130
|
+
"dll",
|
|
131
|
+
"so",
|
|
132
|
+
"dylib",
|
|
133
|
+
"wasm",
|
|
134
|
+
];
|
|
135
|
+
return binaryExtensions.includes(ext || "");
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Get list of available branches
|
|
139
|
+
*/
|
|
140
|
+
async getBranches() {
|
|
141
|
+
const branchSummary = await this.git.branchLocal();
|
|
142
|
+
const allBranches = branchSummary.all;
|
|
143
|
+
// Filter by allowed branches if specified
|
|
144
|
+
if (this.options.branches && this.options.branches.length > 0) {
|
|
145
|
+
return allBranches.filter((branch) => this.options.branches.includes(branch));
|
|
146
|
+
}
|
|
147
|
+
return allBranches;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Ensure worktree exists for a branch (lazy creation)
|
|
151
|
+
*/
|
|
152
|
+
async ensureWorktree(branch) {
|
|
153
|
+
if (this.worktrees.has(branch)) {
|
|
154
|
+
return this.worktrees.get(branch);
|
|
155
|
+
}
|
|
156
|
+
// Check if this is the current branch in the main repo
|
|
157
|
+
const currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"]);
|
|
158
|
+
if (currentBranch.trim() === branch) {
|
|
159
|
+
// Use the main repo path for the current branch
|
|
160
|
+
this.worktrees.set(branch, this.options.repoPath);
|
|
161
|
+
return this.options.repoPath;
|
|
162
|
+
}
|
|
163
|
+
const worktreePath = (0, node_path_1.join)(this.tempBase, branch);
|
|
164
|
+
// Check if worktree directory already exists
|
|
165
|
+
const exists = await (0, promises_1.stat)(worktreePath)
|
|
166
|
+
.then(() => true)
|
|
167
|
+
.catch(() => false);
|
|
168
|
+
if (!exists) {
|
|
169
|
+
await (0, promises_1.mkdir)(this.tempBase, { recursive: true });
|
|
170
|
+
await this.git.raw(["worktree", "add", worktreePath, branch]);
|
|
171
|
+
}
|
|
172
|
+
this.worktrees.set(branch, worktreePath);
|
|
173
|
+
return worktreePath;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* List files using git ls-tree (no worktree needed)
|
|
177
|
+
*/
|
|
178
|
+
async listWithGitLsTree(branch, path, options) {
|
|
179
|
+
const maxDepth = options?.maxDepth ?? 1;
|
|
180
|
+
const limit = Math.min(options?.limit || LIST_MAX_LIMIT, LIST_MAX_LIMIT);
|
|
181
|
+
const entries = [];
|
|
182
|
+
const targetPath = path || "";
|
|
183
|
+
const treeish = targetPath ? `${branch}:${targetPath}` : branch;
|
|
184
|
+
try {
|
|
185
|
+
// Check if the path exists and is a directory
|
|
186
|
+
const pathType = await this.git
|
|
187
|
+
.raw(["cat-file", "-t", treeish])
|
|
188
|
+
.then((t) => t.trim())
|
|
189
|
+
.catch(() => null);
|
|
190
|
+
if (pathType === null) {
|
|
191
|
+
// Path doesn't exist
|
|
192
|
+
return { data: [] };
|
|
193
|
+
}
|
|
194
|
+
// If it's a file, just return it
|
|
195
|
+
if (pathType === "blob") {
|
|
196
|
+
const size = await this.git
|
|
197
|
+
.raw(["cat-file", "-s", treeish])
|
|
198
|
+
.then((s) => Number.parseInt(s.trim(), 10));
|
|
199
|
+
const afsPath = this.buildPath(branch, path);
|
|
200
|
+
entries.push({
|
|
201
|
+
id: afsPath,
|
|
202
|
+
path: afsPath,
|
|
203
|
+
metadata: {
|
|
204
|
+
type: "file",
|
|
205
|
+
size,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
return { data: entries };
|
|
209
|
+
}
|
|
210
|
+
const queue = [{ path: targetPath, depth: 0 }];
|
|
211
|
+
while (queue.length > 0) {
|
|
212
|
+
const item = queue.shift();
|
|
213
|
+
const { path: itemPath, depth } = item;
|
|
214
|
+
// List directory contents
|
|
215
|
+
const itemTreeish = itemPath ? `${branch}:${itemPath}` : branch;
|
|
216
|
+
const output = await this.git.raw(["ls-tree", "-l", itemTreeish]);
|
|
217
|
+
const lines = output
|
|
218
|
+
.split("\n")
|
|
219
|
+
.filter((line) => line.trim())
|
|
220
|
+
.slice(0, limit - entries.length);
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
// Format: <mode> <type> <hash> <size (with padding)> <name>
|
|
223
|
+
// Example: 100644 blob abc123 1234\tREADME.md
|
|
224
|
+
// Note: size is "-" for trees/directories, and there can be multiple spaces/tabs before name
|
|
225
|
+
const match = line.match(/^(\d+)\s+(blob|tree)\s+(\w+)\s+(-|\d+)\s+(.+)$/);
|
|
226
|
+
if (!match)
|
|
227
|
+
continue;
|
|
228
|
+
const type = match[2];
|
|
229
|
+
const sizeStr = match[4];
|
|
230
|
+
const name = match[5];
|
|
231
|
+
const isDirectory = type === "tree";
|
|
232
|
+
const size = sizeStr === "-" ? undefined : Number.parseInt(sizeStr, 10);
|
|
233
|
+
const fullPath = itemPath ? `${itemPath}/${name}` : name;
|
|
234
|
+
const afsPath = this.buildPath(branch, fullPath);
|
|
235
|
+
entries.push({
|
|
236
|
+
id: afsPath,
|
|
237
|
+
path: afsPath,
|
|
238
|
+
metadata: {
|
|
239
|
+
type: isDirectory ? "directory" : "file",
|
|
240
|
+
size,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
// Add to queue if it's a directory and we haven't reached max depth
|
|
244
|
+
if (isDirectory && depth + 1 < maxDepth) {
|
|
245
|
+
queue.push({ path: fullPath, depth: depth + 1 });
|
|
246
|
+
}
|
|
247
|
+
// Check limit
|
|
248
|
+
if (entries.length >= limit) {
|
|
249
|
+
return { data: entries };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { data: entries };
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return { data: [], message: error.message };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Build AFS path with encoded branch name
|
|
261
|
+
* Branch names with slashes are encoded by replacing / with ~
|
|
262
|
+
* @param branch Branch name (may contain slashes)
|
|
263
|
+
* @param filePath File path within branch
|
|
264
|
+
*/
|
|
265
|
+
buildPath(branch, filePath) {
|
|
266
|
+
// Replace / with ~ in branch name
|
|
267
|
+
const encodedBranch = branch.replace(/\//g, "~");
|
|
268
|
+
if (!filePath) {
|
|
269
|
+
return `/${encodedBranch}`;
|
|
270
|
+
}
|
|
271
|
+
return `/${encodedBranch}/${filePath}`;
|
|
272
|
+
}
|
|
273
|
+
async list(path, options) {
|
|
274
|
+
const { branch, filePath } = this.parsePath(path);
|
|
275
|
+
// Root path - list branches
|
|
276
|
+
if (!branch) {
|
|
277
|
+
const branches = await this.getBranches();
|
|
278
|
+
return {
|
|
279
|
+
data: branches.map((name) => {
|
|
280
|
+
const encodedPath = this.buildPath(name);
|
|
281
|
+
return {
|
|
282
|
+
id: encodedPath,
|
|
283
|
+
path: encodedPath,
|
|
284
|
+
metadata: {
|
|
285
|
+
type: "directory",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// List files in branch using git ls-tree
|
|
292
|
+
return this.listWithGitLsTree(branch, filePath, options);
|
|
293
|
+
}
|
|
294
|
+
async read(path, _options) {
|
|
295
|
+
const { branch, filePath } = this.parsePath(path);
|
|
296
|
+
if (!branch) {
|
|
297
|
+
return {
|
|
298
|
+
data: {
|
|
299
|
+
id: "/",
|
|
300
|
+
path: "/",
|
|
301
|
+
metadata: { type: "directory" },
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (!filePath) {
|
|
306
|
+
const branchPath = this.buildPath(branch);
|
|
307
|
+
return {
|
|
308
|
+
data: {
|
|
309
|
+
id: branchPath,
|
|
310
|
+
path: branchPath,
|
|
311
|
+
metadata: { type: "directory" },
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
// Check if path is a blob (file) or tree (directory)
|
|
317
|
+
const objectType = await this.git
|
|
318
|
+
.raw(["cat-file", "-t", `${branch}:${filePath}`])
|
|
319
|
+
.then((t) => t.trim());
|
|
320
|
+
if (objectType === "tree") {
|
|
321
|
+
// It's a directory
|
|
322
|
+
const afsPath = this.buildPath(branch, filePath);
|
|
323
|
+
return {
|
|
324
|
+
data: {
|
|
325
|
+
id: afsPath,
|
|
326
|
+
path: afsPath,
|
|
327
|
+
metadata: {
|
|
328
|
+
type: "directory",
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// It's a file, get content
|
|
334
|
+
const size = await this.git
|
|
335
|
+
.raw(["cat-file", "-s", `${branch}:${filePath}`])
|
|
336
|
+
.then((s) => Number.parseInt(s.trim(), 10));
|
|
337
|
+
// Determine mimeType based on file extension
|
|
338
|
+
const mimeType = this.getMimeType(filePath);
|
|
339
|
+
const isBinary = this.isBinaryFile(filePath);
|
|
340
|
+
let content;
|
|
341
|
+
const metadata = {
|
|
342
|
+
type: "file",
|
|
343
|
+
size,
|
|
344
|
+
mimeType,
|
|
345
|
+
};
|
|
346
|
+
if (isBinary) {
|
|
347
|
+
// For binary files, use execFileAsync to get raw buffer
|
|
348
|
+
const { stdout } = await execFileAsync("git", ["cat-file", "-p", `${branch}:${filePath}`], {
|
|
349
|
+
cwd: this.options.repoPath,
|
|
350
|
+
encoding: "buffer",
|
|
351
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB max
|
|
352
|
+
});
|
|
353
|
+
// Store only base64 string without data URL prefix
|
|
354
|
+
content = stdout.toString("base64");
|
|
355
|
+
// Mark content as base64 in metadata
|
|
356
|
+
metadata.contentType = "base64";
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
// For text files, use git.show
|
|
360
|
+
content = await this.git.show([`${branch}:${filePath}`]);
|
|
361
|
+
}
|
|
362
|
+
const afsPath = this.buildPath(branch, filePath);
|
|
363
|
+
return {
|
|
364
|
+
data: {
|
|
365
|
+
id: afsPath,
|
|
366
|
+
path: afsPath,
|
|
367
|
+
content,
|
|
368
|
+
metadata,
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
return {
|
|
374
|
+
data: undefined,
|
|
375
|
+
message: error.message,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async write(path, entry, options) {
|
|
380
|
+
const { branch, filePath } = this.parsePath(path);
|
|
381
|
+
if (!branch || !filePath) {
|
|
382
|
+
throw new Error("Cannot write to root or branch root");
|
|
383
|
+
}
|
|
384
|
+
// Create worktree for write operations
|
|
385
|
+
const worktreePath = await this.ensureWorktree(branch);
|
|
386
|
+
const fullPath = (0, node_path_1.join)(worktreePath, filePath);
|
|
387
|
+
const append = options?.append ?? false;
|
|
388
|
+
// Ensure parent directory exists
|
|
389
|
+
const parentDir = (0, node_path_1.dirname)(fullPath);
|
|
390
|
+
await (0, promises_1.mkdir)(parentDir, { recursive: true });
|
|
391
|
+
// Write content
|
|
392
|
+
if (entry.content !== undefined) {
|
|
393
|
+
let contentToWrite;
|
|
394
|
+
if (typeof entry.content === "string") {
|
|
395
|
+
contentToWrite = entry.content;
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
contentToWrite = JSON.stringify(entry.content, null, 2);
|
|
399
|
+
}
|
|
400
|
+
await (0, promises_1.writeFile)(fullPath, contentToWrite, { encoding: "utf8", flag: append ? "a" : "w" });
|
|
401
|
+
}
|
|
402
|
+
// Auto commit if enabled
|
|
403
|
+
if (this.options.autoCommit) {
|
|
404
|
+
const gitInstance = (0, simple_git_1.simpleGit)(worktreePath);
|
|
405
|
+
await gitInstance.add(filePath);
|
|
406
|
+
if (this.options.commitAuthor) {
|
|
407
|
+
await gitInstance.addConfig("user.name", this.options.commitAuthor.name, undefined, "local");
|
|
408
|
+
await gitInstance.addConfig("user.email", this.options.commitAuthor.email, undefined, "local");
|
|
409
|
+
}
|
|
410
|
+
await gitInstance.commit(`Update ${filePath}`);
|
|
411
|
+
}
|
|
412
|
+
// Get file stats
|
|
413
|
+
const stats = await (0, promises_1.stat)(fullPath);
|
|
414
|
+
const afsPath = this.buildPath(branch, filePath);
|
|
415
|
+
const writtenEntry = {
|
|
416
|
+
id: afsPath,
|
|
417
|
+
path: afsPath,
|
|
418
|
+
content: entry.content,
|
|
419
|
+
summary: entry.summary,
|
|
420
|
+
createdAt: stats.birthtime,
|
|
421
|
+
updatedAt: stats.mtime,
|
|
422
|
+
metadata: {
|
|
423
|
+
...entry.metadata,
|
|
424
|
+
type: stats.isDirectory() ? "directory" : "file",
|
|
425
|
+
size: stats.size,
|
|
426
|
+
},
|
|
427
|
+
userId: entry.userId,
|
|
428
|
+
sessionId: entry.sessionId,
|
|
429
|
+
linkTo: entry.linkTo,
|
|
430
|
+
};
|
|
431
|
+
return { data: writtenEntry };
|
|
432
|
+
}
|
|
433
|
+
async delete(path, options) {
|
|
434
|
+
const { branch, filePath } = this.parsePath(path);
|
|
435
|
+
if (!branch || !filePath) {
|
|
436
|
+
throw new Error("Cannot delete root or branch root");
|
|
437
|
+
}
|
|
438
|
+
// Create worktree for delete operations
|
|
439
|
+
const worktreePath = await this.ensureWorktree(branch);
|
|
440
|
+
const fullPath = (0, node_path_1.join)(worktreePath, filePath);
|
|
441
|
+
const recursive = options?.recursive ?? false;
|
|
442
|
+
const stats = await (0, promises_1.stat)(fullPath);
|
|
443
|
+
if (stats.isDirectory() && !recursive) {
|
|
444
|
+
throw new Error(`Cannot delete directory '${path}' without recursive option. Set recursive: true to delete directories.`);
|
|
445
|
+
}
|
|
446
|
+
await (0, promises_1.rm)(fullPath, { recursive, force: true });
|
|
447
|
+
// Auto commit if enabled
|
|
448
|
+
if (this.options.autoCommit) {
|
|
449
|
+
const gitInstance = (0, simple_git_1.simpleGit)(worktreePath);
|
|
450
|
+
await gitInstance.add(filePath);
|
|
451
|
+
if (this.options.commitAuthor) {
|
|
452
|
+
await gitInstance.addConfig("user.name", this.options.commitAuthor.name, undefined, "local");
|
|
453
|
+
await gitInstance.addConfig("user.email", this.options.commitAuthor.email, undefined, "local");
|
|
454
|
+
}
|
|
455
|
+
await gitInstance.commit(`Delete ${filePath}`);
|
|
456
|
+
}
|
|
457
|
+
return { message: `Successfully deleted: ${path}` };
|
|
458
|
+
}
|
|
459
|
+
async rename(oldPath, newPath, options) {
|
|
460
|
+
const { branch: oldBranch, filePath: oldFilePath } = this.parsePath(oldPath);
|
|
461
|
+
const { branch: newBranch, filePath: newFilePath } = this.parsePath(newPath);
|
|
462
|
+
if (!oldBranch || !oldFilePath) {
|
|
463
|
+
throw new Error("Cannot rename from root or branch root");
|
|
464
|
+
}
|
|
465
|
+
if (!newBranch || !newFilePath) {
|
|
466
|
+
throw new Error("Cannot rename to root or branch root");
|
|
467
|
+
}
|
|
468
|
+
if (oldBranch !== newBranch) {
|
|
469
|
+
throw new Error("Cannot rename across branches");
|
|
470
|
+
}
|
|
471
|
+
// Create worktree for rename operations
|
|
472
|
+
const worktreePath = await this.ensureWorktree(oldBranch);
|
|
473
|
+
const oldFullPath = (0, node_path_1.join)(worktreePath, oldFilePath);
|
|
474
|
+
const newFullPath = (0, node_path_1.join)(worktreePath, newFilePath);
|
|
475
|
+
const overwrite = options?.overwrite ?? false;
|
|
476
|
+
// Check if source exists
|
|
477
|
+
await (0, promises_1.stat)(oldFullPath);
|
|
478
|
+
// Check if destination exists
|
|
479
|
+
try {
|
|
480
|
+
await (0, promises_1.stat)(newFullPath);
|
|
481
|
+
if (!overwrite) {
|
|
482
|
+
throw new Error(`Destination '${newPath}' already exists. Set overwrite: true to replace it.`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (error.code !== "ENOENT") {
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// Ensure parent directory exists
|
|
491
|
+
const newParentDir = (0, node_path_1.dirname)(newFullPath);
|
|
492
|
+
await (0, promises_1.mkdir)(newParentDir, { recursive: true });
|
|
493
|
+
// Perform rename
|
|
494
|
+
await (0, promises_1.rename)(oldFullPath, newFullPath);
|
|
495
|
+
// Auto commit if enabled
|
|
496
|
+
if (this.options.autoCommit) {
|
|
497
|
+
const gitInstance = (0, simple_git_1.simpleGit)(worktreePath);
|
|
498
|
+
await gitInstance.add([oldFilePath, newFilePath]);
|
|
499
|
+
if (this.options.commitAuthor) {
|
|
500
|
+
await gitInstance.addConfig("user.name", this.options.commitAuthor.name, undefined, "local");
|
|
501
|
+
await gitInstance.addConfig("user.email", this.options.commitAuthor.email, undefined, "local");
|
|
502
|
+
}
|
|
503
|
+
await gitInstance.commit(`Rename ${oldFilePath} to ${newFilePath}`);
|
|
504
|
+
}
|
|
505
|
+
return { message: `Successfully renamed '${oldPath}' to '${newPath}'` };
|
|
506
|
+
}
|
|
507
|
+
async search(path, query, options) {
|
|
508
|
+
const { branch, filePath } = this.parsePath(path);
|
|
509
|
+
if (!branch) {
|
|
510
|
+
return { data: [], message: "Search requires a branch path" };
|
|
511
|
+
}
|
|
512
|
+
const limit = Math.min(options?.limit || LIST_MAX_LIMIT, LIST_MAX_LIMIT);
|
|
513
|
+
try {
|
|
514
|
+
// Use git grep for searching (no worktree needed)
|
|
515
|
+
const args = ["grep", "-n", "-I"]; // -n for line numbers, -I to skip binary files
|
|
516
|
+
if (options?.caseSensitive === false) {
|
|
517
|
+
args.push("-i");
|
|
518
|
+
}
|
|
519
|
+
args.push(query, branch);
|
|
520
|
+
// Add path filter if specified
|
|
521
|
+
if (filePath) {
|
|
522
|
+
args.push("--", filePath);
|
|
523
|
+
}
|
|
524
|
+
const output = await this.git.raw(args);
|
|
525
|
+
const lines = output.split("\n").filter((line) => line.trim());
|
|
526
|
+
const entries = [];
|
|
527
|
+
const processedFiles = new Set();
|
|
528
|
+
for (const line of lines) {
|
|
529
|
+
// Format when searching in branch: branch:path:linenum:content
|
|
530
|
+
// Try the format with branch prefix first
|
|
531
|
+
let matchPath;
|
|
532
|
+
let lineNum;
|
|
533
|
+
let content;
|
|
534
|
+
const matchWithBranch = line.match(/^[^:]+:([^:]+):(\d+):(.+)$/);
|
|
535
|
+
if (matchWithBranch) {
|
|
536
|
+
matchPath = matchWithBranch[1];
|
|
537
|
+
lineNum = matchWithBranch[2];
|
|
538
|
+
content = matchWithBranch[3];
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
// Try format without branch: path:linenum:content
|
|
542
|
+
const matchNoBranch = line.match(/^([^:]+):(\d+):(.+)$/);
|
|
543
|
+
if (!matchNoBranch)
|
|
544
|
+
continue;
|
|
545
|
+
matchPath = matchNoBranch[1];
|
|
546
|
+
lineNum = matchNoBranch[2];
|
|
547
|
+
content = matchNoBranch[3];
|
|
548
|
+
}
|
|
549
|
+
const afsPath = this.buildPath(branch, matchPath);
|
|
550
|
+
if (processedFiles.has(afsPath))
|
|
551
|
+
continue;
|
|
552
|
+
processedFiles.add(afsPath);
|
|
553
|
+
entries.push({
|
|
554
|
+
id: afsPath,
|
|
555
|
+
path: afsPath,
|
|
556
|
+
summary: `Line ${lineNum}: ${content}`,
|
|
557
|
+
metadata: {
|
|
558
|
+
type: "file",
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
if (entries.length >= limit) {
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
data: entries,
|
|
567
|
+
message: entries.length >= limit ? `Results truncated to limit ${limit}` : undefined,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
// git grep returns exit code 1 if no matches found
|
|
572
|
+
if (error.message.includes("did not match any file(s)")) {
|
|
573
|
+
return { data: [] };
|
|
574
|
+
}
|
|
575
|
+
return { data: [], message: error.message };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Cleanup all worktrees (useful when unmounting)
|
|
580
|
+
*/
|
|
581
|
+
async cleanup() {
|
|
582
|
+
for (const [_branch, worktreePath] of this.worktrees) {
|
|
583
|
+
try {
|
|
584
|
+
await this.git.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
585
|
+
}
|
|
586
|
+
catch (_error) {
|
|
587
|
+
// Ignore errors during cleanup
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
this.worktrees.clear();
|
|
591
|
+
// Remove temp directory
|
|
592
|
+
try {
|
|
593
|
+
await (0, promises_1.rm)(this.tempBase, { recursive: true, force: true });
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// Ignore errors
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
exports.AFSGit = AFSGit;
|
|
601
|
+
const _typeCheck = AFSGit;
|