Build a VS Code Git Sidebar Extension: 10 Steps [2026]

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.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

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.

ToolMinimum versionNotes
Visual Studio Code1.140.0Released September 30, 2026; Extension API is stable across 1.9x to 1.14x
Cursor3.20Released September 23, 2026; uses the same extension host as VS Code
Node.js20.x LTS or newerRequired by @vscode/vsce and yo generator-code
npm10.xShips with Node 20
TypeScript5.6 or newerInstalled as a devDependency in the generated project
Git2.40 or newerRequired for the built-in vscode.git extension to expose its API
Yeoman + generator-codeLatest via npmScaffolds 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

SymptomLikely causeFix
Sidebar icon never appears in Activity BarIcon file path mismatch or missing viewsContainers blockVerify the SVG path matches package.json exactly, case included
Tree shows "No repository detected" in a valid Git foldervscode.git extension not yet activated when your code queries itAwait gitExtension.activate() before calling getAPI(1)
Tree never refreshes after staging a fileMissing or unwired onDidChangeTreeData emitterConfirm the fire() method is called inside the state-change handler
Extension host crashes on folder with no Git repoCode assumes this.repo is always definedAdd explicit undefined checks before accessing repo.state
Commands registered twice, duplicate error on reloadCommand registered outside context.subscriptions, surviving a hot reloadAlways push registerCommand results into subscriptions
Inline stage/unstage icons missing from tree itemscontextValue on tree item does not match the when clause in view/item/contextDouble-check exact string match between code and manifest
Extension works in VS Code but not CursorCode references a proposed, unstable API not yet mirrored in Cursor's forkCheck the API against the stable vscode.d.ts, avoid engines.vscode ranges tied to insider builds
.vsix installs but view never loadsWebpack bundling excluded a required file or the entry point in package.json main field is wrongConfirm main points to the bundled dist/extension.js, not the raw src/extension.ts
High CPU usage while extension is idleA leftover polling loop from early prototypingReplace 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.

ApproachBest forTradeoff
Tree View + SCM API (this tutorial)Structured, hierarchical repository data with native look and feelLimited layout control compared to a custom UI
Webview panelFully custom UI, charts, rich interactivityYou own all styling, accessibility, and theming yourself
Status bar item onlySingle-glance summary, zero extra clicksNo room for detail or hierarchy
CodeLens providerInline annotations tied to specific lines of codeNot suited to repository-wide or project-wide state
Custom SCM provider replacing Git entirelySupporting a non-Git version control systemSignificant 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.

Related Coverage

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles