Skip to content

Commit 3600b64

Browse files
committed
feat: add git status to environment details
- Add getGitStatus() function that returns raw git status output - Truncate git status to 20 lines to prevent context bloat - Only show section headers (Visible Files, Open Tabs) when content exists - Add comprehensive tests for git status functionality - All tests passing (43 git tests, 15 environment tests)
1 parent 3e0bd0e commit 3600b64

File tree

4 files changed

+183
-9
lines changed

4 files changed

+183
-9
lines changed

src/core/environment/__tests__/getEnvironmentDetails.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,7 @@ describe("getEnvironmentDetails", () => {
143143

144144
expect(result).toContain("<environment_details>")
145145
expect(result).toContain("</environment_details>")
146-
expect(result).toContain("# VSCode Visible Files")
147-
expect(result).toContain("# VSCode Open Tabs")
146+
// Visible Files and Open Tabs headers only appear when there's content
148147
expect(result).toContain("# Current Time")
149148
expect(result).toContain("# Current Cost")
150149
expect(result).toContain("# Current Mode")

src/core/environment/getEnvironmentDetails.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
1717
import { Terminal } from "../../integrations/terminal/Terminal"
1818
import { arePathsEqual } from "../../utils/path"
1919
import { formatResponse } from "../prompts/responses"
20+
import { getGitStatus } from "../../utils/git"
2021

2122
import { Task } from "../task/Task"
2223
import { formatReminderSection } from "./reminder"
@@ -34,8 +35,6 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
3435

3536
// It could be useful for cline to know if the user went from one or no
3637
// file to another between messages, so we always include this context.
37-
details += "\n\n# VSCode Visible Files"
38-
3938
const visibleFilePaths = vscode.window.visibleTextEditors
4039
?.map((editor) => editor.document?.uri?.fsPath)
4140
.filter(Boolean)
@@ -48,12 +47,10 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
4847
: visibleFilePaths.map((p) => p.toPosix()).join("\n")
4948

5049
if (allowedVisibleFiles) {
50+
details += "\n\n# VSCode Visible Files"
5151
details += `\n${allowedVisibleFiles}`
52-
} else {
53-
details += "\n(No visible files)"
5452
}
5553

56-
details += "\n\n# VSCode Open Tabs"
5754
const { maxOpenTabsContext } = state ?? {}
5855
const maxTabs = maxOpenTabsContext ?? 20
5956
const openTabPaths = vscode.window.tabGroups.all
@@ -70,9 +67,8 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
7067
: openTabPaths.map((p) => p.toPosix()).join("\n")
7168

7269
if (allowedOpenTabs) {
70+
details += "\n\n# VSCode Open Tabs"
7371
details += `\n${allowedOpenTabs}`
74-
} else {
75-
details += "\n(No open tabs)"
7672
}
7773

7874
// Get task-specific and background terminals.
@@ -205,6 +201,12 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
205201
details += `\n\n# Current Time\nCurrent time in ISO 8601 UTC format: ${now.toISOString()}\nUser time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
206202
}
207203

204+
// Add git status information
205+
const gitStatus = await getGitStatus(cline.cwd)
206+
if (gitStatus) {
207+
details += `\n\n# Git Status\n${gitStatus}`
208+
}
209+
208210
// Add context tokens information (if enabled).
209211
if (includeCurrentCost) {
210212
const { totalCost } = getApiMetrics(cline.clineMessages)

src/utils/__tests__/git.spec.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
extractRepositoryName,
1313
getWorkspaceGitInfo,
1414
convertGitUrlToHttps,
15+
getGitStatus,
1516
} from "../git"
1617
import { truncateOutput } from "../../integrations/misc/extract-text"
1718

@@ -834,3 +835,131 @@ describe("getWorkspaceGitInfo", () => {
834835
expect(readFileSpy).toHaveBeenCalled()
835836
})
836837
})
838+
839+
describe("getGitStatus", () => {
840+
const cwd = "/test/path"
841+
842+
beforeEach(() => {
843+
vitest.clearAllMocks()
844+
})
845+
846+
it("should return git status output", async () => {
847+
const mockOutput = `## main...origin/main [ahead 2, behind 1]
848+
M src/staged-file.ts
849+
M src/unstaged-file.ts
850+
MM src/both-modified.ts
851+
?? src/untracked-file.ts`
852+
853+
const responses = new Map([
854+
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
855+
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
856+
["git status --porcelain=v1 --branch", { stdout: mockOutput, stderr: "" }],
857+
])
858+
859+
vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
860+
for (const [cmd, response] of responses) {
861+
if (command === cmd) {
862+
callback(null, response)
863+
return {} as any
864+
}
865+
}
866+
callback(new Error("Unexpected command"))
867+
return {} as any
868+
})
869+
870+
const result = await getGitStatus(cwd)
871+
872+
expect(result).toBe(mockOutput)
873+
})
874+
875+
it("should return null when git is not installed", async () => {
876+
vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
877+
if (command === "git --version") {
878+
callback(new Error("git not found"))
879+
return {} as any
880+
}
881+
callback(new Error("Unexpected command"))
882+
return {} as any
883+
})
884+
885+
const result = await getGitStatus(cwd)
886+
expect(result).toBeNull()
887+
})
888+
889+
it("should return null when not in a git repository", async () => {
890+
const responses = new Map([
891+
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
892+
["git rev-parse --git-dir", null],
893+
])
894+
895+
vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
896+
const response = responses.get(command)
897+
if (response === null) {
898+
callback(new Error("not a git repository"))
899+
return {} as any
900+
} else if (response) {
901+
callback(null, response)
902+
return {} as any
903+
}
904+
callback(new Error("Unexpected command"))
905+
return {} as any
906+
})
907+
908+
const result = await getGitStatus(cwd)
909+
expect(result).toBeNull()
910+
})
911+
912+
it("should truncate output to 20 lines", async () => {
913+
const lines = Array.from({ length: 30 }, (_, i) => {
914+
if (i === 0) return "## main"
915+
return `M file${i}.ts`
916+
})
917+
const mockOutput = lines.join("\n")
918+
919+
const responses = new Map([
920+
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
921+
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
922+
["git status --porcelain=v1 --branch", { stdout: mockOutput, stderr: "" }],
923+
])
924+
925+
vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
926+
for (const [cmd, response] of responses) {
927+
if (command === cmd) {
928+
callback(null, response)
929+
return {} as any
930+
}
931+
}
932+
callback(new Error("Unexpected command"))
933+
return {} as any
934+
})
935+
936+
const result = await getGitStatus(cwd)
937+
938+
expect(result).toContain("## main")
939+
expect(result).toContain("M file1.ts")
940+
expect(result).toContain("... 10 more lines")
941+
expect(result).not.toContain("file25.ts")
942+
})
943+
944+
it("should return null when status is empty", async () => {
945+
const responses = new Map([
946+
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
947+
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
948+
["git status --porcelain=v1 --branch", { stdout: "", stderr: "" }],
949+
])
950+
951+
vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
952+
for (const [cmd, response] of responses) {
953+
if (command === cmd) {
954+
callback(null, response)
955+
return {} as any
956+
}
957+
}
958+
callback(new Error("Unexpected command"))
959+
return {} as any
960+
})
961+
962+
const result = await getGitStatus(cwd)
963+
expect(result).toBeNull()
964+
})
965+
})

src/utils/git.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,47 @@ export async function getWorkingState(cwd: string): Promise<string> {
355355
return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}`
356356
}
357357
}
358+
359+
const GIT_STATUS_LINE_LIMIT = 20
360+
361+
/**
362+
* Gets git status output with line limit
363+
* @param cwd The working directory to check git status in
364+
* @returns Git status string or null if not a git repository
365+
*/
366+
export async function getGitStatus(cwd: string): Promise<string | null> {
367+
try {
368+
const isInstalled = await checkGitInstalled()
369+
if (!isInstalled) {
370+
return null
371+
}
372+
373+
const isRepo = await checkGitRepo(cwd)
374+
if (!isRepo) {
375+
return null
376+
}
377+
378+
// Use porcelain v1 format with branch info
379+
const { stdout } = await execAsync("git status --porcelain=v1 --branch", { cwd })
380+
381+
if (!stdout.trim()) {
382+
return null
383+
}
384+
385+
// Truncate to line limit
386+
const lines = stdout.trim().split("\n")
387+
const limitedLines = lines.slice(0, GIT_STATUS_LINE_LIMIT)
388+
389+
let output = limitedLines.join("\n")
390+
391+
// Add truncation notice if needed
392+
if (lines.length > GIT_STATUS_LINE_LIMIT) {
393+
output += `\n... ${lines.length - GIT_STATUS_LINE_LIMIT} more lines`
394+
}
395+
396+
return output
397+
} catch (error) {
398+
console.error("Error getting git status:", error)
399+
return null
400+
}
401+
}

0 commit comments

Comments
 (0)