Skip to content
Open
15 changes: 14 additions & 1 deletion src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import { MdmService } from "../../services/mdm/MdmService"

import { fileExistsAtPath } from "../../utils/fs"
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
import { getWorkspaceGitInfo } from "../../utils/git"
import { getWorkspaceGitInfo, isGitHubRepository } from "../../utils/git"
import { getWorkspacePath } from "../../utils/path"
import { OrganizationAllowListViolationError } from "../../utils/errors"

Expand Down Expand Up @@ -1886,6 +1886,15 @@ 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

// Check if the repository is specifically a GitHub repository
const isGithubRepository = isGitHubRepository(gitInfo.repositoryUrl)

return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
Expand Down Expand Up @@ -2016,6 +2025,8 @@ export class ClineProvider
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
featureRoomoteControlEnabled,
isGitRepository,
isGithubRepository,
}
}

Expand Down Expand Up @@ -2250,6 +2261,8 @@ export class ClineProvider
return false
}
})(),
isGitRepository: false, // Will be computed in getStateToPostToWebview
isGithubRepository: false, // Will be computed in getStateToPostToWebview
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,8 @@ describe("ClineProvider", () => {
taskSyncEnabled: false,
featureRoomoteControlEnabled: false,
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
isGitRepository: false,
isGithubRepository: false,
}

const message: ExtensionMessage = {
Expand Down
56 changes: 56 additions & 0 deletions src/services/command/built-in-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<task>
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.
</task>

<instructions>
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 <base-branch>...HEAD
- Also get commit messages: git log <base-branch>..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 <org/repo> --head <current-branch> --title "<generated-title>" --body "<generated-description>"

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.
</instructions>`,
},
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ export type ExtensionState = Pick<
remoteControlEnabled: boolean
taskSyncEnabled: boolean
featureRoomoteControlEnabled: boolean
isGitRepository: boolean
isGithubRepository: boolean
}

export interface ClineSayTool {
Expand Down
163 changes: 163 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,
isGitHubRepository,
} from "../git"
import { truncateOutput } from "../../integrations/misc/extract-text"

Expand All @@ -33,6 +34,7 @@ vitest.mock("fs", () => ({
promises: {
access: vitest.fn(),
readFile: vitest.fn(),
stat: vitest.fn(),
},
}))

Expand Down Expand Up @@ -470,6 +472,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]
Expand Down Expand Up @@ -524,6 +532,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]
Expand Down Expand Up @@ -561,6 +575,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) {
Expand Down Expand Up @@ -588,6 +608,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) {
Expand Down Expand Up @@ -619,6 +645,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]
Expand Down Expand Up @@ -654,6 +686,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", () => {
Expand Down Expand Up @@ -774,6 +899,38 @@ describe("extractRepositoryName", () => {
})
})

describe("isGitHubRepository", () => {
it("should return true for github.com HTTPS URLs", () => {
expect(isGitHubRepository("https://github.com/user/repo.git")).toBe(true)
expect(isGitHubRepository("https://github.com/user/repo")).toBe(true)
})

it("should return true for github.com SSH URLs", () => {
expect(isGitHubRepository("[email protected]:user/repo.git")).toBe(true)
expect(isGitHubRepository("ssh://[email protected]/user/repo.git")).toBe(true)
})

it("should return true for GitHub URLs with different casing", () => {
expect(isGitHubRepository("https://GitHub.com/user/repo.git")).toBe(true)
expect(isGitHubRepository("https://GITHUB.COM/user/repo.git")).toBe(true)
})

it("should return false for non-GitHub URLs", () => {
expect(isGitHubRepository("https://gitlab.com/user/repo.git")).toBe(false)
expect(isGitHubRepository("https://bitbucket.org/user/repo.git")).toBe(false)
expect(isGitHubRepository("[email protected]:user/repo.git")).toBe(false)
})

it("should return false for undefined or empty URLs", () => {
expect(isGitHubRepository(undefined)).toBe(false)
expect(isGitHubRepository("")).toBe(false)
})

it("should handle sanitized GitHub URLs", () => {
expect(isGitHubRepository("https://github.com/user/repo")).toBe(true)
})
})

describe("getWorkspaceGitInfo", () => {
const workspaceRoot = "/test/workspace"

Expand Down Expand Up @@ -804,6 +961,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"]
Expand Down
Loading
Loading