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"