From d3e36375d6457d40674335cdb00d894a51c794ba Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Fri, 24 Jul 2026 09:57:20 +1200 Subject: [PATCH 1/3] stack: prefer a committed .cabal over re-running hpack In the stack path, findCabalFiles UsePackageYamlFirst always re-rendered a package.yaml through hpack whenever it existed, even when the package directory also contained a committed .cabal file. This discarded the committed file and historically failed outright when the .cabal had been hand-modified or was produced by a newer hpack. The cabalProject path already prefers a committed .cabal (call-cabal-project-to-nix.nix skips hpack when a .cabal exists). Mirror that in the stack path: use the on-disk .cabal when present, falling back to hpack only when there is no .cabal. --ignore-package-yaml continues to force cabal-only discovery. Adds a self-contained (no external tools/network) test-suite stack-project-tests exercising findCabalFiles. --- nix-tools/nix-tools/lib/Stack2nix/Project.hs | 30 ++++-- nix-tools/nix-tools/nix-tools.cabal | 14 +++ .../nix-tools/tests/StackProjectTests.hs | 92 +++++++++++++++++++ 3 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 nix-tools/nix-tools/tests/StackProjectTests.hs diff --git a/nix-tools/nix-tools/lib/Stack2nix/Project.hs b/nix-tools/nix-tools/lib/Stack2nix/Project.hs index ace8823e23..372ed87a5a 100644 --- a/nix-tools/nix-tools/lib/Stack2nix/Project.hs +++ b/nix-tools/nix-tools/lib/Stack2nix/Project.hs @@ -21,16 +21,26 @@ import Stack2nix.CLI (HpackUse(..)) findCabalFiles :: HpackUse -> FilePath -> IO [CabalFile] findCabalFiles IgnorePackageYaml path = findOnlyCabalFiles path -findCabalFiles UsePackageYamlFirst path = doesFileExist (path Hpack.packageConfig) >>= \case - False -> findOnlyCabalFiles path - True -> do - mbPkg <- Hpack.readPackageConfig Hpack.defaultDecodeOptions {Hpack.decodeOptionsTarget = path Hpack.packageConfig} - case mbPkg of - Left e -> error e - Right r -> - return $ [InMemory (Just Hpack) - (Hpack.decodeResultCabalFile r) - (encodeUtf8 $ render r)] +findCabalFiles UsePackageYamlFirst path = findOnlyCabalFiles path >>= \case + -- Prefer a committed `.cabal` file when one exists, matching the + -- `cabalProject` behaviour (which ignores a `package.yaml` whenever a + -- `.cabal` file is present, see call-cabal-project-to-nix.nix). Previously a + -- `package.yaml` alongside a checked-in `.cabal` was re-rendered through hpack + -- unconditionally, discarding the committed file — and historically failing + -- outright when the committed `.cabal` was hand-modified or produced by a + -- newer hpack (see #626, #767). + cabalFiles@(_:_) -> return cabalFiles + -- No `.cabal` file: fall back to hpack when a `package.yaml` is present. + [] -> doesFileExist (path Hpack.packageConfig) >>= \case + False -> return [] + True -> do + mbPkg <- Hpack.readPackageConfig Hpack.defaultDecodeOptions {Hpack.decodeOptionsTarget = path Hpack.packageConfig} + case mbPkg of + Left e -> error e + Right r -> + return $ [InMemory (Just Hpack) + (Hpack.decodeResultCabalFile r) + (encodeUtf8 $ render r)] where render :: Hpack.DecodeResult -> String diff --git a/nix-tools/nix-tools/nix-tools.cabal b/nix-tools/nix-tools/nix-tools.cabal index e0573d48b4..29b3b70457 100644 --- a/nix-tools/nix-tools/nix-tools.cabal +++ b/nix-tools/nix-tools/nix-tools.cabal @@ -281,3 +281,17 @@ test-suite tests , cabal-install:cabal hs-source-dirs: tests default-language: Haskell2010 + +-- Self-contained unit tests for the stack path's cabal-file discovery. Unlike +-- the golden `tests` suite above these need no external tools or network, so +-- they can run in a plain build. +test-suite stack-project-tests + import: warnings + type: exitcode-stdio-1.0 + main-is: StackProjectTests.hs + build-depends: base + , extra + , filepath + , nix-tools + hs-source-dirs: tests + default-language: Haskell2010 diff --git a/nix-tools/nix-tools/tests/StackProjectTests.hs b/nix-tools/nix-tools/tests/StackProjectTests.hs new file mode 100644 index 0000000000..cd96ac07f1 --- /dev/null +++ b/nix-tools/nix-tools/tests/StackProjectTests.hs @@ -0,0 +1,92 @@ +{-# LANGUAGE LambdaCase #-} + +-- | Self-contained tests for 'findCabalFiles' from the stack path. These only +-- touch a temporary directory (no external tools, no network, no extra test +-- framework), so unlike the golden @tests@ suite they run in a plain build. +-- +-- Regression coverage for #626 / #767: when a package directory contains both a +-- committed @.cabal@ file and a @package.yaml@, 'UsePackageYamlFirst' must +-- prefer the committed @.cabal@ rather than re-rendering through hpack. +module Main (main) where + +import Control.Monad (forM_, unless) +import Data.IORef (modifyIORef', newIORef, readIORef) +import System.Exit (exitFailure) +import System.FilePath (()) +import System.IO.Extra (withTempDir) + +import Cabal2Nix (CabalFile(..)) +import Stack2nix.CLI (HpackUse(..)) +import Stack2nix.Project (findCabalFiles) + +-- A minimal, valid package.yaml (hpack input) and a committed .cabal that +-- deliberately disagree with it, so we can tell which one was picked. +packageYaml :: String +packageYaml = unlines + [ "name: foo" + , "version: 9.9.9" + ] + +committedCabal :: String +committedCabal = unlines + [ "cabal-version: 2.4" + , "name: foo" + , "version: 1.0.0" + , "library" + , " default-language: Haskell2010" + ] + +-- A crude, dependency-free description used both for assertions and messages. +describe :: CabalFile -> String +describe = \case + OnDisk fp -> "OnDisk " ++ fp + InMemory _ fp _ -> "InMemory " ++ fp + +-- Just the constructor name, for assertions that shouldn't depend on the exact +-- (hpack-derived) file path. +constructorOf :: CabalFile -> String +constructorOf = \case + OnDisk{} -> "OnDisk" + InMemory{} -> "InMemory" + +main :: IO () +main = do + failures <- newIORef (0 :: Int) + let check name expected actual = + unless (actual == expected) $ do + putStrLn $ "FAIL: " ++ name + putStrLn $ " expected: " ++ show expected + putStrLn $ " actual: " ++ show actual + modifyIORef' failures (+ 1) + inTmp files k = withTempDir $ \dir -> do + forM_ files $ \(fname, contents) -> writeFile (dir fname) contents + k dir + + -- #626/#767: committed .cabal wins over package.yaml. + inTmp [("foo.cabal", committedCabal), ("package.yaml", packageYaml)] $ \dir -> do + cfs <- findCabalFiles UsePackageYamlFirst dir + check "prefers committed .cabal when both files exist" + ["OnDisk " ++ (dir "foo.cabal")] (map describe cfs) + + -- Only package.yaml -> hpack (in-memory) is still used. + inTmp [("package.yaml", packageYaml)] $ \dir -> do + cfs <- findCabalFiles UsePackageYamlFirst dir + check "renders via hpack when only package.yaml exists" + ["InMemory"] (map constructorOf cfs) + + -- Only a .cabal -> used as-is. + inTmp [("foo.cabal", committedCabal)] $ \dir -> do + cfs <- findCabalFiles UsePackageYamlFirst dir + check "uses the .cabal when only a .cabal exists" + ["OnDisk " ++ (dir "foo.cabal")] (map describe cfs) + + -- IgnorePackageYaml ignores package.yaml even when present. + inTmp [("foo.cabal", committedCabal), ("package.yaml", packageYaml)] $ \dir -> do + cfs <- findCabalFiles IgnorePackageYaml dir + check "IgnorePackageYaml uses only the .cabal even with both present" + ["OnDisk " ++ (dir "foo.cabal")] (map describe cfs) + + n <- readIORef failures + if n == 0 + then putStrLn "All findCabalFiles tests passed." + else putStrLn (show n ++ " test(s) failed.") >> exitFailure From 788e46f046c9138dcd80b11fdbab7dd7947c4ef5 Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Mon, 26 Oct 2026 00:43:29 +1300 Subject: [PATCH 2/3] nix-tools: fix stack-project-tests build-depends `StackProjectTests` imports `Cabal2Nix` for the `CabalFile` constructors, which lives in the `cabal2nix` internal library rather than the main `nix-tools` library, so the test suite failed to compile with Could not load module 'Cabal2Nix' It is a member of the hidden package 'nix-tools-0.1.0.0:cabal2nix'. Add `nix-tools:cabal2nix` to its build-depends. --- nix-tools/nix-tools/nix-tools.cabal | 1 + 1 file changed, 1 insertion(+) diff --git a/nix-tools/nix-tools/nix-tools.cabal b/nix-tools/nix-tools/nix-tools.cabal index e26a1279d5..2c22547fdf 100644 --- a/nix-tools/nix-tools/nix-tools.cabal +++ b/nix-tools/nix-tools/nix-tools.cabal @@ -294,5 +294,6 @@ test-suite stack-project-tests , extra , filepath , nix-tools + , nix-tools:cabal2nix hs-source-dirs: tests default-language: Haskell2010 From 4ba71bf176de572e301fed1ef28f753a3809f11a Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Mon, 26 Oct 2026 16:45:21 +1300 Subject: [PATCH 3/3] plan-to-nix: prefer a committed .cabal too plan-to-nix kept its own copy of findCabalFiles, which still rendered package.yaml through hpack whenever one existed. For a cabalProject with both files cabal-install planned against the committed .cabal, but the generated .plan.nix came from hpack and was marked cabal-generator = "hpack", so comp-builder re-ran hpack at build time and could hit the same "modified manually" failure as #767. Drop Plan2Nix.Project and use Stack2nix.Project.findCabalFiles, so both tools take the committed .cabal (even a stale one) and only fall back to hpack when there is no .cabal. The same change is applied to the nix-tools-sh copy, whose files were identical. --- .../nix-tools/lib/Stack2nix/Project.hs | 32 ++++++++----- nix-tools-sh/nix-tools/nix-tools.cabal | 2 - nix-tools-sh/nix-tools/plan2nix/Plan2Nix.hs | 2 +- .../nix-tools/plan2nix/Plan2Nix/Project.hs | 45 ------------------- nix-tools/nix-tools/lib/Stack2nix/Project.hs | 14 +++--- nix-tools/nix-tools/nix-tools.cabal | 2 - nix-tools/nix-tools/plan2nix/Plan2Nix.hs | 2 +- .../nix-tools/plan2nix/Plan2Nix/Project.hs | 45 ------------------- 8 files changed, 32 insertions(+), 112 deletions(-) delete mode 100644 nix-tools-sh/nix-tools/plan2nix/Plan2Nix/Project.hs delete mode 100644 nix-tools/nix-tools/plan2nix/Plan2Nix/Project.hs diff --git a/nix-tools-sh/nix-tools/lib/Stack2nix/Project.hs b/nix-tools-sh/nix-tools/lib/Stack2nix/Project.hs index ace8823e23..73d39f1a1e 100644 --- a/nix-tools-sh/nix-tools/lib/Stack2nix/Project.hs +++ b/nix-tools-sh/nix-tools/lib/Stack2nix/Project.hs @@ -21,16 +21,28 @@ import Stack2nix.CLI (HpackUse(..)) findCabalFiles :: HpackUse -> FilePath -> IO [CabalFile] findCabalFiles IgnorePackageYaml path = findOnlyCabalFiles path -findCabalFiles UsePackageYamlFirst path = doesFileExist (path Hpack.packageConfig) >>= \case - False -> findOnlyCabalFiles path - True -> do - mbPkg <- Hpack.readPackageConfig Hpack.defaultDecodeOptions {Hpack.decodeOptionsTarget = path Hpack.packageConfig} - case mbPkg of - Left e -> error e - Right r -> - return $ [InMemory (Just Hpack) - (Hpack.decodeResultCabalFile r) - (encodeUtf8 $ render r)] +findCabalFiles UsePackageYamlFirst path = findOnlyCabalFiles path >>= \case + -- Prefer a committed `.cabal` file when one exists, even if it is stale + -- relative to `package.yaml` (stack itself would regenerate it). This is + -- what cabal-install sees for `cabalProject` too (call-cabal-project-to-nix.nix + -- only runs hpack when no `.cabal` is present), and plan-to-nix shares this + -- function so the generated nix matches the plan. Previously a + -- `package.yaml` alongside a checked-in `.cabal` was re-rendered through hpack + -- unconditionally, discarding the committed file — and failing outright when + -- the committed `.cabal` was hand-modified or produced by a newer hpack + -- (see #626, #767). + cabalFiles@(_:_) -> return cabalFiles + -- No `.cabal` file: fall back to hpack when a `package.yaml` is present. + [] -> doesFileExist (path Hpack.packageConfig) >>= \case + False -> return [] + True -> do + mbPkg <- Hpack.readPackageConfig Hpack.defaultDecodeOptions {Hpack.decodeOptionsTarget = path Hpack.packageConfig} + case mbPkg of + Left e -> error e + Right r -> + return $ [InMemory (Just Hpack) + (Hpack.decodeResultCabalFile r) + (encodeUtf8 $ render r)] where render :: Hpack.DecodeResult -> String diff --git a/nix-tools-sh/nix-tools/nix-tools.cabal b/nix-tools-sh/nix-tools/nix-tools.cabal index 8c3fb86220..b634fe12a7 100644 --- a/nix-tools-sh/nix-tools/nix-tools.cabal +++ b/nix-tools-sh/nix-tools/nix-tools.cabal @@ -128,7 +128,6 @@ executable plan-to-nix , extra , filepath , hnix - , hpack , microlens , microlens-aeson , nix-tools @@ -143,7 +142,6 @@ executable plan-to-nix other-modules: Plan2Nix , Plan2Nix.Cache , Plan2Nix.CLI - , Plan2Nix.Project , Plan2Nix.Plan hs-source-dirs: plan2nix default-language: Haskell2010 diff --git a/nix-tools-sh/nix-tools/plan2nix/Plan2Nix.hs b/nix-tools-sh/nix-tools/plan2nix/Plan2Nix.hs index e0c3693e98..74820a1f8b 100644 --- a/nix-tools-sh/nix-tools/plan2nix/Plan2Nix.hs +++ b/nix-tools-sh/nix-tools/plan2nix/Plan2Nix.hs @@ -49,7 +49,7 @@ import Plan2Nix.CLI (Args(..)) import Plan2Nix.Plan (Plan(..), PkgSrc(..), Package(..), Location(..)) import Plan2Nix.Cache (appendCache, cacheHits) -import Plan2Nix.Project +import Stack2nix.Project (findCabalFiles) import System.FilePath ((<.>), (), takeDirectory, dropFileName) import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory) diff --git a/nix-tools-sh/nix-tools/plan2nix/Plan2Nix/Project.hs b/nix-tools-sh/nix-tools/plan2nix/Plan2Nix/Project.hs deleted file mode 100644 index a08205f2db..0000000000 --- a/nix-tools-sh/nix-tools/plan2nix/Plan2Nix/Project.hs +++ /dev/null @@ -1,45 +0,0 @@ -{-# LANGUAGE LambdaCase #-} - -module Plan2Nix.Project - ( findCabalFiles - ) where - -import qualified Data.Text as T -import qualified Data.Text.Encoding as T -import Data.ByteString (ByteString) - -import System.FilePath (()) -import System.Directory (listDirectory, doesFileExist) -import Data.List (isSuffixOf) - -import qualified Hpack.Config as Hpack -import qualified Hpack.Render as Hpack - -import Cabal2Nix (CabalFile(..), CabalFileGenerator(..)) -import Plan2Nix.CLI (HpackUse(..)) - -findOnlyCabalFiles :: FilePath -> IO [ CabalFile] -findOnlyCabalFiles path = fmap (OnDisk . (path )) . filter (isSuffixOf ".cabal") <$> listDirectory path - -findCabalFiles :: HpackUse -> FilePath -> IO [CabalFile] -findCabalFiles IgnorePackageYaml path = findOnlyCabalFiles path -findCabalFiles UsePackageYamlFirst path = doesFileExist (path Hpack.packageConfig) >>= \case - False -> findOnlyCabalFiles path - True -> do - mbPkg <- Hpack.readPackageConfig Hpack.defaultDecodeOptions {Hpack.decodeOptionsTarget = path Hpack.packageConfig} - case mbPkg of - Left e -> error e - Right r -> - return $ [InMemory (Just Hpack) - (Hpack.decodeResultCabalFile r) - (encodeUtf8 $ render r)] - - where - render :: Hpack.DecodeResult -> String - render r = - let body = Hpack.renderPackage [] (Hpack.decodeResultPackage r) - cabalVersion = Hpack.decodeResultCabalVersion r - in cabalVersion ++ body - - encodeUtf8 :: String -> ByteString - encodeUtf8 = T.encodeUtf8 . T.pack diff --git a/nix-tools/nix-tools/lib/Stack2nix/Project.hs b/nix-tools/nix-tools/lib/Stack2nix/Project.hs index 372ed87a5a..73d39f1a1e 100644 --- a/nix-tools/nix-tools/lib/Stack2nix/Project.hs +++ b/nix-tools/nix-tools/lib/Stack2nix/Project.hs @@ -22,13 +22,15 @@ import Stack2nix.CLI (HpackUse(..)) findCabalFiles :: HpackUse -> FilePath -> IO [CabalFile] findCabalFiles IgnorePackageYaml path = findOnlyCabalFiles path findCabalFiles UsePackageYamlFirst path = findOnlyCabalFiles path >>= \case - -- Prefer a committed `.cabal` file when one exists, matching the - -- `cabalProject` behaviour (which ignores a `package.yaml` whenever a - -- `.cabal` file is present, see call-cabal-project-to-nix.nix). Previously a + -- Prefer a committed `.cabal` file when one exists, even if it is stale + -- relative to `package.yaml` (stack itself would regenerate it). This is + -- what cabal-install sees for `cabalProject` too (call-cabal-project-to-nix.nix + -- only runs hpack when no `.cabal` is present), and plan-to-nix shares this + -- function so the generated nix matches the plan. Previously a -- `package.yaml` alongside a checked-in `.cabal` was re-rendered through hpack - -- unconditionally, discarding the committed file — and historically failing - -- outright when the committed `.cabal` was hand-modified or produced by a - -- newer hpack (see #626, #767). + -- unconditionally, discarding the committed file — and failing outright when + -- the committed `.cabal` was hand-modified or produced by a newer hpack + -- (see #626, #767). cabalFiles@(_:_) -> return cabalFiles -- No `.cabal` file: fall back to hpack when a `package.yaml` is present. [] -> doesFileExist (path Hpack.packageConfig) >>= \case diff --git a/nix-tools/nix-tools/nix-tools.cabal b/nix-tools/nix-tools/nix-tools.cabal index 227b25d3ea..fc1ac59403 100644 --- a/nix-tools/nix-tools/nix-tools.cabal +++ b/nix-tools/nix-tools/nix-tools.cabal @@ -128,7 +128,6 @@ executable plan-to-nix , extra , filepath , hnix - , hpack , microlens , microlens-aeson , nix-tools @@ -143,7 +142,6 @@ executable plan-to-nix other-modules: Plan2Nix , Plan2Nix.Cache , Plan2Nix.CLI - , Plan2Nix.Project , Plan2Nix.Plan hs-source-dirs: plan2nix default-language: Haskell2010 diff --git a/nix-tools/nix-tools/plan2nix/Plan2Nix.hs b/nix-tools/nix-tools/plan2nix/Plan2Nix.hs index e0c3693e98..74820a1f8b 100644 --- a/nix-tools/nix-tools/plan2nix/Plan2Nix.hs +++ b/nix-tools/nix-tools/plan2nix/Plan2Nix.hs @@ -49,7 +49,7 @@ import Plan2Nix.CLI (Args(..)) import Plan2Nix.Plan (Plan(..), PkgSrc(..), Package(..), Location(..)) import Plan2Nix.Cache (appendCache, cacheHits) -import Plan2Nix.Project +import Stack2nix.Project (findCabalFiles) import System.FilePath ((<.>), (), takeDirectory, dropFileName) import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory) diff --git a/nix-tools/nix-tools/plan2nix/Plan2Nix/Project.hs b/nix-tools/nix-tools/plan2nix/Plan2Nix/Project.hs deleted file mode 100644 index a08205f2db..0000000000 --- a/nix-tools/nix-tools/plan2nix/Plan2Nix/Project.hs +++ /dev/null @@ -1,45 +0,0 @@ -{-# LANGUAGE LambdaCase #-} - -module Plan2Nix.Project - ( findCabalFiles - ) where - -import qualified Data.Text as T -import qualified Data.Text.Encoding as T -import Data.ByteString (ByteString) - -import System.FilePath (()) -import System.Directory (listDirectory, doesFileExist) -import Data.List (isSuffixOf) - -import qualified Hpack.Config as Hpack -import qualified Hpack.Render as Hpack - -import Cabal2Nix (CabalFile(..), CabalFileGenerator(..)) -import Plan2Nix.CLI (HpackUse(..)) - -findOnlyCabalFiles :: FilePath -> IO [ CabalFile] -findOnlyCabalFiles path = fmap (OnDisk . (path )) . filter (isSuffixOf ".cabal") <$> listDirectory path - -findCabalFiles :: HpackUse -> FilePath -> IO [CabalFile] -findCabalFiles IgnorePackageYaml path = findOnlyCabalFiles path -findCabalFiles UsePackageYamlFirst path = doesFileExist (path Hpack.packageConfig) >>= \case - False -> findOnlyCabalFiles path - True -> do - mbPkg <- Hpack.readPackageConfig Hpack.defaultDecodeOptions {Hpack.decodeOptionsTarget = path Hpack.packageConfig} - case mbPkg of - Left e -> error e - Right r -> - return $ [InMemory (Just Hpack) - (Hpack.decodeResultCabalFile r) - (encodeUtf8 $ render r)] - - where - render :: Hpack.DecodeResult -> String - render r = - let body = Hpack.renderPackage [] (Hpack.decodeResultPackage r) - cabalVersion = Hpack.decodeResultCabalVersion r - in cabalVersion ++ body - - encodeUtf8 :: String -> ByteString - encodeUtf8 = T.encodeUtf8 . T.pack