Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d5b4016
feat(web): preview worktree storage before saving cleanup rules
tris203 Sep 19, 2026
f16118e
fix(web): use shared number field padding for cleanup days
tris203 Sep 19, 2026
91937b5
fix(web): pluralize storage project counts correctly
tris203 Sep 19, 2026
0283925
refactor(server): narrow storage scan exceptions and verify read-only…
tris203 Sep 19, 2026
ab49eef
fix(server): count shared project worktrees and directory allocations
tris203 Sep 19, 2026
29f54a4
fix(server): include worktree-local hardlinks in storage estimates
tris203 Sep 19, 2026
41bc68b
perf(storage): scan worktrees natively with background progress
tris203 Sep 19, 2026
67586ed
fix(storage): bound preview work and guard native traversal
tris203 Sep 19, 2026
cfc90b6
refactor(storage): preserve service module namespaces
tris203 Sep 19, 2026
2199a30
fix(storage): tolerate paths disappearing during scans
tris203 Sep 19, 2026
f00090d
fix(storage): classify merged and settled worktrees accurately
tris203 Sep 20, 2026
09b5085
fix(server): isolate legacy pull request link failures
tris203 Sep 20, 2026
02f1b0c
fix(storage): bound scanner queues and exact byte totals
tris203 Sep 23, 2026
86f1efc
refactor(server): retain storage size module namespace
tris203 Sep 23, 2026
ddb4155
fix(web): use theme tokens for storage headings
tris203 Sep 25, 2026
32a44eb
fix(web): export scoped settings write result type
tris203 Sep 25, 2026
ab61e65
fix(web): keep storage usage values on one line
tris203 Sep 25, 2026
67eb20b
fix(server): bound merged pull request evidence refreshes
tris203 Sep 26, 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
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ export const makeOrchestrationIntegrationHarness = (
);
const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
Layer.provideMerge(
Layer.succeed(StorageCleanup.StorageCleanup, {
Layer.mock(StorageCleanup.StorageCleanup)({
start: () => Effect.void,
drain: Effect.void,
}),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,
[WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
[WS_METHODS.serverPreviewStorageCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeStorageCleanup]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
[WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope,
[WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const make = Effect.gen(function* () {
threadSettlement: true,
threadAutoSettlement: true,
storageCleanup: true,
storageCleanupPreview: true,
projectWorktreeCleanup: true,
threadRestartContinuation: true,
projectSettingsOverrides: true,
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ interface FakeGhScenario {
createdPrUrl?: string;
defaultBranch?: string;
pullRequest?: {
headSha?: string;
number: number;
title: string;
url: string;
Expand Down Expand Up @@ -1127,6 +1128,39 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("reads an identified merged PR head afresh without checking out its branch", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const reference = "https://github.com/pingdotgg/t3code/pull/216";
const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 216,
title: "Squash merged",
url: reference,
baseRefName: "release",
headRefName: "feature",
headSha: "a".repeat(40),
state: "merged",
},
},
});
const pr = yield* manager.branchPullRequest(
{ cwd: repoDir, branch: "feature", reference },
{ refresh: true },
);
expect(pr).toMatchObject({
state: "merged",
headSha: "a".repeat(40),
baseRef: "release",
headRef: "feature",
repositoryKey: "github.com/pingdotgg/t3code",
});
expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
}),
);

it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
21 changes: 19 additions & 2 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOpti
}

export type GitBranchPullRequest = NonNullable<VcsStatusResult["pr"]> & {
readonly headSha?: string;
readonly repositoryKey: string | null;
readonly updatedAt: string | null;
readonly closedAt?: string | null;
Expand All @@ -113,7 +114,7 @@ export class GitManager extends Context.Service<
) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
/** Resolve the PR for a saved branch without changing the current checkout. */
readonly branchPullRequest: (
input: { readonly cwd: string; readonly branch: string },
input: { readonly cwd: string; readonly branch: string; readonly reference?: string },
options?: { readonly refresh?: boolean },
) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>;
readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
Expand Down Expand Up @@ -188,6 +189,7 @@ interface OpenPrInfo {
}

interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
headSha?: string | undefined;
state: "open" | "closed" | "merged";
isDraft?: boolean;
closedAt?: string | null;
Expand Down Expand Up @@ -452,6 +454,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
url: summary.url,
baseRefName: summary.baseRefName,
headRefName: summary.headRefName,
...(summary.headSha ? { headSha: summary.headSha } : {}),
state: summary.state ?? "open",
...(summary.isDraft === true ? { isDraft: true } : {}),
closedAt: summary.closedAt ?? null,
Expand Down Expand Up @@ -2142,7 +2145,20 @@ export const make = Effect.gen(function* () {
});
const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn(
"branchPullRequest",
)(function* ({ cwd, branch }, options) {
)(function* ({ cwd, branch, reference }, options) {
if (reference !== undefined) {
// Cleanup reads an identified PR afresh, rather than selecting a reused branch's PR.
const latest = toPullRequestInfo(
yield* (yield* sourceControlProvider(cwd)).getChangeRequest({ cwd, reference }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical git/GitManager.ts:2152

When cleanup supplies reference, branchPullRequest returns the referenced PR without validating its repositoryKey, so a merged PR from a fork with the same branch and head SHA is treated as the current repository's PR. This can delete an unmerged worktree from the current repository, including ignored files; validate the referenced PR's repository identity before returning it.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/git/GitManager.ts around line 2152:

When cleanup supplies `reference`, `branchPullRequest` returns the referenced PR without validating its `repositoryKey`, so a merged PR from a fork with the same branch and head SHA is treated as the current repository's PR. This can delete an unmerged worktree from the current repository, including ignored files; validate the referenced PR's repository identity before returning it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

GPT-6 responding on behalf of @tris203

The explicit reference is the PR linked to the thread, not a PR selected by searching another repository for a matching branch. This path intentionally treats that linked PR as authoritative, including fork/upstream and non-default-base workflows. Cleanup still requires a fresh merged state, matching head branch, and the exact current HEAD SHA; a reused branch name alone cannot qualify. The identified-PR test also covers a non-default release base.

Requiring the linked PR to belong to the checkout’s primary remote would reject supported fork/upstream workflows and change the agreed semantics (the current commit was merged through the linked PR). The branch-discovery path retains its repository checks because it has no explicit PR identity. Keeping the explicit-reference behavior; no change for this finding.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

);
return {
...toStatusPr(latest),
...(latest.headSha ? { headSha: latest.headSha } : {}),
closedAt: latest.closedAt ?? null,
mergedAt: latest.mergedAt ?? null,
repositoryKey: pullRequestRepositoryKey(latest.url),
};
}
const cacheCwd = yield* normalizeStatusCacheKey(cwd);
const remotes = yield* gitCore.execute({
operation: "GitManager.branchPullRequest.remotes",
Expand Down Expand Up @@ -2286,6 +2302,7 @@ export const make = Effect.gen(function* () {
}
return {
...toStatusPr(latest),
...(latest.headSha ? { headSha: latest.headSha } : {}),
closedAt: latest.closedAt ?? null,
mergedAt: latest.mergedAt ?? null,
// Hosting CLIs can select an upstream repository instead of origin.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("OrchestrationReactor", () => {
runtime = ManagedRuntime.make(
Layer.effect(OrchestrationReactor, makeOrchestrationReactor).pipe(
Layer.provideMerge(
Layer.succeed(StorageCleanup, {
Layer.mock(StorageCleanup)({
start: () => {
started.push("storage-cleanup");
return Effect.void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3547,12 +3547,13 @@ it.effect(
('t-deleted', 'github.com', 'acme/web', 6, 'https://github.com/acme/web/pull/6', 'manual', '2026-09-02T00:00:00Z', NULL)`;
const expected = (yield* query.getShellSnapshot()).threads
.filter((thread) => thread.pullRequests.length > 0)
.map(({ id, projectId, settledOverride, settledAt, pullRequests }) => ({
.map(({ id, projectId, settledOverride, settledAt, pullRequests, branchPullRequest }) => ({
id,
projectId,
settledOverride,
settledAt,
pullRequests,
branchPullRequest,
}));
resolved.length = 0;

Expand All @@ -3571,6 +3572,26 @@ it.effect(
assert.deepStrictEqual(threads, expected);
assert.strictEqual(counter.count(), 1);
assert.deepStrictEqual(resolved, []);

const legacy = {
projectId: "p1",
repository: "acme/web",
number: 7,
url: "https://github.com/acme/web/pull/7",
};
yield* sql`UPDATE projection_threads SET branch_pull_request_json = '{"projectId":"p1","repository":"acme/web","number":7,"url":"https://github.com/acme/web/pull/7"}'
WHERE thread_id IN ('t-plain', 't-deleted')`;
yield* sql`UPDATE projection_threads SET worktree_path = '/worktrees/archived'
WHERE thread_id = 't-archived'`;
const eligible = yield* query.listThreadsWithPullRequests();
assert.deepStrictEqual(
eligible.map((thread) => thread.id),
["t-archived", "t-first", "t-plain", "t-late", "t-early"],
);
const legacyThread = eligible.find((thread) => thread.id === "t-plain");
assert.deepStrictEqual(legacyThread?.branchPullRequest, legacy);
assert.deepStrictEqual(legacyThread?.pullRequests, []);
assert.deepStrictEqual(resolved, []);
}).pipe(Effect.provide(layer));
},
);
Expand Down
38 changes: 23 additions & 15 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,23 +811,29 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
`,
});

// One row per link, in the shell snapshot's thread order and link order.
const listActiveThreadPullRequestSyncRows = SqlSchema.findAll({
const pullRequestSyncThreadFields = {
threadId: ProjectionThread.fields.threadId,
projectId: ProjectionThread.fields.projectId,
settledOverride: ProjectionThread.fields.settledOverride,
settledAt: ProjectionThread.fields.settledAt,
branchPullRequest: ProjectionThreadDbRowSchema.fields.branchPullRequest,
};

// A left join retains legacy-only links without loading full shell snapshots.
const listThreadPullRequestSyncRows = SqlSchema.findAll({
Request: Schema.Void,
Result: ProjectionThreadPullRequestDbRowSchema.mapFields(
Struct.assign({
projectId: ProjectionThread.fields.projectId,
settledOverride: ProjectionThread.fields.settledOverride,
settledAt: ProjectionThread.fields.settledAt,
}),
),
Result: Schema.Union([
ProjectionThreadPullRequestDbRowSchema.mapFields(Struct.assign(pullRequestSyncThreadFields)),
Schema.Struct({ ...pullRequestSyncThreadFields, host: Schema.Null }),
]),
execute: () =>
sql`
SELECT
links.thread_id AS "threadId",
threads.thread_id AS "threadId",
threads.project_id AS "projectId",
threads.settled_override AS "settledOverride",
threads.settled_at AS "settledAt",
threads.branch_pull_request_json AS "branchPullRequest",
links.host,
links.repository,
links.number,
Expand All @@ -836,11 +842,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
links.linked_at AS "linkedAt",
links.snapshot_json AS "snapshot",
links.stack_json AS "stack"
FROM projection_thread_pull_requests links
INNER JOIN projection_threads threads
FROM projection_threads threads
LEFT JOIN projection_thread_pull_requests links
ON threads.thread_id = links.thread_id
WHERE threads.deleted_at IS NULL
AND threads.archived_at IS NULL
AND (threads.archived_at IS NULL OR threads.worktree_path IS NOT NULL)
AND (links.thread_id IS NOT NULL OR threads.branch_pull_request_json IS NOT NULL)
ORDER BY threads.project_id ASC, threads.created_at ASC, threads.thread_id ASC,
links.linked_at ASC, links.number ASC
`,
Expand Down Expand Up @@ -2828,7 +2835,7 @@ pending_approval_requests AS (

const listThreadsWithPullRequests: ProjectionSnapshotQueryShape["listThreadsWithPullRequests"] =
() =>
listActiveThreadPullRequestSyncRows(undefined).pipe(
listThreadPullRequestSyncRows(undefined).pipe(
Effect.map((rows) => {
const threads = new Map<
ThreadId,
Expand All @@ -2840,9 +2847,10 @@ pending_approval_requests AS (
projectId: row.projectId,
settledOverride: row.settledOverride,
settledAt: row.settledAt,
branchPullRequest: row.branchPullRequest,
pullRequests: [],
};
thread.pullRequests.push(mapPullRequestRow(row));
if (row.host !== null) thread.pullRequests.push(mapPullRequestRow(row));
threads.set(row.threadId, thread);
}
return [...threads.values()];
Expand Down
Loading
Loading