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
196 changes: 194 additions & 2 deletions apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import path from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
import { Cause, Effect, FileSystem, Layer, PlatformError, Schema, Scope } from "effect";
import { describe, expect, vi } from "vitest";

import { GitCoreLive, makeGitCore } from "./GitCore.ts";
import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
import { GitCommandError } from "@t3tools/contracts";
import { GitCheckoutDirtyWorktreeError, GitCommandError } from "@t3tools/contracts";
import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
import { ServerConfig } from "../../config.ts";

Expand Down Expand Up @@ -1532,6 +1532,198 @@ it.layer(TestLayer)("git integration", (it) => {
);
});

describe("stashAndCheckout", () => {
it.effect("stashes uncommitted changes, checks out, and pops stash", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

yield* core.createBranch({ cwd: tmp, branch: "feature" });
yield* core.checkoutBranch({ cwd: tmp, branch: "feature" });
yield* writeTextFile(path.join(tmp, "feature.txt"), "feature content\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "add feature file"]);
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });

yield* writeTextFile(path.join(tmp, "README.md"), "dirty changes\n");

yield* core.stashAndCheckout({ cwd: tmp, branch: "feature" });

const branches = yield* core.listBranches({ cwd: tmp });
expect(branches.branches.find((b) => b.current)!.name).toBe("feature");

const stashList = yield* git(tmp, ["stash", "list"]);
expect(stashList.trim()).toBe("");
}),
);

it.effect("includes descriptive stash message", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

yield* core.createBranch({ cwd: tmp, branch: "target-branch" });

yield* writeTextFile(path.join(tmp, "README.md"), "modified\n");

const stashBefore = yield* git(tmp, ["stash", "list"]);
expect(stashBefore.trim()).toBe("");

yield* git(tmp, [
"stash",
"push",
"-u",
"-m",
"t3code: stash before switching to target-branch",
]);
const stashAfter = yield* git(tmp, ["stash", "list"]);
expect(stashAfter).toContain("t3code: stash before switching to target-branch");
yield* git(tmp, ["stash", "pop"]);
}),
);

it.effect("cleans up and preserves stash on pop conflict", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

yield* core.createBranch({ cwd: tmp, branch: "conflicting" });
yield* core.checkoutBranch({ cwd: tmp, branch: "conflicting" });
yield* writeTextFile(path.join(tmp, "README.md"), "conflicting content\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "conflicting change"]);
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });

yield* writeTextFile(path.join(tmp, "README.md"), "local edits that will conflict\n");

const result = yield* Effect.result(
core.stashAndCheckout({ cwd: tmp, branch: "conflicting" }),
);
expect(result._tag).toBe("Failure");

const stashList = yield* git(tmp, ["stash", "list"]);
expect(stashList).toContain("t3code:");
}),
);

it.effect("cleans untracked files from failed stash pop", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

yield* core.createBranch({ cwd: tmp, branch: "other" });
yield* core.checkoutBranch({ cwd: tmp, branch: "other" });
yield* writeTextFile(path.join(tmp, "new-file.txt"), "new file on other\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "add new file on other"]);
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });

yield* writeTextFile(path.join(tmp, "new-file.txt"), "untracked content that conflicts\n");

const result = yield* Effect.result(core.stashAndCheckout({ cwd: tmp, branch: "other" }));
expect(result._tag).toBe("Failure");

const branches = yield* core.listBranches({ cwd: tmp });
expect(branches.branches.find((b) => b.current)!.name).toBe("other");
}),
);

it.effect("repo is usable after stash pop conflict", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

yield* core.createBranch({ cwd: tmp, branch: "conflict-target" });
yield* core.checkoutBranch({ cwd: tmp, branch: "conflict-target" });
yield* writeTextFile(path.join(tmp, "README.md"), "conflicting\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "diverge"]);
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });

yield* writeTextFile(path.join(tmp, "README.md"), "local dirty\n");

yield* Effect.result(core.stashAndCheckout({ cwd: tmp, branch: "conflict-target" }));

const status = yield* core.status({ cwd: tmp });
expect(status.isRepo).toBe(true);
expect(status.hasWorkingTreeChanges).toBe(false);

yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
const branchesAfter = yield* core.listBranches({ cwd: tmp });
expect(branchesAfter.branches.find((b) => b.current)!.name).toBe(initialBranch);
}),
);
});

describe("stashDrop", () => {
it.effect("drops the top stash entry", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

yield* writeTextFile(path.join(tmp, "README.md"), "stashed changes\n");
yield* git(tmp, ["stash", "push", "-m", "test stash"]);

const stashBefore = yield* git(tmp, ["stash", "list"]);
expect(stashBefore).toContain("test stash");

yield* core.stashDrop(tmp);

const stashAfter = yield* git(tmp, ["stash", "list"]);
expect(stashAfter.trim()).toBe("");
}),
);

it.effect("fails when stash is empty", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

const result = yield* Effect.result(core.stashDrop(tmp));
expect(result._tag).toBe("Failure");
}),
);
});

describe("checkoutBranch untracked conflicts", () => {
it.effect("raises GitCheckoutDirtyWorktreeError for untracked file conflicts", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

yield* core.createBranch({ cwd: tmp, branch: "with-tracked-file" });
yield* core.checkoutBranch({ cwd: tmp, branch: "with-tracked-file" });
yield* writeTextFile(path.join(tmp, "conflict.txt"), "tracked content\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "add tracked file"]);
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });

yield* writeTextFile(path.join(tmp, "conflict.txt"), "untracked content\n");

const result = yield* Effect.exit(
core.checkoutBranch({ cwd: tmp, branch: "with-tracked-file" }),
);
expect(result._tag).toBe("Failure");
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
expect(Schema.is(GitCheckoutDirtyWorktreeError)(error)).toBe(true);
if (Schema.is(GitCheckoutDirtyWorktreeError)(error)) {
expect(error.conflictingFiles).toContain("conflict.txt");
expect(error.branch).toBe("with-tracked-file");
}
}
}),
);
});

describe("GitCore", () => {
it.effect("supports branch lifecycle operations through the service API", () =>
Effect.gen(function* () {
Expand Down
136 changes: 130 additions & 6 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { GitCommandError, type GitBranch } from "@t3tools/contracts";
import { GitCheckoutDirtyWorktreeError, GitCommandError, type GitBranch } from "@t3tools/contracts";
import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
import { compactTraceAttributes } from "../../observability/Attributes.ts";
import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../../observability/Metrics.ts";
Expand Down Expand Up @@ -349,6 +349,21 @@ function createGitCommandError(
});
}

const DIRTY_WORKTREE_PATTERN =
/Your local changes to the following files would be overwritten by (?:checkout|merge):\s*([\s\S]*?)Please commit your changes or stash them/;

const UNTRACKED_OVERWRITE_PATTERN =
/The following untracked working tree files would be overwritten by checkout:\s*([\s\S]*?)Please move or remove them/;

function parseDirtyWorktreeFiles(stderr: string): string[] | null {
const match = DIRTY_WORKTREE_PATTERN.exec(stderr) ?? UNTRACKED_OVERWRITE_PATTERN.exec(stderr);
if (!match?.[1]) return null;
return match[1]
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}

function quoteGitCommand(args: ReadonlyArray<string>): string {
return `git ${args.join(" ")}`;
}
Expand Down Expand Up @@ -2077,10 +2092,29 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
? ["checkout", localTrackingBranch]
: ["checkout", input.branch];

yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {
timeoutMs: 10_000,
fallbackErrorMessage: "git checkout failed",
});
const checkoutResult = yield* executeGit(
"GitCore.checkoutBranch.checkout",
input.cwd,
checkoutArgs,
{ timeoutMs: 10_000, allowNonZeroExit: true },
);
if (checkoutResult.code !== 0) {
const dirtyFiles = parseDirtyWorktreeFiles(checkoutResult.stderr);
if (dirtyFiles && dirtyFiles.length > 0) {
return yield* new GitCheckoutDirtyWorktreeError({
branch: input.branch,
cwd: input.cwd,
conflictingFiles: dirtyFiles,
});
}
const stderr = checkoutResult.stderr.trim();
return yield* createGitCommandError(
"GitCore.checkoutBranch.checkout",
input.cwd,
checkoutArgs,
stderr.length > 0 ? stderr : "git checkout failed",
);
}

const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [
"branch",
Expand All @@ -2097,12 +2131,100 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
fallbackErrorMessage: "git branch create failed",
});
if (input.checkout) {
yield* checkoutBranch({ cwd: input.cwd, branch: input.branch });
yield* Effect.scoped(
checkoutBranch({ cwd: input.cwd, branch: input.branch }).pipe(
Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) =>
Effect.fail(
createGitCommandError(
"GitCore.createBranch.checkout",
input.cwd,
["checkout", input.branch],
e.message,
),
),
),
),
);
}

return { branch: input.branch };
});

const stashAndCheckout: GitCoreShape["stashAndCheckout"] = (input) =>
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.

🟡 Medium Layers/GitCore.ts:2118

In stashAndCheckout, if checkoutBranch fails after stash push succeeds, the function exits with an error but the stash remains orphaned on the stack. The user's changes are stranded in the stash with no automatic recovery or notification, and subsequent operations may compound the stash entries without the user realizing their prior work was captured but not restored.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/src/git/Layers/GitCore.ts around line 2118:

In `stashAndCheckout`, if `checkoutBranch` fails after `stash push` succeeds, the function exits with an error but the stash remains orphaned on the stack. The user's changes are stranded in the stash with no automatic recovery or notification, and subsequent operations may compound the stash entries without the user realizing their prior work was captured but not restored.

Evidence trail:
apps/server/src/git/Layers/GitCore.ts lines 2118-2145 at REVIEWED_COMMIT. The `stashAndCheckout` function executes `stash push` (lines 2120-2124), then `checkoutBranch` (lines 2126-2136). If `checkoutBranch` fails, the generator exits early via `Effect.fail()` (line 2129-2134) or error propagation. The `stash pop` code at line 2138 is only reached on checkout success. No recovery logic exists to handle or notify about the orphaned stash when checkout fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional. If checkoutBranch fails after stash push, the user is still on the original branch with their changes on disk — the checkout never happened. The stash entry is a safety net, not an orphan. Auto-popping it would just restore the state they're already in (and could itself fail). The error surfaces to the UI so the user knows the switch didn't happen.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Effect.gen(function* () {
yield* executeGit(
"GitCore.stashAndCheckout.stash",
input.cwd,
["stash", "push", "-u", "-m", `t3code: stash before switching to ${input.branch}`],
{ timeoutMs: 15_000, fallbackErrorMessage: "git stash failed" },
);

yield* checkoutBranch(input).pipe(
Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) =>
Effect.fail(
createGitCommandError(
"GitCore.stashAndCheckout.checkout",
input.cwd,
["checkout", input.branch],
e.message,
),
),
),
);

const popResult = yield* executeGit(
"GitCore.stashAndCheckout.stashPop",
input.cwd,
["stash", "pop"],
{ timeoutMs: 15_000, allowNonZeroExit: true },
);
if (popResult.code !== 0) {
const stashFiles = yield* executeGit(
"GitCore.stashAndCheckout.stashFileList",
input.cwd,
["stash", "show", "--name-only"],
{ timeoutMs: 5_000, allowNonZeroExit: true },
);
yield* executeGit("GitCore.stashAndCheckout.resetIndex", input.cwd, ["reset", "HEAD"], {
timeoutMs: 10_000,
allowNonZeroExit: true,
});
yield* executeGit(
"GitCore.stashAndCheckout.restoreWorktree",
input.cwd,
["checkout", "--", "."],
{ timeoutMs: 10_000, allowNonZeroExit: true },
);
if (stashFiles.code === 0 && stashFiles.stdout.trim().length > 0) {
const filePaths = stashFiles.stdout
.trim()
.split("\n")
.map((f) => f.trim())
.filter((f) => f.length > 0);
if (filePaths.length > 0) {
yield* executeGit(
"GitCore.stashAndCheckout.cleanStashRemnants",
input.cwd,
["clean", "-f", "--", ...filePaths],
{ timeoutMs: 10_000, allowNonZeroExit: true },
);
}
}
return yield* createGitCommandError(
"GitCore.stashAndCheckout.stashPop",
input.cwd,
["stash", "pop"],
"Stash could not be applied to this branch. Your changes are saved in the stash.",
);
}
});

const stashDrop: GitCoreShape["stashDrop"] = (cwd) =>
executeGit("GitCore.stashDrop", cwd, ["stash", "drop"], {
timeoutMs: 10_000,
fallbackErrorMessage: "git stash drop failed",
}).pipe(Effect.asVoid);

const initRepo: GitCoreShape["initRepo"] = (input) =>
executeGit("GitCore.initRepo", input.cwd, ["init"], {
timeoutMs: 10_000,
Expand Down Expand Up @@ -2148,6 +2270,8 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
renameBranch,
createBranch,
checkoutBranch,
stashAndCheckout,
stashDrop,
initRepo,
listLocalBranchNames,
} satisfies GitCoreShape;
Expand Down
Loading
Loading