@theholocron/git-utils 1.9.2 → 1.10.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/dist/index.d.mts +89 -0
- package/dist/index.mjs +165 -0
- package/package.json +1 -1
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
//#region src/managed-block.d.ts
|
|
2
|
+
export interface ManagedBlockOptions {
|
|
3
|
+
/** The line that opens the block, e.g. `# managed by my-tool — skills`. Matched as text, so it must be distinctive. */
|
|
4
|
+
start: string;
|
|
5
|
+
/** The line that closes the block. */
|
|
6
|
+
end: string;
|
|
7
|
+
/** What goes between the markers, one entry per line. No blank lines, and no line breaks inside an entry. */
|
|
8
|
+
lines: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Inserts or replaces a block of lines delimited by marker lines, leaving
|
|
12
|
+
* everything outside it untouched.
|
|
13
|
+
*
|
|
14
|
+
* - Block present (`start` … `end`): replaced in place.
|
|
15
|
+
* - No block: appended, separated from existing content by a blank line, with a
|
|
16
|
+
* single trailing newline.
|
|
17
|
+
* - `start` present but `end` missing (hand-edited or truncated): the block is
|
|
18
|
+
* taken to run to the next blank line, or to the end of the file — so the
|
|
19
|
+
* content after it is kept, not lost. An `end` marker only closes the block
|
|
20
|
+
* when it comes before that blank line; one further down is left alone.
|
|
21
|
+
*
|
|
22
|
+
* Line endings follow the existing text (`\r\n` when it mostly uses them), so
|
|
23
|
+
* a CRLF file stays CRLF. That is also why `lines` can't contain blank lines: a
|
|
24
|
+
* blank line is how an unterminated block is told apart from the next section.
|
|
25
|
+
*
|
|
26
|
+
* Idempotent: calling it again with the same options returns the same text.
|
|
27
|
+
* There are no default markers; callers choose them, and must keep them stable
|
|
28
|
+
* so existing blocks are still found.
|
|
29
|
+
*
|
|
30
|
+
* @throws {TypeError} when an entry in `lines` is blank or contains a line break.
|
|
31
|
+
*/
|
|
32
|
+
export declare function upsertManagedBlock(content: string, { start, end, lines }: ManagedBlockOptions): string;
|
|
33
|
+
/**
|
|
34
|
+
* {@link upsertManagedBlock} applied to a file (a missing file counts as empty,
|
|
35
|
+
* and is created). Writes only when the content changes, and atomically (a
|
|
36
|
+
* temp file renamed into place; a symlinked path is updated through the link).
|
|
37
|
+
* Resolves to `true` when it wrote.
|
|
38
|
+
*
|
|
39
|
+
* Calls for the same path within one process run one after another, each
|
|
40
|
+
* reading the file as the previous left it. Separate processes aren't
|
|
41
|
+
* coordinated. Any read error other than "no such file" rejects, rather than
|
|
42
|
+
* being mistaken for an empty file and overwritten.
|
|
43
|
+
*/
|
|
44
|
+
export declare function updateGitignore(path: string, options: ManagedBlockOptions): Promise<boolean>;
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/remote.d.ts
|
|
47
|
+
export interface GitRemote {
|
|
48
|
+
host: string;
|
|
49
|
+
/** Everything before the repo name: `owner`, or `group/subgroup` on hosts with nested groups. */
|
|
50
|
+
owner: string;
|
|
51
|
+
repo: string;
|
|
52
|
+
/** `owner/repo`. */
|
|
53
|
+
slug: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parses a git remote URL — any host, any of the common shapes:
|
|
57
|
+
*
|
|
58
|
+
* - `https://github.com/owner/repo(.git)(/)`, with optional credentials or port
|
|
59
|
+
* - `git@github.com:owner/repo(.git)` (scp-style SSH)
|
|
60
|
+
* - `ssh://git@github.com(:22)/owner/repo(.git)`
|
|
61
|
+
* - `git://github.com/owner/repo.git`
|
|
62
|
+
*
|
|
63
|
+
* A bracketed IPv6 host is returned without its brackets. The whole input must
|
|
64
|
+
* be one remote: trailing text after the URL (apart from a `?query` or
|
|
65
|
+
* `#fragment`) makes it `undefined`.
|
|
66
|
+
*
|
|
67
|
+
* `undefined` for anything else (a local path, an empty string, a URL with
|
|
68
|
+
* fewer than two path segments).
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseGitRemoteUrl(url: string): GitRemote | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Runs `git <args>` in `options.cwd` and resolves its stdout; rejects when git
|
|
73
|
+
* exits non-zero. Injectable so callers' tests don't need a real repository.
|
|
74
|
+
*/
|
|
75
|
+
export type GitExec = (args: string[], options: {
|
|
76
|
+
cwd: string;
|
|
77
|
+
}) => Promise<string>;
|
|
78
|
+
export interface ReadGitRemoteOptions {
|
|
79
|
+
exec?: GitExec;
|
|
80
|
+
/** Remote name. Default `"origin"`. */
|
|
81
|
+
remote?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The parsed URL of a repository's remote (`git remote get-url <remote>`), or
|
|
85
|
+
* `undefined` when `cwd` isn't a repository, has no such remote, git isn't
|
|
86
|
+
* installed, or the URL isn't one {@link parseGitRemoteUrl} understands.
|
|
87
|
+
*/
|
|
88
|
+
export declare function readGitRemote(cwd: string, options?: ReadGitRemoteOptions): Promise<GitRemote | undefined>;
|
|
89
|
+
//#endregion
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
//#region src/managed-block.ts
|
|
5
|
+
/** A blank (or whitespace-only) line: the boundary of a block whose end marker went missing. */
|
|
6
|
+
const BLANK_LINE = /\r?\n[ \t]*\r?\n/;
|
|
7
|
+
/** `\r\n` when it is the text's majority line ending, else `\n`. */
|
|
8
|
+
function detectEol(text) {
|
|
9
|
+
const crlf = text.match(/\r\n/g)?.length ?? 0;
|
|
10
|
+
return crlf > (text.match(/\n/g)?.length ?? 0) - crlf ? "\r\n" : "\n";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Inserts or replaces a block of lines delimited by marker lines, leaving
|
|
14
|
+
* everything outside it untouched.
|
|
15
|
+
*
|
|
16
|
+
* - Block present (`start` … `end`): replaced in place.
|
|
17
|
+
* - No block: appended, separated from existing content by a blank line, with a
|
|
18
|
+
* single trailing newline.
|
|
19
|
+
* - `start` present but `end` missing (hand-edited or truncated): the block is
|
|
20
|
+
* taken to run to the next blank line, or to the end of the file — so the
|
|
21
|
+
* content after it is kept, not lost. An `end` marker only closes the block
|
|
22
|
+
* when it comes before that blank line; one further down is left alone.
|
|
23
|
+
*
|
|
24
|
+
* Line endings follow the existing text (`\r\n` when it mostly uses them), so
|
|
25
|
+
* a CRLF file stays CRLF. That is also why `lines` can't contain blank lines: a
|
|
26
|
+
* blank line is how an unterminated block is told apart from the next section.
|
|
27
|
+
*
|
|
28
|
+
* Idempotent: calling it again with the same options returns the same text.
|
|
29
|
+
* There are no default markers; callers choose them, and must keep them stable
|
|
30
|
+
* so existing blocks are still found.
|
|
31
|
+
*
|
|
32
|
+
* @throws {TypeError} when an entry in `lines` is blank or contains a line break.
|
|
33
|
+
*/
|
|
34
|
+
function upsertManagedBlock(content, { start, end, lines }) {
|
|
35
|
+
for (const line of lines) if (line.trim() === "" || /[\r\n]/.test(line)) throw new TypeError(`Managed block lines must be non-blank single lines, got ${JSON.stringify(line)}`);
|
|
36
|
+
const eol = detectEol(content);
|
|
37
|
+
const block = [
|
|
38
|
+
start,
|
|
39
|
+
...lines,
|
|
40
|
+
end
|
|
41
|
+
].join(eol);
|
|
42
|
+
const startAt = content.indexOf(start);
|
|
43
|
+
if (startAt === -1) {
|
|
44
|
+
const base = content.trimEnd();
|
|
45
|
+
return (base ? `${base}${eol}${eol}` : "") + `${block}${eol}`;
|
|
46
|
+
}
|
|
47
|
+
const boundary = BLANK_LINE.exec(content.slice(startAt));
|
|
48
|
+
const boundaryAt = boundary ? startAt + boundary.index : -1;
|
|
49
|
+
const endAt = content.indexOf(end, startAt);
|
|
50
|
+
if (endAt !== -1 && (boundaryAt === -1 || endAt < boundaryAt)) return content.slice(0, startAt) + block + content.slice(endAt + end.length);
|
|
51
|
+
return content.slice(0, startAt) + block + (boundaryAt === -1 ? eol : content.slice(boundaryAt));
|
|
52
|
+
}
|
|
53
|
+
/** Updates in flight, per file: a second call for the same path waits for the first. */
|
|
54
|
+
const queues = /* @__PURE__ */ new Map();
|
|
55
|
+
function serialized(key, task) {
|
|
56
|
+
const result = (queues.get(key) ?? Promise.resolve()).then(task, task);
|
|
57
|
+
const tail = result.catch(() => void 0);
|
|
58
|
+
queues.set(key, tail);
|
|
59
|
+
tail.then(() => {
|
|
60
|
+
if (queues.get(key) === tail) queues.delete(key);
|
|
61
|
+
});
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
async function readIfExists(path) {
|
|
65
|
+
try {
|
|
66
|
+
return await readFile(path, "utf8");
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code === "ENOENT") return "";
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Writes `path` by renaming a finished temp file over it, so a failure never leaves it truncated. */
|
|
73
|
+
async function writeAtomic(path, text) {
|
|
74
|
+
const mode = await stat(path).then((s) => s.mode & 511, () => void 0);
|
|
75
|
+
const tmp = join(dirname(path), `.${randomBytes(6).toString("hex")}.tmp`);
|
|
76
|
+
try {
|
|
77
|
+
await writeFile(tmp, text, "utf8");
|
|
78
|
+
if (mode !== void 0) await chmod(tmp, mode);
|
|
79
|
+
await rename(tmp, path);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
await unlink(tmp).catch(() => void 0);
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* {@link upsertManagedBlock} applied to a file (a missing file counts as empty,
|
|
87
|
+
* and is created). Writes only when the content changes, and atomically (a
|
|
88
|
+
* temp file renamed into place; a symlinked path is updated through the link).
|
|
89
|
+
* Resolves to `true` when it wrote.
|
|
90
|
+
*
|
|
91
|
+
* Calls for the same path within one process run one after another, each
|
|
92
|
+
* reading the file as the previous left it. Separate processes aren't
|
|
93
|
+
* coordinated. Any read error other than "no such file" rejects, rather than
|
|
94
|
+
* being mistaken for an empty file and overwritten.
|
|
95
|
+
*/
|
|
96
|
+
async function updateGitignore(path, options) {
|
|
97
|
+
const target = await realpath(path).catch(() => path);
|
|
98
|
+
return serialized(target, async () => {
|
|
99
|
+
const existing = await readIfExists(target);
|
|
100
|
+
const updated = upsertManagedBlock(existing, options);
|
|
101
|
+
if (updated === existing) return false;
|
|
102
|
+
await writeAtomic(target, updated);
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/remote.ts
|
|
108
|
+
/**
|
|
109
|
+
* Parses a git remote URL — any host, any of the common shapes:
|
|
110
|
+
*
|
|
111
|
+
* - `https://github.com/owner/repo(.git)(/)`, with optional credentials or port
|
|
112
|
+
* - `git@github.com:owner/repo(.git)` (scp-style SSH)
|
|
113
|
+
* - `ssh://git@github.com(:22)/owner/repo(.git)`
|
|
114
|
+
* - `git://github.com/owner/repo.git`
|
|
115
|
+
*
|
|
116
|
+
* A bracketed IPv6 host is returned without its brackets. The whole input must
|
|
117
|
+
* be one remote: trailing text after the URL (apart from a `?query` or
|
|
118
|
+
* `#fragment`) makes it `undefined`.
|
|
119
|
+
*
|
|
120
|
+
* `undefined` for anything else (a local path, an empty string, a URL with
|
|
121
|
+
* fewer than two path segments).
|
|
122
|
+
*/
|
|
123
|
+
function parseGitRemoteUrl(url) {
|
|
124
|
+
const input = url.trim();
|
|
125
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/\s]*@)?(\[[0-9a-f:.]+\]|[^:/\s]+)(?::\d+)?\/([^\s?#]+)(?:[?#]\S*)?$/i.exec(input);
|
|
126
|
+
const scp = withScheme ? null : /^(?:[^@/\s]+@)?(\[[0-9a-f:.]+\]|[^:/\s]{2,}):(?!\/\/)([^\s]+)$/i.exec(input);
|
|
127
|
+
const match = withScheme ?? scp;
|
|
128
|
+
if (!match) return void 0;
|
|
129
|
+
const host = match[1].replace(/^\[|\]$/g, "");
|
|
130
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
131
|
+
const last = segments.pop().replace(/\.git$/, "");
|
|
132
|
+
if (last) segments.push(last);
|
|
133
|
+
if (segments.length < 2) return void 0;
|
|
134
|
+
const repo = segments[segments.length - 1];
|
|
135
|
+
const owner = segments.slice(0, -1).join("/");
|
|
136
|
+
return {
|
|
137
|
+
host,
|
|
138
|
+
owner,
|
|
139
|
+
repo,
|
|
140
|
+
slug: `${owner}/${repo}`
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const defaultExec = async (args, { cwd }) => {
|
|
144
|
+
const [{ execFile }, { promisify }] = await Promise.all([import("node:child_process"), import("node:util")]);
|
|
145
|
+
return (await promisify(execFile)("git", args, { cwd })).stdout;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* The parsed URL of a repository's remote (`git remote get-url <remote>`), or
|
|
149
|
+
* `undefined` when `cwd` isn't a repository, has no such remote, git isn't
|
|
150
|
+
* installed, or the URL isn't one {@link parseGitRemoteUrl} understands.
|
|
151
|
+
*/
|
|
152
|
+
async function readGitRemote(cwd, options = {}) {
|
|
153
|
+
const exec = options.exec ?? defaultExec;
|
|
154
|
+
try {
|
|
155
|
+
return parseGitRemoteUrl(await exec([
|
|
156
|
+
"remote",
|
|
157
|
+
"get-url",
|
|
158
|
+
options.remote ?? "origin"
|
|
159
|
+
], { cwd }));
|
|
160
|
+
} catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
export { parseGitRemoteUrl, readGitRemote, updateGitignore, upsertManagedBlock };
|