Skip to content

Commit b170efc

Browse files
Merge pull request #56 from Marve10s/feature/stash-switch-recovery
Add branch switch recovery actions
2 parents f6466a6 + b121bd8 commit b170efc

11 files changed

Lines changed: 962 additions & 77 deletions

File tree

apps/server/src/git/Errors.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,23 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()(
1515
}
1616
}
1717

18+
/**
19+
* GitCheckoutDirtyWorktreeError - Checkout would overwrite local files.
20+
*/
21+
export class GitCheckoutDirtyWorktreeError extends Schema.TaggedErrorClass<GitCheckoutDirtyWorktreeError>()(
22+
"GitCheckoutDirtyWorktreeError",
23+
{
24+
branch: Schema.String,
25+
cwd: Schema.String,
26+
conflictingFiles: Schema.Array(Schema.String),
27+
},
28+
) {
29+
override get message(): string {
30+
const fileList = this.conflictingFiles.map((file) => ` - ${file}`).join("\n");
31+
return `Uncommitted changes block checkout to ${this.branch}:\n${fileList}`;
32+
}
33+
}
34+
1835
/**
1936
* GitHubCliError - GitHub CLI execution or authentication failed.
2037
*/
@@ -63,5 +80,6 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()(
6380
export type GitManagerServiceError =
6481
| GitManagerError
6582
| GitCommandError
83+
| GitCheckoutDirtyWorktreeError
6684
| GitHubCliError
6785
| TextGenerationError;

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

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ 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 "../Errors.ts";
11+
import { GitCheckoutDirtyWorktreeError, GitCommandError } from "../Errors.ts";
1212
import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
1313
import { ServerConfig } from "../../config.ts";
1414

@@ -40,6 +40,15 @@ function writeTextFile(
4040
});
4141
}
4242

43+
function readTextFile(
44+
filePath: string,
45+
): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem> {
46+
return Effect.gen(function* () {
47+
const fileSystem = yield* FileSystem.FileSystem;
48+
return yield* fileSystem.readFileString(filePath);
49+
});
50+
}
51+
4352
/** Run a raw git command for test setup (not under test). */
4453
function git(
4554
cwd: string,
@@ -755,6 +764,124 @@ it.layer(TestLayer)("git integration", (it) => {
755764
(yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }),
756765
);
757766
expect(result._tag).toBe("Failure");
767+
if (result._tag === "Failure") {
768+
const error = result.failure;
769+
expect(error).toBeInstanceOf(GitCheckoutDirtyWorktreeError);
770+
if (error instanceof GitCheckoutDirtyWorktreeError) {
771+
expect(error.branch).toBe("other");
772+
expect(error.conflictingFiles).toContain("README.md");
773+
expect(error.message).toContain("Uncommitted changes block checkout to other:");
774+
}
775+
}
776+
}),
777+
);
778+
});
779+
780+
describe("stashAndCheckout", () => {
781+
it.effect("stashes dirty changes, switches branches, and reapplies the stash", () =>
782+
Effect.gen(function* () {
783+
const tmp = yield* makeTmpDir();
784+
const { initialBranch } = yield* initRepoWithCommit(tmp);
785+
const core = yield* GitCore;
786+
787+
yield* core.createBranch({ cwd: tmp, branch: "feature" });
788+
yield* core.checkoutBranch({ cwd: tmp, branch: "feature" });
789+
yield* writeTextFile(path.join(tmp, "feature.txt"), "feature content\n");
790+
yield* git(tmp, ["add", "."]);
791+
yield* git(tmp, ["commit", "-m", "add feature file"]);
792+
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
793+
794+
yield* writeTextFile(path.join(tmp, "README.md"), "dirty changes\n");
795+
796+
yield* core.stashAndCheckout({ cwd: tmp, branch: "feature" });
797+
798+
const branches = yield* core.listBranches({ cwd: tmp });
799+
expect(branches.branches.find((branch) => branch.current)?.name).toBe("feature");
800+
expect(yield* readTextFile(path.join(tmp, "README.md"))).toBe("dirty changes\n");
801+
expect((yield* git(tmp, ["stash", "list"])).trim()).toBe("");
802+
}),
803+
);
804+
805+
it.effect("keeps the stash when reapplying dirty changes conflicts", () =>
806+
Effect.gen(function* () {
807+
const tmp = yield* makeTmpDir();
808+
const { initialBranch } = yield* initRepoWithCommit(tmp);
809+
const core = yield* GitCore;
810+
811+
yield* core.createBranch({ cwd: tmp, branch: "conflicting" });
812+
yield* core.checkoutBranch({ cwd: tmp, branch: "conflicting" });
813+
yield* writeTextFile(path.join(tmp, "README.md"), "conflicting content\n");
814+
yield* git(tmp, ["add", "."]);
815+
yield* git(tmp, ["commit", "-m", "conflicting change"]);
816+
yield* core.checkoutBranch({ cwd: tmp, branch: initialBranch });
817+
818+
yield* writeTextFile(path.join(tmp, "README.md"), "local edits that will conflict\n");
819+
820+
const result = yield* Effect.result(
821+
core.stashAndCheckout({ cwd: tmp, branch: "conflicting" }),
822+
);
823+
824+
expect(result._tag).toBe("Failure");
825+
expect((yield* git(tmp, ["stash", "list"]))).toContain(
826+
"dpcode: stash before switching to conflicting",
827+
);
828+
}),
829+
);
830+
});
831+
832+
describe("stashDrop", () => {
833+
it.effect("reads the top stash details", () =>
834+
Effect.gen(function* () {
835+
const tmp = yield* makeTmpDir();
836+
const core = yield* GitCore;
837+
const { initialBranch } = yield* initRepoWithCommit(tmp);
838+
839+
yield* writeTextFile(path.join(tmp, "README.md"), "stashed changes\n");
840+
yield* writeTextFile(path.join(tmp, "new-file.txt"), "new file\n");
841+
yield* git(tmp, ["stash", "push", "-u", "-m", "test stash"]);
842+
843+
const info = yield* core.stashInfo({ cwd: tmp });
844+
845+
expect(info.cwd).toBe(tmp);
846+
expect(info.branch).toBe(initialBranch);
847+
expect(info.stashRef).toBe("stash@{0}");
848+
expect(info.message).toContain("test stash");
849+
expect(info.files).toContain("README.md");
850+
expect(info.files).toContain("new-file.txt");
851+
}),
852+
);
853+
854+
it.effect("drops the top stash entry", () =>
855+
Effect.gen(function* () {
856+
const tmp = yield* makeTmpDir();
857+
const core = yield* GitCore;
858+
yield* initRepoWithCommit(tmp);
859+
860+
yield* writeTextFile(path.join(tmp, "README.md"), "stashed changes\n");
861+
yield* git(tmp, ["stash", "push", "-m", "test stash"]);
862+
expect(yield* git(tmp, ["stash", "list"])).toContain("test stash");
863+
864+
yield* core.stashDrop({ cwd: tmp });
865+
866+
expect((yield* git(tmp, ["stash", "list"])).trim()).toBe("");
867+
}),
868+
);
869+
});
870+
871+
describe("removeIndexLock", () => {
872+
it.effect("removes the repository index lock path reported by git", () =>
873+
Effect.gen(function* () {
874+
const tmp = yield* makeTmpDir();
875+
const core = yield* GitCore;
876+
yield* initRepoWithCommit(tmp);
877+
878+
const lockPath = path.join(tmp, ".git", "index.lock");
879+
yield* writeTextFile(lockPath, "");
880+
expect(existsSync(lockPath)).toBe(true);
881+
882+
yield* core.removeIndexLock({ cwd: tmp });
883+
884+
expect(existsSync(lockPath)).toBe(false);
758885
}),
759886
);
760887
});

0 commit comments

Comments
 (0)