mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Check git out - Checkpoints 2.0
**Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task). - **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task. - **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task. - **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion. No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.
This commit is contained in:
parent
e5d616b0bf
commit
30a4a0e1f1
15 changed files with 2013 additions and 315 deletions
11
.changeset/silly-coats-switch.md
Normal file
11
.changeset/silly-coats-switch.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
- **Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task).
|
||||
- **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task.
|
||||
|
||||
- **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task.
|
||||
- **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion.
|
||||
|
||||
No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.
|
||||
|
|
@ -301,7 +301,10 @@ export class Cline {
|
|||
case "workspace":
|
||||
if (!this.checkpointTracker) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
|
|
@ -414,7 +417,10 @@ export class Cline {
|
|||
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
|
||||
if (!this.checkpointTracker) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
|
|
@ -518,7 +524,10 @@ export class Cline {
|
|||
|
||||
if (!this.checkpointTracker) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
|
|
@ -2940,7 +2949,7 @@ export class Cline {
|
|||
}
|
||||
|
||||
/*
|
||||
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
|
||||
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
|
||||
When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
|
||||
*/
|
||||
this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
|
||||
|
|
@ -3047,7 +3056,10 @@ export class Cline {
|
|||
// isNewTask &&
|
||||
if (!this.checkpointTracker) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import pWaitFor from "p-wait-for"
|
|||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { buildApiHandler } from "../../api"
|
||||
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
|
||||
import { downloadTask } from "../../integrations/misc/export-markdown"
|
||||
import { openFile, openImage } from "../../integrations/misc/open-file"
|
||||
import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview"
|
||||
|
|
@ -1661,12 +1662,29 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
|||
}
|
||||
|
||||
async deleteTaskWithId(id: string) {
|
||||
console.info("deleteTaskWithId: ", id)
|
||||
|
||||
if (id === this.cline?.taskId) {
|
||||
await this.clearTask()
|
||||
console.debug("cleared task")
|
||||
}
|
||||
|
||||
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
|
||||
|
||||
// Delete checkpoints
|
||||
// deleteCheckpoints will determine if the task has legacy checkpoints or not and handle it accordingly
|
||||
console.info("deleting checkpoints")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
await this.deleteTaskFromState(id)
|
||||
|
||||
// Delete the task files
|
||||
|
|
@ -1683,21 +1701,12 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
|||
await fs.unlink(legacyMessagesFilePath)
|
||||
}
|
||||
|
||||
// Delete the checkpoints directory if it exists
|
||||
const checkpointsDir = path.join(taskDirPath, "checkpoints")
|
||||
if (await fileExistsAtPath(checkpointsDir)) {
|
||||
try {
|
||||
await fs.rm(checkpointsDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete checkpoints directory for task ${id}:`, error)
|
||||
// Continue with deletion of task directory - don't throw since this is a cleanup operation
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
|
||||
}
|
||||
|
||||
async deleteTaskFromState(id: string) {
|
||||
console.log("deleteTaskFromState: ", id)
|
||||
|
||||
// Remove the task from history
|
||||
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
|
||||
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
|
||||
|
|
@ -1768,7 +1777,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
|||
|
||||
/*
|
||||
It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content.
|
||||
VSCode docs about state: "The value must be JSON-stringifyable ... value — A value. MUST not contain cyclic references."
|
||||
VSCode docs about state: "The value must be JSON-stringifyable ... value A value. MUST not contain cyclic references."
|
||||
For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification.
|
||||
*/
|
||||
|
||||
|
|
|
|||
73
src/integrations/checkpoints/Checkpoint-test-utils.ts.bak
Normal file
73
src/integrations/checkpoints/Checkpoint-test-utils.ts.bak
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import CheckpointTracker from "./CheckpointTracker"
|
||||
|
||||
export async function createTestEnvironment() {
|
||||
// Create temp directory structure
|
||||
const tempDir = path.join(os.tmpdir(), `checkpoint-test-${Date.now()}`)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
// Create storage path outside of working directory to avoid submodule issues
|
||||
const globalStoragePath = path.join(os.tmpdir(), `storage-${Date.now()}`)
|
||||
await fs.mkdir(globalStoragePath, { recursive: true })
|
||||
|
||||
// Create test file in a subdirectory
|
||||
const testDir = path.join(tempDir, "src")
|
||||
await fs.mkdir(testDir, { recursive: true })
|
||||
const testFilePath = path.join(testDir, "test.txt")
|
||||
|
||||
// Create .gitignore to prevent git from treating directories as submodules
|
||||
await fs.writeFile(path.join(tempDir, ".gitignore"), "storage/\n")
|
||||
|
||||
// Mock VS Code workspace
|
||||
const mockWorkspaceFolders = [
|
||||
{
|
||||
uri: { fsPath: tempDir },
|
||||
name: "test",
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders")
|
||||
Object.defineProperty(vscode.workspace, "workspaceFolders", {
|
||||
get: () => mockWorkspaceFolders,
|
||||
})
|
||||
|
||||
// Mock findFiles to return no nested git repos
|
||||
const originalFindFiles = vscode.workspace.findFiles
|
||||
vscode.workspace.findFiles = async () => []
|
||||
|
||||
// Mock VS Code configuration
|
||||
const originalGetConfiguration = vscode.workspace.getConfiguration
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
|
||||
}) as any
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
globalStoragePath,
|
||||
testFilePath,
|
||||
originalDescriptor,
|
||||
originalFindFiles,
|
||||
originalGetConfiguration,
|
||||
cleanup: async () => {
|
||||
// Restore VS Code mocks
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(vscode.workspace, "workspaceFolders", originalDescriptor)
|
||||
}
|
||||
vscode.workspace.getConfiguration = originalGetConfiguration
|
||||
vscode.workspace.findFiles = originalFindFiles
|
||||
|
||||
// Clean up temp directories
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
await fs.rm(globalStoragePath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTestTracker(globalStoragePath?: string, taskId = "test-task-1") {
|
||||
return await CheckpointTracker.create(taskId, globalStoragePath)
|
||||
}
|
||||
153
src/integrations/checkpoints/CheckpointCommit.test.ts.bak
Normal file
153
src/integrations/checkpoints/CheckpointCommit.test.ts.bak
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
|
||||
describe("Checkpoint Commit Operations", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should create commit with single file changes", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create initial file
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
|
||||
// Create first commit
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Modify file
|
||||
await fs.writeFile(env.testFilePath, "modified content")
|
||||
|
||||
// Create second commit
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Verify commits are different
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("modified content")
|
||||
})
|
||||
|
||||
it("should create commit with multiple file changes", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create initial files with newlines
|
||||
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
|
||||
await fs.writeFile(env.testFilePath, "file1 initial\n")
|
||||
await fs.writeFile(testFile2Path, "file2 initial\n")
|
||||
|
||||
// Create first commit
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Modify both files with newlines
|
||||
await fs.writeFile(env.testFilePath, "file1 modified\n")
|
||||
await fs.writeFile(testFile2Path, "file2 modified\n")
|
||||
|
||||
// Create second commit
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Get diff between commits
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(2)
|
||||
|
||||
// Sort diffSet by path for consistent ordering
|
||||
const sortedDiffs = diffSet.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
||||
|
||||
// Verify file paths
|
||||
expect(sortedDiffs[0].relativePath).to.equal("src/test.txt")
|
||||
expect(sortedDiffs[1].relativePath).to.equal("src/test2.txt")
|
||||
|
||||
// Verify file contents
|
||||
expect(sortedDiffs[0].before).to.equal("file1 initial\nfile2 initial\n")
|
||||
expect(sortedDiffs[0].after).to.equal("file1 modified\nfile2 modified\n")
|
||||
})
|
||||
|
||||
it("should create commit when files are deleted", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial file
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Delete file
|
||||
await fs.unlink(env.testFilePath)
|
||||
|
||||
// Create second commit
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Verify file deletion was committed
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("")
|
||||
})
|
||||
|
||||
it("should create empty commit when no changes", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial file
|
||||
await fs.writeFile(env.testFilePath, "test content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Create commit without changes
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Verify no changes between commits
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should handle files in nested directories", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create nested directory structure
|
||||
const nestedDir = path.join(env.tempDir, "src", "deep", "nested")
|
||||
await fs.mkdir(nestedDir, { recursive: true })
|
||||
const nestedFilePath = path.join(nestedDir, "nested.txt")
|
||||
|
||||
// Create and commit file in nested directory
|
||||
await fs.writeFile(nestedFilePath, "nested content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Modify nested file
|
||||
await fs.writeFile(nestedFilePath, "modified nested content")
|
||||
|
||||
// Create second commit
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Verify changes were committed
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].relativePath).to.equal("src/deep/nested/nested.txt")
|
||||
expect(diffSet[0].before).to.equal("nested content")
|
||||
expect(diffSet[0].after).to.equal("modified nested content")
|
||||
})
|
||||
})
|
||||
35
src/integrations/checkpoints/CheckpointCreation.test.ts.bak
Normal file
35
src/integrations/checkpoints/CheckpointCreation.test.ts.bak
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
import CheckpointTracker from "./CheckpointTracker"
|
||||
|
||||
describe("Checkpoint Creation", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should create a new checkpoint tracker", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(tracker).to.not.be.undefined
|
||||
expect(tracker).to.be.instanceOf(CheckpointTracker)
|
||||
|
||||
// Verify shadow git config
|
||||
const configWorkTree = await tracker?.getShadowGitConfigWorkTree()
|
||||
expect(configWorkTree).to.not.be.undefined
|
||||
})
|
||||
|
||||
it("should throw error when globalStoragePath is missing", async () => {
|
||||
try {
|
||||
await createTestTracker(undefined)
|
||||
expect.fail("Expected error was not thrown")
|
||||
} catch (error: any) {
|
||||
expect(error.message).to.equal("Global storage path is required to create a checkpoint tracker")
|
||||
}
|
||||
})
|
||||
})
|
||||
68
src/integrations/checkpoints/CheckpointDiff.test.ts.bak
Normal file
68
src/integrations/checkpoints/CheckpointDiff.test.ts.bak
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
|
||||
describe("Checkpoint Diff Operations", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should detect file changes between commits", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create initial file
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
|
||||
// Create first checkpoint
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.not.be.undefined
|
||||
|
||||
// Modify file
|
||||
await fs.writeFile(env.testFilePath, "modified content")
|
||||
|
||||
// Create second checkpoint
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.not.be.undefined
|
||||
|
||||
// Get diff between commits
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
|
||||
// Verify diff results
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].relativePath).to.equal("src/test.txt")
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("modified content")
|
||||
})
|
||||
|
||||
it("should detect changes between commit and working directory", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create initial file
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
|
||||
// Create checkpoint
|
||||
const commit = await tracker.commit()
|
||||
expect(commit).to.not.be.undefined
|
||||
|
||||
// Modify file without committing
|
||||
await fs.writeFile(env.testFilePath, "working directory changes")
|
||||
|
||||
// Get diff between commit and working directory
|
||||
const diffSet = await tracker.getDiffSet(commit)
|
||||
|
||||
// Verify diff results
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].relativePath).to.equal("src/test.txt")
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("working directory changes")
|
||||
})
|
||||
})
|
||||
94
src/integrations/checkpoints/CheckpointDisabled.test.ts.bak
Normal file
94
src/integrations/checkpoints/CheckpointDisabled.test.ts.bak
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import * as vscode from "vscode"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
|
||||
describe("Checkpoint Disabled State", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
let originalGetConfiguration: typeof vscode.workspace.getConfiguration
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
originalGetConfiguration = vscode.workspace.getConfiguration
|
||||
|
||||
// Mock VS Code configuration to disable checkpoints
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
|
||||
}) as any
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
// Restore original configuration
|
||||
vscode.workspace.getConfiguration = originalGetConfiguration
|
||||
})
|
||||
|
||||
it("should return undefined when creating tracker", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(tracker).to.be.undefined
|
||||
})
|
||||
|
||||
it("should allow re-enabling checkpoints", async () => {
|
||||
// First verify disabled state
|
||||
const disabledTracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(disabledTracker).to.be.undefined
|
||||
|
||||
// Mock configuration to enable checkpoints
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
|
||||
}) as any
|
||||
|
||||
// Verify tracker can be created when enabled
|
||||
const enabledTracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(enabledTracker).to.not.be.undefined
|
||||
|
||||
// Verify operations work
|
||||
if (!enabledTracker) {throw new Error("Failed to create tracker")}
|
||||
await fs.writeFile(env.testFilePath, "test content")
|
||||
const commit = await enabledTracker.commit()
|
||||
expect(commit).to.be.a("string").and.not.empty
|
||||
})
|
||||
|
||||
it("should prevent operations when disabled mid-session", async () => {
|
||||
// Start with checkpoints enabled
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
|
||||
}) as any
|
||||
|
||||
// Create tracker and initial commit
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(tracker).to.not.be.undefined
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Disable checkpoints
|
||||
vscode.workspace.getConfiguration = () =>
|
||||
({
|
||||
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
|
||||
}) as any
|
||||
|
||||
// Verify new tracker cannot be created
|
||||
const disabledTracker = await createTestTracker(env.globalStoragePath)
|
||||
expect(disabledTracker).to.be.undefined
|
||||
|
||||
// Verify existing tracker still works
|
||||
// This is expected behavior since the tracker was created when enabled
|
||||
await fs.writeFile(env.testFilePath, "modified content")
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.be.a("string").and.not.empty
|
||||
expect(secondCommit).to.not.equal(firstCommit)
|
||||
|
||||
// Verify diffs still work on existing tracker
|
||||
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
|
||||
expect(diffSet).to.have.lengthOf(1)
|
||||
expect(diffSet[0].before).to.equal("initial content")
|
||||
expect(diffSet[0].after).to.equal("modified content")
|
||||
})
|
||||
})
|
||||
335
src/integrations/checkpoints/CheckpointExclusions.ts
Normal file
335
src/integrations/checkpoints/CheckpointExclusions.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import fs from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
|
||||
|
||||
/**
|
||||
* CheckpointExclusions Module
|
||||
*
|
||||
* A specialized module within Cline's Checkpoints system that manages file exclusion rules
|
||||
* for the checkpoint tracking process. It provides:
|
||||
*
|
||||
* File Filtering:
|
||||
* - File types (build artifacts, media, cache files, etc.)
|
||||
* - Git LFS patterns from workspace
|
||||
* - Environment and configuration files
|
||||
* - Temporary and cache files
|
||||
*
|
||||
* Pattern Management:
|
||||
* - Extensible category-based pattern system
|
||||
* - Comprehensive file type coverage
|
||||
* - Easy pattern updates and maintenance
|
||||
*
|
||||
* Git Integration:
|
||||
* - Seamless integration with Git's exclude mechanism
|
||||
* - Support for workspace-specific LFS patterns
|
||||
* - Automatic pattern updates during checkpoints
|
||||
*
|
||||
* The module ensures efficient checkpoint creation by preventing unnecessary tracking
|
||||
* of large files, binary files, and temporary artifacts while maintaining a clean
|
||||
* and organized checkpoint history.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface representing the result of a file exclusion check
|
||||
*/
|
||||
interface ExclusionResult {
|
||||
/** Whether the file should be excluded */
|
||||
excluded: boolean
|
||||
/** Optional reason for exclusion */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default list of file and directory patterns to exclude from checkpoints.
|
||||
* Combines built-in patterns with workspace-specific LFS patterns.
|
||||
*
|
||||
* @param lfsPatterns - Optional array of Git LFS patterns from workspace
|
||||
* @returns Array of glob patterns to exclude
|
||||
* @todo Make this configurable by the user
|
||||
*/
|
||||
export const getDefaultExclusions = (lfsPatterns: string[] = []): string[] => [
|
||||
// Build and Development Artifacts
|
||||
".git/",
|
||||
`.git${GIT_DISABLED_SUFFIX}/`,
|
||||
...getBuildArtifactPatterns(),
|
||||
|
||||
// Media Files
|
||||
...getMediaFilePatterns(),
|
||||
|
||||
// Cache and Temporary Files
|
||||
...getCacheFilePatterns(),
|
||||
|
||||
// Environment and Config Files
|
||||
...getConfigFilePatterns(),
|
||||
|
||||
// Large Data Files
|
||||
...getLargeDataFilePatterns(),
|
||||
|
||||
// Database Files
|
||||
...getDatabaseFilePatterns(),
|
||||
|
||||
// Geospatial Datasets
|
||||
...getGeospatialPatterns(),
|
||||
|
||||
// Log Files
|
||||
...getLogFilePatterns(),
|
||||
|
||||
...lfsPatterns,
|
||||
]
|
||||
|
||||
/**
|
||||
* Returns patterns for common build and development artifact directories
|
||||
* @returns Array of glob patterns for build artifacts
|
||||
*/
|
||||
function getBuildArtifactPatterns(): string[] {
|
||||
return [
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".parcel-cache/",
|
||||
".pytest_cache/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
".sass-cache/",
|
||||
".vs/",
|
||||
".vscode/",
|
||||
"Pods/",
|
||||
"__pycache__/",
|
||||
"bin/",
|
||||
"build/",
|
||||
"bundle/",
|
||||
"coverage/",
|
||||
"deps/",
|
||||
"dist/",
|
||||
"env/",
|
||||
"node_modules/",
|
||||
"obj/",
|
||||
"out/",
|
||||
"pkg/",
|
||||
"pycache/",
|
||||
"target/dependency/",
|
||||
"temp/",
|
||||
"vendor/",
|
||||
"venv/",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for common media and image file types
|
||||
* @returns Array of glob patterns for media files
|
||||
*/
|
||||
function getMediaFilePatterns(): string[] {
|
||||
return [
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
"*.webp",
|
||||
"*.tiff",
|
||||
"*.tif",
|
||||
"*.svg",
|
||||
"*.raw",
|
||||
"*.heic",
|
||||
"*.avif",
|
||||
"*.eps",
|
||||
"*.psd",
|
||||
"*.3gp",
|
||||
"*.aac",
|
||||
"*.aiff",
|
||||
"*.asf",
|
||||
"*.avi",
|
||||
"*.divx",
|
||||
"*.flac",
|
||||
"*.m4a",
|
||||
"*.m4v",
|
||||
"*.mkv",
|
||||
"*.mov",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.mpeg",
|
||||
"*.mpg",
|
||||
"*.ogg",
|
||||
"*.opus",
|
||||
"*.rm",
|
||||
"*.rmvb",
|
||||
"*.vob",
|
||||
"*.wav",
|
||||
"*.webm",
|
||||
"*.wma",
|
||||
"*.wmv",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for cache, temporary, and system files
|
||||
* @returns Array of glob patterns for cache files
|
||||
*/
|
||||
function getCacheFilePatterns(): string[] {
|
||||
return [
|
||||
"*.DS_Store",
|
||||
"*.bak",
|
||||
"*.cache",
|
||||
"*.crdownload",
|
||||
"*.dmp",
|
||||
"*.dump",
|
||||
"*.eslintcache",
|
||||
"*.lock",
|
||||
"*.log",
|
||||
"*.old",
|
||||
"*.part",
|
||||
"*.partial",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.stackdump",
|
||||
"*.swo",
|
||||
"*.swp",
|
||||
"*.temp",
|
||||
"*.tmp",
|
||||
"*.Thumbs.db",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for environment and configuration files
|
||||
* @returns Array of glob patterns for config files
|
||||
*/
|
||||
function getConfigFilePatterns(): string[] {
|
||||
return ["*.env*", "*.local", "*.development", "*.production"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for common large binary and archive files
|
||||
* @returns Array of glob patterns for large data files
|
||||
*/
|
||||
function getLargeDataFilePatterns(): string[] {
|
||||
return [
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
"*.dat",
|
||||
"*.dmg",
|
||||
"*.msi",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for database and data storage files
|
||||
* @returns Array of glob patterns for database files
|
||||
*/
|
||||
function getDatabaseFilePatterns(): string[] {
|
||||
return [
|
||||
"*.arrow",
|
||||
"*.accdb",
|
||||
"*.aof",
|
||||
"*.avro",
|
||||
"*.bak",
|
||||
"*.bson",
|
||||
"*.csv",
|
||||
"*.db",
|
||||
"*.dbf",
|
||||
"*.dmp",
|
||||
"*.frm",
|
||||
"*.ibd",
|
||||
"*.mdb",
|
||||
"*.myd",
|
||||
"*.myi",
|
||||
"*.orc",
|
||||
"*.parquet",
|
||||
"*.pdb",
|
||||
"*.rdb",
|
||||
"*.sql",
|
||||
"*.sqlite",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for geospatial and mapping data files
|
||||
* @returns Array of glob patterns for geospatial files
|
||||
*/
|
||||
function getGeospatialPatterns(): string[] {
|
||||
return [
|
||||
"*.shp",
|
||||
"*.shx",
|
||||
"*.dbf",
|
||||
"*.prj",
|
||||
"*.sbn",
|
||||
"*.sbx",
|
||||
"*.shp.xml",
|
||||
"*.cpg",
|
||||
"*.gdb",
|
||||
"*.mdb",
|
||||
"*.gpkg",
|
||||
"*.kml",
|
||||
"*.kmz",
|
||||
"*.gml",
|
||||
"*.geojson",
|
||||
"*.dem",
|
||||
"*.asc",
|
||||
"*.img",
|
||||
"*.ecw",
|
||||
"*.las",
|
||||
"*.laz",
|
||||
"*.mxd",
|
||||
"*.qgs",
|
||||
"*.grd",
|
||||
"*.csv",
|
||||
"*.dwg",
|
||||
"*.dxf",
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns patterns for log and debug output files
|
||||
* @returns Array of glob patterns for log files
|
||||
*/
|
||||
function getLogFilePatterns(): string[] {
|
||||
return ["*.error", "*.log", "*.logs", "*.npm-debug.log*", "*.out", "*.stdout", "yarn-debug.log*", "yarn-error.log*"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the combined exclusion patterns to Git's exclude file.
|
||||
* Creates the info directory if it doesn't exist.
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @param lfsPatterns - Optional array of Git LFS patterns to include
|
||||
*/
|
||||
export const writeExcludesFile = async (gitPath: string, lfsPatterns: string[] = []): Promise<void> => {
|
||||
const excludesPath = join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(join(gitPath, "info"), { recursive: true })
|
||||
|
||||
const patterns = getDefaultExclusions(lfsPatterns)
|
||||
await fs.writeFile(excludesPath, patterns.join("\n"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Git LFS patterns from the workspace's .gitattributes file.
|
||||
* Returns an empty array if no patterns found or file doesn't exist.
|
||||
*
|
||||
* @param workspacePath - Path to the workspace root
|
||||
* @returns Array of Git LFS patterns found in .gitattributes
|
||||
*/
|
||||
export const getLfsPatterns = async (workspacePath: string): Promise<string[]> => {
|
||||
try {
|
||||
const attributesPath = join(workspacePath, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
return attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
482
src/integrations/checkpoints/CheckpointGitOperations.ts
Normal file
482
src/integrations/checkpoints/CheckpointGitOperations.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import * as vscode from "vscode"
|
||||
import { getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
|
||||
interface StorageProvider {
|
||||
context: {
|
||||
globalStorageUri: { fsPath: string }
|
||||
}
|
||||
}
|
||||
|
||||
interface CheckpointAddResult {
|
||||
success: boolean
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GitOperations Class
|
||||
*
|
||||
* Handles git-specific operations for Cline's Checkpoints system.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Git repository initialization and configuration
|
||||
* - Git settings management (user, LFS, etc.)
|
||||
* - Worktree configuration and management
|
||||
* - Task-specific branch management (creation, switching, deletion)
|
||||
* - Handling of both legacy and branch-per-task checkpoint structures
|
||||
* - Managing nested git repositories during checkpoint operations
|
||||
* - File staging and checkpoint creation
|
||||
* - Shadow git repository maintenance and cleanup
|
||||
*/
|
||||
export class GitOperations {
|
||||
private cwd: string
|
||||
private isLegacyCheckpoint: boolean
|
||||
|
||||
/**
|
||||
* Creates a new GitOperations instance.
|
||||
*
|
||||
* @param cwd - The current working directory for git operations
|
||||
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
|
||||
*/
|
||||
constructor(cwd: string, isLegacyCheckpoint: boolean) {
|
||||
this.cwd = cwd
|
||||
this.isLegacyCheckpoint = isLegacyCheckpoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes or verifies a shadow Git repository for checkpoint tracking.
|
||||
* Creates a new repository if one doesn't exist, or verifies the worktree
|
||||
* configuration if it does.
|
||||
*
|
||||
* Key operations:
|
||||
* - Creates/verifies shadow git repository
|
||||
* - Configures git settings (user, LFS, etc.)
|
||||
* - Sets up worktree to point to workspace
|
||||
* - Creates initial empty commit
|
||||
* - Handles both legacy and branch-per-task checkpoint structures
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @param cwd - The current working directory for git operations
|
||||
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
|
||||
* @returns Promise<string> Path to the initialized .git directory
|
||||
* @throws Error if:
|
||||
* - Worktree verification fails for existing repository
|
||||
* - Git initialization or configuration fails
|
||||
* - Unable to create initial commit
|
||||
* - LFS pattern setup fails
|
||||
*/
|
||||
public static async initShadowGit(gitPath: string, cwd: string, isLegacyCheckpoint: boolean): Promise<string> {
|
||||
console.info(`Initializing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git`)
|
||||
|
||||
// If repo exists, just verify worktree
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
if (worktree.value !== cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
|
||||
}
|
||||
console.warn(`Using existing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git at ${gitPath}`)
|
||||
return gitPath
|
||||
}
|
||||
|
||||
// Initialize new repo
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
console.warn(`Creating new ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git in ${checkpointsDir}`)
|
||||
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
// Configure repo
|
||||
await git.addConfig("core.worktree", cwd)
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "checkpoint@cline.bot")
|
||||
await git.addConfig("core.quotePath", "false")
|
||||
await git.addConfig("core.precomposeunicode", "true")
|
||||
|
||||
// Set up LFS patterns
|
||||
const lfsPatterns = await getLfsPatterns(cwd)
|
||||
await writeExcludesFile(gitPath, lfsPatterns)
|
||||
|
||||
// Initial commit only on first repo creation
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
console.warn(`${isLegacyCheckpoint ? "Legacy" : "New"} shadow git initialization completed`)
|
||||
|
||||
return gitPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the worktree path from the shadow git configuration.
|
||||
* The worktree path indicates where the shadow git repository is tracking files,
|
||||
* which should match the current workspace directory.
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @returns Promise<string | undefined> The worktree path or undefined if not found
|
||||
* @throws Error if unable to get worktree path
|
||||
*/
|
||||
public async getShadowGitConfigWorkTree(gitPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
return worktree.value || undefined
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a shadow Git repository exists for the given task and workspace.
|
||||
* Checks both legacy checkpoint paths (tasks/{taskId}/checkpoints/.git) and
|
||||
* branch-per-task paths (checkpoints/{workspaceHash}/.git).
|
||||
*
|
||||
* @param taskId - The ID of the task whose shadow git to check
|
||||
* @param provider - The ClineProvider instance for accessing VS Code functionality
|
||||
* @returns Promise<boolean> True if either a legacy or branch-per-task shadow git exists, false otherwise
|
||||
*/
|
||||
public static async doesShadowGitExist(taskId: string, provider?: StorageProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check legacy checkpoint path to see if this is a legacy task
|
||||
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
if (await fileExistsAtPath(legacyGitPath)) {
|
||||
console.info("Found legacy shadow git")
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 branch-per-task 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, checkpointsDir: 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 {
|
||||
// Temporarily unset worktree to prevent workspace modifications
|
||||
console.debug("Temporarily unsetting worktree config")
|
||||
await git.raw(["config", "--unset", "core.worktree"])
|
||||
|
||||
// Force discard all changes
|
||||
console.debug("Discarding all changes")
|
||||
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 branch
|
||||
console.debug(`Attempting to force switch to ${defaultBranch} branch`)
|
||||
await git.checkout([defaultBranch, "--force"])
|
||||
|
||||
// Verify the switch completed
|
||||
let retries = 3
|
||||
while (retries > 0) {
|
||||
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
console.debug(`Verifying branch switch - current branch: ${newBranch}, attempts left: ${retries}`)
|
||||
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) {
|
||||
console.debug(`Restoring worktree config to: ${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} since we're not on it`)
|
||||
await git.raw(["branch", "-D", branchName])
|
||||
console.debug(`Successfully deleted branch: ${branchName}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to delete a task's branch using stored workspace path.
|
||||
* Handles both branch-per-task and legacy checkpoint formats:
|
||||
* 1. First attempts to delete branch-per-task checkpoint if it exists
|
||||
* 2. Falls back to deleting legacy checkpoint directory if found
|
||||
*
|
||||
* @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
|
||||
* - Legacy checkpoint directory 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")
|
||||
}
|
||||
|
||||
// First try to handle branch-per-task checkpoint
|
||||
let workingDir: string
|
||||
if (historyItem.shadowGitConfigWorkTree) {
|
||||
workingDir = historyItem.shadowGitConfigWorkTree
|
||||
} else {
|
||||
// Try to determine working directory from current state
|
||||
workingDir = await getWorkingDirectory()
|
||||
}
|
||||
|
||||
const cwdHash = hashWorkingDir(workingDir)
|
||||
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
|
||||
const gitPath = path.join(checkpointsDir, ".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, checkpointsDir)
|
||||
return
|
||||
}
|
||||
console.warn(`Branch ${branchName} not found in branch-per-task repository`)
|
||||
}
|
||||
|
||||
// Only check legacy checkpoint if we didn't find/delete a branch-per-task branch
|
||||
const legacyCheckpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
|
||||
const legacyGitPath = path.join(legacyCheckpointsDir, ".git")
|
||||
|
||||
if (await fileExistsAtPath(legacyGitPath)) {
|
||||
console.info("Found legacy checkpoint, deleting directory")
|
||||
try {
|
||||
await fs.rm(legacyCheckpointsDir, { recursive: true, force: true })
|
||||
console.debug("Successfully deleted legacy checkpoint directory")
|
||||
return
|
||||
} catch (error) {
|
||||
console.error("Failed to delete legacy checkpoint directory:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* This method renames nested .git directories by adding/removing a suffix to temporarily disable/enable them.
|
||||
* The root .git directory is preserved. Uses VS Code's workspace API to find nested .git directories and
|
||||
* only processes actual directories (not files named .git).
|
||||
*
|
||||
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
|
||||
* @throws Error if renaming any .git directory fails
|
||||
*/
|
||||
public async renameNestedGitRepos(disable: boolean): Promise<void> {
|
||||
// Find all .git directories that are not at the root level using VS Code API
|
||||
const gitFiles = await vscode.workspace.findFiles(
|
||||
new vscode.RelativePattern(this.cwd, "**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX)),
|
||||
new vscode.RelativePattern(this.cwd, ".git/**"), // Exclude root .git
|
||||
)
|
||||
|
||||
// Filter to only include directories
|
||||
const gitPaths: string[] = []
|
||||
for (const file of gitFiles) {
|
||||
const relativePath = path.relative(this.cwd, file.fsPath)
|
||||
try {
|
||||
const stats = await fs.stat(path.join(this.cwd, relativePath))
|
||||
if (stats.isDirectory()) {
|
||||
gitPaths.push(relativePath)
|
||||
}
|
||||
} catch {
|
||||
// Skip if stat fails
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// For each nested .git directory, rename it based on the disable flag
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.info(`${disable ? "Disabled" : "Enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to or creates a task-specific branch in the shadow Git repository.
|
||||
* For legacy checkpoints, this is a no-op since they use separate repositories.
|
||||
* 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}`
|
||||
|
||||
// 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)
|
||||
} 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: ${currentBranch}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds files to the shadow git repository while handling nested git repos.
|
||||
* Uses git commands to list files and stages them for commit.
|
||||
* Respects .gitignore and handles LFS patterns.
|
||||
*
|
||||
* Process:
|
||||
* 1. Updates exclude patterns from LFS config
|
||||
* 2. Temporarily disables nested git repos
|
||||
* 3. Gets list of tracked and untracked files from git (respecting .gitignore)
|
||||
* 4. Adds all files to git staging
|
||||
* 5. Re-enables nested git repos
|
||||
*
|
||||
* @param git - SimpleGit instance configured for the shadow git repo
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @returns Promise<CheckpointAddResult> Object containing success status, message, and file count
|
||||
* @throws Error if:
|
||||
* - File operations fail
|
||||
* - Git commands error
|
||||
* - LFS pattern updates fail
|
||||
* - Nested git repo handling fails
|
||||
*/
|
||||
public async addCheckpointFiles(git: SimpleGit, gitPath: string): Promise<CheckpointAddResult> {
|
||||
try {
|
||||
// Update exclude patterns before each commit
|
||||
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
|
||||
await this.renameNestedGitRepos(true)
|
||||
//console.info("Starting checkpoint add operation...")
|
||||
|
||||
// Get list of all files git would track (respects .gitignore)
|
||||
await git.addConfig("core.quotePath", "false")
|
||||
await git.addConfig("core.precomposeunicode", "true")
|
||||
const gitFiles = (await git.raw(["ls-files", "--others", "--exclude-standard", "--cached"]))
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
|
||||
// Add filtered files
|
||||
if (gitFiles.length === 0) {
|
||||
console.info("No files to add to checkpoint")
|
||||
return { success: true, fileCount: 0 }
|
||||
}
|
||||
|
||||
try {
|
||||
console.info(`Adding ${gitFiles.length} files to checkpoint`)
|
||||
await git.addConfig("core.quotePath", "false")
|
||||
await git.addConfig("core.precomposeunicode", "true")
|
||||
await git.add(gitFiles)
|
||||
console.info("Checkpoint add operation completed successfully")
|
||||
return { success: true, fileCount: gitFiles.length }
|
||||
} catch (error) {
|
||||
console.error("Checkpoint add operation failed:", error)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to checkpoint", error)
|
||||
throw error
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
95
src/integrations/checkpoints/CheckpointRevert.test.ts.bak
Normal file
95
src/integrations/checkpoints/CheckpointRevert.test.ts.bak
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
|
||||
describe("Checkpoint Revert Operations", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should revert working directory to a previous checkpoint state", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial state
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.not.be.undefined
|
||||
|
||||
// Create and commit changes
|
||||
await fs.writeFile(env.testFilePath, "modified content")
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.not.be.undefined
|
||||
|
||||
// Make more changes without committing
|
||||
await fs.writeFile(env.testFilePath, "uncommitted changes")
|
||||
|
||||
// Revert to first commit
|
||||
await tracker.resetHead(firstCommit!)
|
||||
|
||||
// Verify file content matches initial state
|
||||
const resetContent = await fs.readFile(env.testFilePath, "utf8")
|
||||
expect(resetContent).to.equal("initial content")
|
||||
})
|
||||
|
||||
it("should handle reverting with multiple files", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial state with multiple files
|
||||
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
|
||||
await fs.writeFile(env.testFilePath, "file1 initial")
|
||||
await fs.writeFile(testFile2Path, "file2 initial")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.not.be.undefined
|
||||
|
||||
// Modify both files and commit
|
||||
await fs.writeFile(env.testFilePath, "file1 modified")
|
||||
await fs.writeFile(testFile2Path, "file2 modified")
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.not.be.undefined
|
||||
|
||||
// Make more changes
|
||||
await fs.writeFile(env.testFilePath, "file1 uncommitted")
|
||||
await fs.writeFile(testFile2Path, "file2 uncommitted")
|
||||
|
||||
// Reset to first commit
|
||||
await tracker.resetHead(firstCommit!)
|
||||
|
||||
// Verify both files match initial state
|
||||
const file1Content = await fs.readFile(env.testFilePath, "utf8")
|
||||
const file2Content = await fs.readFile(testFile2Path, "utf8")
|
||||
expect(file1Content).to.equal("file1 initial")
|
||||
expect(file2Content).to.equal("file2 initial")
|
||||
})
|
||||
|
||||
it("should handle reverting when files are deleted", async () => {
|
||||
const tracker = await createTestTracker(env.globalStoragePath)
|
||||
if (!tracker) {throw new Error("Failed to create tracker")}
|
||||
|
||||
// Create and commit initial state
|
||||
await fs.writeFile(env.testFilePath, "initial content")
|
||||
const firstCommit = await tracker.commit()
|
||||
expect(firstCommit).to.not.be.undefined
|
||||
|
||||
// Delete file and commit
|
||||
await fs.unlink(env.testFilePath)
|
||||
const secondCommit = await tracker.commit()
|
||||
expect(secondCommit).to.not.be.undefined
|
||||
|
||||
// Revert to first commit
|
||||
await tracker.resetHead(firstCommit!)
|
||||
|
||||
// Verify file is restored with original content
|
||||
const resetContent = await fs.readFile(env.testFilePath, "utf8")
|
||||
expect(resetContent).to.equal("initial content")
|
||||
})
|
||||
})
|
||||
120
src/integrations/checkpoints/CheckpointTaskSwitch.test.ts.bak
Normal file
120
src/integrations/checkpoints/CheckpointTaskSwitch.test.ts.bak
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { expect } from "chai"
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import fs from "fs/promises"
|
||||
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import CheckpointTracker from "./CheckpointTracker"
|
||||
|
||||
describe("Checkpoint Task Switching", () => {
|
||||
let env: Awaited<ReturnType<typeof createTestEnvironment>>
|
||||
let taskId1: string
|
||||
let taskId2: string
|
||||
let tracker1: CheckpointTracker | undefined
|
||||
let tracker2: CheckpointTracker | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
env = await createTestEnvironment()
|
||||
taskId1 = "task-1"
|
||||
taskId2 = "task-2"
|
||||
tracker1 = await createTestTracker(env.globalStoragePath, taskId1)
|
||||
if (!tracker1) {throw new Error("Failed to create tracker1")}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await env.cleanup()
|
||||
})
|
||||
|
||||
it("should maintain separate history for each task", async () => {
|
||||
if (!tracker1) {throw new Error("Failed to create tracker1")}
|
||||
|
||||
// Create and commit file in first task
|
||||
await fs.writeFile(env.testFilePath, "task1 initial")
|
||||
const task1Commit1 = await tracker1.commit()
|
||||
expect(task1Commit1).to.be.a("string").and.not.empty
|
||||
|
||||
// Modify and commit again in first task
|
||||
await fs.writeFile(env.testFilePath, "task1 modified")
|
||||
const task1Commit2 = await tracker1.commit()
|
||||
expect(task1Commit2).to.be.a("string").and.not.empty
|
||||
|
||||
// Create second task tracker
|
||||
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
|
||||
if (!tracker2) {throw new Error("Failed to create tracker2")}
|
||||
|
||||
// Create and commit file in second task
|
||||
await fs.writeFile(env.testFilePath, "task2 initial")
|
||||
const task2Commit1 = await tracker2.commit()
|
||||
expect(task2Commit1).to.be.a("string").and.not.empty
|
||||
|
||||
// Create another commit to establish history
|
||||
await fs.writeFile(env.testFilePath, "task2 modified")
|
||||
const task2Commit2 = await tracker2.commit()
|
||||
expect(task2Commit2).to.be.a("string").and.not.empty
|
||||
|
||||
// Verify second task's history
|
||||
const task2Diff = await tracker2.getDiffSet(task2Commit1, task2Commit2)
|
||||
expect(task2Diff).to.have.lengthOf(1)
|
||||
expect(task2Diff[0].before).to.equal("task2 initial")
|
||||
expect(task2Diff[0].after).to.equal("task2 modified")
|
||||
|
||||
// Switch back to first task by creating new tracker
|
||||
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
|
||||
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
|
||||
|
||||
// Verify first task's history is preserved
|
||||
const task1Diff = await tracker1Again.getDiffSet(task1Commit1, task1Commit2)
|
||||
expect(task1Diff[0].before).to.equal("task1 initial")
|
||||
expect(task1Diff[0].after).to.equal("task1 modified")
|
||||
|
||||
// Reset first task to initial state
|
||||
if (!task1Commit1) {throw new Error("Failed to create initial commit")}
|
||||
await tracker1Again.resetHead(task1Commit1)
|
||||
const resetContent = await fs.readFile(env.testFilePath, "utf8")
|
||||
expect(resetContent).to.equal("task1 initial")
|
||||
})
|
||||
|
||||
it("should handle task deletion and recreation", async () => {
|
||||
if (!tracker1) {throw new Error("Failed to create tracker1")}
|
||||
|
||||
// Create and commit file in first task
|
||||
await fs.writeFile(env.testFilePath, "task1 content")
|
||||
const task1Commit = await tracker1.commit()
|
||||
expect(task1Commit).to.be.a("string").and.not.empty
|
||||
|
||||
// Create second task
|
||||
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
|
||||
if (!tracker2) {throw new Error("Failed to create tracker2")}
|
||||
await fs.writeFile(env.testFilePath, "task2 content")
|
||||
const task2Commit = await tracker2.commit()
|
||||
expect(task2Commit).to.be.a("string").and.not.empty
|
||||
|
||||
// Delete second task's checkpoints
|
||||
const historyItem: HistoryItem = {
|
||||
id: `test-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
task: taskId2,
|
||||
shadowGitConfigWorkTree: env.tempDir,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
}
|
||||
await CheckpointTracker.deleteCheckpoints(taskId2, historyItem, env.globalStoragePath)
|
||||
|
||||
// Recreate second task
|
||||
const tracker2Again = await createTestTracker(env.globalStoragePath, taskId2)
|
||||
if (!tracker2Again) {throw new Error("Failed to create tracker2Again")}
|
||||
|
||||
// Create new commit in recreated task
|
||||
await fs.writeFile(env.testFilePath, "task2 new content")
|
||||
const newCommit = await tracker2Again.commit()
|
||||
expect(newCommit).to.be.a("string").and.not.empty
|
||||
|
||||
// Switch back to first task and verify its history is intact
|
||||
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
|
||||
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
|
||||
if (!task1Commit) {throw new Error("Failed to create initial commit")}
|
||||
await tracker1Again.resetHead(task1Commit)
|
||||
const resetContent = await fs.readFile(env.testFilePath, "utf8")
|
||||
expect(resetContent).to.equal("task1 content")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,31 +1,100 @@
|
|||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { globby } from "globby"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { GitOperations } from "./CheckpointGitOperations"
|
||||
import { getShadowGitPath, hashWorkingDir, getWorkingDirectory, detectLegacyCheckpoint } from "./CheckpointUtils"
|
||||
|
||||
/**
|
||||
* CheckpointTracker Module
|
||||
*
|
||||
* Core implementation of Cline's Checkpoints system that provides version control
|
||||
* capabilities without interfering with the user's main Git repository. Key features:
|
||||
*
|
||||
* Shadow Git Repository:
|
||||
* - Creates and manages an isolated Git repository for tracking checkpoints
|
||||
* - Handles nested Git repositories by temporarily disabling them
|
||||
* - Configures Git settings automatically (identity, LFS, etc.)
|
||||
*
|
||||
* File Management:
|
||||
* - Integrates with CheckpointExclusions for file filtering
|
||||
* - Handles workspace validation and path resolution
|
||||
* - Manages Git worktree configuration
|
||||
*
|
||||
* Checkpoint Operations:
|
||||
* - Creates checkpoints (commits) of the current state
|
||||
* - Provides diff capabilities between checkpoints
|
||||
* - Supports resetting to previous checkpoints
|
||||
*
|
||||
* Safety Features:
|
||||
* - Prevents usage in sensitive directories (home, desktop, etc.)
|
||||
* - Validates workspace configuration
|
||||
* - Handles cleanup and resource disposal
|
||||
*
|
||||
* Checkpoint Architecture:
|
||||
* - Uses a branch-per-task model to consolidate shadow git repositories
|
||||
* - Each task gets its own branch within a single shadow git per workspace
|
||||
* - Maintains backward compatibility with legacy checkpoint structure
|
||||
* - Automatically cleans up by deleting task branches when tasks are removed
|
||||
*/
|
||||
|
||||
class CheckpointTracker {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private globalStoragePath: string
|
||||
private taskId: string
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private cwd: string
|
||||
private cwdHash: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
lastCheckpointHash?: string
|
||||
private lastCheckpointHash?: string
|
||||
private isLegacyCheckpoint: boolean = false
|
||||
private gitOperations: GitOperations
|
||||
|
||||
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
|
||||
* The constructor is private - use the static create() method to instantiate.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task being tracked
|
||||
* @param cwd - The current working directory to track files in
|
||||
* @param cwdHash - Hash of the working directory path for shadow git organization
|
||||
*/
|
||||
private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) {
|
||||
this.globalStoragePath = globalStoragePath
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
this.cwdHash = cwdHash
|
||||
this.gitOperations = new GitOperations(cwd, false) // Initialize with non-legacy mode
|
||||
}
|
||||
|
||||
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance for tracking changes in a task.
|
||||
* Handles initialization of the shadow git repository and branch setup.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task to track
|
||||
* @param globalStoragePath - the globalStorage path
|
||||
* @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled
|
||||
* @throws Error if:
|
||||
* - globalStoragePath is not supplied
|
||||
* - Git is not installed
|
||||
* - Working directory is invalid or in a protected location
|
||||
* - Shadow git initialization fails
|
||||
*
|
||||
* Key operations:
|
||||
* - Validates git installation and settings
|
||||
* - Creates/initializes shadow git repository
|
||||
* - Detects and handles legacy checkpoint structure
|
||||
* - Sets up task-specific branch for new checkpoints
|
||||
*
|
||||
* Configuration:
|
||||
* - Respects 'cline.enableCheckpoints' VS Code setting
|
||||
* - Uses branch-per-task architecture for new checkpoints
|
||||
* - Maintains backwards compatibility with legacy structure
|
||||
*/
|
||||
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage path is required to create a checkpoint tracker")
|
||||
}
|
||||
try {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required to create a checkpoint tracker")
|
||||
}
|
||||
console.info(`Creating new CheckpointTracker for task ${taskId}`)
|
||||
|
||||
// Check if checkpoints are disabled in VS Code settings
|
||||
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
|
||||
|
|
@ -40,9 +109,36 @@ class CheckpointTracker {
|
|||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
const cwd = await CheckpointTracker.getWorkingDirectory()
|
||||
const newTracker = new CheckpointTracker(provider, taskId, cwd)
|
||||
await newTracker.initShadowGit()
|
||||
const workingDir = await getWorkingDirectory()
|
||||
const cwdHash = hashWorkingDir(workingDir)
|
||||
console.debug(`Repository ID (cwdHash): ${cwdHash}`)
|
||||
|
||||
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
|
||||
|
||||
// Check if this is a legacy task
|
||||
newTracker.isLegacyCheckpoint = await detectLegacyCheckpoint(newTracker.globalStoragePath, newTracker.taskId)
|
||||
if (newTracker.isLegacyCheckpoint) {
|
||||
console.debug("Using legacy checkpoint path structure")
|
||||
const gitPath = await getShadowGitPath(
|
||||
newTracker.globalStoragePath,
|
||||
newTracker.taskId,
|
||||
newTracker.cwdHash,
|
||||
newTracker.isLegacyCheckpoint,
|
||||
)
|
||||
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
|
||||
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
|
||||
return newTracker
|
||||
}
|
||||
|
||||
// Branch-per-task structure
|
||||
const gitPath = await getShadowGitPath(
|
||||
newTracker.globalStoragePath,
|
||||
newTracker.taskId,
|
||||
newTracker.cwdHash,
|
||||
newTracker.isLegacyCheckpoint,
|
||||
)
|
||||
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
|
||||
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
|
|
@ -50,203 +146,110 @@ class CheckpointTracker {
|
|||
}
|
||||
}
|
||||
|
||||
private static async getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
/**
|
||||
* Creates a new checkpoint commit in the shadow git repository.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Creates commit with checkpoint files in shadow git repo
|
||||
* - Handles both legacy and branch-per-task checkpoint structures
|
||||
* - For new tasks, switches to task-specific branch first
|
||||
* - Caches the created commit hash
|
||||
*
|
||||
* Commit structure:
|
||||
* - Legacy: Simple "checkpoint" message
|
||||
* - Branch-per-task: "checkpoint-{cwdHash}-{taskId}"
|
||||
* - Always allows empty commits
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - For new checkpoints, requires task branch setup
|
||||
* - Uses addCheckpointFiles to stage changes
|
||||
*
|
||||
* @returns Promise<string | undefined> The created commit hash, or undefined if:
|
||||
* - Shadow git access fails
|
||||
* - Branch switch fails
|
||||
* - Staging files fails
|
||||
* - Commit creation fails
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Switch branches
|
||||
* - Stage or commit files
|
||||
*/
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
console.info(`Using shadow git at: ${gitPath}`)
|
||||
|
||||
private async getShadowGitPath(): Promise<string> {
|
||||
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
|
||||
await fs.mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
// Disable nested git repos before any operations
|
||||
await this.gitOperations.renameNestedGitRepos(true)
|
||||
|
||||
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
return await fileExistsAtPath(gitPath)
|
||||
}
|
||||
|
||||
public async initShadowGit(): Promise<string> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
// Make sure it's the same cwd as the configured worktree
|
||||
const worktree = await this.getShadowGitConfigWorkTree()
|
||||
if (worktree !== this.cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
|
||||
}
|
||||
|
||||
return gitPath
|
||||
} else {
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
|
||||
|
||||
// Disable commit signing for shadow repo
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
|
||||
// Get LFS patterns from workspace if they exist
|
||||
let lfsPatterns: string[] = []
|
||||
try {
|
||||
const attributesPath = path.join(this.cwd, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
lfsPatterns = attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
if (!this.isLegacyCheckpoint) {
|
||||
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
await this.gitOperations.addCheckpointFiles(git, gitPath)
|
||||
|
||||
const commitMessage = this.isLegacyCheckpoint ? "checkpoint" : "checkpoint-" + this.cwdHash + "-" + this.taskId
|
||||
|
||||
console.info(
|
||||
`Creating ${this.isLegacyCheckpoint ? "legacy" : "new"} checkpoint commit with message: ${commitMessage}`,
|
||||
)
|
||||
const result = await git.commit(commitMessage, {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
console.warn(`Checkpoint commit created.`)
|
||||
return commitHash
|
||||
} finally {
|
||||
// Always re-enable nested git repos
|
||||
await this.gitOperations.renameNestedGitRepos(false)
|
||||
}
|
||||
|
||||
// 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
|
||||
// TODO: let user customize these
|
||||
const excludesPath = path.join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
excludesPath,
|
||||
[
|
||||
".git/", // ignore the user's .git
|
||||
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
"env/",
|
||||
"venv/",
|
||||
"target/dependency/",
|
||||
"build/dependencies/",
|
||||
"dist/",
|
||||
"out/",
|
||||
"bundle/",
|
||||
"vendor/",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
"deps/",
|
||||
"pkg/",
|
||||
"Pods/",
|
||||
// Media files
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
// "*.svg",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.avi",
|
||||
"*.mov",
|
||||
"*.wmv",
|
||||
"*.webm",
|
||||
"*.webp",
|
||||
"*.m4a",
|
||||
"*.flac",
|
||||
// Build and dependency directories
|
||||
"build/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".vs/",
|
||||
"coverage/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
// Cache and temporary files
|
||||
"*.cache",
|
||||
"*.tmp",
|
||||
"*.temp",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
".pytest_cache/",
|
||||
".eslintcache",
|
||||
// Environment and config files
|
||||
".env*",
|
||||
"*.local",
|
||||
"*.development",
|
||||
"*.production",
|
||||
// Large data files
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
// Database files
|
||||
"*.sqlite",
|
||||
"*.db",
|
||||
"*.sql",
|
||||
// Log files
|
||||
"*.logs",
|
||||
"*.error",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
...lfsPatterns,
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// Set up git identity (git throws an error if user.name or user.email is not set)
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "noreply@example.com")
|
||||
|
||||
await this.addAllFiles(git)
|
||||
// Initial commit (--allow-empty ensures it works even with no files)
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
return gitPath
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", {
|
||||
taskId: this.taskId,
|
||||
error,
|
||||
isLegacyCheckpoint: this.isLegacyCheckpoint,
|
||||
})
|
||||
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the worktree path from the shadow git configuration.
|
||||
* The worktree path indicates where the shadow git repository is tracking files,
|
||||
* which should match the current workspace directory.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
|
||||
* - Returns cached value if available
|
||||
* - Reads git config if no cached value exists
|
||||
* - Handles both legacy and new checkpoint structures
|
||||
*
|
||||
* Configuration read:
|
||||
* - Uses simple-git to read core.worktree config
|
||||
* - Operates on shadow git at path from getShadowGitPath()
|
||||
*
|
||||
* @returns Promise<string | undefined> The configured worktree path, or undefined if:
|
||||
* - Shadow git repository doesn't exist
|
||||
* - Config read fails
|
||||
* - No worktree is configured
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Read git configuration
|
||||
*/
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
|
||||
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
|
|
@ -254,36 +257,32 @@ class CheckpointTracker {
|
|||
}
|
||||
}
|
||||
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
await this.addAllFiles(git)
|
||||
const result = await git.commit("checkpoint", {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the shadow git repository's HEAD to a specific checkpoint commit.
|
||||
* This will discard all changes after the target commit and restore the
|
||||
* working directory to that checkpoint's state.
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - For new checkpoints, requires task branch setup
|
||||
* - Must be called with a valid commit hash from this task's history
|
||||
*
|
||||
* @param commitHash - The hash of the checkpoint commit to reset to
|
||||
* @returns Promise<void> Resolves when reset is complete
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Switch to task branch
|
||||
* - Reset to target commit
|
||||
*/
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
console.info(`Resetting to checkpoint: ${commitHash}`)
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// Clean working directory and force reset
|
||||
// This ensures that the operation will succeed regardless of:
|
||||
// - Untracked files in the workspace
|
||||
// - Staged changes
|
||||
// - Unstaged changes
|
||||
// - Partial commits
|
||||
// - Merge conflicts
|
||||
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
|
||||
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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -310,111 +309,145 @@ class CheckpointTracker {
|
|||
after: string
|
||||
}>
|
||||
> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
if (!this.isLegacyCheckpoint) {
|
||||
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
|
||||
}
|
||||
|
||||
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
// If lhsHash is missing, use the initial commit of the repo
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
|
||||
baseHash = rootCommit.trim()
|
||||
console.debug(`Using root commit as base: ${baseHash}`)
|
||||
}
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.addAllFiles(git)
|
||||
await this.gitOperations.addCheckpointFiles(git, gitPath)
|
||||
|
||||
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
|
||||
console.info(`Found ${diffSummary.files.length} changed files`)
|
||||
|
||||
// For each changed file, gather before/after content
|
||||
const result = []
|
||||
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
|
||||
const files = diffSummary.files.map((f) => f.file)
|
||||
const batchSize = 50
|
||||
|
||||
for (const file of diffSummary.files) {
|
||||
const filePath = file.file
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
// Get list of files that exist in base commit
|
||||
const existingFiles = await this.getExistingFiles(git, baseHash, files)
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${baseHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
// Process files in batches
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const batch = files.slice(i, i + batchSize)
|
||||
|
||||
// Split batch into existing and new files
|
||||
const existingBatch = batch.filter((file) => existingFiles.has(file))
|
||||
const newBatch = batch.filter((file) => !existingFiles.has(file))
|
||||
|
||||
// Get before contents for existing files
|
||||
let beforeContents: string[] = new Array(batch.length).fill("")
|
||||
if (existingBatch.length > 0) {
|
||||
await git.addConfig("core.quotePath", "false")
|
||||
await git.addConfig("core.precomposeunicode", "true")
|
||||
const args = ["show", "--format="]
|
||||
existingBatch.forEach((file) => {
|
||||
args.push(`${baseHash}:${file}`)
|
||||
})
|
||||
const beforeResult = await git.raw(args)
|
||||
const existingContents = beforeResult.split("\n\0\n")
|
||||
// Map contents back to original batch positions
|
||||
existingBatch.forEach((file, index) => {
|
||||
const batchIndex = batch.indexOf(file)
|
||||
if (batchIndex !== -1) {
|
||||
beforeContents[batchIndex] = existingContents[index] || ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let afterContent = ""
|
||||
// Get after contents
|
||||
let afterContents: string[] = []
|
||||
if (rhsHash) {
|
||||
// if user provided a newer commit, use git.show at that commit
|
||||
try {
|
||||
afterContent = await git.show([`${rhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
// Split after files into existing and new in target commit
|
||||
const afterExistingFiles = await this.getExistingFiles(git, rhsHash, batch)
|
||||
const afterExistingBatch = batch.filter((file) => afterExistingFiles.has(file))
|
||||
|
||||
if (afterExistingBatch.length > 0) {
|
||||
const args = ["show", "--format="]
|
||||
afterExistingBatch.forEach((file) => {
|
||||
args.push(`${rhsHash}:${file}`)
|
||||
})
|
||||
const afterResult = await git.raw(args)
|
||||
const existingContents = afterResult.split("\n\0\n")
|
||||
afterContents = new Array(batch.length).fill("")
|
||||
afterExistingBatch.forEach((file, index) => {
|
||||
const batchIndex = batch.indexOf(file)
|
||||
if (batchIndex !== -1) {
|
||||
afterContents[batchIndex] = existingContents[index] || ""
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// otherwise, read from disk (includes uncommitted changes)
|
||||
try {
|
||||
afterContent = await fs.readFile(absolutePath, "utf8")
|
||||
} catch (_) {
|
||||
// file might be deleted => remains empty
|
||||
}
|
||||
// Read from disk for working directory changes
|
||||
afterContents = await Promise.all(
|
||||
batch.map(async (filePath) => {
|
||||
try {
|
||||
return await fs.readFile(path.join(cwdPath, filePath), "utf8")
|
||||
} catch (_) {
|
||||
return ""
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
result.push({
|
||||
relativePath: filePath,
|
||||
absolutePath,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
})
|
||||
// Add results for this batch
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const filePath = batch[j]
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
result.push({
|
||||
relativePath: filePath,
|
||||
absolutePath,
|
||||
before: beforeContents[j] || "",
|
||||
after: afterContents[j] || "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async addAllFiles(git: SimpleGit) {
|
||||
await this.renameNestedGitRepos(true)
|
||||
/**
|
||||
* Deletes all checkpoint data for a given task.
|
||||
* Handles both legacy checkpoints and branch-per-task checkpoints.
|
||||
*
|
||||
* @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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get a set of files that exist in a given commit
|
||||
*/
|
||||
private async getExistingFiles(git: SimpleGit, commitHash: string, files: string[]): Promise<Set<string>> {
|
||||
try {
|
||||
await git.add(".")
|
||||
const result = await git.raw(["ls-tree", "-r", "--name-only", commitHash])
|
||||
const existingFiles = new Set<string>(result.split("\n"))
|
||||
return existingFiles
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to git:", error)
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
console.error("Error getting existing files:", error)
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
private async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
|
||||
const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
|
||||
export default CheckpointTracker
|
||||
|
|
|
|||
159
src/integrations/checkpoints/CheckpointUtils.ts
Normal file
159
src/integrations/checkpoints/CheckpointUtils.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { mkdir } from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import os from "os"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
|
||||
/**
|
||||
* Gets the path to the legacy shadow Git repository in globalStorage.
|
||||
* Legacy checkpoints stored each task's checkpoints in a separate git repository
|
||||
* under the tasks/{taskId}/checkpoints directory.
|
||||
*
|
||||
* Legacy path structure:
|
||||
* globalStorage/
|
||||
* tasks/
|
||||
* {taskId}/
|
||||
* checkpoints/
|
||||
* .git/
|
||||
*
|
||||
* @param globalStoragePath - The VS Code global storage path
|
||||
* @param taskId - The ID of the task
|
||||
* @returns Promise<string> The absolute path to the legacy shadow git directory
|
||||
* @throws Error if global storage path is invalid
|
||||
*/
|
||||
export async function getLegacyShadowGitPath(globalStoragePath: string, taskId: string): Promise<string> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
|
||||
await mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
console.info(`Legacy shadow git path: ${gitPath}`)
|
||||
return gitPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the shadow Git repository in globalStorage.
|
||||
* For legacy checkpoints, delegates to getLegacyShadowGitPath().
|
||||
* For new checkpoints, uses the consolidated branch-per-task structure.
|
||||
*
|
||||
* Branch-per-task path structure:
|
||||
* globalStorage/
|
||||
* checkpoints/
|
||||
* {cwdHash}/
|
||||
* .git/
|
||||
*
|
||||
* @param globalStoragePath - The VS Code global storage path
|
||||
* @param taskId - The ID of the task
|
||||
* @param cwdHash - Hash of the working directory path
|
||||
* @param isLegacyCheckpoint - Whether this is a legacy checkpoint
|
||||
* @returns Promise<string> The absolute path to the shadow git directory
|
||||
* @throws Error if global storage path is invalid
|
||||
*/
|
||||
export async function getShadowGitPath(
|
||||
globalStoragePath: string,
|
||||
taskId: string,
|
||||
cwdHash: string,
|
||||
isLegacyCheckpoint: boolean,
|
||||
): Promise<string> {
|
||||
if (isLegacyCheckpoint) {
|
||||
return getLegacyShadowGitPath(globalStoragePath, taskId)
|
||||
}
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
|
||||
await mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current working directory from the VS Code workspace.
|
||||
* Validates that checkpoints are not being used in protected directories
|
||||
* like home, Desktop, Documents, or Downloads.
|
||||
*
|
||||
* Protected directories:
|
||||
* - User's home directory
|
||||
* - Desktop
|
||||
* - Documents
|
||||
* - Downloads
|
||||
*
|
||||
* @returns Promise<string> The absolute path to the current working directory
|
||||
* @throws Error if no workspace is detected or if in a protected directory
|
||||
*/
|
||||
export async function getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes the current working directory to a 13-character numeric hash.
|
||||
* @param workingDir - The absolute path to the working directory
|
||||
* @returns A 13-character numeric hash string used to identify the workspace
|
||||
* @throws {Error} If the working directory path is empty or invalid
|
||||
*/
|
||||
export function hashWorkingDir(workingDir: string): string {
|
||||
if (!workingDir) {
|
||||
throw new Error("Working directory path cannot be empty")
|
||||
}
|
||||
let hash = 0
|
||||
for (let i = 0; i < workingDir.length; i++) {
|
||||
hash = (hash * 31 + workingDir.charCodeAt(i)) >>> 0
|
||||
}
|
||||
const bigHash = BigInt(hash)
|
||||
const numericHash = bigHash.toString().slice(0, 13)
|
||||
return numericHash
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a task uses the legacy checkpoint structure.
|
||||
* Legacy checkpoints stored each task's checkpoints in a separate git repository
|
||||
* under the tasks/{taskId}/checkpoints directory. New checkpoints use a single
|
||||
* repository with branches per task.
|
||||
*
|
||||
* @param globalStoragePath - The VS Code global storage path
|
||||
* @param taskId - The ID of the task to check
|
||||
* @returns Promise<boolean> True if task uses legacy checkpoint structure, false otherwise
|
||||
*
|
||||
* Legacy path structure:
|
||||
* globalStorage/
|
||||
* tasks/
|
||||
* {taskId}/
|
||||
* checkpoints/
|
||||
* .git/
|
||||
*
|
||||
* Branch-per-task structure:
|
||||
* globalStorage/
|
||||
* checkpoints/
|
||||
* {cwdHash}/
|
||||
* .git/
|
||||
*/
|
||||
export async function detectLegacyCheckpoint(globalStoragePath: string | undefined, taskId: string): Promise<boolean> {
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
const isLegacy = await fileExistsAtPath(legacyGitPath)
|
||||
console.info(`Legacy checkpoint detection result: ${isLegacy}`)
|
||||
return isLegacy
|
||||
}
|
||||
|
|
@ -4,6 +4,25 @@ const vscode = require("vscode")
|
|||
describe("Extension Tests", function () {
|
||||
this.timeout(60000) // Increased timeout for extension operations
|
||||
|
||||
let originalGetConfiguration
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original configuration
|
||||
originalGetConfiguration = vscode.workspace.getConfiguration
|
||||
// Setup mock configuration
|
||||
const mockUpdate = async () => Promise.resolve()
|
||||
const mockConfig = {
|
||||
get: () => true,
|
||||
update: mockUpdate,
|
||||
}
|
||||
vscode.workspace.getConfiguration = () => mockConfig
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original configuration
|
||||
vscode.workspace.getConfiguration = originalGetConfiguration
|
||||
})
|
||||
|
||||
it("should activate extension successfully", async () => {
|
||||
// Get the extension
|
||||
const extension = vscode.extensions.getExtension("saoudrizwan.claude-dev")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue