Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
96177fa
docs: add repo-level worktree housekeeping design spec
pat-s Jun 10, 2026
b0c78ce
docs: add repo-level worktree housekeeping implementation plan
pat-s Jun 10, 2026
bbfec43
feat(contracts): add worktreeCleanupScope setting
pat-s Jun 10, 2026
3ebc67e
feat(contracts): add managed-worktree cleanup schemas
pat-s Jun 10, 2026
b8897f9
feat(contracts): register managed-worktree cleanup RPCs
pat-s Jun 10, 2026
40579ad
feat(server): list managed worktrees with dirty status
pat-s Jun 10, 2026
5460f19
feat(server): compute worktree on-disk size
pat-s Jun 10, 2026
6822b79
feat(server): batch-remove worktrees with per-path force
pat-s Jun 10, 2026
58e670f
feat(server): expose managed-worktree cleanup on GitWorkflowService
pat-s Jun 10, 2026
110da68
feat(server): wire managed-worktree cleanup WS handlers
pat-s Jun 10, 2026
ea2a75f
feat(contracts): add managed-worktree cleanup to EnvironmentApi.vcs
pat-s Jun 10, 2026
6e8778a
feat(client-runtime): add managed-worktree cleanup RPC methods
pat-s Jun 10, 2026
ef1dbf3
feat(web,mobile): wire managed-worktree cleanup environment API
pat-s Jun 10, 2026
edaede5
feat(web): classify managed worktrees by thread state and scope
pat-s Jun 10, 2026
d7ce592
feat(web): add worktree cleanup dialog logic helpers
pat-s Jun 10, 2026
cdfe1da
feat(web): add worktree cleanup dialog component
pat-s Jun 10, 2026
aaa9f03
feat(web): add worktree cleanup scope setting UI
pat-s Jun 10, 2026
684dc6a
feat(web): add worktree cleanup and visible delete to archived panel
pat-s Jun 10, 2026
4de32a6
feat(web): add clean up worktrees to sidebar repo menu
pat-s Jun 10, 2026
174b544
test(contracts): hoist worktree-schema decoders to module scope
pat-s Jun 10, 2026
1ae469a
fix(web,contracts): persist worktreeCleanupScope patch and fix reclai…
pat-s Jun 10, 2026
ab15030
Merge remote-tracking branch 'upstream/main' into t3code/deffe2b9
pat-s Jun 10, 2026
19b0dd0
fix(web,server): address PR review findings for worktree cleanup
pat-s Jun 10, 2026
76754a0
fix(web): scope worktree-cleanup thread refs to the target environment
pat-s Jun 10, 2026
79c2077
fix(web): self-load thread refs in cleanup dialog and show protected …
pat-s Jun 10, 2026
8010b27
fix(web): wrap cleanup dialog thread selector in useShallow
pat-s Jun 10, 2026
8ddbdf4
fix(web,server): surface archived-snapshot and git-list failures in c…
pat-s Jun 10, 2026
a8e2927
fix(server): guard batch worktree removal to the managed worktrees dir
pat-s Jun 11, 2026
eb8bad8
Merge branch 'main' into t3code/deffe2b9
pat-s Jun 12, 2026
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
24 changes: 24 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {
type GitPullRequestRefInput,
type VcsPullResult,
type VcsRemoveWorktreeInput,
type VcsListManagedWorktreesInput,
type VcsListManagedWorktreesResult,
type VcsWorktreeSizeInput,
type VcsWorktreeSizeResult,
type VcsRemoveWorktreesInput,
type VcsRemoveWorktreesResult,
type GitResolvePullRequestResult,
type GitRunStackedActionInput,
type GitRunStackedActionResult,
Expand Down Expand Up @@ -61,6 +67,15 @@ export interface GitWorkflowServiceShape {
input: VcsCreateWorktreeInput,
) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
readonly listManagedWorktrees: (
input: VcsListManagedWorktreesInput,
) => Effect.Effect<VcsListManagedWorktreesResult, GitCommandError>;
readonly worktreeSize: (
input: VcsWorktreeSizeInput,
) => Effect.Effect<VcsWorktreeSizeResult, GitCommandError>;
readonly removeWorktrees: (
input: VcsRemoveWorktreesInput,
) => Effect.Effect<VcsRemoveWorktreesResult, GitCommandError>;
readonly createRef: (
input: VcsCreateRefInput,
) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
Expand Down Expand Up @@ -298,6 +313,15 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
Effect.andThen(git.removeWorktree(input)),
),
listManagedWorktrees: (input) =>
ensureGitCommand("GitWorkflowService.listManagedWorktrees", input.cwd).pipe(
Effect.andThen(git.listManagedWorktrees(input)),
),
worktreeSize: (input) => git.worktreeSize(input),
removeWorktrees: (input) =>
ensureGitCommand("GitWorkflowService.removeWorktrees", input.cwd).pipe(
Effect.andThen(git.removeWorktrees(input)),
),
createRef: (input) =>
ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
Effect.andThen(git.createRef(input)),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4861,6 +4861,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
worktree: { path: "/tmp/wt", refName: "feature/demo" },
}),
removeWorktree: () => Effect.void,
listManagedWorktrees: () => Effect.succeed({ worktrees: [] }),
worktreeSize: () => Effect.succeed({ sizeBytes: 0 }),
removeWorktrees: () => Effect.succeed({ results: [] }),
createRef: (input) => Effect.succeed({ refName: input.refName }),
switchRef: (input) => Effect.succeed({ refName: input.refName }),
},
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ import {
type VcsListRefsResult,
type VcsPullResult,
type VcsRemoveWorktreeInput,
type VcsListManagedWorktreesInput,
type VcsListManagedWorktreesResult,
type VcsWorktreeSizeInput,
type VcsWorktreeSizeResult,
type VcsRemoveWorktreesInput,
type VcsRemoveWorktreesResult,
type VcsStatusInput,
type VcsStatusResult,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -222,6 +228,15 @@ export interface GitVcsDriverShape {
input: GitSetBranchUpstreamInput,
) => Effect.Effect<void, GitCommandError>;
readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
readonly listManagedWorktrees: (
input: VcsListManagedWorktreesInput,
) => Effect.Effect<VcsListManagedWorktreesResult, GitCommandError>;
readonly worktreeSize: (
input: VcsWorktreeSizeInput,
) => Effect.Effect<VcsWorktreeSizeResult, GitCommandError>;
readonly removeWorktrees: (
input: VcsRemoveWorktreesInput,
) => Effect.Effect<VcsRemoveWorktreesResult, GitCommandError>;
readonly renameBranch: (
input: GitRenameBranchInput,
) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
Expand Down
96 changes: 96 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,102 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
);
});

describe("managed worktrees", () => {
it.effect("lists managed worktrees under the worktrees dir with dirty status", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;

yield* driver.createWorktree({
cwd,
refName: initialBranch,
newRefName: "feature-a",
path: null,
});

const result = yield* driver.listManagedWorktrees({ cwd });

assert.equal(result.worktrees.length, 1);
assert.equal(result.worktrees[0]?.refName, "feature-a");
// Fresh worktree branch has no remote => treated as dirty (unpushed).
assert.equal(result.worktrees[0]?.isDirty, true);
}),
);

it.effect("computes the on-disk byte size of a worktree", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;

const created = yield* driver.createWorktree({
cwd,
refName: initialBranch,
newRefName: "feature-size",
path: null,
});

const { sizeBytes } = yield* driver.worktreeSize({ path: created.worktree.path });

// A real checkout always has tracked files on disk.
assert.isAbove(sizeBytes, 0);
}),
);

it.effect("batch-removes worktrees and reports per-path outcomes", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;

const a = yield* driver.createWorktree({
cwd,
refName: initialBranch,
newRefName: "rm-a",
path: null,
});

const { results } = yield* driver.removeWorktrees({
cwd,
items: [
{ path: a.worktree.path, force: true },
{ path: "/does/not/exist", force: true },
],
});

assert.equal(results.length, 2);
assert.equal(results.find((r) => r.path === a.worktree.path)?.ok, true);
const missing = results.find((r) => r.path === "/does/not/exist");
assert.equal(missing?.ok, false);
assert.isString(missing?.error);
}),
);

it.effect("refuses to remove a path outside the managed worktrees dir", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;

// A real directory that exists but is not under the server worktrees dir.
const outside = yield* makeTmpDir("outside-");

const { results } = yield* driver.removeWorktrees({
cwd,
items: [{ path: outside, force: true }],
});

assert.equal(results.length, 1);
assert.equal(results[0]?.ok, false);
assert.match(results[0]?.error ?? "", /managed worktrees directory/i);
// The refusal must not delete the directory.
const fileSystem = yield* FileSystem.FileSystem;
assert.equal(yield* fileSystem.exists(outside), true);
}),
);
});

describe("commit context", () => {
it.effect("stages selected files and commits only those files", () =>
Effect.gen(function* () {
Expand Down
174 changes: 174 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2247,6 +2247,177 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
);
});

const readWorktreeDirty = (worktreePath: string): Effect.Effect<boolean, never> =>
Effect.gen(function* () {
const statusResult = yield* executeGit(
"GitVcsDriver.listManagedWorktrees.status",
worktreePath,
["status", "--porcelain"],
{ timeoutMs: 10_000, allowNonZeroExit: true },
).pipe(Effect.orElseSucceed(() => null));
if (statusResult && statusResult.stdout.trim().length > 0) {
return true;
}
const remoteContains = yield* executeGit(
"GitVcsDriver.listManagedWorktrees.remoteContains",
worktreePath,
["branch", "--remotes", "--contains", "HEAD"],
{ timeoutMs: 10_000, allowNonZeroExit: true },
).pipe(Effect.orElseSucceed(() => null));
const hasRemoteContainingHead =
remoteContains !== null &&
remoteContains.exitCode === 0 &&
remoteContains.stdout.trim().length > 0;
// No remote branch contains HEAD => there is unpushed work.
return !hasRemoteContainingHead;
});

const resolveRealPath = (p: string): Effect.Effect<string, never> =>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
fileSystem.realPath(p).pipe(Effect.orElseSucceed(() => p));

const listManagedWorktrees: GitVcsDriver.GitVcsDriverShape["listManagedWorktrees"] = Effect.fn(
"listManagedWorktrees",
)(function* (input) {
const result = yield* executeGit(
"GitVcsDriver.listManagedWorktrees",
input.cwd,
["worktree", "list", "--porcelain"],
{ timeoutMs: 10_000, fallbackErrorMessage: "git worktree list failed" },
);

const realWorktreesDir = yield* resolveRealPath(worktreesDir);
const isUnderWorktreesDir = (candidate: string): boolean =>
candidate === realWorktreesDir || candidate.startsWith(realWorktreesDir + path.sep);

const rawCandidates: { path: string; refName: string }[] = [];
let currentPath: string | null = null;
let currentBranch: string | null = null;
const flush = () => {
if (currentPath) {
rawCandidates.push({
path: currentPath,
refName: currentBranch ?? path.basename(currentPath),
});
}
currentPath = null;
currentBranch = null;
};
for (const line of result.stdout.split("\n")) {
if (line.startsWith("worktree ")) {
flush();
currentPath = line.slice("worktree ".length).trim();
} else if (line.startsWith("branch refs/heads/")) {
currentBranch = line.slice("branch refs/heads/".length).trim();
} else if (line.trim() === "") {
flush();
}
}
flush();

// Resolve each candidate's real path (also confirms it still exists on disk) and
// compare against the resolved worktrees dir so symlinked paths match symmetrically.
const existing = yield* Effect.forEach(
rawCandidates,
(candidate) =>
fileSystem.realPath(candidate.path).pipe(
Effect.map((realCandidatePath) =>
isUnderWorktreesDir(realCandidatePath)
? Option.some({ path: candidate.path, refName: candidate.refName })
: Option.none<{ path: string; refName: string }>(),
),
Effect.orElseSucceed(() => Option.none<{ path: string; refName: string }>()),
),
{ concurrency: 8 },
).pipe(Effect.map((options) => options.flatMap((o) => (Option.isSome(o) ? [o.value] : []))));

const worktrees = yield* Effect.forEach(
existing,
(candidate) =>
readWorktreeDirty(candidate.path).pipe(
Effect.map((isDirty) => ({ path: candidate.path, refName: candidate.refName, isDirty })),
),
{ concurrency: 4 },
);

return { worktrees };
});

const directorySizeBytes = (rootPath: string): Effect.Effect<number, never> => {
const walk = (current: string): Effect.Effect<number, never> =>
fileSystem.readDirectory(current).pipe(
Effect.flatMap((entries) =>
Effect.forEach(
entries,
(entry) => {
const childPath = path.join(current, entry);
return fileSystem.stat(childPath).pipe(
Effect.flatMap((info) =>
info.type === "Directory"
? walk(childPath)
: Effect.succeed(Number(info.size)),
),
Effect.orElseSucceed(() => 0),
);
},
{ concurrency: 8 },
),
),
Effect.map((sizes) => sizes.reduce((total, size) => total + size, 0)),
Effect.orElseSucceed(() => 0),
);
return walk(rootPath);
};

const worktreeSize: GitVcsDriver.GitVcsDriverShape["worktreeSize"] = Effect.fn("worktreeSize")(
function* (input) {
const sizeBytes = yield* directorySizeBytes(input.path);
return { sizeBytes };
},
);

const removeWorktrees: GitVcsDriver.GitVcsDriverShape["removeWorktrees"] = Effect.fn(
"removeWorktrees",
)(function* (input) {
// Defense-in-depth: never run `git worktree remove` on a client-supplied path
// that is not under the managed worktrees dir, even though git itself only
// operates on registered worktrees.
const realWorktreesDir = yield* resolveRealPath(worktreesDir);
const results = yield* Effect.forEach(
input.items,
(item) =>
resolveRealPath(item.path).pipe(
Effect.flatMap((realCandidate) => {
const isManaged =
realCandidate === realWorktreesDir ||
realCandidate.startsWith(realWorktreesDir + path.sep);
if (!isManaged) {
return Effect.succeed({
path: item.path,
ok: false as const,
error: "refused: path is outside the managed worktrees directory",
});
}
const args = ["worktree", "remove"];
if (item.force) {
args.push("--force");
}
args.push(item.path);
return executeGit("GitVcsDriver.removeWorktrees", input.cwd, args, {
timeoutMs: 15_000,
fallbackErrorMessage: "git worktree remove failed",
}).pipe(
Effect.as({ path: item.path, ok: true as const }),
Effect.catch((error) =>
Effect.succeed({ path: item.path, ok: false as const, error: error.message }),
),
);
}),
),
{ concurrency: 1 },
);
return { results };
Comment thread
cursor[bot] marked this conversation as resolved.
});

const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")(
function* (input) {
if (input.oldBranch === input.newBranch) {
Expand Down Expand Up @@ -2411,6 +2582,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
fetchRemoteTrackingBranch,
setBranchUpstream,
removeWorktree,
listManagedWorktrees,
worktreeSize,
removeWorktrees,
renameBranch,
createRef,
switchRef,
Expand Down
Loading
Loading