@@ -3,12 +3,12 @@ import path from "node:path";
33
44import * as NodeServices from "@effect/platform-node/NodeServices" ;
55import { it } from "@effect/vitest" ;
6- import { Effect , FileSystem , Layer , PlatformError , Scope } from "effect" ;
6+ import { Cause , Effect , FileSystem , Layer , PlatformError , Scope } from "effect" ;
77import { describe , expect , vi } from "vitest" ;
88
99import { GitCoreLive , makeGitCore } from "./GitCore.ts" ;
1010import { GitCore , type GitCoreShape } from "../Services/GitCore.ts" ;
11- import { GitCommandError } from "@t3tools/contracts" ;
11+ import { GitCheckoutDirtyWorktreeError , GitCommandError } from "@t3tools/contracts" ;
1212import { type ProcessRunResult , runProcess } from "../../processRunner.ts" ;
1313import { 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 ( error ) . toBeInstanceOf ( GitCheckoutDirtyWorktreeError ) ;
1718+ if ( error instanceof GitCheckoutDirtyWorktreeError ) {
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 * ( ) {
0 commit comments