Every VS Code extension tutorial on the web seems to stop at “hello world” commands or chat panels built on the Language Model API. Almost none of them tackle the part of the Extension API that professional tooling teams actually reach for when they need to put live, structured project data in front of a developer: the Tree View API paired with the Source Control Management (SCM) API. This guide builds a real one, a Git-aware sidebar panel that shows the current branch, staged and unstaged changes, and recent commits, directly inside VS Code and Cursor, using nothing but the extension APIs Microsoft ships today.
By the end you will have a working extension that activates in both editors, refreshes itself when the repository changes, and exposes quick actions like stage, unstage, and open-file straight from the sidebar. Along the way you will hit the same traps that catch most first-time extension authors: activation events that never fire, tree items that render but never refresh, and Git API handles that disappear mid-session. We cover all of it, with code you can copy into a working project today.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why build a Tree View + SCM extension in late 2026
The VS Code Marketplace has grown past 55,000 extensions with total installs reported north of 5 billion, and the catalog adds more than 400 new extensions a month, according to the 2026 VS Code statistics roundup from Skillademia. That volume tells you two things: the platform is mature enough that the core extension APIs are stable, and the market is crowded enough that generic “hello world” extensions do not stand out. A Git-aware sidebar panel is different. It solves a concrete problem, seeing repository state without leaving the editor, using two APIs, Tree View and SCM, that most tutorials skip because they are slightly harder to wire together than a simple command palette entry.
Timing also matters here. VS Code shipped version 1.140 on September 30, 2026, following 1.139’s push into faster agent session lists and expanded remote Dev Container support for SSH, Tunnel, and WSL projects, per the GitHub releases page for microsoft/vscode. Cursor, which runs on a fork of the same extension host, published its own changelog update on September 23, 2026 (build 3.20) adding a Security Review bot for pull requests and a beta Projects feature that coordinates shared file context across a repository. Both editors are leaning harder into repository-level awareness right now, which is exactly the terrain the Tree View and SCM APIs were built for.
There’s a practical reason to learn these two APIs together rather than separately. The Tree View API gives you a place to render hierarchical data in the Activity Bar or a panel. The SCM API gives you a structured way to read and act on Git state, staged resources, commit history, branch info, without shelling out to raw git commands yourself. Combine them and you get a sidebar that behaves like a first-class part of the editor instead of a bolted-on webview.
Prerequisites and tool versions
Confirm these versions before starting. Extension API behavior shifts slightly between releases, and the code in this tutorial targets the APIs as they ship in the versions below.
| Tool | Minimum version | Notes |
|---|---|---|
| Visual Studio Code | 1.140.0 | Released September 30, 2026; Extension API is stable across 1.9x to 1.14x |
| Cursor | 3.20 | Released September 23, 2026; uses the same extension host as VS Code |
| Node.js | 20.x LTS or newer | Required by @vscode/vsce and yo generator-code |
| npm | 10.x | Ships with Node 20 |
| TypeScript | 5.6 or newer | Installed as a devDependency in the generated project |
| Git | 2.40 or newer | Required for the built-in vscode.git extension to expose its API |
| Yeoman + generator-code | Latest via npm | Scaffolds the extension project structure |
You also need a GitHub or Open VSX publisher account if you plan to distribute the extension later, though that step is optional for local development and testing. Nothing in this tutorial requires a paid account, a cloud subscription, or special hardware; everything runs locally in under an hour on a normal development laptop.
Step 1: Scaffold the extension project
Start by generating the base extension project with Yeoman. This avoids hand-writing boilerplate for the extension manifest, TypeScript config, and bundler setup.
npm install -g yo generator-code
yo code
# Select: New Extension (TypeScript)
# Name: git-sidebar-tree
# Identifier: git-sidebar-tree
# Description: Git-aware sidebar for VS Code and Cursor
# Initialize git repository: Yes
# Bundler: webpack
# Package manager: npm
Once the generator finishes, open the folder in VS Code (or Cursor) and run npm install to pull down the generated dependencies. You should see a src/extension.ts file with an empty activate() function and a package.json manifest. That manifest is where the real configuration work for a Tree View extension happens, so open it next.
Step 2: Declare the view container and view in package.json
Tree Views in VS Code are not created purely in code. You declare a view container (the icon group in the Activity Bar) and a view (the panel itself) in the extension manifest, then bind a data provider to that view ID at activation time. Add this to package.json inside the contributes block:
{
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "gitSidebar",
"title": "Git Sidebar",
"icon": "media/git-icon.svg"
}
]
},
"views": {
"gitSidebar": [
{
"id": "gitSidebar.repoTree",
"name": "Repository",
"icon": "media/git-icon.svg",
"contextualTitle": "Git Sidebar"
}
]
},
"commands": [
{ "command": "gitSidebar.refresh", "title": "Refresh", "icon": "$(refresh)" },
{ "command": "gitSidebar.stageFile", "title": "Stage File", "icon": "$(add)" },
{ "command": "gitSidebar.unstageFile", "title": "Unstage File", "icon": "$(remove)" },
{ "command": "gitSidebar.openFile", "title": "Open File" }
],
"menus": {
"view/title": [
{ "command": "gitSidebar.refresh", "when": "view == gitSidebar.repoTree", "group": "navigation" }
],
"view/item/context": [
{ "command": "gitSidebar.stageFile", "when": "view == gitSidebar.repoTree && viewItem == unstagedFile", "group": "inline" },
{ "command": "gitSidebar.unstageFile", "when": "view == gitSidebar.repoTree && viewItem == stagedFile", "group": "inline" }
]
}
},
"activationEvents": [
"onView:gitSidebar.repoTree"
]
}
Note the activationEvents entry. A narrow activation event like onView:gitSidebar.repoTree means your extension only wakes up when the user opens that specific view, not on every VS Code startup. This matters more than it sounds. With the Marketplace sitting at roughly 60,000 extensions and 45,000 publishers per the Koi Security research cited in Security Boulevard’s May 2026 extension-security report, editor startup time is a real competitive factor, and broad activation events like a bare wildcard are one of the most common reasons an extension gets flagged as poorly behaved in reviews.
Step 3: Build the TreeDataProvider
The TreeDataProvider interface is the contract VS Code uses to ask your extension what items exist at this level of the tree, and what they look like. Create a new file src/gitTreeProvider.ts:
import * as vscode from 'vscode';
import { GitExtension, Repository, Change } from './git';
export class GitTreeItem extends vscode.TreeItem {
constructor(
public readonly label: string,
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
public readonly contextValue: string,
public readonly resourceUri?: vscode.Uri,
public readonly command?: vscode.Command
) {
super(label, collapsibleState);
this.contextValue = contextValue;
this.resourceUri = resourceUri;
}
}
export class GitTreeProvider implements vscode.TreeDataProvider {
private _onDidChangeTreeData = new vscode.EventEmitter();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private repo: Repository | undefined;
constructor() {
this.initGitApi();
}
private async initGitApi() {
const gitExtension = vscode.extensions.getExtension('vscode.git');
if (!gitExtension) {
vscode.window.showWarningMessage('Git Sidebar: built-in Git extension not found.');
return;
}
const api = gitExtension.isActive
? gitExtension.exports.getAPI(1)
: (await gitExtension.activate()).getAPI(1);
if (api.repositories.length > 0) {
this.repo = api.repositories[0];
this.repo.state.onDidChange(() => this.refresh());
}
api.onDidOpenRepository((repo) => {
this.repo = repo;
repo.state.onDidChange(() => this.refresh());
this.refresh();
});
}
refresh(): void {
this._onDidChangeTreeData.fire();
}
getTreeItem(element: GitTreeItem): vscode.TreeItem {
return element;
}
async getChildren(element?: GitTreeItem): Promise {
if (!this.repo) return [new GitTreeItem('No repository detected', vscode.TreeItemCollapsibleState.None, 'empty')];
if (!element) {
const branch = this.repo.state.HEAD?.name ?? 'detached HEAD';
return [
new GitTreeItem(`Branch: ${branch}`, vscode.TreeItemCollapsibleState.None, 'branch'),
new GitTreeItem(`Staged (${this.repo.state.indexChanges.length})`, vscode.TreeItemCollapsibleState.Expanded, 'stagedGroup'),
new GitTreeItem(`Changes (${this.repo.state.workingTreeChanges.length})`, vscode.TreeItemCollapsibleState.Expanded, 'unstagedGroup')
];
}
if (element.contextValue === 'stagedGroup') {
return this.repo.state.indexChanges.map((c: Change) => this.toFileItem(c, 'stagedFile'));
}
if (element.contextValue === 'unstagedGroup') {
return this.repo.state.workingTreeChanges.map((c: Change) => this.toFileItem(c, 'unstagedFile'));
}
return [];
}
private toFileItem(change: Change, contextValue: string): GitTreeItem {
const fileName = change.uri.path.split('/').pop() ?? change.uri.path;
return new GitTreeItem(fileName, vscode.TreeItemCollapsibleState.None, contextValue, change.uri, {
command: 'vscode.open',
title: 'Open File',
arguments: [change.uri]
});
}
}
This file does the real work. It pulls the built-in vscode.git extension’s API (version 1, the stable surface Microsoft has supported for years), subscribes to repository state changes, and maps staged versus unstaged changes into tree items. The key detail most tutorials get wrong is subscribing to repo.state.onDidChange rather than polling. Polling a Git repository on an interval wastes CPU and battery; the event-driven approach only fires when Git state actually changes.
Step 4: Register the provider and commands in extension.ts
Now wire the provider into the extension’s activation lifecycle. Replace the contents of src/extension.ts with:
import * as vscode from 'vscode';
import { GitTreeProvider } from './gitTreeProvider';
export function activate(context: vscode.ExtensionContext) {
const treeProvider = new GitTreeProvider();
const view = vscode.window.createTreeView('gitSidebar.repoTree', {
treeDataProvider: treeProvider,
showCollapseAll: true
});
context.subscriptions.push(view);
context.subscriptions.push(
vscode.commands.registerCommand('gitSidebar.refresh', () => treeProvider.refresh())
);
context.subscriptions.push(
vscode.commands.registerCommand('gitSidebar.stageFile', async (item) => {
const gitExtension = vscode.extensions.getExtension('vscode.git');
const api = gitExtension?.exports.getAPI(1);
const repo = api?.repositories[0];
if (repo && item.resourceUri) {
await repo.add([item.resourceUri.fsPath]);
treeProvider.refresh();
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('gitSidebar.unstageFile', async (item) => {
const gitExtension = vscode.extensions.getExtension('vscode.git');
const api = gitExtension?.exports.getAPI(1);
const repo = api?.repositories[0];
if (repo && item.resourceUri) {
await repo.revert([item.resourceUri.fsPath]);
treeProvider.refresh();
}
})
);
}
export function deactivate() {}
Every subscription goes into context.subscriptions. This is not optional boilerplate, it is how VS Code knows to dispose of your event listeners and commands when the extension deactivates. Skipping it is one of the most common sources of memory leaks in published extensions, and it is invisible in local testing because you rarely deactivate and reactivate an extension hundreds of times in a single debug session the way a real user’s editor does over weeks of uptime.
Step 5: Add the activity bar icon
Create a media folder in your project root and add a simple SVG icon referenced in the manifest. VS Code recolors monochrome SVGs automatically to match the current theme, so keep the icon as a single-color silhouette:
mkdir media
cat > media/git-icon.svg << 'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#C5C5C5" d="M8 1a1.5 1.5 0 100 3 1.5 1.5 0 000-3zM4 6a1.5 1.5 0 100 3 1.5 1.5 0 000-3zm8 0a1.5 1.5 0 100 3 1.5 1.5 0 000-3zM4 10.5V8m8 2.5V8M8 4v2.5"/>
</svg>
EOF
If the icon does not appear after reload, check that the SVG path in package.json matches the actual file path exactly, including case. VS Code does not throw a visible error for a missing icon file; the Activity Bar entry just silently falls back to a generic placeholder, which is a frustrating debugging experience the first time it happens.
Step 6: Run and debug the extension
Press F5 in VS Code (or Cursor) to launch an Extension Development Host window with your extension loaded. Open any folder that contains a Git repository, click the new icon in the Activity Bar, and you should see the branch name, staged count, and unstaged count render as top-level tree nodes.
Set a breakpoint inside getChildren() in gitTreeProvider.ts and make a change to a tracked file in the test repository. The breakpoint should hit again automatically when refresh() fires from the Git state-change event, without you touching the sidebar. If it does not hit, the most common cause is that the built-in Git extension has not finished scanning the folder yet when your initGitApi() runs, which Step 10 covers under troubleshooting.
Step 7: Add recent commit history to the tree
A sidebar that only shows staged and unstaged files is useful, but recent commit history adds real value for anyone jumping into an unfamiliar branch. Extend getChildren() to add a third top-level group:
if (!element) {
const branch = this.repo.state.HEAD?.name ?? 'detached HEAD';
return [
new GitTreeItem(`Branch: ${branch}`, vscode.TreeItemCollapsibleState.None, 'branch'),
new GitTreeItem(`Staged (${this.repo.state.indexChanges.length})`, vscode.TreeItemCollapsibleState.Expanded, 'stagedGroup'),
new GitTreeItem(`Changes (${this.repo.state.workingTreeChanges.length})`, vscode.TreeItemCollapsibleState.Expanded, 'unstagedGroup'),
new GitTreeItem('Recent Commits', vscode.TreeItemCollapsibleState.Collapsed, 'commitGroup')
];
}
if (element.contextValue === 'commitGroup') {
const log = await this.repo.log({ maxEntries: 10 });
return log.map((commit) =>
new GitTreeItem(
`${commit.hash.substring(0, 7)} ${commit.message.split('\n')[0]}`,
vscode.TreeItemCollapsibleState.None,
'commit'
)
);
}
The repo.log() method is part of the same Git extension API surface and accepts a maxEntries option so you are not pulling the entire project history into memory on every refresh. Ten entries is a reasonable default; anything beyond that starts to clutter the sidebar without adding much practical value.
Step 8: Add a status bar summary
Pairing the tree view with a lightweight status bar item gives users an at-a-glance signal without opening the sidebar at all. Add this to activate():
const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
statusBarItem.command = 'gitSidebar.refresh';
context.subscriptions.push(statusBarItem);
function updateStatusBar(repo: any) {
if (!repo) {
statusBarItem.hide();
return;
}
const staged = repo.state.indexChanges.length;
const unstaged = repo.state.workingTreeChanges.length;
statusBarItem.text = `$(git-branch) ${repo.state.HEAD?.name ?? '?'} $(diff) ${staged + unstaged}`;
statusBarItem.tooltip = `${staged} staged, ${unstaged} unstaged changes`;
statusBarItem.show();
}
Call updateStatusBar() from the same event handler that triggers treeProvider.refresh() so both UI surfaces update in lockstep. Keeping them in sync, rather than maintaining two separate polling loops, is what stops the sidebar and status bar from occasionally disagreeing with each other after a rapid sequence of stages and unstages.
Step 9: Package the extension with vsce
Once the extension works in the debug host, package it into a distributable .vsix file:
npm install -g @vscode/vsce
vsce package
# Output: git-sidebar-tree-0.0.1.vsix
Install the packaged file locally in either editor to confirm it behaves the same outside the debug host:
code --install-extension git-sidebar-tree-0.0.1.vsix
# or in Cursor
cursor --install-extension git-sidebar-tree-0.0.1.vsix
Test installation from a packaged .vsix matters because the webpack bundling step can behave differently from the unbundled TypeScript the debug host runs directly. Catching a bundling issue here, before publishing, saves you from a broken release reaching real users.
Step 10: Verify cross-editor compatibility with Cursor
Because Cursor forks the VS Code extension host, most extensions that rely on stable, non-proposed APIs work without modification. Install the .vsix in Cursor 3.20 using the command above, open a Git repository, and confirm the sidebar renders identically. The one thing worth double-checking is the vscode.git extension ID. Cursor ships its own bundled Git integration under the same extension ID, so vscode.extensions.getExtension('vscode.git') should resolve correctly without any Cursor-specific branching in your code.
If you want to go further, Cursor's September 2026 changelog update introduced a beta Projects feature built around a coordinator agent with shared file context, according to Cursor's own changelog. A natural extension of this tutorial's sidebar, once Projects exits beta, would be surfacing which files the coordinator agent has touched recently alongside the Git state, though that integration depends on APIs Cursor has not yet stabilized for third-party extensions.
Output example: what the sidebar looks like once wired up
After completing the steps above, opening a repository with pending changes produces a tree structured like this in the Activity Bar panel:
Git Sidebar
|-- Branch: feature/sidebar-tree
|-- Staged (2)
| |-- gitTreeProvider.ts
| `-- package.json
|-- Changes (3)
| |-- extension.ts
| |-- README.md
| `-- media/git-icon.svg
`-- Recent Commits
|-- a3f91c2 Add tree data provider
|-- 7bd402e Wire up stage/unstage commands
`-- 1ef88aa Initial scaffold from yo code
Clicking any file under Staged or Changes opens it in the editor via the vscode.open command bound earlier. Hovering over a file in the Changes group reveals the inline stage icon; hovering over a file in Staged reveals the inline unstage icon, both driven by the view/item/context menu contributions with the inline group from Step 2.
Common pitfalls when building Tree View and SCM extensions
Activation timing issues
The single most common failure mode is calling getExtension('vscode.git') before the Git extension itself has activated. Because the built-in Git extension activates lazily based on workspace content, your code may run a split second before gitExtension.exports exists. Always check gitExtension.isActive and call .activate() explicitly if it has not started yet, exactly as shown in Step 3's initGitApi() method. Skipping that check produces an extension that works most of the time and silently does nothing on a freshly opened window, which is a miserable bug to chase because it is intermittent.
Forgetting to dispose event listeners
Every call to onDidChange, registerCommand, or createTreeView returns a disposable. If you do not push it into context.subscriptions, VS Code has no way to clean it up when the extension deactivates, and in long editor sessions this leaks memory and can cause duplicate event firing if the extension ever reactivates without a full editor restart.
Over-broad activation events
Declaring a wildcard activation event is tempting because it just works during development, but it means your extension loads on every single VS Code startup regardless of whether the user ever opens your view. With the Marketplace now carrying over 55,000 extensions and average installs across all extensions exceeding 5 billion per Skillademia's 2026 data, editor startup time is a shared resource every extension author is competing for. Use the specific onView: event instead.
Polling instead of subscribing to state changes
Setting an interval timer to re-check Git status every few seconds is a shortcut that works in a demo and performs badly in practice. It burns CPU cycles even when nothing has changed and introduces a visible lag between an actual file change and the sidebar updating. The repo.state.onDidChange event exists specifically to avoid this pattern.
Assuming a single repository
The Git API exposes an array of repositories, not a single one, because VS Code supports multi-root workspaces where more than one Git repository can be open simultaneously. The code above uses api.repositories[0] for simplicity, but a production-grade extension should listen to api.onDidOpenRepository and api.onDidCloseRepository to track the full set, and expose a repository picker if more than one is active.
Not handling the no-repository state
If a user opens a folder with no Git repository at all, api.repositories is an empty array and this.repo stays undefined. Without an explicit check, calling this.repo.state throws a TypeError that surfaces as a generic extension host error message in VS Code's output panel, which gives the user zero information about what actually went wrong.
Icon path case sensitivity
On macOS and Windows, filesystem paths are case-insensitive by default, so a mismatched icon path like an uppercase folder or file name versus the actual lowercase path will work fine on your machine and fail silently once the extension is packaged and installed on a Linux machine, where the filesystem is case-sensitive. This is a frequent source of works-on-my-machine bug reports after publishing.
Troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Sidebar icon never appears in Activity Bar | Icon file path mismatch or missing viewsContainers block | Verify the SVG path matches package.json exactly, case included |
| Tree shows "No repository detected" in a valid Git folder | vscode.git extension not yet activated when your code queries it | Await gitExtension.activate() before calling getAPI(1) |
| Tree never refreshes after staging a file | Missing or unwired onDidChangeTreeData emitter | Confirm the fire() method is called inside the state-change handler |
| Extension host crashes on folder with no Git repo | Code assumes this.repo is always defined | Add explicit undefined checks before accessing repo.state |
| Commands registered twice, duplicate error on reload | Command registered outside context.subscriptions, surviving a hot reload | Always push registerCommand results into subscriptions |
| Inline stage/unstage icons missing from tree items | contextValue on tree item does not match the when clause in view/item/context | Double-check exact string match between code and manifest |
| Extension works in VS Code but not Cursor | Code references a proposed, unstable API not yet mirrored in Cursor's fork | Check the API against the stable vscode.d.ts, avoid engines.vscode ranges tied to insider builds |
| .vsix installs but view never loads | Webpack bundling excluded a required file or the entry point in package.json main field is wrong | Confirm main points to the bundled dist/extension.js, not the raw src/extension.ts |
| High CPU usage while extension is idle | A leftover polling loop from early prototyping | Replace polling with the onDidChange event subscription |
Advanced tips for production-grade extensions
Once the base extension works, a few refinements separate a demo from something you would actually ship to the Marketplace or Open VSX.
First, debounce the refresh calls. Git state can fire several change events in rapid succession during operations like a rebase or a large stage/unstage batch. Wrapping refresh() in a simple debounce of 100 to 200 milliseconds prevents the tree from re-rendering dozens of times for what is effectively one logical user action.
Second, cache the commit log. Calling repo.log() on every tree expansion is wasteful if the user is just collapsing and re-expanding the same node. Store the last fetched log alongside a timestamp and only re-fetch if the Git state's HEAD commit hash has changed since the last fetch.
Third, respect the resourceUri property on each TreeItem fully. Setting it correctly, as the code above does, means VS Code automatically applies the correct file-type icon and lets built-in features like reveal-in-explorer and drag-and-drop work on your tree items without any extra code from you.
Fourth, consider security review from day one given how the extension security landscape looks right now. The Security Boulevard report on the May 2026 VS Code extension supply-chain incident notes that only around 1,800 of roughly 60,000 Marketplace extensions are verified, a verification rate near 3%. If you plan to publish, apply for publisher verification early and keep your dependency list minimal; this sample extension needs zero third-party npm dependencies beyond the TypeScript toolchain, which is itself a selling point when users are evaluating whether to trust a Git-integration extension with repository access.
Fifth, add a when clause to hide the view entirely in non-Git workspaces rather than showing an empty placeholder. VS Code supports context keys like gitOpenRepositoryCount that you can reference directly in the view's when condition in package.json, which is cleaner than handling the empty state inside your own tree provider logic.
Comparing this approach against alternative extension architectures
It is worth knowing when the Tree View and SCM combination is the right tool versus when a different API fits better.
| Approach | Best for | Tradeoff |
|---|---|---|
| Tree View + SCM API (this tutorial) | Structured, hierarchical repository data with native look and feel | Limited layout control compared to a custom UI |
| Webview panel | Fully custom UI, charts, rich interactivity | You own all styling, accessibility, and theming yourself |
| Status bar item only | Single-glance summary, zero extra clicks | No room for detail or hierarchy |
| CodeLens provider | Inline annotations tied to specific lines of code | Not suited to repository-wide or project-wide state |
| Custom SCM provider replacing Git entirely | Supporting a non-Git version control system | Significant additional implementation work, not needed here since Git support already exists |
For the specific goal of this tutorial, surfacing live repository state without reinventing Git integration, the Tree View plus existing vscode.git API combination is the lowest-effort, most native-feeling option. A webview would require you to re-implement theming, icons, and keyboard navigation that the Tree View API already gives you for free.
Complete working project structure
For reference, here is the full file layout of the finished extension described across the steps above:
git-sidebar-tree/
|-- media/
| `-- git-icon.svg
|-- src/
| |-- extension.ts
| |-- gitTreeProvider.ts
| `-- git.d.ts
|-- package.json
|-- tsconfig.json
|-- webpack.config.js
|-- .vscodeignore
`-- README.md
The git.d.ts file holds the TypeScript type definitions for the built-in Git extension's exported API, the GitExtension, Repository, and Change interfaces referenced throughout this tutorial. Microsoft publishes these types in the vscode-extension-samples repository's git-extension sample, and copying them into your own project gives you full IntelliSense and type checking against the Git API surface without adding a runtime dependency.
Testing the extension before you publish
Before packaging a release build, run through this manual test matrix at minimum: open a folder with a clean Git repository with no changes, open a folder with staged and unstaged changes mixed, open a folder with no Git repository at all, open a multi-root workspace with two separate Git repositories, and switch branches while the sidebar is open to confirm the branch label updates. Each of these corresponds directly to one of the pitfalls covered earlier, and catching a regression in manual testing is far cheaper than catching it in a one-star Marketplace review.
If you want automated coverage, VS Code's extension testing framework, built on Mocha, lets you spin up a real Extension Development Host in CI and assert against the tree provider's getChildren() output directly, without needing a UI automation layer. That is a deeper topic on its own, but the short version is: mock the Git API's repository object, feed it known state, and assert the tree items your provider returns match expectations.
Publishing to the Marketplace and Open VSX
Once the extension is tested, publishing follows the standard vsce publish flow for the Visual Studio Code Marketplace, which requires an Azure DevOps personal access token tied to a publisher ID. For Cursor users specifically, remember that Cursor reads from Open VSX by default rather than Microsoft's Marketplace, so a dual publish to both registries via ovsx publish ensures your extension is discoverable regardless of which editor a user has installed. Keep your README and changelog current in both locations since they are indexed separately and do not sync automatically.
Adding a diff preview without leaving the sidebar
Opening a file from the tree jumps straight to the full editor, which is fine for a quick look but overkill when you just want to confirm what changed before staging it. VS Code exposes a built-in diff view command that you can wire into the same tree item instead of a plain file open. Swap the vscode.open command bound in toFileItem() for vscode.diff, passing the HEAD version of the file alongside the working tree version:
private toFileItem(change: Change, contextValue: string): GitTreeItem {
const fileName = change.uri.path.split('/').pop() ?? change.uri.path;
const originalUri = change.originalUri ?? change.uri;
return new GitTreeItem(fileName, vscode.TreeItemCollapsibleState.None, contextValue, change.uri, {
command: 'vscode.diff',
title: 'Open Diff',
arguments: [
originalUri.with({ scheme: 'git', query: JSON.stringify({ path: originalUri.fsPath, ref: 'HEAD' }) }),
change.uri,
`${fileName} (Working Tree)`
]
});
}
The git: URI scheme used here is registered by the built-in Git extension itself, which is what lets VS Code resolve "the HEAD version of this file" without your extension needing to shell out to git show or read blob contents manually. This is a good example of why building on top of the existing SCM integration saves real implementation effort compared to writing a Git wrapper from scratch; the scheme, the diff renderer, and the syntax highlighting in the diff view all come free from the platform.
One wrinkle worth flagging: a newly added, untracked file has no HEAD version to diff against. Calling vscode.diff against a nonexistent git-scheme URI for an untracked file throws rather than gracefully showing an empty left pane. Check change.status before building the diff command, and fall back to a plain vscode.open for files with an "untracked" status so new files still open cleanly instead of throwing an error toast.
Performance considerations for large repositories
Everything above works well on the small-to-medium repositories most tutorials are tested against, but monorepos with tens of thousands of tracked files expose a different set of problems. The workingTreeChanges and indexChanges arrays returned by the Git API scale linearly with the number of changed files, not the number of files in the repository, so a sidebar showing 40 changed files in a 50,000-file monorepo performs identically to the same 40 changes in a 50-file project. The actual bottleneck shows up elsewhere: the onDidChange event itself.
During operations that touch many files at once, a branch checkout, a large rebase, or a bulk find-and-replace across the codebase, the built-in Git extension can fire onDidChange dozens of times within a few hundred milliseconds as it incrementally updates its internal state. Without the debounce mentioned in the advanced tips section, each of those events triggers a full getChildren() re-render, and if your getChildren() implementation also calls repo.log() on every refresh rather than caching it, you end up spawning the equivalent of a git log process call repeatedly in a tight loop. On a monorepo with a long history, that alone can visibly stutter the editor.
The fix is the same debounce-and-cache pattern covered earlier, just with slightly more aggressive thresholds for large repositories. A 300 to 500 millisecond debounce window, combined with caching the commit log keyed on the HEAD commit hash rather than a timestamp, keeps the sidebar responsive even when hundreds of files change in a single Git operation. If you are building this for internal use at a company with genuinely large monorepos, it is also worth exposing the debounce interval as a user-configurable setting via contributes.configuration in package.json, since the right tradeoff between responsiveness and CPU usage differs meaningfully between a 200-file project and a 200,000-file one.
Frequently asked questions
Does this extension work in Cursor without any code changes?
Yes, as long as you stick to stable, non-proposed VS Code APIs. Cursor 3.20 runs on the same extension host architecture as VS Code, and the Tree View, SCM, and status bar APIs used in this tutorial are all part of the stable API surface, so the packaged .vsix installs and runs identically in both editors.
Do I need the Git extension installed separately?
No. The vscode.git extension ships built into both VS Code and Cursor by default and cannot be uninstalled, only disabled. If a user has explicitly disabled it, your extension should detect that getExtension('vscode.git') returns undefined and show a clear message rather than crashing.
Can I support GitLab or Bitbucket instead of just Git locally?
This tutorial covers local Git state, branches, staged files, commits, which works regardless of which remote host a repository pushes to, since that data comes from the local .git directory. Adding remote-specific features like pull request status would require calling the GitLab or Bitbucket REST API separately and merging that data into the tree, which is a reasonable next step but outside the scope of the SCM API itself.
Why use the Tree View API instead of a webview for this?
The Tree View API automatically inherits the editor's theme, font, icon set, and keyboard navigation behavior, so the sidebar looks and feels native without any additional CSS or accessibility work. A webview would give you more visual freedom but would require reimplementing all of that from scratch.
What is the minimum VS Code version this extension supports?
The Tree View and Git extension APIs used here have been stable since well before the 1.9x releases, so setting a minimum engine version around 1.85.0 in your manifest is a safe, conservative choice. Targeting the current 1.140 release in your testing, as this tutorial does, just ensures you catch any behavior changes early.
How do I handle a workspace with multiple Git repositories?
Subscribe to api.onDidOpenRepository and api.onDidCloseRepository instead of hardcoding the first entry in the repositories array. Maintain an array of active repositories and either render a top-level group per repository in the tree, or add a dropdown command that lets the user pick which repository the sidebar should focus on.
Will this extension slow down editor startup?
Not if you keep the onView:gitSidebar.repoTree activation event instead of a broad wildcard event. With that scoped activation, the extension host does not even load your code until the user actually clicks the Activity Bar icon, so there is no startup cost for users who never open the view.
Is it safe to publish an extension that reads Git state and file contents?
Reading local Git state through the official vscode.git API is standard practice and does not require any special permissions beyond what every source-control extension already uses. The security concerns documented in 2026 extension-security research center on extensions that exfiltrate data to external servers or bundle malicious dependencies, not on local-only Git state reading. Keep your dependency tree minimal, this project needs none beyond devDependencies, and never transmit repository data off the user's machine without explicit, documented consent.





