@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.
@@ -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;
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }