Path: blob/main/src/vs/platform/agentHost/node/agentHostGitService.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as cp from 'child_process';6import { URI } from '../../../base/common/uri.js';7import { VSBuffer } from '../../../base/common/buffer.js';8import { generateUuid } from '../../../base/common/uuid.js';9import { INativeEnvironmentService } from '../../environment/common/environment.js';10import { IFileService } from '../../files/common/files.js';11import { createDecorator } from '../../instantiation/common/instantiation.js';12import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js';13import { buildGitBlobUri } from './gitDiffContent.js';1415export const IAgentHostGitService = createDecorator<IAgentHostGitService>('agentHostGitService');1617export interface IAgentHostGitService {18readonly _serviceBrand: undefined;19isInsideWorkTree(workingDirectory: URI): Promise<boolean>;20getCurrentBranch(workingDirectory: URI): Promise<string | undefined>;21getDefaultBranch(workingDirectory: URI): Promise<string | undefined>;22getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise<string[]>;23getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined>;24getWorktreeRoots(workingDirectory: URI): Promise<URI[]>;25addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise<void>;26/**27* Adds a worktree for an existing branch (no `-b`). Used when restoring28* a worktree whose branch was preserved (e.g. unarchiving a session29* whose worktree was previously cleaned up on archive).30*/31addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise<void>;32removeWorktree(repositoryRoot: URI, worktree: URI): Promise<void>;33/**34* Returns true when the named branch exists in the repository35* (`refs/heads/<branchName>` resolves). Used by archive cleanup to36* confirm the branch is preserved before deleting the worktree, and by37* the unarchive path to confirm the branch is still around before38* recreating the worktree.39*/40branchExists(repositoryRoot: URI, branchName: string): Promise<boolean>;41/**42* Returns true when the working tree has any tracked, staged, or43* untracked changes. Used by archive cleanup to skip removing a44* worktree that still contains uncommitted work.45*/46hasUncommittedChanges(workingDirectory: URI): Promise<boolean>;47/**48* Computes the {@link ISessionGitState} for the working directory by49* shelling out to `git`. Returns undefined if the directory is not a50* git work tree. Called on session open and after each turn completes51* so the UI always reflects current branch/remote/change state.52*/53getSessionGitState(workingDirectory: URI): Promise<ISessionGitState | undefined>;5455/**56* Computes per-file diffs for the session by shelling out to `git57* diff --raw --numstat --diff-filter=ADMR -z` against the merge base of58* the current branch and {@link IComputeSessionFileDiffsOptions.baseBranch}59* (or `HEAD` if no base branch is available). When the working tree has60* untracked files, the diff is computed via a temp index so the61* untracked content is included.62*63* Returns `undefined` when {@link workingDirectory} is not a git work64* tree, so callers can fall back to other diff sources.65*66* Each returned {@link ISessionFileDiff} has its `before.content` set to67* a `git-blob:` URI ({@link buildGitBlobUri}); `after.content` is a68* `file:` URI on the working-tree path. Adds and deletes drop the69* missing side.70*/71computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise<readonly ISessionFileDiff[] | undefined>;7273/**74* Reads a single git blob via `git show <sha>:<repoRelativePath>` from75* the given working directory. Returns `undefined` when the blob does76* not exist or the directory is not a git work tree.77*/78showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise<VSBuffer | undefined>;79}8081/**82* Provider-agnostic session-database metadata key under which agents83* persist the branch they want git-driven diffs anchored to. Read by84* {@link AgentSideEffects} when computing per-session file diffs; absent85* value means the diff falls back to anchoring at HEAD.86*/87export const META_DIFF_BASE_BRANCH = 'agentHost.diffBaseBranch';8889/** Options for {@link IAgentHostGitService.computeSessionFileDiffs}. */90export interface IComputeSessionFileDiffsOptions {91/**92* The session URI, used as the authority of the produced93* `git-blob:` URIs so the resolver can find the session's working94* directory.95*/96readonly sessionUri: string;97/**98* The branch to diff against. Typically the worktree's start-point99* branch (for worktree sessions) or the repository's default branch.100* When undefined or unresolvable, the diff is taken against `HEAD`,101* which surfaces uncommitted work but no committed-on-branch work.102*/103readonly baseBranch?: string;104}105106function getCommonBranchPriority(branch: string): number {107if (branch === 'main') {108return 0;109}110if (branch === 'master') {111return 1;112}113return 2;114}115116export function getBranchCompletions(branches: readonly string[], options?: { readonly query?: string; readonly limit?: number }): string[] {117const normalizedQuery = options?.query?.toLowerCase();118const filtered = normalizedQuery119? branches.filter(branch => branch.toLowerCase().includes(normalizedQuery))120: [...branches];121122filtered.sort((a, b) => getCommonBranchPriority(a) - getCommonBranchPriority(b));123return options?.limit ? filtered.slice(0, options.limit) : filtered;124}125126export class AgentHostGitService implements IAgentHostGitService {127declare readonly _serviceBrand: undefined;128129constructor(130@IFileService private readonly _fileService: IFileService,131@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,132) { }133134async isInsideWorkTree(workingDirectory: URI): Promise<boolean> {135return (await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']))?.trim() === 'true';136}137138async getCurrentBranch(workingDirectory: URI): Promise<string | undefined> {139return (await this._runGit(workingDirectory, ['branch', '--show-current']))?.trim()140|| (await this._runGit(workingDirectory, ['rev-parse', '--short', 'HEAD']))?.trim()141|| undefined;142}143144async getDefaultBranch(workingDirectory: URI): Promise<string | undefined> {145// Try to read the default branch from the remote HEAD reference146const remoteRef = (await this._runGit(workingDirectory, ['symbolic-ref', 'refs/remotes/origin/HEAD']))?.trim();147if (remoteRef) {148if (!remoteRef.startsWith('refs/remotes/origin/')) {149return remoteRef;150}151152const branch = remoteRef.substring('refs/remotes/origin/'.length);153// Check whether a local branch exists; if not, use the remote-tracking ref154// so that 'git worktree add ... <startPoint>' resolves correctly.155const hasLocalBranch = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== undefined;156return hasLocalBranch ? branch : `origin/${branch}`;157}158return undefined;159}160161async getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise<string[]> {162const args = ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate'];163args.push('refs/heads');164165const output = await this._runGit(workingDirectory, args);166if (!output) {167return [];168}169const branches = output.split(/\r?\n/g).map(line => line.trim()).filter(branch => branch.length > 0);170return getBranchCompletions(branches, options);171}172173async getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined> {174const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim();175return repositoryRootPath ? URI.file(repositoryRootPath) : undefined;176}177178async getWorktreeRoots(workingDirectory: URI): Promise<URI[]> {179const output = await this._runGit(workingDirectory, ['worktree', 'list', '--porcelain']);180if (!output) {181return [];182}183return output.split(/\r?\n/g)184.filter(line => line.startsWith('worktree '))185.map(line => URI.file(line.substring('worktree '.length)));186}187188async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise<void> {189await this._runGit(repositoryRoot, ['worktree', 'add', '-b', branchName, worktree.fsPath, startPoint], { timeout: 30_000, throwOnError: true });190}191192async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise<void> {193await this._runGit(repositoryRoot, ['worktree', 'add', worktree.fsPath, branchName], { timeout: 30_000, throwOnError: true });194}195196async removeWorktree(repositoryRoot: URI, worktree: URI): Promise<void> {197await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 30_000, throwOnError: true });198}199200async branchExists(repositoryRoot: URI, branchName: string): Promise<boolean> {201// `show-ref --verify --quiet` exits 0 when the ref exists and 1 otherwise.202// `_runGit` returns undefined on non-zero exit, so `!== undefined` is the existence signal.203const output = await this._runGit(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);204return output !== undefined;205}206207async hasUncommittedChanges(workingDirectory: URI): Promise<boolean> {208const output = await this._runGit(workingDirectory, ['status', '--porcelain']);209return !!output && output.trim().length > 0;210}211212async computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise<readonly ISessionFileDiff[] | undefined> {213// Bail fast if not inside a git work tree so callers can fall back214// to other diff sources.215const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']);216if (inside?.trim() !== 'true') {217return undefined;218}219220// All git invocations run from the working tree's repository root so221// `--raw` paths are repo-relative — that's what `git show <sha>:<path>`222// expects when we resolve `git-blob:` URIs later.223const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim();224if (!repositoryRootPath) {225return undefined;226}227const repositoryRoot = URI.file(repositoryRootPath);228229// Resolve the merge-base commit. With a base branch, this is230// `merge-base HEAD <base>` so the diff stays anchored even when the231// base branch advances. Without one, fall back to HEAD itself, which232// surfaces uncommitted work but no committed-on-branch work — the233// best we can do without context. For empty repos with no HEAD, fall234// back to the well-known empty-tree object.235let mergeBaseCommit: string | undefined;236if (options.baseBranch) {237mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', options.baseBranch]))?.trim();238}239if (!mergeBaseCommit) {240mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim();241}242if (!mergeBaseCommit) {243mergeBaseCommit = EMPTY_TREE_OBJECT;244}245246// Detect whether the working tree has any untracked files. If so we247// have to use the temp-index trick so the untracked content is248// included in `--cached --raw` output; otherwise a plain `git diff`249// is sufficient and avoids the temp-dir overhead.250const statusOut = await this._runGit(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);251const untracked = parseUntrackedPaths(statusOut);252253let rawDiffOutput: string | undefined;254if (untracked.length === 0) {255rawDiffOutput = await this._runGit(repositoryRoot, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z', mergeBaseCommit, '--']);256} else {257rawDiffOutput = await this._runWithTempIndex(repositoryRoot, mergeBaseCommit);258}259260if (rawDiffOutput === undefined) {261return undefined;262}263264return parseGitDiffRawNumstat(rawDiffOutput, repositoryRoot, options.sessionUri, mergeBaseCommit);265}266267private async _runWithTempIndex(repositoryRoot: URI, mergeBaseCommit: string): Promise<string | undefined> {268// Build a throwaway index so we can stage the entire working tree269// (including untracked files) without disturbing the user's real270// index. `read-tree HEAD` seeds it; in empty repos that fails so we271// fall back to the empty tree, leaving everything as "added".272const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-git-diff-${generateUuid()}`);273await this._fileService.createFolder(tempDir);274// `GIT_INDEX_FILE` is consumed by the `git` subprocess so it must be275// a real OS path string, not a URI.276const indexFile = URI.joinPath(tempDir, 'index').fsPath;277const env: Record<string, string> = { GIT_INDEX_FILE: indexFile };278// GVFS (Virtual File System) repos use a hook that acquires a lock around279// git commands. Setting COMMAND_HOOK_LOCK=1 prevents the temp-index280// operations from blocking the main working-tree lock. This mirrors what281// the extension's `buildTempIndexEnv` does for the same reason.282env.COMMAND_HOOK_LOCK = '1';283try {284const seeded = await this._runGit(repositoryRoot, ['read-tree', 'HEAD'], { env });285if (seeded === undefined) {286// Empty repo (no HEAD yet) - `read-tree` of the empty tree always succeeds.287await this._runGit(repositoryRoot, ['read-tree', EMPTY_TREE_OBJECT], { env });288}289// Stage every change in the working tree (modified, deleted,290// untracked, renamed). `add -A` plus an explicit `:/` pathspec291// covers the entire repo from any cwd.292await this._runGit(repositoryRoot, ['add', '-A', '--', ':/'], { env });293return await this._runGit(repositoryRoot, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', '-z', mergeBaseCommit, '--'], { env });294} finally {295try { await this._fileService.del(tempDir, { recursive: true, useTrash: false }); } catch { /* best-effort */ }296}297}298299async showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise<VSBuffer | undefined> {300// Validate sha before passing it to git. `git show <sha>:<path>` parses301// its argument as a revision, so an attacker-controlled sha that starts302// with `-` could inject options, and a non-hex value could resolve to303// commit could resolve to surprising refs. Object names are 4-64 lowercase hex chars.304if (!/^[0-9a-f]{4,64}$/.test(sha)) {305return undefined;306}307const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']);308if (inside?.trim() !== 'true') {309return undefined;310}311// `git show` exits non-zero when the path didn't exist at that312// commit; `_runGit` swallows that into `undefined` which is exactly313// the contract callers want.314return new Promise((resolve) => {315cp.execFile('git', ['show', `${sha}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => {316if (error) {317resolve(undefined);318return;319}320resolve(VSBuffer.wrap(stdout as Buffer));321});322});323}324325async getSessionGitState(workingDirectory: URI): Promise<ISessionGitState | undefined> {326return this._computeSessionGitState(workingDirectory);327}328329private async _computeSessionGitState(workingDirectory: URI): Promise<ISessionGitState | undefined> {330// Bail fast if not inside a git work tree.331const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']);332if (inside?.trim() !== 'true') {333return undefined;334}335336// Run all probes in parallel. Each handles its own errors and returns337// undefined on failure so we can populate fields independently.338const [339statusOutput,340remotesOutput,341defaultBranchRef,342] = await Promise.all([343this._runGit(workingDirectory, ['status', '-b', '--porcelain=v2']),344this._runGit(workingDirectory, ['remote', '-v']),345this._runGit(workingDirectory, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']),346]);347348const status = parseGitStatusV2(statusOutput);349const hasGitHubRemote = parseHasGitHubRemote(remotesOutput);350const baseBranchName = parseDefaultBranchRef(defaultBranchRef);351352// `git status -b --porcelain=v2` only emits ahead/behind counts when the353// branch has an upstream tracking ref. For agent-host worktrees the354// branch is typically created locally with no upstream, so the user can355// have committed work that we'd otherwise report as 0 outgoing changes356// and the "Create PR" button would never appear. Fall back to counting357// commits relative to the base branch — that matches what the user358// actually cares about for "is there work to PR?".359let outgoingChanges = status.outgoingChanges;360if (outgoingChanges === undefined && baseBranchName && status.branchName && status.branchName !== baseBranchName) {361const ahead = await this._runGit(workingDirectory, ['rev-list', '--count', `${baseBranchName}..HEAD`]);362const parsed = ahead === undefined ? NaN : Number(ahead.trim());363if (Number.isFinite(parsed)) {364outgoingChanges = parsed;365}366}367368const result: ISessionGitState = {369hasGitHubRemote,370branchName: status.branchName,371baseBranchName,372upstreamBranchName: status.upstreamBranchName,373incomingChanges: status.incomingChanges,374outgoingChanges,375uncommittedChanges: status.uncommittedChanges,376};377// Strip undefined fields so the resulting object is the same regardless378// of which probes succeeded — easier to compare in tests.379return stripUndefined(result);380}381382private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean; readonly env?: Record<string, string>; readonly maxBuffer?: number }): Promise<string | undefined> {383return new Promise((resolve, reject) => {384const env = options?.env ? { ...process.env, ...options.env } : undefined;385// Default maxBuffer is 32MB — Node's default is ~1MB, which is386// easy to exceed for diff output in large repos. Exceeding it387// causes execFile to error and we'd silently drop the diff.388cp.execFile('git', [...args], { cwd: workingDirectory.fsPath, timeout: options?.timeout ?? 5000, env, maxBuffer: options?.maxBuffer ?? 32 * 1024 * 1024 }, (error, stdout, stderr) => {389if (error) {390if (options?.throwOnError) {391reject(new Error(stderr || error.message));392return;393}394resolve(undefined);395return;396}397resolve(stdout);398});399});400}401}402403/**404* The well-known SHA-1 of git's empty tree, used as a fallback when a405* repository has no commits (no `HEAD` to read into the temp index).406*/407export const EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';408409/**410* Parses NUL-separated `git status --porcelain=v1 -z --untracked-files=all`411* output and returns the repo-relative paths of untracked entries (status412* `??`). Other entries are ignored; we only need to know whether any413* untracked files exist to decide whether to use the temp-index path.414*415* Exported for tests.416*/417export function parseUntrackedPaths(output: string | undefined): string[] {418if (!output) {419return [];420}421const result: string[] = [];422const segments = output.split('\x00');423for (let i = 0; i < segments.length; i++) {424const seg = segments[i];425if (!seg) { continue; }426// Each entry is "XY <path>"; for renames v1 emits a second NUL-separated427// "from" path that we have to skip. We only care about untracked here.428const status = seg.substring(0, 2);429const path = seg.substring(3);430if (status === '??') {431result.push(path);432} else if (status[0] === 'R' || status[0] === 'C') {433// Skip the "from" path for renames/copies.434i++;435}436}437return result;438}439440/**441* Parses combined `--raw --numstat -z` output produced by442* {@link IAgentHostGitService.computeSessionFileDiffs} and converts each443* change into an {@link ISessionFileDiff} ready for the protocol.444*445* The combined NUL-separated stream alternates between `--raw` segments446* (start with `:`) and `--numstat` segments. For renames the raw segment447* is followed by two extra path segments (old, new); the numstat segment448* has an empty path field followed by old/new path segments.449*450* Exported for tests.451*/452export function parseGitDiffRawNumstat(output: string, repositoryRoot: URI, sessionUri: string, mergeBaseCommit: string): ISessionFileDiff[] {453const segments = output.split('\x00');454const changes: { kind: FileEditKind; oldPath?: string; newPath?: string }[] = [];455const numStats = new Map<string, { added: number; removed: number }>();456457let i = 0;458while (i < segments.length) {459const segment = segments[i++];460if (!segment) { continue; }461462if (segment.startsWith(':')) {463// Raw line: ":<srcMode> <dstMode> <srcSha> <dstSha> <status>"464// followed by NUL-separated path(s).465const fields = segment.split(' ');466const status = fields[4] ?? '';467const path1 = segments[i++];468if (!path1) { continue; }469470switch (status[0]) {471case 'A':472changes.push({ kind: FileEditKind.Create, newPath: path1 });473break;474case 'M':475changes.push({ kind: FileEditKind.Edit, oldPath: path1, newPath: path1 });476break;477case 'D':478changes.push({ kind: FileEditKind.Delete, oldPath: path1 });479break;480case 'R': {481const path2 = segments[i++];482if (!path2) { continue; }483changes.push({ kind: FileEditKind.Rename, oldPath: path1, newPath: path2 });484break;485}486default:487break;488}489} else {490// Numstat line: "<added>\t<removed>\t<path>" or, for renames,491// "<added>\t<removed>\t" followed by NUL-separated old/new paths.492const [addedStr, removedStr, filePath] = segment.split('\t');493let key: string;494if (filePath === '' || filePath === undefined) {495const oldPath = segments[i++];496const newPath = segments[i++];497key = newPath ?? oldPath ?? '';498} else {499key = filePath;500}501if (!key) { continue; }502numStats.set(key, {503added: addedStr === '-' ? 0 : Number(addedStr) || 0,504removed: removedStr === '-' ? 0 : Number(removedStr) || 0,505});506}507}508509return changes.map(change => {510const stats = numStats.get(change.newPath ?? change.oldPath ?? '');511const hasBefore = change.kind !== FileEditKind.Create;512const hasAfter = change.kind !== FileEditKind.Delete;513return {514...(hasBefore && change.oldPath ? {515before: {516uri: URI.joinPath(repositoryRoot, change.oldPath).toString(),517content: { uri: buildGitBlobUri(sessionUri, mergeBaseCommit, change.oldPath) },518},519} : {}),520...(hasAfter && change.newPath ? {521after: {522uri: URI.joinPath(repositoryRoot, change.newPath).toString(),523content: { uri: URI.joinPath(repositoryRoot, change.newPath).toString() },524},525} : {}),526diff: { added: stats?.added ?? 0, removed: stats?.removed ?? 0 },527};528});529}530531/**532* Parses output of `git status -b --porcelain=v2`. The format is documented533* at https://git-scm.com/docs/git-status. We care about a few header lines:534*535* # branch.head <name>536* # branch.upstream <name>537* # branch.ab +<ahead> -<behind>538*539* and the count of non-header lines (one per changed entry).540*541* Exported for tests.542*/543export function parseGitStatusV2(output: string | undefined): {544branchName?: string;545upstreamBranchName?: string;546outgoingChanges?: number;547incomingChanges?: number;548uncommittedChanges?: number;549} {550if (!output) {551return {};552}553let branchName: string | undefined;554let upstreamBranchName: string | undefined;555let outgoingChanges: number | undefined;556let incomingChanges: number | undefined;557let uncommittedChanges = 0;558for (const rawLine of output.split(/\r?\n/g)) {559const line = rawLine.trimEnd();560if (!line) { continue; }561if (line.startsWith('# branch.head ')) {562const head = line.substring('# branch.head '.length).trim();563// `(detached)` is what git emits for a detached HEAD. Treat as no branch.564branchName = head === '(detached)' ? undefined : head;565} else if (line.startsWith('# branch.upstream ')) {566upstreamBranchName = line.substring('# branch.upstream '.length).trim();567} else if (line.startsWith('# branch.ab ')) {568const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line);569if (m) {570outgoingChanges = Number(m[1]);571incomingChanges = Number(m[2]);572}573} else if (!line.startsWith('#')) {574uncommittedChanges++;575}576}577return { branchName, upstreamBranchName, outgoingChanges, incomingChanges, uncommittedChanges };578}579580/** Exported for tests. */581export function parseHasGitHubRemote(remotesOutput: string | undefined): boolean | undefined {582if (remotesOutput === undefined) {583return undefined;584}585if (!remotesOutput.trim()) {586return false;587}588return /github\.com[:\/]/i.test(remotesOutput);589}590591/** Exported for tests. */592export function parseDefaultBranchRef(symbolicRefOutput: string | undefined): string | undefined {593const ref = symbolicRefOutput?.trim();594if (!ref) { return undefined; }595const prefix = 'refs/remotes/origin/';596return ref.startsWith(prefix) ? ref.substring(prefix.length) : ref;597}598599function stripUndefined<T extends object>(obj: T): T {600const out: Record<string, unknown> = {};601for (const [k, v] of Object.entries(obj)) {602if (v !== undefined) { out[k] = v; }603}604return out as T;605}606607608