-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathShadowCheckpointService.ts
More file actions
553 lines (456 loc) · 17.6 KB
/
Copy pathShadowCheckpointService.ts
File metadata and controls
553 lines (456 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import crypto from "crypto"
import EventEmitter from "events"
import simpleGit, { SimpleGit, SimpleGitOptions } from "simple-git"
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { executeRipgrep } from "../../services/search/file-search"
import { t } from "../../i18n"
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
import { getExcludePatterns } from "./excludes"
/**
* Environment variables stripped before passing the env to simple-git.
*
* Two categories:
* - Location-override vars (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY,
* GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_CEILING_DIRECTORIES): redirect git operations to
* unintended repositories or limit where git searches.
* - Code-execution vectors blocked by simple-git ≥3.36's blockUnsafeOperationsPlugin when
* passed via .env(): GIT_EDITOR, GIT_SSH_COMMAND, GIT_PAGER, PREFIX, etc.
*
* Stripping GIT_CONFIG_COUNT also neutralises the entire GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
* family — git ignores those per-key entries when the count key is absent.
*/
export const BLOCKED_ENV_KEYS = new Set([
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CEILING_DIRECTORIES",
"GIT_TEMPLATE_DIR",
"GIT_EDITOR",
"GIT_SEQUENCE_EDITOR",
"GIT_ASKPASS",
"GIT_SSH",
"GIT_SSH_COMMAND",
"GIT_PAGER",
"GIT_PROXY_COMMAND",
"GIT_EXEC_PATH",
"GIT_EXTERNAL_DIFF",
"GIT_CONFIG",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_COUNT",
"PREFIX",
"EDITOR",
"PAGER",
"SSH_ASKPASS",
])
// Lowercase set for case-insensitive lookup — the plugin uses toLowerCase() internally,
// so a var like Git_Editor would bypass an exact-match check on Linux.
const BLOCKED_ENV_KEYS_LOWER = new Set([...BLOCKED_ENV_KEYS].map((k) => k.toLowerCase()))
/**
* Creates a SimpleGit instance with sanitized environment variables to prevent
* interference from inherited git environment variables like GIT_DIR and GIT_WORK_TREE.
* This ensures checkpoint operations always target the intended shadow repository.
*
* @param baseDir - The directory where git operations should be executed
* @returns A SimpleGit instance with sanitized environment
*/
function createSanitizedGit(baseDir: string): SimpleGit {
const sanitizedEnv: Record<string, string> = {}
const removedKeys: string[] = []
for (const [key, value] of Object.entries(process.env)) {
if (BLOCKED_ENV_KEYS_LOWER.has(key.toLowerCase())) {
removedKeys.push(key)
continue
}
if (value !== undefined) {
sanitizedEnv[key] = value
}
}
// Log which git env vars were removed (helps with debugging Dev Container issues)
if (removedKeys.length > 0) {
console.log(
`[createSanitizedGit] Removed git environment variables for checkpoint isolation: ${removedKeys.join(", ")}`,
)
}
const options: Partial<SimpleGitOptions> = {
baseDir,
config: [],
// --template="" stops git copying hooks/templates into the shadow repo (axis 1).
// GIT_TEMPLATE_DIR is stripped from the env above to block the env-var path (axis 2).
// allowUnsafeTemplateDir opts out of simple-git ≥3.36's blockUnsafeOperationsPlugin
// so the --template arg is not rejected before reaching git.
unsafe: { allowUnsafeTemplateDir: true },
}
// Create git instance and set the sanitized environment
const git = simpleGit(options)
// Use the .env() method to set the complete sanitized environment
// This replaces the inherited environment with our sanitized version
git.env(sanitizedEnv)
console.log(`[createSanitizedGit] Created git instance for baseDir: ${baseDir}`)
return git
}
export abstract class ShadowCheckpointService extends EventEmitter {
public readonly taskId: string
public readonly checkpointsDir: string
public readonly workspaceDir: string
protected _checkpoints: string[] = []
protected _baseHash?: string
protected readonly dotGitDir: string
protected git?: SimpleGit
protected readonly log: (message: string) => void
protected shadowGitConfigWorktree?: string
public get baseHash() {
return this._baseHash
}
protected set baseHash(value: string | undefined) {
this._baseHash = value
}
public get isInitialized() {
return !!this.git
}
public getCheckpoints(): string[] {
return this._checkpoints.slice()
}
constructor(taskId: string, checkpointsDir: string, workspaceDir: string, log: (message: string) => void) {
super()
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath]
if (protectedPaths.includes(workspaceDir)) {
throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
}
this.taskId = taskId
this.checkpointsDir = checkpointsDir
this.workspaceDir = workspaceDir
this.dotGitDir = path.join(this.checkpointsDir, ".git")
this.log = log
}
public async initShadowGit(onInit?: () => Promise<void>) {
if (this.git) {
throw new Error("Shadow git repo already initialized")
}
const nestedGitPath = await this.getNestedGitRepository()
if (nestedGitPath) {
// Show persistent error message with the offending path
const relativePath = path.relative(this.workspaceDir, nestedGitPath)
const message = t("common:errors.nested_git_repos_warning", { path: relativePath })
vscode.window.showErrorMessage(message)
throw new Error(
`Checkpoints are disabled because a nested git repository was detected at: ${relativePath}. ` +
"Please remove or relocate nested git repositories to use the checkpoints feature.",
)
}
await fs.mkdir(this.checkpointsDir, { recursive: true })
const git = createSanitizedGit(this.checkpointsDir)
const gitVersion = await git.version()
this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
let created = false
const startTime = Date.now()
if (await fileExistsAtPath(this.dotGitDir)) {
this.log(`[${this.constructor.name}#initShadowGit] shadow git repo already exists at ${this.dotGitDir}`)
const worktree = await this.getShadowGitConfigWorktree(git)
if (!worktree) {
throw new Error("Checkpoints require core.worktree to be set in the shadow git config")
}
const worktreeTrimmed = worktree.trim()
if (!arePathsEqual(worktreeTrimmed, this.workspaceDir)) {
throw new Error(
`Checkpoints can only be used in the original workspace: ${worktreeTrimmed} !== ${this.workspaceDir}`,
)
}
await this.writeExcludeFile()
this.baseHash = await git.revparse(["HEAD"])
} else {
this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
await git.init({ "--template": "" })
await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
await git.addConfig("user.name", "Roo Code")
await git.addConfig("user.email", "noreply@example.com")
await this.writeExcludeFile()
await this.stageAll(git)
const { commit } = await git.commit("initial commit", { "--allow-empty": null })
this.baseHash = commit
created = true
}
const duration = Date.now() - startTime
this.log(
`[${this.constructor.name}#initShadowGit] initialized shadow repo with base commit ${this.baseHash} in ${duration}ms`,
)
this.git = git
await onInit?.()
this.emit("initialize", {
type: "initialize",
workspaceDir: this.workspaceDir,
baseHash: this.baseHash,
created,
duration,
})
return { created, duration }
}
// Add basic excludes directly in git config, while respecting any
// .gitignore in the workspace.
// .git/info/exclude is local to the shadow git repo, so it's not
// shared with the main repo - and won't conflict with user's
// .gitignore.
protected async writeExcludeFile() {
await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
const patterns = await getExcludePatterns(this.workspaceDir)
await fs.writeFile(path.join(this.dotGitDir, "info", "exclude"), patterns.join("\n"))
}
private async stageAll(git: SimpleGit) {
try {
await git.add([".", "--ignore-errors"])
} catch (error) {
this.log(
`[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
private async getNestedGitRepository(): Promise<string | null> {
try {
// Find all .git/HEAD files that are not at the root level.
const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]
const gitPaths = await executeRipgrep({ args, workspacePath: this.workspaceDir })
// Filter to only include nested git directories (not the root .git).
// Since we're searching for HEAD files, we expect type to be "file"
const nestedGitPaths = gitPaths.filter(({ type, path: filePath }) => {
// Check if it's a file and is a nested .git/HEAD (not at root)
if (type !== "file") return false
// Ensure it's a .git/HEAD file and not the root one
const normalizedPath = filePath.replace(/\\/g, "/")
return (
normalizedPath.includes(".git/HEAD") &&
!normalizedPath.startsWith(".git/") &&
normalizedPath !== ".git/HEAD"
)
})
if (nestedGitPaths.length > 0) {
// Get the first nested git repository path
// Remove .git/HEAD from the path to get the repository directory
const headPath = nestedGitPaths[0].path
// Use path module to properly extract the repository directory
// The HEAD file is at .git/HEAD, so we need to go up two directories
const gitDir = path.dirname(headPath) // removes HEAD, gives us .git
const repoDir = path.dirname(gitDir) // removes .git, gives us the repo directory
const absolutePath = path.join(this.workspaceDir, repoDir)
this.log(
`[${this.constructor.name}#getNestedGitRepository] found ${nestedGitPaths.length} nested git repositories, first at: ${repoDir}`,
)
return absolutePath
}
return null
} catch (error) {
this.log(
`[${this.constructor.name}#getNestedGitRepository] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
)
// If we can't check, assume there are no nested repos to avoid blocking the feature.
return null
}
}
private async getShadowGitConfigWorktree(git: SimpleGit) {
if (!this.shadowGitConfigWorktree) {
try {
this.shadowGitConfigWorktree = (await git.getConfig("core.worktree")).value || undefined
} catch (error) {
this.log(
`[${this.constructor.name}#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
return this.shadowGitConfigWorktree
}
public async saveCheckpoint(
message: string,
options?: { allowEmpty?: boolean; suppressMessage?: boolean },
): Promise<CheckpointResult | undefined> {
try {
this.log(
`[${this.constructor.name}#saveCheckpoint] starting checkpoint save (allowEmpty: ${options?.allowEmpty ?? false})`,
)
if (!this.git) {
throw new Error("Shadow git repo not initialized")
}
const startTime = Date.now()
await this.stageAll(this.git)
const commitArgs = options?.allowEmpty ? { "--allow-empty": null } : undefined
const result = await this.git.commit(message, commitArgs)
const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
const toHash = result.commit || fromHash
this._checkpoints.push(toHash)
const duration = Date.now() - startTime
if (result.commit) {
this.emit("checkpoint", {
type: "checkpoint",
fromHash,
toHash,
duration,
suppressMessage: options?.suppressMessage ?? false,
})
}
if (result.commit) {
this.log(
`[${this.constructor.name}#saveCheckpoint] checkpoint saved in ${duration}ms -> ${result.commit}`,
)
return result
} else {
this.log(`[${this.constructor.name}#saveCheckpoint] found no changes to commit in ${duration}ms`)
return undefined
}
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.log(`[${this.constructor.name}#saveCheckpoint] failed to create checkpoint: ${error.message}`)
this.emit("error", { type: "error", error })
throw error
}
}
public async restoreCheckpoint(commitHash: string) {
try {
this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`)
if (!this.git) {
throw new Error("Shadow git repo not initialized")
}
const start = Date.now()
await this.git.clean("f", ["-d", "-f"])
await this.git.reset(["--hard", commitHash])
// Remove all checkpoints after the specified commitHash.
const checkpointIndex = this._checkpoints.indexOf(commitHash)
if (checkpointIndex !== -1) {
this._checkpoints = this._checkpoints.slice(0, checkpointIndex + 1)
}
const duration = Date.now() - start
this.emit("restore", { type: "restore", commitHash, duration })
this.log(`[${this.constructor.name}#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.log(`[${this.constructor.name}#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
this.emit("error", { type: "error", error })
throw error
}
}
public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
if (!this.git) {
throw new Error("Shadow git repo not initialized")
}
const result = []
if (!from) {
from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim()
}
// Stage all changes so that untracked files appear in diff summary.
await this.stageAll(this.git)
this.log(`[${this.constructor.name}#getDiff] diffing ${to ? `${from}..${to}` : `${from}..HEAD`}`)
const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from])
const cwdPath = (await this.getShadowGitConfigWorktree(this.git)) || this.workspaceDir || ""
for (const file of files) {
const relPath = file.file
const absPath = path.join(cwdPath, relPath)
const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
const after = to
? await this.git.show([`${to}:${relPath}`]).catch(() => "")
: await fs.readFile(absPath, "utf8").catch(() => "")
result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
}
return result
}
/**
* EventEmitter
*/
override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]) {
return super.emit(event, data)
}
override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
return super.on(event, listener)
}
override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
return super.off(event, listener)
}
override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
return super.once(event, listener)
}
/**
* Storage
*/
public static hashWorkspaceDir(workspaceDir: string) {
return crypto.createHash("sha256").update(workspaceDir).digest("hex").toString().slice(0, 8)
}
protected static taskRepoDir({ taskId, globalStorageDir }: { taskId: string; globalStorageDir: string }) {
return path.join(globalStorageDir, "tasks", taskId, "checkpoints")
}
protected static workspaceRepoDir({
globalStorageDir,
workspaceDir,
}: {
globalStorageDir: string
workspaceDir: string
}) {
return path.join(globalStorageDir, "checkpoints", this.hashWorkspaceDir(workspaceDir))
}
public static async deleteTask({
taskId,
globalStorageDir,
workspaceDir,
}: {
taskId: string
globalStorageDir: string
workspaceDir: string
}) {
const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
const branchName = `roo-${taskId}`
const git = createSanitizedGit(workspaceRepoDir)
const success = await this.deleteBranch(git, branchName)
if (success) {
console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`)
} else {
console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`)
}
}
public static async deleteBranch(git: SimpleGit, branchName: string) {
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.error(`[${this.constructor.name}#deleteBranch] branch ${branchName} does not exist`)
return false
}
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
if (currentBranch === branchName) {
const worktree = await git.getConfig("core.worktree")
try {
await git.raw(["config", "--unset", "core.worktree"])
await git.reset(["--hard"])
await git.clean("f", ["-d"])
const defaultBranch = branches.all.includes("main") ? "main" : "master"
await git.checkout([defaultBranch, "--force"])
await pWaitFor(
async () => {
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
return newBranch === defaultBranch
},
{ interval: 500, timeout: 2_000 },
)
await git.branch(["-D", branchName])
return true
} catch (error) {
console.error(
`[${this.constructor.name}#deleteBranch] failed to delete branch ${branchName}: ${error instanceof Error ? error.message : String(error)}`,
)
return false
} finally {
if (worktree.value) {
await git.addConfig("core.worktree", worktree.value)
}
}
} else {
await git.branch(["-D", branchName])
return true
}
}
}