Skip to content

Commit 66b0377

Browse files
committed
Adds a 'Create PR' button to the button bar after attempt_completion
1 parent 8e4b145 commit 66b0377

File tree

10 files changed

+275
-18
lines changed

10 files changed

+275
-18
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1886,6 +1886,12 @@ export class ClineProvider
18861886
const currentMode = mode ?? defaultModeSlug
18871887
const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode)
18881888

1889+
// Check if the current workspace is a git repository
1890+
const gitInfo = await getWorkspaceGitInfo()
1891+
// A repository is valid if we found ANY git info (not just a remote URL)
1892+
// This includes defaultBranch, which is populated even for worktrees.
1893+
const isGitRepository = Object.keys(gitInfo).length > 0
1894+
18891895
return {
18901896
version: this.context.extension?.packageJSON?.version ?? "",
18911897
apiConfiguration,
@@ -2016,6 +2022,7 @@ export class ClineProvider
20162022
openRouterImageGenerationSelectedModel,
20172023
openRouterUseMiddleOutTransform,
20182024
featureRoomoteControlEnabled,
2025+
isGitRepository,
20192026
}
20202027
}
20212028

@@ -2250,6 +2257,7 @@ export class ClineProvider
22502257
return false
22512258
}
22522259
})(),
2260+
isGitRepository: false, // Will be computed in getStateToPostToWebview
22532261
}
22542262
}
22552263

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ describe("ClineProvider", () => {
562562
taskSyncEnabled: false,
563563
featureRoomoteControlEnabled: false,
564564
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
565+
isGitRepository: false,
565566
}
566567

567568
const message: ExtensionMessage = {

src/services/command/built-in-commands.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,62 @@ Please analyze this codebase and create an AGENTS.md file containing:
284284
285285
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.`,
286286
},
287+
create_pr: {
288+
name: "create-pr",
289+
description: "Create a GitHub pull request from current branch",
290+
content: `<task>
291+
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.
292+
</task>
293+
294+
<instructions>
295+
1. Check if there are any unstaged/uncommitted changes:
296+
- Run: git status
297+
- If changes exist, stage and commit them with a descriptive message
298+
299+
2. Identify the base branch (main or master):
300+
- Check which exists: git branch --list main master
301+
- Use the one that exists as base branch
302+
303+
3. Get current branch name:
304+
- Run: git branch --show-current
305+
306+
4. Analyze all changes between current branch and base:
307+
- Run: git diff <base-branch>...HEAD
308+
- Also get commit messages: git log <base-branch>..HEAD --oneline
309+
310+
5. Generate PR title and description:
311+
- Analyze the diff and commit messages
312+
- Create concise, descriptive title (50 chars max)
313+
- Write clear PR description explaining:
314+
* What changed and why
315+
* Key implementation details
316+
* Any breaking changes or important notes
317+
318+
6. Get repository info:
319+
- Extract from: git remote get-url origin
320+
- Parse to get org/repo format
321+
322+
7. Check if gh CLI is installed:
323+
- Run: gh --version
324+
- If not found, guide user to install:
325+
* macOS: brew install gh
326+
* Windows: winget install GitHub.cli
327+
* Linux: See https://github.com/cli/cli#installation
328+
* After install: gh auth login
329+
330+
8. Create the pull request:
331+
- Run: gh pr create --repo <org/repo> --head <current-branch> --title "<generated-title>" --body "<generated-description>"
332+
333+
9. After successful PR creation:
334+
- Extract PR URL from gh output
335+
- Present success message with:
336+
* Link to the created PR
337+
* Offer to have it reviewed by Roo Code Cloud's PR Reviewer agent
338+
* Link: https://roocode.com/reviewer
339+
340+
If gh CLI is not installed or authenticated, provide clear setup instructions and wait for user to complete setup before proceeding.
341+
</instructions>`,
342+
},
287343
}
288344

289345
/**

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ export type ExtensionState = Pick<
362362
remoteControlEnabled: boolean
363363
taskSyncEnabled: boolean
364364
featureRoomoteControlEnabled: boolean
365+
isGitRepository: boolean
365366
}
366367

367368
export interface ClineSayTool {

src/utils/__tests__/git.spec.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ vitest.mock("fs", () => ({
3333
promises: {
3434
access: vitest.fn(),
3535
readFile: vitest.fn(),
36+
stat: vitest.fn(),
3637
},
3738
}))
3839

@@ -470,6 +471,12 @@ describe("getGitRepositoryInfo", () => {
470471
// Mock successful access to .git directory
471472
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
472473

474+
// Mock stat to indicate .git is a directory (not a worktree file)
475+
vitest.mocked(fs.promises.stat).mockResolvedValue({
476+
isFile: () => false,
477+
isDirectory: () => true,
478+
} as any)
479+
473480
// Mock git config file content
474481
const mockConfig = `
475482
[core]
@@ -524,6 +531,12 @@ describe("getGitRepositoryInfo", () => {
524531
// Mock successful access to .git directory
525532
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
526533

534+
// Mock stat to indicate .git is a directory (not a worktree file)
535+
vitest.mocked(fs.promises.stat).mockResolvedValue({
536+
isFile: () => false,
537+
isDirectory: () => true,
538+
} as any)
539+
527540
// Mock git config file without URL
528541
const mockConfig = `
529542
[core]
@@ -561,6 +574,12 @@ describe("getGitRepositoryInfo", () => {
561574
// Mock successful access to .git directory
562575
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
563576

577+
// Mock stat to indicate .git is a directory (not a worktree file)
578+
vitest.mocked(fs.promises.stat).mockResolvedValue({
579+
isFile: () => false,
580+
isDirectory: () => true,
581+
} as any)
582+
564583
// Setup the readFile mock to return different values based on the path
565584
gitSpy.mockImplementation((path: any, encoding: any) => {
566585
if (path === configPath) {
@@ -588,6 +607,12 @@ describe("getGitRepositoryInfo", () => {
588607
// Mock successful access to .git directory
589608
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
590609

610+
// Mock stat to indicate .git is a directory (not a worktree file)
611+
vitest.mocked(fs.promises.stat).mockResolvedValue({
612+
isFile: () => false,
613+
isDirectory: () => true,
614+
} as any)
615+
591616
// Setup the readFile mock to return different values based on the path
592617
gitSpy.mockImplementation((path: any, encoding: any) => {
593618
if (path === configPath) {
@@ -619,6 +644,12 @@ describe("getGitRepositoryInfo", () => {
619644
// Mock successful access to .git directory
620645
vitest.mocked(fs.promises.access).mockResolvedValue(undefined)
621646

647+
// Mock stat to indicate .git is a directory (not a worktree file)
648+
vitest.mocked(fs.promises.stat).mockResolvedValue({
649+
isFile: () => false,
650+
isDirectory: () => true,
651+
} as any)
652+
622653
// Mock git config file with SSH URL
623654
const mockConfig = `
624655
[core]
@@ -654,6 +685,99 @@ describe("getGitRepositoryInfo", () => {
654685
defaultBranch: "main",
655686
})
656687
})
688+
689+
it("should handle git worktrees where .git is a file", async () => {
690+
// Clear previous mocks
691+
vitest.clearAllMocks()
692+
693+
// Create a spy to track the implementation
694+
const accessSpy = vitest.spyOn(fs.promises, "access")
695+
const statSpy = vitest.spyOn(fs.promises, "stat")
696+
const readFileSpy = vitest.spyOn(fs.promises, "readFile")
697+
698+
// Mock successful access to .git file (not directory)
699+
accessSpy.mockResolvedValue(undefined)
700+
701+
// Mock stat to indicate .git is a file (worktree)
702+
statSpy.mockResolvedValue({
703+
isFile: () => true,
704+
isDirectory: () => false,
705+
} as any)
706+
707+
// Mock .git file content (worktree reference)
708+
const gitFileContent = "gitdir: /path/to/main/repo/.git/worktrees/my-worktree"
709+
710+
// Mock git config file content from the actual git directory
711+
const mockConfig = `
712+
[core]
713+
repositoryformatversion = 0
714+
filemode = true
715+
bare = false
716+
[remote "origin"]
717+
url = https://github.com/RooCodeInc/Roo-Code.git
718+
fetch = +refs/heads/*:refs/remotes/origin/*
719+
[branch "main"]
720+
remote = origin
721+
merge = refs/heads/main
722+
`
723+
// Mock HEAD file content
724+
const mockHead = "ref: refs/heads/feature-branch"
725+
726+
// Setup the readFile mock to return different values based on the path
727+
readFileSpy.mockImplementation((filePath: any, encoding: any) => {
728+
const pathStr = String(filePath)
729+
if (pathStr.endsWith(".git")) {
730+
// Reading the .git file itself
731+
return Promise.resolve(gitFileContent)
732+
} else if (pathStr.includes("config")) {
733+
return Promise.resolve(mockConfig)
734+
} else if (pathStr.includes("HEAD")) {
735+
return Promise.resolve(mockHead)
736+
}
737+
return Promise.reject(new Error(`Unexpected path: ${pathStr}`))
738+
})
739+
740+
const result = await getGitRepositoryInfo(workspaceRoot)
741+
742+
// Verify that the worktree was handled correctly
743+
expect(result).toEqual({
744+
repositoryUrl: "https://github.com/RooCodeInc/Roo-Code.git",
745+
repositoryName: "RooCodeInc/Roo-Code",
746+
defaultBranch: "main",
747+
})
748+
749+
// Verify the .git file was read
750+
expect(statSpy).toHaveBeenCalledWith(gitDir)
751+
expect(readFileSpy).toHaveBeenCalledWith(gitDir, "utf8")
752+
})
753+
754+
it("should return empty object if .git file has invalid format", async () => {
755+
// Clear previous mocks
756+
vitest.clearAllMocks()
757+
758+
// Create a spy to track the implementation
759+
const accessSpy = vitest.spyOn(fs.promises, "access")
760+
const statSpy = vitest.spyOn(fs.promises, "stat")
761+
const readFileSpy = vitest.spyOn(fs.promises, "readFile")
762+
763+
// Mock successful access to .git file
764+
accessSpy.mockResolvedValue(undefined)
765+
766+
// Mock stat to indicate .git is a file (worktree)
767+
statSpy.mockResolvedValue({
768+
isFile: () => true,
769+
isDirectory: () => false,
770+
} as any)
771+
772+
// Mock invalid .git file content
773+
const gitFileContent = "invalid content without gitdir"
774+
775+
readFileSpy.mockResolvedValue(gitFileContent)
776+
777+
const result = await getGitRepositoryInfo(workspaceRoot)
778+
779+
expect(result).toEqual({})
780+
})
657781
})
658782

659783
describe("convertGitUrlToHttps", () => {
@@ -804,6 +928,12 @@ describe("getWorkspaceGitInfo", () => {
804928
// Mock successful access to .git directory
805929
gitSpy.mockResolvedValue(undefined)
806930

931+
// Mock stat to indicate .git is a directory (not a worktree file)
932+
vitest.mocked(fs.promises.stat).mockResolvedValue({
933+
isFile: () => false,
934+
isDirectory: () => true,
935+
} as any)
936+
807937
// Mock git config file content
808938
const mockConfig = `
809939
[remote "origin"]

src/utils/git.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,41 @@ export interface GitCommit {
2929
*/
3030
export async function getGitRepositoryInfo(workspaceRoot: string): Promise<GitRepositoryInfo> {
3131
try {
32-
const gitDir = path.join(workspaceRoot, ".git")
32+
let gitDir = path.join(workspaceRoot, ".git")
3333

34-
// Check if .git directory exists
34+
// Check if .git exists (could be a directory or file)
3535
try {
3636
await fs.access(gitDir)
3737
} catch {
3838
// Not a git repository
3939
return {}
4040
}
4141

42+
// Check if .git is a file (worktree) or directory
43+
const stats = await fs.stat(gitDir)
44+
if (stats.isFile()) {
45+
// This is a worktree - read the .git file to get the actual git directory
46+
const gitFileContent = await fs.readFile(gitDir, "utf8")
47+
const gitdirMatch = gitFileContent.match(/gitdir:\s*(.+)/)
48+
if (gitdirMatch && gitdirMatch[1]) {
49+
const worktreeGitDir = gitdirMatch[1].trim() // This is the worktree's .git directory
50+
gitDir = worktreeGitDir // Update gitDir to the actual .git directory
51+
52+
// For worktrees, the config is in the main repo's .git directory
53+
// Worktree path is like: /path/to/repo/.git/worktrees/name
54+
// Main config is at: /path/to/repo/.git/config
55+
if (worktreeGitDir.includes("/worktrees/")) {
56+
// Extract the path to the main repository's .git directory
57+
const mainGitDir = worktreeGitDir.split("/worktrees/")[0]
58+
// Use this mainGitDir for reading the config file
59+
gitDir = mainGitDir // This is crucial: we need to read config from the main repo's .git dir
60+
}
61+
} else {
62+
// Invalid .git file format
63+
return {}
64+
}
65+
}
66+
4267
const gitInfo: GitRepositoryInfo = {}
4368

4469
// Try to read git config file

0 commit comments

Comments
 (0)