packages feed

seihou-core 0.10.0.0 → 0.10.1.0

raw patch · 7 files changed

+173/−5 lines, 7 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Seihou.Engine.UpdateTransaction: adoptTrackedLinks :: UpdateTransaction -> Map FilePath Text -> IO (Either TransactionError ())
+ Seihou.Engine.UpdateTransaction: trackedLinkIdentity :: FilePath -> FilePath -> IO (Maybe Text)

Files

seihou-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-core-version: 0.10.0.0+version: 0.10.1.0 synopsis: Core library for Seihou project scaffolding description:   Core library for Seihou, a composable project scaffolding system.
src/Seihou/Engine/Checkout.hs view
@@ -76,7 +76,7 @@         if gitRoot == Nothing           then pure (False, [])           else do-            (code, _, _) <- runProcess "git" ["check-ignore", "-q", "--", "./" <> T.pack dest] (Just root)+            (code, _, _) <- runProcess "git" ["check-ignore", "--no-index", "-q", "--", "./" <> T.pack dest] (Just root)             pure $ case code of               ExitSuccess -> (True, [])               ExitFailure 1 -> (False, [])
src/Seihou/Engine/UpdateTransaction.hs view
@@ -8,6 +8,8 @@     setUpdateTransactionExpectedManifest,     completeUpdateTransaction,     recoverIncompleteTransactions,+    trackedLinkIdentity,+    adoptTrackedLinks,   ) where @@ -31,11 +33,14 @@ import Seihou.Effect.BaselineStoreInterp (runBaselineStore) import Seihou.Effect.Filesystem (PathEntry (..), inspectPath) import Seihou.Effect.FilesystemInterp (runFilesystem)+import Seihou.Effect.Process (runProcess)+import Seihou.Effect.ProcessInterp (runProcessIO) import Seihou.Engine.Reconcile import Seihou.Manifest.Hash (hashContent) import Seihou.Manifest.Types (manifestFromJSON) import Seihou.Prelude import System.Directory qualified as Directory+import System.Exit (ExitCode (..)) import System.FilePath (splitDirectories, takeDirectory, takeFileName) import System.IO (hClose, hSetEncoding, utf8) import System.IO.Temp (createTempDirectory, openTempFile)@@ -71,6 +76,7 @@     createdAt :: !UTCTime,     entries :: ![JournalEntry],     newDirectories :: ![FilePath],+    indexLinks :: !(Map FilePath Text),     expectedManifest :: !(Maybe Manifest)   }   deriving stock (Eq, Generic, Show)@@ -111,18 +117,20 @@         "createdAt" .= (metadata ^. #createdAt),         "entries" .= (metadata ^. #entries),         "newDirectories" .= (metadata ^. #newDirectories),+        "indexLinks" .= (metadata ^. #indexLinks),         "expectedManifest" .= (metadata ^. #expectedManifest)       ]  instance FromJSON JournalMetadata where   parseJSON = Aeson.withObject "JournalMetadata" $ \object -> do     version <- object .: "version"-    unless (version `elem` [1, 2]) (fail "unsupported transaction journal version")+    unless (version `elem` [1, 2, 3]) (fail "unsupported transaction journal version")     JournalMetadata       <$> pure version       <*> object .: "createdAt"       <*> (mapM (parseJournalEntry version) =<< object .: "entries")       <*> object .: "newDirectories"+      <*> (if version < 3 then pure Map.empty else object .: "indexLinks")       <*> object .:? "expectedManifest"  beginUpdateTransaction :: FilePath -> Set FilePath -> IO (Either TransactionError UpdateTransaction)@@ -164,9 +172,74 @@         createdAt = now,         entries = entries,         newDirectories = missingDirectories,+        indexLinks = Map.empty,         expectedManifest = Nothing       } +-- | Only unchanged HEAD/index symlinks with ordinary index flags can be adopted.+-- The checkout planner additionally checks path safety and the candidate target.+trackedLinkIdentity :: FilePath -> FilePath -> IO (Maybe Text)+trackedLinkIdentity root path = do+  indexed <- gitOutput root ["ls-files", "--stage", "-z", "--", literal path]+  headed <- gitOutput root ["ls-tree", "-z", "HEAD", "--", literal path]+  flags <- gitOutput root ["ls-files", "-v", "-z", "--", literal path]+  working <- runEff $ runFilesystem $ inspectPath (root </> path)+  let identity = do+        record <- indexed+        let (prefix, suffix) = T.breakOn "\t" record+        case T.words prefix of+          ["120000", oid, "0"] | suffix == "\t" <> T.pack path <> "\0" -> Just oid+          _ -> Nothing+  blob <- case identity of+    Nothing -> pure Nothing+    Just oid -> gitOutput root ["cat-file", "blob", oid]+  pure $ do+    oid <- identity+    if headed == Just ("120000 blob " <> oid <> "\t" <> T.pack path <> "\0")+      && flags == Just ("H " <> T.pack path <> "\0")+      && (case working of EntrySymlink target -> blob == Just (T.pack target); _ -> False)+      then Just oid+      else Nothing++literal :: FilePath -> Text+literal path = ":(literal)" <> T.pack path++gitOutput :: FilePath -> [Text] -> IO (Maybe Text)+gitOutput root args = do+  (code, out, _) <- runEff $ runProcessIO $ runProcess "git" args (Just root)+  pure (if code == ExitSuccess then Just out else Nothing)++-- | Journal the exact entries before removing any of them. Recovery restores+-- just these paths, preserving unrelated staging and linked-worktree indexes.+adoptTrackedLinks :: UpdateTransaction -> Map FilePath Text -> IO (Either TransactionError ())+adoptTrackedLinks _ links | Map.null links = pure (Right ())+adoptTrackedLinks transaction links = do+  result <- try @SomeException $ do+    forM_ (Map.toAscList links) $ \(path, oid) -> do+      either (ioError . userError . show) (const (pure ())) (validateTransactionPath path)+      unless (path `Set.member` (transaction ^. #targets)) (ioError (userError "unjournaled adoption path"))+      actual <- trackedLinkIdentity (transaction ^. #projectRoot) path+      unless (actual == Just oid) (ioError (userError ("Git index changed for " <> path)))+    metadata <- readJournal (transaction ^. #transactionDirectory) >>= either (ioError . userError . show) pure+    writeJournal (transaction ^. #transactionDirectory) (metadata & #journalVersion .~ 3 & #indexLinks .~ links)+    removed <- gitOutput (transaction ^. #projectRoot) (["update-index", "--force-remove", "--"] <> map T.pack (Map.keys links))+    when (removed == Nothing) (ioError (userError "could not adopt tracked links in Git's index"))+  pure $ first (\err -> TransactionApplyFailed (exceptionText err) Nothing) result++restoreIndexLinks :: FilePath -> Map FilePath Text -> IO ()+restoreIndexLinks root links = do+  -- Preflight the whole set before restoring any entry. A later user edit is+  -- preserved and leaves the journal available for deliberate recovery.+  missing <- fmap concat $ forM (Map.toAscList links) $ \(path, oid) -> do+    current <- gitOutput root ["ls-files", "--stage", "-z", "--", literal path]+    unless+      (current `elem` [Just "", Just ("120000 " <> oid <> " 0\t" <> T.pack path <> "\0")])+      (ioError (userError ("Git index changed during recovery for " <> path)))+    pure [(path, oid) | current == Just ""]+  forM_ missing $ \(path, oid) -> do+    restored <- gitOutput root ["update-index", "--add", "--cacheinfo", "120000", oid, T.pack path]+    when (restored == Nothing) (ioError (userError ("cannot restore Git index entry " <> path)))+ missingParentDirectories :: FilePath -> [FilePath] -> IO [FilePath] missingParentDirectories projectRoot paths = do   let candidates =@@ -393,6 +466,7 @@       newDirectories =         sortOn pathDepth . Set.toList $           Set.fromList (metadata ^. #newDirectories <> additionalDirectories),+      indexLinks = metadata ^. #indexLinks,       expectedManifest = Just candidate     } @@ -438,7 +512,15 @@     Right metadata -> do       result <- try @SomeException $ do         forM_ (metadata ^. #entries) (verifySafeParents (transaction ^. #projectRoot) . (^. #targetPath))-        forM_ (metadata ^. #entries) (restoreEntry transaction)+        -- Adoption never writes the working link. Preserve a later edit rather+        -- than restoring a snapshot over something this transition did not own.+        forM_ (metadata ^. #entries) $ \entry -> when (Map.member (entry ^. #targetPath) (metadata ^. #indexLinks)) $ do+          current <- runEff $ runFilesystem $ inspectPath (transaction ^. #projectRoot </> entry ^. #targetPath)+          unless+            (Just current == (EntrySymlink <$> entry ^. #linkTarget))+            (ioError (userError ("adopted working link changed during recovery: " <> entry ^. #targetPath)))+        restoreIndexLinks (transaction ^. #projectRoot) (metadata ^. #indexLinks)+        forM_ (filter (\entry -> Map.notMember (entry ^. #targetPath) (metadata ^. #indexLinks)) (metadata ^. #entries)) (restoreEntry transaction)         removeNewDirectories (transaction ^. #projectRoot) (metadata ^. #newDirectories)         cleanupDirectory (transaction ^. #transactionDirectory)       pure $ first (TransactionRollbackFailed . exceptionText) result@@ -512,6 +594,7 @@                 createdAt = metadata ^. #createdAt,                 entries = metadata ^. #entries,                 newDirectories = metadata ^. #newDirectories,+                indexLinks = metadata ^. #indexLinks,                 expectedManifest = Just expected               }       result <- try @SomeException (writeJournal (transaction ^. #transactionDirectory) updated)@@ -589,11 +672,19 @@  validateJournal :: JournalMetadata -> Either Text JournalMetadata validateJournal metadata = do+  traverse_ (first renderTransactionPathError . validateTransactionPath) (Map.keys (metadata ^. #indexLinks))+  traverse_ validateObjectId (Map.elems (metadata ^. #indexLinks))+  unless+    (Map.keysSet (metadata ^. #indexLinks) `Set.isSubsetOf` Set.fromList [entry ^. #targetPath | entry <- metadata ^. #entries, entry ^. #linkTarget /= Nothing])+    (Left "index adoption paths must have journaled symlink snapshots")   traverse_ (validateJournalEntry . (^. #targetPath)) (metadata ^. #entries)   traverse_ validateBackupName [name | JournalEntry _ (Just name) _ <- metadata ^. #entries]   traverse_ (first renderTransactionPathError . validateTransactionPath) (metadata ^. #newDirectories)   pure metadata   where+    validateObjectId oid+      | T.length oid `elem` [40, 64] && T.all (`elem` ("0123456789abcdef" :: String)) oid = Right ()+      | otherwise = Left "invalid Git object id in checkout adoption journal"     validateJournalEntry path = first renderTransactionPathError (validateTransactionPath path)     validateBackupName name       | takeFileName name == name && name /= "." && name /= ".." = Right ()
src/Seihou/Prelude.hs view
@@ -44,6 +44,7 @@ where  import "base" Data.Bifunctor (first)+ -- Every record type in the project derives Generic, both because the house -- style requires it and because generic-lens synthesises #label lenses from -- the Generic representation. Re-exporting it here keeps the derive clauses@@ -54,6 +55,7 @@ import "effectful-core" Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, IOE, MonadIO, liftIO, runEff, type (:>), type (:>>)) import "effectful-core" Effectful.Dispatch.Dynamic (EffectHandler, HasCallStack, interpret, reinterpret, send) import "filepath" System.FilePath ((</>))+ -- Re-export the whole lens API. PackageImports pins the package so that -- `Control.Lens` unambiguously means the `lens` package's module. --
test/Seihou/Engine/CheckoutSpec.hs view
@@ -98,7 +98,7 @@             cmd == "git" && case args of               ["rev-parse", "--show-toplevel"] -> True               ["ls-files", "--cached", "-z"] -> True-              ["check-ignore", "-q", "--", path] -> "./" `T.isPrefixOf` path+              ["check-ignore", "--no-index", "-q", "--", path] -> "./" `T.isPrefixOf` path               _ -> False         )   it "creates portable links and converges without following their contents" $ withFixture $ \root -> do
test/Seihou/Engine/UpdateTransactionSpec.hs view
@@ -19,12 +19,15 @@ import Seihou.Effect.BaselineStore (putBaseline) import Seihou.Effect.BaselineStoreInterp (runBaselineStore) import Seihou.Effect.FilesystemInterp (runFilesystem)+import Seihou.Effect.Process (runProcess)+import Seihou.Effect.ProcessInterp (runProcessIO) import Seihou.Engine.Reconcile import Seihou.Engine.UpdateTransaction import Seihou.Manifest.Hash (baselineRefForContent, hashContent) import Seihou.Manifest.Types (emptyManifest, manifestToJSON) import System.Directory (findExecutable) import System.Directory qualified as Directory+import System.Exit (ExitCode (..)) import System.FilePath (takeDirectory, (</>)) import System.IO.Temp (withSystemTempDirectory) import Test.Hspec@@ -36,6 +39,77 @@  spec :: Spec spec = do+  describe "tracked checkout-link adoption recovery" $ do+    let git root args = do+          (code, out, err) <- runEff $ runProcessIO $ runProcess "git" args (Just root)+          unless (code == ExitSuccess) (expectationFailure (T.unpack err))+          pure out+        prepare root = do+          _ <- git root ["init", "-q"]+          Directory.createDirectoryLink "missing-target" (root </> "discovery")+          TIO.writeFile (root </> "unrelated") "committed\n"+          _ <- git root ["add", "."]+          _ <- git root ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "test: initial links"]+          TIO.writeFile (root </> "unrelated") "staged edit\n"+          _ <- git root ["add", "unrelated"]+          Just oid <- trackedLinkIdentity root "discovery"+          pure oid+    it "recovers an interrupted adoption without changing unrelated staging" $+      withSystemTempDirectory "adoption-recovery" $ \root -> do+        oid <- prepare root+        before <- git root ["ls-files", "--stage", "-z"]+        Right transaction <- beginUpdateTransaction root (Set.singleton "discovery")+        adoptTrackedLinks transaction (Map.singleton "discovery" oid) `shouldReturn` Right ()+        git root ["ls-files", "--", "discovery"] `shouldReturn` ""+        Directory.getSymbolicLinkTarget (root </> "discovery") `shouldReturn` "missing-target"+        recoverIncompleteTransactions root `shouldReturn` [Right ()]+        git root ["ls-files", "--stage", "-z"] `shouldReturn` before+    it "refuses modified working links and changed index entries" $+      withSystemTempDirectory "adoption-stale" $ \root -> do+        oid <- prepare root+        Directory.removeDirectoryLink (root </> "discovery")+        Directory.createDirectoryLink "user-target" (root </> "discovery")+        trackedLinkIdentity root "discovery" `shouldReturn` Nothing+        Right transaction <- beginUpdateTransaction root (Set.singleton "discovery")+        refused <- adoptTrackedLinks transaction (Map.singleton "discovery" oid)+        refused `shouldSatisfy` \case Left {} -> True; _ -> False+        _ <- git root ["add", "-f", "discovery"]+        trackedLinkIdentity root "discovery" `shouldReturn` Nothing+    it "keeps an adoption when the final manifest was published before interruption" $+      withSystemTempDirectory "adoption-committed" $ \root -> do+        oid <- prepare root+        Right transaction <- beginUpdateTransaction root (Set.singleton "discovery")+        adoptTrackedLinks transaction (Map.singleton "discovery" oid) `shouldReturn` Right ()+        let manifest = emptyManifest fixedTime+        setUpdateTransactionExpectedManifest transaction manifest `shouldReturn` Right ()+        LBS.writeFile (root </> ".seihou/manifest.json") (manifestToJSON manifest)+        recoverIncompleteTransactions root `shouldReturn` [Right ()]+        git root ["ls-files", "--", "discovery"] `shouldReturn` ""+        Directory.getSymbolicLinkTarget (root </> "discovery") `shouldReturn` "missing-target"+    it "preserves a later working-link edit during recovery" $+      withSystemTempDirectory "adoption-working-conflict" $ \root -> do+        oid <- prepare root+        Right transaction <- beginUpdateTransaction root (Set.singleton "discovery")+        adoptTrackedLinks transaction (Map.singleton "discovery" oid) `shouldReturn` Right ()+        Directory.removeDirectoryLink (root </> "discovery")+        Directory.createDirectoryLink "user-target" (root </> "discovery")+        recovered <- rollbackUpdateTransaction transaction+        recovered `shouldSatisfy` \case Left TransactionRollbackFailed {} -> True; _ -> False+        git root ["ls-files", "--", "discovery"] `shouldReturn` ""+        Directory.getSymbolicLinkTarget (root </> "discovery") `shouldReturn` "user-target"+    it "preserves a later staged edit instead of overwriting it during recovery" $+      withSystemTempDirectory "adoption-recovery-conflict" $ \root -> do+        oid <- prepare root+        Right transaction <- beginUpdateTransaction root (Set.singleton "discovery")+        adoptTrackedLinks transaction (Map.singleton "discovery" oid) `shouldReturn` Right ()+        Directory.removeDirectoryLink (root </> "discovery")+        Directory.createDirectoryLink "user-target" (root </> "discovery")+        _ <- git root ["add", "discovery"]+        before <- git root ["ls-files", "--stage", "-z"]+        recovered <- rollbackUpdateTransaction transaction+        recovered `shouldSatisfy` \case Left TransactionRollbackFailed {} -> True; _ -> False+        git root ["ls-files", "--stage", "-z"] `shouldReturn` before+        Directory.getSymbolicLinkTarget (root </> "discovery") `shouldReturn` "user-target"   describe "literal symlink journal snapshots" $ do     it "restores a dangling target without traversing it" $ withSystemTempDirectory "checkout-journal" $ \root -> do       Directory.createDirectoryLink "missing-old-target" (root </> "local-link")
test/Seihou/Integration/ModuleLoadSpec.hs view
@@ -4,6 +4,7 @@ import Data.Generics.Labels () import Data.Map.Strict qualified as Map import Effectful+ -- Re-use the real loader for end-to-end tests import Seihou.Core.Module (loadModule) import Seihou.Core.Types