Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@
"clean": "rimraf dist .turbo"
},
"dependencies": {
"@roo-code/core": "workspace:^",
"@roo-code/types": "workspace:^",
"@roo-code/vscode-shim": "workspace:^",
"@vscode/ripgrep": "^1.15.9",
"commander": "^12.1.0"
"commander": "^12.1.0",
"execa": "^9.5.2",
"cross-spawn": "^7.0.6"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@roo-code/types": "workspace:^",
"esbuild": "^0.25.0",
"execa": "^9.5.2",
"ignore": "^7.0.3",
"openai": "^5.12.2",
"zod": "^3.25.61"
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./custom-tools/index.js"
export * from "./worktree/index.js"
268 changes: 268 additions & 0 deletions packages/core/src/worktree/__tests__/worktree-include.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import { execFile } from "child_process"
import { promisify } from "util"

import { WorktreeIncludeService } from "../worktree-include.js"

const execFileAsync = promisify(execFile)

async function execGit(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf8" })
return stdout
}

describe("WorktreeIncludeService", () => {
let service: WorktreeIncludeService
let tempDir: string

beforeEach(async () => {
service = new WorktreeIncludeService()
// Create a temp directory for each test
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "worktree-test-"))
})

afterEach(async () => {
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true })
} catch {
// Ignore cleanup errors
}
})

describe("hasWorktreeInclude", () => {
it("should return true when .worktreeinclude exists", async () => {
await fs.writeFile(path.join(tempDir, ".worktreeinclude"), "node_modules")

const result = await service.hasWorktreeInclude(tempDir)

expect(result).toBe(true)
})

it("should return false when .worktreeinclude does not exist", async () => {
const result = await service.hasWorktreeInclude(tempDir)

expect(result).toBe(false)
})

it("should return false for non-existent directory", async () => {
const result = await service.hasWorktreeInclude("/non/existent/path")

expect(result).toBe(false)
})
})

describe("branchHasWorktreeInclude", () => {
it("should detect .worktreeinclude on the specified branch", async () => {
const repoDir = path.join(tempDir, "repo")
await fs.mkdir(repoDir, { recursive: true })

await execGit(repoDir, ["init"])
await execGit(repoDir, ["config", "user.name", "Test User"])
await execGit(repoDir, ["config", "user.email", "test@example.com"])

await fs.writeFile(path.join(repoDir, "README.md"), "test")
await execGit(repoDir, ["add", "README.md"])
await execGit(repoDir, ["commit", "-m", "init"])

const baseBranch = (await execGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim()

expect(await service.branchHasWorktreeInclude(repoDir, baseBranch)).toBe(false)

await execGit(repoDir, ["checkout", "-b", "with-include"])
await fs.writeFile(path.join(repoDir, ".worktreeinclude"), "node_modules")
await execGit(repoDir, ["add", ".worktreeinclude"])
await execGit(repoDir, ["commit", "-m", "add include"])

expect(await service.branchHasWorktreeInclude(repoDir, "with-include")).toBe(true)
}, 30_000)
})

describe("getStatus", () => {
it("should return correct status when both files exist", async () => {
const gitignoreContent = "node_modules\n.env\ndist"
await fs.writeFile(path.join(tempDir, ".worktreeinclude"), "node_modules")
await fs.writeFile(path.join(tempDir, ".gitignore"), gitignoreContent)

const result = await service.getStatus(tempDir)

expect(result.exists).toBe(true)
expect(result.hasGitignore).toBe(true)
expect(result.gitignoreContent).toBe(gitignoreContent)
})

it("should return correct status when only .gitignore exists", async () => {
const gitignoreContent = "node_modules\n.env"
await fs.writeFile(path.join(tempDir, ".gitignore"), gitignoreContent)

const result = await service.getStatus(tempDir)

expect(result.exists).toBe(false)
expect(result.hasGitignore).toBe(true)
expect(result.gitignoreContent).toBe(gitignoreContent)
})

it("should return correct status when only .worktreeinclude exists", async () => {
await fs.writeFile(path.join(tempDir, ".worktreeinclude"), "node_modules")

const result = await service.getStatus(tempDir)

expect(result.exists).toBe(true)
expect(result.hasGitignore).toBe(false)
expect(result.gitignoreContent).toBeUndefined()
})

it("should return correct status when neither file exists", async () => {
const result = await service.getStatus(tempDir)

expect(result.exists).toBe(false)
expect(result.hasGitignore).toBe(false)
expect(result.gitignoreContent).toBeUndefined()
})
})

describe("createWorktreeInclude", () => {
it("should create .worktreeinclude file with specified content", async () => {
const content = "node_modules\n.env\ndist"

await service.createWorktreeInclude(tempDir, content)

const fileContent = await fs.readFile(path.join(tempDir, ".worktreeinclude"), "utf-8")
expect(fileContent).toBe(content)
})

it("should overwrite existing .worktreeinclude file", async () => {
await fs.writeFile(path.join(tempDir, ".worktreeinclude"), "old content")
const newContent = "new content"

await service.createWorktreeInclude(tempDir, newContent)

const fileContent = await fs.readFile(path.join(tempDir, ".worktreeinclude"), "utf-8")
expect(fileContent).toBe(newContent)
})
})

describe("copyWorktreeIncludeFiles", () => {
let sourceDir: string
let targetDir: string

beforeEach(async () => {
sourceDir = path.join(tempDir, "source")
targetDir = path.join(tempDir, "target")
await fs.mkdir(sourceDir, { recursive: true })
await fs.mkdir(targetDir, { recursive: true })
})

it("should return empty array when no .worktreeinclude exists", async () => {
await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules")

const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)

expect(result).toEqual([])
})

it("should return empty array when no .gitignore exists", async () => {
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules")

const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)

expect(result).toEqual([])
})

it("should return empty array when patterns do not match", async () => {
// .worktreeinclude wants node_modules, .gitignore only ignores .env
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules")
await fs.writeFile(path.join(sourceDir, ".gitignore"), ".env")
await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })

const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)

expect(result).toEqual([])
})

it("should copy files that match both patterns", async () => {
// Both files include node_modules
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules")
await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules")
// Create a file in node_modules
await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
await fs.writeFile(path.join(sourceDir, "node_modules", "package.json"), '{"name": "test"}')

const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)

expect(result).toContain("node_modules")
// Verify the file was copied
const copiedContent = await fs.readFile(path.join(targetDir, "node_modules", "package.json"), "utf-8")
expect(copiedContent).toBe('{"name": "test"}')
})

it("should only copy intersection of patterns", async () => {
// .worktreeinclude: node_modules, dist
// .gitignore: node_modules, .env
// Only node_modules should be copied (intersection)
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "node_modules\ndist")
await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules\n.env")
await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })
await fs.mkdir(path.join(sourceDir, "dist"), { recursive: true })
await fs.writeFile(path.join(sourceDir, ".env"), "SECRET=123")
await fs.writeFile(path.join(sourceDir, "node_modules", "test.txt"), "test")
await fs.writeFile(path.join(sourceDir, "dist", "main.js"), "console.log('dist')")

const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)

// Only node_modules should be in the result (matches both)
expect(result).toContain("node_modules")
expect(result).not.toContain("dist") // only in .worktreeinclude
expect(result).not.toContain(".env") // only in .gitignore

// Verify node_modules was copied
const nodeModulesExists = await fs
.access(path.join(targetDir, "node_modules"))
.then(() => true)
.catch(() => false)
expect(nodeModulesExists).toBe(true)

// Verify dist was NOT copied
const distExists = await fs
.access(path.join(targetDir, "dist"))
.then(() => true)
.catch(() => false)
expect(distExists).toBe(false)
})

it("should skip .git directory", async () => {
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), ".git")
await fs.writeFile(path.join(sourceDir, ".gitignore"), ".git")
await fs.mkdir(path.join(sourceDir, ".git"), { recursive: true })
await fs.writeFile(path.join(sourceDir, ".git", "config"), "[core]")

const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)

expect(result).not.toContain(".git")
})

it("should copy single files", async () => {
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), ".env.local")
await fs.writeFile(path.join(sourceDir, ".gitignore"), ".env.local")
await fs.writeFile(path.join(sourceDir, ".env.local"), "LOCAL_VAR=value")

const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)

expect(result).toContain(".env.local")
const copiedContent = await fs.readFile(path.join(targetDir, ".env.local"), "utf-8")
expect(copiedContent).toBe("LOCAL_VAR=value")
})

it("should ignore comment lines in pattern files", async () => {
await fs.writeFile(path.join(sourceDir, ".worktreeinclude"), "# comment\nnode_modules\n# another comment")
await fs.writeFile(path.join(sourceDir, ".gitignore"), "node_modules")
await fs.mkdir(path.join(sourceDir, "node_modules"), { recursive: true })

const result = await service.copyWorktreeIncludeFiles(sourceDir, targetDir)

expect(result).toContain("node_modules")
})
})
})
13 changes: 13 additions & 0 deletions packages/core/src/worktree/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Worktree Module
*
* Platform-agnostic git worktree management functionality.
* These exports are decoupled from VSCode and can be used by any consumer.
*/

// Types
export * from "./types.js"

// Services
export { WorktreeService, worktreeService } from "./worktree-service.js"
export { WorktreeIncludeService, worktreeIncludeService } from "./worktree-include.js"
17 changes: 17 additions & 0 deletions packages/core/src/worktree/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Worktree Types
*
* Re-exports platform-agnostic type definitions from @roo-code/types.
*/

export type {
Worktree,
WorktreeResult,
BranchInfo,
CreateWorktreeOptions,
MergeWorktreeOptions,
MergeWorktreeResult,
WorktreeIncludeStatus,
WorktreeListResponse,
WorktreeDefaultsResponse,
} from "@roo-code/types"
Loading
Loading