Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e7ca8cb
feat(git): friendly checkout error messages with stash & switch recovery
Marve10s Apr 6, 2026
3699a1b
feat(git): wire stash recovery into EnvironmentApi and fix optimistic UI
Marve10s Apr 9, 2026
5fa9a95
Fix client settings type coverage for thread preview count
Marve10s Apr 10, 2026
a4d5318
Match client settings fixture to desktop contract
Marve10s Apr 10, 2026
766b49a
fix(git): add missing refreshGitStatus to stashDrop and handle persis…
Marve10s Apr 10, 2026
7025fb7
Remove unnecessary git checkout scope requirement
Marve10s Apr 16, 2026
3c844c6
Skip stash pop when stash push creates no entry
Marve10s Apr 16, 2026
bd4148c
fix(git): preserve dirty worktree metadata
Marve10s Apr 17, 2026
fb38720
Merge upstream/main into feat/checkout-dirty-worktree-error-handling
Marve10s Apr 17, 2026
91709b3
fix(git): handle ignored checkout conflicts
Marve10s Apr 18, 2026
301e9cc
fix(git): address review comments on checkout error handling
Marve10s Apr 20, 2026
146b672
style: apply oxfmt to checkout error handling files
Marve10s Apr 20, 2026
c240917
Merge remote-tracking branch 'upstream/main' into feat/checkout-dirty…
Marve10s Apr 24, 2026
26c156d
Add stash discard recovery modal
Marve10s Apr 24, 2026
9345413
fix(web): guard stash discard branch action state
Marve10s Apr 29, 2026
97f198a
fix(web): report rejected nested branch actions
Marve10s Apr 29, 2026
3b76843
fix(server): refresh git status after stash checkout failure
Marve10s Apr 29, 2026
144ec1a
Merge upstream/main into feat/checkout-dirty-worktree-error-handling
Marve10s May 11, 2026
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
293 changes: 291 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 @@ -58,6 +58,15 @@ function makeDirectory(
});
}

function readTextFile(
filePath: string,
): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem> {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
return yield* fileSystem.readFileString(filePath);
});
}

/** Run a raw git command for test setup (not under test). */
function git(
cwd: string,
Expand Down Expand Up @@ -1620,6 +1629,286 @@ 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("skips stash pop when stash push creates no entry", () =>
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* 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);

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"]);
}),
);
Comment thread
Marve10s marked this conversation as resolved.
Outdated

it.effect("restores local changes when checkout fails after stashing", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

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

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

const readme = yield* readTextFile(path.join(tmp, "README.md"));
expect(readme).toContain("dirty changes");

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

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 remnants 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, "README.md"), "local edits that will conflict\n");
yield* writeTextFile(path.join(tmp, "new-file.txt"), "untracked content that conflicts\n");
yield* writeTextFile(path.join(tmp, "leftover.txt"), "temporary untracked file\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");

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

const newFile = yield* readTextFile(path.join(tmp, "new-file.txt"));
expect(newFile).toBe("new file on other\n");

expect(existsSync(path.join(tmp, "leftover.txt"))).toBe(false);
}),
);

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);
}),
);

it.effect("includes dirty worktree metadata when ignored files block checkout", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(tmp);
const core = yield* GitCore;

yield* writeTextFile(path.join(tmp, ".gitignore"), "ignored/\n");
yield* git(tmp, ["add", ".gitignore"]);
yield* git(tmp, ["commit", "-m", "ignore generated files"]);

yield* core.createBranch({ cwd: tmp, branch: "other" });
yield* core.checkoutBranch({ cwd: tmp, branch: "other" });
yield* makeDirectory(path.join(tmp, "ignored"));
yield* writeTextFile(path.join(tmp, "ignored/output.log"), "tracked on other\n");
yield* git(tmp, ["add", "-f", "ignored/output.log"]);
yield* git(tmp, ["commit", "-m", "track ignored file"]);
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });

yield* makeDirectory(path.join(tmp, "ignored"));
yield* writeTextFile(path.join(tmp, "ignored/output.log"), "local ignored file\n");

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

expect(Schema.is(GitCommandError)(result.failure)).toBe(true);
if (!Schema.is(GitCommandError)(result.failure)) {
return;
}

expect(result.failure.dirtyWorktree?.branch).toBe("other");
expect(result.failure.dirtyWorktree?.conflictingFiles).toContain("ignored/output.log");
}),
);
});

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
Loading
Loading