Skip to content

Commit d67c7e3

Browse files
authored
Add git context mention (RooCodeInc#1806)
* Add git context mention * Fix context mention highlight * Create long-guests-occur.md
1 parent 584af64 commit d67c7e3

File tree

12 files changed

+427
-48
lines changed

12 files changed

+427
-48
lines changed

.changeset/long-guests-occur.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"claude-dev": patch
3+
---
4+
5+
Add git context mention

src/core/mentions/index.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,24 @@ import { extractTextFromFile } from "../../integrations/misc/extract-text"
88
import { isBinaryFile } from "isbinaryfile"
99
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
1010
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
11+
import { getCommitInfo } from "../../utils/git"
12+
import { getWorkingState } from "../../utils/git"
1113

1214
export function openMention(mention?: string): void {
1315
if (!mention) {
1416
return
1517
}
1618

19+
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
20+
if (!cwd) {
21+
return
22+
}
23+
1724
if (mention.startsWith("/")) {
1825
const relPath = mention.slice(1)
19-
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
20-
if (!cwd) {
21-
return
22-
}
2326
const absPath = path.resolve(cwd, relPath)
2427
if (mention.endsWith("/")) {
2528
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
26-
// vscode.commands.executeCommand("vscode.openFolder", , { forceNewWindow: false }) opens in new window
2729
} else {
2830
openFile(absPath)
2931
}
@@ -51,6 +53,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
5153
return `Workspace Problems (see below for diagnostics)`
5254
} else if (mention === "terminal") {
5355
return `Terminal Output (see below for output)`
56+
} else if (mention === "git-changes") {
57+
return `Working directory changes (see below for details)`
58+
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
59+
return `Git commit '${mention}' (see below for commit info)`
5460
}
5561
return match
5662
})
@@ -111,6 +117,20 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
111117
} catch (error) {
112118
parsedText += `\n\n<terminal_output>\nError fetching terminal output: ${error.message}\n</terminal_output>`
113119
}
120+
} else if (mention === "git-changes") {
121+
try {
122+
const workingState = await getWorkingState(cwd)
123+
parsedText += `\n\n<git_working_state>\n${workingState}\n</git_working_state>`
124+
} catch (error) {
125+
parsedText += `\n\n<git_working_state>\nError fetching working state: ${error.message}\n</git_working_state>`
126+
}
127+
} else if (/^[a-f0-9]{7,40}$/.test(mention)) {
128+
try {
129+
const commitInfo = await getCommitInfo(mention, cwd)
130+
parsedText += `\n\n<git_commit hash="${mention}">\n${commitInfo}\n</git_commit>`
131+
} catch (error) {
132+
parsedText += `\n\n<git_commit hash="${mention}">\nError fetching commit info: ${error.message}\n</git_commit>`
133+
}
114134
}
115135
}
116136

src/core/webview/ClineProvider.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getUri } from "./getUri"
2828
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
2929
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
3030
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
31+
import { searchCommits } from "../../utils/git"
3132

3233
/*
3334
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -803,6 +804,21 @@ export class ClineProvider implements vscode.WebviewViewProvider {
803804
}
804805
break
805806
}
807+
case "searchCommits": {
808+
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
809+
if (cwd) {
810+
try {
811+
const commits = await searchCommits(message.text || "", cwd)
812+
await this.postMessageToWebview({
813+
type: "commitSearchResults",
814+
commits,
815+
})
816+
} catch (error) {
817+
console.error(`Error searching commits: ${JSON.stringify(error)}`)
818+
}
819+
}
820+
break
821+
}
806822
case "openExtensionSettings": {
807823
const settingsFilter = message.text || ""
808824
await vscode.commands.executeCommand(

src/shared/ExtensionMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello'
22

3+
import { GitCommit } from "../utils/git"
34
import { ApiConfiguration, ModelInfo } from "./api"
45
import { AutoApprovalSettings } from "./AutoApprovalSettings"
56
import { BrowserSettings } from "./BrowserSettings"
@@ -26,6 +27,7 @@ export interface ExtensionMessage {
2627
| "vsCodeLmModels"
2728
| "requestVsCodeLmModels"
2829
| "emailSubscribed"
30+
| "commitSearchResults"
2931
text?: string
3032
action?:
3133
| "chatButtonClicked"
@@ -46,6 +48,7 @@ export interface ExtensionMessage {
4648
openRouterModels?: Record<string, ModelInfo>
4749
openAiModels?: string[]
4850
mcpServers?: McpServer[]
51+
commits?: GitCommit[]
4952
}
5053

5154
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface WebviewMessage {
4343
| "accountLoginClicked"
4444
| "accountLogoutClicked"
4545
| "subscribeEmail"
46+
| "searchCommits"
4647
// | "relaunchChromeDebugMode"
4748
text?: string
4849
disabled?: boolean

src/shared/context-mentions.ts

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,46 +7,48 @@ Mention regex:
77
88
- **Regex Breakdown**:
99
- `/@`:
10-
- **@**: The mention must start with the '@' symbol.
10+
- **@**: The mention must start with the '@' symbol.
1111
1212
- `((?:\/|\w+:\/\/)[^\s]+?|problems\b)`:
13-
- **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns.
14-
- `(?:\/|\w+:\/\/)`:
15-
- **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing.
16-
- `\/`:
17-
- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
18-
- `|`: Logical OR.
19-
- `\w+:\/\/`:
20-
- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
21-
- `[^\s]+?`:
22-
- **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace.
23-
- **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
24-
- `|`: Logical OR.
25-
- `problems\b`:
13+
- **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns.
14+
- `(?:\/|\w+:\/\/)`:
15+
- **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing.
16+
- `\/`:
17+
- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
18+
- `|`: Logical OR.
19+
- `\w+:\/\/`:
20+
- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
21+
- `[^\s]+?`:
22+
- **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace.
23+
- **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
24+
- `|`: Logical OR.
25+
- `problems\b`:
2626
- **Exact Word ('problems')**: Matches the exact word 'problems'.
2727
- **Word Boundary (`\b`)**: Ensures that 'problems' is matched as a whole word and not as part of another word (e.g., 'problematic').
2828
- `terminal\b`:
2929
- **Exact Word ('terminal')**: Matches the exact word 'terminal'.
3030
- **Word Boundary (`\b`)**: Ensures that 'terminal' is matched as a whole word and not as part of another word (e.g., 'terminals').
3131
3232
- `(?=[.,;:!?]?(?=[\s\r\n]|$))`:
33-
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
34-
- `[.,;:!?]?`:
35-
- **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks.
36-
- `(?=[\s\r\n]|$)`:
37-
- **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string.
33+
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
34+
- `[.,;:!?]?`:
35+
- **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks.
36+
- `(?=[\s\r\n]|$)`:
37+
- **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string.
3838
3939
- **Summary**:
4040
- The regex effectively matches:
41-
- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
42-
- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
43-
- The exact word 'problems'.
44-
- The exact word 'terminal'.
41+
- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
42+
- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
43+
- The exact word 'problems'.
44+
- The exact word 'terminal'.
45+
- The exact word 'git-changes'.
4546
- It ensures that any trailing punctuation marks (such as ',', '.', '!', etc.) are not included in the matched mention, allowing the punctuation to follow the mention naturally in the text.
4647
4748
- **Global Regex**:
4849
- `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
4950
5051
*/
51-
export const mentionRegex = /@((?:\/|\w+:\/\/)[^\s]+?|problems\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
52+
export const mentionRegex =
53+
/@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|terminal\b|git-changes\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
5254
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")

src/utils/git.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { exec } from "child_process"
2+
import { promisify } from "util"
3+
4+
const execAsync = promisify(exec)
5+
const GIT_OUTPUT_LINE_LIMIT = 500
6+
7+
export interface GitCommit {
8+
hash: string
9+
shortHash: string
10+
subject: string
11+
author: string
12+
date: string
13+
}
14+
15+
async function checkGitRepo(cwd: string): Promise<boolean> {
16+
try {
17+
await execAsync("git rev-parse --git-dir", { cwd })
18+
return true
19+
} catch (error) {
20+
return false
21+
}
22+
}
23+
24+
async function checkGitInstalled(): Promise<boolean> {
25+
try {
26+
await execAsync("git --version")
27+
return true
28+
} catch (error) {
29+
return false
30+
}
31+
}
32+
33+
export async function searchCommits(query: string, cwd: string): Promise<GitCommit[]> {
34+
try {
35+
const isInstalled = await checkGitInstalled()
36+
if (!isInstalled) {
37+
console.error("Git is not installed")
38+
return []
39+
}
40+
41+
const isRepo = await checkGitRepo(cwd)
42+
if (!isRepo) {
43+
console.error("Not a git repository")
44+
return []
45+
}
46+
47+
// Search commits by hash or message, limiting to 10 results
48+
const { stdout } = await execAsync(
49+
`git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--grep="${query}" --regexp-ignore-case`,
50+
{ cwd },
51+
)
52+
53+
let output = stdout
54+
if (!output.trim() && /^[a-f0-9]+$/i.test(query)) {
55+
// If no results from grep search and query looks like a hash, try searching by hash
56+
const { stdout: hashStdout } = await execAsync(
57+
`git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--author-date-order ${query}`,
58+
{ cwd },
59+
).catch(() => ({ stdout: "" }))
60+
61+
if (!hashStdout.trim()) {
62+
return []
63+
}
64+
65+
output = hashStdout
66+
}
67+
68+
const commits: GitCommit[] = []
69+
const lines = output
70+
.trim()
71+
.split("\n")
72+
.filter((line) => line !== "--")
73+
74+
for (let i = 0; i < lines.length; i += 5) {
75+
commits.push({
76+
hash: lines[i],
77+
shortHash: lines[i + 1],
78+
subject: lines[i + 2],
79+
author: lines[i + 3],
80+
date: lines[i + 4],
81+
})
82+
}
83+
84+
return commits
85+
} catch (error) {
86+
console.error("Error searching commits:", error)
87+
return []
88+
}
89+
}
90+
91+
export async function getCommitInfo(hash: string, cwd: string): Promise<string> {
92+
try {
93+
const isInstalled = await checkGitInstalled()
94+
if (!isInstalled) {
95+
return "Git is not installed"
96+
}
97+
98+
const isRepo = await checkGitRepo(cwd)
99+
if (!isRepo) {
100+
return "Not a git repository"
101+
}
102+
103+
// Get commit info, stats, and diff separately
104+
const { stdout: info } = await execAsync(`git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch ${hash}`, {
105+
cwd,
106+
})
107+
const [fullHash, shortHash, subject, author, date, body] = info.trim().split("\n")
108+
109+
const { stdout: stats } = await execAsync(`git show --stat --format="" ${hash}`, { cwd })
110+
111+
const { stdout: diff } = await execAsync(`git show --format="" ${hash}`, { cwd })
112+
113+
const summary = [
114+
`Commit: ${shortHash} (${fullHash})`,
115+
`Author: ${author}`,
116+
`Date: ${date}`,
117+
`\nMessage: ${subject}`,
118+
body ? `\nDescription:\n${body}` : "",
119+
"\nFiles Changed:",
120+
stats.trim(),
121+
"\nFull Changes:",
122+
].join("\n")
123+
124+
const output = summary + "\n\n" + diff.trim()
125+
return truncateOutput(output)
126+
} catch (error) {
127+
console.error("Error getting commit info:", error)
128+
return `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}`
129+
}
130+
}
131+
132+
export async function getWorkingState(cwd: string): Promise<string> {
133+
try {
134+
const isInstalled = await checkGitInstalled()
135+
if (!isInstalled) {
136+
return "Git is not installed"
137+
}
138+
139+
const isRepo = await checkGitRepo(cwd)
140+
if (!isRepo) {
141+
return "Not a git repository"
142+
}
143+
144+
// Get status of working directory
145+
const { stdout: status } = await execAsync("git status --short", { cwd })
146+
if (!status.trim()) {
147+
return "No changes in working directory"
148+
}
149+
150+
// Get all changes (both staged and unstaged) compared to HEAD
151+
const { stdout: diff } = await execAsync("git diff HEAD", { cwd })
152+
const output = `Working directory changes:\n\n${status}\n\n${diff}`.trim()
153+
return truncateOutput(output)
154+
} catch (error) {
155+
console.error("Error getting working state:", error)
156+
return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}`
157+
}
158+
}
159+
160+
function truncateOutput(content: string): string {
161+
if (!GIT_OUTPUT_LINE_LIMIT) {
162+
return content
163+
}
164+
165+
const lines = content.split("\n")
166+
if (lines.length <= GIT_OUTPUT_LINE_LIMIT) {
167+
return content
168+
}
169+
170+
const beforeLimit = Math.floor(GIT_OUTPUT_LINE_LIMIT * 0.2) // 20% of lines before
171+
const afterLimit = GIT_OUTPUT_LINE_LIMIT - beforeLimit // remaining 80% after
172+
return [
173+
...lines.slice(0, beforeLimit),
174+
`\n[...${lines.length - GIT_OUTPUT_LINE_LIMIT} lines omitted...]\n`,
175+
...lines.slice(-afterLimit),
176+
].join("\n")
177+
}

0 commit comments

Comments
 (0)