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
2 changes: 1 addition & 1 deletion apps/server/src/checkpointing/Layers/CheckpointRevert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* @module CheckpointRevertLive
*/
import type { CheckpointRef, OrchestrationThread, ThreadId } from "@threadlines/contracts";
import { normalizeWorkspacePath } from "@threadlines/shared/path";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand All @@ -35,7 +36,6 @@ import {
checkpointPreTurnRefForThreadTurn,
checkpointPreTurnRefForThreadTurnCount,
checkpointRefForThreadTurn,
normalizeWorkspacePath,
resolveThreadWorkspaceCwd,
} from "../Utils.ts";

Expand Down
8 changes: 0 additions & 8 deletions apps/server/src/checkpointing/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,6 @@ export function checkpointPreTurnRefForThreadTurnCount(
);
}

/**
* Normalizes a workspace path for equality comparisons across separators,
* trailing slashes, and case-insensitive filesystems.
*/
export function normalizeWorkspacePath(value: string): string {
return value.replaceAll("\\", "/").replace(/\/+$/u, "").toLowerCase();
}

export function resolveThreadWorkspaceCwd(input: {
readonly thread: {
readonly projectId: ProjectId;
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
type VcsCreateWorktreeResult,
type VcsListRefsInput,
type VcsListRefsResult,
type VcsListWorktreesInput,
type VcsListWorktreesResult,
type GitGenerateCommitMessageInput,
type GitGenerateCommitMessageResult,
type GitManagerServiceError,
Expand Down Expand Up @@ -116,6 +118,10 @@ export interface GitWorkflowServiceShape {
readonly listWorktrees: (input: {
readonly cwd: string;
}) => Effect.Effect<ReadonlyArray<GitWorktreeEntry>, GitCommandError>;
/** See GitVcsDriverShape.listWorktreeStatuses. Empty for a non-repository cwd. */
readonly listWorktreeStatuses: (
input: VcsListWorktreesInput,
) => Effect.Effect<VcsListWorktreesResult, GitCommandError>;
readonly commitGraph: (
input: VcsCommitGraphInput,
) => Effect.Effect<VcsCommitGraphResult, GitCommandError>;
Expand Down Expand Up @@ -444,6 +450,14 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
: Effect.succeed<ReadonlyArray<GitWorktreeEntry>>([]),
),
),
listWorktreeStatuses: (input) =>
detectGitRepositoryForCommand("GitWorkflowService.listWorktreeStatuses", input.cwd).pipe(
Effect.flatMap((isGitRepository) =>
isGitRepository
? git.listWorktreeStatuses(input)
: Effect.succeed<VcsListWorktreesResult>({ worktrees: [] }),
),
),
commitGraph: (input) =>
detectGitRepositoryForCommand("GitWorkflowService.commitGraph", input.cwd).pipe(
Effect.flatMap((isGitRepository) =>
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import { makeDrainableWorker } from "@threadlines/shared/DrainableWorker";
import { normalizeWorkspacePath } from "@threadlines/shared/path";

import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
import { normalizeCheckpointFilePath } from "../../checkpointing/SelectiveRevert.ts";
import {
checkpointPreTurnRefForThreadTurn,
checkpointPreTurnRefForThreadTurnCount,
checkpointRefForThreadTurn,
normalizeWorkspacePath,
resolveThreadWorkspaceCwd,
} from "../../checkpointing/Utils.ts";
import {
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
type VcsDeleteBranchResult,
type VcsCreateWorktreeInput,
type VcsCreateWorktreeResult,
type VcsListWorktreesInput,
type VcsListWorktreesResult,
type VcsInitInput,
type VcsListRefsInput,
type VcsListRefsResult,
Expand Down Expand Up @@ -257,6 +259,15 @@ export interface GitVcsDriverShape {
readonly listWorktrees: (input: {
readonly cwd: string;
}) => Effect.Effect<ReadonlyArray<GitWorktreeEntry>, GitCommandError>;
/**
* The same enumeration plus the state a cleanup decision needs: uncommitted
* changes and commits the default branch cannot reach. One extra pair of git
* calls per checkout, run sequentially -- a repository has a handful of
* checkouts, not thousands.
*/
readonly listWorktreeStatuses: (
input: VcsListWorktreesInput,
) => Effect.Effect<VcsListWorktreesResult, GitCommandError>;
readonly commitGraph: (
input: VcsCommitGraphInput,
) => Effect.Effect<VcsCommitGraphResult, GitCommandError>;
Expand Down
73 changes: 73 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,79 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

// The cleanup UI only ever offers to delete a checkout it can describe:
// the tag it shows ("has uncommitted changes", "2 commits not on main")
// comes straight from these two fields.
it.effect("reports dirty and unmerged state per checkout", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;
const path = yield* Path.Path;

const cleanPath = path.join(cwd, "worktrees", "clean");
const messyPath = path.join(cwd, "worktrees", "messy");
yield* git(cwd, ["worktree", "add", "-b", "clean-branch", cleanPath]);
yield* git(cwd, ["worktree", "add", "-b", "messy-branch", messyPath]);
yield* git(messyPath, ["config", "user.email", "test@test.com"]);
yield* git(messyPath, ["config", "user.name", "Test"]);
yield* writeTextFile(messyPath, "shipped.ts", "export const shipped = true;\n");
yield* git(messyPath, ["add", "."]);
yield* git(messyPath, ["commit", "-m", "work only this checkout has"]);
yield* writeTextFile(messyPath, "scratch.ts", "export const scratch = true;\n");

const { worktrees } = yield* driver.listWorktreeStatuses({ cwd });

const root = worktrees.find((worktree) => worktree.isRoot);
assert.isDefined(root);
const clean = worktrees.find((worktree) => worktree.refName === "clean-branch");
assert.deepStrictEqual(
{ dirty: clean?.dirty, unmerged: clean?.unmergedCommitCount, isRoot: clean?.isRoot },
{ dirty: false, unmerged: 0, isRoot: false },
);
const messy = worktrees.find((worktree) => worktree.refName === "messy-branch");
assert.deepStrictEqual(
{ dirty: messy?.dirty, unmerged: messy?.unmergedCommitCount, isRoot: messy?.isRoot },
{ dirty: true, unmerged: 1, isRoot: false },
);
assert.isFalse(clean?.unrelatedHistory);
assert.isFalse(messy?.unrelatedHistory);
}),
);

// Checkouts left over from before a history rewrite share no commit with
// the default branch. Counting there reports the branch's whole history as
// unshipped work, so the listing says "unrelated" and skips the number.
it.effect("flags a checkout whose branch shares no history with the base", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;
const path = yield* Path.Path;

yield* git(cwd, ["checkout", "--orphan", "ancient-branch"]);
yield* git(cwd, ["rm", "-rf", "--cached", "."]);
yield* writeTextFile(cwd, "ancient.ts", "export const ancient = true;\n");
yield* git(cwd, ["add", "ancient.ts"]);
yield* git(cwd, ["commit", "-m", "history from before the rewrite"]);
// Forced: the orphan commit leaves the base branch's files untracked.
yield* git(cwd, ["checkout", "-f", initialBranch]);
const ancientPath = path.join(cwd, "worktrees", "ancient");
yield* git(cwd, ["worktree", "add", ancientPath, "ancient-branch"]);

const { worktrees } = yield* driver.listWorktreeStatuses({ cwd });

const ancient = worktrees.find((worktree) => worktree.refName === "ancient-branch");
assert.deepStrictEqual(
{
unrelated: ancient?.unrelatedHistory,
unmerged: ancient?.unmergedCommitCount,
},
{ unrelated: true, unmerged: null },
);
}),
);

it.effect("reports no checkouts for a non-repository directory", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
124 changes: 124 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3821,6 +3821,129 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
},
);

/**
* Absolute path of the repository's main checkout, derived from the shared
* git directory. Null when it cannot be determined (bare repositories), in
* which case no listed checkout is reported as the root.
*/
const resolveMainWorktreePath = Effect.fn("resolveMainWorktreePath")(function* (cwd: string) {
const result = yield* executeGit(
"GitVcsDriver.listWorktreeStatuses.commonDir",
cwd,
["rev-parse", "--git-common-dir"],
{ timeoutMs: 5_000, allowNonZeroExit: true },
).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
if (result === null || result.exitCode !== 0) {
return null;
}
const rawGitCommonDir = result.stdout.trim();
if (rawGitCommonDir.length === 0) {
return null;
}
const gitCommonDir = path.isAbsolute(rawGitCommonDir)
? path.normalize(rawGitCommonDir)
: path.normalize(path.resolve(cwd, rawGitCommonDir));
if (path.basename(gitCommonDir) !== ".git") {
return null;
}
return path.dirname(gitCommonDir);
});

const realPathOrSelf = (candidate: string): Effect.Effect<string> =>
fileSystem
.realPath(candidate)
.pipe(Effect.catch(() => Effect.succeed(path.resolve(candidate))));

/** Uncommitted changes in one checkout. A checkout git can no longer read
* (its directory was removed underneath us) counts as clean rather than
* failing the whole listing. */
const readWorktreeDirty = (cwd: string): Effect.Effect<boolean> =>
executeGit("GitVcsDriver.listWorktreeStatuses.status", cwd, ["status", "--porcelain"], {
timeoutMs: 10_000,
allowNonZeroExit: true,
}).pipe(
Effect.map((result) => result.exitCode === 0 && result.stdout.trim().length > 0),
Effect.catch(() => Effect.succeed(false)),
);

/**
* Commits on `branch` the repository's default branch cannot reach.
*
* A branch with no merge base against that default branch reports
* `unrelatedHistory` and no count: `rev-list` would answer with the whole of
* its history, which reads as a mountain of unshipped work when the truth is
* that the two histories were never joined (checkouts predating a history
* rewrite are the usual source).
*/
const readUnmergedCommits = (
cwd: string,
branch: string | null,
): Effect.Effect<{ readonly count: number | null; readonly unrelatedHistory: boolean }> =>
Effect.gen(function* () {
if (branch === null) {
return { count: null, unrelatedHistory: false };
}
const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, branch);
if (!baseRef) {
return { count: null, unrelatedHistory: false };
}
const mergeBase = yield* executeGit(
"GitVcsDriver.listWorktreeStatuses.mergeBase",
cwd,
["merge-base", baseRef, branch],
{ timeoutMs: 10_000, allowNonZeroExit: true },
);
// Exit 1 is git's specific "no merge base"; anything higher is a broken
// ref or a failed call, which says nothing about the histories.
if (mergeBase.exitCode === 1) {
return { count: null, unrelatedHistory: true };
}
if (mergeBase.exitCode !== 0 || mergeBase.stdout.trim().length === 0) {
return { count: null, unrelatedHistory: false };
}
const result = yield* executeGit(
"GitVcsDriver.listWorktreeStatuses.revList",
cwd,
["rev-list", "--count", `${baseRef}..${branch}`],
{ timeoutMs: 10_000, allowNonZeroExit: true },
);
if (result.exitCode !== 0) {
return { count: null, unrelatedHistory: false };
}
const parsed = Number.parseInt(result.stdout.trim(), 10);
return {
count: Number.isFinite(parsed) ? Math.max(0, parsed) : null,
unrelatedHistory: false,
};
}).pipe(Effect.catch(() => Effect.succeed({ count: null, unrelatedHistory: false })));

const listWorktreeStatuses: GitVcsDriver.GitVcsDriverShape["listWorktreeStatuses"] = Effect.fn(
"listWorktreeStatuses",
)(function* (input) {
const entries = yield* listWorktrees({ cwd: input.cwd });
if (entries.length === 0) {
return { worktrees: [] };
}
const mainWorktreePath = yield* resolveMainWorktreePath(input.cwd);
const mainRealPath = mainWorktreePath === null ? null : yield* realPathOrSelf(mainWorktreePath);

const worktrees = [];
for (const entry of entries) {
const entryRealPath = yield* realPathOrSelf(entry.path);
const isRoot = mainRealPath !== null && entryRealPath === mainRealPath;
const unmerged = yield* readUnmergedCommits(entry.path, entry.branch);
worktrees.push({
path: entry.path,
refName: entry.branch,
isRoot,
dirty: yield* readWorktreeDirty(entry.path),
unmergedCommitCount: unmerged.count,
unrelatedHistory: unmerged.unrelatedHistory,
});
}
return { worktrees };
});

const readListRefsRepositoryContext = Effect.fn("readListRefsRepositoryContext")(function* (
cwd: string,
) {
Expand Down Expand Up @@ -4757,6 +4880,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
readConfigValue,
listRefs,
listWorktrees,
listWorktreeStatuses,
commitGraph,
commitDetails,
workingTreeDiff,
Expand Down
Loading
Loading