+
,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
@@ -104,7 +108,7 @@ const CommandItem = React.forwardRef<
{
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(price)
+}
From 85dacb03a3afa1b5967d6032a309f86bac554f6d Mon Sep 17 00:00:00 2001
From: Roo Code
Date: Fri, 7 Feb 2025 15:57:35 -0500
Subject: [PATCH 07/15] Better UX for adding new API config profiles
---
.changeset/dirty-coins-exist.md | 5 +
.../components/settings/ApiConfigManager.tsx | 323 ++++++++++++++----
.../__tests__/ApiConfigManager.test.tsx | 154 ++++++++-
3 files changed, 399 insertions(+), 83 deletions(-)
create mode 100644 .changeset/dirty-coins-exist.md
diff --git a/.changeset/dirty-coins-exist.md b/.changeset/dirty-coins-exist.md
new file mode 100644
index 0000000000..d01a3ba76e
--- /dev/null
+++ b/.changeset/dirty-coins-exist.md
@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Improve the user experience for adding a new configuration profile
diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx
index b10adf4a49..652803fe76 100644
--- a/webview-ui/src/components/settings/ApiConfigManager.tsx
+++ b/webview-ui/src/components/settings/ApiConfigManager.tsx
@@ -1,8 +1,9 @@
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
-import { memo, useEffect, useRef, useState } from "react"
+import { memo, useEffect, useReducer, useRef } from "react"
import { ApiConfigMeta } from "../../../../src/shared/ExtensionMessage"
import { Dropdown } from "vscrui"
import type { DropdownOption } from "vscrui"
+import { Dialog, DialogContent } from "../ui/dialog"
interface ApiConfigManagerProps {
currentApiConfigName?: string
@@ -13,6 +14,86 @@ interface ApiConfigManagerProps {
onUpsertConfig: (configName: string) => void
}
+type State = {
+ isRenaming: boolean
+ isCreating: boolean
+ inputValue: string
+ newProfileName: string
+ error: string | null
+}
+
+type Action =
+ | { type: "START_RENAME"; payload: string }
+ | { type: "CANCEL_EDIT" }
+ | { type: "SET_INPUT"; payload: string }
+ | { type: "SET_NEW_NAME"; payload: string }
+ | { type: "START_CREATE" }
+ | { type: "CANCEL_CREATE" }
+ | { type: "SET_ERROR"; payload: string | null }
+ | { type: "RESET_STATE" }
+
+const initialState: State = {
+ isRenaming: false,
+ isCreating: false,
+ inputValue: "",
+ newProfileName: "",
+ error: null,
+}
+
+const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "START_RENAME":
+ return {
+ ...state,
+ isRenaming: true,
+ inputValue: action.payload,
+ error: null,
+ }
+ case "CANCEL_EDIT":
+ return {
+ ...state,
+ isRenaming: false,
+ inputValue: "",
+ error: null,
+ }
+ case "SET_INPUT":
+ return {
+ ...state,
+ inputValue: action.payload,
+ error: null,
+ }
+ case "SET_NEW_NAME":
+ return {
+ ...state,
+ newProfileName: action.payload,
+ error: null,
+ }
+ case "START_CREATE":
+ return {
+ ...state,
+ isCreating: true,
+ newProfileName: "",
+ error: null,
+ }
+ case "CANCEL_CREATE":
+ return {
+ ...state,
+ isCreating: false,
+ newProfileName: "",
+ error: null,
+ }
+ case "SET_ERROR":
+ return {
+ ...state,
+ error: action.payload,
+ }
+ case "RESET_STATE":
+ return initialState
+ default:
+ return state
+ }
+}
+
const ApiConfigManager = ({
currentApiConfigName = "",
listApiConfigMeta = [],
@@ -21,55 +102,93 @@ const ApiConfigManager = ({
onRenameConfig,
onUpsertConfig,
}: ApiConfigManagerProps) => {
- const [editState, setEditState] = useState<"new" | "rename" | null>(null)
- const [inputValue, setInputValue] = useState("")
- const inputRef = useRef()
+ const [state, dispatch] = useReducer(reducer, initialState)
+ const inputRef = useRef(null)
+ const newProfileInputRef = useRef(null)
- // Focus input when entering edit mode
- useEffect(() => {
- if (editState) {
- setTimeout(() => inputRef.current?.focus(), 0)
+ const validateName = (name: string, isNewProfile: boolean): string | null => {
+ const trimmed = name.trim()
+ if (!trimmed) return "Name cannot be empty"
+
+ const nameExists = listApiConfigMeta?.some((config) => config.name.toLowerCase() === trimmed.toLowerCase())
+
+ // For new profiles, any existing name is invalid
+ if (isNewProfile && nameExists) {
+ return "A profile with this name already exists"
}
- }, [editState])
- // Reset edit state when current profile changes
+ // For rename, only block if trying to rename to a different existing profile
+ if (!isNewProfile && nameExists && trimmed.toLowerCase() !== currentApiConfigName?.toLowerCase()) {
+ return "A profile with this name already exists"
+ }
+
+ return null
+ }
+
+ // Focus input when entering rename mode
useEffect(() => {
- setEditState(null)
- setInputValue("")
+ if (state.isRenaming) {
+ const timeoutId = setTimeout(() => inputRef.current?.focus(), 0)
+ return () => clearTimeout(timeoutId)
+ }
+ }, [state.isRenaming])
+
+ // Focus input when opening new dialog
+ useEffect(() => {
+ if (state.isCreating) {
+ const timeoutId = setTimeout(() => newProfileInputRef.current?.focus(), 0)
+ return () => clearTimeout(timeoutId)
+ }
+ }, [state.isCreating])
+
+ // Reset state when current profile changes
+ useEffect(() => {
+ dispatch({ type: "RESET_STATE" })
}, [currentApiConfigName])
const handleAdd = () => {
- const newConfigName = currentApiConfigName + " (copy)"
- onUpsertConfig(newConfigName)
+ dispatch({ type: "START_CREATE" })
}
const handleStartRename = () => {
- setEditState("rename")
- setInputValue(currentApiConfigName || "")
+ dispatch({ type: "START_RENAME", payload: currentApiConfigName || "" })
}
const handleCancel = () => {
- setEditState(null)
- setInputValue("")
+ dispatch({ type: "CANCEL_EDIT" })
}
const handleSave = () => {
- const trimmedValue = inputValue.trim()
- if (!trimmedValue) return
+ const trimmedValue = state.inputValue.trim()
+ const error = validateName(trimmedValue, false)
- if (editState === "new") {
- onUpsertConfig(trimmedValue)
- } else if (editState === "rename" && currentApiConfigName) {
+ if (error) {
+ dispatch({ type: "SET_ERROR", payload: error })
+ return
+ }
+
+ if (state.isRenaming && currentApiConfigName) {
if (currentApiConfigName === trimmedValue) {
- setEditState(null)
- setInputValue("")
+ dispatch({ type: "CANCEL_EDIT" })
return
}
onRenameConfig(currentApiConfigName, trimmedValue)
}
- setEditState(null)
- setInputValue("")
+ dispatch({ type: "CANCEL_EDIT" })
+ }
+
+ const handleNewProfileSave = () => {
+ const trimmedValue = state.newProfileName.trim()
+ const error = validateName(trimmedValue, true)
+
+ if (error) {
+ dispatch({ type: "SET_ERROR", payload: error })
+ return
+ }
+
+ onUpsertConfig(trimmedValue)
+ dispatch({ type: "CANCEL_CREATE" })
}
const handleDelete = () => {
@@ -93,49 +212,62 @@ const ApiConfigManager = ({
Configuration Profile
- {editState ? (
-
-
setInputValue(e.target.value)}
- placeholder={editState === "new" ? "Enter profile name" : "Enter new name"}
- style={{ flexGrow: 1 }}
- onKeyDown={(e: any) => {
- if (e.key === "Enter" && inputValue.trim()) {
- handleSave()
- } else if (e.key === "Escape") {
- handleCancel()
- }
- }}
- />
-
-
-
-
-
-
+ {state.isRenaming ? (
+
+
+ {
+ const target = e as { target: { value: string } }
+ dispatch({ type: "SET_INPUT", payload: target.target.value })
+ }}
+ placeholder="Enter new name"
+ style={{ flexGrow: 1 }}
+ onKeyDown={(e: unknown) => {
+ const event = e as { key: string }
+ if (event.key === "Enter" && state.inputValue.trim()) {
+ handleSave()
+ } else if (event.key === "Escape") {
+ handleCancel()
+ }
+ }}
+ />
+
+
+
+
+
+
+
+ {state.error && (
+
+ {state.error}
+
+ )}
) : (
<>
@@ -211,6 +343,57 @@ const ApiConfigManager = ({
>
)}
+
+ dispatch({ type: open ? "START_CREATE" : "CANCEL_CREATE" })}
+ aria-labelledby="new-profile-title">
+
+
+ New Configuration Profile
+
+ dispatch({ type: "CANCEL_CREATE" })}>
+
+
+ {
+ const target = e as { target: { value: string } }
+ dispatch({ type: "SET_NEW_NAME", payload: target.target.value })
+ }}
+ placeholder="Enter profile name"
+ style={{ width: "100%" }}
+ onKeyDown={(e: unknown) => {
+ const event = e as { key: string }
+ if (event.key === "Enter" && state.newProfileName.trim()) {
+ handleNewProfileSave()
+ } else if (event.key === "Escape") {
+ dispatch({ type: "CANCEL_CREATE" })
+ }
+ }}
+ />
+ {state.error && (
+
+ {state.error}
+
+ )}
+
+ dispatch({ type: "CANCEL_CREATE" })}>
+ Cancel
+
+
+ Create Profile
+
+
+
+
)
diff --git a/webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx b/webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx
index ac6245d6d1..24e62215ec 100644
--- a/webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx
+++ b/webview-ui/src/components/settings/__tests__/ApiConfigManager.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent } from "@testing-library/react"
+import { render, screen, fireEvent, within } from "@testing-library/react"
import ApiConfigManager from "../ApiConfigManager"
// Mock VSCode components
@@ -8,11 +8,12 @@ jest.mock("@vscode/webview-ui-toolkit/react", () => ({
{children}
),
- VSCodeTextField: ({ value, onInput, placeholder }: any) => (
+ VSCodeTextField: ({ value, onInput, placeholder, onKeyDown }: any) => (
onInput(e)}
placeholder={placeholder}
+ onKeyDown={onKeyDown}
ref={undefined} // Explicitly set ref to undefined to avoid warning
/>
),
@@ -32,6 +33,16 @@ jest.mock("vscrui", () => ({
),
}))
+// Mock Dialog component
+jest.mock("@/components/ui/dialog", () => ({
+ Dialog: ({ children, open, onOpenChange }: any) => (
+
+ {children}
+
+ ),
+ DialogContent: ({ children }: any) =>
{children}
,
+}))
+
describe("ApiConfigManager", () => {
const mockOnSelectConfig = jest.fn()
const mockOnDeleteConfig = jest.fn()
@@ -54,34 +65,74 @@ describe("ApiConfigManager", () => {
jest.clearAllMocks()
})
- it("immediately creates a copy when clicking add button", () => {
+ const getRenameForm = () => screen.getByTestId("rename-form")
+ const getDialogContent = () => screen.getByTestId("dialog-content")
+
+ it("opens new profile dialog when clicking add button", () => {
render(
)
- // Find and click the add button
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
- // Verify that onUpsertConfig was called with the correct name
- expect(mockOnUpsertConfig).toHaveBeenCalledTimes(1)
- expect(mockOnUpsertConfig).toHaveBeenCalledWith("Default Config (copy)")
+ expect(screen.getByTestId("dialog")).toBeVisible()
+ expect(screen.getByText("New Configuration Profile")).toBeInTheDocument()
})
- it("creates copy with correct name when current config has spaces", () => {
- render(
)
+ it("creates new profile with entered name", () => {
+ render(
)
+ // Open dialog
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
- expect(mockOnUpsertConfig).toHaveBeenCalledWith("My Test Config (copy)")
+ // Enter new profile name
+ const input = screen.getByPlaceholderText("Enter profile name")
+ fireEvent.input(input, { target: { value: "New Profile" } })
+
+ // Click create button
+ const createButton = screen.getByText("Create Profile")
+ fireEvent.click(createButton)
+
+ expect(mockOnUpsertConfig).toHaveBeenCalledWith("New Profile")
})
- it("handles empty current config name gracefully", () => {
- render(
)
+ it("shows error when creating profile with existing name", () => {
+ render(
)
+ // Open dialog
const addButton = screen.getByTitle("Add profile")
fireEvent.click(addButton)
- expect(mockOnUpsertConfig).toHaveBeenCalledWith(" (copy)")
+ // Enter existing profile name
+ const input = screen.getByPlaceholderText("Enter profile name")
+ fireEvent.input(input, { target: { value: "Default Config" } })
+
+ // Click create button to trigger validation
+ const createButton = screen.getByText("Create Profile")
+ fireEvent.click(createButton)
+
+ // Verify error message
+ const dialogContent = getDialogContent()
+ const errorMessage = within(dialogContent).getByTestId("error-message")
+ expect(errorMessage).toHaveTextContent("A profile with this name already exists")
+ expect(mockOnUpsertConfig).not.toHaveBeenCalled()
+ })
+
+ it("prevents creating profile with empty name", () => {
+ render(
)
+
+ // Open dialog
+ const addButton = screen.getByTitle("Add profile")
+ fireEvent.click(addButton)
+
+ // Enter empty name
+ const input = screen.getByPlaceholderText("Enter profile name")
+ fireEvent.input(input, { target: { value: " " } })
+
+ // Verify create button is disabled
+ const createButton = screen.getByText("Create Profile")
+ expect(createButton).toBeDisabled()
+ expect(mockOnUpsertConfig).not.toHaveBeenCalled()
})
it("allows renaming the current config", () => {
@@ -102,6 +153,45 @@ describe("ApiConfigManager", () => {
expect(mockOnRenameConfig).toHaveBeenCalledWith("Default Config", "New Name")
})
+ it("shows error when renaming to existing config name", () => {
+ render(
)
+
+ // Start rename
+ const renameButton = screen.getByTitle("Rename profile")
+ fireEvent.click(renameButton)
+
+ // Find input and enter existing name
+ const input = screen.getByDisplayValue("Default Config")
+ fireEvent.input(input, { target: { value: "Another Config" } })
+
+ // Save to trigger validation
+ const saveButton = screen.getByTitle("Save")
+ fireEvent.click(saveButton)
+
+ // Verify error message
+ const renameForm = getRenameForm()
+ const errorMessage = within(renameForm).getByTestId("error-message")
+ expect(errorMessage).toHaveTextContent("A profile with this name already exists")
+ expect(mockOnRenameConfig).not.toHaveBeenCalled()
+ })
+
+ it("prevents renaming to empty name", () => {
+ render(
)
+
+ // Start rename
+ const renameButton = screen.getByTitle("Rename profile")
+ fireEvent.click(renameButton)
+
+ // Find input and enter empty name
+ const input = screen.getByDisplayValue("Default Config")
+ fireEvent.input(input, { target: { value: " " } })
+
+ // Verify save button is disabled
+ const saveButton = screen.getByTitle("Save")
+ expect(saveButton).toBeDisabled()
+ expect(mockOnRenameConfig).not.toHaveBeenCalled()
+ })
+
it("allows selecting a different config", () => {
render(
)
@@ -149,4 +239,42 @@ describe("ApiConfigManager", () => {
// Verify we're back to normal view
expect(screen.queryByDisplayValue("New Name")).not.toBeInTheDocument()
})
+
+ it("handles keyboard events in new profile dialog", () => {
+ render(
)
+
+ // Open dialog
+ const addButton = screen.getByTitle("Add profile")
+ fireEvent.click(addButton)
+
+ const input = screen.getByPlaceholderText("Enter profile name")
+
+ // Test Enter key
+ fireEvent.input(input, { target: { value: "New Profile" } })
+ fireEvent.keyDown(input, { key: "Enter" })
+ expect(mockOnUpsertConfig).toHaveBeenCalledWith("New Profile")
+
+ // Test Escape key
+ fireEvent.keyDown(input, { key: "Escape" })
+ expect(screen.getByTestId("dialog")).not.toBeVisible()
+ })
+
+ it("handles keyboard events in rename mode", () => {
+ render(
)
+
+ // Start rename
+ const renameButton = screen.getByTitle("Rename profile")
+ fireEvent.click(renameButton)
+
+ const input = screen.getByDisplayValue("Default Config")
+
+ // Test Enter key
+ fireEvent.input(input, { target: { value: "New Name" } })
+ fireEvent.keyDown(input, { key: "Enter" })
+ expect(mockOnRenameConfig).toHaveBeenCalledWith("Default Config", "New Name")
+
+ // Test Escape key
+ fireEvent.keyDown(input, { key: "Escape" })
+ expect(screen.queryByDisplayValue("New Name")).not.toBeInTheDocument()
+ })
})
From 30cf0d53198031e74755fef9964dcc98e36ec457 Mon Sep 17 00:00:00 2001
From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>
Date: Fri, 7 Feb 2025 15:50:28 -0700
Subject: [PATCH 08/15] Update HistoryPreview.tsx
Added a copy button to the history preview view
---
.../src/components/history/HistoryPreview.tsx | 47 +++++++++++++++++--
1 file changed, 44 insertions(+), 3 deletions(-)
diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx
index 08aca2a44d..562c110097 100644
--- a/webview-ui/src/components/history/HistoryPreview.tsx
+++ b/webview-ui/src/components/history/HistoryPreview.tsx
@@ -1,7 +1,7 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
-import { memo } from "react"
+import { memo, useState } from "react"
import { formatLargeNumber } from "../../utils/format"
type HistoryPreviewProps = {
@@ -10,6 +10,18 @@ type HistoryPreviewProps = {
const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
const { taskHistory } = useExtensionState()
+ const [showCopyModal, setShowCopyModal] = useState(false)
+
+ const handleCopyTask = async (e: React.MouseEvent, task: string) => {
+ e.stopPropagation()
+ try {
+ await navigator.clipboard.writeText(task)
+ setShowCopyModal(true)
+ setTimeout(() => setShowCopyModal(false), 2000)
+ } catch (error) {
+ console.error("Failed to copy to clipboard:", error)
+ }
+ }
const handleHistorySelect = (id: string) => {
vscode.postMessage({ type: "showTaskWithId", text: id })
}
@@ -31,8 +43,30 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
return (
+ {showCopyModal &&
Prompt Copied to Clipboard
}
- {showCopyModal &&
Prompt Copied to Clipboard
}
+ {showCopyFeedback &&
Prompt Copied to Clipboard
}
{
title="Copy Prompt"
className="copy-button"
data-appearance="icon"
- onClick={(e) => handleCopyTask(e, item.task)}>
+ onClick={(e) => copyWithFeedback(item.task, e)}>
void
+ /** Optional callback when copy fails */
+ onError?: (error: Error) => void
+}
+
+/**
+ * Copy text to clipboard with error handling
+ */
+export const copyToClipboard = async (text: string, options?: CopyOptions): Promise => {
+ try {
+ await navigator.clipboard.writeText(text)
+ options?.onSuccess?.()
+ return true
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error("Failed to copy to clipboard")
+ options?.onError?.(err)
+ console.error("Failed to copy to clipboard:", err)
+ return false
+ }
+}
+
+/**
+ * React hook for managing clipboard copy state with feedback
+ */
+export const useCopyToClipboard = (feedbackDuration = 2000) => {
+ const [showCopyFeedback, setShowCopyFeedback] = useState(false)
+
+ const copyWithFeedback = useCallback(
+ async (text: string, e?: React.MouseEvent) => {
+ e?.stopPropagation()
+
+ const success = await copyToClipboard(text, {
+ onSuccess: () => {
+ setShowCopyFeedback(true)
+ setTimeout(() => setShowCopyFeedback(false), feedbackDuration)
+ },
+ })
+
+ return success
+ },
+ [feedbackDuration],
+ )
+
+ return {
+ showCopyFeedback,
+ copyWithFeedback,
+ }
+}
From 19a213f32353a588e5ccd60c0ac4f11a6cca6cce Mon Sep 17 00:00:00 2001
From: Roo Code
Date: Fri, 7 Feb 2025 22:24:20 -0500
Subject: [PATCH 12/15] Add aria-label
---
webview-ui/src/components/history/HistoryPreview.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx
index 7825844e70..b2898fc6a8 100644
--- a/webview-ui/src/components/history/HistoryPreview.tsx
+++ b/webview-ui/src/components/history/HistoryPreview.tsx
@@ -122,6 +122,7 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
copyWithFeedback(item.task, e)}>
From 44e00fa9edf597bc49a58e7401b22f89f6f9ccb4 Mon Sep 17 00:00:00 2001
From: Roo Code
Date: Fri, 7 Feb 2025 22:25:40 -0500
Subject: [PATCH 13/15] Add changeset
---
.changeset/cyan-insects-marry.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/cyan-insects-marry.md
diff --git a/.changeset/cyan-insects-marry.md b/.changeset/cyan-insects-marry.md
new file mode 100644
index 0000000000..98dc450f7e
--- /dev/null
+++ b/.changeset/cyan-insects-marry.md
@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Add a copy button to the recent tasks
From 6e8153eae016b940703f069252b7d8f88b80f7fc Mon Sep 17 00:00:00 2001
From: Roo Code
Date: Sat, 8 Feb 2025 01:18:17 -0500
Subject: [PATCH 14/15] v3.3.15
---
.changeset/violet-rockets-fetch.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/violet-rockets-fetch.md
diff --git a/.changeset/violet-rockets-fetch.md b/.changeset/violet-rockets-fetch.md
new file mode 100644
index 0000000000..9da0face9a
--- /dev/null
+++ b/.changeset/violet-rockets-fetch.md
@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+v3.3.15
From 2bff8298166d75522d2852c4f3f2315fe377dde0 Mon Sep 17 00:00:00 2001
From: cte
Date: Fri, 7 Feb 2025 22:23:54 -0800
Subject: [PATCH 15/15] Checkpoints service
---
src/services/checkpoints/CheckpointService.ts | 317 ++++++++++++++++
.../__tests__/CheckpointService.test.ts | 337 ++++++++++++++++++
2 files changed, 654 insertions(+)
create mode 100644 src/services/checkpoints/CheckpointService.ts
create mode 100644 src/services/checkpoints/__tests__/CheckpointService.test.ts
diff --git a/src/services/checkpoints/CheckpointService.ts b/src/services/checkpoints/CheckpointService.ts
new file mode 100644
index 0000000000..af1d438e54
--- /dev/null
+++ b/src/services/checkpoints/CheckpointService.ts
@@ -0,0 +1,317 @@
+import fs from "fs/promises"
+import { existsSync } from "fs"
+import path from "path"
+
+import debug from "debug"
+import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
+
+if (process.env.NODE_ENV !== "test") {
+ debug.enable("simple-git")
+}
+
+export interface Checkpoint {
+ hash: string
+ message: string
+ timestamp?: Date
+}
+
+export type CheckpointServiceOptions = {
+ taskId: string
+ git?: SimpleGit
+ baseDir: string
+ log?: (message: string) => void
+}
+
+/**
+ * The CheckpointService provides a mechanism for storing a snapshot of the
+ * current VSCode workspace each time a Roo Code tool is executed. It uses Git
+ * under the hood.
+ *
+ * HOW IT WORKS
+ *
+ * Two branches are used:
+ * - A main branch for normal operation (the branch you are currently on).
+ * - A hidden branch for storing checkpoints.
+ *
+ * Saving a checkpoint:
+ * - Current changes are stashed (including untracked files).
+ * - The hidden branch is reset to match main.
+ * - Stashed changes are applied and committed as a checkpoint on the hidden
+ * branch.
+ * - We return to the main branch with the original state restored.
+ *
+ * Restoring a checkpoint:
+ * - The workspace is restored to the state of the specified checkpoint using
+ * `git restore` and `git clean`.
+ *
+ * This approach allows for:
+ * - Non-destructive version control (main branch remains untouched).
+ * - Preservation of the full history of checkpoints.
+ * - Safe restoration to any previous checkpoint.
+ *
+ * NOTES
+ *
+ * - Git must be installed.
+ * - If the current working directory is not a Git repository, we will
+ * initialize a new one with a .gitkeep file.
+ * - If you manually edit files and then restore a checkpoint, the changes
+ * will be lost. Addressing this adds some complexity to the implementation
+ * and it's not clear whether it's worth it.
+ */
+
+export class CheckpointService {
+ constructor(
+ public readonly taskId: string,
+ private readonly git: SimpleGit,
+ public readonly baseDir: string,
+ public readonly mainBranch: string,
+ public readonly baseCommitHash: string,
+ public readonly hiddenBranch: string,
+ private readonly log: (message: string) => void,
+ ) {}
+
+ private async pushStash() {
+ const status = await this.git.status()
+
+ if (status.files.length > 0) {
+ await this.git.stash(["-u"]) // Includes tracked and untracked files.
+ return true
+ }
+
+ return false
+ }
+
+ private async applyStash() {
+ const stashList = await this.git.stashList()
+
+ if (stashList.all.length > 0) {
+ await this.git.stash(["apply"]) // Applies the most recent stash only.
+ return true
+ }
+
+ return false
+ }
+
+ private async popStash() {
+ const stashList = await this.git.stashList()
+
+ if (stashList.all.length > 0) {
+ await this.git.stash(["pop", "--index"]) // Pops the most recent stash only.
+ return true
+ }
+
+ return false
+ }
+
+ private async ensureBranch(expectedBranch: string) {
+ const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
+
+ if (branch.trim() !== expectedBranch) {
+ throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
+ }
+ }
+
+ public async getDiff({ from, to }: { from?: string; to: string }) {
+ const result = []
+
+ if (!from) {
+ from = this.baseCommitHash
+ }
+
+ const { files } = await this.git.diffSummary([`${from}..${to}`])
+
+ for (const file of files.filter((f) => !f.binary)) {
+ const relPath = file.file
+ const absPath = path.join(this.baseDir, relPath)
+
+ // If modified both before and after will generate content.
+ // If added only after will generate content.
+ // If deleted only before will generate content.
+ let beforeContent = ""
+ let afterContent = ""
+
+ try {
+ beforeContent = await this.git.show([`${from}:${relPath}`])
+ } catch (err) {
+ // File doesn't exist in older commit.
+ }
+
+ try {
+ afterContent = await this.git.show([`${to}:${relPath}`])
+ } catch (err) {
+ // File doesn't exist in newer commit.
+ }
+
+ result.push({
+ paths: { relative: relPath, absolute: absPath },
+ content: { before: beforeContent, after: afterContent },
+ })
+ }
+
+ return result
+ }
+
+ public async saveCheckpoint(message: string) {
+ await this.ensureBranch(this.mainBranch)
+
+ // Attempt to stash pending changes (including untracked files).
+ const pendingChanges = await this.pushStash()
+
+ // Get the latest commit on the hidden branch before we reset it.
+ const latestHash = await this.git.revparse([this.hiddenBranch])
+
+ // Check if there is any diff relative to the latest commit.
+ if (!pendingChanges) {
+ const diff = await this.git.diff([latestHash])
+
+ if (!diff) {
+ this.log(`[saveCheckpoint] No changes detected, giving up`)
+ return undefined
+ }
+ }
+
+ await this.git.checkout(this.hiddenBranch)
+
+ const reset = async () => {
+ await this.git.reset(["HEAD", "."])
+ await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
+ await this.git.reset(["--hard", latestHash])
+ await this.git.checkout(this.mainBranch)
+ await this.popStash()
+ }
+
+ try {
+ // Reset hidden branch to match main and apply the pending changes.
+ await this.git.reset(["--hard", this.mainBranch])
+
+ if (pendingChanges) {
+ await this.applyStash()
+ }
+
+ // Using "-A" ensures that deletions are staged as well.
+ await this.git.add(["-A"])
+ const diff = await this.git.diff([latestHash])
+
+ if (!diff) {
+ this.log(`[saveCheckpoint] No changes detected, resetting and giving up`)
+ await reset()
+ return undefined
+ }
+
+ // Otherwise, commit the changes.
+ const status = await this.git.status()
+ this.log(`[saveCheckpoint] Changes detected, committing ${JSON.stringify(status)}`)
+
+ // Allow empty commits in order to correctly handle deletion of
+ // untracked files (see unit tests for an example of this).
+ // Additionally, skip pre-commit hooks so that they don't slow
+ // things down or tamper with the contents of the commit.
+ const commit = await this.git.commit(message, undefined, {
+ "--allow-empty": null,
+ "--no-verify": null,
+ })
+
+ await this.git.checkout(this.mainBranch)
+
+ if (pendingChanges) {
+ await this.popStash()
+ }
+
+ return commit
+ } catch (err) {
+ this.log(`[saveCheckpoint] Failed to save checkpoint: ${err instanceof Error ? err.message : String(err)}`)
+
+ // If we're not on the main branch then we need to trigger a reset
+ // to return to the main branch and restore it's previous state.
+ const currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
+
+ if (currentBranch.trim() !== this.mainBranch) {
+ await reset()
+ }
+
+ throw err
+ }
+ }
+
+ public async restoreCheckpoint(commitHash: string) {
+ await this.ensureBranch(this.mainBranch)
+ await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
+ await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
+ }
+
+ public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) {
+ git =
+ git ||
+ simpleGit({
+ baseDir,
+ binary: "git",
+ maxConcurrentProcesses: 1,
+ config: [],
+ trimmed: true,
+ })
+
+ const version = await git.version()
+
+ if (!version?.installed) {
+ throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
+ }
+
+ if (!baseDir || !existsSync(baseDir)) {
+ throw new Error(`Base directory is not set or does not exist.`)
+ }
+
+ const { currentBranch, currentSha, hiddenBranch } = await CheckpointService.initRepo({
+ taskId,
+ git,
+ baseDir,
+ log,
+ })
+
+ log(
+ `[CheckpointService] taskId = ${taskId}, baseDir = ${baseDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
+ )
+ return new CheckpointService(taskId, git, baseDir, currentBranch, currentSha, hiddenBranch, log)
+ }
+
+ private static async initRepo({ taskId, git, baseDir, log }: Required) {
+ const isExistingRepo = existsSync(path.join(baseDir, ".git"))
+
+ if (!isExistingRepo) {
+ await git.init()
+ log(`[initRepo] Initialized new Git repository at ${baseDir}`)
+ }
+
+ await git.addConfig("user.name", "Roo Code")
+ await git.addConfig("user.email", "support@roocode.com")
+
+ if (!isExistingRepo) {
+ // We need at least one file to commit, otherwise the initial
+ // commit will fail, unless we use the `--allow-empty` flag.
+ // However, using an empty commit causes problems when restoring
+ // the checkpoint (i.e. the `git restore` command doesn't work
+ // for empty commits).
+ await fs.writeFile(path.join(baseDir, ".gitkeep"), "")
+ await git.add(".")
+ const commit = await git.commit("Initial commit")
+
+ if (!commit.commit) {
+ throw new Error("Failed to create initial commit")
+ }
+
+ log(`[initRepo] Initial commit: ${commit.commit}`)
+ }
+
+ const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
+ const currentSha = await git.revparse(["HEAD"])
+
+ const hiddenBranch = `roo-code-checkpoints-${taskId}`
+ const branchSummary = await git.branch()
+
+ if (!branchSummary.all.includes(hiddenBranch)) {
+ await git.checkoutBranch(hiddenBranch, currentBranch) // git checkout -b
+ await git.checkout(currentBranch) // git checkout
+ }
+
+ return { currentBranch, currentSha, hiddenBranch }
+ }
+}
diff --git a/src/services/checkpoints/__tests__/CheckpointService.test.ts b/src/services/checkpoints/__tests__/CheckpointService.test.ts
new file mode 100644
index 0000000000..cd33a5dc7c
--- /dev/null
+++ b/src/services/checkpoints/__tests__/CheckpointService.test.ts
@@ -0,0 +1,337 @@
+// npx jest src/services/checkpoints/__tests__/CheckpointService.test.ts
+
+import fs from "fs/promises"
+import path from "path"
+import os from "os"
+
+import { simpleGit, SimpleGit } from "simple-git"
+
+import { CheckpointService } from "../CheckpointService"
+
+describe("CheckpointService", () => {
+ const taskId = "test-task"
+ let git: SimpleGit
+ let testFile: string
+ let service: CheckpointService
+
+ beforeEach(async () => {
+ // Create a temporary directory for testing.
+ const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-${Date.now()}`)
+ await fs.mkdir(baseDir)
+
+ // Initialize git repo.
+ git = simpleGit(baseDir)
+ await git.init()
+ await git.addConfig("user.name", "Roo Code")
+ await git.addConfig("user.email", "support@roo.vet")
+
+ // Create test file.
+ testFile = path.join(baseDir, "test.txt")
+ await fs.writeFile(testFile, "Hello, world!")
+
+ // Create initial commit.
+ await git.add(".")
+ await git.commit("Initial commit")!
+
+ // Create service instance.
+ const log = () => {}
+ service = await CheckpointService.create({ taskId, git, baseDir, log })
+ })
+
+ afterEach(async () => {
+ await fs.rm(service.baseDir, { recursive: true, force: true })
+ jest.restoreAllMocks()
+ })
+
+ describe("getDiff", () => {
+ it("returns the correct diff between commits", async () => {
+ await fs.writeFile(testFile, "Ahoy, world!")
+ const commit1 = await service.saveCheckpoint("First checkpoint")
+ expect(commit1?.commit).toBeTruthy()
+
+ await fs.writeFile(testFile, "Goodbye, world!")
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeTruthy()
+
+ const diff1 = await service.getDiff({ to: commit1!.commit })
+ expect(diff1).toHaveLength(1)
+ expect(diff1[0].paths.relative).toBe("test.txt")
+ expect(diff1[0].paths.absolute).toBe(testFile)
+ expect(diff1[0].content.before).toBe("Hello, world!")
+ expect(diff1[0].content.after).toBe("Ahoy, world!")
+
+ const diff2 = await service.getDiff({ to: commit2!.commit })
+ expect(diff2).toHaveLength(1)
+ expect(diff2[0].paths.relative).toBe("test.txt")
+ expect(diff2[0].paths.absolute).toBe(testFile)
+ expect(diff2[0].content.before).toBe("Hello, world!")
+ expect(diff2[0].content.after).toBe("Goodbye, world!")
+
+ const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
+ expect(diff12).toHaveLength(1)
+ expect(diff12[0].paths.relative).toBe("test.txt")
+ expect(diff12[0].paths.absolute).toBe(testFile)
+ expect(diff12[0].content.before).toBe("Ahoy, world!")
+ expect(diff12[0].content.after).toBe("Goodbye, world!")
+ })
+
+ it("handles new files in diff", async () => {
+ const newFile = path.join(service.baseDir, "new.txt")
+ await fs.writeFile(newFile, "New file content")
+ const commit = await service.saveCheckpoint("Add new file")
+ expect(commit?.commit).toBeTruthy()
+
+ const changes = await service.getDiff({ to: commit!.commit })
+ const change = changes.find((c) => c.paths.relative === "new.txt")
+ expect(change).toBeDefined()
+ expect(change?.content.before).toBe("")
+ expect(change?.content.after).toBe("New file content")
+ })
+
+ it("handles deleted files in diff", async () => {
+ const fileToDelete = path.join(service.baseDir, "new.txt")
+ await fs.writeFile(fileToDelete, "New file content")
+ const commit1 = await service.saveCheckpoint("Add file")
+ expect(commit1?.commit).toBeTruthy()
+
+ await fs.unlink(fileToDelete)
+ const commit2 = await service.saveCheckpoint("Delete file")
+ expect(commit2?.commit).toBeTruthy()
+
+ const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
+ const change = changes.find((c) => c.paths.relative === "new.txt")
+ expect(change).toBeDefined()
+ expect(change!.content.before).toBe("New file content")
+ expect(change!.content.after).toBe("")
+ })
+ })
+
+ describe("saveCheckpoint", () => {
+ it("creates a checkpoint if there are pending changes", async () => {
+ await fs.writeFile(testFile, "Ahoy, world!")
+ const commit1 = await service.saveCheckpoint("First checkpoint")
+ expect(commit1?.commit).toBeTruthy()
+ const details1 = await git.show([commit1!.commit])
+ expect(details1).toContain("-Hello, world!")
+ expect(details1).toContain("+Ahoy, world!")
+
+ await fs.writeFile(testFile, "Hola, world!")
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeTruthy()
+ const details2 = await git.show([commit2!.commit])
+ expect(details2).toContain("-Hello, world!")
+ expect(details2).toContain("+Hola, world!")
+
+ // Switch to checkpoint 1.
+ await service.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
+
+ // Switch to checkpoint 2.
+ await service.restoreCheckpoint(commit2!.commit)
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
+
+ // Switch back to initial commit.
+ await service.restoreCheckpoint(service.baseCommitHash)
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
+ })
+
+ it("preserves workspace and index state after saving checkpoint", async () => {
+ // Create three files with different states: staged, unstaged, and mixed.
+ const unstagedFile = path.join(service.baseDir, "unstaged.txt")
+ const stagedFile = path.join(service.baseDir, "staged.txt")
+ const mixedFile = path.join(service.baseDir, "mixed.txt")
+
+ await fs.writeFile(unstagedFile, "Initial unstaged")
+ await fs.writeFile(stagedFile, "Initial staged")
+ await fs.writeFile(mixedFile, "Initial mixed")
+ await git.add(["."])
+ const result = await git.commit("Add initial files")
+ expect(result?.commit).toBeTruthy()
+
+ await fs.writeFile(unstagedFile, "Modified unstaged")
+
+ await fs.writeFile(stagedFile, "Modified staged")
+ await git.add([stagedFile])
+
+ await fs.writeFile(mixedFile, "Modified mixed - staged")
+ await git.add([mixedFile])
+ await fs.writeFile(mixedFile, "Modified mixed - unstaged")
+
+ // Save checkpoint.
+ const commit = await service.saveCheckpoint("Test checkpoint")
+ expect(commit?.commit).toBeTruthy()
+
+ // Verify workspace state is preserved.
+ const status = await git.status()
+
+ // All files should be modified.
+ expect(status.modified).toContain("unstaged.txt")
+ expect(status.modified).toContain("staged.txt")
+ expect(status.modified).toContain("mixed.txt")
+
+ // Only staged and mixed files should be staged.
+ expect(status.staged).not.toContain("unstaged.txt")
+ expect(status.staged).toContain("staged.txt")
+ expect(status.staged).toContain("mixed.txt")
+
+ // Verify file contents.
+ expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged")
+ expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged")
+ expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged")
+
+ // Verify staged changes (--cached shows only staged changes).
+ const stagedDiff = await git.diff(["--cached", "mixed.txt"])
+ expect(stagedDiff).toContain("-Initial mixed")
+ expect(stagedDiff).toContain("+Modified mixed - staged")
+
+ // Verify unstaged changes (shows working directory changes).
+ const unstagedDiff = await git.diff(["mixed.txt"])
+ expect(unstagedDiff).toContain("-Modified mixed - staged")
+ expect(unstagedDiff).toContain("+Modified mixed - unstaged")
+ })
+
+ it("does not create a checkpoint if there are no pending changes", async () => {
+ await fs.writeFile(testFile, "Ahoy, world!")
+ const commit = await service.saveCheckpoint("First checkpoint")
+ expect(commit?.commit).toBeTruthy()
+
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeFalsy()
+ })
+
+ it("includes untracked files in checkpoints", async () => {
+ // Create an untracked file.
+ const untrackedFile = path.join(service.baseDir, "untracked.txt")
+ await fs.writeFile(untrackedFile, "I am untracked!")
+
+ // Save a checkpoint with the untracked file.
+ const commit1 = await service.saveCheckpoint("Checkpoint with untracked file")
+ expect(commit1?.commit).toBeTruthy()
+
+ // Verify the untracked file was included in the checkpoint.
+ const details = await git.show([commit1!.commit])
+ expect(details).toContain("+I am untracked!")
+
+ // Create another checkpoint with a different state.
+ await fs.writeFile(testFile, "Changed tracked file")
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeTruthy()
+
+ // Restore first checkpoint and verify untracked file is preserved.
+ await service.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
+
+ // Restore second checkpoint and verify untracked file remains (since
+ // restore preserves untracked files)
+ await service.restoreCheckpoint(commit2!.commit)
+ expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+ expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file")
+ })
+
+ it("throws if we're on the wrong branch", async () => {
+ // Create and switch to a feature branch.
+ await git.checkoutBranch("feature", service.mainBranch)
+
+ // Attempt to save checkpoint from feature branch.
+ await expect(service.saveCheckpoint("test")).rejects.toThrow(
+ `Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`,
+ )
+
+ // Attempt to restore checkpoint from feature branch.
+ await expect(service.restoreCheckpoint(service.baseCommitHash)).rejects.toThrow(
+ `Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`,
+ )
+ })
+
+ it("cleans up staged files if a commit fails", async () => {
+ await fs.writeFile(testFile, "Changed content")
+
+ // Mock git commit to simulate failure.
+ jest.spyOn(git, "commit").mockRejectedValue(new Error("Simulated commit failure"))
+
+ // Attempt to save checkpoint.
+ await expect(service.saveCheckpoint("test")).rejects.toThrow("Simulated commit failure")
+
+ // Verify files are unstaged.
+ const status = await git.status()
+ expect(status.staged).toHaveLength(0)
+ })
+
+ it("handles file deletions correctly", async () => {
+ await fs.writeFile(testFile, "I am tracked!")
+ const untrackedFile = path.join(service.baseDir, "new.txt")
+ await fs.writeFile(untrackedFile, "I am untracked!")
+ const commit1 = await service.saveCheckpoint("First checkpoint")
+ expect(commit1?.commit).toBeTruthy()
+
+ await fs.unlink(testFile)
+ await fs.unlink(untrackedFile)
+ const commit2 = await service.saveCheckpoint("Second checkpoint")
+ expect(commit2?.commit).toBeTruthy()
+
+ // Verify files are gone.
+ await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
+ await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
+
+ // Restore first checkpoint.
+ await service.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!")
+ expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
+
+ // Restore second checkpoint.
+ await service.restoreCheckpoint(commit2!.commit)
+ await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
+ await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
+ })
+ })
+
+ describe("create", () => {
+ it("initializes a git repository if one does not already exist", async () => {
+ const baseDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`)
+ await fs.mkdir(baseDir)
+ const newTestFile = path.join(baseDir, "test.txt")
+
+ const newGit = simpleGit(baseDir)
+ const initSpy = jest.spyOn(newGit, "init")
+ const newService = await CheckpointService.create({ taskId, git: newGit, baseDir, log: () => {} })
+
+ // Ensure the git repository was initialized.
+ expect(initSpy).toHaveBeenCalled()
+
+ // Save a checkpoint: Hello, world!
+ await fs.writeFile(newTestFile, "Hello, world!")
+ const commit1 = await newService.saveCheckpoint("Hello, world!")
+ expect(commit1?.commit).toBeTruthy()
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+ // Restore initial commit; the file should no longer exist.
+ await newService.restoreCheckpoint(newService.baseCommitHash)
+ await expect(fs.access(newTestFile)).rejects.toThrow()
+
+ // Restore to checkpoint 1; the file should now exist.
+ await newService.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+ // Save a new checkpoint: Ahoy, world!
+ await fs.writeFile(newTestFile, "Ahoy, world!")
+ const commit2 = await newService.saveCheckpoint("Ahoy, world!")
+ expect(commit2?.commit).toBeTruthy()
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
+
+ // Restore "Hello, world!"
+ await newService.restoreCheckpoint(commit1!.commit)
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
+
+ // Restore "Ahoy, world!"
+ await newService.restoreCheckpoint(commit2!.commit)
+ expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
+
+ // Restore initial commit.
+ await newService.restoreCheckpoint(newService.baseCommitHash)
+ await expect(fs.access(newTestFile)).rejects.toThrow()
+
+ await fs.rm(newService.baseDir, { recursive: true, force: true })
+ })
+ })
+})