Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
38 changes: 38 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2883,6 +2883,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("unlinks ignored directory links without deleting their targets", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
yield* writeTextFile(cwd, ".gitignore", "linked\n");
yield* git(cwd, ["add", ".gitignore"]);
yield* git(cwd, ["commit", "-m", "ignore links"]);
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const outside = yield* makeTmpDir("git-worktree-link-target-");
yield* writeTextFile(outside, "keep.txt", "keep\n");
const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked");
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.createWorktree({
cwd,
path: worktreePath,
refName: initialBranch,
newRefName: "feature/linked",
});
// A junction on Windows, which Git for Windows leaves behind; a symlink elsewhere.
NodeFS.symlinkSync(outside, pathService.join(worktreePath, "linked"), "junction");
NodeFS.mkdirSync(pathService.join(worktreePath, "linked-parent"));
NodeFS.symlinkSync(
outside,
pathService.join(worktreePath, "linked-parent", "linked"),
"junction",
);

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

assert.equal(yield* fileSystem.exists(worktreePath), false);
assert.equal(
yield* fileSystem.readFileString(pathService.join(outside, "keep.txt")),
"keep\n",
);
}),
);

it.effect("allows worktree removal to run longer than the default command timeout", () =>
Effect.gen(function* () {
const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";
import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
import * as GitVcsDriver from "./GitVcsDriver.ts";
import { removeEmptyDirectory } from "./removeEmptyDirectory.ts";
import {
parseRemoteNames,
parseRemoteNamesInGitOrder,
Expand Down Expand Up @@ -3646,6 +3647,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
input.branch,
]);

/**
* Unlinks links under `target` without following them and removes the
* directories that leaves empty. Returns false when anything else remains.
*/
const removeLeftoverLinks = (
target: string,
): Effect.Effect<boolean, PlatformError.PlatformError> =>
Effect.gen(function* () {
if (Option.isSome(yield* fileSystem.readLink(target).pipe(Effect.option))) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
yield* fileSystem.remove(target);
return true;
}
if ((yield* fileSystem.stat(target)).type !== "Directory") return false;
let empty = true;
for (const name of yield* fileSystem.readDirectory(target)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!(yield* removeLeftoverLinks(path.join(target, name)))) empty = false;
}
// Only an empty directory goes, so files written since the listing stay.
return empty && (yield* removeEmptyDirectory(target));
});

const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn(
"removeWorktree",
)(function* (input) {
Expand All @@ -3667,6 +3689,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
},
);
if (result.exitCode === 0) {
// Git for Windows never descends into NTFS junctions, such as pnpm's
// node_modules links. It reports success but leaves them and their parent
// directories behind, so a resumed thread would find a stub instead of
// recreating its checkout. Git has already unregistered the worktree, so
// anything left here is logged rather than returned: a retry could never
// succeed.
const leftover = path.resolve(input.cwd, input.path);
if (yield* fileSystem.exists(leftover).pipe(Effect.orElseSucceed(() => false))) {
Comment thread
juliusmarminge marked this conversation as resolved.
Outdated
const removed = yield* removeLeftoverLinks(leftover).pipe(
Effect.catch((error) =>
Effect.logWarning("GitVcsDriver.removeWorktree: failed to delete leftover links", {
path: leftover,
error,
}).pipe(Effect.as(true)),
),
);
if (!removed) {
yield* Effect.logWarning(
"GitVcsDriver.removeWorktree: kept files written after git removed the worktree",
{ path: leftover },
);
}
}
return;
}
// Threads can share a worktree path, and worktrees get removed or pruned
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/vcs/removeEmptyDirectory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// @effect-diagnostics nodeBuiltinImport:off - Effect's FileSystem has no rmdir.
import * as NodeFSP from "node:fs/promises";

import * as Effect from "effect/Effect";

/**
* Removes `path` only while it is an empty directory. Succeeds with false when
* anything else is there, including files written after the caller looked.
*/
export const removeEmptyDirectory = (path: string) =>
Effect.tryPromise(() => NodeFSP.rmdir(path)).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
);
Loading