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