Skip to content
Closed
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/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,77 @@ const GitManagerTestLayer = GitCoreLive.pipe(
);

it.layer(GitManagerTestLayer)("GitManager", (it) => {
it.effect("returns compact totals for every working-tree diff scope", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("synara-git-manager-stats-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/diff-stats"]);

fs.writeFileSync(path.join(repoDir, "branch.txt"), "branch\n");
yield* runGit(repoDir, ["add", "branch.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Add branch file"]);

fs.writeFileSync(path.join(repoDir, "staged.txt"), "staged\n");
yield* runGit(repoDir, ["add", "staged.txt"]);
fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nunstaged\n");
fs.writeFileSync(path.join(repoDir, "untracked.txt"), "first\nsecond\n");

const { manager } = yield* makeManager();

expect(yield* manager.readWorkingTreeDiffStats({ cwd: repoDir, scope: "branch" })).toEqual({
additions: 1,
deletions: 0,
fileCount: 1,
});
expect(yield* manager.readWorkingTreeDiffStats({ cwd: repoDir, scope: "staged" })).toEqual({
additions: 1,
deletions: 0,
fileCount: 1,
});
expect(yield* manager.readWorkingTreeDiffStats({ cwd: repoDir, scope: "unstaged" })).toEqual({
additions: 3,
deletions: 0,
fileCount: 2,
});
expect(
yield* manager.readWorkingTreeDiffStats({ cwd: repoDir, scope: "workingTree" }),
).toEqual({ additions: 4, deletions: 0, fileCount: 3 });
}),
);

it.effect("returns zero totals for a clean scope", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("synara-git-manager-stats-clean-");
yield* initRepo(repoDir);
const { manager } = yield* makeManager();

expect(yield* manager.readWorkingTreeDiffStats({ cwd: repoDir, scope: "staged" })).toEqual({
additions: 0,
deletions: 0,
fileCount: 0,
});
}),
);

it.effect("preserves git errors when stats cannot read a repository", () =>
Effect.gen(function* () {
const directory = yield* makeTempDir("synara-git-manager-stats-error-");
const { manager } = yield* makeManager();

const error = yield* manager
.readWorkingTreeDiffStats({
cwd: path.join(directory, "missing"),
scope: "workingTree",
})
.pipe(Effect.flip);

expect(error).toBeInstanceOf(GitCommandError);
}),
);

it.effect("status includes PR metadata when branch already has an open PR", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("synara-git-manager-");
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
sanitizeFeatureBranchName,
} from "@synara/shared/git";
import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@synara/shared/githubRepository";
import { summarizeUnifiedPatchTotals } from "@synara/shared/unifiedPatchStats";
import { resolveWorktreeHandoffIntent } from "@synara/shared/worktreeHandoff";

import { GitManagerError } from "../Errors.ts";
Expand Down Expand Up @@ -1392,6 +1393,13 @@ export const makeGitManager = Effect.gen(function* () {
},
);

const readWorkingTreeDiffStats: GitManagerShape["readWorkingTreeDiffStats"] = Effect.fnUntraced(
function* (input) {
const { patch } = yield* readWorkingTreeDiff(input);
return summarizeUnifiedPatchTotals(patch) ?? { additions: 0, deletions: 0, fileCount: 0 };
},
);

// Keep diff summaries read-only by summarizing the patch already selected in the UI.
const summarizeDiff: GitManagerShape["summarizeDiff"] = Effect.fnUntraced(function* (input) {
const patch = input.patch.trim();
Expand Down Expand Up @@ -2754,6 +2762,7 @@ The local stash entry was kept for recovery.`,
return {
status,
readWorkingTreeDiff,
readWorkingTreeDiffStats,
summarizeDiff,
resolvePullRequest,
pullRequestSnapshot,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/git/Layers/GitStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ function makeTestLayer(state: {
return state.currentStatus;
}),
readWorkingTreeDiff: () => Effect.die("readWorkingTreeDiff should not be called in this test"),
readWorkingTreeDiffStats: () =>
Effect.die("readWorkingTreeDiffStats should not be called in this test"),
summarizeDiff: () => Effect.die("summarizeDiff should not be called in this test"),
resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"),
pullRequestSnapshot: () => Effect.die("pullRequestSnapshot should not be called in this test"),
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/git/Services/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
GitSummarizeDiffResult,
} from "@synara/contracts";
import type { AuthorizedGitRunStackedActionInput } from "@synara/shared/gitMutationRpc";
import type { GitWorkingTreeDiffStatsResult } from "@synara/shared/gitDiffStatsRpc";
import { ServiceMap } from "effect";
import type { Effect } from "effect";
import type { GitManagerServiceError } from "../Errors.ts";
Expand Down Expand Up @@ -56,6 +57,11 @@ export interface GitManagerShape {
input: GitReadWorkingTreeDiffInput,
) => Effect.Effect<GitReadWorkingTreeDiffResult, GitManagerServiceError>;

/** Count one working-tree diff scope without returning its patch text. */
readonly readWorkingTreeDiffStats: (
input: GitReadWorkingTreeDiffInput,
) => Effect.Effect<GitWorkingTreeDiffStatsResult, GitManagerServiceError>;

/**
* Generate a read-only markdown summary for an existing diff patch.
*/
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/wsRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {
type AuthorizedGitPullInput,
type AuthorizedGitRunStackedActionInput,
} from "@synara/shared/gitMutationRpc";
import {
GIT_WORKING_TREE_DIFF_STATS_METHOD,
GitDiffStatsRpcGroup,
} from "@synara/shared/gitDiffStatsRpc";

import { AutomationService } from "./automation/Services/AutomationService";
import { authErrorResponse, makeEffectAuthRequest } from "./auth/http";
Expand Down Expand Up @@ -95,7 +99,8 @@ import { cloneProjectSource, getRepositorySourceStatuses } from "./projectSource

const MAX_DIAGNOSTIC_CHILD_PROCESSES = 80;
const MAX_DIAGNOSTIC_ARGS_CHARS = 500;
const ScientWsRpcGroup = LiveHtmlPreviewRpcGroup.merge(GitMutationRpcGroup);
const ScientWsRpcGroup =
LiveHtmlPreviewRpcGroup.merge(GitMutationRpcGroup).merge(GitDiffStatsRpcGroup);

// Relative subdirectories scaffolded under a freshly created chat container workspace root.
// The Studio layout lives in studioWorkspaceScaffold.ts alongside its instruction files.
Expand Down Expand Up @@ -864,6 +869,11 @@ export const makeWsRpcLayer = () =>
rpcEffect(gitStatusBroadcaster.getStatus(input), "Failed to read git status"),
[WS_METHODS.gitReadWorkingTreeDiff]: (input) =>
rpcEffect(gitManager.readWorkingTreeDiff(input), "Failed to read working tree diff"),
[GIT_WORKING_TREE_DIFF_STATS_METHOD]: (input) =>
rpcEffect(
gitManager.readWorkingTreeDiffStats(input),
"Failed to read working tree diff stats",
),
[WS_METHODS.gitSummarizeDiff]: (input) =>
rpcEffect(gitManager.summarizeDiff(input), "Failed to summarize diff"),
[WS_METHODS.gitPull]: (input) => {
Expand Down
43 changes: 41 additions & 2 deletions apps/web/src/components/DiffPanel.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isDiffPanelPickerOptionSelected,
isStaleDiffTurnSelection,
resolveConversationCacheScope,
resolveDiffPanelCompactScopeCountQueryEnabled,
resolveDiffPanelGitStatusQueriesEnabled,
resolveDiffPanelQueriesEnabled,
resolveDiffPanelRepoLiveRefresh,
Expand Down Expand Up @@ -160,6 +161,36 @@ describe("diff panel view source helpers", () => {
expect(
resolveDiffPanelScopeCountQueriesEnabled({ queriesEnabled: true, scopePickerOpen: true }),
).toBe(true);

const activeRepoSource = { kind: "repo", scope: "unstaged" } as const;
expect(
resolveDiffPanelCompactScopeCountQueryEnabled({
queriesEnabled: true,
scope: "unstaged",
viewSource: activeRepoSource,
}),
).toBe(false);
expect(
resolveDiffPanelCompactScopeCountQueryEnabled({
queriesEnabled: true,
scope: "staged",
viewSource: activeRepoSource,
}),
).toBe(true);
expect(
resolveDiffPanelCompactScopeCountQueryEnabled({
queriesEnabled: false,
scope: "staged",
viewSource: activeRepoSource,
}),
).toBe(false);
expect(
resolveDiffPanelCompactScopeCountQueryEnabled({
queriesEnabled: true,
scope: "unstaged",
viewSource: { kind: "turn", turnId: null },
}),
).toBe(true);
});

it("only enables git status work for repo diffs with a cwd", () => {
Expand All @@ -186,7 +217,7 @@ describe("diff panel view source helpers", () => {
).toBe(false);
});

it("only surfaces scope file counts for the active scope until the picker opens", () => {
it("keeps the rendered active scope count authoritative over stale compact stats", () => {
expect(
resolveDiffPanelScopeFileCounts({
viewSource: { kind: "repo", scope: "unstaged" },
Expand All @@ -200,9 +231,17 @@ describe("diff panel view source helpers", () => {
viewSource: { kind: "repo", scope: "unstaged" },
activeScopeFileCount: 3,
scopePickerOpen: true,
pickerScopeCounts: { unstaged: 3, staged: 1 },
pickerScopeCounts: { unstaged: 99, staged: 1 },
}),
).toEqual({ unstaged: 3, staged: 1 });
expect(
resolveDiffPanelScopeFileCounts({
viewSource: { kind: "repo", scope: "unstaged" },
activeScopeFileCount: undefined,
scopePickerOpen: true,
pickerScopeCounts: { unstaged: 99, staged: 1 },
}),
).toEqual({ staged: 1 });
});

it("only polls repo diffs while a turn is live and the repo view is active", () => {
Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/components/DiffPanel.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ export function resolveDiffPanelScopeCountQueriesEnabled(input: {
return input.queriesEnabled && input.scopePickerOpen;
}

/** Avoid a second request for the active repo scope: its rendered patch is authoritative. */
export function resolveDiffPanelCompactScopeCountQueryEnabled(input: {
queriesEnabled: boolean;
scope: RepoDiffScope;
viewSource: DiffPanelViewSource;
}): boolean {
return (
!(input.viewSource.kind === "repo" && input.viewSource.scope === input.scope) &&
input.queriesEnabled
);
}

export function resolveDiffPanelGitStatusQueriesEnabled(input: {
queriesEnabled: boolean;
activeCwd: string | null;
Expand All @@ -134,7 +146,16 @@ export function resolveDiffPanelScopeFileCounts(input: {
pickerScopeCounts: Partial<Record<RepoDiffScope, number>>;
}): Partial<Record<RepoDiffScope, number>> {
if (input.scopePickerOpen) {
return input.pickerScopeCounts;
const counts = { ...input.pickerScopeCounts };
if (input.viewSource.kind === "repo") {
// A disabled compact query can retain old cached data. Never let it override the
// currently rendered patch (including the rendered empty state).
delete counts[input.viewSource.scope];
if (typeof input.activeScopeFileCount === "number" && input.activeScopeFileCount > 0) {
counts[input.viewSource.scope] = input.activeScopeFileCount;
}
}
return counts;
}
if (
input.viewSource.kind === "repo" &&
Expand Down
Loading
Loading