Skip to content
Closed
52 changes: 52 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,58 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("local status coherence token tracks HEAD, primary remote, and upstream identity", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-coherence-");
yield* initRepo(repoDir);
const originDir = yield* createBareRemote();
const replacementOriginDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
const { manager } = yield* makeManager();

const initial = yield* manager.localStatus({ cwd: repoDir });
expect(initial.coherenceToken).toBeTruthy();
expect(initial.remoteAssociationToken).toBeTruthy();

const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(NodePath.join(repoDir, "WORKTREE-ONLY.md"), "uncommitted\n");
yield* manager.invalidateLocalStatus(repoDir);
const afterWorkingTreeEdit = yield* manager.localStatus({ cwd: repoDir });
expect(afterWorkingTreeEdit.coherenceToken).toBe(initial.coherenceToken);
expect(afterWorkingTreeEdit.remoteAssociationToken).toBe(initial.remoteAssociationToken);

yield* fs.writeFileString(NodePath.join(repoDir, "HEAD-MOVE.md"), "next\n");
yield* runGit(repoDir, ["add", "HEAD-MOVE.md", "WORKTREE-ONLY.md"]);
yield* runGit(repoDir, ["commit", "-m", "Move head"]);
yield* manager.invalidateLocalStatus(repoDir);
const afterHeadMove = yield* manager.localStatus({ cwd: repoDir });
expect(afterHeadMove.coherenceToken).not.toBe(initial.coherenceToken);
expect(afterHeadMove.remoteAssociationToken).toBe(initial.remoteAssociationToken);

yield* runGit(repoDir, ["remote", "remove", "origin"]);
yield* manager.invalidateLocalStatus(repoDir);
const afterPrimaryRemoteRemoval = yield* manager.localStatus({ cwd: repoDir });
expect(afterPrimaryRemoteRemoval.coherenceToken).not.toBe(afterHeadMove.coherenceToken);

yield* runGit(repoDir, ["remote", "add", "origin", replacementOriginDir]);
yield* manager.invalidateLocalStatus(repoDir);
const afterPrimaryRemoteAddition = yield* manager.localStatus({ cwd: repoDir });
expect(afterPrimaryRemoteAddition.coherenceToken).not.toBe(
afterPrimaryRemoteRemoval.coherenceToken,
);

yield* runGit(repoDir, ["remote", "add", "fork", originDir]);
yield* runGit(repoDir, ["fetch", "fork", "main"]);
yield* runGit(repoDir, ["branch", "--set-upstream-to=fork/main", "main"]);
yield* manager.invalidateLocalStatus(repoDir);
const afterUpstreamChange = yield* manager.localStatus({ cwd: repoDir });
expect(afterUpstreamChange.coherenceToken).not.toBe(
afterPrimaryRemoteAddition.coherenceToken,
);
}),
);

it.effect("status skips the provider lookup for a branch that was never pushed", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as NodeCrypto from "node:crypto";

import * as Arr from "effect/Array";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -69,6 +71,11 @@ export interface GitRunStackedActionOptions {
readonly progressReporter?: GitActionProgressReporter;
}

export interface GitLocalStatusIdentity {
readonly coherenceToken: string;
readonly remoteAssociationToken: string;
}

interface SourceControlTextGenerationSettings {
readonly modelSelection: ModelSelection;
readonly style: SourceControlWritingStyleSettings;
Expand All @@ -83,6 +90,9 @@ export class GitManager extends Context.Service<
readonly localStatus: (
input: VcsStatusInput,
) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
readonly localStatusIdentity: (
input: VcsStatusInput,
) => Effect.Effect<GitLocalStatusIdentity, GitManagerServiceError>;
readonly remoteStatus: (
input: VcsStatusInput,
options?: GitVcsDriver.GitRemoteStatusOptions,
Expand Down Expand Up @@ -849,6 +859,7 @@ export const make = Effect.gen(function* () {
const normalizeStatusCacheKey = canonicalizeExistingPath;
const nonRepositoryStatusDetails = {
isRepo: false,
headOid: null,
hasOriginRemote: false,
isDefaultBranch: false,
branch: null,
Expand All @@ -860,6 +871,34 @@ export const make = Effect.gen(function* () {
behindCount: 0,
aheadOfDefaultCount: 0,
} satisfies GitVcsDriver.GitStatusDetails;
const makeLocalStatusIdentity = (
details: Pick<GitVcsDriver.GitStatusDetails, "isRepo" | "headOid" | "branch" | "upstreamRef">,
primaryRemoteUrlKey: string | null,
): GitLocalStatusIdentity => {
const remoteAssociationToken = NodeCrypto.createHash("sha256")
.update(
JSON.stringify({
isRepo: details.isRepo,
refName: details.branch,
upstreamRef: details.upstreamRef,
primaryRemoteUrlKey,
}),
)
.digest("hex");
const coherenceToken = NodeCrypto.createHash("sha256")
.update(JSON.stringify({ remoteAssociationToken, headOid: details.headOid }))
.digest("hex");
return { coherenceToken, remoteAssociationToken };
};
const readPrimaryRemoteUrlKey = Effect.fn("readPrimaryRemoteUrlKey")(function* (cwd: string) {
const url = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
return url === null ? null : normalizeGitRemoteUrl(url);
});
const readLocalStatusIdentity = Effect.fn("readLocalStatusIdentity")(function* (cwd: string) {
const details = yield* gitCore.statusIdentityDetails(cwd);
const primaryRemoteUrlKey = details.isRepo ? yield* readPrimaryRemoteUrlKey(cwd) : null;
return makeLocalStatusIdentity(details, primaryRemoteUrlKey);
});
const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
const details = yield* gitCore
.statusDetailsLocal(cwd)
Expand All @@ -869,13 +908,16 @@ export const make = Effect.gen(function* () {
const hostingProvider = details.isRepo
? yield* resolveHostingProvider(cwd, details.branch)
: null;
const primaryRemoteUrlKey = details.isRepo ? yield* readPrimaryRemoteUrlKey(cwd) : null;
const identity = makeLocalStatusIdentity(details, primaryRemoteUrlKey);

return {
isRepo: details.isRepo,
...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}),
hasPrimaryRemote: details.hasOriginRemote,
isDefaultRef: details.isDefaultBranch,
refName: details.branch,
...(details.isRepo ? identity : {}),
hasWorkingTreeChanges: details.hasWorkingTreeChanges,
workingTree: details.workingTree,
} satisfies VcsStatusLocalResult;
Expand Down Expand Up @@ -1749,6 +1791,12 @@ export const make = Effect.gen(function* () {
return yield* Cache.get(localStatusResultCache, cacheKey);
},
);
const localStatusIdentity: GitManager["Service"]["localStatusIdentity"] = Effect.fn(
"localStatusIdentity",
)(function* (input) {
const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
return yield* readLocalStatusIdentity(cacheKey);
});
const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")(
function* (input, options) {
const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
Expand Down Expand Up @@ -2215,6 +2263,7 @@ export const make = Effect.gen(function* () {

return GitManager.of({
localStatus,
localStatusIdentity,
remoteStatus,
status,
invalidateLocalStatus,
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export class GitWorkflowService extends Context.Service<
readonly localStatus: (
input: VcsStatusInput,
) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
readonly localStatusIdentity: (
input: VcsStatusInput,
) => Effect.Effect<GitManager.GitLocalStatusIdentity, GitManagerServiceError>;
readonly remoteStatus: (
input: VcsStatusInput,
options?: GitVcsDriver.GitRemoteStatusOptions,
Expand Down Expand Up @@ -268,6 +271,25 @@ export const make = Effect.gen(function* () {
: Effect.succeed(nonRepositoryLocalStatus()),
),
),
localStatusIdentity: (input) =>
detectGitRepositoryForStatus("GitWorkflowService.localStatusIdentity", input.cwd).pipe(
Effect.flatMap((isGitRepository) =>
isGitRepository
? gitManager.localStatusIdentity(input)
: Effect.succeed({
coherenceToken: JSON.stringify({
isRepo: false,
refName: null,
hasPrimaryRemote: false,
}),
remoteAssociationToken: JSON.stringify({
isRepo: false,
refName: null,
hasPrimaryRemote: false,
}),
}),
),
),
remoteStatus: (input, options) =>
detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe(
Effect.flatMap((isGitRepository) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no
isRepo: true,
hasOriginRemote: true,
isDefaultBranch: true,
headOid: null,
branch: "main",
upstreamRef: null,
hasWorkingTreeChanges: false,
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 @@ -57,6 +57,7 @@ export interface ExecuteGitResult {

export interface GitStatusDetails {
isRepo: boolean;
headOid: string | null;
sourceControlProvider?: VcsStatusResult["sourceControlProvider"];
hasOriginRemote: boolean;
isDefaultBranch: boolean;
Expand All @@ -81,6 +82,13 @@ export interface GitRemoteStatusDetails {
aheadOfDefaultCount: number;
}

export interface GitStatusIdentityDetails {
isRepo: boolean;
headOid: string | null;
branch: string | null;
upstreamRef: string | null;
}

export interface GitPreparedCommitContext {
stagedSummary: string;
stagedPatch: string;
Expand Down Expand Up @@ -202,6 +210,9 @@ export class GitVcsDriver extends Context.Service<
readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
readonly statusIdentityDetails: (
cwd: string,
) => Effect.Effect<GitStatusIdentityDetails, GitCommandError>;
readonly statusDetailsRemote: (
cwd: string,
options?: GitRemoteStatusOptions,
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({
isRepo: false,
headOid: null,
hasOriginRemote: false,
isDefaultBranch: false,
branch: null,
Expand Down Expand Up @@ -1653,6 +1654,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
);
const statusStdout = statusResult.stdout;

let headOid: string | null = null;
let refName: string | null = null;
let upstreamRef: string | null = null;
let aheadCount = 0;
Expand All @@ -1662,6 +1664,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const changedFilesWithoutNumstat = new Set<string>();

for (const line of statusStdout.split(/\r?\n/g)) {
if (line.startsWith("# branch.oid ")) {
const value = line.slice("# branch.oid ".length).trim();
headOid = value === "(initial)" ? null : value;
continue;
}
if (line.startsWith("# branch.head ")) {
const value = line.slice("# branch.head ".length).trim();
refName = value.startsWith("(") ? null : value;
Expand Down Expand Up @@ -1731,6 +1738,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*

return {
isRepo: true,
headOid,
hasOriginRemote: hasPrimaryRemote,
isDefaultBranch,
branch: refName,
Expand Down Expand Up @@ -2614,6 +2622,55 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero),
},
);

const statusIdentityDetails: GitVcsDriver.GitVcsDriver["Service"]["statusIdentityDetails"] =
Effect.fn("statusIdentityDetails")(function* (cwd) {
const result = yield* executeGitWithStableDiagnostics(
"GitVcsDriver.statusIdentityDetails",
cwd,
["status", "--porcelain=2", "--branch", "--untracked-files=no"],
{ allowNonZeroExit: true },
).pipe(
Effect.catchTags({
GitCommandError: (error) =>
isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
}),
);

if (result === null || isNonRepositoryGitStderr(result.stderr)) {
return { isRepo: false, headOid: null, branch: null, upstreamRef: null };
}
if (result.exitCode !== 0) {
return yield* new GitCommandError({
...gitCommandContext({
operation: "GitVcsDriver.statusIdentityDetails",
cwd,
args: ["status", "--porcelain=2", "--branch", "--untracked-files=no"],
}),
detail: "Git status identity lookup failed.",
exitCode: result.exitCode,
stdoutLength: result.stdout.length,
stderrLength: result.stderr.length,
});
}

let headOid: string | null = null;
let branch: string | null = null;
let upstreamRef: string | null = null;
for (const line of result.stdout.split(/\r?\n/g)) {
if (line.startsWith("# branch.oid ")) {
const value = line.slice("# branch.oid ".length).trim();
headOid = value === "(initial)" ? null : value;
} else if (line.startsWith("# branch.head ")) {
const value = line.slice("# branch.head ".length).trim();
branch = value.startsWith("(") ? null : value;
} else if (line.startsWith("# branch.upstream ")) {
const value = line.slice("# branch.upstream ".length).trim();
upstreamRef = value.length > 0 ? value : null;
}
}
return { isRepo: true, headOid, branch, upstreamRef };
});
const listRefsRefreshSnapshotCache = yield* Cache.makeWith(
(cacheKey: GitRefsRefreshCacheKey) =>
Effect.suspend(() => {
Expand Down Expand Up @@ -3056,6 +3113,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
status,
statusDetails,
statusDetailsLocal,
statusIdentityDetails,
statusDetailsRemote,
prepareCommitContext,
commit: (cwd, subject, body, options) =>
Expand Down
Loading
Loading