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
71 changes: 71 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 Expand Up @@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => {
) {
return ok();
}
if (input.operation === "GitCore.statusDetails.defaultRef") {
return ok("refs/remotes/origin/main\n");
}
return Effect.fail(
new GitCommandError({
operation: input.operation,
Expand Down Expand Up @@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => {
) {
return ok();
}
if (input.operation === "GitCore.statusDetails.defaultRef") {
return ok("refs/remotes/origin/main\n");
}
return Effect.fail(
new GitCommandError({
operation: input.operation,
Expand Down
123 changes: 112 additions & 11 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 @@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));

const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
[
runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]),
runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]),
],
{ concurrency: "unbounded" },
const statusResult = yield* executeGit(
"GitCore.statusDetails.status",
cwd,
["status", "--porcelain=2", "--branch"],
{
allowNonZeroExit: true,
},
);

if (statusResult.code !== 0) {
const stderr = statusResult.stderr.trim();
return yield* createGitCommandError(
"GitCore.statusDetails.status",
cwd,
["status", "--porcelain=2", "--branch"],
stderr || "git status failed",
);
}

const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] =
yield* Effect.all(
[
runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [
"diff",
"--cached",
"--numstat",
]),
executeGit(
"GitCore.statusDetails.defaultRef",
cwd,
["symbolic-ref", "refs/remotes/origin/HEAD"],
{
allowNonZeroExit: true,
},
),
originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))),
],
{ concurrency: "unbounded" },
);
const statusStdout = statusResult.stdout;
const defaultBranch =
defaultRefResult.code === 0
? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
: null;

let branch: string | null = null;
let upstreamRef: string | null = null;
let aheadCount = 0;
Expand Down Expand Up @@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
files.sort((a, b) => a.path.localeCompare(b.path));

return {
isRepo: true,
hasOriginRemote,
isDefaultBranch:
branch !== null &&
(branch === defaultBranch ||
(defaultBranch === null && (branch === "main" || branch === "master"))),
branch,
upstreamRef,
hasWorkingTreeChanges,
Expand All @@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
const status: GitCoreShape["status"] = (input) =>
statusDetails(input.cwd).pipe(
Effect.map((details) => ({
isRepo: details.isRepo,
hasOriginRemote: details.hasOriginRemote,
isDefaultBranch: details.isDefaultBranch,
branch: details.branch,
hasWorkingTreeChanges: details.hasWorkingTreeChanges,
workingTree: details.workingTree,
Expand Down Expand Up @@ -1571,7 +1653,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 +1823,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
29 changes: 29 additions & 0 deletions apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
});

const status = yield* manager.status({ cwd: repoDir });
expect(status.isRepo).toBe(true);
expect(status.hasOriginRemote).toBe(true);
expect(status.isDefaultBranch).toBe(false);
expect(status.branch).toBe("feature/status-open-pr");
expect(status.pr).toEqual({
number: 13,
Expand All @@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("status returns an explicit non-repo result for non-git directories", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
const { manager } = yield* makeManager();

const status = yield* manager.status({ cwd });

expect(status).toEqual({
isRepo: false,
hasOriginRemote: false,
isDefaultBranch: false,
branch: null,
hasWorkingTreeChanges: false,
workingTree: {
files: [],
insertions: 0,
deletions: 0,
},
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
pr: null,
});
}),
);

it.effect("status briefly caches repeated lookups for the same cwd", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
Loading
Loading