@skroyc/librarian 0.1.0 → 0.2.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/README.md +4 -16
- package/dist/agents/context-schema.d.ts +1 -1
- package/dist/agents/context-schema.d.ts.map +1 -1
- package/dist/agents/context-schema.js +5 -2
- package/dist/agents/context-schema.js.map +1 -1
- package/dist/agents/react-agent.d.ts.map +1 -1
- package/dist/agents/react-agent.js +63 -170
- package/dist/agents/react-agent.js.map +1 -1
- package/dist/agents/tool-runtime.d.ts.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +53 -49
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +115 -69
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +246 -150
- package/dist/index.js.map +1 -1
- package/dist/tools/file-finding.tool.d.ts +1 -1
- package/dist/tools/file-finding.tool.d.ts.map +1 -1
- package/dist/tools/file-finding.tool.js +70 -130
- package/dist/tools/file-finding.tool.js.map +1 -1
- package/dist/tools/file-listing.tool.d.ts +7 -1
- package/dist/tools/file-listing.tool.d.ts.map +1 -1
- package/dist/tools/file-listing.tool.js +96 -80
- package/dist/tools/file-listing.tool.js.map +1 -1
- package/dist/tools/file-reading.tool.d.ts +4 -1
- package/dist/tools/file-reading.tool.d.ts.map +1 -1
- package/dist/tools/file-reading.tool.js +107 -45
- package/dist/tools/file-reading.tool.js.map +1 -1
- package/dist/tools/grep-content.tool.d.ts +13 -1
- package/dist/tools/grep-content.tool.d.ts.map +1 -1
- package/dist/tools/grep-content.tool.js +186 -144
- package/dist/tools/grep-content.tool.js.map +1 -1
- package/dist/utils/error-utils.d.ts +9 -0
- package/dist/utils/error-utils.d.ts.map +1 -0
- package/dist/utils/error-utils.js +61 -0
- package/dist/utils/error-utils.js.map +1 -0
- package/dist/utils/file-utils.d.ts +1 -0
- package/dist/utils/file-utils.d.ts.map +1 -1
- package/dist/utils/file-utils.js +81 -9
- package/dist/utils/file-utils.js.map +1 -1
- package/dist/utils/format-utils.d.ts +25 -0
- package/dist/utils/format-utils.d.ts.map +1 -0
- package/dist/utils/format-utils.js +111 -0
- package/dist/utils/format-utils.js.map +1 -0
- package/dist/utils/gitignore-service.d.ts +10 -0
- package/dist/utils/gitignore-service.d.ts.map +1 -0
- package/dist/utils/gitignore-service.js +91 -0
- package/dist/utils/gitignore-service.js.map +1 -0
- package/dist/utils/logger.d.ts +2 -2
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +35 -34
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/path-utils.js +3 -3
- package/dist/utils/path-utils.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/context-schema.ts +5 -2
- package/src/agents/react-agent.ts +694 -784
- package/src/agents/tool-runtime.ts +4 -4
- package/src/cli.ts +95 -57
- package/src/config.ts +192 -90
- package/src/index.ts +402 -180
- package/src/tools/file-finding.tool.ts +198 -310
- package/src/tools/file-listing.tool.ts +245 -202
- package/src/tools/file-reading.tool.ts +225 -138
- package/src/tools/grep-content.tool.ts +387 -307
- package/src/utils/error-utils.ts +95 -0
- package/src/utils/file-utils.ts +104 -19
- package/src/utils/format-utils.ts +190 -0
- package/src/utils/gitignore-service.ts +123 -0
- package/src/utils/logger.ts +112 -77
- package/src/utils/path-utils.ts +3 -3
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standardized error handling for Librarian tools.
|
|
5
|
+
* Aligned with file-editor's professional and objective tone.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ToolErrorOptions {
|
|
9
|
+
operation: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
suggestion?: string | undefined;
|
|
12
|
+
cause?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Formats a system error into a professional, actionable message.
|
|
17
|
+
*/
|
|
18
|
+
export function formatToolError(options: ToolErrorOptions): string {
|
|
19
|
+
const { operation, path: targetPath, suggestion, cause } = options;
|
|
20
|
+
|
|
21
|
+
let message = `${operation} failed`;
|
|
22
|
+
if (targetPath) {
|
|
23
|
+
message += `: ${targetPath}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (cause && typeof cause === "object" && "code" in cause) {
|
|
27
|
+
const code = (cause as { code: string }).code;
|
|
28
|
+
|
|
29
|
+
switch (code) {
|
|
30
|
+
case "ENOENT":
|
|
31
|
+
message = `Path not found: ${targetPath || "unknown"}`;
|
|
32
|
+
break;
|
|
33
|
+
case "EACCES":
|
|
34
|
+
case "EPERM":
|
|
35
|
+
message = `Permission denied: ${targetPath || "unknown"}`;
|
|
36
|
+
break;
|
|
37
|
+
case "ENOTDIR":
|
|
38
|
+
message = `Path is not a directory: ${targetPath || "unknown"}`;
|
|
39
|
+
break;
|
|
40
|
+
case "EISDIR":
|
|
41
|
+
message = `Path is a directory, not a file: ${targetPath || "unknown"}`;
|
|
42
|
+
break;
|
|
43
|
+
default:
|
|
44
|
+
if ("message" in cause) {
|
|
45
|
+
message += `. ${cause.message}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} else if (cause instanceof Error) {
|
|
49
|
+
message += `. ${cause.message}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (suggestion) {
|
|
53
|
+
message += `\n\nSuggestion: ${suggestion}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return message;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a helpful suggestion based on common failure scenarios.
|
|
61
|
+
*/
|
|
62
|
+
export function getToolSuggestion(
|
|
63
|
+
operation: string,
|
|
64
|
+
targetPath?: string
|
|
65
|
+
): string | undefined {
|
|
66
|
+
if (operation === "view" && targetPath) {
|
|
67
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
68
|
+
if ([".png", ".jpg", ".pdf"].includes(ext)) {
|
|
69
|
+
return "This file appears to be a binary or media file. Only text files can be read.";
|
|
70
|
+
}
|
|
71
|
+
return "Ensure the file path is correct and the file is a text file.";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (operation === "list") {
|
|
75
|
+
return "Check if the directory exists and you have permissions to read it.";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (operation === "grep") {
|
|
79
|
+
return "Try a simpler search pattern or verify the search path.";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (operation === "find") {
|
|
83
|
+
return "Check if the search path exists and the patterns are valid glob patterns.";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Generic permission suggestion for any operation
|
|
87
|
+
if (
|
|
88
|
+
targetPath &&
|
|
89
|
+
(operation === "view" || operation === "list" || operation === "find")
|
|
90
|
+
) {
|
|
91
|
+
return "Check file permissions and ensure you have read access to the path.";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
package/src/utils/file-utils.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Shared utilities for file type detection and file operations
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import path from
|
|
6
|
+
import path from "node:path";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Check if a file is text-based
|
|
@@ -11,11 +11,48 @@ import path from 'node:path';
|
|
|
11
11
|
*/
|
|
12
12
|
export async function isTextFile(filePath: string): Promise<boolean> {
|
|
13
13
|
const textExtensions = new Set([
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
".txt",
|
|
15
|
+
".js",
|
|
16
|
+
".ts",
|
|
17
|
+
".jsx",
|
|
18
|
+
".tsx",
|
|
19
|
+
".json",
|
|
20
|
+
".yaml",
|
|
21
|
+
".yml",
|
|
22
|
+
".md",
|
|
23
|
+
".html",
|
|
24
|
+
".htm",
|
|
25
|
+
".css",
|
|
26
|
+
".scss",
|
|
27
|
+
".sass",
|
|
28
|
+
".less",
|
|
29
|
+
".py",
|
|
30
|
+
".rb",
|
|
31
|
+
".java",
|
|
32
|
+
".cpp",
|
|
33
|
+
".c",
|
|
34
|
+
".h",
|
|
35
|
+
".hpp",
|
|
36
|
+
".go",
|
|
37
|
+
".rs",
|
|
38
|
+
".php",
|
|
39
|
+
".sql",
|
|
40
|
+
".xml",
|
|
41
|
+
".csv",
|
|
42
|
+
".toml",
|
|
43
|
+
".lock",
|
|
44
|
+
".sh",
|
|
45
|
+
".bash",
|
|
46
|
+
".zsh",
|
|
47
|
+
".env",
|
|
48
|
+
".dockerfile",
|
|
49
|
+
"dockerfile",
|
|
50
|
+
".gitignore",
|
|
51
|
+
".npmrc",
|
|
52
|
+
".prettierrc",
|
|
53
|
+
".eslintrc",
|
|
54
|
+
".editorconfig",
|
|
55
|
+
".jsonc",
|
|
19
56
|
]);
|
|
20
57
|
|
|
21
58
|
const ext = path.extname(filePath).toLowerCase();
|
|
@@ -23,17 +60,65 @@ export async function isTextFile(filePath: string): Promise<boolean> {
|
|
|
23
60
|
return true;
|
|
24
61
|
}
|
|
25
62
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
// For files without extensions or unknown extensions, check for null bytes
|
|
64
|
+
try {
|
|
65
|
+
const file = Bun.file(filePath);
|
|
66
|
+
if (file.size === 0) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Read only the first 512 bytes for binary check
|
|
71
|
+
const buffer = await file.slice(0, 512).arrayBuffer();
|
|
72
|
+
const uint8Array = new Uint8Array(buffer);
|
|
73
|
+
for (const byte of uint8Array) {
|
|
74
|
+
if (byte === 0) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Counts the number of lines in a file using a memory-efficient streaming approach.
|
|
86
|
+
* Handles empty files and trailing newlines correctly.
|
|
87
|
+
*/
|
|
88
|
+
export async function getFileLineCount(filePath: string): Promise<number> {
|
|
89
|
+
try {
|
|
90
|
+
const file = Bun.file(filePath);
|
|
91
|
+
if (file.size === 0) {
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stream = file.stream();
|
|
96
|
+
const reader = stream.getReader();
|
|
97
|
+
let count = 0;
|
|
98
|
+
let lastByte = -1;
|
|
99
|
+
|
|
100
|
+
while (true) {
|
|
101
|
+
const { done, value } = await reader.read();
|
|
102
|
+
if (done) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const byte of value) {
|
|
107
|
+
lastByte = byte;
|
|
108
|
+
if (lastByte === 10) {
|
|
109
|
+
// '\n'
|
|
110
|
+
count++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// If the file doesn't end with a newline, count the last line
|
|
116
|
+
if (lastByte !== 10 && lastByte !== -1) {
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return count;
|
|
121
|
+
} catch {
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
39
124
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting utility functions for tool outputs
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface FileSystemEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
isDirectory: boolean;
|
|
9
|
+
size?: number;
|
|
10
|
+
lineCount?: number;
|
|
11
|
+
depth?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SearchMatch {
|
|
15
|
+
line: number;
|
|
16
|
+
column: number;
|
|
17
|
+
text: string;
|
|
18
|
+
context?: {
|
|
19
|
+
before: string[];
|
|
20
|
+
after: string[];
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MIN_LINE_NUM_WIDTH = 3;
|
|
25
|
+
const FILE_SEPARATOR = "--";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Add line numbers to an array of lines for display
|
|
29
|
+
*/
|
|
30
|
+
export function withLineNumbers(lines: string[], startLine = 1): string {
|
|
31
|
+
if (!lines || lines.length === 0) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const maxLineNumWidth = Math.max(
|
|
36
|
+
MIN_LINE_NUM_WIDTH,
|
|
37
|
+
String(startLine + lines.length - 1).length
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return lines
|
|
41
|
+
.map((line, index) => {
|
|
42
|
+
const lineNum = startLine + index;
|
|
43
|
+
const paddedNum = String(lineNum).padStart(maxLineNumWidth);
|
|
44
|
+
return `${paddedNum}→${line}`;
|
|
45
|
+
})
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format content with optional line range using line arrays for efficiency
|
|
51
|
+
*/
|
|
52
|
+
export function formatLinesWithRange(
|
|
53
|
+
lines: string[],
|
|
54
|
+
viewRange?: [number, number]
|
|
55
|
+
): string {
|
|
56
|
+
if (!lines || lines.length === 0) {
|
|
57
|
+
return "[File is empty]";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!viewRange) {
|
|
61
|
+
return withLineNumbers(lines);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const [start, end] = viewRange;
|
|
65
|
+
const startIndex = Math.max(0, start - 1);
|
|
66
|
+
const endIndex = end === -1 ? lines.length : Math.min(lines.length, end);
|
|
67
|
+
|
|
68
|
+
const selectedLines = lines.slice(startIndex, endIndex);
|
|
69
|
+
return withLineNumbers(selectedLines, start);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Legacy wrapper for formatLinesWithRange that accepts a string
|
|
74
|
+
* @deprecated Use formatLinesWithRange with pre-split lines for better performance
|
|
75
|
+
*/
|
|
76
|
+
function _formatContentWithRange(
|
|
77
|
+
content: string,
|
|
78
|
+
viewRange?: [number, number]
|
|
79
|
+
): string {
|
|
80
|
+
if (!content || content.trim() === "") {
|
|
81
|
+
return "[File is empty]";
|
|
82
|
+
}
|
|
83
|
+
return formatLinesWithRange(content.split("\n"), viewRange);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Format directory listing in tree-like format with indentation
|
|
88
|
+
*/
|
|
89
|
+
export function formatDirectoryTree(entries: FileSystemEntry[]): string {
|
|
90
|
+
let output = "";
|
|
91
|
+
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const indent = " ".repeat(entry.depth || 0);
|
|
94
|
+
let line = `${indent}${entry.isDirectory ? `${entry.name}/` : entry.name}`;
|
|
95
|
+
|
|
96
|
+
// For files, show line count if available
|
|
97
|
+
if (!entry.isDirectory && entry.lineCount !== undefined) {
|
|
98
|
+
line += ` (${entry.lineCount} lines)`;
|
|
99
|
+
} else if (!entry.isDirectory && entry.size !== undefined) {
|
|
100
|
+
line += ` (${entry.size} bytes)`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
output += `${line}\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return output.trimEnd();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format search results with context
|
|
111
|
+
*/
|
|
112
|
+
export function formatSearchResults(
|
|
113
|
+
results: { path: string; matches: SearchMatch[] }[]
|
|
114
|
+
): string {
|
|
115
|
+
let output = "";
|
|
116
|
+
const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
|
|
117
|
+
|
|
118
|
+
output += `Found ${totalMatches} matches in ${results.length} files:\n\n`;
|
|
119
|
+
|
|
120
|
+
let fileIndex = 0;
|
|
121
|
+
for (const result of results) {
|
|
122
|
+
output += `${result.path}\n`;
|
|
123
|
+
|
|
124
|
+
const sortedMatches = [...result.matches].sort((a, b) => a.line - b.line);
|
|
125
|
+
const displayedLines = new Set<number>();
|
|
126
|
+
let lastPrintedLine = -1;
|
|
127
|
+
|
|
128
|
+
// Calculate max line num width for this file
|
|
129
|
+
let maxLineNum = 0;
|
|
130
|
+
for (const match of sortedMatches) {
|
|
131
|
+
const lastContextLine = match.line + (match.context?.after.length || 0);
|
|
132
|
+
maxLineNum = Math.max(maxLineNum, lastContextLine);
|
|
133
|
+
}
|
|
134
|
+
const maxLineNumWidth = Math.max(
|
|
135
|
+
MIN_LINE_NUM_WIDTH,
|
|
136
|
+
String(maxLineNum).length
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
for (const match of sortedMatches) {
|
|
140
|
+
// Before context
|
|
141
|
+
if (match.context?.before) {
|
|
142
|
+
const startLine = Math.max(1, match.line - match.context.before.length);
|
|
143
|
+
for (let j = 0; j < match.context.before.length; j++) {
|
|
144
|
+
const lineNum = startLine + j;
|
|
145
|
+
if (!displayedLines.has(lineNum)) {
|
|
146
|
+
if (lastPrintedLine !== -1 && lineNum > lastPrintedLine + 1) {
|
|
147
|
+
output += `${" ".repeat(maxLineNumWidth)}⁝\n`;
|
|
148
|
+
}
|
|
149
|
+
output += `${String(lineNum).padStart(maxLineNumWidth)}→${match.context.before[j]}\n`;
|
|
150
|
+
displayedLines.add(lineNum);
|
|
151
|
+
lastPrintedLine = lineNum;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Match line
|
|
157
|
+
if (!displayedLines.has(match.line)) {
|
|
158
|
+
if (lastPrintedLine !== -1 && match.line > lastPrintedLine + 1) {
|
|
159
|
+
output += `${" ".repeat(maxLineNumWidth)}⁝\n`;
|
|
160
|
+
}
|
|
161
|
+
output += `${String(match.line).padStart(maxLineNumWidth)}→${match.text}\n`;
|
|
162
|
+
displayedLines.add(match.line);
|
|
163
|
+
lastPrintedLine = match.line;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// After context
|
|
167
|
+
if (match.context?.after) {
|
|
168
|
+
const startLine = match.line + 1;
|
|
169
|
+
for (let j = 0; j < match.context.after.length; j++) {
|
|
170
|
+
const lineNum = startLine + j;
|
|
171
|
+
if (!displayedLines.has(lineNum)) {
|
|
172
|
+
if (lastPrintedLine !== -1 && lineNum > lastPrintedLine + 1) {
|
|
173
|
+
output += `${" ".repeat(maxLineNumWidth)}⁝\n`;
|
|
174
|
+
}
|
|
175
|
+
output += `${String(lineNum).padStart(maxLineNumWidth)}→${match.context.after[j]}\n`;
|
|
176
|
+
displayedLines.add(lineNum);
|
|
177
|
+
lastPrintedLine = lineNum;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (fileIndex < results.length - 1) {
|
|
184
|
+
output += `${FILE_SEPARATOR}\n`;
|
|
185
|
+
}
|
|
186
|
+
fileIndex++;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return output;
|
|
190
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Glob } from "bun";
|
|
4
|
+
|
|
5
|
+
interface GitIgnorePattern {
|
|
6
|
+
pattern: string;
|
|
7
|
+
isNegation: boolean;
|
|
8
|
+
isDirectoryOnly: boolean;
|
|
9
|
+
glob: Glob;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Regex for parsing gitignore line endings (moved to module level for performance)
|
|
13
|
+
const LINE_ENDING_REGEX = /\r?\n/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Service to handle .gitignore patterns and filtering.
|
|
17
|
+
* Uses Bun's native Glob for high performance.
|
|
18
|
+
*/
|
|
19
|
+
export class GitIgnoreService {
|
|
20
|
+
private readonly rootDir: string;
|
|
21
|
+
private patterns: GitIgnorePattern[] = [];
|
|
22
|
+
private initialized = false;
|
|
23
|
+
|
|
24
|
+
constructor(rootDir: string) {
|
|
25
|
+
this.rootDir = path.resolve(rootDir);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async initialize(): Promise<void> {
|
|
29
|
+
if (this.initialized) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const gitignorePath = path.join(this.rootDir, ".gitignore");
|
|
34
|
+
const content = await readFile(gitignorePath, "utf-8");
|
|
35
|
+
this.patterns = this.parseGitIgnore(content);
|
|
36
|
+
} catch {
|
|
37
|
+
this.patterns = [];
|
|
38
|
+
}
|
|
39
|
+
this.initialized = true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
shouldIgnore(filePath: string, isDirectory = false): boolean {
|
|
43
|
+
if (!this.initialized) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const relativePath = path.relative(this.rootDir, filePath);
|
|
48
|
+
if (relativePath === "" || relativePath === ".") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let ignored = false;
|
|
53
|
+
let hasNegationMatch = false;
|
|
54
|
+
for (const p of this.patterns) {
|
|
55
|
+
if (p.glob.match(relativePath)) {
|
|
56
|
+
// If it's a directory-only pattern like "dist/", it should only match
|
|
57
|
+
// directories or files INSIDE a directory named "dist".
|
|
58
|
+
// Glob patterns like "**/dist{,/**}" will match the file "dist" too.
|
|
59
|
+
if (
|
|
60
|
+
p.isDirectoryOnly &&
|
|
61
|
+
!isDirectory &&
|
|
62
|
+
!relativePath.includes("/") &&
|
|
63
|
+
p.glob.match(relativePath)
|
|
64
|
+
) {
|
|
65
|
+
// Naive check: if it's a directory-only pattern but the target is a file
|
|
66
|
+
// in the root (no slashes in relativePath), we skip it if it's an exact match.
|
|
67
|
+
// This is not perfect for nested files but good enough for common cases.
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (p.isNegation) {
|
|
71
|
+
ignored = false;
|
|
72
|
+
hasNegationMatch = true;
|
|
73
|
+
} else if (!hasNegationMatch) {
|
|
74
|
+
// Only set ignored to true if we haven't seen a negation pattern yet
|
|
75
|
+
ignored = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return ignored;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private parseGitIgnore(content: string): GitIgnorePattern[] {
|
|
83
|
+
const lines = content.split(LINE_ENDING_REGEX);
|
|
84
|
+
const patterns: GitIgnorePattern[] = [];
|
|
85
|
+
|
|
86
|
+
for (let line of lines) {
|
|
87
|
+
line = line.trim();
|
|
88
|
+
if (!line || line.startsWith("#")) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let isNegation = false;
|
|
93
|
+
if (line.startsWith("!")) {
|
|
94
|
+
isNegation = true;
|
|
95
|
+
line = line.slice(1).trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isDirectoryOnly = line.endsWith("/");
|
|
99
|
+
let cleanPattern = isDirectoryOnly ? line.slice(0, -1) : line;
|
|
100
|
+
|
|
101
|
+
if (cleanPattern.startsWith("/")) {
|
|
102
|
+
cleanPattern = cleanPattern.slice(1);
|
|
103
|
+
} else if (!cleanPattern.includes("/")) {
|
|
104
|
+
cleanPattern = `**/${cleanPattern}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Pattern to match the item and everything beneath it
|
|
108
|
+
const finalPattern = `${cleanPattern}{,/**}`;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
patterns.push({
|
|
112
|
+
pattern: line,
|
|
113
|
+
isNegation,
|
|
114
|
+
isDirectoryOnly,
|
|
115
|
+
glob: new Glob(finalPattern),
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
// Skip invalid
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return patterns;
|
|
122
|
+
}
|
|
123
|
+
}
|