Compare commits

...

1 Commits

Author SHA1 Message Date
Saoud Rizwan 597457fdc6 Replace branching strategy with one repo per workspace 2025-03-07 18:38:18 -08:00
4 changed files with 49 additions and 284 deletions
+34 -10
View File
@@ -440,21 +440,30 @@ export class Cline {
try {
if (seeNewChangesSinceLastTaskCompletion) {
// Get last task completed
const lastTaskCompletedMessage = findLast(
const lastTaskCompletedMessageCheckpointHash = findLast(
this.clineMessages.slice(0, messageIndex),
(m) => m.say === "completion_result",
) // ask is only used to relinquish control, its the last say we care about
)?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
// if undefined, then we get diff from beginning of git
// if (!lastTaskCompletedMessage) {
// console.error("No previous task completion message found")
// return
// }
// This value *should* always exist
const firstCheckpointMessageCheckpointHash = this.clineMessages.find(
(m) => m.say === "checkpoint_created",
)?.lastCheckpointHash
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
if (!previousCheckpointHash) {
vscode.window.showErrorMessage("Unexpected error: No checkpoint hash found")
relinquishButton()
return
}
// Get changed files between current state and commit
changedFiles = await this.checkpointTracker?.getDiffSet(
lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started
hash,
)
changedFiles = await this.checkpointTracker?.getDiffSet(previousCheckpointHash, hash)
if (!changedFiles?.length) {
vscode.window.showInformationMessage("No changes found")
relinquishButton()
@@ -535,11 +544,26 @@ export class Cline {
const lastTaskCompletedMessage = findLast(this.clineMessages.slice(0, messageIndex), (m) => m.say === "completion_result")
try {
// Get last task completed
const lastTaskCompletedMessageCheckpointHash = lastTaskCompletedMessage?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
// if undefined, then we get diff from beginning of git
// if (!lastTaskCompletedMessage) {
// console.error("No previous task completion message found")
// return
// }
// This value *should* always exist
const firstCheckpointMessageCheckpointHash = this.clineMessages.find(
(m) => m.say === "checkpoint_created",
)?.lastCheckpointHash
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
if (!previousCheckpointHash) {
return false
}
// Get changed files between current state and commit
const changedFiles = await this.checkpointTracker?.getDiffSet(
lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started
hash,
)
const changedFiles = await this.checkpointTracker?.getDiffSet(previousCheckpointHash, hash)
const changedFilesCount = changedFiles?.length || 0
if (changedFilesCount > 0) {
return true
+7 -7
View File
@@ -1722,13 +1722,13 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const historyItem = taskHistory.find((item) => item.id === id)
//console.log("historyItem: ", historyItem)
if (historyItem) {
try {
await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath)
} catch (error) {
console.error(`Failed to delete checkpoints for task ${id}:`, error)
}
}
// if (historyItem) {
// try {
// await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath)
// } catch (error) {
// console.error(`Failed to delete checkpoints for task ${id}:`, error)
// }
// }
await this.deleteTaskFromState(id)
@@ -2,17 +2,8 @@ import fs from "fs/promises"
import { globby } from "globby"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import { HistoryItem } from "../../shared/HistoryItem"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { fileExistsAtPath } from "../../utils/fs"
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
import { getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
interface StorageProvider {
context: {
globalStorageUri: { fsPath: string }
}
}
interface CheckpointAddResult {
success: boolean
@@ -74,6 +65,10 @@ export class GitOperations {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
}
console.warn(`Using existing shadow git at ${gitPath}`)
// shadow git repo already exists, but update the excludes just in case
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
return gitPath
}
@@ -125,174 +120,6 @@ export class GitOperations {
}
}
/**
* Checks if a shadow Git repository exists for the current workspace.
* (checkpoints/{workspaceHash}/.git).
*
* @param provider - The ClineProvider instance for accessing VS Code functionality
* @returns Promise<boolean> True if a branch-per-task shadow git exists, false otherwise
*/
// public static async doesShadowGitExist(provider?: StorageProvider): Promise<boolean> {
// const globalStoragePath = provider?.context.globalStorageUri.fsPath
// if (!globalStoragePath) {
// return false
// }
// // Check branch-per-task path for newer tasks
// const workingDir = await getWorkingDirectory()
// const cwdHash = hashWorkingDir(workingDir)
// const gitPath = path.join(globalStoragePath, "checkpoints", cwdHash, ".git")
// const exists = await fileExistsAtPath(gitPath)
// if (exists) {
// console.info("Found existing shadow git")
// }
// return exists
// }
/**
* Deletes a branch in the git repository, handling cases where the branch is currently checked out.
* If the branch to be deleted is currently checked out, the method will:
* 1. Save the current worktree configuration
* 2. Temporarily unset the worktree to prevent workspace modifications
* 3. Force switch to master/main branch
* 4. Delete the target branch
* 5. Restore the worktree configuration
*
* @param git - SimpleGit instance to use for operations
* @param branchName - Name of the branch to delete
* @param checkpointsDir - Directory containing the git repository
* @throws Error if:
* - Branch deletion fails
* - Unable to switch to master/main branch after 3 retries
* - Git operations fail during the process
*/
public static async deleteBranchForGit(git: SimpleGit, branchName: string): Promise<void> {
// Check if branch exists
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.error(`Task branch ${branchName} does not exist, nothing to delete`)
return // Branch doesn't exist, nothing to delete
}
// First, if we're on the branch to be deleted, switch to master/main
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Current branch: ${currentBranch}, target branch to delete: ${branchName}`)
if (currentBranch === branchName) {
console.debug("Currently on branch to be deleted, switching to master/main first")
// Save the current worktree config
const worktree = await git.getConfig("core.worktree")
console.debug(`Saved current worktree config: ${worktree.value}`)
try {
await git.raw(["config", "--unset", "core.worktree"])
// Force discard all changes before we delete the branch
await git.reset(["--hard"])
await git.clean("f", ["-d"]) // Clean mode 'f' for force, -d for directories
// Determine default branch (master or main)
const defaultBranch = branches.all.includes("main") ? "main" : "master"
console.debug(`Using ${defaultBranch} as default branch`)
// Switch to default branch and delete target branch
console.debug(`Attempting to force switch to ${defaultBranch} branch`)
await git.checkout([defaultBranch, "--force"])
// Verify the switch completed, sometimes this takes a second
let retries = 3
while (retries > 0) {
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
if (newBranch === defaultBranch) {
console.debug(`Successfully switched to ${defaultBranch} branch`)
break
}
retries--
if (retries === 0) {
throw new Error(`Failed to switch to ${defaultBranch} branch`)
}
}
console.info(`Deleting branch: ${branchName}`)
await git.raw(["branch", "-D", branchName])
console.debug(`Successfully deleted branch: ${branchName}`)
} finally {
// Restore the worktree config
if (worktree.value) {
await git.addConfig("core.worktree", worktree.value)
}
}
} else {
// If we're not on the branch, we can safely delete it
console.info(`Directly deleting branch ${branchName}`)
await git.raw(["branch", "-D", branchName])
console.debug(`Successfully deleted branch: ${branchName}`)
}
}
/**
* Static method to delete a task's branch using stored workspace path.
* 1. First attempts to delete branch-per-task checkpoint if it exists
*
* @param taskId - The ID of the task whose branch should be deleted
* @param historyItem - The history item containing the shadow git config
* @param globalStoragePath - Path to VS Code's global storage
* @throws Error if:
* - Global storage path is invalid
* - Branch deletion fails
*/
public static async deleteTaskBranchStatic(
taskId: string,
historyItem: HistoryItem,
globalStoragePath: string,
): Promise<void> {
try {
console.debug("Starting static task branch deletion process...")
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
// Handle both active and inactive tasks
let workingDir: string
if (historyItem.shadowGitConfigWorkTree) {
workingDir = historyItem.shadowGitConfigWorkTree
} else {
workingDir = await getWorkingDirectory()
}
const gitPath = path.join(globalStoragePath, "checkpoints", hashWorkingDir(workingDir), ".git")
if (await fileExistsAtPath(gitPath)) {
console.debug(`Found branch-per-task git repository at ${gitPath}`)
const git = simpleGit(path.dirname(gitPath))
const branchName = `task-${taskId}`
// Check if the branch exists
const branches = await git.branchLocal()
if (branches.all.includes(branchName)) {
console.info(`Found branch ${branchName} to delete`)
await GitOperations.deleteBranchForGit(git, branchName)
// Determine if the task is active based on whether we had to use the stored worktree path
const isTaskActive = !historyItem.shadowGitConfigWorkTree
telemetryService.captureCheckpointUsage(
taskId,
isTaskActive ? "branch_deleted_active" : "branch_deleted_inactive",
)
return
}
console.warn(`Branch ${branchName} not found in branch-per-task repository`)
}
console.info("No checkpoints found to delete")
} catch (error) {
console.error("Failed to delete task branch:", error)
throw new Error(`Failed to delete task branch: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
* requirement of using submodules for nested repos.
@@ -333,48 +160,6 @@ export class GitOperations {
}
}
/**
* Switches to or creates a task-specific branch in the shadow Git repository.
* For branch-per-task checkpoints, this ensures we're on the correct task branch before operations.
*
* The method performs the following:
* 1. Gets the shadow git path and initializes simple-git
* 2. Constructs the branch name using the task ID
* 3. Checks if the branch exists:
* - If not, creates a new branch
* - If yes, switches to the existing branch
* 4. Verifies the branch switch completed successfully
*
* Branch naming convention:
* task-{taskId}
*
* @param taskId - The ID of the task whose branch to switch to
* @param gitPath - Path to the .git directory
* @returns Promise<void>
* @throws Error if branch operations fail or git commands error
*/
public async switchToTaskBranch(taskId: string, gitPath: string): Promise<void> {
const git = simpleGit(path.dirname(gitPath))
const branchName = `task-${taskId}`
// Update excludes when creating a new branch for a new task
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
// Create new task-specific branch, or switch to one if it already exists.
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.info(`Creating new task branch: ${branchName}`)
await git.checkoutLocalBranch(branchName)
telemetryService.captureCheckpointUsage(taskId, "branch_created")
} else {
console.info(`Switching to existing task branch: ${branchName}`)
await git.checkout(branchName)
}
// const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Current Checkpoint branch after switch: ${branchName}`)
}
/**
* Adds files to the shadow git repository while handling nested git repos.
* Uses git commands to list files and stages them for commit.
@@ -2,10 +2,9 @@ import fs from "fs/promises"
import * as path from "path"
import simpleGit from "simple-git"
import * as vscode from "vscode"
import { HistoryItem } from "../../shared/HistoryItem"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { GitOperations } from "./CheckpointGitOperations"
import { getShadowGitPath, hashWorkingDir, getWorkingDirectory } from "./CheckpointUtils"
import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
/**
* CheckpointTracker Module
@@ -117,8 +116,6 @@ class CheckpointTracker {
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized")
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
return newTracker
} catch (error) {
console.error("Failed to create CheckpointTracker:", error)
@@ -244,7 +241,6 @@ class CheckpointTracker {
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
console.debug(`Using shadow git at: ${gitPath}`)
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
await git.reset(["--hard", commitHash]) // Hard reset to target commit
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
telemetryService.captureCheckpointUsage(this.taskId, "restored")
@@ -264,7 +260,7 @@ class CheckpointTracker {
* @returns Array of file changes with before/after content
*/
public async getDiffSet(
lhsHash?: string,
lhsHash: string,
rhsHash?: string,
): Promise<
Array<{
@@ -279,35 +275,10 @@ class CheckpointTracker {
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
// If lhsHash is missing, iteratively check up to 5 commits to find the first one with tracked files
let baseHash = lhsHash
if (!baseHash) {
// Ensure we're on the correct task branch before getting commits
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
// Verify which branch we're on after switching
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Getting commits from branch: ${currentBranch}`)
try {
// Get all commits that match the checkpoint pattern for this specific task
const commitPattern = `checkpoint-${this.cwdHash}-${this.taskId}`
const branchCommits = await git.log(["--grep", commitPattern, "--reverse"])
if (!branchCommits.all.length) {
throw new Error("No commits found in the branch.")
}
// Get the first commit that matches our task's checkpoint pattern
baseHash = branchCommits.all[0].hash
} catch (error) {
console.error("Failed to get branch commits:", error)
throw new Error("Failed to determine branch history")
}
}
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git)
const diffRange = rhsHash ? `${baseHash}..${rhsHash}` : baseHash
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
console.info(`Diff range: ${diffRange}`)
const diffSummary = await git.diffSummary([diffRange])
@@ -318,7 +289,7 @@ class CheckpointTracker {
let beforeContent = ""
try {
beforeContent = await git.show([`${baseHash}:${filePath}`])
beforeContent = await git.show([`${lhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in older commit => remains empty
}
@@ -348,21 +319,6 @@ class CheckpointTracker {
return result
}
/**
* Deletes all checkpoint data for a given task.
*
* @param taskId - The ID of the task whose checkpoints should be deleted
* @param historyItem - The history item containing the shadow git config for this task
* @param globalStoragePath - the globalStorage path
* @throws Error if deletion fails
*/
public static async deleteCheckpoints(taskId: string, historyItem: HistoryItem, globalStoragePath: string): Promise<void> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
await GitOperations.deleteTaskBranchStatic(taskId, historyItem, globalStoragePath)
}
}
export default CheckpointTracker