Skip to content

Commit c3cac0a

Browse files
committed
feat(git): friendly checkout error messages with stash & switch recovery
Replace raw GitCommandError stack traces with structured, user-friendly error handling when branch checkout fails due to uncommitted changes.
1 parent 340dbbb commit c3cac0a

11 files changed

Lines changed: 592 additions & 58 deletions

File tree

apps/server/src/git/Layers/GitCore.test.ts

Lines changed: 194 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import path from "node:path";
33

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

99
import { GitCoreLive, makeGitCore } from "./GitCore.ts";
1010
import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
11-
import { GitCommandError } from "@t3tools/contracts";
11+
import { GitCheckoutDirtyWorktreeError, GitCommandError } from "@t3tools/contracts";
1212
import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
1313
import { ServerConfig } from "../../config.ts";
1414

@@ -1532,6 +1532,198 @@ it.layer(TestLayer)("git integration", (it) => {
15321532
);
15331533
});
15341534

1535+
describe("stashAndCheckout", () => {
1536+
it.effect("stashes uncommitted changes, checks out, and pops stash", () =>
1537+
Effect.gen(function* () {
1538+
const tmp = yield* makeTmpDir();
1539+
const { initialBranch } = yield* initRepoWithCommit(tmp);
1540+
const core = yield* GitCore;
1541+
1542+
yield* core.createBranch({ cwd: tmp, branch: "feature" });
1543+
yield* core.checkoutBranch({ cwd: tmp, branch: "feature" });
1544+
yield* writeTextFile(path.join(tmp, "feature.txt"), "feature content\n");
1545+
yield* git(tmp, ["add", "."]);
1546+
yield* git(tmp, ["commit", "-m", "add feature file"]);
1547+
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
1548+
1549+
yield* writeTextFile(path.join(tmp, "README.md"), "dirty changes\n");
1550+
1551+
yield* core.stashAndCheckout({ cwd: tmp, branch: "feature" });
1552+
1553+
const branches = yield* core.listBranches({ cwd: tmp });
1554+
expect(branches.branches.find((b) => b.current)!.name).toBe("feature");
1555+
1556+
const stashList = yield* git(tmp, ["stash", "list"]);
1557+
expect(stashList.trim()).toBe("");
1558+
}),
1559+
);
1560+
1561+
it.effect("includes descriptive stash message", () =>
1562+
Effect.gen(function* () {
1563+
const tmp = yield* makeTmpDir();
1564+
yield* initRepoWithCommit(tmp);
1565+
const core = yield* GitCore;
1566+
1567+
yield* core.createBranch({ cwd: tmp, branch: "target-branch" });
1568+
1569+
yield* writeTextFile(path.join(tmp, "README.md"), "modified\n");
1570+
1571+
const stashBefore = yield* git(tmp, ["stash", "list"]);
1572+
expect(stashBefore.trim()).toBe("");
1573+
1574+
yield* git(tmp, [
1575+
"stash",
1576+
"push",
1577+
"-u",
1578+
"-m",
1579+
"t3code: stash before switching to target-branch",
1580+
]);
1581+
const stashAfter = yield* git(tmp, ["stash", "list"]);
1582+
expect(stashAfter).toContain("t3code: stash before switching to target-branch");
1583+
yield* git(tmp, ["stash", "pop"]);
1584+
}),
1585+
);
1586+
1587+
it.effect("cleans up and preserves stash on pop conflict", () =>
1588+
Effect.gen(function* () {
1589+
const tmp = yield* makeTmpDir();
1590+
const { initialBranch } = yield* initRepoWithCommit(tmp);
1591+
const core = yield* GitCore;
1592+
1593+
yield* core.createBranch({ cwd: tmp, branch: "conflicting" });
1594+
yield* core.checkoutBranch({ cwd: tmp, branch: "conflicting" });
1595+
yield* writeTextFile(path.join(tmp, "README.md"), "conflicting content\n");
1596+
yield* git(tmp, ["add", "."]);
1597+
yield* git(tmp, ["commit", "-m", "conflicting change"]);
1598+
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
1599+
1600+
yield* writeTextFile(path.join(tmp, "README.md"), "local edits that will conflict\n");
1601+
1602+
const result = yield* Effect.result(
1603+
core.stashAndCheckout({ cwd: tmp, branch: "conflicting" }),
1604+
);
1605+
expect(result._tag).toBe("Failure");
1606+
1607+
const stashList = yield* git(tmp, ["stash", "list"]);
1608+
expect(stashList).toContain("t3code:");
1609+
}),
1610+
);
1611+
1612+
it.effect("cleans untracked files from failed stash pop", () =>
1613+
Effect.gen(function* () {
1614+
const tmp = yield* makeTmpDir();
1615+
const { initialBranch } = yield* initRepoWithCommit(tmp);
1616+
const core = yield* GitCore;
1617+
1618+
yield* core.createBranch({ cwd: tmp, branch: "other" });
1619+
yield* core.checkoutBranch({ cwd: tmp, branch: "other" });
1620+
yield* writeTextFile(path.join(tmp, "new-file.txt"), "new file on other\n");
1621+
yield* git(tmp, ["add", "."]);
1622+
yield* git(tmp, ["commit", "-m", "add new file on other"]);
1623+
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
1624+
1625+
yield* writeTextFile(path.join(tmp, "new-file.txt"), "untracked content that conflicts\n");
1626+
1627+
const result = yield* Effect.result(core.stashAndCheckout({ cwd: tmp, branch: "other" }));
1628+
expect(result._tag).toBe("Failure");
1629+
1630+
const branches = yield* core.listBranches({ cwd: tmp });
1631+
expect(branches.branches.find((b) => b.current)!.name).toBe("other");
1632+
}),
1633+
);
1634+
1635+
it.effect("repo is usable after stash pop conflict", () =>
1636+
Effect.gen(function* () {
1637+
const tmp = yield* makeTmpDir();
1638+
const { initialBranch } = yield* initRepoWithCommit(tmp);
1639+
const core = yield* GitCore;
1640+
1641+
yield* core.createBranch({ cwd: tmp, branch: "conflict-target" });
1642+
yield* core.checkoutBranch({ cwd: tmp, branch: "conflict-target" });
1643+
yield* writeTextFile(path.join(tmp, "README.md"), "conflicting\n");
1644+
yield* git(tmp, ["add", "."]);
1645+
yield* git(tmp, ["commit", "-m", "diverge"]);
1646+
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
1647+
1648+
yield* writeTextFile(path.join(tmp, "README.md"), "local dirty\n");
1649+
1650+
yield* Effect.result(core.stashAndCheckout({ cwd: tmp, branch: "conflict-target" }));
1651+
1652+
const status = yield* core.status({ cwd: tmp });
1653+
expect(status.isRepo).toBe(true);
1654+
expect(status.hasWorkingTreeChanges).toBe(false);
1655+
1656+
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
1657+
const branchesAfter = yield* core.listBranches({ cwd: tmp });
1658+
expect(branchesAfter.branches.find((b) => b.current)!.name).toBe(initialBranch);
1659+
}),
1660+
);
1661+
});
1662+
1663+
describe("stashDrop", () => {
1664+
it.effect("drops the top stash entry", () =>
1665+
Effect.gen(function* () {
1666+
const tmp = yield* makeTmpDir();
1667+
yield* initRepoWithCommit(tmp);
1668+
const core = yield* GitCore;
1669+
1670+
yield* writeTextFile(path.join(tmp, "README.md"), "stashed changes\n");
1671+
yield* git(tmp, ["stash", "push", "-m", "test stash"]);
1672+
1673+
const stashBefore = yield* git(tmp, ["stash", "list"]);
1674+
expect(stashBefore).toContain("test stash");
1675+
1676+
yield* core.stashDrop(tmp);
1677+
1678+
const stashAfter = yield* git(tmp, ["stash", "list"]);
1679+
expect(stashAfter.trim()).toBe("");
1680+
}),
1681+
);
1682+
1683+
it.effect("fails when stash is empty", () =>
1684+
Effect.gen(function* () {
1685+
const tmp = yield* makeTmpDir();
1686+
yield* initRepoWithCommit(tmp);
1687+
const core = yield* GitCore;
1688+
1689+
const result = yield* Effect.result(core.stashDrop(tmp));
1690+
expect(result._tag).toBe("Failure");
1691+
}),
1692+
);
1693+
});
1694+
1695+
describe("checkoutBranch untracked conflicts", () => {
1696+
it.effect("raises GitCheckoutDirtyWorktreeError for untracked file conflicts", () =>
1697+
Effect.gen(function* () {
1698+
const tmp = yield* makeTmpDir();
1699+
const { initialBranch } = yield* initRepoWithCommit(tmp);
1700+
const core = yield* GitCore;
1701+
1702+
yield* core.createBranch({ cwd: tmp, branch: "with-tracked-file" });
1703+
yield* core.checkoutBranch({ cwd: tmp, branch: "with-tracked-file" });
1704+
yield* writeTextFile(path.join(tmp, "conflict.txt"), "tracked content\n");
1705+
yield* git(tmp, ["add", "."]);
1706+
yield* git(tmp, ["commit", "-m", "add tracked file"]);
1707+
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
1708+
1709+
yield* writeTextFile(path.join(tmp, "conflict.txt"), "untracked content\n");
1710+
1711+
const result = yield* Effect.exit(
1712+
core.checkoutBranch({ cwd: tmp, branch: "with-tracked-file" }),
1713+
);
1714+
expect(result._tag).toBe("Failure");
1715+
if (result._tag === "Failure") {
1716+
const error = Cause.squash(result.cause);
1717+
expect(Schema.is(GitCheckoutDirtyWorktreeError)(error)).toBe(true);
1718+
if (Schema.is(GitCheckoutDirtyWorktreeError)(error)) {
1719+
expect(error.conflictingFiles).toContain("conflict.txt");
1720+
expect(error.branch).toBe("with-tracked-file");
1721+
}
1722+
}
1723+
}),
1724+
);
1725+
});
1726+
15351727
describe("GitCore", () => {
15361728
it.effect("supports branch lifecycle operations through the service API", () =>
15371729
Effect.gen(function* () {

apps/server/src/git/Layers/GitCore.ts

Lines changed: 130 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from "effect";
1919
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2020

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

352+
const DIRTY_WORKTREE_PATTERN =
353+
/Your local changes to the following files would be overwritten by (?:checkout|merge):\s*([\s\S]*?)Please commit your changes or stash them/;
354+
355+
const UNTRACKED_OVERWRITE_PATTERN =
356+
/The following untracked working tree files would be overwritten by checkout:\s*([\s\S]*?)Please move or remove them/;
357+
358+
function parseDirtyWorktreeFiles(stderr: string): string[] | null {
359+
const match = DIRTY_WORKTREE_PATTERN.exec(stderr) ?? UNTRACKED_OVERWRITE_PATTERN.exec(stderr);
360+
if (!match?.[1]) return null;
361+
return match[1]
362+
.split(/\r?\n/)
363+
.map((line) => line.trim())
364+
.filter((line) => line.length > 0);
365+
}
366+
352367
function quoteGitCommand(args: ReadonlyArray<string>): string {
353368
return `git ${args.join(" ")}`;
354369
}
@@ -2077,10 +2092,29 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
20772092
? ["checkout", localTrackingBranch]
20782093
: ["checkout", input.branch];
20792094

2080-
yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {
2081-
timeoutMs: 10_000,
2082-
fallbackErrorMessage: "git checkout failed",
2083-
});
2095+
const checkoutResult = yield* executeGit(
2096+
"GitCore.checkoutBranch.checkout",
2097+
input.cwd,
2098+
checkoutArgs,
2099+
{ timeoutMs: 10_000, allowNonZeroExit: true },
2100+
);
2101+
if (checkoutResult.code !== 0) {
2102+
const dirtyFiles = parseDirtyWorktreeFiles(checkoutResult.stderr);
2103+
if (dirtyFiles && dirtyFiles.length > 0) {
2104+
return yield* new GitCheckoutDirtyWorktreeError({
2105+
branch: input.branch,
2106+
cwd: input.cwd,
2107+
conflictingFiles: dirtyFiles,
2108+
});
2109+
}
2110+
const stderr = checkoutResult.stderr.trim();
2111+
return yield* createGitCommandError(
2112+
"GitCore.checkoutBranch.checkout",
2113+
input.cwd,
2114+
checkoutArgs,
2115+
stderr.length > 0 ? stderr : "git checkout failed",
2116+
);
2117+
}
20842118

20852119
const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [
20862120
"branch",
@@ -2097,12 +2131,100 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
20972131
fallbackErrorMessage: "git branch create failed",
20982132
});
20992133
if (input.checkout) {
2100-
yield* checkoutBranch({ cwd: input.cwd, branch: input.branch });
2134+
yield* Effect.scoped(
2135+
checkoutBranch({ cwd: input.cwd, branch: input.branch }).pipe(
2136+
Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) =>
2137+
Effect.fail(
2138+
createGitCommandError(
2139+
"GitCore.createBranch.checkout",
2140+
input.cwd,
2141+
["checkout", input.branch],
2142+
e.message,
2143+
),
2144+
),
2145+
),
2146+
),
2147+
);
21012148
}
21022149

21032150
return { branch: input.branch };
21042151
});
21052152

2153+
const stashAndCheckout: GitCoreShape["stashAndCheckout"] = (input) =>
2154+
Effect.gen(function* () {
2155+
yield* executeGit(
2156+
"GitCore.stashAndCheckout.stash",
2157+
input.cwd,
2158+
["stash", "push", "-u", "-m", `t3code: stash before switching to ${input.branch}`],
2159+
{ timeoutMs: 15_000, fallbackErrorMessage: "git stash failed" },
2160+
);
2161+
2162+
yield* checkoutBranch(input).pipe(
2163+
Effect.catchTag("GitCheckoutDirtyWorktreeError", (e) =>
2164+
Effect.fail(
2165+
createGitCommandError(
2166+
"GitCore.stashAndCheckout.checkout",
2167+
input.cwd,
2168+
["checkout", input.branch],
2169+
e.message,
2170+
),
2171+
),
2172+
),
2173+
);
2174+
2175+
const popResult = yield* executeGit(
2176+
"GitCore.stashAndCheckout.stashPop",
2177+
input.cwd,
2178+
["stash", "pop"],
2179+
{ timeoutMs: 15_000, allowNonZeroExit: true },
2180+
);
2181+
if (popResult.code !== 0) {
2182+
const stashFiles = yield* executeGit(
2183+
"GitCore.stashAndCheckout.stashFileList",
2184+
input.cwd,
2185+
["stash", "show", "--name-only"],
2186+
{ timeoutMs: 5_000, allowNonZeroExit: true },
2187+
);
2188+
yield* executeGit("GitCore.stashAndCheckout.resetIndex", input.cwd, ["reset", "HEAD"], {
2189+
timeoutMs: 10_000,
2190+
allowNonZeroExit: true,
2191+
});
2192+
yield* executeGit(
2193+
"GitCore.stashAndCheckout.restoreWorktree",
2194+
input.cwd,
2195+
["checkout", "--", "."],
2196+
{ timeoutMs: 10_000, allowNonZeroExit: true },
2197+
);
2198+
if (stashFiles.code === 0 && stashFiles.stdout.trim().length > 0) {
2199+
const filePaths = stashFiles.stdout
2200+
.trim()
2201+
.split("\n")
2202+
.map((f) => f.trim())
2203+
.filter((f) => f.length > 0);
2204+
if (filePaths.length > 0) {
2205+
yield* executeGit(
2206+
"GitCore.stashAndCheckout.cleanStashRemnants",
2207+
input.cwd,
2208+
["clean", "-f", "--", ...filePaths],
2209+
{ timeoutMs: 10_000, allowNonZeroExit: true },
2210+
);
2211+
}
2212+
}
2213+
return yield* createGitCommandError(
2214+
"GitCore.stashAndCheckout.stashPop",
2215+
input.cwd,
2216+
["stash", "pop"],
2217+
"Stash could not be applied to this branch. Your changes are saved in the stash.",
2218+
);
2219+
}
2220+
});
2221+
2222+
const stashDrop: GitCoreShape["stashDrop"] = (cwd) =>
2223+
executeGit("GitCore.stashDrop", cwd, ["stash", "drop"], {
2224+
timeoutMs: 10_000,
2225+
fallbackErrorMessage: "git stash drop failed",
2226+
}).pipe(Effect.asVoid);
2227+
21062228
const initRepo: GitCoreShape["initRepo"] = (input) =>
21072229
executeGit("GitCore.initRepo", input.cwd, ["init"], {
21082230
timeoutMs: 10_000,
@@ -2148,6 +2270,8 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
21482270
renameBranch,
21492271
createBranch,
21502272
checkoutBranch,
2273+
stashAndCheckout,
2274+
stashDrop,
21512275
initRepo,
21522276
listLocalBranchNames,
21532277
} satisfies GitCoreShape;

0 commit comments

Comments
 (0)