Skip to content
Source
Sign inSign up

worktreeRemoval.ts

156 lines6.3 KB

1import { GitCommandError } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7​
8import type * as GitVcsDriver from "./GitVcsDriver.ts";
9​
10const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024;
11​
12/**
13 * Git for Windows before 2.54 follows NTFS junctions; newer Git leaves them behind.
14 * On Windows, mirror Git's removal checks, then delete with Node's `rm`, which
15 * unlinks junctions without following them. Git then removes the worktree metadata.
16 */
17export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorktree")(function* <
18 A,
19 E,
20 R,
21>(input: {
22 readonly operation: string;
23 readonly cwd: string;
24 readonly worktreePath: string;
25 readonly force: boolean;
26 readonly execute: GitVcsDriver.GitVcsDriver["Service"]["execute"];
27 readonly run: (args: ReadonlyArray<string>) => Effect.Effect<A, E, R>;
28}) {
29 const fileSystem = yield* FileSystem.FileSystem;
30 const path = yield* Path.Path;
31 const worktreePath = input.worktreePath;
32 const gitFile = path.join(worktreePath, ".git");
33 // Git refuses to remove a worktree with untracked files unless forced, but
34 // its check honors `status.showUntrackedFiles=no` and would delete them.
35 const statusConfig = ["-c", "status.showUntrackedFiles=normal"];
36 const linked =
37 (yield* HostProcess.isWindows) &&
38 (yield* fileSystem.stat(gitFile).pipe(
39 Effect.map((info) => info.type === "File"),
40 Effect.orElseSucceed(() => false),
41 ));
42 if (!linked) {
43 return yield* input.run([
44 ...statusConfig,
45 "worktree",
46 "remove",
47 ...(input.force ? ["--force"] : []),
48 "--",
49 worktreePath,
50 ]);
51 }
52​
53 // Git's own refusals, worded the way it prints them.
54 const refuse = (message: string, cause?: unknown) =>
55 new GitCommandError({
56 operation: input.operation,
57 command: "git worktree remove",
58 cwd: input.cwd,
59 detail: `fatal: ${message}`,
60 ...(cause === undefined ? {} : { cause }),
61 });
62 const gitOutput = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>
63 input
64 .execute({
65 operation: input.operation,
66 cwd,
67 args,
68 maxOutputBytes: INDEX_LIST_MAX_BYTES,
69 ...(env === undefined ? {} : { env }),
70 })
71 .pipe(Effect.map((result) => result.stdout));
72 const samePath = (left: string, right: string) =>
73 Effect.all([fileSystem.realPath(left), fileSystem.realPath(right)]).pipe(
74 Effect.map(([a, b]) => a.toLowerCase() === b.toLowerCase()),
75 Effect.orElseSucceed(() => false),
76 );
77​
78 // A registered worktree of this repository: its `.git` file names an admin
79 // directory under the common `worktrees/`, whose `gitdir` points back.
80 const notAWorktree = refuse(`'${worktreePath}' is not a working tree`);
81 const adminDir = path.resolve(
82 worktreePath,
83 (yield* gitOutput(worktreePath, ["rev-parse", "--absolute-git-dir"]).pipe(
84 Effect.mapError(() => notAWorktree),
85 )).trim(),
86 );
87 const commonDir = path.resolve(
88 input.cwd,
89 (yield* gitOutput(input.cwd, ["rev-parse", "--git-common-dir"])).trim(),
90 );
91 const pointsBack = yield* fileSystem.readFileString(path.join(adminDir, "gitdir")).pipe(
92 Effect.flatMap((gitdir) => samePath(path.resolve(adminDir, gitdir.trim()), gitFile)),
93 Effect.orElseSucceed(() => false),
94 );
95 if (!pointsBack || !(yield* samePath(path.dirname(adminDir), path.join(commonDir, "worktrees"))))
96 return yield* notAWorktree;
97 // Git counts the file's existence as a lock. T3 passes at most one
98 // `--force`, which never removes a locked worktree. Anything but a
99 // confirmed absence refuses.
100 const lockFile = path.join(adminDir, "locked");
101 const locked = yield* fileSystem.stat(lockFile).pipe(
102 Effect.as(true),
103 Effect.catch((error) =>
104 error.reason._tag === "NotFound"
105 ? Effect.succeed(false)
106 : Effect.fail(refuse(`cannot inspect the lock of '${worktreePath}'`, error)),
107 ),
108 );
109 if (locked) {
110 const reason = (yield* fileSystem.readFileString(lockFile).pipe(Effect.option)).pipe(
111 Option.map((text) => text.trim()),
112 Option.getOrElse(() => ""),
113 );
114 return yield* refuse(
115 reason === ""
116 ? "cannot remove a locked working tree;\nuse 'remove -f -f' to override or unlock first"
117 : `cannot remove a locked working tree, lock reason: ${reason}\nuse 'remove -f -f' to override or unlock first`,
118 );
119 }
120 if (!input.force) {
121 // Bound like Git's own check, so `core.worktree` can't point it elsewhere.
122 const boundEnv = { GIT_DIR: gitFile, GIT_WORK_TREE: worktreePath };
123 const submodules = refuse("working trees containing submodules cannot be moved or removed");
124 // An unreadable path counts as present, so a doubt refuses.
125 const present = (target: string) =>
126 fileSystem.exists(target).pipe(Effect.orElseSucceed(() => true));
127 if (yield* present(path.join(adminDir, "modules"))) return yield* submodules;
128 const index = yield* gitOutput(worktreePath, ["ls-files", "--stage", "-z"], boundEnv);
129 for (const entry of index.split("\0")) {
130 if (!entry.startsWith("160000 ")) continue;
131 const submodule = entry.slice(entry.indexOf("\t") + 1);
132 if (yield* present(path.join(worktreePath, submodule, ".git"))) return yield* submodules;
133 }
134 const status = yield* gitOutput(
135 worktreePath,
136 [...statusConfig, "status", "--porcelain", "--ignore-submodules=none"],
137 boundEnv,
138 );
139 if (status !== "")
140 return yield* refuse(
141 `'${worktreePath}' contains modified or untracked files, use --force to delete it`,
142 );
143 }
144​
145 // A file held open can stop the delete partway. Putting `.git` back, when
146 // that works, leaves a worktree that a retry can remove.
147 const gitFileContents = yield* fileSystem
148 .readFileString(gitFile)
149 .pipe(Effect.mapError(() => notAWorktree));
150 yield* fileSystem.remove(worktreePath, { recursive: true }).pipe(
151 Effect.tapError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)),
152 Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)),
153 );
154 // The folder is gone, so Git only drops the worktree's metadata.
155 return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]);
156});