srcpack 0.2.0 → 0.3.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/README.md +34 -15
- package/dist/bundle.d.ts +47 -5
- package/dist/cli.js +818 -400
- package/dist/config.d.ts +50 -9
- package/dist/index.js +50 -13
- package/dist/linear.d.ts +17 -0
- package/package.json +4 -1
- package/src/bundle.ts +242 -51
- package/src/cli.ts +224 -54
- package/src/config.ts +180 -37
- package/src/linear.ts +368 -0
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare function expandPath(p: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Linear issues as a bundle source. Each issue becomes a virtual file at
|
|
5
|
+
* `linear/issues/<identifier>.md`, so it gets its own index entry and line
|
|
6
|
+
* range and can be filtered with ordinary `!` exclusions.
|
|
7
|
+
*
|
|
8
|
+
* Authentication reads `LINEAR_API_KEY` from the environment. It is
|
|
9
|
+
* deliberately not a config field: config files are committed, and the
|
|
10
|
+
* `package.json` config form cannot express `process.env`.
|
|
11
|
+
*
|
|
12
|
+
* `team` is required. A workspace-wide fetch is a footgun — it looks innocuous
|
|
13
|
+
* and can pull thousands of issues into a context window.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* bundles: {
|
|
18
|
+
* backlog: { linear: "ENG" },
|
|
19
|
+
* roadmap: { linear: { team: "ENG", project: "Roadmap" } },
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
declare const LinearSourceSchema: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
24
|
+
team: z.ZodString;
|
|
25
|
+
project: z.ZodOptional<z.ZodString>;
|
|
26
|
+
includeClosed: z.ZodDefault<z.ZodBoolean>;
|
|
27
|
+
}, z.core.$strict>]>;
|
|
3
28
|
/**
|
|
4
29
|
* Bundle configuration. Accepts a string pattern, array of patterns, or object.
|
|
5
30
|
* Patterns prefixed with `!` are exclusions. Patterns prefixed with `+` force
|
|
@@ -9,17 +34,27 @@ export declare function expandPath(p: string): string;
|
|
|
9
34
|
* `git:unstaged`, `git:untracked`, `git:dirty`, or `git:<rev>` (e.g.
|
|
10
35
|
* `git:main`, `git:HEAD~3`).
|
|
11
36
|
*
|
|
37
|
+
* The object form takes files (`include`), Linear issues (`linear`), or both.
|
|
38
|
+
*
|
|
12
39
|
* @example
|
|
13
40
|
* ```ts
|
|
14
|
-
* bundles: {
|
|
41
|
+
* bundles: {
|
|
42
|
+
* review: ["git:staged", "!bun.lock"],
|
|
43
|
+
* planning: { include: ["docs/**"], linear: { team: "ENG" } },
|
|
44
|
+
* }
|
|
15
45
|
* ```
|
|
16
46
|
*/
|
|
17
47
|
declare const BundleConfigSchema: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>, z.ZodObject<{
|
|
18
|
-
include: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]
|
|
48
|
+
include: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
49
|
+
linear: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
50
|
+
team: z.ZodString;
|
|
51
|
+
project: z.ZodOptional<z.ZodString>;
|
|
52
|
+
includeClosed: z.ZodDefault<z.ZodBoolean>;
|
|
53
|
+
}, z.core.$strict>]>>;
|
|
19
54
|
outfile: z.ZodOptional<z.ZodString>;
|
|
20
55
|
index: z.ZodDefault<z.ZodBoolean>;
|
|
21
56
|
prompt: z.ZodOptional<z.ZodString>;
|
|
22
|
-
}, z.core.$
|
|
57
|
+
}, z.core.$strict>]>;
|
|
23
58
|
/**
|
|
24
59
|
* Upload destination configuration.
|
|
25
60
|
*
|
|
@@ -40,7 +75,7 @@ declare const UploadConfigSchema: z.ZodObject<{
|
|
|
40
75
|
clientId: z.ZodString;
|
|
41
76
|
clientSecret: z.ZodString;
|
|
42
77
|
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
43
|
-
}, z.core.$
|
|
78
|
+
}, z.core.$strict>;
|
|
44
79
|
/** Root configuration for srcpack. */
|
|
45
80
|
declare const ConfigSchema: z.ZodObject<{
|
|
46
81
|
root: z.ZodDefault<z.ZodString>;
|
|
@@ -52,21 +87,27 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
52
87
|
clientId: z.ZodString;
|
|
53
88
|
clientSecret: z.ZodString;
|
|
54
89
|
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
55
|
-
}, z.core.$
|
|
90
|
+
}, z.core.$strict>, z.ZodArray<z.ZodObject<{
|
|
56
91
|
provider: z.ZodLiteral<"gdrive">;
|
|
57
92
|
folderId: z.ZodOptional<z.ZodString>;
|
|
58
93
|
clientId: z.ZodString;
|
|
59
94
|
clientSecret: z.ZodString;
|
|
60
95
|
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
61
|
-
}, z.core.$
|
|
96
|
+
}, z.core.$strict>>]>>;
|
|
62
97
|
bundles: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>, z.ZodObject<{
|
|
63
|
-
include: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]
|
|
98
|
+
include: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
99
|
+
linear: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
100
|
+
team: z.ZodString;
|
|
101
|
+
project: z.ZodOptional<z.ZodString>;
|
|
102
|
+
includeClosed: z.ZodDefault<z.ZodBoolean>;
|
|
103
|
+
}, z.core.$strict>]>>;
|
|
64
104
|
outfile: z.ZodOptional<z.ZodString>;
|
|
65
105
|
index: z.ZodDefault<z.ZodBoolean>;
|
|
66
106
|
prompt: z.ZodOptional<z.ZodString>;
|
|
67
|
-
}, z.core.$
|
|
68
|
-
}, z.core.$
|
|
107
|
+
}, z.core.$strict>]>>;
|
|
108
|
+
}, z.core.$strict>;
|
|
69
109
|
export type UploadConfig = z.infer<typeof UploadConfigSchema>;
|
|
110
|
+
export type LinearSourceInput = z.input<typeof LinearSourceSchema>;
|
|
70
111
|
export type BundleConfig = z.infer<typeof BundleConfigSchema>;
|
|
71
112
|
export type BundleConfigInput = z.input<typeof BundleConfigSchema>;
|
|
72
113
|
export type Config = z.infer<typeof ConfigSchema>;
|
package/dist/index.js
CHANGED
|
@@ -18292,29 +18292,57 @@ var PatternsSchema = exports_external.union([
|
|
|
18292
18292
|
exports_external.string().min(1),
|
|
18293
18293
|
exports_external.array(exports_external.string().min(1)).min(1)
|
|
18294
18294
|
]);
|
|
18295
|
+
var IdentifierSchema = exports_external.string().trim().min(1);
|
|
18296
|
+
var BundleNameSchema = exports_external.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "Bundle name must start with a letter or digit and contain only letters, digits, dot, underscore or hyphen");
|
|
18297
|
+
var LinearSourceSchema = exports_external.union([
|
|
18298
|
+
IdentifierSchema,
|
|
18299
|
+
exports_external.strictObject({
|
|
18300
|
+
team: IdentifierSchema,
|
|
18301
|
+
project: IdentifierSchema.optional(),
|
|
18302
|
+
includeClosed: exports_external.boolean().default(false)
|
|
18303
|
+
})
|
|
18304
|
+
]);
|
|
18295
18305
|
var BundleConfigSchema = exports_external.union([
|
|
18296
18306
|
exports_external.string().min(1),
|
|
18297
18307
|
exports_external.array(exports_external.string().min(1)).min(1),
|
|
18298
|
-
exports_external.
|
|
18299
|
-
include: PatternsSchema,
|
|
18300
|
-
|
|
18308
|
+
exports_external.strictObject({
|
|
18309
|
+
include: PatternsSchema.optional(),
|
|
18310
|
+
linear: LinearSourceSchema.optional(),
|
|
18311
|
+
outfile: exports_external.string().min(1).optional(),
|
|
18301
18312
|
index: exports_external.boolean().default(true),
|
|
18302
18313
|
prompt: exports_external.string().optional()
|
|
18314
|
+
}).refine((bundle) => bundle.include || bundle.linear, {
|
|
18315
|
+
message: 'Bundle needs a source: "include" patterns, "linear", or both'
|
|
18303
18316
|
})
|
|
18304
18317
|
]);
|
|
18305
|
-
var UploadConfigSchema = exports_external.
|
|
18318
|
+
var UploadConfigSchema = exports_external.strictObject({
|
|
18306
18319
|
provider: exports_external.literal("gdrive"),
|
|
18307
|
-
folderId:
|
|
18308
|
-
clientId:
|
|
18309
|
-
clientSecret:
|
|
18320
|
+
folderId: IdentifierSchema.optional(),
|
|
18321
|
+
clientId: IdentifierSchema,
|
|
18322
|
+
clientSecret: IdentifierSchema,
|
|
18310
18323
|
exclude: exports_external.array(exports_external.string()).optional()
|
|
18311
18324
|
});
|
|
18312
|
-
var ConfigSchema = exports_external.
|
|
18325
|
+
var ConfigSchema = exports_external.strictObject({
|
|
18313
18326
|
root: exports_external.string().default(""),
|
|
18314
18327
|
outDir: exports_external.string().default(".srcpack"),
|
|
18315
18328
|
emptyOutDir: exports_external.boolean().optional(),
|
|
18316
18329
|
upload: exports_external.union([UploadConfigSchema, exports_external.array(UploadConfigSchema).min(1)]).optional(),
|
|
18317
|
-
bundles: exports_external.record(
|
|
18330
|
+
bundles: exports_external.record(BundleNameSchema, BundleConfigSchema)
|
|
18331
|
+
}).superRefine((config2, ctx) => {
|
|
18332
|
+
const uploads = config2.upload ? Array.isArray(config2.upload) ? config2.upload : [config2.upload] : [];
|
|
18333
|
+
const names = new Set(Object.keys(config2.bundles));
|
|
18334
|
+
uploads.forEach((upload, i) => {
|
|
18335
|
+
const path = Array.isArray(config2.upload) ? ["upload", i, "exclude"] : ["upload", "exclude"];
|
|
18336
|
+
for (const name of upload.exclude ?? []) {
|
|
18337
|
+
if (!names.has(name)) {
|
|
18338
|
+
ctx.addIssue({
|
|
18339
|
+
code: "custom",
|
|
18340
|
+
path,
|
|
18341
|
+
message: `Unknown bundle "${name}"`
|
|
18342
|
+
});
|
|
18343
|
+
}
|
|
18344
|
+
}
|
|
18345
|
+
});
|
|
18318
18346
|
});
|
|
18319
18347
|
function defineConfig(config2) {
|
|
18320
18348
|
return config2;
|
|
@@ -18326,13 +18354,22 @@ class ConfigError extends Error {
|
|
|
18326
18354
|
this.name = "ConfigError";
|
|
18327
18355
|
}
|
|
18328
18356
|
}
|
|
18357
|
+
function flatten(issue2, prefix = []) {
|
|
18358
|
+
const path = [...prefix, ...issue2.path];
|
|
18359
|
+
const nested = issue2.code === "invalid_union" ? issue2.errors?.flat() : issue2.code === "invalid_key" ? issue2.issues : undefined;
|
|
18360
|
+
return nested?.length ? nested.flatMap((child) => flatten(child, path)) : [{ ...issue2, path }];
|
|
18361
|
+
}
|
|
18362
|
+
function describe3(issues) {
|
|
18363
|
+
const leaves = issues.flatMap((issue2) => flatten(issue2));
|
|
18364
|
+
const specific = leaves.filter((leaf) => leaf.code !== "invalid_type");
|
|
18365
|
+
const best = (specific.length ? specific : leaves).reduce((a, b) => b.path.length > a.path.length ? b : a);
|
|
18366
|
+
const path = best.path.join(".");
|
|
18367
|
+
return path ? `${path}: ${best.message}` : best.message;
|
|
18368
|
+
}
|
|
18329
18369
|
function parseConfig(value) {
|
|
18330
18370
|
const result = ConfigSchema.safeParse(value);
|
|
18331
18371
|
if (!result.success) {
|
|
18332
|
-
|
|
18333
|
-
const path = issue2.path.join(".");
|
|
18334
|
-
const message = path ? `${path}: ${issue2.message}` : issue2.message;
|
|
18335
|
-
throw new ConfigError(message);
|
|
18372
|
+
throw new ConfigError(describe3(result.error.issues));
|
|
18336
18373
|
}
|
|
18337
18374
|
const config2 = result.data;
|
|
18338
18375
|
config2.root = config2.root ? resolve(expandPath(config2.root)) : process.cwd();
|
package/dist/linear.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { LinearSourceInput } from "./config.ts";
|
|
2
|
+
export declare class LinearError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a `linear` bundle source to one virtual file per issue.
|
|
7
|
+
*
|
|
8
|
+
* Paths are namespaced under `linear/issues/` so they sort together, read as
|
|
9
|
+
* files in the index, and can be filtered with ordinary `!` exclusions.
|
|
10
|
+
*
|
|
11
|
+
* The return type is structural rather than `Entry` from `bundle.ts`: a source
|
|
12
|
+
* shouldn't depend on the module that orchestrates it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveLinearSource(source: LinearSourceInput): Promise<{
|
|
15
|
+
path: string;
|
|
16
|
+
content: string;
|
|
17
|
+
}[]>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "srcpack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Zero-config CLI for bundling code into LLM-optimized context files",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -94,5 +94,8 @@
|
|
|
94
94
|
"typescript": "^6.0.3",
|
|
95
95
|
"vitepress": "^2.0.0-alpha.19",
|
|
96
96
|
"vitepress-plugin-llms": "^1.13.5"
|
|
97
|
+
},
|
|
98
|
+
"prettier": {
|
|
99
|
+
"proseWrap": "never"
|
|
97
100
|
}
|
|
98
101
|
}
|
package/src/bundle.ts
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
|
-
import { lstat, open, readFile } from "node:fs/promises";
|
|
4
|
-
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
5
3
|
import { glob } from "fast-glob";
|
|
6
|
-
import picomatch from "picomatch";
|
|
7
4
|
import ignore, { type Ignore } from "ignore";
|
|
8
|
-
import {
|
|
5
|
+
import { lstat, open, readFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
7
|
+
import picomatch from "picomatch";
|
|
8
|
+
import {
|
|
9
|
+
ConfigError,
|
|
10
|
+
expandPath,
|
|
11
|
+
type BundleConfigInput,
|
|
12
|
+
type LinearSourceInput,
|
|
13
|
+
} from "./config.ts";
|
|
9
14
|
import { isGitSource, resolveGitSource } from "./git.ts";
|
|
15
|
+
import { resolveLinearSource } from "./linear.ts";
|
|
10
16
|
|
|
11
17
|
// Binary file detection: check first 8KB for null bytes (same heuristic as git)
|
|
12
18
|
const BINARY_CHECK_SIZE = 8192;
|
|
13
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Shared glob options. `followSymbolicLinks: false` is the load-bearing one:
|
|
22
|
+
* fast-glob defaults to true, so a link like `vendor -> ../../elsewhere` would
|
|
23
|
+
* be walked and every regular file under it bundled. Rejecting symlinks at the
|
|
24
|
+
* final component (see {@link isBundleable}) can't catch that — the leaf is an
|
|
25
|
+
* ordinary file; the escape happened in a directory along the way.
|
|
26
|
+
*/
|
|
27
|
+
const GLOB_OPTIONS = {
|
|
28
|
+
onlyFiles: true,
|
|
29
|
+
dot: true,
|
|
30
|
+
followSymbolicLinks: false,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
14
33
|
/**
|
|
15
34
|
* Whether a path can be read into a bundle: an existing regular text file.
|
|
16
35
|
* Globs only yield files, but git can name a submodule directory or a file
|
|
@@ -46,16 +65,29 @@ async function isBundleable(filePath: string): Promise<boolean> {
|
|
|
46
65
|
}
|
|
47
66
|
}
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
/**
|
|
69
|
+
* One bundle member, before its content is laid out.
|
|
70
|
+
*
|
|
71
|
+
* `content` present means the entry is virtual — produced by a non-filesystem
|
|
72
|
+
* source such as Linear — and its `path` is synthetic. Absent means an ordinary
|
|
73
|
+
* file, read from disk at bundle time.
|
|
74
|
+
*/
|
|
75
|
+
export interface Entry {
|
|
76
|
+
path: string;
|
|
77
|
+
content?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One line of the bundle index — a file or a virtual entry, once laid out. */
|
|
81
|
+
export interface IndexEntry {
|
|
82
|
+
path: string; // Relative path from cwd, or a synthetic path
|
|
83
|
+
lines: number; // Line count in the entry's content
|
|
52
84
|
startLine: number; // Start line in bundle (1-indexed)
|
|
53
85
|
endLine: number; // End line in bundle
|
|
54
86
|
}
|
|
55
87
|
|
|
56
88
|
export interface BundleResult {
|
|
57
89
|
content: string;
|
|
58
|
-
index:
|
|
90
|
+
index: IndexEntry[];
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
/**
|
|
@@ -88,6 +120,9 @@ function normalizePatterns(config: BundleConfigInput): {
|
|
|
88
120
|
patterns = [config];
|
|
89
121
|
} else if (Array.isArray(config)) {
|
|
90
122
|
patterns = config;
|
|
123
|
+
} else if (config.include === undefined) {
|
|
124
|
+
// A `linear`-only bundle has no patterns at all
|
|
125
|
+
patterns = [];
|
|
91
126
|
} else {
|
|
92
127
|
patterns = Array.isArray(config.include)
|
|
93
128
|
? config.include
|
|
@@ -156,25 +191,29 @@ function gitignoreToGlobPatterns(lines: string[]): string[] {
|
|
|
156
191
|
// Skip empty lines and comments
|
|
157
192
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
158
193
|
|
|
194
|
+
// A trailing slash only says "directory", which is what this prunes anyway.
|
|
195
|
+
// Stripping it first matters: `node_modules/` is the common spelling, and
|
|
196
|
+
// testing for "/" before stripping rejected every one of them.
|
|
197
|
+
const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
|
198
|
+
|
|
159
199
|
// Skip patterns with special gitignore features we can't safely convert:
|
|
160
200
|
// - Root-anchored (starts with /)
|
|
161
201
|
// - Contains globs (*, ?, [)
|
|
162
202
|
// - Contains path separators (complex paths)
|
|
163
203
|
// - Escaped characters
|
|
164
204
|
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
205
|
+
name.startsWith("/") ||
|
|
206
|
+
name.includes("*") ||
|
|
207
|
+
name.includes("?") ||
|
|
208
|
+
name.includes("[") ||
|
|
209
|
+
name.includes("/") ||
|
|
210
|
+
name.includes("\\")
|
|
171
211
|
) {
|
|
172
212
|
continue;
|
|
173
213
|
}
|
|
174
214
|
|
|
175
215
|
// Only convert simple directory names (e.g., "node_modules", "dist")
|
|
176
216
|
// These are safe to prune at any depth
|
|
177
|
-
const name = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
|
178
217
|
if (name && /^[\w.-]+$/.test(name)) {
|
|
179
218
|
patterns.push(`**/${name}/**`);
|
|
180
219
|
}
|
|
@@ -184,28 +223,98 @@ function gitignoreToGlobPatterns(lines: string[]): string[] {
|
|
|
184
223
|
}
|
|
185
224
|
|
|
186
225
|
interface GitignoreResult {
|
|
187
|
-
ignore
|
|
226
|
+
/** Whether git would ignore this cwd-relative posix path. */
|
|
227
|
+
ignores: (path: string) => boolean;
|
|
188
228
|
globPatterns: string[];
|
|
189
229
|
}
|
|
190
230
|
|
|
231
|
+
/** One directory's ignore rules. `dir` is a cwd-relative prefix, "" for root. */
|
|
232
|
+
interface IgnoreLayer {
|
|
233
|
+
dir: string;
|
|
234
|
+
ig: Ignore;
|
|
235
|
+
}
|
|
236
|
+
|
|
191
237
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
238
|
+
* Read an ignore file. Only a missing file means "no rules" — a permission or
|
|
239
|
+
* I/O error would otherwise widen the bundle to exactly the files someone chose
|
|
240
|
+
* to hide, so it fails the run instead.
|
|
241
|
+
*/
|
|
242
|
+
async function readIgnoreFile(path: string): Promise<string | null> {
|
|
243
|
+
try {
|
|
244
|
+
return await readFile(path, "utf-8");
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
247
|
+
throw new ConfigError(
|
|
248
|
+
`Cannot read "${path}": ${(error as Error).message}. ` +
|
|
249
|
+
"srcpack stops rather than bundle files it cannot confirm are ignored.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Load the .gitignore rules that apply anywhere under `cwd`.
|
|
256
|
+
*
|
|
257
|
+
* Git resolves ignores per directory: a path is governed by the ignore file in
|
|
258
|
+
* its own directory and in every parent, deepest rule winning. Reading only the
|
|
259
|
+
* root file bundles whatever a nested .gitignore hides — `packages/app/.env` in
|
|
260
|
+
* a monorepo being the case that matters, since ignored secrets staying out is
|
|
261
|
+
* a documented guarantee rather than a convenience.
|
|
194
262
|
*/
|
|
195
263
|
async function loadGitignore(cwd: string): Promise<GitignoreResult> {
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
|
|
264
|
+
const rootContent = await readIgnoreFile(join(cwd, ".gitignore"));
|
|
265
|
+
const globPatterns = rootContent
|
|
266
|
+
? gitignoreToGlobPatterns(rootContent.split("\n"))
|
|
267
|
+
: [];
|
|
268
|
+
|
|
269
|
+
const layers: IgnoreLayer[] = [];
|
|
270
|
+
if (rootContent) layers.push({ dir: "", ig: ignore().add(rootContent) });
|
|
271
|
+
|
|
272
|
+
// Pruned by the root file's directory patterns: no point walking node_modules
|
|
273
|
+
// to collect ignore files that only govern paths already ignored.
|
|
274
|
+
const nested = await glob(["**/.gitignore"], {
|
|
275
|
+
cwd,
|
|
276
|
+
dot: true,
|
|
277
|
+
onlyFiles: true,
|
|
278
|
+
followSymbolicLinks: false,
|
|
279
|
+
ignore: globPatterns,
|
|
280
|
+
});
|
|
199
281
|
|
|
200
|
-
|
|
201
|
-
const content = await
|
|
202
|
-
ig.add(content);
|
|
203
|
-
globPatterns = gitignoreToGlobPatterns(content.split("\n"));
|
|
204
|
-
} catch {
|
|
205
|
-
// No .gitignore file, return empty ignore instance
|
|
282
|
+
for (const file of nested) {
|
|
283
|
+
const content = await readIgnoreFile(join(cwd, file));
|
|
284
|
+
if (content) layers.push({ dir: dirname(file), ig: ignore().add(content) });
|
|
206
285
|
}
|
|
207
286
|
|
|
208
|
-
return {
|
|
287
|
+
return { ignores: makeIgnores(layers), globPatterns };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Resolve the layered rules for one path, deepest directory first. */
|
|
291
|
+
function makeIgnores(layers: IgnoreLayer[]): (path: string) => boolean {
|
|
292
|
+
if (layers.length === 0) return () => false;
|
|
293
|
+
const ordered = [...layers].sort((a, b) => b.dir.length - a.dir.length);
|
|
294
|
+
|
|
295
|
+
// First layer with an opinion wins; an explicit negation (`!keep.env`) is an
|
|
296
|
+
// opinion too, which is what lets a nested file re-include what its parent hid.
|
|
297
|
+
const opinion = (path: string): boolean | undefined => {
|
|
298
|
+
for (const { dir, ig } of ordered) {
|
|
299
|
+
if (dir && !path.startsWith(`${dir}/`)) continue;
|
|
300
|
+
const relative = dir ? path.slice(dir.length + 1) : path;
|
|
301
|
+
if (!relative || relative === "/") continue;
|
|
302
|
+
const { ignored, unignored } = ig.test(relative);
|
|
303
|
+
if (ignored) return true;
|
|
304
|
+
if (unignored) return false;
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return (path: string) => {
|
|
310
|
+
// Ancestors first: git never descends into an ignored directory, so nothing
|
|
311
|
+
// beneath one can be negated back in.
|
|
312
|
+
const segments = path.split("/");
|
|
313
|
+
for (let i = 1; i < segments.length; i++) {
|
|
314
|
+
if (opinion(`${segments.slice(0, i).join("/")}/`)) return true;
|
|
315
|
+
}
|
|
316
|
+
return opinion(path) === true;
|
|
317
|
+
};
|
|
209
318
|
}
|
|
210
319
|
|
|
211
320
|
/**
|
|
@@ -221,14 +330,40 @@ function isExternalPattern(pattern: string): boolean {
|
|
|
221
330
|
return normalized.startsWith("../");
|
|
222
331
|
}
|
|
223
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The key two paths are compared by: canonical spelling, then case folded, on
|
|
335
|
+
* every platform. A case-insensitive filesystem — the default on macOS and
|
|
336
|
+
* Windows — treats `Context.txt` and `context.txt` as one directory entry, and
|
|
337
|
+
* APFS additionally folds Unicode normalisation, so `Café.txt` written as
|
|
338
|
+
* precomposed U+00E9 and as `e` plus U+0301 is also one entry. Normalising
|
|
339
|
+
* before folding is what makes the comparison sound: equal inputs stay equal
|
|
340
|
+
* afterwards whether or not case folding preserves normalisation. `realpath`
|
|
341
|
+
* resolves
|
|
342
|
+
* an existing component to its on-disk spelling, but that doesn't cover these:
|
|
343
|
+
* an output not yet written has no on-disk spelling, and the destination entry
|
|
344
|
+
* is deliberately left unresolved so `rename` replaces a symlink rather than
|
|
345
|
+
* following it. A config that works in Linux CI and loses a bundle on the
|
|
346
|
+
* author's laptop is worse than one rejected everywhere, so the rule is the
|
|
347
|
+
* same on every platform rather than keyed to the filesystem under it.
|
|
348
|
+
*
|
|
349
|
+
* Comparison only. Paths used for I/O keep their original spelling, and
|
|
350
|
+
* ownership stays an exact match: folding there could only widen what srcpack
|
|
351
|
+
* deletes, which is the one direction that must never be widened by a guess.
|
|
352
|
+
*/
|
|
353
|
+
export function pathKey(path: string): string {
|
|
354
|
+
return path.normalize("NFC").toLowerCase();
|
|
355
|
+
}
|
|
356
|
+
|
|
224
357
|
/**
|
|
225
358
|
* Whether a path is one srcpack writes. `outputs` holds absolute paths of
|
|
226
359
|
* files or directories; a directory covers everything beneath it.
|
|
227
360
|
*/
|
|
228
361
|
function isOwnOutput(filePath: string, outputs: string[]): boolean {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
362
|
+
const key = pathKey(filePath);
|
|
363
|
+
return outputs.some((out) => {
|
|
364
|
+
const outKey = pathKey(out);
|
|
365
|
+
return key === outKey || key.startsWith(outKey + sep);
|
|
366
|
+
});
|
|
232
367
|
}
|
|
233
368
|
|
|
234
369
|
/**
|
|
@@ -280,33 +415,86 @@ export async function resolvePatterns(
|
|
|
280
415
|
// Internal patterns (within cwd): respect .gitignore
|
|
281
416
|
const internalPatterns = globs.filter((p) => !isExternalPattern(p));
|
|
282
417
|
if (internalPatterns.length > 0) {
|
|
283
|
-
const {
|
|
418
|
+
const { ignores, globPatterns } = await loadGitignore(cwd);
|
|
284
419
|
const matches = await glob(internalPatterns, {
|
|
420
|
+
...GLOB_OPTIONS,
|
|
285
421
|
cwd,
|
|
286
|
-
onlyFiles: true,
|
|
287
|
-
dot: true,
|
|
288
422
|
ignore: globPatterns,
|
|
289
423
|
});
|
|
290
|
-
await add(matches.filter((m) => !
|
|
424
|
+
await add(matches.filter((m) => !ignores(m)));
|
|
291
425
|
}
|
|
292
426
|
|
|
293
427
|
// External patterns: skip .gitignore (it doesn't apply outside cwd)
|
|
294
428
|
const externalPatterns = globs.filter(isExternalPattern);
|
|
295
429
|
if (externalPatterns.length > 0) {
|
|
296
|
-
await add(
|
|
297
|
-
await glob(externalPatterns, { cwd, onlyFiles: true, dot: true }),
|
|
298
|
-
);
|
|
430
|
+
await add(await glob(externalPatterns, { ...GLOB_OPTIONS, cwd }));
|
|
299
431
|
}
|
|
300
432
|
|
|
301
433
|
// Force includes: bypass .gitignore (no ignore patterns passed to glob)
|
|
302
434
|
if (force.length > 0) {
|
|
303
|
-
await add(await glob(force, {
|
|
435
|
+
await add(await glob(force, { ...GLOB_OPTIONS, cwd }));
|
|
304
436
|
}
|
|
305
437
|
|
|
306
438
|
// Sort for deterministic output
|
|
307
439
|
return [...files].sort();
|
|
308
440
|
}
|
|
309
441
|
|
|
442
|
+
/** Read the `linear` source off a bundle config, if it declares one. */
|
|
443
|
+
function getLinear(config: BundleConfigInput): LinearSourceInput | undefined {
|
|
444
|
+
if (typeof config === "object" && !Array.isArray(config)) {
|
|
445
|
+
return config.linear;
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Resolve every source a bundle declares into a sorted entry list.
|
|
452
|
+
*
|
|
453
|
+
* Filesystem and `git:` sources produce paths ({@link resolvePatterns}, which
|
|
454
|
+
* never touches the network); `linear` produces virtual entries carrying their
|
|
455
|
+
* own content. Both then meet the same rules — `!` exclusions apply uniformly,
|
|
456
|
+
* and the result is sorted by path so bundles stay deterministic.
|
|
457
|
+
*/
|
|
458
|
+
export async function resolveEntries(
|
|
459
|
+
config: BundleConfigInput,
|
|
460
|
+
cwd: string,
|
|
461
|
+
outputs: string[] = [],
|
|
462
|
+
): Promise<Entry[]> {
|
|
463
|
+
const linearSource = getLinear(config);
|
|
464
|
+
|
|
465
|
+
// Sequential, not parallel: a bad pattern should fail before spending a
|
|
466
|
+
// network round trip, and a failed fetch shouldn't race a filesystem walk.
|
|
467
|
+
const paths = await resolvePatterns(config, cwd, outputs);
|
|
468
|
+
const entries: Entry[] = paths.map((path) => ({ path }));
|
|
469
|
+
|
|
470
|
+
if (linearSource) {
|
|
471
|
+
const { exclude } = normalizePatterns(config);
|
|
472
|
+
const excludeMatchers = exclude.map((p) => picomatch(p));
|
|
473
|
+
// Compare resolved paths, not the strings: an absolute pattern and a
|
|
474
|
+
// relative one name the same file with different spellings, and only the
|
|
475
|
+
// resolved form tells whether a real file occupies a synthetic path.
|
|
476
|
+
const taken = new Set(paths.map((path) => resolve(cwd, path)));
|
|
477
|
+
|
|
478
|
+
for (const entry of await resolveLinearSource(linearSource)) {
|
|
479
|
+
if (isExcluded(entry.path, excludeMatchers)) continue;
|
|
480
|
+
// `linear/issues/` is a reserved namespace once a bundle pulls issues:
|
|
481
|
+
// a real file there would put two entries in the index under one name,
|
|
482
|
+
// and an `!` exclusion can't drop one without dropping both.
|
|
483
|
+
if (taken.has(resolve(cwd, entry.path))) {
|
|
484
|
+
throw new ConfigError(
|
|
485
|
+
`Linear issue collides with the file "${entry.path}". ` +
|
|
486
|
+
"Rename the file, or narrow the include patterns so it isn't matched.",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
entries.push(entry);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return entries.sort((a, b) =>
|
|
494
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
310
498
|
/**
|
|
311
499
|
* Count lines in a string (handles empty strings correctly)
|
|
312
500
|
*/
|
|
@@ -324,7 +512,7 @@ function countLines(content: string): number {
|
|
|
324
512
|
* - ASCII-only characters for broad compatibility
|
|
325
513
|
* - Line locations that point to actual file content
|
|
326
514
|
*/
|
|
327
|
-
export function formatIndex(index:
|
|
515
|
+
export function formatIndex(index: IndexEntry[]): string {
|
|
328
516
|
if (index.length === 0) return "# Index\n# (empty)";
|
|
329
517
|
|
|
330
518
|
const count = index.length;
|
|
@@ -355,43 +543,46 @@ function formatSeparator(index: number, filePath: string): string {
|
|
|
355
543
|
}
|
|
356
544
|
|
|
357
545
|
/**
|
|
358
|
-
* Create a bundle from a list of
|
|
546
|
+
* Create a bundle from a list of entries.
|
|
359
547
|
* Line numbers in the index point to the first line of actual file content,
|
|
360
548
|
* not to the separator line.
|
|
361
549
|
*/
|
|
362
550
|
export async function createBundle(
|
|
363
|
-
|
|
551
|
+
entries: Entry[],
|
|
364
552
|
cwd: string,
|
|
365
553
|
options: BundleOptions = {},
|
|
366
554
|
): Promise<BundleResult> {
|
|
367
555
|
const { includeIndex = true } = options;
|
|
368
556
|
// Normalize prompt: trim and treat whitespace-only as no prompt
|
|
369
557
|
const prompt = options.prompt?.trim() || undefined;
|
|
370
|
-
const index:
|
|
558
|
+
const index: IndexEntry[] = [];
|
|
371
559
|
const contentParts: string[] = [];
|
|
372
560
|
let currentLine = 1;
|
|
373
561
|
|
|
374
|
-
for (let i = 0; i <
|
|
375
|
-
const
|
|
376
|
-
const
|
|
562
|
+
for (let i = 0; i < entries.length; i++) {
|
|
563
|
+
const entry = entries[i]!;
|
|
564
|
+
const filePath = entry.path;
|
|
565
|
+
// Virtual entries carry their content; files are read from disk
|
|
566
|
+
const content =
|
|
567
|
+
entry.content ?? (await readFile(resolve(cwd, filePath), "utf-8"));
|
|
377
568
|
const lines = countLines(content);
|
|
378
569
|
|
|
379
570
|
// Separator takes 1 line, then content starts on next line
|
|
380
571
|
const contentStartLine = currentLine + 1;
|
|
381
572
|
|
|
382
|
-
const
|
|
573
|
+
const indexEntry: IndexEntry = {
|
|
383
574
|
path: filePath,
|
|
384
575
|
lines,
|
|
385
576
|
startLine: contentStartLine,
|
|
386
577
|
endLine: contentStartLine + Math.max(0, lines - 1),
|
|
387
578
|
};
|
|
388
|
-
index.push(
|
|
579
|
+
index.push(indexEntry);
|
|
389
580
|
|
|
390
581
|
contentParts.push(formatSeparator(i + 1, filePath));
|
|
391
582
|
contentParts.push(content.endsWith("\n") ? content.slice(0, -1) : content);
|
|
392
583
|
|
|
393
584
|
// Next separator line = after content
|
|
394
|
-
currentLine =
|
|
585
|
+
currentLine = indexEntry.endLine + 1;
|
|
395
586
|
}
|
|
396
587
|
|
|
397
588
|
// Calculate prompt offset (prompt text + blank + "---" + blank)
|
|
@@ -494,8 +685,8 @@ export async function bundleOne(
|
|
|
494
685
|
cwd: string,
|
|
495
686
|
outputs: string[] = [],
|
|
496
687
|
): Promise<BundleResult> {
|
|
497
|
-
const
|
|
688
|
+
const entries = await resolveEntries(config, cwd, outputs);
|
|
498
689
|
const includeIndex = getIncludeIndex(config);
|
|
499
690
|
const prompt = await resolvePrompt(getPrompt(config), cwd);
|
|
500
|
-
return createBundle(
|
|
691
|
+
return createBundle(entries, cwd, { includeIndex, prompt });
|
|
501
692
|
}
|