Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export class GitWorkflowService extends Context.Service<
readonly listWorktrees: (input: {
readonly cwd: string;
}) => Effect.Effect<ReadonlyArray<GitVcsDriver.GitWorktreeEntry>, GitCommandError>;
readonly hasWorktreeChanges: (input: {
readonly cwd: string;
}) => Effect.Effect<boolean, GitCommandError>;
readonly deleteBranch: (input: {
readonly cwd: string;
readonly branch: string;
Expand Down Expand Up @@ -382,6 +385,10 @@ export const make = Effect.gen(function* () {
ensureGitCommand("GitWorkflowService.listWorktrees", input.cwd).pipe(
Effect.andThen(git.listWorktrees(input)),
),
hasWorktreeChanges: (input) =>
ensureGitCommand("GitWorkflowService.hasWorktreeChanges", input.cwd).pipe(
Effect.andThen(git.hasWorktreeChanges(input)),
),
deleteBranch: (input) =>
ensureGitCommand("GitWorkflowService.deleteBranch", input.cwd).pipe(
Effect.andThen(git.deleteBranch(input)),
Expand Down
9 changes: 5 additions & 4 deletions apps/server/src/git/WorktreeCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,12 @@ export const make = Effect.gen(function* () {
Effect.orElseSucceed(() => undefined),
);

// Not `localStatus`: its status read hides untracked files under
// `status.showUntrackedFiles=no`, which would let cleanup delete them.
// Avi Code addition: a failed check reads as dirty, so an unverifiable worktree is
// never auto-removed or pre-selected.
const isDirtyFor = (cwd: string): Effect.Effect<boolean> =>
gitWorkflow.localStatus({ cwd }).pipe(
Effect.map((local) => local.hasWorkingTreeChanges),
Effect.orElseSucceed(() => false),
);
gitWorkflow.hasWorktreeChanges({ cwd }).pipe(Effect.orElseSucceed(() => true));

const listThreads = (
projectId: string,
Expand Down
76 changes: 71 additions & 5 deletions apps/server/src/git/WorktreeCleanupExecute.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { VcsStatusLocalResult } from "@t3tools/contracts";
import { ProjectId, ThreadId } from "@t3tools/contracts";
import { GitCommandError, ProjectId, ThreadId } from "@t3tools/contracts";
import { it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
Expand All @@ -14,8 +13,7 @@ import { ProjectionThreadRepository } from "../persistence/Services/ProjectionTh
import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts";
import { GitWorkflowService } from "./GitWorkflowService.ts";
import * as WorktreeCleanup from "./WorktreeCleanup.ts";

const cleanLocalStatus = { hasWorkingTreeChanges: false } as unknown as VcsStatusLocalResult;
import { selectAutoCleanupCandidates } from "./WorktreeHealthMonitor.ts";

interface Counters {
pruneCalls: number;
Expand All @@ -32,6 +30,8 @@ interface MockState {
archivedAt: string | null;
}>;
activeThreadIds: ReadonlyArray<string>;
dirtyPaths?: ReadonlyArray<string>;
failingDirtyCheckPaths?: ReadonlyArray<string>;
}

// The mock reads `state` lazily at call time so a test can fill it after it has
Expand All @@ -49,7 +49,17 @@ function makeDeps(counters: Counters, state: MockState) {
isLocked: false,
})),
),
localStatus: () => Effect.succeed(cleanLocalStatus),
hasWorktreeChanges: ({ cwd }) =>
state.failingDirtyCheckPaths?.includes(cwd)
? Effect.fail(
new GitCommandError({
operation: "GitVcsDriver.hasWorktreeChanges",
command: "git",
cwd,
detail: "git status failed",
}),
)
: Effect.succeed(state.dirtyPaths?.includes(cwd) ?? false),
removeWorktree: () => {
counters.removeWorktreeCalls += 1;
return Effect.void;
Expand Down Expand Up @@ -209,4 +219,60 @@ describe("WorktreeCleanupService.classify", () => {
}).pipe(Effect.provide(deps));
}),
);
it.effect("marks a candidate dirty from the worktree change check", () =>
Effect.gen(function* () {
const counters: Counters = { pruneCalls: 0, removeWorktreeCalls: 0, gcCalls: 0 };
const state = emptyState();
const deps = makeDeps(counters, state);

yield* Effect.gen(function* () {
const path = yield* Path.Path;
const config = yield* ServerConfigModule.ServerConfig;
const dirtyPath = path.join(config.worktreesDir, "repo", "t3code-dirty001");
const cleanPath = path.join(config.worktreesDir, "repo", "t3code-clean001");
state.worktrees = [
{ path: dirtyPath, branch: "feature/dirty", isMain: false },
{ path: cleanPath, branch: "feature/clean", isMain: false },
];
state.dirtyPaths = [dirtyPath];

const service = yield* WorktreeCleanup.make;
const candidates = yield* service.classify({
cwd: config.cwd,
projectId: ProjectId.make("p1"),
});

expect(candidates.map((candidate) => [candidate.worktreePath, candidate.isDirty])).toEqual([
[dirtyPath, true],
[cleanPath, false],
]);
}).pipe(Effect.provide(deps));
}),
);
it.effect("keeps a worktree whose dirty check fails out of auto-cleanup and selection", () =>
Effect.gen(function* () {
const counters: Counters = { pruneCalls: 0, removeWorktreeCalls: 0, gcCalls: 0 };
const state = emptyState();
const deps = makeDeps(counters, state);

yield* Effect.gen(function* () {
const path = yield* Path.Path;
const config = yield* ServerConfigModule.ServerConfig;
const brokenPath = path.join(config.worktreesDir, "repo", "t3code-broken01");
state.worktrees = [{ path: brokenPath, branch: "feature/broken", isMain: false }];
state.failingDirtyCheckPaths = [brokenPath];

const service = yield* WorktreeCleanup.make;
const candidates = yield* service.classify({
cwd: config.cwd,
projectId: ProjectId.make("p1"),
});

expect(candidates).toHaveLength(1);
expect(candidates[0]?.reason).toBe("orphaned");
expect(candidates[0]?.isDirty).toBe(true);
expect(selectAutoCleanupCandidates(candidates)).toEqual([]);
}).pipe(Effect.provide(deps));
}),
);
});
3 changes: 3 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ export class GitVcsDriver extends Context.Service<
readonly listWorktrees: (input: {
cwd: string;
}) => Effect.Effect<ReadonlyArray<GitWorktreeEntry>, GitCommandError>;
readonly hasWorktreeChanges: (input: {
cwd: string;
}) => Effect.Effect<boolean, GitCommandError>;
readonly deleteBranch: (input: GitDeleteBranchInput) => Effect.Effect<void, GitCommandError>;
readonly pruneWorktrees: (
input: GitPruneWorktreesInput,
Expand Down
Loading
Loading