Skip to content

Commit db16877

Browse files
committed
Add local checkpointer with options
1 parent 12b462e commit db16877

File tree

2 files changed

+329
-0
lines changed

2 files changed

+329
-0
lines changed
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { existsSync } from "fs"
2+
import path from "path"
3+
4+
import simpleGit, { SimpleGit, VersionResult, SimpleGitOptions } from "simple-git"
5+
6+
export interface Checkpoint {
7+
hash: string
8+
message: string
9+
timestamp?: Date
10+
}
11+
12+
export type CheckpointerOptions = {
13+
workspacePath: string
14+
mainBranch: string
15+
hiddenBranch: string
16+
}
17+
18+
export class LocalCheckpointer {
19+
public readonly workspacePath: string
20+
public readonly mainBranch: string
21+
public readonly hiddenBranch: string
22+
private git: SimpleGit
23+
public gitVersion?: VersionResult
24+
25+
public static async create(options: CheckpointerOptions) {
26+
const checkpointer = new LocalCheckpointer(options)
27+
await checkpointer.ensureGitInstalled()
28+
await checkpointer.ensureGitRepo()
29+
await checkpointer.initHiddenBranch()
30+
return checkpointer
31+
}
32+
33+
constructor({ workspacePath, mainBranch, hiddenBranch }: CheckpointerOptions) {
34+
this.workspacePath = workspacePath
35+
this.mainBranch = mainBranch
36+
this.hiddenBranch = hiddenBranch
37+
38+
const options: SimpleGitOptions = {
39+
baseDir: workspacePath,
40+
binary: "git",
41+
maxConcurrentProcesses: 1,
42+
config: [],
43+
trimmed: true,
44+
}
45+
46+
this.git = simpleGit(options)
47+
}
48+
49+
/**
50+
* Initialize git configuration. Should be called after constructor.
51+
*/
52+
private async initGitConfig(): Promise<void> {
53+
try {
54+
await this.git.addConfig("user.name", "Roo Code")
55+
await this.git.addConfig("user.email", "[email protected]")
56+
} catch (err) {
57+
throw new Error(`Failed to configure Git: ${err instanceof Error ? err.message : String(err)}`)
58+
}
59+
}
60+
61+
/**
62+
* Ensure that Git is installed.
63+
*/
64+
private async ensureGitInstalled() {
65+
try {
66+
this.gitVersion = await this.git.version()
67+
68+
if (!this.gitVersion?.installed) {
69+
throw new Error()
70+
}
71+
} catch (err) {
72+
throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
73+
}
74+
}
75+
76+
/**
77+
* Checks if .git directory exists. If not, either throw or initialize Git.
78+
*/
79+
private async ensureGitRepo() {
80+
const gitDir = path.join(this.workspacePath, ".git")
81+
const isGitRepo = existsSync(gitDir)
82+
83+
if (!isGitRepo) {
84+
throw new Error(`No .git directory found at ${gitDir}. Please initialize a Git repository first.`)
85+
}
86+
}
87+
88+
private async pushStash() {
89+
try {
90+
const status = await this.git.status()
91+
92+
if (status.files.length > 0) {
93+
await this.git.stash() // This stashes both tracked and untracked files by default.
94+
return true
95+
} else {
96+
return undefined
97+
}
98+
} catch (err) {
99+
return false
100+
}
101+
}
102+
103+
private async applyStash() {
104+
try {
105+
const stashList = await this.git.stashList()
106+
107+
if (stashList.all.length > 0) {
108+
await this.git.stash(["apply"]) // Apply the most recent stash.
109+
return true
110+
} else {
111+
return undefined
112+
}
113+
} catch (err) {
114+
return false
115+
}
116+
}
117+
118+
private async popStash() {
119+
try {
120+
const stashList = await this.git.stashList()
121+
122+
if (stashList.all.length > 0) {
123+
await this.git.stash(["pop"]) // Pop the most recent stash.
124+
return true
125+
} else {
126+
return undefined
127+
}
128+
} catch (err) {
129+
return false
130+
}
131+
}
132+
133+
private async dropStash() {
134+
try {
135+
const stashList = await this.git.stashList()
136+
137+
if (stashList.all.length > 0) {
138+
await this.git.stash(["drop", "0"]) // Drop the most recent stash.
139+
return true
140+
} else {
141+
return undefined
142+
}
143+
} catch (err) {
144+
return false
145+
}
146+
}
147+
148+
/**
149+
* Create the hidden branch if it doesn't exist. Otherwise, do nothing.
150+
* If the branch is missing, we base it off the main branch.
151+
*/
152+
private async initHiddenBranch(): Promise<void> {
153+
// Check if the branch already exists.
154+
const branchSummary = await this.git.branch()
155+
156+
if (!branchSummary.all.includes(this.hiddenBranch)) {
157+
// Create the new branch from main.
158+
await this.git.checkoutBranch(this.hiddenBranch, this.mainBranch)
159+
160+
// Switch back to main.
161+
await this.git.checkout(this.mainBranch)
162+
}
163+
}
164+
165+
/**
166+
* List commits on the hidden branch as checkpoints.
167+
* We can parse the commit log to build an array of `Checkpoint`.
168+
*/
169+
public async listCheckpoints(): Promise<Checkpoint[]> {
170+
const log = await this.git.log({ "--all": null, "--branches": this.hiddenBranch })
171+
172+
return log.all.map((commit) => ({
173+
hash: commit.hash,
174+
message: commit.message,
175+
timestamp: commit.date ? new Date(commit.date) : undefined,
176+
}))
177+
}
178+
179+
/**
180+
* Commit changes in the working directory (on the hidden branch) as a new checkpoint.\
181+
* Preserves the current state of the main branch.
182+
*/
183+
public async saveCheckpoint(message: string) {
184+
const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
185+
186+
if (branch.trim() !== this.mainBranch) {
187+
throw new Error(`Must be on ${this.mainBranch} branch to save checkpoints. Currently on: ${branch}`)
188+
}
189+
190+
const pendingChanges = await this.pushStash()
191+
192+
if (!pendingChanges) {
193+
return undefined
194+
}
195+
196+
try {
197+
await this.git.checkout(this.hiddenBranch)
198+
await this.git.reset(["--hard", this.mainBranch]) // Reset hidden branch to match main
199+
await this.applyStash() // Apply the stashed changes
200+
await this.git.add(["."]) // Stage everything
201+
const commit = await this.git.commit(message)
202+
await this.git.checkout(this.mainBranch)
203+
await this.popStash()
204+
return commit
205+
} catch (err) {
206+
// Ensure we return to main branch and pop stash even if something fails.
207+
// @TODO: Disable checkpointing since we encountered an error.
208+
await this.git.checkout(this.mainBranch)
209+
await this.popStash()
210+
throw err
211+
}
212+
}
213+
214+
/**
215+
* Revert the workspace to a specific commit by resetting the hidden branch to
216+
* that commit.
217+
*/
218+
public async restoreCheckpoint(commitHash: string) {
219+
const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
220+
221+
if (branch.trim() !== this.mainBranch) {
222+
throw new Error(`Must be on ${this.mainBranch} branch to restore checkpoints. Currently on: ${branch}`)
223+
}
224+
225+
// Discard any pending changes. Note that these should already be preserved
226+
// as a checkpoint, but we should verify that.
227+
await this.pushStash()
228+
await this.dropStash()
229+
230+
await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
231+
}
232+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// npx jest src/integrations/checkpoints/__tests__/LocalCheckpointer.test.ts
2+
3+
import fs from "fs/promises"
4+
import path from "path"
5+
import os from "os"
6+
7+
import { CommitResult, simpleGit, SimpleGit } from "simple-git"
8+
9+
import { LocalCheckpointer } from "../LocalCheckpointer"
10+
11+
describe("LocalCheckpointer", () => {
12+
let checkpointer: LocalCheckpointer
13+
let tempDir: string
14+
let git: SimpleGit
15+
let testFile: string
16+
let initialCommit: CommitResult
17+
18+
beforeEach(async () => {
19+
// Create a temporary directory for testing.
20+
tempDir = path.join(os.tmpdir(), `checkpointer-test-${Date.now()}`)
21+
await fs.mkdir(tempDir)
22+
console.log(tempDir)
23+
24+
// Initialize git repo.
25+
git = simpleGit(tempDir)
26+
await git.init()
27+
await git.addConfig("user.name", "Roo Code")
28+
await git.addConfig("user.email", "[email protected]")
29+
30+
// Create test file.
31+
testFile = path.join(tempDir, "test.txt")
32+
await fs.writeFile(testFile, "Hello, world!")
33+
34+
// Create initial commit.
35+
await git.add(".")
36+
initialCommit = await git.commit("Initial commit")!
37+
38+
// Create checkpointer instance.
39+
checkpointer = await LocalCheckpointer.create({
40+
workspacePath: tempDir,
41+
mainBranch: "main",
42+
hiddenBranch: "checkpoints",
43+
})
44+
})
45+
46+
afterEach(async () => {
47+
// Clean up temporary directory.
48+
await fs.rm(tempDir, { recursive: true, force: true })
49+
})
50+
51+
it("creates a hidden branch on initialization", async () => {
52+
const branches = await git.branch()
53+
expect(branches.all).toContain("checkpoints")
54+
})
55+
56+
it("saves and lists checkpoints", async () => {
57+
const commitMessage = "Test checkpoint"
58+
59+
await fs.writeFile(testFile, "Ahoy, world!")
60+
const commit = await checkpointer.saveCheckpoint(commitMessage)
61+
expect(commit?.commit).toBeTruthy()
62+
63+
const checkpoints = await checkpointer.listCheckpoints()
64+
expect(checkpoints.length).toBeGreaterThan(0)
65+
expect(checkpoints[0].message).toBe(commitMessage)
66+
expect(checkpoints[0].hash).toBe(commit?.commit)
67+
})
68+
69+
it("saves and restores checkpoints", async () => {
70+
await fs.writeFile(testFile, "Ahoy, world!")
71+
const commit1 = await checkpointer.saveCheckpoint("First checkpoint")
72+
expect(commit1?.commit).toBeTruthy()
73+
const details1 = await git.show([commit1!.commit])
74+
expect(details1).toContain("-Hello, world!")
75+
expect(details1).toContain("+Ahoy, world!")
76+
77+
await fs.writeFile(testFile, "Hola, world!")
78+
const commit2 = await checkpointer.saveCheckpoint("Second checkpoint")
79+
expect(commit2?.commit).toBeTruthy()
80+
const details2 = await git.show([commit2!.commit])
81+
console.log(details2)
82+
expect(details2).toContain("-Hello, world!")
83+
expect(details2).toContain("+Hola, world!")
84+
85+
// Switch to checkpoint 1.
86+
await checkpointer.restoreCheckpoint(commit1!.commit)
87+
expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
88+
89+
// Switch to checkpoint 2.
90+
await checkpointer.restoreCheckpoint(commit2!.commit)
91+
expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
92+
93+
// Switch back to initial commit.
94+
await checkpointer.restoreCheckpoint(initialCommit.commit)
95+
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
96+
})
97+
})

0 commit comments

Comments
 (0)