mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-19 00:01:19 +00:00
* fix: cancel backend auto-approval timeout when auto-approve is toggled off mid-countdown (#11439) Co-authored-by: Sannidhya <sann@Sannidhyas-MacBook-Pro.local> * fix: prevent chat history loss during cloud/settings navigation (#11371) (#11372) Co-authored-by: Sannidhya <sann@Sannidhyas-MacBook-Pro.local> * fix: preserve pasted images in chatbox during chat activity (#11375) Co-authored-by: Roo Code <roomote@roocode.com> * fix: resolve chat scroll anchoring and task-switch scroll race condit… (#11385) * fix: avoid zsh process-substitution false positives in assignments (#11365) * fix(editor): make tab close best-effort in DiffViewProvider.open (#11363) * fix(checkpoints): canonicalize core.worktree comparison to prevent Windows path mismatch failures (#11346) * fix: prevent double notification sound playback (#11283) * fix: prevent false unsaved changes prompt with OpenAI Compatible headers (#8230) (#11334) fix: prevent false unsaved changes prompt with OpenAI Compatible headers Mark automatic header syncs in ApiOptions and OpenAICompatible as non-user actions (isUserAction: false) and enhance SettingsView change detection to skip automatic syncs with semantically equal values. Root cause: two components (ApiOptions and OpenAICompatible) manage openAiHeaders state and automatically sync it back on mount/remount. These syncs were treated as user changes, triggering a false dirty state. Co-authored-by: Robert McIntyre <robertjmcintyre@users.noreply.github.com> * fix: remove noisy console.warn logs from NativeToolCallParser (#11264) Remove two console.warn messages that fire excessively when loading tasks from history: - 'Attempting to finalize unknown tool call' in finalizeStreamingToolCall() - 'Received chunk for unknown tool call' in processStreamingChunk() The defensive null-return behavior is preserved; only the log output is removed. * refactor: remove footgun prompting (file-based system prompt override) (#11387) * refactor: delete orphaned per-provider caching transform files (#11388) * feat: add disabledTools setting to globally disable native tools (#11277) * feat: add disabledTools setting to globally disable native tools Add a disabledTools field to GlobalSettings that allows disabling specific native tools by name. This enables cloud agents to be configured with restricted tool access. Schema: - Add disabledTools: z.array(toolNamesSchema).optional() to globalSettingsSchema - Add disabledTools to organizationDefaultSettingsSchema.pick() - Add disabledTools to ExtensionState Pick type Prompt generation (tool filtering): - Add disabledTools to BuildToolsOptions interface - Pass disabledTools through filterSettings to filterNativeToolsForMode() - Remove disabled tools from allowedToolNames set in filterNativeToolsForMode() Execution-time validation (safety net): - Extract disabledTools from state in presentAssistantMessage - Convert disabledTools to toolRequirements format for validateToolUse() Wiring: - Add disabledTools to ClineProvider getState() and getStateToPostToWebview() - Pass disabledTools to all buildNativeToolsArrayWithRestrictions() call sites EXT-778 * fix: check toolRequirements before ALWAYS_AVAILABLE_TOOLS Moves the toolRequirements check before the ALWAYS_AVAILABLE_TOOLS early-return in isToolAllowedForMode(). This ensures disabledTools can block always-available tools (switch_mode, new_task, etc.) at execution time, making the validation layer consistent with the filtering layer. * feat: add support for .agents/skills directory (#11181) * feat: add support for .agents/skills directory This change adds support for discovering skills from the .agents/skills directory, following the Agent Skills convention for sharing skills across different AI coding tools. Priority order (later entries override earlier ones): 1. Global ~/.agents/skills (shared across AI coding tools, lowest priority) 2. Project .agents/skills 3. Global ~/.roo/skills (Roo-specific) 4. Project .roo/skills (highest priority) Changes: - Add getGlobalAgentsDirectory() and getProjectAgentsDirectoryForCwd() functions to roo-config - Update SkillsManager.getSkillsDirectories() to include .agents/skills - Update SkillsManager.setupFileWatchers() to watch .agents/skills - Add tests for new functionality * fix: clarify skill priority comment to match actual behavior * fix: clarify skill priority comment to explain Map.set replacement mechanism --------- Co-authored-by: Roo Code <roomote@roocode.com> * feat(history): render nested subtasks as recursive tree (#11299) * feat(history): render nested subtasks as recursive tree * fix(lockfile): resolve missing ai-sdk provider entry * fix: address review feedback — dedupe countAll, increase SubtaskRow max-h - HistoryView: replace local countAll with imported countAllSubtasks from types.ts - SubtaskRow: increase nested children max-h from 500px to 2000px to match TaskGroupItem * perf(refactor): consolidate getState calls in resolveWebviewView (#11320) * perf(refactor): consolidate getState calls in resolveWebviewView Replace three separate this.getState().then() calls with a single await this.getState() and destructuring. This avoids running the full getState() method (CloudService calls, ContextProxy reads, etc.) three times during webview view resolution. * fix: keep getState consolidation non-blocking to avoid delaying webview render --------- Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com> * fix: harden command auto-approval against inline JS false positives (#11382) * feat: rename search_and_replace tool to edit and unify edit-family UI (#11296) * Revert "refactor: delete orphaned per-provider caching transform files (#11388)" This reverts commit13a45b0361. * chore: regenerate built-in-skills.ts with updated formatting * fix: add missing maxReadFileLine property to test baseState The ExtensionState type now requires maxReadFileLine property (added in commit63e3f769a). Update the test to include this property with the default value of -1 (unlimited reading). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: add pnpm serve command for code-server development (#10964) Co-authored-by: Roo Code <roomote@roocode.com> * chore: remove Feature Request from issue template options (#11141) Co-authored-by: Roo Code <roomote@roocode.com> * refactor(docs-extractor): simplify mode to focus on raw fact extraction (#11129) * Add cli support for linux (#11167) * fix: replace heredocs with echo statements in cli-release workflow (#11168) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * Drop MacOS-13 cli support (#11169) * fix(cli): correct example in install script (#11170) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * feat: add Kimi K2.5 model to Fireworks provider (#11177) * feat(cli): improve dev experience and roo provider API key support (#11203) - Allow --api-key and ROO_API_KEY env var for the roo provider instead of requiring cloud auth token - Switch dev/start scripts to use tsx for running directly from source without building first - Fix path resolution (version.ts, extension.ts, extension-host.ts) to work from both source and bundled locations - Disable debug log file (~/.roo/cli-debug.log) unless --debug is passed - Update README with complete env var table and dev workflow docs Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * Roo Code CLI v0.0.50 (#11204) * Roo Code CLI v0.0.50 * docs(cli): add --exit-on-error to changelog --------- Co-authored-by: Roo Code <roomote@roocode.com> * feat(cli): update default model from Opus 4.5 to Opus 4.6 (#11273) Co-authored-by: Roo Code <roomote@roocode.com> * feat(web): replace Roomote Control with Linear Integration in cloud features grid (#11280) Co-authored-by: Roo Code <roomote@roocode.com> * Add linux-arm64 for the roo cli (#11314) * chore: clean up repo-facing mode rules (#11410) * Make CLI auto-approve by default with require-approval opt-in (#11424) Co-authored-by: Roo Code <roomote@roocode.com> * Add new code owners to CODEOWNERS file * Update next.js (#11108) * feat(web): Replace bespoke navigation menu with shadcn navigation menu (#11117) Co-authored-by: Roo Code <roomote@roocode.com> --------- Co-authored-by: SannidhyaSah <sah_sannidhya@outlook.com> Co-authored-by: Sannidhya <sann@Sannidhyas-MacBook-Pro.local> Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> Co-authored-by: Roo Code <roomote@roocode.com> Co-authored-by: Hannes Rudolph <hrudolph@gmail.com> Co-authored-by: 0xMink <dennis@dennismink.com> Co-authored-by: Robert McIntyre <robertjmcintyre@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com> Co-authored-by: Chris Estreich <cestreich@gmail.com>
516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
import fs from "fs/promises"
|
|
import os from "os"
|
|
import * as path from "path"
|
|
import crypto from "crypto"
|
|
import EventEmitter from "events"
|
|
|
|
import simpleGit, { SimpleGit, SimpleGitOptions } from "simple-git"
|
|
import pWaitFor from "p-wait-for"
|
|
import * as vscode from "vscode"
|
|
|
|
import { fileExistsAtPath } from "../../utils/fs"
|
|
import { arePathsEqual } from "../../utils/path"
|
|
import { executeRipgrep } from "../../services/search/file-search"
|
|
import { t } from "../../i18n"
|
|
|
|
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
|
|
import { getExcludePatterns } from "./excludes"
|
|
|
|
/**
|
|
* Creates a SimpleGit instance with sanitized environment variables to prevent
|
|
* interference from inherited git environment variables like GIT_DIR and GIT_WORK_TREE.
|
|
* This ensures checkpoint operations always target the intended shadow repository.
|
|
*
|
|
* @param baseDir - The directory where git operations should be executed
|
|
* @returns A SimpleGit instance with sanitized environment
|
|
*/
|
|
function createSanitizedGit(baseDir: string): SimpleGit {
|
|
// Create a clean environment by explicitly unsetting git-related environment variables
|
|
// that could interfere with checkpoint operations
|
|
const sanitizedEnv: Record<string, string> = {}
|
|
const removedVars: string[] = []
|
|
|
|
// Copy all environment variables except git-specific ones
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
// Skip git environment variables that would override repository location
|
|
if (
|
|
key === "GIT_DIR" ||
|
|
key === "GIT_WORK_TREE" ||
|
|
key === "GIT_INDEX_FILE" ||
|
|
key === "GIT_OBJECT_DIRECTORY" ||
|
|
key === "GIT_ALTERNATE_OBJECT_DIRECTORIES" ||
|
|
key === "GIT_CEILING_DIRECTORIES"
|
|
) {
|
|
removedVars.push(`${key}=${value}`)
|
|
continue
|
|
}
|
|
|
|
// Only include defined values
|
|
if (value !== undefined) {
|
|
sanitizedEnv[key] = value
|
|
}
|
|
}
|
|
|
|
// Log which git env vars were removed (helps with debugging Dev Container issues)
|
|
if (removedVars.length > 0) {
|
|
console.log(
|
|
`[createSanitizedGit] Removed git environment variables for checkpoint isolation: ${removedVars.join(", ")}`,
|
|
)
|
|
}
|
|
|
|
const options: Partial<SimpleGitOptions> = {
|
|
baseDir,
|
|
config: [],
|
|
}
|
|
|
|
// Create git instance and set the sanitized environment
|
|
const git = simpleGit(options)
|
|
|
|
// Use the .env() method to set the complete sanitized environment
|
|
// This replaces the inherited environment with our sanitized version
|
|
git.env(sanitizedEnv)
|
|
|
|
console.log(`[createSanitizedGit] Created git instance for baseDir: ${baseDir}`)
|
|
|
|
return git
|
|
}
|
|
|
|
export abstract class ShadowCheckpointService extends EventEmitter {
|
|
public readonly taskId: string
|
|
public readonly checkpointsDir: string
|
|
public readonly workspaceDir: string
|
|
|
|
protected _checkpoints: string[] = []
|
|
protected _baseHash?: string
|
|
|
|
protected readonly dotGitDir: string
|
|
protected git?: SimpleGit
|
|
protected readonly log: (message: string) => void
|
|
protected shadowGitConfigWorktree?: string
|
|
|
|
public get baseHash() {
|
|
return this._baseHash
|
|
}
|
|
|
|
protected set baseHash(value: string | undefined) {
|
|
this._baseHash = value
|
|
}
|
|
|
|
public get isInitialized() {
|
|
return !!this.git
|
|
}
|
|
|
|
public getCheckpoints(): string[] {
|
|
return this._checkpoints.slice()
|
|
}
|
|
|
|
constructor(taskId: string, checkpointsDir: string, workspaceDir: string, log: (message: string) => void) {
|
|
super()
|
|
|
|
const homedir = os.homedir()
|
|
const desktopPath = path.join(homedir, "Desktop")
|
|
const documentsPath = path.join(homedir, "Documents")
|
|
const downloadsPath = path.join(homedir, "Downloads")
|
|
const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath]
|
|
|
|
if (protectedPaths.includes(workspaceDir)) {
|
|
throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
|
|
}
|
|
|
|
this.taskId = taskId
|
|
this.checkpointsDir = checkpointsDir
|
|
this.workspaceDir = workspaceDir
|
|
|
|
this.dotGitDir = path.join(this.checkpointsDir, ".git")
|
|
this.log = log
|
|
}
|
|
|
|
public async initShadowGit(onInit?: () => Promise<void>) {
|
|
if (this.git) {
|
|
throw new Error("Shadow git repo already initialized")
|
|
}
|
|
|
|
const nestedGitPath = await this.getNestedGitRepository()
|
|
|
|
if (nestedGitPath) {
|
|
// Show persistent error message with the offending path
|
|
const relativePath = path.relative(this.workspaceDir, nestedGitPath)
|
|
const message = t("common:errors.nested_git_repos_warning", { path: relativePath })
|
|
vscode.window.showErrorMessage(message)
|
|
|
|
throw new Error(
|
|
`Checkpoints are disabled because a nested git repository was detected at: ${relativePath}. ` +
|
|
"Please remove or relocate nested git repositories to use the checkpoints feature.",
|
|
)
|
|
}
|
|
|
|
await fs.mkdir(this.checkpointsDir, { recursive: true })
|
|
const git = createSanitizedGit(this.checkpointsDir)
|
|
const gitVersion = await git.version()
|
|
this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
|
|
|
|
let created = false
|
|
const startTime = Date.now()
|
|
|
|
if (await fileExistsAtPath(this.dotGitDir)) {
|
|
this.log(`[${this.constructor.name}#initShadowGit] shadow git repo already exists at ${this.dotGitDir}`)
|
|
const worktree = await this.getShadowGitConfigWorktree(git)
|
|
|
|
if (!worktree) {
|
|
throw new Error("Checkpoints require core.worktree to be set in the shadow git config")
|
|
}
|
|
|
|
const worktreeTrimmed = worktree.trim()
|
|
|
|
if (!arePathsEqual(worktreeTrimmed, this.workspaceDir)) {
|
|
throw new Error(
|
|
`Checkpoints can only be used in the original workspace: ${worktreeTrimmed} !== ${this.workspaceDir}`,
|
|
)
|
|
}
|
|
|
|
await this.writeExcludeFile()
|
|
this.baseHash = await git.revparse(["HEAD"])
|
|
} else {
|
|
this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
|
|
await git.init()
|
|
await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
|
|
await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
|
|
await git.addConfig("user.name", "Roo Code")
|
|
await git.addConfig("user.email", "noreply@example.com")
|
|
await this.writeExcludeFile()
|
|
await this.stageAll(git)
|
|
const { commit } = await git.commit("initial commit", { "--allow-empty": null })
|
|
this.baseHash = commit
|
|
created = true
|
|
}
|
|
|
|
const duration = Date.now() - startTime
|
|
|
|
this.log(
|
|
`[${this.constructor.name}#initShadowGit] initialized shadow repo with base commit ${this.baseHash} in ${duration}ms`,
|
|
)
|
|
|
|
this.git = git
|
|
|
|
await onInit?.()
|
|
|
|
this.emit("initialize", {
|
|
type: "initialize",
|
|
workspaceDir: this.workspaceDir,
|
|
baseHash: this.baseHash,
|
|
created,
|
|
duration,
|
|
})
|
|
|
|
return { created, duration }
|
|
}
|
|
|
|
// Add basic excludes directly in git config, while respecting any
|
|
// .gitignore in the workspace.
|
|
// .git/info/exclude is local to the shadow git repo, so it's not
|
|
// shared with the main repo - and won't conflict with user's
|
|
// .gitignore.
|
|
protected async writeExcludeFile() {
|
|
await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
|
|
const patterns = await getExcludePatterns(this.workspaceDir)
|
|
await fs.writeFile(path.join(this.dotGitDir, "info", "exclude"), patterns.join("\n"))
|
|
}
|
|
|
|
private async stageAll(git: SimpleGit) {
|
|
try {
|
|
await git.add([".", "--ignore-errors"])
|
|
} catch (error) {
|
|
this.log(
|
|
`[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
private async getNestedGitRepository(): Promise<string | null> {
|
|
try {
|
|
// Find all .git/HEAD files that are not at the root level.
|
|
const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]
|
|
|
|
const gitPaths = await executeRipgrep({ args, workspacePath: this.workspaceDir })
|
|
|
|
// Filter to only include nested git directories (not the root .git).
|
|
// Since we're searching for HEAD files, we expect type to be "file"
|
|
const nestedGitPaths = gitPaths.filter(({ type, path: filePath }) => {
|
|
// Check if it's a file and is a nested .git/HEAD (not at root)
|
|
if (type !== "file") return false
|
|
|
|
// Ensure it's a .git/HEAD file and not the root one
|
|
const normalizedPath = filePath.replace(/\\/g, "/")
|
|
return (
|
|
normalizedPath.includes(".git/HEAD") &&
|
|
!normalizedPath.startsWith(".git/") &&
|
|
normalizedPath !== ".git/HEAD"
|
|
)
|
|
})
|
|
|
|
if (nestedGitPaths.length > 0) {
|
|
// Get the first nested git repository path
|
|
// Remove .git/HEAD from the path to get the repository directory
|
|
const headPath = nestedGitPaths[0].path
|
|
|
|
// Use path module to properly extract the repository directory
|
|
// The HEAD file is at .git/HEAD, so we need to go up two directories
|
|
const gitDir = path.dirname(headPath) // removes HEAD, gives us .git
|
|
const repoDir = path.dirname(gitDir) // removes .git, gives us the repo directory
|
|
|
|
const absolutePath = path.join(this.workspaceDir, repoDir)
|
|
|
|
this.log(
|
|
`[${this.constructor.name}#getNestedGitRepository] found ${nestedGitPaths.length} nested git repositories, first at: ${repoDir}`,
|
|
)
|
|
return absolutePath
|
|
}
|
|
|
|
return null
|
|
} catch (error) {
|
|
this.log(
|
|
`[${this.constructor.name}#getNestedGitRepository] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
|
|
// If we can't check, assume there are no nested repos to avoid blocking the feature.
|
|
return null
|
|
}
|
|
}
|
|
|
|
private async getShadowGitConfigWorktree(git: SimpleGit) {
|
|
if (!this.shadowGitConfigWorktree) {
|
|
try {
|
|
this.shadowGitConfigWorktree = (await git.getConfig("core.worktree")).value || undefined
|
|
} catch (error) {
|
|
this.log(
|
|
`[${this.constructor.name}#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
return this.shadowGitConfigWorktree
|
|
}
|
|
|
|
public async saveCheckpoint(
|
|
message: string,
|
|
options?: { allowEmpty?: boolean; suppressMessage?: boolean },
|
|
): Promise<CheckpointResult | undefined> {
|
|
try {
|
|
this.log(
|
|
`[${this.constructor.name}#saveCheckpoint] starting checkpoint save (allowEmpty: ${options?.allowEmpty ?? false})`,
|
|
)
|
|
|
|
if (!this.git) {
|
|
throw new Error("Shadow git repo not initialized")
|
|
}
|
|
|
|
const startTime = Date.now()
|
|
await this.stageAll(this.git)
|
|
const commitArgs = options?.allowEmpty ? { "--allow-empty": null } : undefined
|
|
const result = await this.git.commit(message, commitArgs)
|
|
const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
|
|
const toHash = result.commit || fromHash
|
|
this._checkpoints.push(toHash)
|
|
const duration = Date.now() - startTime
|
|
|
|
if (result.commit) {
|
|
this.emit("checkpoint", {
|
|
type: "checkpoint",
|
|
fromHash,
|
|
toHash,
|
|
duration,
|
|
suppressMessage: options?.suppressMessage ?? false,
|
|
})
|
|
}
|
|
|
|
if (result.commit) {
|
|
this.log(
|
|
`[${this.constructor.name}#saveCheckpoint] checkpoint saved in ${duration}ms -> ${result.commit}`,
|
|
)
|
|
return result
|
|
} else {
|
|
this.log(`[${this.constructor.name}#saveCheckpoint] found no changes to commit in ${duration}ms`)
|
|
return undefined
|
|
}
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e : new Error(String(e))
|
|
this.log(`[${this.constructor.name}#saveCheckpoint] failed to create checkpoint: ${error.message}`)
|
|
this.emit("error", { type: "error", error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async restoreCheckpoint(commitHash: string) {
|
|
try {
|
|
this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`)
|
|
|
|
if (!this.git) {
|
|
throw new Error("Shadow git repo not initialized")
|
|
}
|
|
|
|
const start = Date.now()
|
|
await this.git.clean("f", ["-d", "-f"])
|
|
await this.git.reset(["--hard", commitHash])
|
|
|
|
// Remove all checkpoints after the specified commitHash.
|
|
const checkpointIndex = this._checkpoints.indexOf(commitHash)
|
|
|
|
if (checkpointIndex !== -1) {
|
|
this._checkpoints = this._checkpoints.slice(0, checkpointIndex + 1)
|
|
}
|
|
|
|
const duration = Date.now() - start
|
|
this.emit("restore", { type: "restore", commitHash, duration })
|
|
this.log(`[${this.constructor.name}#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e : new Error(String(e))
|
|
this.log(`[${this.constructor.name}#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
|
|
this.emit("error", { type: "error", error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
|
|
if (!this.git) {
|
|
throw new Error("Shadow git repo not initialized")
|
|
}
|
|
|
|
const result = []
|
|
|
|
if (!from) {
|
|
from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim()
|
|
}
|
|
|
|
// Stage all changes so that untracked files appear in diff summary.
|
|
await this.stageAll(this.git)
|
|
|
|
this.log(`[${this.constructor.name}#getDiff] diffing ${to ? `${from}..${to}` : `${from}..HEAD`}`)
|
|
const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from])
|
|
|
|
const cwdPath = (await this.getShadowGitConfigWorktree(this.git)) || this.workspaceDir || ""
|
|
|
|
for (const file of files) {
|
|
const relPath = file.file
|
|
const absPath = path.join(cwdPath, relPath)
|
|
const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
|
|
|
|
const after = to
|
|
? await this.git.show([`${to}:${relPath}`]).catch(() => "")
|
|
: await fs.readFile(absPath, "utf8").catch(() => "")
|
|
|
|
result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* EventEmitter
|
|
*/
|
|
|
|
override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]) {
|
|
return super.emit(event, data)
|
|
}
|
|
|
|
override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
|
|
return super.on(event, listener)
|
|
}
|
|
|
|
override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
|
|
return super.off(event, listener)
|
|
}
|
|
|
|
override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
|
|
return super.once(event, listener)
|
|
}
|
|
|
|
/**
|
|
* Storage
|
|
*/
|
|
|
|
public static hashWorkspaceDir(workspaceDir: string) {
|
|
return crypto.createHash("sha256").update(workspaceDir).digest("hex").toString().slice(0, 8)
|
|
}
|
|
|
|
protected static taskRepoDir({ taskId, globalStorageDir }: { taskId: string; globalStorageDir: string }) {
|
|
return path.join(globalStorageDir, "tasks", taskId, "checkpoints")
|
|
}
|
|
|
|
protected static workspaceRepoDir({
|
|
globalStorageDir,
|
|
workspaceDir,
|
|
}: {
|
|
globalStorageDir: string
|
|
workspaceDir: string
|
|
}) {
|
|
return path.join(globalStorageDir, "checkpoints", this.hashWorkspaceDir(workspaceDir))
|
|
}
|
|
|
|
public static async deleteTask({
|
|
taskId,
|
|
globalStorageDir,
|
|
workspaceDir,
|
|
}: {
|
|
taskId: string
|
|
globalStorageDir: string
|
|
workspaceDir: string
|
|
}) {
|
|
const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
|
|
const branchName = `roo-${taskId}`
|
|
const git = createSanitizedGit(workspaceRepoDir)
|
|
const success = await this.deleteBranch(git, branchName)
|
|
|
|
if (success) {
|
|
console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`)
|
|
} else {
|
|
console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`)
|
|
}
|
|
}
|
|
|
|
public static async deleteBranch(git: SimpleGit, branchName: string) {
|
|
const branches = await git.branchLocal()
|
|
|
|
if (!branches.all.includes(branchName)) {
|
|
console.error(`[${this.constructor.name}#deleteBranch] branch ${branchName} does not exist`)
|
|
return false
|
|
}
|
|
|
|
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
|
|
|
if (currentBranch === branchName) {
|
|
const worktree = await git.getConfig("core.worktree")
|
|
|
|
try {
|
|
await git.raw(["config", "--unset", "core.worktree"])
|
|
await git.reset(["--hard"])
|
|
await git.clean("f", ["-d"])
|
|
const defaultBranch = branches.all.includes("main") ? "main" : "master"
|
|
await git.checkout([defaultBranch, "--force"])
|
|
|
|
await pWaitFor(
|
|
async () => {
|
|
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
|
return newBranch === defaultBranch
|
|
},
|
|
{ interval: 500, timeout: 2_000 },
|
|
)
|
|
|
|
await git.branch(["-D", branchName])
|
|
return true
|
|
} catch (error) {
|
|
console.error(
|
|
`[${this.constructor.name}#deleteBranch] failed to delete branch ${branchName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
|
|
return false
|
|
} finally {
|
|
if (worktree.value) {
|
|
await git.addConfig("core.worktree", worktree.value)
|
|
}
|
|
}
|
|
} else {
|
|
await git.branch(["-D", branchName])
|
|
return true
|
|
}
|
|
}
|
|
}
|