Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
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
5 changes: 3 additions & 2 deletions apps/code/src/main/services/archive/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
deleteCheckpoint,
RevertCheckpointSaga,
} from "@posthog/git/sagas/checkpoint";
import { forceRemove } from "@posthog/git/utils";
import { type WorktreeInfo, WorktreeManager } from "@posthog/git/worktree";
import { inject, injectable } from "inversify";
import type {
Expand Down Expand Up @@ -232,7 +233,7 @@ export class ArchiveService {
});
await manager.deleteWorktree(worktreePath);
const parentDir = path.dirname(worktreePath);
await fs.rm(parentDir, { recursive: true, force: true });
await forceRemove(parentDir);
},
async () => {},
);
Expand Down Expand Up @@ -352,7 +353,7 @@ export class ArchiveService {
);
await manager.deleteWorktree(worktreePath);
const parentDir = path.dirname(worktreePath);
await fs.rm(parentDir, { recursive: true, force: true });
await forceRemove(parentDir);
}
},
);
Expand Down
17 changes: 14 additions & 3 deletions apps/code/src/main/services/suspension/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,20 @@ vi.mock("@posthog/git/worktree", () => ({
mockWorktreeManagerProto.createDetachedWorktreeAtCommit;
},
}));
vi.mock("node:fs/promises", () => ({
default: { rm: vi.fn(), access: vi.fn() },
}));
vi.mock("node:fs/promises", () => {
const fns = {
rm: vi.fn(),
access: vi.fn(),
lstat: vi
.fn()
.mockRejectedValue(
Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
),
chmod: vi.fn(),
readdir: vi.fn().mockResolvedValue([]),
};
return { default: fns, ...fns };
});

vi.mock("../../utils/logger.js", () => ({
logger: {
Expand Down
3 changes: 2 additions & 1 deletion apps/code/src/main/services/suspension/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
deleteCheckpoint,
RevertCheckpointSaga,
} from "@posthog/git/sagas/checkpoint";
import { forceRemove } from "@posthog/git/utils";
import { type WorktreeInfo, WorktreeManager } from "@posthog/git/worktree";
import { inject, injectable } from "inversify";
import type { IArchiveRepository } from "../../db/repositories/archive-repository.js";
Expand Down Expand Up @@ -278,7 +279,7 @@ export class SuspensionService extends TypedEventEmitter<SuspensionServiceEvents
): Promise<void> {
const manager = this.createWorktreeManager(folderPath);
await manager.deleteWorktree(worktreePath);
await fs.rm(path.dirname(worktreePath), { recursive: true, force: true });
await forceRemove(path.dirname(worktreePath));
}

private async killTaskProcesses(
Expand Down
8 changes: 4 additions & 4 deletions packages/git/src/sagas/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { GitSaga, type GitSagaInput } from "../git-saga";
import { addToLocalExclude, branchExists, getDefaultBranch } from "../queries";
import { safeSymlink } from "../utils";
import { forceRemove, safeSymlink } from "../utils";
import { processWorktreeInclude, runPostCheckoutHook } from "../worktree";

export interface CreateWorktreeInput extends GitSagaInput {
Expand Down Expand Up @@ -50,7 +50,7 @@ export class CreateWorktreeSaga extends GitSaga<
try {
await this.git.raw(["worktree", "remove", worktreePath, "--force"]);
} catch {
await fs.rm(worktreePath, { recursive: true, force: true });
await forceRemove(worktreePath);
await this.git.raw(["worktree", "prune"]);
}
try {
Expand Down Expand Up @@ -157,7 +157,7 @@ export class CreateWorktreeForBranchSaga extends GitSaga<
try {
await this.git.raw(["worktree", "remove", worktreePath, "--force"]);
} catch {
await fs.rm(worktreePath, { recursive: true, force: true });
await forceRemove(worktreePath);
await this.git.raw(["worktree", "prune"]);
}
},
Expand Down Expand Up @@ -274,7 +274,7 @@ export class DeleteWorktreeSaga extends GitSaga<
try {
await this.git.raw(["worktree", "remove", worktreePath, "--force"]);
} catch {
await fs.rm(worktreePath, { recursive: true, force: true });
await forceRemove(worktreePath);
await this.git.raw(["worktree", "prune"]);
}
},
Expand Down
68 changes: 68 additions & 0 deletions packages/git/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { forceRemove } from "./utils";

async function fileExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}

describe("forceRemove", () => {
let workDir: string | undefined;

afterEach(async () => {
if (workDir) {
await forceRemove(workDir).catch(() => {});
workDir = undefined;
}
});
Comment thread
richardsolomou marked this conversation as resolved.
Resolved

it("is a no-op for a missing path", async () => {
workDir = await mkdtemp(path.join(tmpdir(), "posthog-force-remove-"));
await expect(
forceRemove(path.join(workDir, "missing")),
).resolves.toBeUndefined();
});

it.each([
{
name: "writable tree",
setup: async (target: string) => {
await mkdir(path.join(target, "nested"), { recursive: true });
await writeFile(path.join(target, "nested", "file.txt"), "x");
},
},
{
name: "read-only directories (Go module cache shape)",
setup: async (target: string) => {
const inner = path.join(target, "yaml.v3@v3.0.1", ".github");
await mkdir(inner, { recursive: true });
await writeFile(path.join(inner, "workflow.yml"), "on: push\n");
await chmod(path.join(target, "yaml.v3@v3.0.1", ".github"), 0o555);
await chmod(path.join(target, "yaml.v3@v3.0.1"), 0o555);
// Confirm plain fs.rm cannot remove it.
await expect(
rm(target, { recursive: true, force: true }),
).rejects.toMatchObject({ code: "EACCES" });
// Restore read-only state so forceRemove starts clean.
await chmod(path.join(target, "yaml.v3@v3.0.1"), 0o555);
await chmod(path.join(target, "yaml.v3@v3.0.1", ".github"), 0o555);
},
},
])("removes a $name", async ({ setup }) => {
workDir = await mkdtemp(path.join(tmpdir(), "posthog-force-remove-"));
const target = path.join(workDir, "tree");
await mkdir(target);
await setup(target);

await forceRemove(target);

expect(await fileExists(target)).toBe(false);
});
});
45 changes: 45 additions & 0 deletions packages/git/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,51 @@ function execFileAsync(
});
}

async function chmodTreeWritable(target: string): Promise<void> {
let stat: import("node:fs").Stats;
try {
stat = await fs.lstat(target);
} catch {
return;
}
if (stat.isSymbolicLink()) return;
try {
await fs.chmod(target, stat.isDirectory() ? 0o700 : 0o600);
} catch (error) {
console.warn(`forceRemove: chmod failed on ${target}`, error);
}
if (!stat.isDirectory()) return;
let entries: string[];
try {
entries = await fs.readdir(target);
} catch (error) {
console.warn(`forceRemove: readdir failed on ${target}`, error);
return;
}
Comment thread
richardsolomou marked this conversation as resolved.
Resolved
await Promise.all(
entries.map((entry) => chmodTreeWritable(path.join(target, entry))),
);
}

/**
* Recursively remove a path, retrying after chmod'ing the tree writable when
* the kernel rejects the initial removal with EACCES/EPERM. Worktrees commonly
* contain read-only subtrees populated by Go's module cache (which marks every
* cached directory mode 0555); plain `fs.rm` cannot unlink entries from those
* parents until we restore the write bit.
*/
export async function forceRemove(target: string): Promise<void> {
try {
await fs.rm(target, { recursive: true, force: true, maxRetries: 3 });
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EACCES" && code !== "EPERM") throw error;
}
await chmodTreeWritable(target);
await fs.rm(target, { recursive: true, force: true, maxRetries: 3 });
}

export interface GitHubPr {
owner: string;
repo: string;
Expand Down
4 changes: 2 additions & 2 deletions packages/git/src/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
getHeadSha,
listWorktrees as listWorktreesRaw,
} from "./queries";
import { clonePath, safeSymlink } from "./utils";
import { clonePath, forceRemove, safeSymlink } from "./utils";

export interface WorktreeInfo {
worktreePath: string;
Expand Down Expand Up @@ -394,7 +394,7 @@ export class WorktreeManager {
try {
await git.raw(["worktree", "remove", worktreePath, "--force"]);
} catch {
await fs.rm(worktreePath, { recursive: true, force: true });
await forceRemove(worktreePath);
await git.raw(["worktree", "prune"]);
}
});
Expand Down
Loading