@infinisys/is-devops 0.0.0-stage → 0.1.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,115 @@
1
+ /**
2
+ * Stable `infinsys-*` identity used for machine-authored GitFox and Git data.
3
+ *
4
+ * GitFox attributes an issue or merge request to `<bot_name>@<token owner>`
5
+ * when `bot_name` is sent with a PAT or OAuth2 token. Git commits have no
6
+ * server-side bot field, so the same codename becomes the author and committer
7
+ * name. Both surfaces deliberately share one naming scheme so a reviewer can
8
+ * correlate an issue, a commit and a push.
9
+ */
10
+
11
+ /** Bot namespace reserved for the Infinity System. */
12
+ export const BOT_PREFIX = 'infinsys';
13
+
14
+ /** Actor used when no live team role can be resolved. */
15
+ export const DEFAULT_AGENT_ACTOR = 'agent';
16
+
17
+ /** GitFox rejects bot names longer than a username. */
18
+ const MAX_BOT_NAME_LENGTH = 50;
19
+
20
+ /** Fallback used when an actor has no usable characters left. */
21
+ const FALLBACK_ACTOR = 'agent';
22
+
23
+ /**
24
+ * Turn an arbitrary role/member label into the suffix of a bot name.
25
+ *
26
+ * GitFox requires the first character to be alphanumeric and allows only
27
+ * letters, digits, `-`, `_` and `.`. A trailing/leading separator is trimmed so
28
+ * the public name never looks like `infinsys--dev`.
29
+ *
30
+ * @param {unknown} value - role id, member name or explicit actor.
31
+ * @returns {string} normalized actor suffix.
32
+ */
33
+ export function normalizeActor(value) {
34
+ const text = typeof value === 'string' ? value.trim().toLowerCase() : '';
35
+ const replaced = text.replaceAll(/[^a-z0-9._-]+/g, '-').replaceAll(/^[-._]+|[-._]+$/g, '');
36
+ if (replaced === '') return FALLBACK_ACTOR;
37
+ const room = MAX_BOT_NAME_LENGTH - BOT_PREFIX.length - 1;
38
+ return replaced.slice(0, room).replaceAll(/[-._]+$/g, '') || FALLBACK_ACTOR;
39
+ }
40
+
41
+ /**
42
+ * Build the public bot codename for one actor.
43
+ *
44
+ * @param {unknown} actor - role id, member name or explicit actor.
45
+ * @returns {string} `infinsys-<actor>` accepted by GitFox `bot_name`.
46
+ */
47
+ export function botNameFor(actor) {
48
+ return `${BOT_PREFIX}-${normalizeActor(actor)}`;
49
+ }
50
+
51
+ /**
52
+ * Git author/committer identity for one actor.
53
+ *
54
+ * The email domain is deliberately local and stable: GitFox does not resolve
55
+ * bot emails to user accounts, and a fake public domain would invite mail
56
+ * misdelivery.
57
+ *
58
+ * @param {unknown} actor - role id, member name or explicit actor.
59
+ * @returns {{ name: string, email: string }} git identity fields.
60
+ */
61
+ export function commitIdentityFor(actor) {
62
+ const botName = botNameFor(actor);
63
+ return { name: botName, email: `${botName}@infinity.bot` };
64
+ }
65
+
66
+ /**
67
+ * Normalize a resolver result into the actor record the tools consume.
68
+ *
69
+ * Resolvers are optional: `is-fabric-team` registers one to expose the live
70
+ * team role, its file boundary and the pinned project. A plain Agent session
71
+ * gets the unrestricted `infinsys-agent` actor, which preserves the previous
72
+ * behaviour of `bash`-driven Git outside Team Collaboration Mode.
73
+ *
74
+ * @param {{ actor?: unknown, canWriteGit?: unknown, isPathAllowed?: unknown, isProjectAllowed?: unknown }} [input] - resolver output.
75
+ * @returns {{ actor: string, botName: string, canWriteGit: boolean, isPathAllowed?: Function, isProjectAllowed?: Function }} normalized actor.
76
+ */
77
+ export function normalizeActorRecord(input = {}) {
78
+ const actor = normalizeActor(input.actor);
79
+ const record = {
80
+ actor,
81
+ botName: botNameFor(actor),
82
+ canWriteGit: input.canWriteGit !== false,
83
+ };
84
+ if (typeof input.isPathAllowed === 'function') record.isPathAllowed = input.isPathAllowed;
85
+ if (typeof input.isProjectAllowed === 'function') {
86
+ record.isProjectAllowed = input.isProjectAllowed;
87
+ }
88
+ return record;
89
+ }
90
+
91
+ /**
92
+ * Add the automatic bot identity to an issue/merge-request payload.
93
+ *
94
+ * @param {Record<string, unknown>} body - API request body.
95
+ * @param {unknown} actor - actor to attribute, or empty to leave unattributed.
96
+ * @returns {Record<string, unknown>} a copy with `bot_name` when requested.
97
+ */
98
+ export function withBotName(body, actor) {
99
+ const payload = { ...body };
100
+ if (actor === undefined || actor === null || actor === '') return payload;
101
+ const botName = typeof actor === 'string' && actor.startsWith(`${BOT_PREFIX}-`)
102
+ ? actor
103
+ : botNameFor(actor);
104
+ payload.bot_name = botName;
105
+ return payload;
106
+ }
107
+
108
+ /**
109
+ * The default actor used when no live team member can be resolved.
110
+ *
111
+ * @returns {ReturnType<typeof normalizeActorRecord>} default actor record.
112
+ */
113
+ export function defaultActorRecord() {
114
+ return normalizeActorRecord({ actor: DEFAULT_AGENT_ACTOR });
115
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Per-seat staging ledger and scoped commits.
3
+ *
4
+ * Git has one index per working tree, so `git commit` commits whatever any
5
+ * seat staged there. In a shared checkout that makes one seat's commit depend
6
+ * on another seat's staging; the recorded incident named one path in the
7
+ * commit call and produced a commit with two files.
8
+ *
9
+ * This module keeps the truth per seat instead:
10
+ * - `stage` records the blob each seat put into the index;
11
+ * - `commit` builds a throwaway index from HEAD plus exactly those blobs and
12
+ * commits that tree, so an unrelated staged file, or an edit made after the
13
+ * seat staged, cannot enter the commit;
14
+ * - `unstage` forgets the path for that seat.
15
+ *
16
+ * The ledger lives next to the repository's Git directory, one JSON file per
17
+ * seat. It never touches the tracked tree, it survives a Host restart, and it
18
+ * needs no lock on the shared index: the only mutable step is the commit
19
+ * itself, which is serialised per repository.
20
+ */
21
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
22
+ import { dirname, join } from 'node:path';
23
+
24
+ import { GitFoxError } from './gitfox-api.js';
25
+
26
+ /** Ledger format version; a future reader may migrate older files. */
27
+ const LEDGER_VERSION = 1;
28
+
29
+ /** Directory under the Git directory that holds ledgers and the commit index. */
30
+ const LEDGER_DIRECTORY = 'infinity-git';
31
+
32
+ /** File name of the throwaway index a scoped commit is built in. */
33
+ const COMMIT_INDEX = 'commit.index';
34
+
35
+ /** Serialised commits, keyed by Git directory. */
36
+ const commitQueues = new Map();
37
+
38
+ /**
39
+ * @param {unknown} actor - actor id.
40
+ * @returns {string} file name safe for one seat's ledger.
41
+ */
42
+ function ledgerFileName(actor) {
43
+ const text = typeof actor === 'string' ? actor.trim() : '';
44
+ return `${(text === '' ? 'agent' : text).replaceAll(/[^a-zA-Z0-9._-]+/g, '_')}.json`;
45
+ }
46
+
47
+ /**
48
+ * @param {string} gitDir - absolute Git directory.
49
+ * @param {unknown} actor - actor id.
50
+ * @returns {string} ledger path.
51
+ */
52
+ function ledgerPath(gitDir, actor) {
53
+ return join(gitDir, LEDGER_DIRECTORY, 'staged', ledgerFileName(actor));
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} entry - candidate ledger row.
58
+ * @returns {boolean} whether the row can drive a commit.
59
+ */
60
+ function isUsableEntry(entry) {
61
+ if (entry === null || typeof entry !== 'object') return false;
62
+ if (typeof entry.path !== 'string' || entry.path === '') return false;
63
+ if (entry.deleted === true) return true;
64
+ return typeof entry.mode === 'string' && entry.mode !== ''
65
+ && typeof entry.sha === 'string' && entry.sha !== '';
66
+ }
67
+
68
+ /**
69
+ * Read one seat's staged entries.
70
+ *
71
+ * A missing or unreadable ledger is empty, not an error: the next `stage`
72
+ * writes a fresh one, and a commit with nothing in it reports that plainly.
73
+ *
74
+ * @param {string} gitDir - absolute Git directory.
75
+ * @param {unknown} actor - actor id.
76
+ * @returns {Promise<{ path: string, mode?: string, sha?: string, deleted?: boolean }[]>} entries.
77
+ */
78
+ export async function readStagedEntries(gitDir, actor) {
79
+ let text;
80
+ try {
81
+ text = await readFile(ledgerPath(gitDir, actor), 'utf8');
82
+ } catch (error) {
83
+ if (error?.code === 'ENOENT') return [];
84
+ throw error;
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(text);
89
+ } catch {
90
+ return [];
91
+ }
92
+ const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
93
+ return entries.filter(isUsableEntry).map((entry) => entry);
94
+ }
95
+
96
+ /**
97
+ * Replace one seat's ledger atomically; an empty ledger removes the file.
98
+ *
99
+ * @param {string} gitDir - absolute Git directory.
100
+ * @param {unknown} actor - actor id.
101
+ * @param {{ path: string, mode?: string, sha?: string, deleted?: boolean }[]} entries - entries to store.
102
+ * @returns {Promise<void>} written.
103
+ */
104
+ async function writeStagedEntries(gitDir, actor, entries) {
105
+ const file = ledgerPath(gitDir, actor);
106
+ if (entries.length === 0) {
107
+ await rm(file, { force: true });
108
+ return;
109
+ }
110
+ await mkdir(dirname(file), { recursive: true });
111
+ const temporary = `${file}.${process.pid}.tmp`;
112
+ const body = { version: LEDGER_VERSION, updatedAt: new Date().toISOString(), entries };
113
+ await writeFile(temporary, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
114
+ await rename(temporary, file);
115
+ }
116
+
117
+ /**
118
+ * Record (or replace) the entries one seat just staged.
119
+ *
120
+ * @param {string} gitDir - absolute Git directory.
121
+ * @param {unknown} actor - actor id.
122
+ * @param {{ path: string, mode?: string, sha?: string, deleted?: boolean }[]} entries - freshly staged entries.
123
+ * @returns {Promise<void>} recorded.
124
+ */
125
+ export async function recordStagedEntries(gitDir, actor, entries) {
126
+ if (!Array.isArray(entries) || entries.length === 0) return;
127
+ const byPath = new Map();
128
+ for (const entry of await readStagedEntries(gitDir, actor)) byPath.set(entry.path, entry);
129
+ for (const entry of entries) {
130
+ if (isUsableEntry(entry)) byPath.set(entry.path, entry);
131
+ }
132
+ const merged = [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
133
+ await writeStagedEntries(gitDir, actor, merged);
134
+ }
135
+
136
+ /**
137
+ * Drop paths from one seat's ledger after `unstage` or a commit.
138
+ *
139
+ * @param {string} gitDir - absolute Git directory.
140
+ * @param {unknown} actor - actor id.
141
+ * @param {string[]} paths - repo-relative paths.
142
+ * @returns {Promise<void>} forgotten.
143
+ */
144
+ export async function forgetStagedEntries(gitDir, actor, paths) {
145
+ const drop = new Set(Array.isArray(paths) ? paths : []);
146
+ if (drop.size === 0) return;
147
+ const kept = (await readStagedEntries(gitDir, actor)).filter((entry) => !drop.has(entry.path));
148
+ await writeStagedEntries(gitDir, actor, kept);
149
+ }
150
+
151
+ /**
152
+ * Run one task at a time for a key.
153
+ *
154
+ * Two seats committing at the same moment would otherwise read the same HEAD
155
+ * and race on the branch ref. Serialising the read-tree/commit pair keeps each
156
+ * commit based on the commit that landed before it.
157
+ *
158
+ * @template T
159
+ * @param {string} key - queue key.
160
+ * @param {() => Promise<T>} task - work to serialise.
161
+ * @returns {Promise<T>} task result.
162
+ */
163
+ export function withCommitLock(key, task) {
164
+ const previous = commitQueues.get(key) ?? Promise.resolve();
165
+ const run = previous.then(task, task);
166
+ const tail = run.then(
167
+ () => undefined,
168
+ () => undefined,
169
+ );
170
+ commitQueues.set(key, tail);
171
+ void tail.then(() => {
172
+ if (commitQueues.get(key) === tail) commitQueues.delete(key);
173
+ });
174
+ return run;
175
+ }
176
+
177
+ /**
178
+ * Parse `git diff --cached --name-status -z` records.
179
+ *
180
+ * @param {string} raw - NUL-separated name/status records.
181
+ * @returns {{ path: string, status: string }[]} entries in index order.
182
+ */
183
+ function parseNameStatus(raw) {
184
+ const tokens = raw.split('\0').filter((token) => token !== '');
185
+ const entries = [];
186
+ for (let index = 0; index + 1 < tokens.length; index += 2) {
187
+ entries.push({ status: tokens[index].slice(0, 1), path: tokens[index + 1] });
188
+ }
189
+ return entries;
190
+ }
191
+
192
+ /**
193
+ * Commit exactly the recorded entries.
194
+ *
195
+ * The throwaway index starts as HEAD's tree, gets the recorded blobs (or their
196
+ * removal), and is committed. A file another seat staged stays out because it
197
+ * is not in this index; an edit made after staging stays out because the
198
+ * recorded blob is committed instead of the worktree file.
199
+ *
200
+ * @param {object} options - commit inputs.
201
+ * @param {(args: string[], env?: Record<string, string>) => Promise<{ stdout: string, stderr: string }>} options.runGit - Git runner bound to the repository.
202
+ * @param {string} options.gitDir - absolute Git directory.
203
+ * @param {string | null} options.head - current HEAD, or null before the first commit.
204
+ * @param {{ path: string, mode?: string, sha?: string, deleted?: boolean }[]} options.entries - entries to commit.
205
+ * @param {string} options.message - commit message.
206
+ * @param {{ name: string, email: string }} options.identity - author/committer identity.
207
+ * @returns {Promise<{ commit: string, files: string[] }>} created commit and the paths it contains.
208
+ */
209
+ export async function commitStagedEntries({ runGit, gitDir, head, entries, message, identity }) {
210
+ const directory = join(gitDir, LEDGER_DIRECTORY);
211
+ const indexPath = join(directory, COMMIT_INDEX);
212
+ await mkdir(directory, { recursive: true });
213
+ await rm(indexPath, { force: true });
214
+ const env = { GIT_INDEX_FILE: indexPath };
215
+ try {
216
+ await runGit(head === null ? ['read-tree', '--empty'] : ['read-tree', head], env);
217
+ for (const entry of entries) {
218
+ const staged = entry.deleted === true
219
+ ? ['update-index', '--force-remove', '--', entry.path]
220
+ : ['update-index', '--add', '--cacheinfo', entry.mode, entry.sha, entry.path];
221
+ await runGit(staged, env);
222
+ }
223
+ const changes = head === null
224
+ ? entries.map((entry) => ({ path: entry.path, status: 'A' }))
225
+ : parseNameStatus(
226
+ (await runGit(['diff', '--cached', '--name-status', '-z', '--no-renames'], env)).stdout,
227
+ );
228
+ if (changes.length === 0) {
229
+ throw new GitFoxError(
230
+ 'the staged paths already match the newest commit; unstage them or stage a change',
231
+ { code: 'nothing-staged' },
232
+ );
233
+ }
234
+ await runGit(
235
+ ['-c', 'commit.gpgsign=false', 'commit', '--no-verify', '--quiet', '-m', message],
236
+ {
237
+ ...env,
238
+ GIT_AUTHOR_NAME: identity.name,
239
+ GIT_AUTHOR_EMAIL: identity.email,
240
+ GIT_COMMITTER_NAME: identity.name,
241
+ GIT_COMMITTER_EMAIL: identity.email,
242
+ },
243
+ );
244
+ const { stdout } = await runGit(['rev-parse', 'HEAD'], env);
245
+ return { commit: stdout.trim(), files: changes.map((change) => change.path) };
246
+ } finally {
247
+ await rm(indexPath, { force: true });
248
+ }
249
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * One audited Git process execution path.
3
+ *
4
+ * Every invocation is argument-array based (never a shell), has a timeout and
5
+ * an output budget, and maps failures to stable `GitFoxError` codes. The token
6
+ * is supplied through the child environment only for the push invocation; it
7
+ * never appears in argv or in an error message.
8
+ */
9
+ import { execFile } from 'node:child_process';
10
+
11
+ import { GitFoxError } from './gitfox-api.js';
12
+
13
+ /** Default budget for local repository operations. */
14
+ export const DEFAULT_GIT_TIMEOUT_MS = 120_000;
15
+
16
+ /** Push may need to transfer a large pack. */
17
+ export const PUSH_TIMEOUT_MS = 300_000;
18
+
19
+ /** Upper bound on captured stdout/stderr per invocation. */
20
+ const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
21
+
22
+ /**
23
+ * Resolve the Git executable without a shell lookup.
24
+ *
25
+ * @returns {string} executable path or `git` from PATH.
26
+ */
27
+ function gitBinary() {
28
+ const override = process.env.INFINITY_GIT_BIN;
29
+ return typeof override === 'string' && override.trim() !== '' ? override.trim() : 'git';
30
+ }
31
+
32
+ /**
33
+ * @param {string | Buffer | undefined} value - captured stream content.
34
+ * @param {number} [limit] - maximum characters retained.
35
+ * @returns {string} text for a model/error message.
36
+ */
37
+ function textOf(value, limit = 4000) {
38
+ let text = '';
39
+ if (typeof value === 'string') text = value;
40
+ else if (Buffer.isBuffer(value)) text = value.toString('utf8');
41
+ return text.trim().slice(0, limit);
42
+ }
43
+
44
+ /**
45
+ * @param {any} error - failed child process result.
46
+ * @param {AbortSignal | undefined} signal - caller cancellation.
47
+ * @returns {boolean} whether the operation was cancelled.
48
+ */
49
+ function isAbort(error, signal) {
50
+ return signal?.aborted === true || error?.name === 'AbortError';
51
+ }
52
+
53
+ /**
54
+ * @param {any} error - failed child process result.
55
+ * @returns {boolean} whether the executable is missing.
56
+ */
57
+ function isMissingBinary(error) {
58
+ return error?.code === 'ENOENT';
59
+ }
60
+
61
+ /**
62
+ * @param {any} error - failed child process result.
63
+ * @returns {boolean} whether the child exceeded its budget.
64
+ */
65
+ function isTimeout(error) {
66
+ return error?.killed === true || error?.code === 'ETIMEDOUT';
67
+ }
68
+
69
+ /**
70
+ * Map a Node child-process failure onto a stable GitFoxError.
71
+ *
72
+ * @param {any} error - execFile failure.
73
+ * @param {AbortSignal | undefined} signal - caller cancellation.
74
+ * @param {string} stderr - captured standard error, which the error object may omit.
75
+ * @returns {GitFoxError} mapped error.
76
+ */
77
+ function mapExecFailure(error, signal, stderr = '') {
78
+ if (isAbort(error, signal)) {
79
+ return new GitFoxError('git operation was cancelled', { status: 499, code: 'git-aborted' });
80
+ }
81
+ if (isMissingBinary(error)) {
82
+ return new GitFoxError('git is not installed or not on PATH', {
83
+ status: 503,
84
+ code: 'git-unavailable',
85
+ });
86
+ }
87
+ if (isTimeout(error)) {
88
+ return new GitFoxError('git operation timed out', { status: 504, code: 'git-timeout' });
89
+ }
90
+ const detail = textOf(stderr) || textOf(error?.stderr) || textOf(error?.stdout) || textOf(error?.message);
91
+ return new GitFoxError(detail === '' ? 'git failed' : `git failed: ${detail}`, {
92
+ status: 400,
93
+ code: 'git-failed',
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Run one Git command in a fixed working directory.
99
+ *
100
+ * @param {{ args: string[], cwd: string, env?: Record<string, string>, signal?: AbortSignal, timeoutMs?: number, execFileImpl?: typeof execFile }} options - invocation.
101
+ * @returns {Promise<{ stdout: string, stderr: string }>} captured output.
102
+ * @throws {GitFoxError} when Git cannot be started, times out or exits non-zero.
103
+ */
104
+ export function runGit(options) {
105
+ const {
106
+ args,
107
+ cwd,
108
+ env = {},
109
+ signal,
110
+ timeoutMs = DEFAULT_GIT_TIMEOUT_MS,
111
+ execFileImpl = execFile,
112
+ } = options;
113
+ return new Promise((resolve, reject) => {
114
+ execFileImpl(
115
+ gitBinary(),
116
+ args,
117
+ {
118
+ cwd,
119
+ env: { ...process.env, ...env },
120
+ encoding: 'utf8',
121
+ maxBuffer: MAX_BUFFER_BYTES,
122
+ timeout: timeoutMs,
123
+ windowsHide: true,
124
+ signal,
125
+ },
126
+ (error, stdout, stderr) => {
127
+ if (error !== null && error !== undefined) {
128
+ reject(mapExecFailure(error, signal, stderr));
129
+ return;
130
+ }
131
+ resolve({ stdout, stderr });
132
+ },
133
+ );
134
+ });
135
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Pure parsers and validators for the local Git tool.
3
+ *
4
+ * Kept free of child-process and credential code so the rules can be tested
5
+ * without a repository or a GitFox connection.
6
+ */
7
+ import { GitFoxError } from './gitfox-api.js';
8
+
9
+ /** Maximum accepted branch name length (Git's own practical limit is looser). */
10
+ const MAX_BRANCH_LENGTH = 200;
11
+
12
+ /** Characters Git rejects in a branch name. */
13
+ const INVALID_BRANCH = /[~^:?*[\\]/;
14
+
15
+ /**
16
+ * Parse `git status --porcelain=v1 -z` output.
17
+ *
18
+ * A rename/copy record is followed by the original path, so the parser must
19
+ * consume two NUL-terminated records for one entry.
20
+ *
21
+ * @param {string} raw - NUL-separated porcelain output.
22
+ * @returns {Array<{ status: string, path: string, from?: string }>} entries in Git order.
23
+ */
24
+ export function parsePorcelainZ(raw) {
25
+ const records = raw.split('\0');
26
+ const entries = [];
27
+ for (let index = 0; index < records.length; index += 1) {
28
+ const record = records[index];
29
+ if (record === '' || record.length < 4) continue;
30
+ const status = record.slice(0, 2);
31
+ const path = record.slice(3);
32
+ /** @type {{ status: string, path: string, from?: string }} */
33
+ const entry = { status, path };
34
+ if (status.includes('R') || status.includes('C')) {
35
+ index += 1;
36
+ const from = records[index];
37
+ if (from !== undefined && from !== '') entry.from = from;
38
+ }
39
+ entries.push(entry);
40
+ }
41
+ return entries;
42
+ }
43
+
44
+ /**
45
+ * Parse `git log` output produced with the `LOG_FORMAT` placeholders.
46
+ *
47
+ * @param {string} raw - record-separated log output.
48
+ * @returns {Array<{ sha: string, author: string, email: string, date: string, subject: string }>} commits.
49
+ */
50
+ export function parseLogRecords(raw) {
51
+ return raw
52
+ .split('\x1e')
53
+ .map((record) => record.trimStart())
54
+ .filter((record) => record !== '')
55
+ .map((record) => {
56
+ const [sha, author, email, date, subject = ''] = record.split('\x1f');
57
+ return { sha, author, email, date, subject };
58
+ })
59
+ .filter((commit) => typeof commit.sha === 'string' && commit.sha !== '');
60
+ }
61
+
62
+ /** The pretty format shared by the service and its tests. */
63
+ export const LOG_FORMAT = '%H%x1f%an%x1f%ae%x1f%at%x1f%s%x1e';
64
+
65
+ /**
66
+ * Validate one branch name before it reaches a Git refspec.
67
+ *
68
+ * The check is intentionally conservative: it also rejects Git pathspec/refspec
69
+ * magic (`:`, `@{`, `..`, a leading `-`) so a branch value can never become an
70
+ * option or address another ref.
71
+ *
72
+ * @param {unknown} value - user-supplied branch.
73
+ * @returns {string} trimmed branch name.
74
+ * @throws {GitFoxError} when the name is missing or unsafe.
75
+ */
76
+ export function validateBranchName(value) {
77
+ const branch = typeof value === 'string' ? value.trim() : '';
78
+ const hasControl = [...branch].some((character) => character.charCodeAt(0) <= 0x20);
79
+ const invalid =
80
+ branch === '' ||
81
+ hasControl ||
82
+ branch.length > MAX_BRANCH_LENGTH ||
83
+ branch === 'HEAD' ||
84
+ branch.startsWith('-') ||
85
+ branch.startsWith('/') ||
86
+ branch.endsWith('/') ||
87
+ branch.endsWith('.') ||
88
+ branch.endsWith('.lock') ||
89
+ branch.includes('..') ||
90
+ branch.includes('//') ||
91
+ branch.includes('@{') ||
92
+ INVALID_BRANCH.test(branch);
93
+ if (invalid) {
94
+ throw new GitFoxError(`invalid branch name "${String(value)}"`, { code: 'invalid-branch' });
95
+ }
96
+ return branch;
97
+ }
98
+
99
+ /**
100
+ * Normalize a workspace-relative path accepted by `stage`/`commit`.
101
+ *
102
+ * Rejects absolute paths, traversal, NUL/newline injection, `.git` internals and
103
+ * Git pathspec magic. Every path is passed after `--`, but rejecting magic here
104
+ * keeps one malicious path from addressing files outside the declared scope.
105
+ *
106
+ * @param {unknown} value - user-supplied path.
107
+ * @returns {string} normalized forward-slash path.
108
+ * @throws {GitFoxError} when the path is not a safe relative path.
109
+ */
110
+ export function normalizeRepoPath(value) {
111
+ if (typeof value !== 'string') {
112
+ throw new GitFoxError('path must be a string', { code: 'invalid-path' });
113
+ }
114
+ let text = value.trim().replaceAll('\\', '/');
115
+ while (text.startsWith('./')) text = text.slice(2);
116
+ text = text.replace(/\/+$/, '');
117
+ const invalid =
118
+ text === '' ||
119
+ text.startsWith('/') ||
120
+ text.startsWith(':') ||
121
+ text.includes('\0') ||
122
+ text.includes('\n') ||
123
+ text.includes('\r') ||
124
+ /^[A-Za-z]:\//.test(text);
125
+ const segments = text.split('/');
126
+ const unsafe =
127
+ invalid ||
128
+ segments.includes('..') ||
129
+ segments.includes('.') ||
130
+ segments.includes('.git') ||
131
+ segments.includes('');
132
+ if (unsafe) {
133
+ throw new GitFoxError(`invalid repository path "${value}"`, { code: 'invalid-path' });
134
+ }
135
+ return text;
136
+ }
137
+
138
+ /**
139
+ * Normalize and de-duplicate a list of repository paths.
140
+ *
141
+ * @param {unknown} value - `paths` tool argument.
142
+ * @param {{ required?: boolean }} [options] - whether an empty list is an error.
143
+ * @returns {string[]} normalized unique paths.
144
+ * @throws {GitFoxError} when required and absent/empty.
145
+ */
146
+ export function normalizeRepoPaths(value, options = {}) {
147
+ if (value === undefined || value === null) {
148
+ if (options.required === true) {
149
+ throw new GitFoxError('paths are required for this action', { code: 'invalid-arguments' });
150
+ }
151
+ return [];
152
+ }
153
+ if (!Array.isArray(value)) {
154
+ throw new GitFoxError('paths must be an array of strings', { code: 'invalid-arguments' });
155
+ }
156
+ const paths = [...new Set(value.map((path) => normalizeRepoPath(path)))];
157
+ if (paths.length === 0 && options.required === true) {
158
+ throw new GitFoxError('paths must not be empty', { code: 'invalid-arguments' });
159
+ }
160
+ return paths;
161
+ }
162
+
163
+ /**
164
+ * Build the tokenless Git HTTP remote URL of one GitFox project.
165
+ *
166
+ * The namespace/name pair comes from `parseProject`, so a caller cannot smuggle
167
+ * a host or a path traversal into the URL.
168
+ *
169
+ * @param {string} baseUrl - normalized GitFox base URL.
170
+ * @param {{ namespace: string, name: string }} project - decoded project parts.
171
+ * @returns {string} HTTPS Git remote URL.
172
+ */
173
+ export function projectRemoteUrl(baseUrl, project) {
174
+ const root = baseUrl.replace(/\/+$/, '');
175
+ return `${root}/${encodeURIComponent(project.namespace)}/${encodeURIComponent(project.name)}.git`;
176
+ }