Skip to content

Commit bf81cee

Browse files
authored
Merge pull request #284 from RooVetGit/git_mentions
Add a Git section to the context mentions
2 parents b32029b + 7e9ea7a commit bf81cee

File tree

18 files changed

+1005
-224
lines changed

18 files changed

+1005
-224
lines changed

.changeset/two-camels-jam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Add a Git section to the context mentions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
66

77
- Drag and drop images into chats
88
- Delete messages from chats
9+
- @-mention Git commits to include their context in the chat
910
- "Enhance prompt" button (OpenRouter models only for now)
1011
- Sound effects for feedback
1112
- Option to use browsers of different sizes and adjust screenshot quality

src/core/Cline.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
1212
import { ApiStream } from "../api/transform/stream"
1313
import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
1414
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
15-
import { extractTextFromFile, addLineNumbers, stripLineNumbers, everyLineHasLineNumbers } from "../integrations/misc/extract-text"
15+
import { extractTextFromFile, addLineNumbers, stripLineNumbers, everyLineHasLineNumbers, truncateOutput } from "../integrations/misc/extract-text"
1616
import { TerminalManager } from "../integrations/terminal/TerminalManager"
1717
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
1818
import { listFiles } from "../services/glob/list-files"
@@ -716,22 +716,6 @@ export class Cline {
716716
}
717717
})
718718

719-
const getFormattedOutput = async () => {
720-
const { terminalOutputLineLimit } = await this.providerRef.deref()?.getState() ?? {}
721-
const limit = terminalOutputLineLimit ?? 0
722-
723-
if (limit > 0 && lines.length > limit) {
724-
const beforeLimit = Math.floor(limit * 0.2) // 20% of lines before
725-
const afterLimit = limit - beforeLimit // remaining 80% after
726-
return [
727-
...lines.slice(0, beforeLimit),
728-
`\n[...${lines.length - limit} lines omitted...]\n`,
729-
...lines.slice(-afterLimit)
730-
].join('\n')
731-
}
732-
return lines.join('\n')
733-
}
734-
735719
let completed = false
736720
process.once("completed", () => {
737721
completed = true
@@ -750,7 +734,8 @@ export class Cline {
750734
// grouping command_output messages despite any gaps anyways)
751735
await delay(50)
752736

753-
const output = await getFormattedOutput()
737+
const { terminalOutputLineLimit } = await this.providerRef.deref()?.getState() ?? {}
738+
const output = truncateOutput(lines.join('\n'), terminalOutputLineLimit)
754739
const result = output.trim()
755740

756741
if (userFeedback) {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Create mock vscode module before importing anything
2+
const createMockUri = (scheme: string, path: string) => ({
3+
scheme,
4+
authority: '',
5+
path,
6+
query: '',
7+
fragment: '',
8+
fsPath: path,
9+
with: jest.fn(),
10+
toString: () => path,
11+
toJSON: () => ({
12+
scheme,
13+
authority: '',
14+
path,
15+
query: '',
16+
fragment: ''
17+
})
18+
})
19+
20+
const mockExecuteCommand = jest.fn()
21+
const mockOpenExternal = jest.fn()
22+
const mockShowErrorMessage = jest.fn()
23+
24+
const mockVscode = {
25+
workspace: {
26+
workspaceFolders: [{
27+
uri: { fsPath: "/test/workspace" }
28+
}]
29+
},
30+
window: {
31+
showErrorMessage: mockShowErrorMessage,
32+
showInformationMessage: jest.fn(),
33+
showWarningMessage: jest.fn(),
34+
createTextEditorDecorationType: jest.fn(),
35+
createOutputChannel: jest.fn(),
36+
createWebviewPanel: jest.fn(),
37+
activeTextEditor: undefined
38+
},
39+
commands: {
40+
executeCommand: mockExecuteCommand
41+
},
42+
env: {
43+
openExternal: mockOpenExternal
44+
},
45+
Uri: {
46+
parse: jest.fn((url: string) => createMockUri('https', url)),
47+
file: jest.fn((path: string) => createMockUri('file', path))
48+
},
49+
Position: jest.fn(),
50+
Range: jest.fn(),
51+
TextEdit: jest.fn(),
52+
WorkspaceEdit: jest.fn(),
53+
DiagnosticSeverity: {
54+
Error: 0,
55+
Warning: 1,
56+
Information: 2,
57+
Hint: 3
58+
}
59+
}
60+
61+
// Mock modules
62+
jest.mock('vscode', () => mockVscode)
63+
jest.mock("../../../services/browser/UrlContentFetcher")
64+
jest.mock("../../../utils/git")
65+
66+
// Now import the modules that use the mocks
67+
import { parseMentions, openMention } from "../index"
68+
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
69+
import * as git from "../../../utils/git"
70+
71+
describe("mentions", () => {
72+
const mockCwd = "/test/workspace"
73+
let mockUrlContentFetcher: UrlContentFetcher
74+
75+
beforeEach(() => {
76+
jest.clearAllMocks()
77+
78+
// Create a mock instance with just the methods we need
79+
mockUrlContentFetcher = {
80+
launchBrowser: jest.fn().mockResolvedValue(undefined),
81+
closeBrowser: jest.fn().mockResolvedValue(undefined),
82+
urlToMarkdown: jest.fn().mockResolvedValue(""),
83+
} as unknown as UrlContentFetcher
84+
})
85+
86+
describe("parseMentions", () => {
87+
it("should parse git commit mentions", async () => {
88+
const commitHash = "abc1234"
89+
const commitInfo = `abc1234 Fix bug in parser
90+
91+
Author: John Doe
92+
Date: Mon Jan 5 23:50:06 2025 -0500
93+
94+
Detailed commit message with multiple lines
95+
- Fixed parsing issue
96+
- Added tests`
97+
98+
jest.mocked(git.getCommitInfo).mockResolvedValue(commitInfo)
99+
100+
const result = await parseMentions(
101+
`Check out this commit @${commitHash}`,
102+
mockCwd,
103+
mockUrlContentFetcher
104+
)
105+
106+
expect(result).toContain(`'${commitHash}' (see below for commit info)`)
107+
expect(result).toContain(`<git_commit hash="${commitHash}">`)
108+
expect(result).toContain(commitInfo)
109+
})
110+
111+
it("should handle errors fetching git info", async () => {
112+
const commitHash = "abc1234"
113+
const errorMessage = "Failed to get commit info"
114+
115+
jest.mocked(git.getCommitInfo).mockRejectedValue(new Error(errorMessage))
116+
117+
const result = await parseMentions(
118+
`Check out this commit @${commitHash}`,
119+
mockCwd,
120+
mockUrlContentFetcher
121+
)
122+
123+
expect(result).toContain(`'${commitHash}' (see below for commit info)`)
124+
expect(result).toContain(`<git_commit hash="${commitHash}">`)
125+
expect(result).toContain(`Error fetching commit info: ${errorMessage}`)
126+
})
127+
})
128+
129+
describe("openMention", () => {
130+
it("should handle file paths and problems", async () => {
131+
await openMention("/path/to/file")
132+
expect(mockExecuteCommand).not.toHaveBeenCalled()
133+
expect(mockOpenExternal).not.toHaveBeenCalled()
134+
expect(mockShowErrorMessage).toHaveBeenCalledWith("Could not open file!")
135+
136+
await openMention("problems")
137+
expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
138+
})
139+
140+
it("should handle URLs", async () => {
141+
const url = "https://example.com"
142+
await openMention(url)
143+
const mockUri = mockVscode.Uri.parse(url)
144+
expect(mockOpenExternal).toHaveBeenCalled()
145+
const calledArg = mockOpenExternal.mock.calls[0][0]
146+
expect(calledArg).toEqual(expect.objectContaining({
147+
scheme: mockUri.scheme,
148+
authority: mockUri.authority,
149+
path: mockUri.path,
150+
query: mockUri.query,
151+
fragment: mockUri.fragment
152+
}))
153+
})
154+
})
155+
})

src/core/mentions/index.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,28 @@ import * as vscode from "vscode"
22
import * as path from "path"
33
import { openFile } from "../../integrations/misc/open-file"
44
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
5-
import { mentionRegexGlobal } from "../../shared/context-mentions"
5+
import { mentionRegexGlobal, formatGitSuggestion, type MentionSuggestion } from "../../shared/context-mentions"
66
import fs from "fs/promises"
77
import { extractTextFromFile } from "../../integrations/misc/extract-text"
88
import { isBinaryFile } from "isbinaryfile"
99
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
10+
import { getCommitInfo, getWorkingState } from "../../utils/git"
1011

11-
export function openMention(mention?: string): void {
12+
export async function openMention(mention?: string): Promise<void> {
1213
if (!mention) {
1314
return
1415
}
1516

17+
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
18+
if (!cwd) {
19+
return
20+
}
21+
1622
if (mention.startsWith("/")) {
1723
const relPath = mention.slice(1)
18-
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
19-
if (!cwd) {
20-
return
21-
}
2224
const absPath = path.resolve(cwd, relPath)
2325
if (mention.endsWith("/")) {
2426
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
25-
// vscode.commands.executeCommand("vscode.openFolder", , { forceNewWindow: false }) opens in new window
2627
} else {
2728
openFile(absPath)
2829
}
@@ -40,12 +41,16 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
4041
if (mention.startsWith("http")) {
4142
return `'${mention}' (see below for site content)`
4243
} else if (mention.startsWith("/")) {
43-
const mentionPath = mention.slice(1) // Remove the leading '/'
44+
const mentionPath = mention.slice(1)
4445
return mentionPath.endsWith("/")
4546
? `'${mentionPath}' (see below for folder content)`
4647
: `'${mentionPath}' (see below for file content)`
4748
} else if (mention === "problems") {
4849
return `Workspace Problems (see below for diagnostics)`
50+
} else if (mention === "git-changes") {
51+
return `Working directory changes (see below for details)`
52+
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
53+
return `Git commit '${mention}' (see below for commit info)`
4954
}
5055
return match
5156
})
@@ -99,6 +104,20 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
99104
} catch (error) {
100105
parsedText += `\n\n<workspace_diagnostics>\nError fetching diagnostics: ${error.message}\n</workspace_diagnostics>`
101106
}
107+
} else if (mention === "git-changes") {
108+
try {
109+
const workingState = await getWorkingState(cwd)
110+
parsedText += `\n\n<git_working_state>\n${workingState}\n</git_working_state>`
111+
} catch (error) {
112+
parsedText += `\n\n<git_working_state>\nError fetching working state: ${error.message}\n</git_working_state>`
113+
}
114+
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
115+
try {
116+
const commitInfo = await getCommitInfo(mention, cwd)
117+
parsedText += `\n\n<git_commit hash="${mention}">\n${commitInfo}\n</git_commit>`
118+
} catch (error) {
119+
parsedText += `\n\n<git_commit hash="${mention}">\nError fetching commit info: ${error.message}\n</git_commit>`
120+
}
102121
}
103122
}
104123

@@ -137,7 +156,6 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
137156
folderContent += `${linePrefix}${entry.name}\n`
138157
const filePath = path.join(mentionPath, entry.name)
139158
const absoluteFilePath = path.resolve(absPath, entry.name)
140-
// const relativeFilePath = path.relative(cwd, absoluteFilePath);
141159
fileContentPromises.push(
142160
(async () => {
143161
try {
@@ -154,7 +172,6 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
154172
)
155173
} else if (entry.isDirectory()) {
156174
folderContent += `${linePrefix}${entry.name}/\n`
157-
// not recursively getting folder contents
158175
} else {
159176
folderContent += `${linePrefix}${entry.name}\n`
160177
}

src/core/webview/ClineProvider.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { getNonce } from "./getNonce"
2424
import { getUri } from "./getUri"
2525
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
2626
import { enhancePrompt } from "../../utils/enhance-prompt"
27+
import { getCommitInfo, searchCommits, getWorkingState } from "../../utils/git"
2728

2829
/*
2930
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -732,6 +733,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
732733
}
733734
}
734735
break
736+
737+
738+
case "searchCommits": {
739+
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
740+
if (cwd) {
741+
try {
742+
const commits = await searchCommits(message.query || "", cwd)
743+
await this.postMessageToWebview({
744+
type: "commitSearchResults",
745+
commits
746+
})
747+
} catch (error) {
748+
console.error("Error searching commits:", error)
749+
vscode.window.showErrorMessage("Failed to search commits")
750+
}
751+
}
752+
break
753+
}
735754
}
736755
},
737756
null,

0 commit comments

Comments
 (0)