feat: add weave semantic merge driver integration for worktrees

This commit is contained in:
Roo Code 2026-02-22 22:29:20 +00:00
parent aca95ccd04
commit d95a8764f6
7 changed files with 681 additions and 1 deletions

View file

@ -0,0 +1,264 @@
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import { WeaveMergeDriverService, WEAVE_SUPPORTED_EXTENSIONS, buildGitattributesLines } from "../weave-merge-driver.js"
// Helper to run git commands in a directory using execFile for proper argument handling
import { execFileSync } from "child_process"
function execGit(cwd: string, args: string[]): void {
execFileSync("git", args, { cwd, stdio: "pipe" })
}
describe("WeaveMergeDriverService", () => {
let service: WeaveMergeDriverService
let tempDir: string
beforeEach(async () => {
service = new WeaveMergeDriverService()
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "weave-test-"))
})
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true })
})
describe("buildGitattributesLines", () => {
it("should build gitattributes lines for default extensions", () => {
const lines = buildGitattributesLines()
expect(lines.length).toBe(WEAVE_SUPPORTED_EXTENSIONS.length)
expect(lines[0]).toBe("*.py merge=weave")
expect(lines[1]).toBe("*.js merge=weave")
expect(lines[2]).toBe("*.jsx merge=weave")
expect(lines[3]).toBe("*.ts merge=weave")
})
it("should build gitattributes lines for custom extensions", () => {
const lines = buildGitattributesLines(["*.py", "*.rs"])
expect(lines).toEqual(["*.py merge=weave", "*.rs merge=weave"])
})
})
describe("WEAVE_SUPPORTED_EXTENSIONS", () => {
it("should include common language extensions", () => {
expect(WEAVE_SUPPORTED_EXTENSIONS).toContain("*.py")
expect(WEAVE_SUPPORTED_EXTENSIONS).toContain("*.js")
expect(WEAVE_SUPPORTED_EXTENSIONS).toContain("*.ts")
expect(WEAVE_SUPPORTED_EXTENSIONS).toContain("*.tsx")
expect(WEAVE_SUPPORTED_EXTENSIONS).toContain("*.rs")
expect(WEAVE_SUPPORTED_EXTENSIONS).toContain("*.go")
expect(WEAVE_SUPPORTED_EXTENSIONS).toContain("*.java")
})
})
describe("isInstalled", () => {
it("should return a boolean", async () => {
const result = await service.isInstalled()
expect(typeof result).toBe("boolean")
})
})
describe("getVersion", () => {
it("should return undefined when weave is not installed", async () => {
// Override PATH to ensure weave isn't found
const originalPath = process.env.PATH
process.env.PATH = ""
try {
const version = await service.getVersion()
expect(version).toBeUndefined()
} finally {
process.env.PATH = originalPath
}
})
})
describe("isConfiguredInGitConfig", () => {
it("should return false for a repo without weave config", async () => {
execGit(tempDir, ["init"])
execGit(tempDir, ["config", "user.email", "test@test.com"])
execGit(tempDir, ["config", "user.name", "Test"])
const result = await service.isConfiguredInGitConfig(tempDir)
expect(result).toBe(false)
})
it("should return true when weave merge driver is configured", async () => {
execGit(tempDir, ["init"])
execGit(tempDir, ["config", "user.email", "test@test.com"])
execGit(tempDir, ["config", "user.name", "Test"])
execGit(tempDir, ["config", "--local", "merge.weave.driver", "weave merge %O %A %B %P"])
const result = await service.isConfiguredInGitConfig(tempDir)
expect(result).toBe(true)
})
it("should return false for a non-git directory", async () => {
const result = await service.isConfiguredInGitConfig(tempDir)
expect(result).toBe(false)
})
})
describe("isConfiguredInGitattributes", () => {
it("should return false when .gitattributes does not exist", async () => {
execGit(tempDir, ["init"])
const result = await service.isConfiguredInGitattributes(tempDir)
expect(result).toBe(false)
})
it("should return false when .gitattributes exists but has no weave entries", async () => {
execGit(tempDir, ["init"])
await fs.writeFile(path.join(tempDir, ".gitattributes"), "*.txt text\n")
const result = await service.isConfiguredInGitattributes(tempDir)
expect(result).toBe(false)
})
it("should return true when .gitattributes contains weave entries", async () => {
execGit(tempDir, ["init"])
await fs.writeFile(path.join(tempDir, ".gitattributes"), "*.py merge=weave\n*.ts merge=weave\n")
const result = await service.isConfiguredInGitattributes(tempDir)
expect(result).toBe(true)
})
})
describe("getStatus", () => {
it("should return full status for an unconfigured repo", async () => {
execGit(tempDir, ["init"])
const status = await service.getStatus(tempDir)
expect(status.isConfiguredInGitConfig).toBe(false)
expect(status.isConfiguredInGitattributes).toBe(false)
expect(status.isFullyConfigured).toBe(false)
expect(typeof status.isInstalled).toBe("boolean")
})
it("should report isFullyConfigured when both config and gitattributes are set", async () => {
execGit(tempDir, ["init"])
execGit(tempDir, ["config", "--local", "merge.weave.driver", "weave merge %O %A %B %P"])
execGit(tempDir, ["config", "--local", "merge.weave.name", "Weave semantic merge driver"])
await fs.writeFile(path.join(tempDir, ".gitattributes"), "*.ts merge=weave\n")
const status = await service.getStatus(tempDir)
expect(status.isConfiguredInGitConfig).toBe(true)
expect(status.isConfiguredInGitattributes).toBe(true)
expect(status.isFullyConfigured).toBe(true)
})
})
describe("configureGitConfig", () => {
it("should add merge.weave entries to local git config", async () => {
execGit(tempDir, ["init"])
execGit(tempDir, ["config", "user.email", "test@test.com"])
execGit(tempDir, ["config", "user.name", "Test"])
await service.configureGitConfig(tempDir)
const result = await service.isConfiguredInGitConfig(tempDir)
expect(result).toBe(true)
})
})
describe("configureGitattributes", () => {
it("should create .gitattributes when it does not exist", async () => {
execGit(tempDir, ["init"])
const added = await service.configureGitattributes(tempDir, ["*.py", "*.ts"])
expect(added).toEqual(["*.py", "*.ts"])
const content = await fs.readFile(path.join(tempDir, ".gitattributes"), "utf-8")
expect(content).toContain("*.py merge=weave")
expect(content).toContain("*.ts merge=weave")
expect(content).toContain("# Weave semantic merge driver")
})
it("should append to existing .gitattributes", async () => {
execGit(tempDir, ["init"])
await fs.writeFile(path.join(tempDir, ".gitattributes"), "*.txt text\n")
const added = await service.configureGitattributes(tempDir, ["*.py"])
expect(added).toEqual(["*.py"])
const content = await fs.readFile(path.join(tempDir, ".gitattributes"), "utf-8")
expect(content).toContain("*.txt text")
expect(content).toContain("*.py merge=weave")
})
it("should not duplicate existing weave entries", async () => {
execGit(tempDir, ["init"])
await fs.writeFile(path.join(tempDir, ".gitattributes"), "*.py merge=weave\n")
const added = await service.configureGitattributes(tempDir, ["*.py", "*.ts"])
expect(added).toEqual(["*.ts"])
const content = await fs.readFile(path.join(tempDir, ".gitattributes"), "utf-8")
// Should only have one *.py line
const pyMatches = content.match(/\*\.py merge=weave/g)
expect(pyMatches?.length).toBe(1)
})
it("should return empty array when all entries already exist", async () => {
execGit(tempDir, ["init"])
await fs.writeFile(path.join(tempDir, ".gitattributes"), "*.py merge=weave\n*.ts merge=weave\n")
const added = await service.configureGitattributes(tempDir, ["*.py", "*.ts"])
expect(added).toEqual([])
})
it("should throw for non-git directory", async () => {
await expect(service.configureGitattributes(tempDir)).rejects.toThrow("Not a git repository")
})
})
describe("configure", () => {
it("should return failure when weave is not installed", async () => {
execGit(tempDir, ["init"])
// Override PATH to ensure weave isn't found
const originalPath = process.env.PATH
process.env.PATH = ""
try {
const result = await service.configure(tempDir)
expect(result.success).toBe(false)
expect(result.message).toContain("weave is not installed")
expect(result.addedExtensions).toEqual([])
} finally {
process.env.PATH = originalPath
}
})
})
describe("unconfigure", () => {
it("should remove weave config and gitattributes entries", async () => {
execGit(tempDir, ["init"])
execGit(tempDir, ["config", "--local", "merge.weave.driver", "weave merge %O %A %B %P"])
execGit(tempDir, ["config", "--local", "merge.weave.name", "Weave semantic merge driver"])
await fs.writeFile(
path.join(tempDir, ".gitattributes"),
"*.txt text\n# Weave semantic merge driver\n*.py merge=weave\n*.ts merge=weave\n",
)
const result = await service.unconfigure(tempDir)
expect(result.success).toBe(true)
// Check git config was removed
const isConfigured = await service.isConfiguredInGitConfig(tempDir)
expect(isConfigured).toBe(false)
// Check gitattributes was cleaned
const content = await fs.readFile(path.join(tempDir, ".gitattributes"), "utf-8")
expect(content).toContain("*.txt text")
expect(content).not.toContain("merge=weave")
expect(content).not.toContain("# Weave semantic merge driver")
})
it("should succeed even when no weave config exists", async () => {
execGit(tempDir, ["init"])
const result = await service.unconfigure(tempDir)
expect(result.success).toBe(true)
})
})
})

View file

@ -11,3 +11,10 @@ export * from "./types.js"
// Services
export { WorktreeService, worktreeService } from "./worktree-service.js"
export { WorktreeIncludeService, worktreeIncludeService, type CopyProgressCallback } from "./worktree-include.js"
export {
WeaveMergeDriverService,
weaveMergeDriverService,
WEAVE_SUPPORTED_EXTENSIONS,
buildGitattributesLines,
type WeaveMergeDriverStatus,
} from "./weave-merge-driver.js"

View file

@ -0,0 +1,312 @@
/**
* WeaveMergeDriverService
*
* Platform-agnostic service for detecting and configuring the weave semantic merge driver.
* Weave uses tree-sitter to merge at the function/class level, reducing false conflicts
* when parallel worktree tasks edit different functions in the same file.
*
* @see https://github.com/ataraxy-labs/weave
*/
import { exec } from "child_process"
import * as fs from "fs/promises"
import * as path from "path"
import { promisify } from "util"
const execAsync = promisify(exec)
/**
* Supported file extensions for the weave merge driver.
* These correspond to languages that weave/tree-sitter can parse semantically.
*/
export const WEAVE_SUPPORTED_EXTENSIONS = [
"*.py",
"*.js",
"*.jsx",
"*.ts",
"*.tsx",
"*.rs",
"*.go",
"*.java",
"*.c",
"*.cpp",
"*.h",
"*.hpp",
"*.rb",
"*.swift",
"*.kt",
"*.scala",
"*.cs",
]
/**
* The gitattributes lines that configure weave as the merge driver for supported file types.
*/
export function buildGitattributesLines(extensions: string[] = WEAVE_SUPPORTED_EXTENSIONS): string[] {
return extensions.map((ext) => `${ext} merge=weave`)
}
/**
* Status of the weave merge driver in a repository.
*/
export interface WeaveMergeDriverStatus {
/** Whether the weave binary is found on PATH */
isInstalled: boolean
/** Version string if installed, undefined otherwise */
version?: string
/** Whether the merge driver is configured in the repo's git config */
isConfiguredInGitConfig: boolean
/** Whether .gitattributes contains weave merge driver entries */
isConfiguredInGitattributes: boolean
/** Whether setup is fully complete (both git config and gitattributes) */
isFullyConfigured: boolean
}
/**
* Service for managing the weave semantic merge driver configuration.
* All methods are platform-agnostic and don't depend on VSCode APIs.
*/
export class WeaveMergeDriverService {
/**
* Check if weave is installed and available on PATH.
*/
async isInstalled(): Promise<boolean> {
try {
await execAsync("weave --version")
return true
} catch {
return false
}
}
/**
* Get the weave version string.
*/
async getVersion(): Promise<string | undefined> {
try {
const { stdout } = await execAsync("weave --version")
return stdout.trim()
} catch {
return undefined
}
}
/**
* Check if the weave merge driver is configured in the repo's local git config.
*/
async isConfiguredInGitConfig(cwd: string): Promise<boolean> {
try {
const { stdout } = await execAsync("git config --local merge.weave.driver", { cwd })
return stdout.trim().length > 0
} catch {
return false
}
}
/**
* Check if .gitattributes contains weave merge driver entries.
*/
async isConfiguredInGitattributes(cwd: string): Promise<boolean> {
try {
const gitRoot = await this.getGitRoot(cwd)
if (!gitRoot) return false
const gitattributesPath = path.join(gitRoot, ".gitattributes")
const content = await fs.readFile(gitattributesPath, "utf-8")
return content.includes("merge=weave")
} catch {
return false
}
}
/**
* Get the full status of the weave merge driver for a repository.
*/
async getStatus(cwd: string): Promise<WeaveMergeDriverStatus> {
const [isInstalled, version, isConfiguredInGitConfig, isConfiguredInGitattributes] = await Promise.all([
this.isInstalled(),
this.getVersion(),
this.isConfiguredInGitConfig(cwd),
this.isConfiguredInGitattributes(cwd),
])
return {
isInstalled,
version,
isConfiguredInGitConfig,
isConfiguredInGitattributes,
isFullyConfigured: isConfiguredInGitConfig && isConfiguredInGitattributes,
}
}
/**
* Configure the weave merge driver in the repo's local git config.
* Adds:
* [merge "weave"]
* name = Weave semantic merge driver
* driver = weave merge %O %A %B %P
*/
async configureGitConfig(cwd: string): Promise<void> {
await execAsync('git config --local merge.weave.name "Weave semantic merge driver"', { cwd })
await execAsync('git config --local merge.weave.driver "weave merge %O %A %B %P"', { cwd })
}
/**
* Add weave merge driver entries to .gitattributes.
* If .gitattributes already exists, appends the entries (avoiding duplicates).
* If it doesn't exist, creates it.
*
* @param cwd - Current working directory (must be in a git repo)
* @param extensions - File extensions to configure (defaults to WEAVE_SUPPORTED_EXTENSIONS)
* @returns The list of extensions that were actually added (excludes already-configured ones)
*/
async configureGitattributes(cwd: string, extensions: string[] = WEAVE_SUPPORTED_EXTENSIONS): Promise<string[]> {
const gitRoot = await this.getGitRoot(cwd)
if (!gitRoot) {
throw new Error("Not a git repository")
}
const gitattributesPath = path.join(gitRoot, ".gitattributes")
let existingContent = ""
try {
existingContent = await fs.readFile(gitattributesPath, "utf-8")
} catch {
// File doesn't exist yet, that's fine
}
const existingLines = new Set(existingContent.split("\n").map((line) => line.trim()))
const newLines = buildGitattributesLines(extensions)
const addedExtensions: string[] = []
const linesToAdd: string[] = []
for (let i = 0; i < newLines.length; i++) {
const line = newLines[i]!
const ext = extensions[i]!
if (!existingLines.has(line)) {
linesToAdd.push(line)
addedExtensions.push(ext)
}
}
if (linesToAdd.length > 0) {
const separator =
existingContent.length > 0 && !existingContent.endsWith("\n")
? "\n\n# Weave semantic merge driver\n"
: existingContent.length > 0
? "\n# Weave semantic merge driver\n"
: "# Weave semantic merge driver\n"
const newContent = existingContent + separator + linesToAdd.join("\n") + "\n"
await fs.writeFile(gitattributesPath, newContent, "utf-8")
}
return addedExtensions
}
/**
* Fully configure the weave merge driver for a repository.
* Sets up both the git config and .gitattributes entries.
*
* @param cwd - Current working directory (must be in a git repo)
* @param extensions - File extensions to configure (defaults to WEAVE_SUPPORTED_EXTENSIONS)
* @returns Status after configuration
*/
async configure(
cwd: string,
extensions: string[] = WEAVE_SUPPORTED_EXTENSIONS,
): Promise<{ success: boolean; message: string; addedExtensions: string[] }> {
const isInstalled = await this.isInstalled()
if (!isInstalled) {
return {
success: false,
message:
"weave is not installed. Install it with: brew install ataraxy-labs/tap/weave (macOS) or cargo install weave-merge (other platforms). See https://github.com/ataraxy-labs/weave",
addedExtensions: [],
}
}
try {
await this.configureGitConfig(cwd)
const addedExtensions = await this.configureGitattributes(cwd, extensions)
const extMsg =
addedExtensions.length > 0
? `Added ${addedExtensions.length} file pattern(s) to .gitattributes.`
: ".gitattributes already configured."
return {
success: true,
message: `Weave semantic merge driver configured. ${extMsg}`,
addedExtensions,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
success: false,
message: `Failed to configure weave merge driver: ${errorMessage}`,
addedExtensions: [],
}
}
}
/**
* Remove weave merge driver configuration from a repository.
* Removes the git config entries and weave lines from .gitattributes.
*/
async unconfigure(cwd: string): Promise<{ success: boolean; message: string }> {
try {
// Remove git config entries
try {
await execAsync("git config --local --remove-section merge.weave", { cwd })
} catch {
// Section may not exist
}
// Remove weave lines from .gitattributes
const gitRoot = await this.getGitRoot(cwd)
if (gitRoot) {
const gitattributesPath = path.join(gitRoot, ".gitattributes")
try {
const content = await fs.readFile(gitattributesPath, "utf-8")
const lines = content.split("\n")
const filteredLines = lines.filter(
(line) => !line.includes("merge=weave") && line.trim() !== "# Weave semantic merge driver",
)
// Clean up double blank lines
const cleanedContent = filteredLines.join("\n").replace(/\n{3,}/g, "\n\n")
await fs.writeFile(gitattributesPath, cleanedContent, "utf-8")
} catch {
// .gitattributes may not exist
}
}
return {
success: true,
message: "Weave merge driver configuration removed.",
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
success: false,
message: `Failed to remove weave configuration: ${errorMessage}`,
}
}
}
/**
* Get the git repository root path.
*/
private async getGitRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd })
return stdout.trim()
} catch {
return null
}
}
}
// Export singleton instance for convenience
export const weaveMergeDriverService = new WeaveMergeDriverService()

View file

@ -101,6 +101,8 @@ export interface ExtensionMessage {
| "worktreeDefaults"
| "worktreeIncludeStatus"
| "branchWorktreeIncludeResult"
| "weaveMergeDriverStatus"
| "weaveMergeDriverResult"
| "folderSelected"
| "skills"
| "fileContent"
@ -236,6 +238,14 @@ export interface ExtensionMessage {
copyProgressBytesCopied?: number
copyProgressTotalBytes?: number
copyProgressItemName?: string
// Weave merge driver status
weaveMergeDriverStatus?: {
isInstalled: boolean
version?: string
isConfiguredInGitConfig: boolean
isConfiguredInGitattributes: boolean
isFullyConfigured: boolean
}
// folderSelected
path?: string
}
@ -572,6 +582,9 @@ export interface WebviewMessage {
| "createWorktreeInclude"
| "checkoutBranch"
| "browseForWorktreePath"
| "getWeaveMergeDriverStatus"
| "configureWeaveMergeDriver"
| "unconfigureWeaveMergeDriver"
// Skills messages
| "requestSkills"
| "createSkill"

View file

@ -86,6 +86,9 @@ import {
handleCheckBranchWorktreeInclude,
handleCreateWorktreeInclude,
handleCheckoutBranch,
handleGetWeaveMergeDriverStatus,
handleConfigureWeaveMergeDriver,
handleUnconfigureWeaveMergeDriver,
} from "./worktree"
export const webviewMessageHandler = async (
@ -3555,6 +3558,59 @@ export const webviewMessageHandler = async (
break
}
case "getWeaveMergeDriverStatus": {
try {
const weaveMergeDriverStatus = await handleGetWeaveMergeDriverStatus(provider)
await provider.postMessageToWebview({ type: "weaveMergeDriverStatus", weaveMergeDriverStatus })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
provider.log(`Error getting weave merge driver status: ${errorMessage}`)
await provider.postMessageToWebview({
type: "weaveMergeDriverStatus",
weaveMergeDriverStatus: {
isInstalled: false,
isConfiguredInGitConfig: false,
isConfiguredInGitattributes: false,
isFullyConfigured: false,
},
})
}
break
}
case "configureWeaveMergeDriver": {
try {
const { success, message: text } = await handleConfigureWeaveMergeDriver(provider)
await provider.postMessageToWebview({ type: "weaveMergeDriverResult", success, text })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
await provider.postMessageToWebview({
type: "weaveMergeDriverResult",
success: false,
text: errorMessage,
})
}
break
}
case "unconfigureWeaveMergeDriver": {
try {
const { success, message: text } = await handleUnconfigureWeaveMergeDriver(provider)
await provider.postMessageToWebview({ type: "weaveMergeDriverResult", success, text })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
await provider.postMessageToWebview({
type: "weaveMergeDriverResult",
success: false,
text: errorMessage,
})
}
break
}
case "browseForWorktreePath": {
try {
const options: vscode.OpenDialogOptions = {

View file

@ -16,7 +16,13 @@ import type {
WorktreeListResponse,
WorktreeDefaultsResponse,
} from "@roo-code/types"
import { worktreeService, worktreeIncludeService, type CopyProgressCallback } from "@roo-code/core"
import {
worktreeService,
worktreeIncludeService,
weaveMergeDriverService,
type CopyProgressCallback,
} from "@roo-code/core"
import type { WeaveMergeDriverStatus } from "@roo-code/core"
import type { ClineProvider } from "../ClineProvider"
@ -277,3 +283,22 @@ export async function handleCheckoutBranch(provider: ClineProvider, branch: stri
const cwd = provider.cwd
return worktreeService.checkoutBranch(cwd, branch)
}
export async function handleGetWeaveMergeDriverStatus(provider: ClineProvider): Promise<WeaveMergeDriverStatus> {
const cwd = provider.cwd
return weaveMergeDriverService.getStatus(cwd)
}
export async function handleConfigureWeaveMergeDriver(provider: ClineProvider): Promise<WorktreeResult> {
const cwd = provider.cwd
const result = await weaveMergeDriverService.configure(cwd)
return {
success: result.success,
message: result.message,
}
}
export async function handleUnconfigureWeaveMergeDriver(provider: ClineProvider): Promise<WorktreeResult> {
const cwd = provider.cwd
return weaveMergeDriverService.unconfigure(cwd)
}

View file

@ -16,6 +16,9 @@ export {
handleCheckBranchWorktreeInclude,
handleCreateWorktreeInclude,
handleCheckoutBranch,
handleGetWeaveMergeDriverStatus,
handleConfigureWeaveMergeDriver,
handleUnconfigureWeaveMergeDriver,
} from "./handlers"
// Re-export types from @roo-code/types for convenience