Skip to content
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
31 changes: 28 additions & 3 deletions apps/server/src/storageCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,15 @@ const cleanupTestLayer = GitVcsDriver.layer.pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })),
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
);
const annexWorktree = Effect.fnUntraced(function* (
fixture: Effect.Success<ReturnType<typeof cleanupFixture>>,
worktree: string,
) {
const adminDir = (yield* fixture.command(worktree, ["rev-parse", "--absolute-git-dir"])).stdout;
yield* fixture.fs.remove(`${worktree}/.git`);
yield* fixture.fs.symlink(adminDir.trim(), `${worktree}/.git`);
});

const runCleanupTest = <A, E>(
effect: Effect.Effect<
A,
Expand Down Expand Up @@ -2099,7 +2108,7 @@ describe("orphan worktree reports", () => {
),
);

it.live.each(["nested", "direct"] as const)(
it.live.each(["nested", "direct", "annexed"] as const)(
"reports a linked orphan's size without removing its files (%s)",
(location) =>
runCleanupTest(
Expand All @@ -2108,8 +2117,13 @@ describe("orphan worktree reports", () => {
fixture.setThreads([]);
const orphan = `${fixture.config.worktreesDir}/${location === "nested" ? "repository/" : ""}orphan`;
yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]);
const sizes = yield* Effect.forEach(yield* fixture.fs.readDirectory(orphan), (name) =>
allocatedBytes(fixture.fs, `${orphan}/${name}`),
if (location === "annexed") yield* annexWorktree(fixture, orphan);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const sizes = yield* Effect.forEach(
// Links are not measured, and the annexed `.git` is one.
(yield* fixture.fs.readDirectory(orphan)).filter(
(name) => location !== "annexed" || name !== ".git",
),
(name) => allocatedBytes(fixture.fs, `${orphan}/${name}`),
);
const outside = yield* fixture.fs.makeTempDirectoryScoped();
yield* fixture.fs.writeFileString(`${outside}/external`, "outside data");
Expand Down Expand Up @@ -2963,6 +2977,17 @@ describe("storage cleanup reports and local file policies", () => {
}),
),
);
it.live("removes a worktree whose .git file git-annex replaced with a symlink", () =>
runCleanupTest(
Effect.gen(function* () {
const fixture = yield* cleanupFixture();
const { service, worktree, fs } = fixture;
yield* annexWorktree(fixture, worktree);
expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" });
expect(yield* fs.exists(worktree)).toBe(false);
}),
),
);
Comment thread
cardoso-neto marked this conversation as resolved.
Resolved
it.live("keeps a worktree when the inspection cannot read its directories", () =>
runCleanupTest(
Effect.gen(function* () {
Expand Down
16 changes: 8 additions & 8 deletions apps/server/src/storageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import * as TerminalManager from "./terminal/Manager.ts";
import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
import { isNonRepositoryGitStderr } from "./vcs/GitVcsDriverCore.ts";
import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts";
import { isLinkedWorktree, removeLinkedWorktree } from "./vcs/worktreeRemoval.ts";
import { withWorkspaceLease } from "./workspace/workspaceLease.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
Expand Down Expand Up @@ -591,7 +591,7 @@ const make = Effect.gen(function* () {
const modified = Option.getOrNull((yield* fs.stat(worktreePath)).mtime);
if (modified !== null && modified.getTime() > now - ORPHAN_WORKTREE_GRACE_MS) return;
const gitPath = path.join(worktreePath, ".git");
const linked = (yield* fs.exists(gitPath)) && (yield* fs.stat(gitPath)).type === "File";
const linked = (yield* fs.exists(gitPath)) && (yield* isLinkedWorktree(worktreePath));
reported.add(key);
const remaining = measurementDeadline - (yield* Clock.currentTimeMillis);
if (!orphanSizes.has(key) && (firstMeasurement || remaining > 0)) {
Expand Down Expand Up @@ -636,7 +636,7 @@ const make = Effect.gen(function* () {
(worktree) =>
Effect.gen(function* () {
if (pathKey(yield* fs.realPath(worktree)) !== pathKey(worktree)) return false;
return (yield* fs.stat(path.join(worktree, ".git"))).type === "File";
return yield* isLinkedWorktree(worktree);
}).pipe(Effect.orElseSucceed(() => false)),
);
});
Expand All @@ -647,7 +647,7 @@ const make = Effect.gen(function* () {
if (referenced.has(pathKey(projectDirectory))) return;
const gitPath = path.join(projectDirectory, ".git");
if (yield* fs.exists(gitPath)) {
if ((yield* fs.stat(gitPath)).type === "File") yield* report(projectDirectory);
if (yield* isLinkedWorktree(projectDirectory)) yield* report(projectDirectory);
return;
}
for (const worktreePath of yield* directories(projectDirectory, root)) {
Expand Down Expand Up @@ -1084,10 +1084,10 @@ const make = Effect.gen(function* () {
return "worktree is a symbolic link";
if (!roots.some((root) => inside(root, realPath)))
return "outside the managed worktree folder";
const gitLink = yield* fs
.stat(path.join(worktreePath, ".git"))
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
if (gitLink?.type !== "File") return "not a linked worktree";
const linked = yield* isLinkedWorktree(worktreePath).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
);
if (!linked) return "not a linked worktree";
return null;
});
const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>`
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7262,6 +7262,41 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);

it.effect.skipIf(!symlinksSupported)(
"removes a worktree whose .git file git-annex replaced with a symlink, keeping the symlink when refused",
() =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const pathService = yield* Path.Path;
const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "annexed");
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.createWorktree({
cwd,
path: worktreePath,
refName: initialBranch,
newRefName: "feature/annexed",
});
const gitFile = pathService.join(worktreePath, ".git");
const adminDir = (yield* git(worktreePath, ["rev-parse", "--absolute-git-dir"])).trim();
NodeFS.rmSync(gitFile);
NodeFS.symlinkSync(adminDir, gitFile);
yield* writeTextFile(worktreePath, "notes.txt", "draft\n");

const refused = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath }));

assert.isTrue(Result.isFailure(refused));
assert.isTrue(NodeFS.lstatSync(gitFile).isSymbolicLink());
assert.equal(NodeFS.realpathSync(gitFile), NodeFS.realpathSync(adminDir));
NodeFS.rmSync(pathService.join(worktreePath, "notes.txt"));

yield* driver.removeWorktree({ cwd, path: worktreePath });

assert.isFalse(NodeFS.existsSync(worktreePath));
assert.isFalse(NodeFS.existsSync(adminDir));
}),
);

it.effect("leaves a folder alone that only shares the worktree's short name", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
80 changes: 70 additions & 10 deletions apps/server/src/vcs/worktreeRemoval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,42 @@ import type * as GitVcsDriver from "./GitVcsDriver.ts";

const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024;

/**
* git-annex replaces a linked worktree's `.git` file with a symlink to its admin
* directory, so its symlinks into `.git/annex` resolve. Git then refuses to remove it.
*/
const annexedAdminDir = Effect.fn("WorktreeRemoval.annexedAdminDir")(
function* (worktreePath: string) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const adminDir = path.resolve(
worktreePath,
yield* fileSystem.readLink(path.join(worktreePath, ".git")),
);
const pointsBackTo = path.dirname(
path.resolve(
adminDir,
(yield* fileSystem.readFileString(path.join(adminDir, "gitdir"))).trim(),
),
);
return (yield* fileSystem.realPath(pointsBackTo)) === (yield* fileSystem.realPath(worktreePath))
? Option.some(adminDir)
: Option.none();
},
Effect.orElseSucceed(() => Option.none<string>()),
);

export const isLinkedWorktree = Effect.fn("WorktreeRemoval.isLinkedWorktree")(function* (
worktreePath: string,
) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
return (
(yield* fileSystem.stat(path.join(worktreePath, ".git"))).type === "File" ||
Option.isSome(yield* annexedAdminDir(worktreePath))
);
});

/**
* Git for Windows before 2.54 follows NTFS junctions; newer Git leaves them behind.
* On Windows, mirror Git's removal checks, then delete with Node's `rm`, which
Expand All @@ -33,6 +69,39 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt
// Git refuses to remove a worktree with untracked files unless forced, but
// its check honors `status.showUntrackedFiles=no` and would delete them.
const statusConfig = ["-c", "status.showUntrackedFiles=normal"];
// Git's own refusals, worded the way it prints them.
const refuse = (message: string, cause?: unknown) =>
new GitCommandError({
operation: input.operation,
command: "git worktree remove",
cwd: input.cwd,
detail: `fatal: ${message}`,
...(cause === undefined ? {} : { cause }),
});
const annexed = yield* annexedAdminDir(worktreePath);
if (Option.isSome(annexed)) {
const adminDir = annexed.value;
const writeGitFile = fileSystem.writeFileString(gitFile, `gitdir: ${adminDir}\n`);
// A kept worktree's annexed files resolve only through the symlink; a gitdir
// file is the fallback when it cannot be recreated.
yield* Effect.addFinalizer(() =>
fileSystem.exists(worktreePath).pipe(
Effect.flatMap((kept) =>
kept
? fileSystem.remove(gitFile, { force: true }).pipe(
Effect.andThen(fileSystem.symlink(adminDir, gitFile)),
Effect.catch(() => writeGitFile),
)
: Effect.void,
),
Effect.ignore,
),
);
yield* fileSystem.remove(gitFile).pipe(
Effect.andThen(writeGitFile),
Effect.mapError((cause) => refuse(`failed to replace '${gitFile}'`, cause)),
);
}
const linked =
(yield* HostProcess.isWindows) &&
(yield* fileSystem.stat(gitFile).pipe(
Expand All @@ -50,15 +119,6 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt
]);
}

// Git's own refusals, worded the way it prints them.
const refuse = (message: string, cause?: unknown) =>
new GitCommandError({
operation: input.operation,
command: "git worktree remove",
cwd: input.cwd,
detail: `fatal: ${message}`,
...(cause === undefined ? {} : { cause }),
});
const gitOutput = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>
input
.execute({
Expand Down Expand Up @@ -153,4 +213,4 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt
);
// The folder is gone, so Git only drops the worktree's metadata.
return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]);
});
}, Effect.scoped);
Loading