Files
GitNexus/gitnexus/src/storage/git.ts
T
jecanoreandGergő Magyar a500f70d6f feat(analyze): add opt-in --self-commit flag for AGENTS.md/CLAUDE.md churn (#2640)
* feat(analyze): add opt-in --self-commit flag for AGENTS.md/CLAUDE.md churn

Adds a new `--self-commit` flag to `gitnexus analyze`. When passed, any
AGENTS.md/CLAUDE.md changes the run makes (including first-time creation)
are auto-committed, scoped to only those two files (never `git add -A`).
No-ops silently if neither exists, neither changed, or the repo has no
git identity configured — never fails the surrounding analyze run.

Complements #1478 (--no-stats): that flag removes the volatile counts
entirely, this one keeps them but eliminates the dangling working-tree
diff they otherwise leave behind on every run.

Closes #2639.

* fix(analyze): log a warning when --self-commit fails to commit

Addresses review feedback on #2640: the commit step's catch block was
silently swallowing failures (e.g. missing git identity) with no signal
to the user. Logs via the existing pino logger (matching the rest of
the codebase's convention) with the error and the file list, while
still never throwing — analyze must not fail over this.

New test forces a real commit failure (missing identity, with
useConfigOnly + isolated HOME/XDG_CONFIG_HOME/GIT_CONFIG_NOSYSTEM so no
ambient global git config on the CI runner can mask it) and asserts the
warning is captured via logger's _captureLogger test hook.

* fix(analyze): refuse to sweep pre-existing edits into --self-commit

Addresses both state-safety blockers from review round 2 on #2640:

1. selfCommitContextFiles could not distinguish a pre-existing unstaged
   user edit in AGENTS.md/CLAUDE.md from this run's generated stats
   refresh — both just showed up as "the file is dirty" — so a user
   edit sitting in either file got silently swept into the generated
   commit. Fixed by snapshotting each candidate's cleanliness via the
   new snapshotSelfCommitSafety() BEFORE analyze writes to it; only
   files confirmed safe (nonexistent pre-run, i.e. first-time creation,
   or clean pre-run) are ever added/committed. A file already dirty
   pre-run is skipped and logged, never touched.

2. On a failed `git commit` (e.g. missing identity), the preceding
   `git add` had already staged the safe files, and analyze reported
   nothing happened while silently leaving them staged. Fixed with a
   `git reset -- <safe files>` in the commit-failure catch, restoring
   the index to its pre-add state for exactly the files this helper
   staged.

Wired analyze.ts to call snapshotSelfCommitSafety() once before
runFullAnalysis (which is where the actual AGENTS.md/CLAUDE.md write
happens, on both the fast path and the primary run), threading the
result through both existing selfCommitContextFiles() call sites.

New tests: a pre-dirty AGENTS.md is skipped while a clean CLAUDE.md
still commits normally, and a post-add commit failure leaves nothing
staged. Updated all existing selfCommitContextFiles() call sites for
the new required safety-map parameter.

* i18n(cli): add zh-CN translation for --self-commit help text

Addresses magyargergo's follow-up on #2640: --self-commit was missing
from the analyze command's OPTION_DESCRIPTION_KEYS map, so its help
text never went through localizeCliHelp and always rendered in English
regardless of locale. Adds the help.option.analyze.selfCommit key to
both en.ts and zh-CN.ts and wires it into help-i18n.ts, matching the
existing --no-stats/--skills entries.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-25 06:23:26 +01:00

673 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFileSync, execSync } from 'child_process';
import { statSync, existsSync } from 'fs';
import path from 'path';
import os from 'os';
import { logger } from '../core/logger.js';
// Git utilities for repository detection, commit tracking, and diff analysis
const chompGitOutput = (value: Buffer): string => value.toString().replace(/\r?\n$/, '');
/**
* True when the working tree has uncommitted changes that analyze would
* re-index, even at a matching HEAD. Excludes the paths GitNexus writes during
* analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the
* repo-local .agents/ mirror) so its own output never counts as dirty
* (regression vs PR #1233 behavior). The entire .agents/ tree is excluded,
* matching the .claude/ treatment, because the skill mirror writes across
* .agents/skills/ and deeper paths. Conservative on any git failure. Shared
* so `analyze`'s fast-path gate and `status`'s freshness report agree on what
* "dirty" means.
*/
export const isWorkingTreeDirty = (repoPath: string): boolean => {
try {
const out = execFileSync(
'git',
[
'status',
'--porcelain',
'--',
'.',
':(exclude).gitnexus',
':(exclude).gitnexus/**',
':(exclude).claude',
':(exclude).claude/**',
':(exclude).cursor',
':(exclude).cursor/**',
':(exclude)AGENTS.md',
':(exclude)CLAUDE.md',
':(exclude).agents',
':(exclude).agents/**',
],
{
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
encoding: 'utf8',
},
);
return out.trim().length > 0;
} catch {
return true; // conservative on git failure
}
};
/**
* Snapshot, per candidate file, whether it is safe for `selfCommitContextFiles`
* to auto-commit — call this BEFORE `analyze` writes AGENTS.md/CLAUDE.md.
* A file is safe when it does not exist yet (first-time creation, the normal
* case) or is currently clean (`git status --porcelain` reports nothing for
* it). A file that already has an uncommitted user edit is unsafe: without
* this check `selfCommitContextFiles` cannot tell that edit apart from the
* stats refresh `analyze` is about to write, and would silently sweep both
* into one generated-looking commit. Fails closed — a git failure marks the
* file unsafe rather than assuming it's clean. See #2639 review round 2.
*/
export const snapshotSelfCommitSafety = (
repoPath: string,
candidateFiles: string[],
): Map<string, boolean> => {
const safety = new Map<string, boolean>();
for (const name of candidateFiles) {
if (!existsSync(path.join(repoPath, name))) {
safety.set(name, true);
continue;
}
try {
const status = execFileSync('git', ['status', '--porcelain', '--', name], {
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
encoding: 'utf8',
});
safety.set(name, status.trim().length === 0);
} catch {
safety.set(name, false);
}
}
return safety;
};
/**
* Best-effort auto-commit for the AGENTS.md/CLAUDE.md files `analyze --self-commit`
* just (re)wrote. Filters `candidateFiles` down to the ones that actually exist
* under `repoPath` AND were marked safe by `snapshotSelfCommitSafety` — a file
* that already had an uncommitted edit before this run is skipped (logged),
* never swept into the generated commit. Never `git add -A`. `git status
* --porcelain` (not `diff --quiet`) is deliberate: a first-time `analyze` run
* creates AGENTS.md/CLAUDE.md fresh, and untracked files never show up in
* `git diff`, only in `git status` — the same reason `isWorkingTreeDirty`
* above uses `--porcelain`. If `git commit` fails after `git add` already
* staged the safe files (e.g. missing git identity), the staged files are
* reset back to unstaged so the user's index isn't silently left mutated.
* No-ops silently (never throws) when: none of the candidate files exist or
* are safe, none changed, or any git step fails. Must never fail the
* surrounding `analyze` run. See #2639.
*/
export const selfCommitContextFiles = (
repoPath: string,
candidateFiles: string[],
preRunSafety: Map<string, boolean>,
): void => {
const existing = candidateFiles.filter((name) => existsSync(path.join(repoPath, name)));
if (existing.length === 0) return;
const safe = existing.filter((name) => preRunSafety.get(name) === true);
const skippedDirty = existing.filter((name) => preRunSafety.get(name) !== true);
if (skippedDirty.length > 0) {
logger.warn(
{ files: skippedDirty },
'gitnexus: --self-commit skipping file(s) with uncommitted changes from before this analyze run',
);
}
if (safe.length === 0) return;
try {
const status = execFileSync('git', ['status', '--porcelain', '--', ...safe], {
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
encoding: 'utf8',
});
if (status.trim().length === 0) return; // nothing to commit
} catch {
return; // git failed (not a repo, git missing, etc.) — nothing to do
}
try {
execFileSync('git', ['add', '--', ...safe], {
cwd: repoPath,
stdio: 'ignore',
windowsHide: true,
});
} catch (err) {
logger.warn({ err, files: safe }, 'gitnexus: --self-commit failed to stage context files');
return;
}
try {
execFileSync(
'git',
['commit', '-m', 'chore(gitnexus): refresh index stats [skip ci]', '--', ...safe],
{ cwd: repoPath, stdio: 'ignore', windowsHide: true },
);
} catch (err) {
// Commit failed after `git add` already staged `safe` (e.g. missing git
// identity). Restore the index to its pre-add state for exactly those
// files rather than leaving them silently staged — `analyze` reporting
// "success" must not leave the user's index mutated.
try {
execFileSync('git', ['reset', '--', ...safe], {
cwd: repoPath,
stdio: 'ignore',
windowsHide: true,
});
} catch {
/* best-effort restore; nothing more we can do */
}
logger.warn({ err, files: safe }, 'gitnexus: --self-commit failed to commit context files');
}
};
export const isGitRepo = (repoPath: string): boolean => {
try {
execSync('git rev-parse --is-inside-work-tree', {
cwd: repoPath,
stdio: 'ignore',
windowsHide: true,
});
return true;
} catch {
return false;
}
};
export const getCurrentCommit = (repoPath: string): string => {
try {
return execSync('git rev-parse HEAD', {
cwd: repoPath,
// Suppress stderr -- without an explicit stdio option, Node's execSync
// forwards the child's stderr to the parent process (documented behaviour).
// When repoPath is not inside a git worktree, git prints
// "fatal: not a git repository" to stderr, which leaks to the user's
// terminal even though the error is caught here (#1172).
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
.toString()
.trim();
} catch {
return '';
}
};
/**
* Get a stable canonical identifier for the repo's `origin` remote, if any.
*
* Used to fingerprint two on-disk clones as the same logical repository
* (prevents silent graph drift across sibling clones — see #2054). `path` alone
* is unreliable: worktrees, "clean clone for indexing" hygiene, and
* multi-agent workspaces routinely have the same repo at multiple
* absolute paths. The remote URL is the only on-disk signal that
* survives those conventions.
*
* Normalisation strategy:
* - Strip a trailing `.git` so `https://x/y` and `https://x/y.git` collapse.
* - Strip a trailing `/` for the same reason.
* - `git@github.com:foo/bar` and `https://github.com/foo/bar` are
* intentionally NOT collapsed — they are different remotes from
* git's perspective and we don't want to assert equivalence.
* - Lower-case the host portion so `GitHub.com` and `github.com`
* don't desync; preserves case in path because some hosts
* (Bitbucket Server) treat repo paths case-sensitively.
*
* Returns `undefined` when there is no origin remote, the directory
* isn't a git repo, or git itself isn't available.
*/
export const getRemoteUrl = (repoPath: string): string | undefined => {
let raw: string;
try {
raw = execSync('git config --get remote.origin.url', {
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
.toString()
.trim();
} catch {
return undefined;
}
if (!raw) return undefined;
let normalised = raw.replace(/\/$/, '').replace(/\.git$/, '');
// Lower-case the host segment of `scheme://[user@]host[:port]/...`
// and the host segment of `git@host:owner/repo` SCP form.
// SSH user-segment regex deliberately accepts the common
// `git@`/`<alnum>-_@` cases. Less common usernames (e.g. with
// dots) fall through to the URL-form branch — they will simply
// not get host-case normalisation, which is acceptable: the raw
// `git config` output is still a valid fingerprint, just slightly
// less collapsible across host casings.
const sshMatch = normalised.match(/^(git@|[a-zA-Z0-9_-]+@)([^:/]+)(:.+)$/);
if (sshMatch) {
normalised = `${sshMatch[1]}${sshMatch[2].toLowerCase()}${sshMatch[3]}`;
} else {
const urlMatch = normalised.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/]+)(\/.*)?$/);
if (urlMatch) {
normalised = `${urlMatch[1]}${urlMatch[2].toLowerCase()}${urlMatch[3] ?? ''}`;
}
}
return normalised;
};
/**
* Find the git repository root from any path inside the repo
*/
export const getGitRoot = (fromPath: string): string | null => {
const resolved = path.resolve(fromPath);
// Avoid git rev-parse --show-toplevel trimming trailing spaces from the
// repository root on Windows; callers that need identity keys canonicalize
// this value with realpath before comparing it.
if (hasGitDir(resolved)) return resolved;
try {
const raw = chompGitOutput(
execSync('git rev-parse --show-toplevel', {
cwd: fromPath,
// Suppress stderr -- see getCurrentCommit comment and #1172.
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
}),
);
// On Windows, git returns /d/Projects/Foo — path.resolve normalizes to D:\Projects\Foo
return path.resolve(raw);
} catch {
return null;
}
};
/**
* Get the *canonical* repository root, dereferencing git worktrees.
*
* Unlike `getGitRoot` (which uses `git rev-parse --show-toplevel` and
* returns the WORKTREE's root when called inside a linked worktree),
* this uses `git rev-parse --git-common-dir` — the shared `.git`
* directory, identical for the main checkout and every linked
* worktree — and returns its parent.
*
* Why it matters (#1259): when `gitnexus analyze` runs inside a
* worktree (e.g. `/repo/wt-feature/`), deriving `repoName` from
* `path.basename(getGitRoot(cwd))` registers the project under the
* worktree's directory slug (`wt-feature`) instead of the canonical
* repo's basename (`repo`). Each worktree then re-registers as a
* "different" project, AGENTS.md is rewritten with the wrong MCP URI,
* and Claude-Code-style worktree workflows silently accumulate
* duplicate registry entries.
*
* Returns `null` when the path is not inside a git repository or
* `git` is not available, so callers can chain safely:
* `getCanonicalRepoRoot(p) ?? getGitRoot(p) ?? p`.
*
* `--path-format=absolute` is required because `--git-common-dir`
* returns a path *relative to cwd* by default (e.g. `../.git` when
* called from a worktree), which would resolve to the wrong absolute
* path if the caller later resolved it from a different directory.
*/
export const getCanonicalRepoRoot = (fromPath: string): string | null => {
try {
const commonDir = chompGitOutput(
execSync('git rev-parse --path-format=absolute --git-common-dir', {
cwd: fromPath,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
}),
);
if (!commonDir) return null;
// Common dir is `<repo>/.git` for both the main checkout and all
// linked worktrees. Its parent is the canonical repo root.
return path.dirname(path.resolve(commonDir));
} catch {
return null;
}
};
// getGitInfoExcludePath/getCoreExcludesFilePath are called once per repo
// PER language/contract extractor during group sync (#2606) — an N-repo
// group fans out to 6+ extractors each calling these, so an uncached
// execSync per call turns into O(extractors × repos) blocking subprocess
// spawns. Both resolve to the same value for the same fromPath for the
// life of the process (git config/exclude files don't change mid-run), so
// memoize by fromPath. ponytail: process-lifetime cache, never invalidated
// — fine for one-shot CLI runs; the long-lived MCP server would need a
// TTL or explicit invalidation if a user edits core.excludesFile mid-session.
const gitInfoExcludePathCache = new Map<string, string | null>();
const coreExcludesFilePathCache = new Map<string, string>();
/**
* Path to the repo's `$GIT_COMMON_DIR/info/exclude` file — git's own
* per-repo, untracked exclude list (same tier as `.gitignore` in
* precedence, but never committed, so it works even when the caller has
* no write access to the repo's tracked content). Shared across every
* linked worktree of a repo, matching git's own resolution (#2606).
*
* Returns `null` when `fromPath` is not inside a git repository or `git`
* is unavailable; callers should treat that the same as "no file".
*/
export const getGitInfoExcludePath = (fromPath: string): string | null => {
const cached = gitInfoExcludePathCache.get(fromPath);
if (cached !== undefined) return cached;
let result: string | null;
try {
const commonDir = chompGitOutput(
execSync('git rev-parse --path-format=absolute --git-common-dir', {
cwd: fromPath,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
}),
);
result = commonDir ? path.join(path.resolve(commonDir), 'info', 'exclude') : null;
} catch {
result = null;
}
gitInfoExcludePathCache.set(fromPath, result);
return result;
};
/**
* Path to git's own global, all-repos ignore file: the value of
* `core.excludesFile` (any config scope — system/global/local, resolved
* the same way `git` itself would from `fromPath`), or git's documented
* default of `$XDG_CONFIG_HOME/git/ignore` when unset (gitignore(5)).
* Lowest-precedence source, mirroring git's own behavior (#2606).
*
* Never throws: an unset key or unavailable `git` falls through to the
* default path, which is always computable without `git`.
*/
export const getCoreExcludesFilePath = (fromPath: string): string => {
const cached = coreExcludesFilePathCache.get(fromPath);
if (cached !== undefined) return cached;
let result: string | undefined;
try {
const configured = chompGitOutput(
execSync('git config --get --type=path core.excludesFile', {
cwd: fromPath,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
}),
);
if (configured) result = configured;
} catch {
// Unset, or git unavailable — fall through to git's documented default.
}
if (!result) {
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
result = path.join(xdgConfigHome, 'git', 'ignore');
}
coreExcludesFilePathCache.set(fromPath, result);
return result;
};
/**
* Resolve `fromPath` to the directory whose basename should drive the
* registry name (#1259) — the *identity root*. Three outcomes:
*
* 1. `fromPath` IS the canonical checkout root → returns it unchanged.
* 2. `fromPath` is a linked-worktree root (has its own `.git` entry, but
* `git rev-parse --git-common-dir` points at a different `.git`) →
* returns the canonical repo root.
* 3. `fromPath` is anything else — an arbitrary subdir under a git repo,
* a non-git folder, a `--skip-git` subdir of an unrelated parent
* checkout — returns `fromPath` unchanged.
*
* Why not just use `getCanonicalRepoRoot` directly? Because `git rev-parse
* --git-common-dir` resolves the same canonical root for ANY path inside
* a git repo, including unrelated subdirs. Using it for registry-name
* derivation would silently re-key a `--skip-git` subdir analyze under
* the parent git's basename, defeating the user's `--skip-git` intent
* (regressing the #1232/#1233 fix). The "is this path a tree root"
* gate confines the canonical-root collapse to exactly the cases where
* #1259 matters: main checkouts and linked worktrees.
*/
export const resolveRepoIdentityRoot = (fromPath: string): string => {
const resolved = path.resolve(fromPath);
const canonical = getCanonicalRepoRoot(resolved);
if (!canonical) return resolved; // non-git → use as-is
if (canonical === resolved) return canonical; // canonical checkout
if (hasGitDir(resolved)) return canonical; // linked worktree (has .git file)
return resolved; // arbitrary subdir under a git repo → preserve as-is
};
/**
* Find a git root by checking only `.git` entries on the ancestor chain.
*
* Unlike `getGitRoot`, this does not spawn `git`, so MCP can cheaply decide
* whether a launch cwd is a worktree before running any subprocess there.
*/
export const findGitRootByDotGit = (fromPath: string): string | null => {
let current = path.resolve(fromPath);
try {
if (!statSync(current).isDirectory()) {
current = path.dirname(current);
}
} catch {
return null;
}
while (true) {
try {
statSync(path.join(current, '.git'));
return current;
} catch {
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
};
/**
* Check whether a directory contains a .git entry (file or folder).
*
* This is intentionally a simple filesystem check rather than running
* `git rev-parse`, so it works even when git is not installed or when
* the directory is a git-worktree root (which has a .git file, not a
* directory). Use `isGitRepo` for a definitive git answer.
*
* @param dirPath - Absolute path to the directory to inspect.
* @returns `true` when `.git` is present, `false` otherwise.
*/
export const hasGitDir = (dirPath: string): boolean => {
try {
statSync(path.join(dirPath, '.git'));
return true;
} catch {
return false;
}
};
/**
* Read `remote.origin.url` from a git repository, or `null` if not a
* git repo, has no `origin` remote, or git is unavailable.
*
* Used by the registry-name inference path (#979) to recover a
* meaningful repo name when `path.basename(repoPath)` is generic
* (e.g. monorepo subprojects, git worktrees, Gas-Town-style
* `<rig>/refinery/rig/` layouts).
*/
export const getRemoteOriginUrl = (repoPath: string): string | null => {
try {
const url = execSync('git config --get remote.origin.url', {
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
.toString()
.trim();
return url || null;
} catch {
return null;
}
};
/**
* Best-effort detection of the repository's default branch (#243).
*
* Reads `git symbolic-ref --short refs/remotes/origin/HEAD`, which resolves to
* the short ref `origin/<branch>` that the local `origin/HEAD` points at, and
* strips the `origin/` prefix. This is a purely local lookup — it never makes a
* network call. Returns `null` when there is no git repo, no `origin` remote, no
* `origin/HEAD` (e.g. it was never set by clone, or the repo is detached), or
* git is unavailable, so callers can fall back to a configured/default branch.
*/
export const getDefaultBranch = (repoPath: string): string | null => {
try {
const ref = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
cwd: repoPath,
// Suppress stderr -- see getCurrentCommit comment and #1172. Without it,
// git prints "fatal: ref refs/remotes/origin/HEAD is not a symbolic ref"
// to the user's terminal on repos that never set origin/HEAD.
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
.toString()
.trim();
if (!ref) return null;
return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref;
} catch {
return null;
}
};
/**
* Name of the currently checked-out branch, or `null` when HEAD is detached
* (CI checkouts, `git checkout <sha>`), the directory is not a git worktree, or
* git is unavailable.
*
* `git rev-parse --abbrev-ref HEAD` prints the literal `HEAD` for a detached
* checkout. We map that (and empty output) to `null` so callers fall back to the
* flat/default index rather than ever creating a branch literally named
* "HEAD" (#2106).
*/
export const getCurrentBranch = (repoPath: string): string | null => {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: repoPath,
// Suppress stderr -- see getCurrentCommit comment and #1172.
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
.toString()
.trim();
if (!branch || branch === 'HEAD') return null;
return branch;
} catch {
return null;
}
};
/**
* Sanitize a repository name to prevent argument injection and ensure
* cross-platform filesystem compatibility.
*
* 1. Strips leading dashes to prevent git command-line argument injection
* (e.g., --upload-pack=evil).
* 2. Replaces characters that are unsafe for directory names across
* platforms (Windows/macOS/Linux) with underscores.
* 3. Blocks path traversal segments ("." and "..") and Windows reserved
* names (e.g., CON, NUL) to prevent directory escape.
*/
export const sanitizeRepoName = (name: string): string => {
// 1. Prevent argument injection by stripping leading dashes.
// 2. Remove characters that are not alphanumerics, dots, underscores, or dashes.
const sanitized = name.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_');
// 3. Block path traversal segments and Windows reserved names.
// Windows reserved names like CON, PRN, AUX, NUL, COM1-9, LPT1-9 cannot
// be used as directory names on Windows even if they have an extension.
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
if (!sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized)) {
return 'unknown';
}
return sanitized;
};
/**
* Parse a repository name out of a git remote URL. Handles common shapes
* including SSH (git@host:owner/repo.git) and HTTPS (https://host/owner/repo.git).
*
* Returns a sanitized, filesystem-safe name or null if no name could be inferred.
* Returning null (rather than 'unknown') allows callers to use ?? null-coalescing
* for fallbacks without risk of registry collisions on 'unknown'.
*/
export const parseRepoNameFromUrl = (url: string | null | undefined): string | null => {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
// Strip trailing slashes without a regex to avoid polynomial-ReDoS on
// pathological inputs like `https://x.com/y` + '/'.repeat(1e6).
let end = trimmed.length;
while (end > 0 && trimmed.charCodeAt(end - 1) === 47 /* '/' */) end--;
let cleaned = trimmed.slice(0, end);
// Strip trailing .git (case-insensitive)
if (cleaned.toLowerCase().endsWith('.git')) {
cleaned = cleaned.slice(0, -4);
}
// Last path segment, handling colons for SSH URLs and path traversal.
// Split on both / and : to consistently extract the last part.
const candidate = cleaned.split(/[/:]/).pop() || '';
if (!candidate) return null;
const safe = sanitizeRepoName(candidate);
return safe === 'unknown' ? null : safe;
};
/**
* Convenience wrapper: derive a registry-friendly name from the repo's
* `origin` remote, or `null` when it cannot be inferred.
*/
export const getInferredRepoName = (repoPath: string): string | null => {
return parseRepoNameFromUrl(getRemoteOriginUrl(repoPath));
};
export interface DiffHunk {
startLine: number;
endLine: number;
}
export interface FileDiff {
filePath: string;
hunks: DiffHunk[];
}
/**
* Parse unified diff output (with -U0) into per-file hunk ranges.
* Extracts the new-file line ranges from @@ hunk headers.
*/
export function parseDiffHunks(diffOutput: string): FileDiff[] {
const files: FileDiff[] = [];
let current: FileDiff | null = null;
for (const line of diffOutput.split('\n')) {
if (line.startsWith('+++ b/')) {
current = { filePath: line.slice(6), hunks: [] };
files.push(current);
} else if (line.startsWith('@@') && current) {
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
if (match) {
const start = parseInt(match[1], 10);
const count = match[2] !== undefined ? parseInt(match[2], 10) : 1;
if (count > 0) {
current.hunks.push({ startLine: start, endLine: start + count - 1 });
}
}
}
}
return files;
}