Skip to content

Assistant: Initial experimental git integration #8257

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions extensions/positron-assistant/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@
]
}
],
"menus": {
"scm/inputBox": [
{
"command": "positron-assistant.generateCommitMessage",
"group": "navigation",
"when": "!positron-assistant.generatingCommitMessage && config.positron.assistant.gitIntegration.enable"
},
{
"command": "positron-assistant.cancelGenerateCommitMessage",
"group": "navigation",
"when": "positron-assistant.generatingCommitMessage && config.positron.assistant.gitIntegration.enable"
}
]
},
"commands": [
{
"command": "positron-assistant.configureModels",
Expand All @@ -116,6 +130,20 @@
"title": "%commands.logStoredModels.title%",
"category": "%commands.category%",
"enablement": "config.positron.assistant.enable"
},
{
"command": "positron-assistant.generateCommitMessage",
"title": "%commands.generateCommitMessage.title%",
"category": "%commands.category%",
"enablement": "config.positron.assistant.enable && config.positron.assistant.gitIntegration.enable",
"icon": "$(sparkle)"
},
{
"command": "positron-assistant.cancelGenerateCommitMessage",
"title": "%commands.cancelGenerateCommitMessage.title%",
"category": "%commands.category%",
"enablement": "config.positron.assistant.enable && config.positron.assistant.gitIntegration.enable",
"icon": "$(stop)"
}
],
"configuration": [
Expand Down Expand Up @@ -146,6 +174,12 @@
"items": {
"type": "string"
}
},
"positron.assistant.gitIntegration.enable": {
"type": "boolean",
"default": false,
"description": "%configuration.gitIntegration.description%",
"tags": ["experimental"]
}
}
}
Expand Down Expand Up @@ -360,6 +394,23 @@
}
}
}
},
{
"name": "getChangedFiles",
"displayName": "Get changed files",
"modelDescription": "Get summaries and git diffs for current changes to files in this workspace.",
"canBeReferencedInPrompt": true,
"userDescription": "Get changed files",
"toolReferenceName": "changes",
"when": "config.positron.assistant.gitIntegration.enable",
"icon": "$(diff)",
"tags": [
"positron-assistant"
],
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
},
Expand Down
5 changes: 4 additions & 1 deletion extensions/positron-assistant/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"description": "Provides default assistant and language models for Positron.",
"commands.configureModels.title": "Configure Language Model Providers",
"commands.logStoredModels.title": "Log the stored language models",
"commands.generateCommitMessage.title": "Generate Commit Message",
"commands.cancelGenerateCommitMessage.title": "Cancel Generate Commit Message",
"commands.copilot.signin.title": "Copilot Sign In",
"commands.copilot.signout.title": "Copilot Sign Out",
"commands.category": "Positron Assistant",
Expand All @@ -15,5 +17,6 @@
"configuration.enable.markdownDescription": "Enable [Positron Assistant](https://positron.posit.co/assistant), an AI assistant for Positron.",
"configuration.useAnthropicSdk.description": "Use the Anthropic SDK for Anthropic models rather than the generic AI SDK.",
"configuration.streamingEdits.enable": "Enable streaming edits in inline editor chats.",
"configuration.inlineCompletionExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from inline completions."
"configuration.inlineCompletionExcludes.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude from inline completions.",
"configuration.gitIntegration.description": "Enable Positron Assistant git integration."
}
10 changes: 10 additions & 0 deletions extensions/positron-assistant/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { registerAssistantTools } from './tools.js';
import { registerCopilotService } from './copilot.js';
import { ALL_DOCUMENTS_SELECTOR, DEFAULT_MAX_TOKEN_OUTPUT } from './constants.js';
import { registerCodeActionProvider } from './codeActions.js';
import { generateCommitMessage } from './git.js';

const hasChatModelsContextKey = 'positron-assistant.hasChatModels';

Expand Down Expand Up @@ -208,6 +209,14 @@ function registerConfigureModelsCommand(context: vscode.ExtensionContext, storag
);
}

function registerGenerateCommitMessageCommand(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand('positron-assistant.generateCommitMessage', () => {
generateCommitMessage(context);
})
);
}

function registerAssistant(context: vscode.ExtensionContext) {

// Initialize secret storage. In web mode, we currently need to use global
Expand All @@ -230,6 +239,7 @@ function registerAssistant(context: vscode.ExtensionContext) {

// Commands
registerConfigureModelsCommand(context, storage);
registerGenerateCommitMessageCommand(context);

// Register mapped edits provider
registerMappedEditsProvider(context, participantService, log);
Expand Down
163 changes: 163 additions & 0 deletions extensions/positron-assistant/src/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import * as fs from 'fs';

import { GitExtension, Repository, Status, Change } from '../../git/src/api/git.js';
import { EXTENSION_ROOT_DIR } from './constants';

const mdDir = `${EXTENSION_ROOT_DIR}/src/md/`;
const generatingGitCommitKey = 'positron-assistant.generatingCommitMessage';

export enum GitRepoChangeKind {
Staged = 'staged',
Unstaged = 'unstaged',
Merge = 'merge',
Untracked = 'untracked',
All = 'all',
}

export interface GitRepoChangeSummary {
uri: vscode.Uri;
summary: string;
}

export interface GitRepoChange {
repo: Repository;
changes: GitRepoChangeSummary[];
}

/** Get the list of active repositories */
function currentGitRepositories(): Repository[] {
// Obtain a handle to git extension API
const gitExtension = vscode.extensions.getExtension<GitExtension>('vscode.git')?.exports;
if (!gitExtension) {
throw new Error('Git extension not found');
}
const git = gitExtension.getAPI(1);
if (git.repositories.length === 0) {
throw new Error('No Git repositories found');
}

return git.repositories;
}

/** Summarise the content of a git change */
async function gitChangeSummary(repo: Repository, change: Change, kind: GitRepoChangeKind): Promise<GitRepoChangeSummary> {
const uri = change.uri.fsPath.replace(repo.rootUri.fsPath, '');
const originalUri = change.originalUri.fsPath.replace(repo.rootUri.fsPath, '');
const renameUri = change.renameUri?.fsPath.replace(repo.rootUri.fsPath, '');
switch (change.status) {
// File-level changes
case Status.INDEX_ADDED:
case Status.UNTRACKED:
return { uri: change.uri, summary: `Added: ${uri}` };
case Status.INDEX_DELETED:
case Status.DELETED:
return { uri: change.uri, summary: `Deleted: ${uri}` };
case Status.INDEX_RENAMED:
return { uri: change.uri, summary: `Renamed: ${originalUri} to ${renameUri}` };
case Status.INDEX_COPIED:
return { uri: change.uri, summary: `Copied: ${originalUri} to ${uri}` };
case Status.IGNORED:
return { uri: change.uri, summary: `Ignored: ${uri}` };
default: {
// Otherwise, git diff text content for this file
if (kind === GitRepoChangeKind.Staged) {
const diff = await repo.diffIndexWithHEAD(change.uri.fsPath);
return { uri: change.uri, summary: `Modified:\n${diff}` };
} else {
const diff = await repo.diffWithHEAD(change.uri.fsPath);
return { uri: change.uri, summary: `Modified:\n${diff}` };
}
}
}
}

/** Get current workspace git repository changes as text summaries */
export async function getWorkspaceGitChanges(kind: GitRepoChangeKind): Promise<GitRepoChange[]> {
const repos = currentGitRepositories();

// Combine and summarise each kind of git repo change
const repoChanges = await Promise.all(repos.map(async (repo) => {
const stateChanges: { change: Change; kind: GitRepoChangeKind }[] = [];

if (kind === GitRepoChangeKind.Staged || kind === GitRepoChangeKind.All) {
stateChanges.push(...repo.state.indexChanges.map((change) => {
return { change, kind: GitRepoChangeKind.Staged };
}));
}
if (kind === GitRepoChangeKind.Unstaged || kind === GitRepoChangeKind.All) {
stateChanges.push(...repo.state.workingTreeChanges.map((change) => {
return { change, kind: GitRepoChangeKind.Unstaged };
}));
}
if (kind === GitRepoChangeKind.Untracked || kind === GitRepoChangeKind.All) {
stateChanges.push(...repo.state.untrackedChanges.map((change) => {
return { change, kind: GitRepoChangeKind.Untracked };
}));
}
if (kind === GitRepoChangeKind.Merge || kind === GitRepoChangeKind.All) {
stateChanges.push(...repo.state.mergeChanges.map((change) => {
return { change, kind: GitRepoChangeKind.Merge };
}));
}

const changes = await Promise.all(stateChanges.map(async (state) => {
return gitChangeSummary(repo, state.change, state.kind);
}));
return { repo, changes };
}));

return repoChanges.filter((repoChange) => repoChange.changes.length > 0);
}

/** Generate a commit message for git repositories with staged changes */
export async function generateCommitMessage(context: vscode.ExtensionContext) {
await vscode.commands.executeCommand('setContext', generatingGitCommitKey, true);

const models = (await vscode.lm.selectChatModels()).filter((model) => {
return model.family !== 'echo' && model.family !== 'error';
});
if (models.length === 0) {
vscode.commands.executeCommand('setContext', generatingGitCommitKey, false);
throw new Error('No language models available for commit message generation.');
}
const model = models[0];

const tokenSource: vscode.CancellationTokenSource = new vscode.CancellationTokenSource();
const cancelDisposable = vscode.commands.registerCommand('positron-assistant.cancelGenerateCommitMessage', () => {
tokenSource.cancel();
vscode.commands.executeCommand('setContext', generatingGitCommitKey, false);
});

// Send repo changes to the LLM and update the commit message input boxes
const allChanges = await getWorkspaceGitChanges(GitRepoChangeKind.All);
const stagedChanges = await getWorkspaceGitChanges(GitRepoChangeKind.Staged);
const gitChanges = stagedChanges.length > 0 ? stagedChanges : allChanges;

const system: string = await fs.promises.readFile(`${mdDir}/prompts/git/commit.md`, 'utf8');
try {
await Promise.all(gitChanges.map(async ({ repo, changes }) => {
if (changes.length > 0) {
const response = await model.sendRequest([
vscode.LanguageModelChatMessage.User(changes.map(change => change.summary).join('\n')),
], { modelOptions: { system } }, tokenSource.token);

repo.inputBox.value = '';
for await (const delta of response.text) {
if (tokenSource.token.isCancellationRequested) {
return null;
}
repo.inputBox.value += delta;
}
}
}));
} finally {
cancelDisposable.dispose();
vscode.commands.executeCommand('setContext', generatingGitCommitKey, false);
}
}
3 changes: 3 additions & 0 deletions extensions/positron-assistant/src/md/prompts/git/commit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
You will be given a set of repository changes and diffs. Output a short git commit message summarizing the changes.

Return ONLY the commit message.
14 changes: 14 additions & 0 deletions extensions/positron-assistant/src/participants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,16 @@ abstract class PositronAssistantParticipant implements IPositronAssistantPartici
}
}

// If the user has explicitly attached a tool reference, add it to the prompt.
if (request.toolReferences.length > 0) {
const referencePrompts: string[] = [];
for (const reference of request.toolReferences) {
referencePrompts.push(xml.node('tool', reference.name));
}
const toolReferencesText = 'Attached tool references:';
prompts.push(xml.node('tool-references', `${toolReferencesText}\n${referencePrompts.join('\n')}`));
}

// If the user has explicitly attached files as context, add them to the prompt.
if (request.references.length > 0) {
const attachmentPrompts: string[] = [];
Expand Down Expand Up @@ -523,8 +533,12 @@ abstract class PositronAssistantParticipant implements IPositronAssistantPartici
this._participantService.trackSessionModel(toolContext.sessionId, request.model.id);
}

// If a user attached a tool reference, ensure it is invoked
const toolMode = request.toolReferences.length > 0 ? vscode.LanguageModelChatToolMode.Required : vscode.LanguageModelChatToolMode.Auto;

const modelResponse = await request.model.sendRequest(messages, {
tools,
toolMode,
modelOptions: {
toolInvocationToken: request.toolInvocationToken,
system,
Expand Down
16 changes: 16 additions & 0 deletions extensions/positron-assistant/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LanguageModelImage } from './languageModelParts.js';
import { ParticipantService } from './participants.js';
import { PositronAssistantToolName } from './types.js';
import { ProjectTreeTool } from './tools/projectTreeTool.js';
import { getWorkspaceGitChanges, GitRepoChangeKind } from './git.js';
import { DocumentCreateTool } from './tools/documentCreate.js';


Expand Down Expand Up @@ -281,6 +282,21 @@ export function registerAssistantTools(
}
});

const getChangedFilesTool = vscode.lm.registerTool<{}>(PositronAssistantToolName.GetChangedFiles, {
invoke: async (options, token) => {
const repoChanges = await getWorkspaceGitChanges(GitRepoChangeKind.All);
const textChanges = repoChanges.map((({ changes }) => {
return changes.map((change) => change.summary).join('\n');
})).join('\n\n');

return new vscode.LanguageModelToolResult([
new vscode.LanguageModelTextPart(textChanges)
]);
},
});

context.subscriptions.push(getChangedFilesTool);

context.subscriptions.push(inspectVariablesTool);

context.subscriptions.push(ProjectTreeTool);
Expand Down
1 change: 1 addition & 0 deletions extensions/positron-assistant/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum PositronAssistantToolName {
InspectVariables = 'inspectVariables',
SelectionEdit = 'selectionEdit',
ProjectTree = 'getProjectTree',
GetChangedFiles = 'getChangedFiles',
DocumentCreate = 'documentCreate',
TextSearch = 'positron_findTextInProject_internal',
FileContents = 'positron_getFileContents_internal',
Expand Down