Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 137 additions & 4 deletions src/services/checkpoints/ShadowCheckpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
import { getExcludePatterns } from "./excludes"

// Stored in the commit itself so an interrupted sidecar write cannot look like a legacy checkpoint.
const IGNORED_RECORD_HEADER = "Zoo-Checkpoint-Ignored: v1"

Check warning on line 20 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:20: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.

Check warning on line 20 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:20: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.

Check warning on line 20 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:20: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.

/**
* Environment variables stripped before passing the env to simple-git.
*
Expand Down Expand Up @@ -216,8 +219,9 @@
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 })
const { commit } = await git.commit(`initial commit\n\n${IGNORED_RECORD_HEADER}`, { "--allow-empty": null })
this.baseHash = commit
await this.recordIgnoredPaths(git, commit)
created = true
}

Expand Down Expand Up @@ -263,6 +267,115 @@
}
}

// Ignore rules are read from the live workspace, so a .gitignore changed after a checkpoint
// would make `git clean` delete files that were ignored (and so never checkpointed) at the
// time (#1832). Each checkpoint therefore records the paths ignored when it was saved.
private ignoredRecordPath(commitHash: string) {
return path.join(this.dotGitDir, "zoo-checkpoint-ignored", commitHash)
}

private async listIgnoredPaths(git: SimpleGit): Promise<string[]> {
// --directory collapses fully ignored directories (e.g. node_modules/) to one entry.
const output = await git.raw(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"])
return output.split("\0").filter(Boolean)
}

private async readIgnoredRecord(commitHash: string, required = false): Promise<string[] | undefined> {

Check warning on line 283 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:283: NoCoverage BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.

Check warning on line 283 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:283: NoCoverage BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.

Check warning on line 283 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:283: NoCoverage BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
let content: string
try {
content = await fs.readFile(this.ignoredRecordPath(commitHash), "utf8")
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT" && !required) return undefined

Check warning on line 288 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:288: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.

Check warning on line 288 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:288: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.

Check warning on line 288 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:288: Survived LogicalOperator mutant (replacement: error instanceof Error && "code" in error || error.code === "ENOENT"). See the job summary for the complete list and resolution guidance.
throw error
}
const prefix = `${IGNORED_RECORD_HEADER}\n`
if (content.startsWith(prefix)) {
const paths: unknown = JSON.parse(content.slice(prefix.length))
if (
!Array.isArray(paths) ||
!paths.every((entry): entry is string => typeof entry === "string" && !!entry)
) {
throw new Error(`Invalid ignored-path record for checkpoint ${commitHash}`)
}
return paths
}
if (required) throw new Error(`Invalid ignored-path record for checkpoint ${commitHash}`)

Check warning on line 302 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:302: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.

Check warning on line 302 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:302: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.

Check warning on line 302 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:302: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
// Records from the first implementation were NUL-delimited, without a commit marker.
return content.split("\0").filter(Boolean)

Check warning on line 304 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:304: Survived MethodExpression mutant (replacement: content.split("\0")). See the job summary for the complete list and resolution guidance.
}

/** Publish protection before reporting success; preserve the previous record on failure. */
private async recordIgnoredPaths(git: SimpleGit, commitHash: string, requireExisting = false) {
const recordPath = this.ignoredRecordPath(commitHash)
const tempPath = `${recordPath}.${crypto.randomUUID()}.tmp`
try {
const ignored = new Set([
...((await this.readIgnoredRecord(commitHash, requireExisting)) ?? []),
...(await this.listIgnoredPaths(git)),
])
await fs.mkdir(path.dirname(recordPath), { recursive: true })
// A no-change save reuses the same commit. Never truncate its existing protection.
await fs.writeFile(tempPath, `${IGNORED_RECORD_HEADER}\n${JSON.stringify([...ignored])}`, { flush: true })

Check warning on line 318 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:318: 2 mutation test gaps; example: Survived BooleanLiteral mutant (replacement: false). See the job summary for the complete list and resolution guidance.
await fs.rename(tempPath, recordPath)
} finally {
await fs.rm(tempPath, { force: true }).catch(() => {})

Check warning on line 321 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:321: 2 mutation test gaps; example: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
}
}

/**
* Removes untracked files like `git clean -f -d -f`, but only those ignored by neither the
* current rules nor the rules recorded when the checkpoint was saved.
*/
private async removeUntrackedFiles(git: SimpleGit, ignoredAtCheckpoint: string[]) {
const ignoredEntries = new Set([...ignoredAtCheckpoint, ...(await this.listIgnoredPaths(git))])
const ignoredDirs = [...ignoredEntries].filter((entry) => entry.endsWith("/"))
const wasIgnored = (relPath: string) =>
ignoredEntries.has(relPath) || ignoredDirs.some((dir) => relPath.startsWith(dir))

// --exclude-standard applies the current rules; nested repositories are listed as "dir/".
const output = await git.raw(["ls-files", "--others", "--exclude-standard", "-z"])
for (const relPath of output.split("\0").filter(Boolean)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (wasIgnored(relPath)) {
continue
}
const absPath = path.join(this.workspaceDir, relPath)
// A per-entry failure must not abort restore before `git reset --hard` runs.
await fs.rm(absPath, { force: true, recursive: relPath.endsWith("/") }).catch((error: unknown) => {

Check warning on line 343 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:343: Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 343 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:343: Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 343 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:343: Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
this.log(
`[${this.constructor.name}#removeUntrackedFiles] failed to remove ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
)
})
}

// --directory includes paths that never contained files. Walk these bottom-up,
// using only rmdir so a directory containing protected files is never deleted.
const removeEmptyDirectories = async (relDir: string): Promise<void> => {
if (wasIgnored(relDir)) return
const absDir = path.join(this.workspaceDir, relDir)
try {
for (const entry of await fs.readdir(absDir, { withFileTypes: true })) {
if (entry.isDirectory()) await removeEmptyDirectories(`${relDir}${entry.name}/`)

Check warning on line 357 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:357: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}
} catch (error: unknown) {

Check warning on line 359 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:359: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 359 in src/services/checkpoints/ShadowCheckpointService.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/services/checkpoints/ShadowCheckpointService.ts:359: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
this.log(
`[${this.constructor.name}#removeUntrackedFiles] failed to read ${relDir}: ${error instanceof Error ? error.message : String(error)}`,
)
return
}
await fs.rmdir(absDir).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOTEMPTY" && error.code !== "EEXIST" && error.code !== "ENOENT") {
this.log(
`[${this.constructor.name}#removeUntrackedFiles] failed to remove ${relDir}: ${error.message}`,
)
}
})
}
const directories = await git.raw(["ls-files", "--others", "--exclude-standard", "--directory", "-z"])
for (const relPath of directories.split("\0").filter((entry) => entry.endsWith("/"))) {
await removeEmptyDirectories(relPath)
}
}

private async getNestedGitRepository(): Promise<string | null> {
try {
// Find all .git/HEAD files that are not at the root level.
Expand Down Expand Up @@ -344,9 +457,14 @@
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 result = await this.git.commit(`${message}\n\n${IGNORED_RECORD_HEADER}`, commitArgs)
const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
const toHash = result.commit || fromHash
const toHash = result.commit || (await this.git.revparse(["HEAD"]))
// A no-op save cannot reconstruct missing checkpoint-time protection from today's ignore rules.
const requireExisting =
!result.commit &&
(await this.git.show(["-s", "--format=%B", toHash])).split(/\r?\n/).includes(IGNORED_RECORD_HEADER)
await this.recordIgnoredPaths(this.git, toHash, requireExisting)
this._checkpoints.push(toHash)
const duration = Date.now() - startTime

Expand Down Expand Up @@ -381,12 +499,27 @@
try {
this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`)

// Never use webview input as a path or Git revision expression.
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commitHash)) {
throw new Error("Invalid checkpoint commit hash")
}
if (!this.git) {
throw new Error("Shadow git repo not initialized")
}

const start = Date.now()
await this.git.clean("f", ["-d", "-f"])
if ((await this.git.raw(["cat-file", "-t", commitHash])).trim() !== "commit") {
throw new Error("Checkpoint hash does not identify a commit")
}
const commitMessage = await this.git.show(["-s", "--format=%B", commitHash])
const recordRequired = commitMessage.split(/\r?\n/).includes(IGNORED_RECORD_HEADER)
const ignoredAtCheckpoint = await this.readIgnoredRecord(commitHash, recordRequired)
if (ignoredAtCheckpoint) {
await this.removeUntrackedFiles(this.git, ignoredAtCheckpoint)
} else {
// Checkpoints saved before ignore records existed keep the previous behavior.
await this.git.clean("f", ["-d", "-f"])
}
await this.git.reset(["--hard", commitHash])

// Remove all checkpoints after the specified commitHash.
Expand Down
Loading
Loading