Skip to content

Commit ec36d19

Browse files
Merge origin/main into fix/issue-23019
2 parents 2cfd636 + 0bdf33a commit ec36d19

13 files changed

Lines changed: 1009 additions & 664 deletions

File tree

‎apps/server/src/storageCleanup.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,12 @@ const cleanupFixture = (
276276
...git,
277277
execute: (input) =>
278278
Effect.gen(function* () {
279-
if (editBeforeRemoval && input.args.includes("remove"))
279+
// Before Git's own clean check: inside `git worktree remove`, or on
280+
// Windows the same status check the removal makes before deleting.
281+
if (
282+
editBeforeRemoval &&
283+
(input.args.includes("remove") || input.args.includes("--porcelain"))
284+
)
280285
yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n");
281286
return yield* git.execute(input);
282287
}),

‎apps/server/src/storageCleanup.ts‎

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import * as Settings from "./serverSettings.ts";
4646
import * as TerminalManager from "./terminal/Manager.ts";
4747
import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts";
4848
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
49+
import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts";
4950
import { withWorkspaceLease } from "./workspace/workspaceLease.ts";
5051
import * as HostProcess from "@t3tools/shared/HostProcess";
5152
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
@@ -1013,20 +1014,15 @@ const make = Effect.gen(function* () {
10131014
const changes = yield* localChangesReason(worktreePath, settings);
10141015
if (changes !== null) return keep(changes);
10151016
}
1016-
yield* cleanupGit(
1017-
project.workspaceRoot,
1018-
[
1019-
"-c",
1020-
"status.showUntrackedFiles=normal",
1021-
"worktree",
1022-
"remove",
1023-
// Git requires force for submodules; recursive checks above cannot make removal atomic.
1024-
...(submodules.force ? ["--force"] : []),
1025-
"--",
1026-
worktreePath,
1027-
],
1028-
"git worktree remove",
1029-
);
1017+
yield* removeLinkedWorktree({
1018+
operation: "StorageCleanup.cleanupGit",
1019+
cwd: project.workspaceRoot,
1020+
worktreePath,
1021+
// Git requires force for submodules; recursive checks above cannot make removal atomic.
1022+
force: submodules.force,
1023+
execute: git.execute,
1024+
run: (args) => cleanupGit(project.workspaceRoot, [...args], "git worktree remove"),
1025+
});
10301026
entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes });
10311027
yield* gitManager.invalidateStatus(project.workspaceRoot);
10321028
// Preserve branch and path: ProviderTurnStartService recreates the checkout

‎apps/server/src/vcs/GitVcsDriverCore.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3416,6 +3416,73 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
34163416
}),
34173417
);
34183418

3419+
it.effect("force-removes a worktree without deleting what its junctions point to", () =>
3420+
Effect.gen(function* () {
3421+
const cwd = yield* makeTmpDir();
3422+
const { initialBranch } = yield* initRepoWithCommit(cwd);
3423+
yield* writeTextFile(cwd, ".gitignore", "node_modules\n");
3424+
yield* git(cwd, ["add", ".gitignore"]);
3425+
yield* git(cwd, ["commit", "-m", "ignore node_modules"]);
3426+
const fileSystem = yield* FileSystem.FileSystem;
3427+
const pathService = yield* Path.Path;
3428+
const outside = yield* makeTmpDir("git-worktree-link-target-");
3429+
yield* writeTextFile(outside, "keep.txt", "keep\n");
3430+
const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked");
3431+
const driver = yield* GitVcsDriver.GitVcsDriver;
3432+
yield* driver.createWorktree({
3433+
cwd,
3434+
path: worktreePath,
3435+
refName: initialBranch,
3436+
newRefName: "feature/linked",
3437+
});
3438+
// Junctions on Windows, the way pnpm links packages; symlinks elsewhere.
3439+
NodeFS.mkdirSync(pathService.join(worktreePath, "node_modules"));
3440+
NodeFS.symlinkSync(
3441+
outside,
3442+
pathService.join(worktreePath, "node_modules", "dep"),
3443+
"junction",
3444+
);
3445+
NodeFS.symlinkSync(outside, pathService.join(worktreePath, "shared"), "junction");
3446+
3447+
yield* driver.removeWorktree({ cwd, path: worktreePath, force: true });
3448+
3449+
assert.deepEqual(NodeFS.readdirSync(pathService.dirname(worktreePath)), []);
3450+
assert.equal(
3451+
yield* fileSystem.readFileString(pathService.join(outside, "keep.txt")),
3452+
"keep\n",
3453+
);
3454+
}),
3455+
);
3456+
3457+
it.effect("keeps the junctions of a worktree that git refuses to remove", () =>
3458+
Effect.gen(function* () {
3459+
const cwd = yield* makeTmpDir();
3460+
const { initialBranch } = yield* initRepoWithCommit(cwd);
3461+
yield* writeTextFile(cwd, ".gitignore", "node_modules\n");
3462+
yield* git(cwd, ["add", ".gitignore"]);
3463+
yield* git(cwd, ["commit", "-m", "ignore node_modules"]);
3464+
const pathService = yield* Path.Path;
3465+
const outside = yield* makeTmpDir("git-worktree-link-target-");
3466+
const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "linked");
3467+
const driver = yield* GitVcsDriver.GitVcsDriver;
3468+
yield* driver.createWorktree({
3469+
cwd,
3470+
path: worktreePath,
3471+
refName: initialBranch,
3472+
newRefName: "feature/linked",
3473+
});
3474+
const link = pathService.join(worktreePath, "node_modules");
3475+
NodeFS.symlinkSync(outside, link, "junction");
3476+
yield* writeTextFile(worktreePath, "notes.txt", "draft\n");
3477+
3478+
const result = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath }));
3479+
3480+
assert.isTrue(Result.isFailure(result));
3481+
assert.isTrue(NodeFS.lstatSync(link).isSymbolicLink());
3482+
assert.deepEqual(NodeFS.readdirSync(pathService.dirname(worktreePath)), ["linked"]);
3483+
}),
3484+
);
3485+
34193486
it.effect("prunes stale registrations when removing an already-gone worktree", () =>
34203487
Effect.gen(function* () {
34213488
const cwd = yield* makeTmpDir();

‎apps/server/src/vcs/GitVcsDriverCore.ts‎

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observabil
4242
import * as GitVcsDriver from "./GitVcsDriver.ts";
4343
import { removeEmptyDirectory } from "./removeEmptyDirectory.ts";
4444
import { explicitBareRepositoryEnv, gitConfigEnv } from "./VcsProcess.ts";
45+
import { removeLinkedWorktree } from "./worktreeRemoval.ts";
4546
import { resolveWorktreesDirectory } from "../worktreesDirectory.ts";
4647
import {
4748
parseRemoteNames,
@@ -4172,25 +4173,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
41724173
const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn(
41734174
"removeWorktree",
41744175
)(function* (input) {
4175-
// Git also accepts a worktree's short name, which can match a different
4176-
// folder than the path resolved against `input.cwd`. Resolve it once so Git
4177-
// and the leftover cleanup below act on the same directory.
4176+
// Git also matches a worktree by its short name, which can name a different
4177+
// folder than this path. Resolving it once keeps Git, the Windows deletion
4178+
// in removeLinkedWorktree, and the leftover cleanup below on one directory.
41784179
const target = path.resolve(input.cwd, input.path);
4179-
// Git refuses to remove a worktree with untracked files unless forced, but
4180-
// its check honors `status.showUntrackedFiles=no` and would delete them.
4181-
const args = ["-c", "status.showUntrackedFiles=normal", "worktree", "remove"];
4182-
if (input.force) {
4183-
args.push("--force");
4184-
}
4185-
args.push("--", target);
4186-
const result = yield* executeGitWithStableDiagnostics(
4187-
"GitVcsDriver.removeWorktree",
4188-
input.cwd,
4189-
args,
4190-
{
4191-
timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
4192-
allowNonZeroExit: true,
4193-
},
4180+
const result = yield* removeLinkedWorktree({
4181+
operation: "GitVcsDriver.removeWorktree",
4182+
cwd: input.cwd,
4183+
worktreePath: target,
4184+
force: input.force === true,
4185+
execute,
4186+
run: (args) =>
4187+
executeGitWithStableDiagnostics("GitVcsDriver.removeWorktree", input.cwd, args, {
4188+
timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
4189+
allowNonZeroExit: true,
4190+
}).pipe(Effect.map((result) => ({ ...result, args }))),
4191+
}).pipe(
4192+
Effect.provideService(FileSystem.FileSystem, fileSystem),
4193+
Effect.provideService(Path.Path, path),
41944194
);
41954195
if (result.exitCode === 0) {
41964196
// Git for Windows never descends into NTFS junctions, such as pnpm's
@@ -4222,7 +4222,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
42224222
// than an error.
42234223
const alreadyGone =
42244224
isMissingWorktreeStderr(result.stderr) &&
4225-
!(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false)));
4225+
!(yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false)));
42264226
if (alreadyGone) {
42274227
yield* pruneWorktrees({ cwd: input.cwd });
42284228
return;
@@ -4231,7 +4231,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
42314231
`GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`,
42324232
);
42334233
return yield* new GitCommandError({
4234-
...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }),
4234+
...gitCommandContext({
4235+
operation: "GitVcsDriver.removeWorktree",
4236+
cwd: input.cwd,
4237+
args: result.args,
4238+
}),
42354239
detail: "git worktree remove failed",
42364240
...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
42374241
stdoutLength: result.stdout.length,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { GitCommandError } from "@t3tools/contracts";
2+
import * as HostProcess from "@t3tools/shared/HostProcess";
3+
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
import * as Option from "effect/Option";
6+
import * as Path from "effect/Path";
7+
8+
import type * as GitVcsDriver from "./GitVcsDriver.ts";
9+
10+
const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024;
11+
12+
/**
13+
* Removes the linked worktree at `worktreePath` by running `git worktree
14+
* remove` through `run`.
15+
*
16+
* Git for Windows before 2.54 follows NTFS junctions while it deletes a
17+
* worktree, and deletes what they point to, such as a shared node_modules.
18+
* Later versions skip junctions but leave them, and the folders holding them,
19+
* behind. On Windows, Git therefore never deletes the files: this makes the
20+
* checks `git worktree remove` makes before deleting (builtin/worktree.c),
21+
* deletes the folder with Node's `rm`, which unlinks junctions without
22+
* following them, and then has Git drop the now-missing worktree's metadata.
23+
* Git elsewhere only unlinks links, so other hosts run Git as before.
24+
*/
25+
export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorktree")(function* <
26+
A,
27+
E,
28+
R,
29+
>(input: {
30+
readonly operation: string;
31+
readonly cwd: string;
32+
readonly worktreePath: string;
33+
readonly force: boolean;
34+
readonly execute: GitVcsDriver.GitVcsDriver["Service"]["execute"];
35+
readonly run: (args: ReadonlyArray<string>) => Effect.Effect<A, E, R>;
36+
}) {
37+
const fileSystem = yield* FileSystem.FileSystem;
38+
const path = yield* Path.Path;
39+
const worktreePath = input.worktreePath;
40+
const gitFile = path.join(worktreePath, ".git");
41+
// Git refuses to remove a worktree with untracked files unless forced, but
42+
// its check honors `status.showUntrackedFiles=no` and would delete them.
43+
const statusConfig = ["-c", "status.showUntrackedFiles=normal"];
44+
const linked =
45+
(yield* HostProcess.isWindows) &&
46+
(yield* fileSystem.stat(gitFile).pipe(
47+
Effect.map((info) => info.type === "File"),
48+
Effect.orElseSucceed(() => false),
49+
));
50+
if (!linked) {
51+
return yield* input.run([
52+
...statusConfig,
53+
"worktree",
54+
"remove",
55+
...(input.force ? ["--force"] : []),
56+
"--",
57+
worktreePath,
58+
]);
59+
}
60+
61+
// Git's own refusals, worded the way it prints them.
62+
const refuse = (message: string, cause?: unknown) =>
63+
new GitCommandError({
64+
operation: input.operation,
65+
command: "git worktree remove",
66+
cwd: input.cwd,
67+
detail: `fatal: ${message}`,
68+
...(cause === undefined ? {} : { cause }),
69+
});
70+
const gitOutput = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>
71+
input
72+
.execute({
73+
operation: input.operation,
74+
cwd,
75+
args,
76+
maxOutputBytes: INDEX_LIST_MAX_BYTES,
77+
...(env === undefined ? {} : { env }),
78+
})
79+
.pipe(Effect.map((result) => result.stdout));
80+
const samePath = (left: string, right: string) =>
81+
Effect.all([fileSystem.realPath(left), fileSystem.realPath(right)]).pipe(
82+
Effect.map(([a, b]) => a.toLowerCase() === b.toLowerCase()),
83+
Effect.orElseSucceed(() => false),
84+
);
85+
86+
// A registered worktree of this repository: its `.git` file names an admin
87+
// directory under the common `worktrees/`, whose `gitdir` points back.
88+
const notAWorktree = refuse(`'${worktreePath}' is not a working tree`);
89+
const adminDir = path.resolve(
90+
worktreePath,
91+
(yield* gitOutput(worktreePath, ["rev-parse", "--absolute-git-dir"]).pipe(
92+
Effect.mapError(() => notAWorktree),
93+
)).trim(),
94+
);
95+
const commonDir = path.resolve(
96+
input.cwd,
97+
(yield* gitOutput(input.cwd, ["rev-parse", "--git-common-dir"])).trim(),
98+
);
99+
const pointsBack = yield* fileSystem.readFileString(path.join(adminDir, "gitdir")).pipe(
100+
Effect.flatMap((gitdir) => samePath(path.resolve(adminDir, gitdir.trim()), gitFile)),
101+
Effect.orElseSucceed(() => false),
102+
);
103+
if (!pointsBack || !(yield* samePath(path.dirname(adminDir), path.join(commonDir, "worktrees"))))
104+
return yield* notAWorktree;
105+
// Git counts the file's existence as a lock. T3 passes at most one
106+
// `--force`, which never removes a locked worktree. Anything but a
107+
// confirmed absence refuses.
108+
const lockFile = path.join(adminDir, "locked");
109+
const locked = yield* fileSystem.stat(lockFile).pipe(
110+
Effect.as(true),
111+
Effect.catch((error) =>
112+
error.reason._tag === "NotFound"
113+
? Effect.succeed(false)
114+
: Effect.fail(refuse(`cannot inspect the lock of '${worktreePath}'`, error)),
115+
),
116+
);
117+
if (locked) {
118+
const reason = (yield* fileSystem.readFileString(lockFile).pipe(Effect.option)).pipe(
119+
Option.map((text) => text.trim()),
120+
Option.getOrElse(() => ""),
121+
);
122+
return yield* refuse(
123+
reason === ""
124+
? "cannot remove a locked working tree;\nuse 'remove -f -f' to override or unlock first"
125+
: `cannot remove a locked working tree, lock reason: ${reason}\nuse 'remove -f -f' to override or unlock first`,
126+
);
127+
}
128+
if (!input.force) {
129+
// Bound like Git's own check, so `core.worktree` can't point it elsewhere.
130+
const boundEnv = { GIT_DIR: gitFile, GIT_WORK_TREE: worktreePath };
131+
const submodules = refuse("working trees containing submodules cannot be moved or removed");
132+
// An unreadable path counts as present, so a doubt refuses.
133+
const present = (target: string) =>
134+
fileSystem.exists(target).pipe(Effect.orElseSucceed(() => true));
135+
if (yield* present(path.join(adminDir, "modules"))) return yield* submodules;
136+
const index = yield* gitOutput(worktreePath, ["ls-files", "--stage", "-z"], boundEnv);
137+
for (const entry of index.split("\0")) {
138+
if (!entry.startsWith("160000 ")) continue;
139+
const submodule = entry.slice(entry.indexOf("\t") + 1);
140+
if (yield* present(path.join(worktreePath, submodule, ".git"))) return yield* submodules;
141+
}
142+
const status = yield* gitOutput(
143+
worktreePath,
144+
[...statusConfig, "status", "--porcelain", "--ignore-submodules=none"],
145+
boundEnv,
146+
);
147+
if (status !== "")
148+
return yield* refuse(
149+
`'${worktreePath}' contains modified or untracked files, use --force to delete it`,
150+
);
151+
}
152+
153+
// A file held open can stop the delete partway. Putting `.git` back, when
154+
// that works, leaves a worktree that a retry can remove.
155+
const gitFileContents = yield* fileSystem
156+
.readFileString(gitFile)
157+
.pipe(Effect.mapError(() => notAWorktree));
158+
yield* fileSystem.remove(worktreePath, { recursive: true }).pipe(
159+
Effect.tapError(() => fileSystem.writeFileString(gitFile, gitFileContents).pipe(Effect.ignore)),
160+
Effect.mapError((cause) => refuse(`failed to delete '${worktreePath}'`, cause)),
161+
);
162+
// The folder is gone, so Git only drops the worktree's metadata.
163+
return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]);
164+
});

‎apps/web/src/components/Sidebar.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ import {
116116
useAllEnvironmentProjectSnapshotsReady,
117117
useProjects,
118118
useServerConfigs,
119-
useThreadShells,
119+
useQuietThreadShells,
120120
} from "../state/entities";
121121
import { environmentPresentations } from "../state/presentation";
122122
import { primaryServerKeybindingsAtom } from "../state/server";
@@ -356,7 +356,7 @@ function useSidebarThreads(input: {
356356
}) {
357357
const { projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, routeThreadRef } =
358358
input;
359-
const threads = useThreadShells();
359+
const threads = useQuietThreadShells();
360360
const routeRowThreadKey =
361361
routeThreadRef === null
362362
? null

0 commit comments

Comments
 (0)