diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index 7aa370f7d2..8d690e208e 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -1886,6 +1886,12 @@ export class ClineProvider
const currentMode = mode ?? defaultModeSlug
const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode)
+ // Check if the current workspace is a git repository
+ const gitInfo = await getWorkspaceGitInfo()
+ // A repository is valid if we found ANY git info (not just a remote URL)
+ // This includes defaultBranch, which is populated even for worktrees.
+ const isGitRepository = Object.keys(gitInfo).length > 0
+
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
@@ -2016,6 +2022,7 @@ export class ClineProvider
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
featureRoomoteControlEnabled,
+ isGitRepository,
}
}
@@ -2250,6 +2257,7 @@ export class ClineProvider
return false
}
})(),
+ isGitRepository: false, // Will be computed in getStateToPostToWebview
}
}
diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts
index a8ab39108d..f44fec74b4 100644
--- a/src/core/webview/__tests__/ClineProvider.spec.ts
+++ b/src/core/webview/__tests__/ClineProvider.spec.ts
@@ -562,6 +562,7 @@ describe("ClineProvider", () => {
taskSyncEnabled: false,
featureRoomoteControlEnabled: false,
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
+ isGitRepository: false,
}
const message: ExtensionMessage = {
diff --git a/src/services/command/built-in-commands.ts b/src/services/command/built-in-commands.ts
index db113c4895..5eb3166252 100644
--- a/src/services/command/built-in-commands.ts
+++ b/src/services/command/built-in-commands.ts
@@ -284,6 +284,62 @@ Please analyze this codebase and create an AGENTS.md file containing:
Remember: The goal is to create documentation that enables AI assistants to be immediately productive in this codebase, focusing on project-specific knowledge that isn't obvious from the code structure alone.`,
},
+ create_pr: {
+ name: "create-pr",
+ description: "Create a GitHub pull request from current branch",
+ content: `
+Stage and commit any outstanding changes, then review the changes made in this branch versus main/master and create a pull request using the gh CLI.
+
+
+
+1. Check if there are any unstaged/uncommitted changes:
+ - Run: git status
+ - If changes exist, stage and commit them with a descriptive message
+
+2. Identify the base branch (main or master):
+ - Check which exists: git branch --list main master
+ - Use the one that exists as base branch
+
+3. Get current branch name:
+ - Run: git branch --show-current
+
+4. Analyze all changes between current branch and base:
+ - Run: git diff ...HEAD
+ - Also get commit messages: git log ..HEAD --oneline
+
+5. Generate PR title and description:
+ - Analyze the diff and commit messages
+ - Create concise, descriptive title (50 chars max)
+ - Write clear PR description explaining:
+ * What changed and why
+ * Key implementation details
+ * Any breaking changes or important notes
+
+6. Get repository info:
+ - Extract from: git remote get-url origin
+ - Parse to get org/repo format
+
+7. Check if gh CLI is installed:
+ - Run: gh --version
+ - If not found, guide user to install:
+ * macOS: brew install gh
+ * Windows: winget install GitHub.cli
+ * Linux: See https://github.com/cli/cli#installation
+ * After install: gh auth login
+
+8. Create the pull request:
+ - Run: gh pr create --repo --head --title "" --body ""
+
+9. After successful PR creation:
+ - Extract PR URL from gh output
+ - Present success message with:
+ * Link to the created PR
+ * Offer to have it reviewed by Roo Code Cloud's PR Reviewer agent
+ * Link: https://roocode.com/reviewer
+
+If gh CLI is not installed or authenticated, provide clear setup instructions and wait for user to complete setup before proceeding.
+`,
+ },
}
/**
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index 7d2759c919..4bcf8b9f0b 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -362,6 +362,7 @@ export type ExtensionState = Pick<
remoteControlEnabled: boolean
taskSyncEnabled: boolean
featureRoomoteControlEnabled: boolean
+ isGitRepository: boolean
}
export interface ClineSayTool {
diff --git a/src/utils/__tests__/git.spec.ts b/src/utils/__tests__/git.spec.ts
index 16b404f9e0..5b10d51787 100644
--- a/src/utils/__tests__/git.spec.ts
+++ b/src/utils/__tests__/git.spec.ts
@@ -33,6 +33,7 @@ vitest.mock("fs", () => ({
promises: {
access: vitest.fn(),
readFile: vitest.fn(),
+ stat: vitest.fn(),
},
}))
@@ -470,6 +471,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+ // Mock stat to indicate .git is a directory (not a worktree file)
+ vitest.mocked(fs.promises.stat).mockResolvedValue({
+ isFile: () => false,
+ isDirectory: () => true,
+ } as any)
+
// Mock git config file content
const mockConfig = `
[core]
@@ -524,6 +531,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+ // Mock stat to indicate .git is a directory (not a worktree file)
+ vitest.mocked(fs.promises.stat).mockResolvedValue({
+ isFile: () => false,
+ isDirectory: () => true,
+ } as any)
+
// Mock git config file without URL
const mockConfig = `
[core]
@@ -561,6 +574,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+ // Mock stat to indicate .git is a directory (not a worktree file)
+ vitest.mocked(fs.promises.stat).mockResolvedValue({
+ isFile: () => false,
+ isDirectory: () => true,
+ } as any)
+
// Setup the readFile mock to return different values based on the path
gitSpy.mockImplementation((path: any, encoding: any) => {
if (path === configPath) {
@@ -588,6 +607,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+ // Mock stat to indicate .git is a directory (not a worktree file)
+ vitest.mocked(fs.promises.stat).mockResolvedValue({
+ isFile: () => false,
+ isDirectory: () => true,
+ } as any)
+
// Setup the readFile mock to return different values based on the path
gitSpy.mockImplementation((path: any, encoding: any) => {
if (path === configPath) {
@@ -619,6 +644,12 @@ describe("getGitRepositoryInfo", () => {
// Mock successful access to .git directory
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
+ // Mock stat to indicate .git is a directory (not a worktree file)
+ vitest.mocked(fs.promises.stat).mockResolvedValue({
+ isFile: () => false,
+ isDirectory: () => true,
+ } as any)
+
// Mock git config file with SSH URL
const mockConfig = `
[core]
@@ -654,6 +685,99 @@ describe("getGitRepositoryInfo", () => {
defaultBranch: "main",
})
})
+
+ it("should handle git worktrees where .git is a file", async () => {
+ // Clear previous mocks
+ vitest.clearAllMocks()
+
+ // Create a spy to track the implementation
+ const accessSpy = vitest.spyOn(fs.promises, "access")
+ const statSpy = vitest.spyOn(fs.promises, "stat")
+ const readFileSpy = vitest.spyOn(fs.promises, "readFile")
+
+ // Mock successful access to .git file (not directory)
+ accessSpy.mockResolvedValue(undefined)
+
+ // Mock stat to indicate .git is a file (worktree)
+ statSpy.mockResolvedValue({
+ isFile: () => true,
+ isDirectory: () => false,
+ } as any)
+
+ // Mock .git file content (worktree reference)
+ const gitFileContent = "gitdir: /path/to/main/repo/.git/worktrees/my-worktree"
+
+ // Mock git config file content from the actual git directory
+ const mockConfig = `
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = false
+[remote "origin"]
+ url = https://github.com/RooCodeInc/Roo-Code.git
+ fetch = +refs/heads/*:refs/remotes/origin/*
+[branch "main"]
+ remote = origin
+ merge = refs/heads/main
+`
+ // Mock HEAD file content
+ const mockHead = "ref: refs/heads/feature-branch"
+
+ // Setup the readFile mock to return different values based on the path
+ readFileSpy.mockImplementation((filePath: any, encoding: any) => {
+ const pathStr = String(filePath)
+ if (pathStr.endsWith(".git")) {
+ // Reading the .git file itself
+ return Promise.resolve(gitFileContent)
+ } else if (pathStr.includes("config")) {
+ return Promise.resolve(mockConfig)
+ } else if (pathStr.includes("HEAD")) {
+ return Promise.resolve(mockHead)
+ }
+ return Promise.reject(new Error(`Unexpected path: ${pathStr}`))
+ })
+
+ const result = await getGitRepositoryInfo(workspaceRoot)
+
+ // Verify that the worktree was handled correctly
+ expect(result).toEqual({
+ repositoryUrl: "https://github.com/RooCodeInc/Roo-Code.git",
+ repositoryName: "RooCodeInc/Roo-Code",
+ defaultBranch: "main",
+ })
+
+ // Verify the .git file was read
+ expect(statSpy).toHaveBeenCalledWith(gitDir)
+ expect(readFileSpy).toHaveBeenCalledWith(gitDir, "utf8")
+ })
+
+ it("should return empty object if .git file has invalid format", async () => {
+ // Clear previous mocks
+ vitest.clearAllMocks()
+
+ // Create a spy to track the implementation
+ const accessSpy = vitest.spyOn(fs.promises, "access")
+ const statSpy = vitest.spyOn(fs.promises, "stat")
+ const readFileSpy = vitest.spyOn(fs.promises, "readFile")
+
+ // Mock successful access to .git file
+ accessSpy.mockResolvedValue(undefined)
+
+ // Mock stat to indicate .git is a file (worktree)
+ statSpy.mockResolvedValue({
+ isFile: () => true,
+ isDirectory: () => false,
+ } as any)
+
+ // Mock invalid .git file content
+ const gitFileContent = "invalid content without gitdir"
+
+ readFileSpy.mockResolvedValue(gitFileContent)
+
+ const result = await getGitRepositoryInfo(workspaceRoot)
+
+ expect(result).toEqual({})
+ })
})
describe("convertGitUrlToHttps", () => {
@@ -804,6 +928,12 @@ describe("getWorkspaceGitInfo", () => {
// Mock successful access to .git directory
gitSpy.mockResolvedValue(undefined)
+ // Mock stat to indicate .git is a directory (not a worktree file)
+ vitest.mocked(fs.promises.stat).mockResolvedValue({
+ isFile: () => false,
+ isDirectory: () => true,
+ } as any)
+
// Mock git config file content
const mockConfig = `
[remote "origin"]
diff --git a/src/utils/git.ts b/src/utils/git.ts
index 3bb562bf43..d1bb719ce6 100644
--- a/src/utils/git.ts
+++ b/src/utils/git.ts
@@ -29,9 +29,9 @@ export interface GitCommit {
*/
export async function getGitRepositoryInfo(workspaceRoot: string): Promise {
try {
- const gitDir = path.join(workspaceRoot, ".git")
+ let gitDir = path.join(workspaceRoot, ".git")
- // Check if .git directory exists
+ // Check if .git exists (could be a directory or file)
try {
await fs.access(gitDir)
} catch {
@@ -39,6 +39,31 @@ export async function getGitRepositoryInfo(workspaceRoot: string): Promise(null)
const userRespondedRef = useRef(false)
const [currentFollowUpTs, setCurrentFollowUpTs] = useState(null)
+ const [prCreationRequested, setPrCreationRequested] = useState(false)
const clineAskRef = useRef(clineAsk)
useEffect(() => {
@@ -403,8 +405,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0) {
+ // Reset PR creation flag when user sends any message
+ // This allows creating another PR if more changes are made
+ setPrCreationRequested(false)
+
// Queue message if:
// - Task is busy (sendingDisabled)
// - API request in progress (isStreaming)
@@ -723,8 +738,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction
{
cloudIsAuthenticated: false,
sharingEnabled: false,
profileThresholds: {},
- hasOpenedModeSelector: false, // Add the new required property
+ hasOpenedModeSelector: false,
maxImageFileSize: 5,
maxTotalImageSize: 20,
remoteControlEnabled: false,
taskSyncEnabled: false,
featureRoomoteControlEnabled: false,
- checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Add the checkpoint timeout property
+ checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
+ isGitRepository: false,
}
const prevState: ExtensionState = {
diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json
index 6f47f040c6..a0122876dc 100644
--- a/webview-ui/src/i18n/locales/en/chat.json
+++ b/webview-ui/src/i18n/locales/en/chat.json
@@ -38,6 +38,10 @@
"title": "Start New Task",
"tooltip": "Begin a new task"
},
+ "createPR": {
+ "title": "Create PR",
+ "tooltip": "Create a pull request from your changes"
+ },
"proceedAnyways": {
"title": "Proceed Anyways",
"tooltip": "Continue while command executes"