Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
65 changes: 65 additions & 0 deletions apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,36 @@ it.layer(TestLayer)("git integration", (it) => {
}),
);

it.effect("paginates branch results and returns paging metadata", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(tmp);
yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" });
yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" });
yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" });

const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 });
expect(firstPage.totalCount).toBe(4);
expect(firstPage.nextCursor).toBe(2);
expect(firstPage.branches.map((branch) => branch.name)).toEqual([
initialBranch,
"feature-a",
]);

const secondPage = yield* (yield* GitCore).listBranches({
cwd: tmp,
cursor: firstPage.nextCursor ?? 0,
limit: 2,
});
expect(secondPage.totalCount).toBe(4);
expect(secondPage.nextCursor).toBeNull();
expect(secondPage.branches.map((branch) => branch.name)).toEqual([
"feature-b",
"feature-c",
]);
}),
);

it.effect("parses separate branch names when column.ui is always enabled", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
Expand Down Expand Up @@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => {
expect(remoteBranch?.remoteName).toBe(remoteName);
}),
);

it.effect(
"filters branch queries before pagination and dedupes origin refs with local matches",
() =>
Effect.gen(function* () {
const remote = yield* makeTmpDir();
const tmp = yield* makeTmpDir();

yield* git(remote, ["init", "--bare"]);
const { initialBranch } = yield* initRepoWithCommit(tmp);
yield* git(tmp, ["remote", "add", "origin", remote]);
yield* git(tmp, ["push", "-u", "origin", initialBranch]);

yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" });
yield* git(tmp, ["push", "-u", "origin", "feature/demo"]);

yield* git(tmp, ["checkout", "-b", "feature/remote-only"]);
yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]);
yield* git(tmp, ["checkout", initialBranch]);
yield* git(tmp, ["branch", "-D", "feature/remote-only"]);

const result = yield* (yield* GitCore).listBranches({
cwd: tmp,
query: "feature/",
limit: 10,
});

expect(result.totalCount).toBe(2);
expect(result.nextCursor).toBeNull();
expect(result.branches.map((branch) => branch.name)).toEqual([
"feature/demo",
"origin/feature/remote-only",
]);
}),
);
});

// ── checkoutGitBranch ──
Expand Down
63 changes: 59 additions & 4 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
} from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { GitCommandError } from "@t3tools/contracts";
import { GitCommandError, type GitBranch } from "@t3tools/contracts";
import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
import {
GitCore,
type ExecuteGitProgress,
Expand Down Expand Up @@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;

type TraceTailState = {
processedChars: number;
Expand Down Expand Up @@ -183,6 +185,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul
};
}

function filterBranchesForListQuery(
branches: ReadonlyArray<GitBranch>,
query?: string,
): ReadonlyArray<GitBranch> {
if (!query) {
return branches;
}

const normalizedQuery = query.toLowerCase();
return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery));
}

function paginateBranches(input: {
branches: ReadonlyArray<GitBranch>;
cursor?: number | undefined;
limit?: number | undefined;
}): {
branches: ReadonlyArray<GitBranch>;
nextCursor: number | null;
totalCount: number;
} {
const cursor = input.cursor ?? 0;
const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT;
const totalCount = input.branches.length;
const branches = input.branches.slice(cursor, cursor + limit);
const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null;

return {
branches,
nextCursor,
totalCount,
};
}

function sanitizeRemoteName(value: string): string {
const sanitized = value
.trim()
Expand Down Expand Up @@ -1571,7 +1607,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
if (localBranchResult.code !== 0) {
const stderr = localBranchResult.stderr.trim();
if (stderr.toLowerCase().includes("not a git repository")) {
return { branches: [], isRepo: false, hasOriginRemote: false };
return {
branches: [],
isRepo: false,
hasOriginRemote: false,
nextCursor: null,
totalCount: 0,
};
}
return yield* createGitCommandError(
"GitCore.listBranches",
Expand Down Expand Up @@ -1735,9 +1777,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
})
: [];

const branches = [...localBranches, ...remoteBranches];
const branches = paginateBranches({
branches: filterBranchesForListQuery(
dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]),
input.query,
),
cursor: input.cursor,
limit: input.limit,
});

return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
return {
branches: [...branches.branches],
isRepo: true,
hasOriginRemote: remoteNames.includes("origin"),
nextCursor: branches.nextCursor,
totalCount: branches.totalCount,
};
});

const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")(
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
],
isRepo: true,
hasOriginRemote: true,
nextCursor: null,
totalCount: 1,
}),
createWorktree: () =>
Effect.succeed({
Expand Down
56 changes: 4 additions & 52 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { GitBranch } from "@t3tools/contracts";
import { Schema } from "effect";
export {
dedupeRemoteBranchesWithLocalMatches,
deriveLocalBranchNameFromRemoteRef,
} from "@t3tools/shared/git";
Comment thread
juliusmarminge marked this conversation as resolved.

export const EnvMode = Schema.Literals(["local", "worktree"]);
export type EnvMode = typeof EnvMode.Type;
Expand Down Expand Up @@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: {
return currentGitBranch ?? activeThreadBranch;
}

export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
const firstSeparatorIndex = branchName.indexOf("/");
if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
return branchName;
}
return branchName.slice(firstSeparatorIndex + 1);
}

function deriveLocalBranchNameCandidatesFromRemoteRef(
branchName: string,
remoteName?: string,
): ReadonlyArray<string> {
const candidates = new Set<string>();
const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName);
if (firstSlashCandidate.length > 0) {
candidates.add(firstSlashCandidate);
}

if (remoteName) {
const remotePrefix = `${remoteName}/`;
if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) {
candidates.add(branchName.slice(remotePrefix.length));
}
}

return [...candidates];
}

export function dedupeRemoteBranchesWithLocalMatches(
branches: ReadonlyArray<GitBranch>,
): ReadonlyArray<GitBranch> {
const localBranchNames = new Set(
branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
);

return branches.filter((branch) => {
if (!branch.isRemote) {
return true;
}

if (branch.remoteName !== "origin") {
return true;
}

const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
branch.name,
branch.remoteName,
);
return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
});
}

export function resolveBranchSelectionTarget(input: {
activeProjectCwd: string;
activeWorktreePath: string | null;
Expand Down
Loading
Loading