Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ export const globalSettingsSchema = z.object({
* @default true
*/
includeCurrentCost: z.boolean().optional(),
/**
* Maximum number of git status file entries to include in the environment details.
* Set to 0 to disable git status. The header (branch, commits) is always included when > 0.
* @default 0
*/
maxGitStatusFiles: z.number().optional(),

/**
* Whether to include diagnostic messages (errors, warnings) in tool outputs
Expand Down
70 changes: 68 additions & 2 deletions src/core/environment/__tests__/getEnvironmentDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ApiHandler } from "../../../api/index"
import { ClineProvider } from "../../webview/ClineProvider"
import { RooIgnoreController } from "../../ignore/RooIgnoreController"
import { formatResponse } from "../../prompts/responses"
import { getGitStatus } from "../../../utils/git"
import { Task } from "../../task/Task"

vi.mock("vscode", () => ({
Expand Down Expand Up @@ -48,6 +49,7 @@ vi.mock("../../../services/glob/list-files")
vi.mock("../../../integrations/terminal/TerminalRegistry")
vi.mock("../../../integrations/terminal/Terminal")
vi.mock("../../../utils/path")
vi.mock("../../../utils/git")
vi.mock("../../prompts/responses")

describe("getEnvironmentDetails", () => {
Expand Down Expand Up @@ -134,6 +136,7 @@ describe("getEnvironmentDetails", () => {
;(TerminalRegistry.getBackgroundTerminals as Mock).mockReturnValue([])
;(TerminalRegistry.isProcessHot as Mock).mockReturnValue(false)
;(TerminalRegistry.getUnretrievedOutput as Mock).mockReturnValue("")
;(getGitStatus as Mock).mockResolvedValue("## main")
vi.mocked(pWaitFor).mockResolvedValue(undefined)
vi.mocked(delay).mockResolvedValue(undefined)
})
Expand All @@ -143,9 +146,9 @@ describe("getEnvironmentDetails", () => {

expect(result).toContain("<environment_details>")
expect(result).toContain("</environment_details>")
expect(result).toContain("# VSCode Visible Files")
expect(result).toContain("# VSCode Open Tabs")
// Visible Files and Open Tabs headers only appear when there's content
expect(result).toContain("# Current Time")
expect(result).not.toContain("# Git Status") // Git status is disabled by default (maxGitStatusFiles = 0)
expect(result).toContain("# Current Cost")
expect(result).toContain("# Current Mode")
expect(result).toContain("<model>test-model</model>")
Expand Down Expand Up @@ -390,4 +393,67 @@ describe("getEnvironmentDetails", () => {
const result = await getEnvironmentDetails(cline as Task)
expect(result).toContain("REMINDERS")
})

it("should include git status when maxGitStatusFiles > 0", async () => {
;(getGitStatus as Mock).mockResolvedValue("## main\nM file1.ts")
mockProvider.getState.mockResolvedValue({
...mockState,
maxGitStatusFiles: 10,
})

const result = await getEnvironmentDetails(mockCline as Task)

expect(result).toContain("# Git Status")
expect(result).toContain("## main")
expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 10)
})

it("should NOT include git status when maxGitStatusFiles is 0", async () => {
mockProvider.getState.mockResolvedValue({
...mockState,
maxGitStatusFiles: 0,
})

const result = await getEnvironmentDetails(mockCline as Task)

expect(result).not.toContain("# Git Status")
expect(getGitStatus).not.toHaveBeenCalled()
})

it("should NOT include git status when maxGitStatusFiles is undefined (defaults to 0)", async () => {
mockProvider.getState.mockResolvedValue({
...mockState,
maxGitStatusFiles: undefined,
})

const result = await getEnvironmentDetails(mockCline as Task)

expect(result).not.toContain("# Git Status")
expect(getGitStatus).not.toHaveBeenCalled()
})

it("should handle git status returning null gracefully when enabled", async () => {
;(getGitStatus as Mock).mockResolvedValue(null)
mockProvider.getState.mockResolvedValue({
...mockState,
maxGitStatusFiles: 10,
})

const result = await getEnvironmentDetails(mockCline as Task)

expect(result).not.toContain("# Git Status")
expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 10)
})

it("should pass maxFiles parameter to getGitStatus", async () => {
;(getGitStatus as Mock).mockResolvedValue("## main")
mockProvider.getState.mockResolvedValue({
...mockState,
maxGitStatusFiles: 5,
})

await getEnvironmentDetails(mockCline as Task)

expect(getGitStatus).toHaveBeenCalledWith(mockCwd, 5)
})
})
20 changes: 12 additions & 8 deletions src/core/environment/getEnvironmentDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
import { Terminal } from "../../integrations/terminal/Terminal"
import { arePathsEqual } from "../../utils/path"
import { formatResponse } from "../prompts/responses"
import { getGitStatus } from "../../utils/git"

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

// It could be useful for cline to know if the user went from one or no
// file to another between messages, so we always include this context.
details += "\n\n# VSCode Visible Files"

const visibleFilePaths = vscode.window.visibleTextEditors
?.map((editor) => editor.document?.uri?.fsPath)
.filter(Boolean)
Expand All @@ -48,12 +47,10 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
: visibleFilePaths.map((p) => p.toPosix()).join("\n")

if (allowedVisibleFiles) {
details += "\n\n# VSCode Visible Files"
details += `\n${allowedVisibleFiles}`
} else {
details += "\n(No visible files)"
}

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

if (allowedOpenTabs) {
details += "\n\n# VSCode Open Tabs"
details += `\n${allowedOpenTabs}`
} else {
details += "\n(No open tabs)"
}

// Get task-specific and background terminals.
Expand Down Expand Up @@ -191,7 +187,7 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
}

// Get settings for time and cost display
const { includeCurrentTime = true, includeCurrentCost = true } = state ?? {}
const { includeCurrentTime = true, includeCurrentCost = true, maxGitStatusFiles = 0 } = state ?? {}

// Add current time information with timezone (if enabled).
if (includeCurrentTime) {
Expand All @@ -205,6 +201,14 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
details += `\n\n# Current Time\nCurrent time in ISO 8601 UTC format: ${now.toISOString()}\nUser time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
}

// Add git status information (if enabled with maxGitStatusFiles > 0).
if (maxGitStatusFiles > 0) {
const gitStatus = await getGitStatus(cline.cwd, maxGitStatusFiles)
if (gitStatus) {
details += `\n\n# Git Status\n${gitStatus}`
}
}

// Add context tokens information (if enabled).
if (includeCurrentCost) {
const { totalCost } = getApiMetrics(cline.clineMessages)
Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1918,6 +1918,7 @@ export class ClineProvider
includeTaskHistoryInEnhance,
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
taskSyncEnabled,
remoteControlEnabled,
openRouterImageApiKey,
Expand Down Expand Up @@ -2082,6 +2083,7 @@ export class ClineProvider
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
taskSyncEnabled,
remoteControlEnabled,
openRouterImageApiKey,
Expand Down Expand Up @@ -2297,6 +2299,7 @@ export class ClineProvider
includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true,
includeCurrentTime: stateValues.includeCurrentTime ?? true,
includeCurrentCost: stateValues.includeCurrentCost ?? true,
maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0,
taskSyncEnabled,
remoteControlEnabled: (() => {
try {
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export type ExtensionState = Pick<
| "reasoningBlockCollapsed"
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"
> & {
version: string
clineMessages: ClineMessage[]
Expand Down
158 changes: 158 additions & 0 deletions src/utils/__tests__/git.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
extractRepositoryName,
getWorkspaceGitInfo,
convertGitUrlToHttps,
getGitStatus,
} from "../git"
import { truncateOutput } from "../../integrations/misc/extract-text"

Expand Down Expand Up @@ -834,3 +835,160 @@ describe("getWorkspaceGitInfo", () => {
expect(readFileSpy).toHaveBeenCalled()
})
})

describe("getGitStatus", () => {
const cwd = "/test/path"

beforeEach(() => {
vitest.clearAllMocks()
})

it("should return git status output with default maxFiles", async () => {
const mockOutput = `## main...origin/main [ahead 2, behind 1]
M src/staged-file.ts
M src/unstaged-file.ts
MM src/both-modified.ts
?? src/untracked-file.ts`

const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
["git status --porcelain=v1 --branch", { stdout: mockOutput, stderr: "" }],
])

vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
for (const [cmd, response] of responses) {
if (command === cmd) {
callback(null, response)
return {} as any
}
}
callback(new Error("Unexpected command"))
return {} as any
})

const result = await getGitStatus(cwd, 20)

expect(result).toBe(mockOutput)
})

it("should show only branch info when maxFiles is 0", async () => {
const mockOutput = `## main...origin/main
M src/file1.ts
?? src/file2.ts`

const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
["git status --porcelain=v1 --branch", { stdout: mockOutput, stderr: "" }],
])

vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
for (const [cmd, response] of responses) {
if (command === cmd) {
callback(null, response)
return {} as any
}
}
callback(new Error("Unexpected command"))
return {} as any
})

const result = await getGitStatus(cwd, 0)

expect(result).toBe("## main...origin/main")
expect(result).not.toContain("M src/file1.ts")
expect(result).not.toContain("?? src/file2.ts")
})

it("should return null when git is not installed", async () => {
vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
if (command === "git --version") {
callback(new Error("git not found"))
return {} as any
}
callback(new Error("Unexpected command"))
return {} as any
})

const result = await getGitStatus(cwd)
expect(result).toBeNull()
})

it("should return null when not in a git repository", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", null],
])

vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
const response = responses.get(command)
if (response === null) {
callback(new Error("not a git repository"))
return {} as any
} else if (response) {
callback(null, response)
return {} as any
}
callback(new Error("Unexpected command"))
return {} as any
})

const result = await getGitStatus(cwd)
expect(result).toBeNull()
})

it("should respect maxFiles parameter", async () => {
const lines = Array.from({ length: 30 }, (_, i) => {
if (i === 0) return "## main"
return `M file${i}.ts`
})
const mockOutput = lines.join("\n")

const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
["git status --porcelain=v1 --branch", { stdout: mockOutput, stderr: "" }],
])

vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
for (const [cmd, response] of responses) {
if (command === cmd) {
callback(null, response)
return {} as any
}
}
callback(new Error("Unexpected command"))
return {} as any
})

const result = await getGitStatus(cwd, 10)

expect(result).toContain("## main")
expect(result).toContain("M file1.ts")
expect(result).toContain("... 19 more files")
expect(result).not.toContain("file15.ts")
})

it("should return null when status is empty", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
["git status --porcelain=v1 --branch", { stdout: "", stderr: "" }],
])

vitest.mocked(exec).mockImplementation((command: string, options: any, callback: any) => {
for (const [cmd, response] of responses) {
if (command === cmd) {
callback(null, response)
return {} as any
}
}
callback(new Error("Unexpected command"))
return {} as any
})

const result = await getGitStatus(cwd)
expect(result).toBeNull()
})
})
Loading
Loading