Skip to content
Open
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
27 changes: 27 additions & 0 deletions apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,33 @@ describe("GitWorkflowService", () => {
}).pipe(Effect.provide(testLayer));
});

it.effect("routes explicit upstream refreshes through the Git driver", () => {
const refreshStatusUpstream = vi.fn(() => Effect.void);
const testLayer = GitWorkflowService.layer.pipe(
Layer.provide(
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
detect: () =>
Effect.succeed({
kind: "git",
} as VcsDriverRegistry.VcsDriverHandle),
}),
),
Layer.provide(
Layer.mock(GitVcsDriver.GitVcsDriver)({
refreshStatusUpstream,
}),
),
Layer.provide(Layer.mock(GitManager.GitManager)({})),
);

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
yield* workflow.refreshStatusUpstream("/repo");

assert.deepStrictEqual(refreshStatusUpstream.mock.calls, [["/repo"]]);
}).pipe(Effect.provide(testLayer));
});

it.effect("returns an empty ref list when no VCS repository is detected", () =>
Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class GitWorkflowService extends Context.Service<
input: VcsStatusInput,
options?: GitVcsDriver.GitRemoteStatusOptions,
) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
readonly refreshStatusUpstream: (cwd: string) => Effect.Effect<void, GitManagerServiceError>;
readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
Expand Down Expand Up @@ -270,6 +271,12 @@ export const make = Effect.gen(function* () {
isGitRepository ? gitManager.remoteStatus(input, options) : Effect.succeed(null),
),
),
refreshStatusUpstream: (cwd) =>
detectGitRepositoryForStatus("GitWorkflowService.refreshStatusUpstream", cwd).pipe(
Effect.flatMap((isGitRepository) =>
isGitRepository ? git.refreshStatusUpstream(cwd) : Effect.void,
),
),
invalidateLocalStatus: gitManager.invalidateLocalStatus,
invalidateRemoteStatus: gitManager.invalidateRemoteStatus,
invalidateStatus: gitManager.invalidateStatus,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export class GitVcsDriver extends Context.Service<
cwd: string,
options?: GitRemoteStatusOptions,
) => Effect.Effect<GitRemoteStatusDetails, GitCommandError>;
readonly refreshStatusUpstream: (cwd: string) => Effect.Effect<void, GitCommandError>;
readonly prepareCommitContext: (
cwd: string,
filePaths?: readonly string[],
Expand Down
120 changes: 120 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,48 @@ const git = (
return result.stdout.trim();
});

function withProcessEnvironment<A, E, R>(
updates: Readonly<Record<string, string | undefined>>,
effect: Effect.Effect<A, E, R>,
): Effect.Effect<A, E, R> {
return Effect.suspend(() => {
const previous = new Map(Object.keys(updates).map((key) => [key, process.env[key]] as const));
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
return effect.pipe(
Effect.ensuring(
Effect.sync(() => {
for (const [key, value] of previous) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}),
),
);
});
}

const configureFailingSshUpstream = Effect.fn("configureFailingSshUpstream")(function* (input: {
readonly cwd: string;
readonly scriptDir: string;
readonly initialBranch: string;
}) {
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const sshWrapperPath = pathService.join(input.scriptDir, "ssh-wrapper.sh");
yield* fileSystem.writeFileString(
sshWrapperPath,
["#!/bin/sh", 'printf "leaked\\n" > "$T3_TEST_TMP_PACK_PATH"', "exit 1", ""].join("\n"),
);
yield* fileSystem.chmod(sshWrapperPath, 0o755);
yield* git(input.cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]);
yield* git(input.cwd, ["update-ref", `refs/remotes/origin/${input.initialBranch}`, "HEAD"]);
yield* git(input.cwd, ["branch", "--set-upstream-to", `origin/${input.initialBranch}`]);
return sshWrapperPath;
});

const initRepoWithCommit = (
cwd: string,
): Effect.Effect<
Expand Down Expand Up @@ -461,6 +503,84 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("removes newly-created temporary packs after a failed upstream refresh", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const scriptDir = yield* makeTmpDir("git-vcs-driver-failed-fetch-");
const { initialBranch } = yield* initRepoWithCommit(cwd);
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const gitCommonDir = pathService.join(cwd, ".git");
const packDir = pathService.join(gitCommonDir, "objects", "pack");
const existingTemporaryPack = pathService.join(packDir, "tmp_pack_existing");
const leakedTemporaryPack = pathService.join(packDir, "tmp_pack_new");

yield* fileSystem.makeDirectory(packDir, { recursive: true });
yield* fileSystem.writeFileString(existingTemporaryPack, "existing\n");
const sshWrapperPath = yield* configureFailingSshUpstream({
cwd,
scriptDir,
initialBranch,
});

const driver = yield* GitVcsDriver.GitVcsDriver;
const error = yield* withProcessEnvironment(
{
GIT_SSH: sshWrapperPath,
GIT_SSH_COMMAND: undefined,
T3_TEST_TMP_PACK_PATH: leakedTemporaryPack,
},
driver.refreshStatusUpstream(cwd).pipe(Effect.flip),
);

assert.equal(error.operation, "GitVcsDriver.fetchRemoteForStatus");
assert.isTrue(yield* fileSystem.exists(existingTemporaryPack));
assert.isFalse(yield* fileSystem.exists(leakedTemporaryPack));
}),
);

it.effect("preserves new temporary packs while a linked worktree fetch lock is active", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const scriptDir = yield* makeTmpDir("git-vcs-driver-locked-fetch-");
const { initialBranch } = yield* initRepoWithCommit(cwd);
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const gitCommonDir = pathService.join(cwd, ".git");
const packDir = pathService.join(gitCommonDir, "objects", "pack");
const leakedTemporaryPack = pathService.join(packDir, "tmp_pack_new");
const linkedWorktreeLock = pathService.join(
gitCommonDir,
"worktrees",
"other",
"FETCH_HEAD.lock",
);

yield* fileSystem.makeDirectory(packDir, { recursive: true });
yield* fileSystem.makeDirectory(pathService.dirname(linkedWorktreeLock), {
recursive: true,
});
yield* fileSystem.writeFileString(linkedWorktreeLock, "locked\n");
const sshWrapperPath = yield* configureFailingSshUpstream({
cwd,
scriptDir,
initialBranch,
});

const driver = yield* GitVcsDriver.GitVcsDriver;
yield* withProcessEnvironment(
{
GIT_SSH: sshWrapperPath,
GIT_SSH_COMMAND: undefined,
T3_TEST_TMP_PACK_PATH: leakedTemporaryPack,
},
driver.refreshStatusUpstream(cwd).pipe(Effect.flip),
);

assert.isTrue(yield* fileSystem.exists(leakedTemporaryPack));
}),
);

it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
149 changes: 141 additions & 8 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;
const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;
const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;
const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.minutes(1);

const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30);
const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
const TEMPORARY_PACK_FILE_PREFIX = "tmp_pack_";
const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
GCM_INTERACTIVE: "never",
GIT_ASKPASS: "",
Expand Down Expand Up @@ -920,23 +921,149 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
);
});

const fetchRemoteForStatus = (
const readTemporaryPackFiles = Effect.fn("GitVcsDriver.readTemporaryPackFiles")(function* (
gitCommonDir: string,
): Effect.fn.Return<ReadonlySet<string> | null> {
const packDir = path.join(gitCommonDir, "objects", "pack");
const entries = yield* fileSystem.readDirectory(packDir, { recursive: false }).pipe(
Effect.map((entries) => entries as ReadonlyArray<string>),
Effect.catchTags({
PlatformError: (error) =>
error.reason._tag === "NotFound"
? Effect.succeed([] as ReadonlyArray<string>)
: Effect.logWarning("Unable to inspect temporary Git pack files", {
reason: error.reason._tag,
}).pipe(Effect.as(null)),
}),
);
if (entries === null) {
return null;
}
return new Set(entries.filter((entry) => entry.startsWith(TEMPORARY_PACK_FILE_PREFIX)));
});

const listFetchHeadLockPaths = Effect.fn("GitVcsDriver.listFetchHeadLockPaths")(function* (
gitCommonDir: string,
): Effect.fn.Return<ReadonlyArray<string> | null> {
const worktreesDir = path.join(gitCommonDir, "worktrees");
const worktreeEntries = yield* fileSystem
.readDirectory(worktreesDir, { recursive: false })
.pipe(
Effect.map((entries) => entries as ReadonlyArray<string>),
Effect.catchTags({
PlatformError: (error) =>
error.reason._tag === "NotFound"
? Effect.succeed([] as ReadonlyArray<string>)
: Effect.logWarning("Unable to inspect linked worktree fetch locks", {
reason: error.reason._tag,
}).pipe(Effect.as(null)),
}),
);
if (worktreeEntries === null) {
return null;
}
return [
path.join(gitCommonDir, "FETCH_HEAD.lock"),
...worktreeEntries.map((entry) => path.join(worktreesDir, entry, "FETCH_HEAD.lock")),
];
});

const hasActiveFetchLock = Effect.fn("GitVcsDriver.hasActiveFetchLock")(function* (
gitCommonDir: string,
): Effect.fn.Return<boolean> {
const lockPaths = yield* listFetchHeadLockPaths(gitCommonDir);
if (lockPaths === null) {
return true;
}
for (const lockPath of lockPaths) {
const exists = yield* fileSystem.exists(lockPath).pipe(
Effect.catchTags({
PlatformError: (error) =>
Effect.logWarning("Unable to inspect a Git fetch lock", {
reason: error.reason._tag,
}).pipe(Effect.as(true)),
}),
);
if (exists) {
return true;
}
}
return false;
});

const removeNewTemporaryPackFiles = Effect.fn("GitVcsDriver.removeNewTemporaryPackFiles")(
function* (gitCommonDir: string, filesBeforeFetch: ReadonlySet<string> | null) {
if (filesBeforeFetch === null) {
return;
}
const filesAfterFetch = yield* readTemporaryPackFiles(gitCommonDir);
if (filesAfterFetch === null) {
return;
}
const newFiles = [...filesAfterFetch].filter((entry) => !filesBeforeFetch.has(entry));
if (newFiles.length === 0) {
return;
}
if (yield* hasActiveFetchLock(gitCommonDir)) {
yield* Effect.logWarning(
"Skipped temporary Git pack cleanup while a fetch lock is active",
{
temporaryPackCount: newFiles.length,
},
);
return;
}

const packDir = path.join(gitCommonDir, "objects", "pack");
const removalResults = yield* Effect.forEach(
newFiles,
(entry) =>
fileSystem.remove(path.join(packDir, entry), { force: true }).pipe(
Effect.as(true),
Effect.catchTags({
PlatformError: () => Effect.succeed(false),
}),
),
{ concurrency: 1 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deletes unrelated fetch temp packs

Medium Severity

After a failed status fetchRemoteForStatus, cleanup removes every new tmp_pack_* name versus a pre-fetch snapshot, not packs created only by that fetch. Another git fetch (for example fetchRemote) can fail, release FETCH_HEAD.lock, and leave temp packs that this path then deletes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5834060. Configure here.

);
const removedCount = removalResults.filter(Boolean).length;
if (removedCount > 0) {
yield* Effect.logWarning("Removed temporary Git pack files after a failed fetch", {
temporaryPackCount: removedCount,
});
}
if (removedCount < newFiles.length) {
yield* Effect.logWarning("Failed to remove some temporary Git pack files", {
temporaryPackCount: newFiles.length - removedCount,
});
}
},
);

const fetchRemoteForStatus = Effect.fn("GitVcsDriver.fetchRemoteForStatus")(function* (
gitCommonDir: string,
remoteName: string,
): Effect.Effect<void, GitCommandError> => {
) {
const fetchCwd =
path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
return executeGit(
const temporaryPackFilesBeforeFetch = yield* readTemporaryPackFiles(gitCommonDir);
yield* executeGit(
"GitVcsDriver.fetchRemoteForStatus",
fetchCwd,
["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
{
allowNonZeroExit: true,
env: STATUS_UPSTREAM_REFRESH_ENV,
fallbackErrorDetail: "Background Git fetch failed.",
timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
},
).pipe(Effect.asVoid);
};
).pipe(
Effect.onExit((exit) =>
Exit.isSuccess(exit)
? Effect.void
: removeNewTemporaryPackFiles(gitCommonDir, temporaryPackFilesBeforeFetch),
),
);
});

const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) {
const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [
Expand Down Expand Up @@ -977,6 +1104,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
);
});

const refreshStatusUpstream: GitVcsDriver.GitVcsDriver["Service"]["refreshStatusUpstream"] =
Effect.fn("refreshStatusUpstream")(function* (cwd) {
yield* refreshStatusUpstreamIfStale(cwd);
});

const resolveDefaultBranchName = (
cwd: string,
remoteName: string,
Expand Down Expand Up @@ -2535,6 +2667,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
statusDetails,
statusDetailsLocal,
statusDetailsRemote,
refreshStatusUpstream,
prepareCommitContext,
commit,
pushCurrentBranch,
Expand Down
Loading
Loading