src/vs/platform/agentHost/node/agentHostGitService.ts

1257 LOC · 514 covered · 743 uncovered · 142 ranges · 57 concepts · 49 introducers · 45 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the fileagentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat parses an add, modify, delete and rename in a single stream|occurrence=1 · 0 introduced LOCagentHostGitService.test…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×6 · 14 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×10 · 60 introduced LOCagentHostGitService.ts ×…agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote falls back to first github remote when origin is not github|occurrence=1 · 0 introduced LOCagentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote returns undefined when no GitHub remote is present|occurrence=1 · 0 introduced LOCagentHostGitService.test…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 3 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×3 · 7 introduced LOCagentHostGitService.ts ×…agentHostGitService.test|title=AgentHostGitService sorts common branch names to the top before applying limit|occurrence=1 · 0 introduced LOCagentHostGitService.test…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×2 · 4 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×2 · 6 introduced LOCagentHostGitService.ts ×…agentHostGitService.test|title=AgentHostGitService preserves git order for non-common branches|occurrence=1 · 0 introduced LOCagentHostGitService.test…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 5 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×2 · 3 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×2 · 5 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×3 · 7 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×4 · 18 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×3 · 24 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×3 · 10 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×3 · 14 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×3 · 13 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 2 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×2 · 3 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×2 · 4 introduced LOCagentHostGitService.ts ×…agentHostGitService.test|title=AgentHostGitService resolveDiffBaseBranchName prefers the persisted base branch, then git state, then undefined|occurrence=1 · 0 introduced LOCagentHostGitService.test…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 7 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×1 · 1 introduced LOCagentHostGitService.ts ×…agentHostGitService.ts ×59 · 259 introduced LOCagentHostGitService.ts ×…agentHostGitService.test|title=AgentHostGitService exports the well-known empty-tree object SHA|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService exports the well-known empty-tree object SHA|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService filters before sorting common branch names|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService filters before sorting common branch names|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService formatGitError falls back to error message when there is no signal or exit code|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService formatGitError falls back to error message when there is no signal or exit code|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService formatGitError keeps missing git-lfs error over the later generic fatal line|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService formatGitError keeps missing git-lfs error over the later generic fatal line|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService formatGitError reports kill signal when killed but not by our timer|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService formatGitError reports kill signal when killed but not by our timer|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService formatGitError reports numeric exit code when git failed normally|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService formatGitError reports numeric exit code when git failed normally|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService formatGitError reports timeout when our timer fired and summarises progress-meter stderr|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService formatGitError reports timeout when our timer fired and summarises progress-meter stderr|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService orders fetch remote URLs with origin first and excludes push URLs|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService orders fetch remote URLs with origin first and excludes push URLs|occurrence=1agentHostGitService.test…copy sources|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseChangedPaths returns every changed path including delete and index rename/copy sources|occurrence=1copy sources|occurrence=…copy source paths too|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseChangedPaths returns worktree rename/copy source paths too|occurrence=1copy source paths too|oc…agentHostGitService.test|title=AgentHostGitService parseDefaultBranchRef returns the ref as-is when prefix is not present|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseDefaultBranchRef returns the ref as-is when prefix is not present|occurrence=1agentHostGitService.test…missing output|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseDefaultBranchRef returns undefined for empty/missing output|occurrence=1missing output|occurrenc… prefix|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseDefaultBranchRef strips refs/remotes/origin/ prefix|occurrence=1 prefix|occurrence=1agentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat anchors after side to afterRef when provided (ref->ref diffs)|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat anchors after side to afterRef when provided (ref->ref diffs)|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat parses an add, modify, delete and rename in a single stream|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat parses an add, modify, delete and rename in a single stream|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat returns empty for empty input|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat returns empty for empty input|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat treats `-` numstat values (binary) as zero|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitDiffRawNumstat treats `-` numstat values (binary) as zero|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote falls back to first github remote when origin is not github|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote falls back to first github remote when origin is not github|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote parses https origin remote without .git suffix|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote parses https origin remote without .git suffix|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote parses ssh (scp-like) origin remote|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote parses ssh (scp-like) origin remote|occurrence=1agentHostGitService.test… scheme remote|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote parses ssh:// scheme remote|occurrence=1 scheme remote|occurrenc…agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote prefers origin over other remotes|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote prefers origin over other remotes|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote returns undefined when no GitHub remote is present|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote returns undefined when no GitHub remote is present|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote returns undefined when no remotes are present|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitHubRepoFromRemote returns undefined when no remotes are present|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitStatusV2 parses a clean checkout with upstream|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitStatusV2 parses a clean checkout with upstream|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitStatusV2 parses a dirty branch ahead and behind upstream|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitStatusV2 parses a dirty branch ahead and behind upstream|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseGitStatusV2 returns empty object for undefined input|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitStatusV2 returns empty object for undefined input|occurrence=1agentHostGitService.test…ab when absent|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseGitStatusV2 treats (detached) HEAD as no branch and omits upstream/ab when absent|occurrence=1ab when absent|occurrenc…agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote detects https github remote|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote detects https github remote|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote detects ssh github remote|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote detects ssh github remote|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote returns false for non-github remotes|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote returns false for non-github remotes|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote returns false when there are no remotes|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote returns false when there are no remotes|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote returns undefined when probe failed (output absent)|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseHasGitHubRemote returns undefined when probe failed (output absent)|occurrence=1agentHostGitService.test…oid and treats empty output as absent|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseSingleLsTreeEntry parses mode/oid and treats empty output as absent|occurrence=1oid and treats empty out…agentHostGitService.test|title=AgentHostGitService parseUntrackedPaths extracts untracked entries and skips others|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseUntrackedPaths extracts untracked entries and skips others|occurrence=1agentHostGitService.test…undefined output|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService parseUntrackedPaths returns empty for empty/undefined output|occurrence=1undefined output|occurre…agentHostGitService.test|title=AgentHostGitService prefers the branch upstream remote before origin|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService prefers the branch upstream remote before origin|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService preserves git order for non-common branches|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService preserves git order for non-common branches|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService resolveDiffBaseBranchName prefers the persisted base branch, then git state, then undefined|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService resolveDiffBaseBranchName prefers the persisted base branch, then git state, then undefined|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService sorts common branch names to the top before applying limit|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService sorts common branch names to the top before applying limit|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService summarizeStderrForError keeps the last non-empty line of a multi-line progress meter|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService summarizeStderrForError keeps the last non-empty line of a multi-line progress meter|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService summarizeStderrForError passes through a normal single-line message|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService summarizeStderrForError passes through a normal single-line message|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService summarizeStderrForError returns empty string for empty input|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService summarizeStderrForError returns empty string for empty input|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService summarizeStderrForError returns empty string for whitespace-only input|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService summarizeStderrForError returns empty string for whitespace-only input|occurrence=1agentHostGitService.test…agentHostGitService.test|title=AgentHostGitService summarizeStderrForError truncates very long lines with an ellipsis|occurrence=1 · introduced test · mocha:v1|namespace=vscode@05c208e9e28d8c1c723fa08f85e2b7a96092e8e5|file=vs/platform/agentHost/test/node/agentHostGitService.test|title=AgentHostGitService summarizeStderrForError truncates very long lines with an ellipsis|occurrence=1agentHostGitService.test…Focused file · src/vs/platform/agentHost/node/agentHostGitService.ts · 1257 LOCnode/agentHostGitService…

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- agentHostGitService.ts ×59
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import * as cp from 'child_process';
7 > import * as fsPromises from 'fs/promises';
8 > import { cp as copyFile } from '@vscode/fs-copyfile';
9 > import * as path from '../../../base/common/path.js';
10 > import { URI } from '../../../base/common/uri.js';
11 > import { VSBuffer } from '../../../base/common/buffer.js';
12 > import { parse } from '../../../base/common/glob.js';
13 > import { generateUuid } from '../../../base/common/uuid.js';
14 > import { INativeEnvironmentService } from '../../environment/common/environment.js';
15 > import { IFileService } from '../../files/common/files.js';
16 > import { ILogService } from '../../log/common/log.js';
17 > import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js';
18 > import { buildGitBlobUri } from './gitDiffContent.js';
19 > import { EMPTY_TREE_OBJECT, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch } from '../common/agentHostGitService.js';
20 > import { LRUCache } from '../../../base/common/map.js';
21 > import { Limiter, SequencerByKey } from '../../../base/common/async.js';
22 >
23 > export class AgentHostGitService implements IAgentHostGitService {
24 > declare readonly _serviceBrand: undefined;
25 >
26 > /**
27 > * A cache of repository roots that have already been discovered.
28 > */
29 > private readonly _repositoryRoots = new LRUCache<string, URI>(100);
30 > private readonly _repositoryRootSequencer = new SequencerByKey<string>();
31 >
32 > constructor(
33 @IFileService private readonly _fileService: IFileService,
34 @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
35 @ILogService private readonly _logService: ILogService,
36 ) { }
38 > async getCurrentBranch(workingDirectory: URI): Promise<string | undefined> {
39 return (await this._runGit(workingDirectory, ['branch', '--show-current']))?.trim()
40 || (await this._runGit(workingDirectory, ['rev-parse', '--short', 'HEAD']))?.trim()
41 || undefined;
42 }
44 > async getDefaultBranch(workingDirectory: URI): Promise<IDefaultBranch | undefined> {
45 // Try to read the default branch from the remote HEAD reference
46 const remoteRef = (await this._runGit(workingDirectory, ['symbolic-ref', 'refs/remotes/origin/HEAD']))?.trim();
47 if (remoteRef) {
48 if (!remoteRef.startsWith('refs/remotes/origin/')) {
49 return { name: remoteRef, startPoint: remoteRef };
50 }
51
52 const branch = remoteRef.substring('refs/remotes/origin/'.length);
53 // Prefer the remote-tracking ref ('origin/<branch>') over the local
54 // branch when both exist, so worktrees are based on the most
55 // up-to-date commit rather than a possibly stale local branch.
56 // This mirrors the extension-host CLI which resolves a branch's
57 // upstream and uses that as the worktree start point. Falls back
58 // to the local branch when the remote-tracking ref is missing
59 // (e.g. fresh clone with no remote-tracking refs yet).
60 const hasRemoteRef = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`])) !== undefined;
61 if (hasRemoteRef) {
62 return { name: branch, startPoint: `origin/${branch}` };
63 }
64 const hasLocalBranch = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== undefined;
65 return hasLocalBranch ? { name: branch, startPoint: branch } : undefined;
66 }
67 return undefined;
68 }
70 > async getRefs(workingDirectory: URI, query?: IRefQuery): Promise<GitRef[]> {
71 const args = ['for-each-ref', '--format=%(refname)%00%(upstream)'];
72
73 if (query?.sort && query.sort !== 'alphabetically') {
74 args.push('--sort', `-${query.sort}`);
75 }
76
77 if (query?.count) {
78 args.push(`--count=${query.count}`);
79 }
80
81 if (query?.pattern) {
82 const patterns = Array.isArray(query.pattern) ? query.pattern : [query.pattern];
83 for (const pattern of patterns) {
84 args.push(pattern.startsWith('refs/') ? pattern : `refs/${pattern}`);
85 }
86 }
87
88 const output = await this._runGit(workingDirectory, args);
89 return parseGitRefs(output);
90 }
92 > async getBranches(workingDirectory: URI, query?: IRefQuery): Promise<Branch[]> {
93 const refs = await this.getRefs(workingDirectory, query);
94 return refs.filter(r => r.kind === GitRefType.Head || r.kind === GitRefType.RemoteHead);
95 }
97 > async getBranch(workingDirectory: URI, name: string): Promise<Branch | undefined> {
98 const refs = await this.getBranches(workingDirectory, { pattern: name });
99 return refs.length > 0 ? refs[0] : undefined;
100 }
102 > async getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined> {
103 const workingDirectoryKey = workingDirectory.toString();
104
105 return this._repositoryRootSequencer.queue(workingDirectoryKey, async () => {
106 let repositoryRoot = this._repositoryRoots.get(workingDirectoryKey);
107 if (repositoryRoot) {
108 return repositoryRoot;
109 }
110
111 try {
112 const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim();
113 if (repositoryRootPath) {
114 repositoryRoot = URI.file(repositoryRootPath);
115 this._repositoryRoots.set(workingDirectoryKey, repositoryRoot);
116 }
117
118 return repositoryRoot;
119 } catch (error) { }
120
121 return undefined;
122 });
123 }
125 > async getWorktreeRoots(workingDirectory: URI): Promise<URI[]> {
126 const output = await this._runGit(workingDirectory, ['worktree', 'list', '--porcelain']);
127 if (!output) {
128 return [];
129 }
130 return output.split(/\r?\n/g)
131 .filter(line => line.startsWith('worktree '))
132 .map(line => URI.file(line.substring('worktree '.length)));
133 }
135 > async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise<void> {
136 const resolvedStartPoint = await this._resolveRemoteTrackingBranch(repositoryRoot, startPoint) ?? startPoint;
137 // Pass --no-track so the new agent branch never picks up upstream
138 // tracking from the start point (e.g. when starting from
139 // 'origin/main', without --no-track git would set the new branch's
140 // upstream to origin/main, which would mis-attribute pushes/pulls).
141 await this._runGit(repositoryRoot, ['-c', 'checkout.workers=0', 'worktree', 'add', '--no-track', '-b', branchName, worktree.fsPath, resolvedStartPoint], { timeout: 180_000, throwOnError: true });
142 }
144 > async copyWorktreeIncludeFiles(repositoryRoot: URI, worktree: URI, globs: readonly string[]): Promise<void> {
145 try {
146 const worktreeIncludePaths = await this._getWorktreeIncludePaths(repositoryRoot, globs);
147 if (worktreeIncludePaths.length === 0) {
148 return;
149 }
150
151 const startTime = performance.now();
152 const limiter = new Limiter<void>(15);
153 const results = await Promise.allSettled(worktreeIncludePaths.map(sourcePath => limiter.queue(async () => {
154 const targetPath = path.join(worktree.fsPath, path.relative(repositoryRoot.fsPath, sourcePath));
155 await fsPromises.mkdir(path.dirname(targetPath), { recursive: true });
156 await copyFile(sourcePath, targetPath, { force: true, recursive: true, verbatimSymlinks: true });
157 })));
158
159 const failedOperations = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
160 this._logService.info(`[AgentHostGitService][copyWorktreeIncludeFiles] Copied ${worktreeIncludePaths.length - failedOperations.length}/${worktreeIncludePaths.length} folder(s)/file(s) to worktree ${worktree.fsPath}. [${(performance.now() - startTime).toFixed(2)}ms]`);
161
162 if (failedOperations.length > 0) {
163 this._logService.warn(`[AgentHostGitService][copyWorktreeIncludeFiles] Failed to copy ${failedOperations.length} folder(s)/file(s) to worktree ${worktree.fsPath}.`);
164 for (const error of failedOperations) {
165 this._logService.warn(`[AgentHostGitService][copyWorktreeIncludeFiles] ${error.reason}`);
166 }
167 }
168 } catch (error) {
169 this._logService.warn(`[AgentHostGitService][copyWorktreeIncludeFiles] Failed to copy folder(s)/file(s) to worktree ${worktree.fsPath}: ${error}`);
170 }
171 }
173 > async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise<void> {
174 // `-f` (force) so recreation succeeds even when the worktree directory was
175 // deleted out-of-band but git still has it registered ("missing but
176 // already registered worktree"). This is our own managed per-session
177 // worktree/branch, so overriding git's safeguards here is safe.
178 await this._runGit(repositoryRoot, ['-c', 'checkout.workers=0', 'worktree', 'add', '-f', worktree.fsPath, branchName], { timeout: 180_000, throwOnError: true });
179 }
181 > async removeWorktree(repositoryRoot: URI, worktree: URI): Promise<void> {
182 await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 60_000, throwOnError: true });
183 }
185 > async branchExists(repositoryRoot: URI, branchName: string): Promise<boolean> {
186 // `show-ref --verify --quiet` exits 0 when the ref exists and 1 otherwise.
187 // `_runGit` returns undefined on non-zero exit, so `!== undefined` is the existence signal.
188 const output = await this._runGit(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
189 return output !== undefined;
190 }
192 > async hasUncommittedChanges(workingDirectory: URI): Promise<boolean> {
193 const output = await this._runGit(workingDirectory, ['status', '--porcelain']);
194 return !!output && output.trim().length > 0;
195 }
197 > async commitAll(workingDirectory: URI, message: string): Promise<void> {
198 await this._runGit(workingDirectory, ['add', '-A', '--', ':/'], { throwOnError: true });
199 await this._runGit(workingDirectory, ['commit', '--no-verify', '-m', message], { timeout: 60_000, throwOnError: true });
200 }
202 > async restore(workingDirectory: URI, paths: readonly string[], options?: { readonly staged?: boolean; readonly ref?: string }): Promise<void> {
203 const args = ['restore'];
204
205 if (options?.staged) {
206 args.push('--staged');
207 }
208
209 if (options?.ref) {
210 args.push('--source', options.ref);
211 }
212
213 if (paths.length === 0) {
214 paths = ['.'];
215 }
216
217 await this._runGit(workingDirectory, [...args, '--', ...paths], { throwOnError: true });
218 }
220 > async hasUpstream(workingDirectory: URI, branchName: string): Promise<boolean> {
221 const output = await this._runGit(workingDirectory, ['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`]);
222 return output !== undefined && output.trim().length > 0;
223 }
225 > async pull(workingDirectory: URI, options?: IPullOptions): Promise<void> {
226 const args = ['pull'];
227
228 if (options?.rebase) {
229 args.push('-r');
230 }
231
232 // A ref can only be passed alongside a
233 // remote; default to `origin` when a ref
234 // is given without one.
235 if (options?.remote || options?.ref) {
236 args.push(options.remote ?? 'origin');
237
238 if (options.ref) {
239 args.push(options.ref);
240 }
241 }
242
243 await this._runGit(workingDirectory, args, { timeout: 180_000, throwOnError: true });
244 }
246 > async push(workingDirectory: URI, options?: IPushOptions): Promise<void> {
247 const args = ['push'];
248
249 if (options?.setUpstream) {
250 args.push('--set-upstream');
251 }
252
253 // A ref can only be passed alongside a
254 // remote; default to `origin` when a ref
255 // is given without one.
256 if (options?.remote || options?.ref) {
257 args.push(options.remote ?? 'origin');
258
259 if (options.ref) {
260 args.push(options.ref);
261 }
262 }
263
264 await this._runGit(workingDirectory, args, { timeout: 180_000, throwOnError: true });
265 }
267 > async computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise<readonly ISessionFileDiff[] | undefined> {
268 // All git invocations run from the working tree's repository root so
269 // `--raw` paths are repo-relative — that's what `git show <sha>:<path>`
270 // expects when we resolve `git-blob:` URIs later.
271 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
272 if (!repositoryRoot) {
273 return undefined;
274 }
275
276 // Resolve the merge-base commit the Branch Changes diff is anchored on.
277 const mergeBaseCommit = await this._resolveBranchMergeBaseCommit(repositoryRoot, options.baseBranch);
278
279 // Detect whether the working tree has any untracked files. If so we
280 // have to use the temp-index trick so the untracked content is
281 // included in `--cached --raw` output; otherwise a plain `git diff`
282 // is sufficient and avoids the temp-dir overhead.
283 const statusOut = await this._runGit(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
284 if (statusOut === undefined) {
285 return undefined;
286 }
287 const hasUntracked = parseUntrackedPaths(statusOut).length > 0;
288
289 let rawDiffOutput: string | undefined;
290 if (!hasUntracked) {
291 rawDiffOutput = await this._runGit(repositoryRoot, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z', mergeBaseCommit, '--']);
292 } else {
293 const changedPaths = parseChangedPaths(statusOut);
294 rawDiffOutput = await this._runWithTempIndex(repositoryRoot, mergeBaseCommit, changedPaths);
295 }
296
297 if (rawDiffOutput === undefined) {
298 return undefined;
299 }
300
301 return parseGitDiffRawNumstat(rawDiffOutput, repositoryRoot, options.sessionUri, mergeBaseCommit);
302 }
304 > async resolveBranchBaselineCommit(workingDirectory: URI, baseBranch?: string): Promise<string | undefined> {
305 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
306 if (!repositoryRoot) {
307 return undefined;
308 }
309
310 return this._resolveBranchMergeBaseCommit(repositoryRoot, baseBranch);
311 }
313 > /**
314 > * Resolves the merge-base commit-ish the Branch Changes baseline is anchored
315 > * on. With a base branch, prefers the corresponding `origin/<base>`
316 > * remote-tracking ref when it exists so branch changes match a PR-style
317 > * comparison even if the local base branch is stale. Without a usable base,
318 > * falls back to `HEAD` (surfaces uncommitted work but no committed-on-branch
319 > * work). For empty repos with no `HEAD`, falls back to the empty-tree object.
320 > * Always resolves to a commit-ish (never `undefined`) once the repository
321 > * root is known.
322 > */
323 > private async _resolveBranchMergeBaseCommit(repositoryRoot: URI, baseBranch?: string): Promise<string> {
324 let mergeBaseCommit: string | undefined;
325 if (baseBranch) {
326 const resolvedBase = await this._resolveRemoteTrackingBranch(repositoryRoot, baseBranch) ?? baseBranch;
327 mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', resolvedBase]))?.trim();
328 }
329 if (!mergeBaseCommit) {
330 mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim();
331 }
332
333 return mergeBaseCommit ?? EMPTY_TREE_OBJECT;
334 }
336 > private async _runWithTempIndex(repositoryRoot: URI, mergeBaseCommit: string, changedPaths: readonly string[]): Promise<string | undefined> {
337 // Build a throwaway index so we can stage the changed working tree
338 // paths (including untracked files) without disturbing the user's real
339 // index. `read-tree HEAD` seeds it; in empty repos that fails so we
340 // fall back to the empty tree, leaving everything as "added".
341 const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-git-diff-${generateUuid()}`);
342 await this._fileService.createFolder(tempDir);
343 // `GIT_INDEX_FILE` is consumed by the `git` subprocess so it must be
344 // a real OS path string, not a URI.
345 const indexFile = URI.joinPath(tempDir, 'index').fsPath;
346 const env: Record<string, string> = { GIT_INDEX_FILE: indexFile };
347 // GVFS (Virtual File System) repos use a hook that acquires a lock around
348 // git commands. Setting COMMAND_HOOK_LOCK=1 prevents the temp-index
349 // operations from blocking the main working-tree lock. This mirrors what
350 // the extension's `buildTempIndexEnv` does for the same reason.
351 env.COMMAND_HOOK_LOCK = '1';
352 try {
353 const seeded = await this._runGit(repositoryRoot, ['read-tree', 'HEAD'], { env });
354 if (seeded === undefined) {
355 // Empty repo (no HEAD yet) - `read-tree` of the empty tree always succeeds.
356 await this._runGit(repositoryRoot, ['read-tree', EMPTY_TREE_OBJECT], { env });
357 }
358 if (!(await this._stageChangedPaths(repositoryRoot, tempDir, changedPaths, env))) {
359 return undefined;
360 }
361 return await this._runGit(repositoryRoot, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', '-z', mergeBaseCommit, '--'], { env });
362 } finally {
363 try { await this._fileService.del(tempDir, { recursive: true, useTrash: false }); } catch { /* best-effort */ }
364 }
365 }
367 > private async _stageChangedPaths(repositoryRoot: URI, tempDir: URI, changedPaths: readonly string[], env: Record<string, string>): Promise<boolean> {
368 if (changedPaths.length === 0) {
369 return true;
370 }
371 const pathspecFile = URI.joinPath(tempDir, 'pathspec');
372 // Stage only the paths `git status` reported as changed. The previous
373 // full-repo `git add -A -- :/` walked nested repos/worktrees and large
374 // checkouts, which made temp-index diffing slow and timeout-prone. A
375 // NUL-separated pathspec preserves odd filenames while keeping deletes
376 // and rename/copy sources in scope.
377 await this._fileService.writeFile(pathspecFile, VSBuffer.fromString(changedPaths.join('\x00') + '\x00'));
378 this._logService.debug(`[agentHostGitService] Staging ${changedPaths.length} changed path(s) into temp index`);
379 return await this._runGit(repositoryRoot, ['add', '-A', `--pathspec-from-file=${pathspecFile.fsPath}`, '--pathspec-file-nul'], {
380 env: { ...env, GIT_LITERAL_PATHSPECS: '1' },
381 }) !== undefined;
382 }
384 > private async _resolveRemoteTrackingBranch(repositoryRoot: URI, branch: string): Promise<string | undefined> {
385 const remoteBranch = `origin/${branch}`;
386 const output = await this._runGit(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/${remoteBranch}`]);
387 return output !== undefined ? remoteBranch : undefined;
388 }
390 > private async _getWorktreeIncludePaths(repositoryRoot: URI, globs: readonly string[]): Promise<string[]> {
391 if (globs.length === 0) {
392 return [];
393 }
394
395 // List the git-ignored (but untracked) files: `--others` selects
396 // untracked files, `--ignored` restricts to those matched by an exclude
397 // source, and `--exclude-standard` uses the standard sources (.gitignore,
398 // .git/info/exclude, core.excludesFile). `-z` NUL-separates entries so
399 // paths containing spaces or other special characters survive intact.
400 //
401 // The `--directory` variant additionally collapses a *wholly*-ignored
402 // directory (one containing no tracked files) into a single `dir/`
403 // entry. It is enumerated in parallel and used below to copy such
404 // directories as one recursive unit rather than file-by-file.
405 const baseArgs = ['ls-files', '--others', '--ignored', '--exclude-standard', '-z'];
406 const [filesOutput, directoryOutput] = await Promise.all([
407 this._runGit(repositoryRoot, baseArgs, { timeout: 30_000 }),
408 this._runGit(repositoryRoot, [...baseArgs, '--directory', '--no-empty-directory'], { timeout: 30_000 }),
409 ]);
410 if (!filesOutput) {
411 return [];
412 }
413
414 // git emits repository-relative, forward-slash paths.
415 const ignoredFiles = filesOutput.split('\x00').filter(entry => entry.length > 0);
416 if (ignoredFiles.length === 0) {
417 return [];
418 }
419
420 // Keep only the ignored files that match one of the configured
421 // `git.worktreeIncludeFiles` glob patterns (VS Code glob semantics),
422 // and — in the same pass — tally which wholly-ignored directories
423 // contain an *unmatched* ignored file (and therefore cannot be
424 // collapsed). `git ls-files --directory` reports a wholly-ignored
425 // directory as a single `dir/` entry and never nests these entries
426 // (it stops descending once a directory is wholly ignored), so each
427 // file has at most one containing directory and no de-duplication of
428 // the directory set is required.
429 const matchers = globs.map(pattern => parse(pattern));
430 const wholeDirectories = new Set((directoryOutput ?? '')
431 .split('\x00').filter(entry => entry.endsWith('/')));
432
433 const matchedFiles: string[] = [];
434 const nonCollapsibleDirectories = new Set<string>();
435 for (const file of ignoredFiles) {
436 if (matchers.some(matcher => matcher(file))) {
437 matchedFiles.push(file);
438 } else if (wholeDirectories.size > 0) {
439 const containingDirectory = findContainingDirectory(file, wholeDirectories);
440 if (containingDirectory !== undefined) {
441 nonCollapsibleDirectories.add(containingDirectory);
442 }
443 }
444 }
445 if (matchedFiles.length === 0) {
446 return [];
447 }
448
449 // Collapse matched files into their containing directory when the whole
450 // directory can be copied as a single recursive unit — i.e. it is
451 // wholly ignored (so it has no tracked files a recursive copy would
452 // clobber) and every ignored file it contains matched a glob (so
453 // nothing unwanted is copied, tracked by `nonCollapsibleDirectories` above).
454 // This turns a large tree such as `node_modules/` into one copy instead
455 // of one per file, while a partially-matched or partially-tracked
456 // directory falls back to its individual matched files. `--directory`
457 // with `--no-empty-directory` never reports an empty directory, so every
458 // entry in `wholeDirectories` is known to contain at least one ignored file.
459 const collapsedDirectories = new Set<string>();
460 for (const dir of wholeDirectories) {
461 if (!nonCollapsibleDirectories.has(dir)) {
462 collapsedDirectories.add(dir);
463 }
464 }
465
466 // Emit the collapsed directories plus every matched file not already
467 // covered by one of them.
468 const includePaths: string[] = [...collapsedDirectories];
469 for (const file of matchedFiles) {
470 if (
471 collapsedDirectories.size === 0 ||
472 findContainingDirectory(file, collapsedDirectories) === undefined
473 ) {
474 includePaths.push(file);
475 }
476 }
477
478 return includePaths.map(entry => path.join(repositoryRoot.fsPath, entry));
479 }
481 > async showBlob(workingDirectory: URI, ref: string, repoRelativePath: string): Promise<VSBuffer | undefined> {
482 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
483 if (!repositoryRoot) {
484 return undefined;
485 }
486
487 // `git show` exits non-zero when the path didn't exist at that
488 // ref; `_runGit` swallows that into `undefined` which is exactly
489 // the contract callers want.
490 return new Promise((resolve) => {
491 cp.execFile('git', ['show', `${ref}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => {
492 if (error) {
493 resolve(undefined);
494 return;
495 }
496 resolve(VSBuffer.wrap(stdout as Buffer));
497 });
498 });
499 }
501 > async getSessionGitState(workingDirectory: URI): Promise<ISessionGitState | undefined> {
502 return this._computeSessionGitState(workingDirectory);
503 }
505 > async getFetchRemoteUrls(workingDirectory: URI, preferredRemote?: string): Promise<readonly string[] | undefined> {
506 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
507 if (!repositoryRoot) {
508 return undefined;
509 }
510 return parseFetchRemoteUrls(await this._runGit(repositoryRoot, ['remote', '-v']), preferredRemote);
511 }
513 > async getUntrackedPaths(workingDirectory: URI): Promise<readonly string[] | undefined> {
514 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
515 if (!repositoryRoot) {
516 return undefined;
517 }
518 const status = await this._runGit(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
519 return status === undefined ? undefined : parseUntrackedPaths(status);
520 }
522 > async captureWorkingTreeAsTree(workingDirectory: URI): Promise<string | undefined> {
523 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
524 if (!repositoryRoot) {
525 return undefined;
526 }
527
528 const statusOut = await this._runGit(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
529 if (statusOut === undefined) {
530 return undefined;
531 }
532 const changedPaths = parseChangedPaths(statusOut);
533 const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-checkpoint-${generateUuid()}`);
534 await this._fileService.createFolder(tempDir);
535 const indexFile = URI.joinPath(tempDir, 'index').fsPath;
536 const env: Record<string, string> = { GIT_INDEX_FILE: indexFile, COMMAND_HOOK_LOCK: '1' };
537 try {
538 // Seed the temp index from HEAD; for empty repos seed from the empty tree.
539 const seeded = await this._runGit(repositoryRoot, ['read-tree', 'HEAD'], { env });
540 if (seeded === undefined) {
541 await this._runGit(repositoryRoot, ['read-tree', EMPTY_TREE_OBJECT], { env });
542 }
543 if (!(await this._stageChangedPaths(repositoryRoot, tempDir, changedPaths, env))) {
544 return undefined;
545 }
546 const tree = (await this._runGit(repositoryRoot, ['write-tree'], { env }))?.trim();
547 return tree || undefined;
548 } finally {
549 try { await this._fileService.del(tempDir, { recursive: true, useTrash: false }); } catch { /* best-effort */ }
550 }
551 }
553 > async commitTree(repositoryRoot: URI, treeOid: string, parentOid: string | undefined, message: string): Promise<string | undefined> {
554 const args = ['commit-tree', treeOid];
555 if (parentOid) {
556 args.push('-p', parentOid);
557 }
558 args.push('-m', message);
559 const out = await this._runGit(repositoryRoot, args, { throwOnError: true });
560 return out?.trim() || undefined;
561 }
563 > async updateRef(repositoryRoot: URI, ref: string, newOid: string): Promise<void> {
564 await this._runGit(repositoryRoot, ['update-ref', ref, newOid], { throwOnError: true });
565 }
567 > async deleteRefs(repositoryRoot: URI, refs: readonly string[]): Promise<void> {
568 if (refs.length === 0) {
569 return;
570 }
571 // Use `update-ref --stdin -z` so all deletions go through a single git
572 // invocation. Each command is `delete SP <ref> NUL [<expected_oid>] NUL`;
573 // we omit the expected oid so already-missing refs don't fail the batch.
574 const stdin = refs.map(ref => `delete ${ref}\x00\x00`).join('');
575 await new Promise<void>((resolve) => {
576 const proc = cp.execFile('git', ['update-ref', '--stdin', '-z'], { cwd: repositoryRoot.fsPath, timeout: 10_000 }, () => {
577 // Tolerate non-zero exits — missing refs are not fatal for cleanup.
578 resolve();
579 });
580 proc.stdin?.end(stdin);
581 });
582 }
584 > async revParse(repositoryRoot: URI, expression: string): Promise<string | undefined> {
585 const out = await this._runGit(repositoryRoot, ['rev-parse', '--verify', '--quiet', expression]);
586 return out?.trim() || undefined;
587 }
589 > async overlayPathIntoTree(repositoryRoot: URI, baseTreeOid: string, path: string, sourceTreeOid: string): Promise<string | undefined> {
590 // Build a throwaway index seeded from `baseTreeOid`, replace/remove the
591 // single `path` using `sourceTreeOid`, and write the result back out as
592 // a new tree. The user's real index is never touched (mirrors the
593 // temp-index technique used by `captureWorkingTreeAsTree`).
594 const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-review-overlay-${generateUuid()}`);
595 await this._fileService.createFolder(tempDir);
596 const indexFile = URI.joinPath(tempDir, 'index').fsPath;
597 const env: Record<string, string> = { GIT_INDEX_FILE: indexFile, COMMAND_HOOK_LOCK: '1' };
598
599 try {
600 const readTreeOut = await this._runGit(repositoryRoot, ['read-tree', baseTreeOid], { env, throwOnError: false });
601 if (readTreeOut === undefined) {
602 return undefined;
603 }
604
605 // Resolve the source blob (mode + oid) for `path`. `-z` avoids
606 // path quoting; an empty result means the path is absent in the
607 // source tree, so the overlay removes it from the base.
608 const lsTreeOut = await this._runGit(repositoryRoot, ['ls-tree', '-z', sourceTreeOid, '--', path], { env });
609 const entry = parseSingleLsTreeEntry(lsTreeOut);
610 if (entry) {
611 const updateIndexOut = await this._runGit(repositoryRoot, ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.oid},${path}`], { env, throwOnError: false });
612 if (updateIndexOut === undefined) {
613 return undefined;
614 }
615 } else {
616 // `--force-remove` tolerates the path already being absent from
617 // the index, so removing an untracked/added path is a no-op.
618 const updateIndexOut = await this._runGit(repositoryRoot, ['update-index', '--force-remove', '--', path], { env, throwOnError: false });
619 if (updateIndexOut === undefined) {
620 return undefined;
621 }
622 }
623
624 const writeTreeOut = await this._runGit(repositoryRoot, ['write-tree'], { env });
625 return writeTreeOut?.trim();
626 } finally {
627 try {
628 await this._fileService.del(tempDir, { recursive: true, useTrash: false });
629 } catch { /* best-effort */ }
630 }
631 }
633 > async diffTreePaths(repositoryRoot: URI, fromTreeish: string, toTreeish: string): Promise<string[] | undefined> {
634 const out = await this._runGit(repositoryRoot, ['diff', '--name-only', '--no-renames', '-z', fromTreeish, toTreeish, '--']);
635 if (out === undefined) {
636 return undefined;
637 }
638 return out.split('\x00').filter(Boolean);
639 }
641 > async computeFileDiffsBetweenRefs(workingDirectory: URI, options: { readonly sessionUri: string; readonly fromRef: string; readonly toRef: string }): Promise<readonly ISessionFileDiff[] | undefined> {
642 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
643 if (!repositoryRoot) {
644 return undefined;
645 }
646
647 try {
648 const raw = await this._runGit(repositoryRoot, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z', options.fromRef, options.toRef, '--']);
649 if (raw === undefined) {
650 return undefined;
651 }
652
653 return parseGitDiffRawNumstat(raw, repositoryRoot, options.sessionUri, options.fromRef, options.toRef);
654 } catch (err) {
655 this._logService.warn(`[AgentHostGitService][computeFileDiffsBetweenRefs] Failed to compute file diffs ${repositoryRoot.toString()}, ${options.fromRef}, ${options.toRef}: ${err}`);
656 return undefined;
657 }
658 }
660 > async getBranchDiffSafetyInfo(workingDirectory: URI, baselineCommit: string): Promise<IBranchDiffSafetyInfo | undefined> {
661 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
662 if (!repositoryRoot) {
663 return undefined;
664 }
665
666 const [virtualFileSystem, sparseCheckout, timestamp, commitCount, workspaceFiles] = await Promise.all([
667 this._runGit(repositoryRoot, ['config', '--get', 'core.virtualfilesystem']),
668 this._runGit(repositoryRoot, ['config', '--get', 'core.sparsecheckout']),
669 this._runGit(repositoryRoot, ['show', '-s', '--format=%ct', baselineCommit]),
670 this._runGit(repositoryRoot, ['rev-list', '--count', `${baselineCommit}..HEAD`]),
671 this._runGit(repositoryRoot, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']),
672 ]);
673 const sparseCheckoutEnabled = new Set(['true', 'yes', 'on', '1']).has(sparseCheckout?.trim().toLowerCase() ?? '');
674 const timestampSeconds = Number(timestamp?.trim());
675 const parsedCommitCount = Number(commitCount?.trim());
676 return {
677 hasVirtualFileSystem: Boolean(virtualFileSystem?.trim()) || sparseCheckoutEnabled,
678 baselineCommitTimestamp: Number.isFinite(timestampSeconds) ? timestampSeconds * 1000 : undefined,
679 commitCount: Number.isFinite(parsedCommitCount) ? parsedCommitCount : undefined,
680 workspaceFileCount: workspaceFiles?.split('\x00').filter(Boolean).length ?? 0,
681 };
682 }
684 > async getDiffPatchBetweenRefs(workingDirectory: URI, options: { readonly fromRef: string; readonly toRef: string; readonly paths: readonly string[]; readonly maxBuffer: number }): Promise<{ readonly patch: string | undefined; readonly tooLarge: boolean } | undefined> {
685 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
686 if (!repositoryRoot) {
687 return undefined;
688 }
689 const paths = [...new Set(options.paths)];
690 if (paths.length === 0) {
691 return { patch: '', tooLarge: false };
692 }
693 try {
694 const patch = await this._runGit(repositoryRoot, ['diff', '--patch', '--no-ext-diff', '--find-renames', '--diff-filter=ADMR', options.fromRef, options.toRef, '--', ...paths], { maxBuffer: options.maxBuffer, throwOnError: true });
695 return patch === undefined ? undefined : { patch, tooLarge: false };
696 } catch (error) {
697 if (isMaxBufferError(error)) {
698 return { patch: undefined, tooLarge: true };
699 }
700 throw error;
701 }
702 }
704 > private async _computeSessionGitState(workingDirectory: URI): Promise<ISessionGitState | undefined> {
705 const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
706 if (!repositoryRoot) {
707 return undefined;
708 }
709
710 // Run all probes in parallel. Each handles its own errors and returns
711 // undefined on failure so we can populate fields independently.
712 const [
713 statusOutput,
714 remotesOutput,
715 defaultBranchRef,
716 ] = await Promise.all([
717 this._runGit(repositoryRoot, ['status', '-b', '--porcelain=v2']),
718 this._runGit(repositoryRoot, ['remote', '-v']),
719 this._runGit(repositoryRoot, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']),
720 ]);
721
722 const status = parseGitStatusV2(statusOutput);
723 const hasGitHubRemote = parseHasGitHubRemote(remotesOutput);
724 const baseBranchName = parseDefaultBranchRef(defaultBranchRef);
725 const githubRepo = parseGitHubRepoFromRemote(remotesOutput);
726
727 // `git status -b --porcelain=v2` only emits ahead/behind counts when the
728 // branch has an upstream tracking ref. For agent-host worktrees the
729 // branch is typically created locally with no upstream, so the user can
730 // have committed work that we'd otherwise report as 0 outgoing changes
731 // and the "Create PR" button would never appear. Fall back to counting
732 // commits relative to the base branch — that matches what the user
733 // actually cares about for "is there work to PR?".
734 let outgoingChanges = status.outgoingChanges;
735 if (outgoingChanges === undefined && baseBranchName && status.branchName && status.branchName !== baseBranchName) {
736 const ahead = await this._runGit(repositoryRoot, ['rev-list', '--count', `${baseBranchName}..HEAD`]);
737 const parsed = ahead === undefined ? NaN : Number(ahead.trim());
738 if (Number.isFinite(parsed)) {
739 outgoingChanges = parsed;
740 }
741 }
742
743 const result: ISessionGitState = {
744 hasGitHubRemote,
745 branchName: status.branchName,
746 baseBranchName,
747 upstreamBranchName: status.upstreamBranchName,
748 incomingChanges: status.incomingChanges,
749 outgoingChanges,
750 uncommittedChanges: status.uncommittedChanges,
751 githubOwner: githubRepo?.owner,
752 githubRepo: githubRepo?.repo,
753 };
754 // Strip undefined fields so the resulting object is the same regardless
755 // of which probes succeeded — easier to compare in tests.
756 return stripUndefined(result);
757 }
759 > private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean; readonly env?: Record<string, string>; readonly maxBuffer?: number }): Promise<string | undefined> {
760 this._logService.trace(`[agentHostGitService] > git ${args.join(' ')}`);
761
762 return new Promise((resolve, reject) => {
763 const env = options?.env ? { ...process.env, ...options.env } : undefined;
764 const timeoutMs = options?.timeout ?? 5000;
765 // Use our own timer rather than execFile's `timeout` option so
766 // we can definitively flag the timeout case in the error
767 // message — execFile only surfaces signal/killed, which can
768 // also mean the process was killed for other reasons.
769 let didTimeOut = false;
770 // Default maxBuffer is 32MB — Node's default is ~1MB, which is
771 // easy to exceed for diff output in large repos. Exceeding it
772 // causes execFile to error and we'd silently drop the diff.
773 const child = cp.execFile('git', [...args], { cwd: workingDirectory.fsPath, env, maxBuffer: options?.maxBuffer ?? 32 * 1024 * 1024 }, (error, stdout, stderr) => {
774 if (error) {
775 // stderr is summarized in the thrown error message to keep
776 // it readable; log the full unmodified output here so the
777 // raw progress/diagnostic text is still available.
778 if (stderr) {
779 this._logService.warn(`[agentHostGitService] > git ${args.join(' ')} failed; full stderr:\n${stderr}`);
780 }
781 if (options?.throwOnError) {
782 reject(new Error(formatGitError(args, timeoutMs, didTimeOut, error, stderr), { cause: error }));
783 return;
784 }
785 resolve(undefined);
786 return;
787 }
788 resolve(stdout);
789 });
790 const timer = setTimeout(() => {
791 didTimeOut = true;
792 child.kill();
793 }, timeoutMs);
794 child.on('exit', () => clearTimeout(timer));
795 });
796 }
798 >
799 > /**
800 > * Returns the shallowest directory from `directories` that contains `file`, or
801 > * `undefined` if none does. `file` is a repository-relative, forward-slash path
802 > * and every entry in `directories` is expected to end with a trailing `/` (as
803 > * produced by `git ls-files --directory`). Walking the path's `/` boundaries
804 > * and probing the set is O(path depth) per file, avoiding an O(directories)
805 > * scan for each file.
806 > */
807 function findContainingDirectory(file: string, directories: ReadonlySet<string>): string | undefined {
808 let index = file.indexOf('/');
809 while (index !== -1) {
810 const prefix = file.slice(0, index + 1);
811 if (directories.has(prefix)) {
812 return prefix;
813 }
814 index = file.indexOf('/', index + 1);
815 }
816 return undefined;
817 }
819 > /**
820 > * Builds a diagnostic error message for a failed `git` invocation that
821 > * preserves the reason (timeout / signal / exit code) instead of just
822 > * surfacing whatever happened to be on stderr. When `git` is killed by
823 > * the timeout, stderr often contains only progress output (e.g.
824 > * `Updating files: 0% (149/14834)`), so without the timeout indicator
825 > * the bubbled-up error is misleading.
826 > *
827 > * Exported for tests.
828 > */
829 > export function formatGitError(args: readonly string[], timeoutMs: number, didTimeOut: boolean, error: cp.ExecFileException, stderr: string): string {
830 > const subcommand = args[0] ?? '(unknown)'; agentHostGitService.ts ×3
831 > let reason: string;
832 > if (didTimeOut) {
833 > reason = `git ${subcommand} timed out after ${timeoutMs}ms`; agentHostGitService.ts ×1
834 > } else if (error.killed && error.signal) { agentHostGitService.ts ×3
835 > reason = `git ${subcommand} killed by ${error.signal}`; agentHostGitService.ts ×1
836 > } else if (typeof error.code === 'number') { agentHostGitService.ts ×1
837 > reason = `git ${subcommand} exited with code ${error.code}`; agentHostGitService.ts ×1
839 > reason = error.message; agentHostGitService.ts ×1
840 > }
841 > const detail = summarizeStderrForError(stderr); agentHostGitService.ts ×3
842 > return detail ? `${reason}: ${detail}` : reason;
843 > }
845 > /**
846 > * Squashes multi-line / carriage-return-heavy stderr (e.g. git progress
847 > * meters that emit `Updating files: 0% (149/14834)\r...` repeatedly)
848 > * into a single short line suitable for a one-liner error message.
849 > * Keeps the most recent non-empty line and caps total length.
850 > *
851 > * Exported for tests.
852 > */
853 > export function summarizeStderrForError(stderr: string): string {
854 > if (!stderr) { agentHostGitService.ts ×2
855 > return ''; agentHostGitService.ts ×1
856 > }
857 > const lines = stderr.split(/[\r\n]+/g).map(line => line.trim()).filter(line => line.length > 0); agentHostGitService.ts ×1
858 > if (lines.length === 0) {
859 > return ''; agentHostGitService.ts ×1
860 > }
861 > const MAX = 200; agentHostGitService.ts ×2
862 > const gitLfsMissing = lines.find(line =>
863 > /\bgit-lfs\b/i.test(line) &&
864 > /(command not found|not recognized|no such file)/i.test(line) agentHostGitService.ts ×1
866 > const summary = gitLfsMissing ?? lines[lines.length - 1];
867 > return summary.length > MAX ? `${summary.slice(0, MAX - 1)}…` : summary; agentHostGitService.ts ×2
868 > }
870 > /**
871 > * Parses NUL-separated `git status --porcelain=v1 -z --untracked-files=all`
872 > * output and returns the repo-relative paths of untracked entries (status
873 > * `??`). Other entries are ignored; we only need to know whether any
874 > * untracked files exist to decide whether to use the temp-index path.
875 > *
876 > * Exported for tests.
877 > */
878 > export function parseUntrackedPaths(output: string | undefined): string[] {
879 > return parseChangedPaths(output, status => status === '??'); agentHostGitService.ts ×1
880 > }
882 > /**
883 > * Parses NUL-separated `git status --porcelain=v1 -z --untracked-files=all`
884 > * output and returns all changed repo-relative paths. Rename/copy entries
885 > * include both the destination and source paths so scoped `git add -A`
886 > * stages both sides of the change.
887 > *
888 > * Exported for tests.
889 > */
890 > export function parseChangedPaths(output: string | undefined, includeStatus: (status: string) => boolean = () => true): string[] {
891 > if (!output) { agentHostGitService.ts ×1
892 > return []; agentHostGitService.ts ×1
893 > }
894 > const result: string[] = []; agentHostGitService.ts ×3
895 > const seen = new Set<string>();
896 > const addPath = (path: string) => {
897 > if (path && !seen.has(path)) {
898 > seen.add(path);
899 > result.push(path);
900 > }
901 > };
902 > const segments = output.split('\x00');
903 > for (let i = 0; i < segments.length; i++) {
904 > const seg = segments[i];
905 > if (!seg) { continue; }
906 > // Each entry is "XY <path>"; for renames v1 emits a second NUL-separated
907 > // "from" path.
908 > const status = seg.substring(0, 2);
909 > const path = seg.substring(3);
910 > const isRenameOrCopy = status[0] === 'R' || status[1] === 'R' || status[0] === 'C' || status[1] === 'C';
911 > if (includeStatus(status)) {
912 > addPath(path);
913 > if (isRenameOrCopy) {
914 > const sourcePath = segments[++i]; agentHostGitService.ts ×1
915 > if (sourcePath) {
916 > addPath(sourcePath);
917 > }
918 > }
919 > } else if (isRenameOrCopy) { agentHostGitService.ts ×3
921 > }
923 > return result;
924 > }
926 > /**
927 > * Parses NUL-terminated `git ls-tree -z <tree> -- <path>` output for a single
928 > * path and returns its `{ mode, oid }`, or `undefined` when the path is absent
929 > * from the tree (empty output). Each entry has the form
930 > * `<mode> SP <type> SP <oid> TAB <path> NUL`; we only need the mode and oid.
931 > *
932 > * Exported for tests.
933 > */
934 > export function parseSingleLsTreeEntry(output: string | undefined): { mode: string; oid: string } | undefined {
935 > if (!output) { agentHostGitService.ts ×3
936 > return undefined;
937 > }
938 > const entry = output.split('\x00')[0];
939 > if (!entry) {
940 return undefined;
941 }
942 > const tabIndex = entry.indexOf('\t'); agentHostGitService.ts ×3
943 > const meta = (tabIndex === -1 ? entry : entry.substring(0, tabIndex)).split(' ');
944 > if (meta.length < 3) {
945 return undefined;
946 }
947 > return { mode: meta[0], oid: meta[2] }; agentHostGitService.ts ×3
948 > }
950 > /**
951 > * Parses combined `--raw --numstat -z` output produced by
952 > * {@link IAgentHostGitService.computeSessionFileDiffs} and converts each
953 > * change into an {@link ISessionFileDiff} ready for the protocol.
954 > *
955 > * The combined NUL-separated stream alternates between `--raw` segments
956 > * (start with `:`) and `--numstat` segments. For renames the raw segment
957 > * is followed by two extra path segments (old, new); the numstat segment
958 > * has an empty path field followed by old/new path segments.
959 > *
960 > * `beforeRef` is the commit the `before` side is anchored on (typically a
961 > * merge-base or the lower bound of a ref-to-ref diff).
962 > *
963 > * `afterRef` controls how the `after` side is built:
964 > * - When `undefined` (the merge-base → working-tree case) the `after`
965 > * content URI points at the on-disk working-tree file. The diff editor
966 > * reads the file from disk as the user currently sees it.
967 > * - When set (the ref → ref case, e.g. checkpoint diffs) both `after.uri`
968 > * and `after.content.uri` are built as `git-blob:` URIs anchored on that
969 > * commit, so the after pane reflects the state at that commit
970 > * regardless of what is currently on disk. This also makes the diff
971 > * correct when the file does not (or no longer) exists in the working
972 > * tree.
973 > *
974 > * Exported for tests.
975 > */
976 > export function parseGitDiffRawNumstat(output: string, repositoryRoot: URI, sessionUri: string, beforeRef: string, afterRef?: string): ISessionFileDiff[] {
977 > const segments = output.split('\x00'); agentHostGitService.ts ×3
978 > const changes: { kind: FileEditKind; oldPath?: string; newPath?: string }[] = [];
979 > const numStats = new Map<string, { added: number; removed: number }>();
980 >
981 > let i = 0;
982 > while (i < segments.length) {
983 > const segment = segments[i++];
984 > if (!segment) { continue; }
986 > if (segment.startsWith(':')) {
987 > // Raw line: ":<srcMode> <dstMode> <srcSha> <dstSha> <status>"
988 > // followed by NUL-separated path(s).
989 > const fields = segment.split(' ');
990 > const status = fields[4] ?? '';
991 > const path1 = segments[i++];
992 > if (!path1) { continue; }
993 >
994 > switch (status[0]) {
995 > case 'A':
996 > changes.push({ kind: FileEditKind.Create, newPath: path1 }); agentHostGitService.ts ×6
997 > break;
998 > case 'M': agentHostGitService.ts ×10
999 > changes.push({ kind: FileEditKind.Edit, oldPath: path1, newPath: path1 });
1000 > break;
1001 > case 'D':
1002 > changes.push({ kind: FileEditKind.Delete, oldPath: path1 }); agentHostGitService.ts ×6
1003 > break;
1004 > case 'R': { agentHostGitService.ts ×10
1005 > const path2 = segments[i++]; agentHostGitService.ts ×6
1006 > if (!path2) { continue; }
1007 > changes.push({ kind: FileEditKind.Rename, oldPath: path1, newPath: path2 });
1008 > break;
1009 > }
1010 > default: agentHostGitService.ts ×10
1011 break;
1013 > } else {
1014 > // Numstat line: "<added>\t<removed>\t<path>" or, for renames,
1015 > // "<added>\t<removed>\t" followed by NUL-separated old/new paths.
1016 > const [addedStr, removedStr, filePath] = segment.split('\t');
1017 > let key: string;
1018 > if (filePath === '' || filePath === undefined) {
1019 > const oldPath = segments[i++]; agentHostGitService.ts ×6
1020 > const newPath = segments[i++];
1021 > key = newPath ?? oldPath ?? '';
1022 > } else { agentHostGitService.ts ×10
1023 > key = filePath;
1024 > }
1025 > if (!key) { continue; }
1026 > numStats.set(key, {
1027 > added: addedStr === '-' ? 0 : Number(addedStr) || 0,
1028 > removed: removedStr === '-' ? 0 : Number(removedStr) || 0,
1029 > });
1030 > }
1032 >
1033 > return changes.map(change => {
1034 > const stats = numStats.get(change.newPath ?? change.oldPath ?? ''); agentHostGitService.ts ×10
1035 >
1036 > const beforeFileUri = change.oldPath ? URI.joinPath(repositoryRoot, change.oldPath) : undefined;
1037 > const afterFileUri = change.newPath ? URI.joinPath(repositoryRoot, change.newPath) : undefined;
1038 >
1039 > const before = change.kind !== FileEditKind.Create && change.oldPath && beforeFileUri
1040 > ? {
1041 > uri: beforeFileUri.toString(),
1042 > content: { uri: buildGitBlobUri(sessionUri, beforeRef, change.oldPath, beforeFileUri.path) },
1043 > }
1044 > : undefined; agentHostGitService.ts ×6
1046 > const after = change.kind !== FileEditKind.Delete && change.newPath && afterFileUri
1047 > ? {
1048 > uri: afterFileUri.toString(),
1049 > content: afterRef !== undefined
1050 > ? { uri: buildGitBlobUri(sessionUri, afterRef, change.newPath, afterFileUri.path) } agentHostGitService.ts ×1
1051 > : { uri: afterFileUri.toString() } agentHostGitService.ts ×1
1053 > : undefined; agentHostGitService.ts ×6
1055 > const diff = {
1056 > added: stats?.added ?? 0,
1057 > removed: stats?.removed ?? 0
1058 > };
1059 >
1060 > return {
1061 > ...(before ? { before } : {}),
1062 > ...(after ? { after } : {}),
1063 > diff
1064 > };
1066 > }
1068 > /**
1069 > * Parses output of `git status -b --porcelain=v2`. The format is documented
1070 > * at https://git-scm.com/docs/git-status. We care about a few header lines:
1071 > *
1072 > * # branch.head <name>
1073 > * # branch.upstream <name>
1074 > * # branch.ab +<ahead> -<behind>
1075 > *
1076 > * and the count of non-header lines (one per changed entry).
1077 > *
1078 > * Exported for tests.
1079 > */
1080 > export function parseGitStatusV2(output: string | undefined): {
1081 > branchName?: string; agentHostGitService.ts ×1
1082 > upstreamBranchName?: string;
1083 > outgoingChanges?: number;
1084 > incomingChanges?: number;
1085 > uncommittedChanges?: number;
1086 > } {
1087 > if (!output) {
1088 > return {}; agentHostGitService.ts ×1
1089 > }
1090 > let branchName: string | undefined; agentHostGitService.ts ×4
1091 > let upstreamBranchName: string | undefined;
1092 > let outgoingChanges: number | undefined;
1093 > let incomingChanges: number | undefined;
1094 > let uncommittedChanges = 0;
1095 > for (const rawLine of output.split(/\r?\n/g)) {
1096 > const line = rawLine.trimEnd();
1097 > if (!line) { continue; }
1098 > if (line.startsWith('# branch.head ')) {
1099 > const head = line.substring('# branch.head '.length).trim();
1100 > // `(detached)` is what git emits for a detached HEAD. Treat as no branch.
1101 > branchName = head === '(detached)' ? undefined : head;
1102 > } else if (line.startsWith('# branch.upstream ')) {
1103 > upstreamBranchName = line.substring('# branch.upstream '.length).trim(); agentHostGitService.ts ×2
1104 > } else if (line.startsWith('# branch.ab ')) { agentHostGitService.ts ×4
1105 > const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line); agentHostGitService.ts ×2
1106 > if (m) {
1107 > outgoingChanges = Number(m[1]);
1108 > incomingChanges = Number(m[2]);
1109 > }
1110 > } else if (!line.startsWith('#')) { agentHostGitService.ts ×4
1111 > uncommittedChanges++; agentHostGitService.ts ×1
1112 > }
1114 > return { branchName, upstreamBranchName, outgoingChanges, incomingChanges, uncommittedChanges };
1115 > }
1117 > /** Exported for tests. */
1118 > export function parseHasGitHubRemote(remotesOutput: string | undefined): boolean | undefined {
1119 > if (remotesOutput === undefined) { agentHostGitService.ts ×1
1120 > return undefined; agentHostGitService.ts ×1
1121 > }
1122 > if (!remotesOutput.trim()) { agentHostGitService.ts ×1
1123 > return false; agentHostGitService.ts ×1
1124 > }
1125 > return /github\.com[:\/]/i.test(remotesOutput); agentHostGitService.ts ×1
1126 > }
1128 > /** Returns fetch remote URLs with the preferred remote, then `origin`, first. */
1129 > export function parseFetchRemoteUrls(remotesOutput: string | undefined, preferredRemote?: string): string[] | undefined {
1130 > if (remotesOutput === undefined) { agentHostGitService.ts ×3
1131 > return undefined; agentHostGitService.ts ×2
1132 > }
1133 > const candidates: { name: string; url: string }[] = []; agentHostGitService.ts ×3
1134 > for (const rawLine of remotesOutput.split(/\r?\n/)) {
1135 > const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(rawLine.trim());
1136 > if (match) {
1137 > candidates.push({ name: match[1], url: match[2] }); agentHostGitService.ts ×1
1138 > }
1140 > const preferredNames = new Set([preferredRemote, 'origin'].filter((name): name is string => Boolean(name)));
1141 > const ordered = [
1142 > ...candidates.filter(candidate => candidate.name === preferredRemote),
1143 > ...candidates.filter(candidate => candidate.name === 'origin' && candidate.name !== preferredRemote),
1144 > ...candidates.filter(candidate => !preferredNames.has(candidate.name)),
1145 > ];
1146 > return [...new Set(ordered.map(candidate => candidate.url))];
1147 > }
1149 > /**
1150 > * Parse `owner` and `repo` from `git remote -v` output. Prefers the `origin`
1151 > * remote; falls back to the first GitHub remote so worktrees that renamed
1152 > * the remote still surface PR state. Returns `undefined` if no GitHub
1153 > * remote is present or the URL doesn't match a GitHub repo shape.
1154 > *
1155 > * Exported for tests.
1156 > */
1157 > export function parseGitHubRepoFromRemote(remotesOutput: string | undefined): { owner: string; repo: string } | undefined {
1158 > const candidates = parseFetchRemoteUrls(remotesOutput); agentHostGitService.ts ×2
1159 > if (!candidates) {
1160 > return undefined; agentHostGitService.ts ×2
1161 > }
1162 > for (const url of candidates) { agentHostGitService.ts ×2
1163 > const parsed = parseGitHubOwnerRepoFromUrl(url); agentHostGitService.ts ×3
1164 > if (parsed) {
1165 > return parsed; agentHostGitService.ts ×1
1166 > }
1168 > return undefined; agentHostGitService.ts ×1
1169 > }
1171 > /**
1172 > * Extract `{owner, repo}` from a GitHub remote URL. Handles the common
1173 > * forms: `[email protected]:owner/repo(.git)?`, `https://github.com/owner/repo(.git)?`,
1174 > * `ssh://[email protected]/owner/repo(.git)?`, `git://github.com/owner/repo(.git)?`.
1175 > */
1176 > function parseGitHubOwnerRepoFromUrl(url: string): { owner: string; repo: string } | undefined { agentHostGitService.ts ×3
1177 > // SCP-like: [email protected]:owner/repo(.git)?
1178 > let m = /^[^@\s]+@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(url);
1179 > if (m) {
1180 > return { owner: m[1], repo: m[2] }; agentHostGitService.ts ×1
1181 > }
1182 > // URL-form: <scheme>://[user@]github.com[:port]/owner/repo(.git)? agentHostGitService.ts ×1
1183 > m = /^[a-z+]+:\/\/(?:[^@\/\s]+@)?github\.com(?::\d+)?\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(url);
1184 > if (m) {
1185 > return { owner: m[1], repo: m[2] }; agentHostGitService.ts ×1
1186 > }
1187 > return undefined; agentHostGitService.ts ×1
1188 > }
1190 > /** Exported for tests. */
1191 > export function parseDefaultBranchRef(symbolicRefOutput: string | undefined): string | undefined {
1192 > const ref = symbolicRefOutput?.trim(); agentHostGitService.ts ×2
1193 > if (!ref) { return undefined; }
1194 > const prefix = 'refs/remotes/origin/'; agentHostGitService.ts ×1
1195 > return ref.startsWith(prefix) ? ref.substring(prefix.length) : ref; agentHostGitService.ts ×2
1196 > }
1198 > export function parseRemoteBranchRef(ref: string): { ref: string; name: string; remote: string } | undefined {
1199 if (!ref.startsWith('refs/remotes/')) {
1200 return undefined;
1201 }
1202
1203 const name = ref.substring(13);
1204 const remote = name.split('/')[0];
1205 return { ref, name, remote };
1206 }
1208 > export function parseGitRefs(output: string | undefined): GitRef[] {
1209 if (!output) {
1210 return [];
1211 }
1212
1213 const refs: GitRef[] = [];
1214 for (const line of output.split(/\r?\n/g)) {
1215 const [ref, upstream] = line.trim().split('\0');
1216
1217 if (ref.startsWith('refs/heads/')) {
1218 refs.push({
1219 ref,
1220 name: ref.substring(11),
1221 upstream: upstream
1222 ? parseRemoteBranchRef(upstream)
1223 : undefined,
1224 kind: GitRefType.Head
1225 } satisfies IBranch);
1226 } else if (ref.startsWith('refs/remotes/') && !/^refs\/remotes\/[^/]+\/HEAD$/.test(ref)) {
1227 const parsedRemoteBranch = parseRemoteBranchRef(ref);
1228 if (parsedRemoteBranch) {
1229 refs.push({
1230 ...parsedRemoteBranch,
1231 kind: GitRefType.RemoteHead
1232 } satisfies IRemoteBranch);
1233 }
1234 } else if (ref.startsWith('refs/tags/')) {
1235 refs.push({
1236 ref,
1237 name: ref.substring(10),
1238 kind: GitRefType.Tag
1239 } satisfies ITag);
1240 }
1241 }
1242
1243 return refs;
1244 }
1246 function stripUndefined<T extends object>(obj: T): T {
1247 const out: Record<string, unknown> = {};
1248 for (const [k, v] of Object.entries(obj)) {
1249 if (v !== undefined) { out[k] = v; }
1250 }
1251 return out as T;
1252 }
1254 function isMaxBufferError(error: unknown): boolean {
1255 const cause = error instanceof Error ? error.cause : undefined;
1256 return cause instanceof Error && (cause as cp.ExecFileException).code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
1257 }