packages feed

seihou-cli-0.11.0.0: test/Seihou/CLI/CheckoutSyncE2ESpec.hs

module Seihou.CLI.CheckoutSyncE2ESpec (tests, copyPinnedSchema) where

import Control.Monad (forM_, unless, when)
import Data.Aeson qualified as A
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.List (isInfixOf)
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TIO
import Seihou.CLI.SchemaVersion (schemaUrl)
import Seihou.CLI.SeihouBinary (seihouBinary)
import Seihou.Engine.UpdateTransaction (beginUpdateTransaction)
import System.Directory qualified as D
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.FilePath (takeDirectory, takeExtension, (</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode, readProcess)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

-- Use the published pin from Git objects in a checkout, or the explicitly
-- identified schema staged by the package builder. Imports remain offline.
copyPinnedSchema source destination = do
  let revision = T.unpack (last (init (T.splitOn "/" schemaUrl)))
      marker = source </> ".seihou-pinned-revision"
  packaged <- D.doesFileExist marker
  if packaged
    then do
      actual <- T.strip <$> TIO.readFile marker
      unless (actual == T.pack revision) (fail "packaged schema does not match the executable's published pin")
      files <- D.listDirectory source
      forM_ (filter ((== ".dhall") . takeExtension) files) $ \file -> D.copyFile (source </> file) (destination </> file)
    else do
      files <- lines <$> readProcess "git" ["-C", source, "ls-tree", "--name-only", revision] ""
      forM_ (filter ((== ".dhall") . takeExtension) files) $ \file -> do
        content <- readProcess "git" ["-C", source, "show", revision <> ":" <> file] ""
        TIO.writeFile (destination </> file) (T.pack content)

-- Every invocation has an empty personal configuration/cache and no inherited HOME.
invoke binary root home args = do
  inherited <- getEnvironment
  let overrides = [("HOME", home), ("XDG_CONFIG_HOME", home </> "config"), ("XDG_CACHE_HOME", home </> "cache")]
      environment = overrides <> filter (\(key, _) -> key `notElem` map fst overrides) inherited
  readCreateProcessWithExitCode ((proc binary args) {cwd = Just root, env = Just environment}) ""

success binary root home args = do
  (code, out, err) <- invoke binary root home args
  unless (code == ExitSuccess) (expectationFailure (unwords args <> ": " <> out <> err))
  pure out

git root args = readProcess "git" (["-C", root] <> args) ""

snapshot root = do
  names <- filter (not . null) . splitNul <$> git root ["ls-files", "-z"]
  blobs <-
    mapM
      ( \path -> do
          linked <- D.pathIsSymbolicLink (root </> path)
          bytes <- if linked then TE.encodeUtf8 . T.pack <$> D.getSymbolicLinkTarget (root </> path) else BS.readFile (root </> path)
          pure (path, bytes)
      )
      names
  index <- git root ["ls-files", "--stage", "-z"]
  pure (blobs, index)
  where
    splitNul [] = []
    splitNul input = let (a, b) = break (== '\0') input in a : splitNul (drop 1 b)

fixtureModule name source destination secondSource secondDestination version enabled =
  T.unlines
    [ "let S = ./schema/package.dhall",
      "in S.Module::{ name = \"" <> name <> "\", version = Some \"" <> version <> "\",",
      "vars = [S.VarDecl::{name = \"source\", type = \"text\", default = Some \"" <> source <> "\"}, S.VarDecl::{name = \"enabled\", type = \"bool\", default = Some \"" <> enabled <> "\"}],",
      "steps = [ S.Step::{strategy = \"symlink\", src = \"{{source}}\", dest = \"" <> destination <> "\", lifecycle = Some \"checkout\", when = Some \"Eq enabled true\"},",
      "S.Step::{strategy = \"symlink\", src = \"" <> secondSource <> "\", dest = \"" <> secondDestination <> "\", lifecycle = Some \"checkout\"}],",
      "commands = [S.Command::{action = S.Command.Action.Apply \"touch command-sentinel\"}] }"
    ]

prepare root home source destination secondSource secondDestination = do
  binary <- seihouBinary
  cwd <- D.getCurrentDirectory
  schema <- D.canonicalizePath (takeDirectory cwd </> "schema")
  let moduleDir = root </> ".seihou/modules/demo"
  D.createDirectoryIfMissing True (moduleDir </> "schema")
  copyPinnedSchema schema (moduleDir </> "schema")
  forM_ [source, secondSource, "config/next"] $ \dir -> do
    D.createDirectoryIfMissing True (root </> T.unpack dir)
    TIO.writeFile (root </> T.unpack dir </> "sentinel") "source bytes\n"
  let ignored = T.unlines [T.pack (takeDirectory (T.unpack destination)) <> "/", T.pack (takeDirectory (T.unpack secondDestination)) <> "/", ".seihou/modules/", ".seihou/config.dhall", "command-sentinel"]
  TIO.writeFile (root </> ".gitignore") ignored
  TIO.writeFile (moduleDir </> "module.dhall") (fixtureModule "demo" source destination secondSource secondDestination "1.0.0" "true")
  _ <- git root ["init", "-q"]
  _ <- git root ["config", "user.name", "Checkout fixture"]
  _ <- git root ["config", "user.email", "checkout@example.invalid"]
  _ <- success binary root home ["run", "demo", "--force"]
  D.removeFile (root </> "command-sentinel")
  _ <- git root ["add", "."]
  _ <- git root ["commit", "-qm", "test: record checkout output declarations"]
  pure (binary, moduleDir)

-- Establish a real old command-producing application, then publish a declaring
-- release. Adoption itself runs in a different, empty personal configuration.
prepareAdoption temp failing = prepareAdoptionWithTracking temp failing True

prepareAdoptionWithTracking temp failing tracked = do
  binary <- seihouBinary
  cwd <- D.getCurrentDirectory
  schema <- D.canonicalizePath (takeDirectory cwd </> "schema")
  let root = temp </> "project"
      remote = temp </> "producer"
      initialHome = temp </> "initial-home"
      home = temp </> "empty-home"
      remoteUrl = "https://seihou-fixture.invalid/demo.git"
      moduleBody version steps commands =
        T.unlines
          [ "let S = ./schema/package.dhall in S.Module::{name = \"demo\", version = Some \"" <> version <> "\",",
            "steps = " <> steps <> ", commands = " <> commands <> "}"
          ]
      commit repo message = do
        _ <- git repo ["add", "."]
        git repo ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", message]
  forM_ [root </> "config/editor", remote </> "schema", initialHome, home] (D.createDirectoryIfMissing True)
  -- Keep the recorded origin portable while Git routes this fixture remote to
  -- disk. The adoption home has Git configuration but no producer cache.
  forM_ [initialHome, home] $ \gitHome -> do
    _ <- git gitHome ["config", "--file", gitHome </> ".gitconfig", "url." <> remote <> ".insteadOf", remoteUrl]
    pure ()
  copyPinnedSchema schema (remote </> "schema")
  TIO.writeFile
    (remote </> "module.dhall")
    ( moduleBody
        "1.0.0"
        "[] : List S.Step.Type"
        "[S.Command::{action = S.Command.Action.Apply \"mkdir -p .local && ln -s ../config/editor .local/config\"}]"
    )
  _ <- git remote ["init", "-q"]
  _ <- commit remote "test: publish command producer"
  _ <- git root ["init", "-q"]
  TIO.writeFile (root </> ".gitignore") ".local/\n.seihou/transactions/\n"
  TIO.writeFile (root </> "config/editor/sentinel") "tracked source\n"
  TIO.writeFile (root </> "unrelated") "committed\n"
  _ <- success binary root initialHome ["install", remoteUrl]
  _ <- success binary root initialHome ["run", "demo", "--force"]
  D.createDirectoryLink "../config/editor" (root </> ".local/unrelated")
  when tracked $ do
    _ <- git root ["add", "-f", ".local/config"]
    pure ()
  _ <- git root ["add", "-f", ".local/unrelated"]
  _ <- commit root "test: record legacy application and discovery links"
  let commands = if failing then "[S.Command::{action = S.Command.Action.Apply \"exit 42\"}]" else "[] : List S.Command.Type"
  TIO.writeFile
    (remote </> "module.dhall")
    ( moduleBody
        "2.0.0"
        "[S.Step::{strategy = \"symlink\", lifecycle = Some \"checkout\", src = \"config/editor\", dest = \".local/config\"}]"
        commands
    )
  _ <- commit remote "test: publish checkout declarations"
  pure (binary, root, home)

-- A small command-only producer using the same schema and compiler as all modules.
setupCommand lifecycle text = "S.Command::{action = S.Command.Action." <> lifecycle <> " \"" <> text <> "\"}"

commandModuleBody name commands dependencies =
  "let S = ./schema/package.dhall in S.Module::{name = \"" <> name <> "\", version = Some \"1.0.0\", commands = [" <> T.intercalate "," commands <> "], dependencies = " <> dependencies <> "}"

tests :: IO TestTree
tests = testSpec "checkout sync and adoption" $ do
  it "restores recorded checkout commands in a fresh clone without producers or application replay" $
    withSystemTempDirectory "checkout-command-clone" $ \temp -> do
      let original = temp </> "original"
          clone = temp </> "different-checkout"
          home = temp </> "empty-home"
      D.createDirectoryIfMissing True original
      (binary, moduleDir) <- prepare original home "config/editor" ".editor/config" "shared/assets" ".local/assets"
      let action = "< Apply : Text | Checkout : Text >"
          command lifecycle text = "{action = " <> action <> "." <> lifecycle <> " \"" <> text <> "\", workDir = None Text, when = None Text}"
          body = T.replace "commands = [S.Command::{action = S.Command.Action.Apply \"touch command-sentinel\"}]" ("commands = [" <> command "Checkout" "mkdir -p .local; printf '{{source}}' > .local/setup" <> "," <> command "Apply" "touch command-sentinel" <> "]") (fixtureModule "demo" "config/editor" ".editor/config" "shared/assets" ".local/assets" "2.0.0" "true")
      TIO.writeFile (moduleDir </> "module.dhall") body
      _ <- success binary original home ["update", "demo", "--json"]
      D.doesPathExist (original </> "command-sentinel") `shouldReturn` False
      TIO.readFile (original </> ".local/setup") `shouldReturn` "config/editor"
      _ <- git original ["add", "."]
      _ <- git original ["commit", "-qm", "test: record repeatable setup"]
      _ <- git original ["clone", "-q", original, clone]
      -- The producer and original checkout are now unavailable to the receiver.
      D.renameDirectory original (temp </> "unavailable-original")
      before <- snapshot clone
      manifest <- BS.readFile (clone </> ".seihou/manifest.json")
      dry <- success binary clone (temp </> "receiver-home") ["sync", "--dry-run", "--json"]
      dry `shouldSatisfy` isInfixOf "would-run"
      D.doesPathExist (clone </> ".local/setup") `shouldReturn` False
      _ <- success binary clone (temp </> "receiver-home") ["sync"]
      TIO.readFile (clone </> ".local/setup") `shouldReturn` "config/editor"
      D.getSymbolicLinkTarget (clone </> ".editor/config") `shouldReturn` "../config/editor"
      D.doesPathExist (clone </> "command-sentinel") `shouldReturn` False
      D.removeFile (clone </> ".local/setup")
      _ <- success binary clone (temp </> "receiver-home") ["sync"]
      TIO.readFile (clone </> ".local/setup") `shouldReturn` "config/editor"
      snapshot clone `shouldReturn` before
      BS.readFile (clone </> ".seihou/manifest.json") `shouldReturn` manifest

  it "keeps command-only updates as no-ops, previews declaration changes and persists disabled setup" $
    withSystemTempDirectory "checkout-command-noop" $ \temp -> do
      let root = temp </> "project"; home = temp </> "home"
      D.createDirectoryIfMissing True root
      (binary, moduleDir) <- prepare root home "config/editor" ".editor/config" "shared/assets" ".local/assets"
      let body value = commandModuleBody "demo" [setupCommand "Checkout" ("mkdir -p .local; printf '" <> value <> "' > .local/setup")] "[] : List S.Dependency.Type"
      TIO.writeFile (moduleDir </> "module.dhall") (body "first")
      _ <- success binary root home ["run", "demo", "--force", "--no-commands"]
      D.doesPathExist (root </> ".local/setup") `shouldReturn` False
      manifest <- BS.readFile (root </> ".seihou/manifest.json")
      forM_ [False, True] $ \deleted -> do
        when deleted $ do
          _ <- success binary root home ["sync"]
          D.removeFile (root </> ".local/setup")
        output <- success binary root home ["update", "demo"]
        output `shouldSatisfy` isInfixOf "Already up to date."
        json <- success binary root home ["update", "demo", "--json"]
        json `shouldSatisfy` isInfixOf "not-run-no-update"
        BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` manifest
        D.doesPathExist (root </> ".local/setup") `shouldReturn` False
      _ <- success binary root home ["sync"]
      TIO.readFile (root </> ".local/setup") `shouldReturn` "first"
      TIO.writeFile (moduleDir </> "module.dhall") (body "second")
      dry <- success binary root home ["update", "demo", "--dry-run", "--json"]
      dry `shouldSatisfy` isInfixOf "second"
      dry `shouldSatisfy` isInfixOf "\"alreadyUpToDate\":false"
      _ <- success binary root home ["update", "demo", "--no-commands", "--json"]
      TIO.readFile (root </> ".local/setup") `shouldReturn` "first"
      _ <- success binary root home ["sync"]
      TIO.readFile (root </> ".local/setup") `shouldReturn` "second"
      D.removeFile (root </> ".local/setup")
      _ <- success binary root home ["update", "demo", "--run-all-commands", "--json"]
      TIO.readFile (root </> ".local/setup") `shouldReturn` "second"

  it "reports a completed prefix and unrun tail, then repeats setup on retry without receipts" $
    withSystemTempDirectory "checkout-command-retry" $ \temp -> do
      let root = temp </> "project"; home = temp </> "home"
      D.createDirectoryIfMissing True root
      (binary, moduleDir) <- prepare root home "config/editor" ".editor/config" "shared/assets" ".local/assets"
      TIO.writeFile
        (moduleDir </> "module.dhall")
        ( commandModuleBody
            "demo"
            [setupCommand "Checkout" "mkdir -p .local; echo prefix > .local/prefix", setupCommand "Checkout" "echo captured-output; test -f .local/allow", setupCommand "Checkout" "echo tail > .local/tail"]
            "[] : List S.Dependency.Type"
        )
      _ <- success binary root home ["run", "demo", "--force", "--no-commands"]
      before <- snapshot root
      manifest <- BS.readFile (root </> ".seihou/manifest.json")
      (code, output, _) <- invoke binary root home ["sync", "--json"]
      code `shouldSatisfy` (/= ExitSuccess)
      (A.eitherDecode (LBS.fromStrict (TE.encodeUtf8 (T.pack output))) :: Either String A.Value) `shouldSatisfy` either (const False) (const True)
      forM_ ["succeeded", "failed", "not-run", "captured-output"] $ \expected -> output `shouldSatisfy` isInfixOf expected
      D.doesPathExist (root </> ".local/tail") `shouldReturn` False
      TIO.writeFile (root </> ".local/allow") "retry\n"
      D.removeFile (root </> ".local/prefix")
      _ <- success binary root home ["sync"]
      TIO.readFile (root </> ".local/prefix") `shouldReturn` "prefix\n"
      TIO.readFile (root </> ".local/tail") `shouldReturn` "tail\n"
      snapshot root `shouldReturn` before
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` manifest

  it "removes a command-only dependency instance without a producer while retaining sibling setup" $
    withSystemTempDirectory "checkout-command-remove" $ \temp -> do
      let root = temp </> "project"; home = temp </> "home"
      D.createDirectoryIfMissing True root
      (binary, moduleDir) <- prepare root home "config/editor" ".editor/config" "shared/assets" ".local/assets"
      let child = root </> ".seihou/modules/child"
      D.createDirectoryIfMissing True (child </> "schema")
      schemaFiles <- D.listDirectory (moduleDir </> "schema")
      forM_ schemaFiles $ \file -> D.copyFile (moduleDir </> "schema" </> file) (child </> "schema" </> file)
      TIO.writeFile (child </> "module.dhall") (commandModuleBody "child" [setupCommand "Checkout" "mkdir -p .local; echo child > .local/child; echo child >> .local/order"] "[] : List S.Dependency.Type")
      TIO.writeFile (moduleDir </> "module.dhall") (commandModuleBody "demo" [setupCommand "Checkout" "mkdir -p .local; echo parent > .local/parent; echo parent >> .local/order"] "[S.Dependency::{module = \"child\"}]")
      _ <- success binary root home ["run", "demo", "--force"]
      TIO.readFile (root </> ".local/order") `shouldReturn` "child\nparent\n"
      D.renameDirectory (root </> ".seihou/modules") (temp </> "unavailable-producers")
      _ <- success binary root home ["remove", "child", "--force"]
      D.removeFile (root </> ".local/child")
      D.removeFile (root </> ".local/parent")
      _ <- success binary root home ["sync"]
      D.doesPathExist (root </> ".local/child") `shouldReturn` False
      TIO.readFile (root </> ".local/parent") `shouldReturn` "parent\n"

  forM_ ["Apply", "Checkout"] $ \failingLifecycle -> do
    it ("retains accepted run declarations after " <> T.unpack failingLifecycle <> " failure and sync retries only setup") $
      withSystemTempDirectory "checkout-run-failure" $ \temp -> do
        let root = temp </> "project"; home = temp </> "home"
        D.createDirectoryIfMissing True root
        (binary, moduleDir) <- prepare root home "config/editor" ".editor/config" "shared/assets" ".local/assets"
        TIO.writeFile
          (moduleDir </> "module.dhall")
          ( commandModuleBody
              "demo"
              [setupCommand "Checkout" "mkdir -p .local; echo prefix > .local/prefix", setupCommand failingLifecycle "test -f .local/allow", setupCommand "Checkout" "echo tail > .local/tail"]
              "[] : List S.Dependency.Type"
          )
        (code, _, _) <- invoke binary root home ["run", "demo", "--force"]
        code `shouldSatisfy` (/= ExitSuccess)
        recorded <- TIO.readFile (root </> ".seihou/manifest.json")
        recorded `shouldSatisfy` T.isInfixOf "echo prefix"
        recorded `shouldSatisfy` T.isInfixOf "echo tail"
        if failingLifecycle == "Checkout" then recorded `shouldSatisfy` T.isInfixOf "test -f .local/allow" else recorded `shouldNotSatisfy` T.isInfixOf "test -f .local/allow"
        D.doesPathExist (root </> ".local/tail") `shouldReturn` False
        when (failingLifecycle == "Checkout") $ TIO.writeFile (root </> ".local/allow") "retry\n"
        _ <- success binary root home ["sync"]
        TIO.readFile (root </> ".local/tail") `shouldReturn` "tail\n"
        TIO.readFile (root </> ".seihou/manifest.json") `shouldReturn` recorded

    it ("rolls back proposed checkout declarations on " <> T.unpack failingLifecycle <> " update failure") $
      withSystemTempDirectory "checkout-update-failure" $ \temp -> do
        let root = temp </> "project"; home = temp </> "home"
        D.createDirectoryIfMissing True root
        (binary, moduleDir) <- prepare root home "config/editor" ".editor/config" "shared/assets" ".local/assets"
        TIO.writeFile (moduleDir </> "module.dhall") (commandModuleBody "demo" [setupCommand "Checkout" "mkdir -p .local; echo accepted > .local/accepted"] "[] : List S.Dependency.Type")
        _ <- success binary root home ["run", "demo", "--force"]
        manifest <- BS.readFile (root </> ".seihou/manifest.json")
        before <- snapshot root
        TIO.writeFile
          (moduleDir </> "module.dhall")
          ( commandModuleBody
              "demo"
              [setupCommand "Checkout" "mkdir -p .local; echo proposed > .local/proposed", setupCommand failingLifecycle "exit 42", setupCommand "Checkout" "echo tail > .local/tail"]
              "[] : List S.Dependency.Type"
          )
        (code, _, _) <- invoke binary root home ["update", "demo", "--json"]
        code `shouldSatisfy` (/= ExitSuccess)
        BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` manifest
        snapshot root `shouldReturn` before
        TIO.readFile (root </> ".local/proposed") `shouldReturn` "proposed\n"
        D.removeFile (root </> ".local/accepted")
        _ <- success binary root home ["sync"]
        TIO.readFile (root </> ".local/accepted") `shouldReturn` "accepted\n"
        D.doesPathExist (root </> ".local/tail") `shouldReturn` False

  it "preflights all working directories and rechecks each after earlier checkout shell" $
    withSystemTempDirectory "checkout-command-workdir" $ \temp -> do
      let root = temp </> "project"; home = temp </> "home"
      D.createDirectoryIfMissing True root
      (binary, moduleDir) <- prepare root home "config/editor" ".editor/config" "shared/assets" ".local/assets"
      D.createDirectoryIfMissing True (root </> ".local/wd")
      let second = "S.Command::{action = S.Command.Action.Checkout \"touch launched\", workDir = Some \".local/wd\"}"
      TIO.writeFile
        (moduleDir </> "module.dhall")
        ( commandModuleBody
            "demo"
            [setupCommand "Checkout" "mv .local/wd .local/real; ln -s real .local/wd", second, setupCommand "Checkout" "touch .local/tail"]
            "[] : List S.Dependency.Type"
        )
      _ <- success binary root home ["run", "demo", "--force", "--no-commands"]
      (code, output, _) <- invoke binary root home ["sync", "--json"]
      code `shouldSatisfy` (/= ExitSuccess)
      forM_ ["succeeded", "failed", "not-run", "existing real directory"] $ \expected -> output `shouldSatisfy` isInfixOf expected
      D.doesPathExist (root </> ".local/real/launched") `shouldReturn` False
      D.doesPathExist (root </> ".local/tail") `shouldReturn` False
      -- A subsequent preflight rejects the symlink before even the first command.
      (again, _, _) <- invoke binary root home ["sync", "--dry-run"]
      again `shouldSatisfy` (/= ExitSuccess)

  forM_ ["run", "update"] $ \mode ->
    it ("records checkout declarations under " <> T.unpack mode <> " --no-commands for a fresh teammate clone") $
      withSystemTempDirectory "checkout-disabled-clone" $ \temp -> do
        let root = temp </> "project"; home = temp </> "home"; clone = temp </> "teammate"
        D.createDirectoryIfMissing True root
        (binary, moduleDir) <- prepare root home "config/editor" ".editor/config" "shared/assets" ".local/assets"
        TIO.writeFile (moduleDir </> "module.dhall") (commandModuleBody "demo" [setupCommand "Checkout" "mkdir -p .local; echo setup > .local/setup", setupCommand "Apply" "touch command-sentinel"] "[] : List S.Dependency.Type")
        _ <- success binary root home (if mode == "run" then ["run", "demo", "--force", "--no-commands"] else ["update", "demo", "--no-commands", "--json"])
        D.doesPathExist (root </> ".local/setup") `shouldReturn` False
        _ <- git root ["add", "."]
        _ <- git root ["commit", "-qm", "test: share disabled checkout declarations"]
        _ <- git root ["clone", "-q", root, clone]
        before <- snapshot clone
        _ <- success binary clone (temp </> "empty-receiver") ["sync"]
        TIO.readFile (clone </> ".local/setup") `shouldReturn` "setup\n"
        D.doesPathExist (clone </> "command-sentinel") `shouldReturn` False
        snapshot clone `shouldReturn` before

  it "refuses an actual interrupted update until the documented recovery entry restores prior files" $
    withSystemTempDirectory "checkout-sync-recovery" $ \temp -> do
      let root = temp </> "project"; home = temp </> "home"
      D.createDirectoryIfMissing True root
      (binary, _) <- prepare root home "config/editor" ".editor/config" "shared/assets" ".local/assets"
      manifest <- BS.readFile (root </> ".seihou/manifest.json")
      transaction <- beginUpdateTransaction root (Set.singleton "config/editor/sentinel")
      transaction `shouldSatisfy` either (const False) (const True)
      TIO.writeFile (root </> "config/editor/sentinel") "interrupted write\n"
      D.removeDirectoryLink (root </> ".editor/config")
      forM_ [["sync"], ["sync", "--dry-run"]] $ \args -> do
        (code, output, _) <- invoke binary root home args
        code `shouldSatisfy` (/= ExitSuccess)
        output `shouldSatisfy` isInfixOf "seihou update --dry-run"
        TIO.readFile (root </> "config/editor/sentinel") `shouldReturn` "interrupted write\n"
        D.doesPathExist (root </> ".editor/config") `shouldReturn` False
        BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` manifest
      _ <- success binary root home ["update", "demo", "--dry-run"]
      TIO.readFile (root </> "config/editor/sentinel") `shouldReturn` "source bytes\n"
      _ <- success binary root home ["sync"]
      D.getSymbolicLinkTarget (root </> ".editor/config") `shouldReturn` "../config/editor"
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` manifest

  it "keeps empty sync read-only, then adopts through update and restores a teammate clone" $
    withSystemTempDirectory "sync-team-adoption" $ \temp -> do
      (binary, original, home) <- prepareAdoptionWithTracking temp False False
      let root = temp </> "older-clone"
      _ <- git original ["clone", "-q", original, root]
      before <- snapshot root
      D.doesPathExist (root </> ".local/config") `shouldReturn` False
      status <- success binary root home ["status"]
      status `shouldSatisfy` isInfixOf "Checkout outputs: none declared."
      status `shouldSatisfy` isInfixOf "No checkout effects are recorded"
      forM_ [["sync"], ["sync", "--dry-run"], ["sync", "--json"]] $ \args -> do
        output <- success binary root home args
        output `shouldSatisfy` isInfixOf "No checkout effects are recorded"
        snapshot root `shouldReturn` before
        D.doesPathExist (root </> ".local/config") `shouldReturn` False
      _ <- success binary root home ["update", "demo", "--json"]
      D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/editor"
      TIO.readFile (root </> ".local/config/sentinel") `shouldReturn` "tracked source\n"
      _ <- git root ["add", ".seihou"]
      _ <- git root ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "test: share link setup"]
      let clone = temp </> "teammate"; cloneHome = temp </> "teammate-home"
      D.createDirectoryIfMissing True cloneHome
      _ <- git root ["clone", "-q", root, clone]
      cloneBefore <- snapshot clone
      first <- success binary clone cloneHome ["sync"]
      first `shouldSatisfy` isInfixOf "Links: 1 created, 0 repaired, 0 unchanged."
      second <- success binary clone cloneHome ["sync"]
      second `shouldSatisfy` isInfixOf "Links: 0 created, 0 repaired, 1 unchanged."
      snapshot clone `shouldReturn` cloneBefore
      D.doesDirectoryExist (cloneHome </> "config/seihou/installed") `shouldReturn` False
  it "adopts tracked links through update while preserving unrelated staging" $
    withSystemTempDirectory "sync-tracked-adoption" $ \temp -> do
      (binary, root, home) <- prepareAdoption temp False
      TIO.writeFile (root </> "unrelated") "staged edit\n"
      _ <- git root ["add", "unrelated"]
      TIO.writeFile (root </> "unrelated") "unstaged edit\n"
      dry <- success binary root home ["update", "demo", "--dry-run"]
      dry `shouldSatisfy` isInfixOf "Adopt link: .local/config"
      _ <- success binary root home ["update", "demo", "--json"]
      git root ["ls-files", "--", ".local/config"] `shouldReturn` ""
      git root ["show", ":unrelated"] `shouldReturn` "staged edit\n"
      TIO.readFile (root </> "unrelated") `shouldReturn` "unstaged edit\n"
      D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/editor"
  it "does not upgrade or prompt for an unrelated application during link setup" $
    withSystemTempDirectory "sync-unrelated-application" $ \temp -> do
      (binary, root, home) <- prepareAdoption temp False
      let other = root </> ".seihou/modules/other"
          body version vars = "let S = ./schema/package.dhall in S.Module::{name = \"other\", version = Some \"" <> version <> "\", vars = " <> vars <> ", steps = [S.Step::{strategy = \"copy\", src = \"sentinel\", dest = \"other-output\"}]}"
      D.createDirectoryIfMissing True (other </> "files")
      D.createDirectoryIfMissing True (other </> "schema")
      schemaFiles <- D.listDirectory (temp </> "producer/schema")
      forM_ schemaFiles $ \file -> D.copyFile (temp </> "producer/schema" </> file) (other </> "schema" </> file)
      TIO.writeFile (other </> "files/sentinel") "original other output\n"
      TIO.writeFile (other </> "module.dhall") (body "1.0.0" "[] : List S.VarDecl.Type")
      _ <- success binary root home ["run", "other", "--force"]
      oldManifest <- A.eitherDecode <$> LBS.readFile (root </> ".seihou/manifest.json")
      TIO.writeFile (other </> "files/sentinel") "unrelated proposed update\n"
      TIO.writeFile (other </> "module.dhall") (body "2.0.0" "[S.VarDecl::{name = \"new-input\", type = \"text\", required = True}]")
      _ <- success binary root home ["update", "demo", "--json"]
      TIO.readFile (root </> "other-output") `shouldReturn` "original other output\n"
      newManifest <- A.eitherDecode <$> LBS.readFile (root </> ".seihou/manifest.json")
      let otherApplication decoded = case decoded of
            Right (A.Object fields) -> case KM.lookup "applications" fields of
              Just (A.Array applications) -> [app | app@(A.Object values) <- foldr (:) [] applications, KM.lookup "target" values == Just (A.object ["kind" A..= ("module" :: T.Text), "name" A..= ("other" :: T.Text)])]
              _ -> []
            _ -> []
      length (otherApplication oldManifest) `shouldBe` 1
      otherApplication newManifest `shouldBe` otherApplication oldManifest
  it "refuses occupied directories during adoption without losing user files" $
    withSystemTempDirectory "sync-adoption-collision" $ \temp -> do
      (binary, root, home) <- prepareAdoptionWithTracking temp False False
      D.removeDirectoryLink (root </> ".local/config")
      D.createDirectoryIfMissing True (root </> ".local/config")
      TIO.writeFile (root </> ".local/config/user-file") "keep me\n"
      before <- snapshot root
      (code, _, _) <- invoke binary root home ["update", "demo", "--json"]
      code `shouldSatisfy` (/= ExitSuccess)
      snapshot root `shouldReturn` before
      TIO.readFile (root </> ".local/config/user-file") `shouldReturn` "keep me\n"
  it "adopts a tracked link using remote update alone, then syncs a fresh clone" $
    withSystemTempDirectory "checkout-adoption" $ \temp -> do
      (binary, root, home) <- prepareAdoption temp False
      D.doesDirectoryExist (home </> "config/seihou/installed") `shouldReturn` False
      notice <- success binary root home ["sync", "--dry-run"]
      notice `shouldSatisfy` isInfixOf "No checkout effects are recorded"
      TIO.writeFile (root </> "unrelated") "staged edit\n"
      _ <- git root ["add", "unrelated"]
      TIO.writeFile (root </> "unrelated") "unstaged edit\n"
      indexBefore <- git root ["ls-files", "--stage", "-z"]
      manifestBefore <- BS.readFile (root </> ".seihou/manifest.json")
      dry <- success binary root home ["update", "demo", "--dry-run"]
      dry `shouldSatisfy` isInfixOf "Adopt link: .local/config"
      git root ["ls-files", "--stage", "-z"] `shouldReturn` indexBefore
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` manifestBefore
      _ <- success binary root home ["update", "demo"]
      git root ["ls-files", "--stage", "-z"] `shouldReturn` indexBefore
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` manifestBefore
      applied <- success binary root home ["update", "demo", "--json"]
      applied `shouldSatisfy` isInfixOf "\"adoptedLinks\":[\".local/config\"]"
      git root ["ls-files", "--", ".local/config"] `shouldReturn` ""
      git root ["ls-files", "--", ".local/unrelated"] `shouldReturn` ".local/unrelated\n"
      git root ["show", ":unrelated"] `shouldReturn` "staged edit\n"
      TIO.readFile (root </> "unrelated") `shouldReturn` "unstaged edit\n"
      D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/editor"
      _ <- git root ["add", ".seihou/manifest.json"]
      _ <- git root ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "test: adopt links"]
      let clone = temp </> "clone"; cloneHome = temp </> "clone-home"
      D.createDirectoryIfMissing True cloneHome
      _ <- git root ["clone", "-q", root, clone]
      before <- snapshot clone
      D.doesPathExist (clone </> ".local/config") `shouldReturn` False
      first <- success binary clone cloneHome ["sync"]
      first `shouldSatisfy` isInfixOf "Links: 1 created, 0 repaired, 0 unchanged."
      second <- success binary clone cloneHome ["sync"]
      second `shouldSatisfy` isInfixOf "Links: 0 created, 0 repaired, 1 unchanged."
      status <- success binary clone cloneHome ["status"]
      status `shouldSatisfy` isInfixOf "Checkout outputs: 1 declared, 0 need attention."
      status `shouldSatisfy` isInfixOf "Remote producer cache: 1 not cached locally (optional)"
      status `shouldNotSatisfy` isInfixOf "Artifacts that differ"
      snapshot clone `shouldReturn` before
      D.doesDirectoryExist (cloneHome </> "config/seihou/installed") `shouldReturn` False
  it "restores the original index and working links if an adopted update fails" $
    withSystemTempDirectory "checkout-adoption-failed" $ \temp -> do
      (binary, root, home) <- prepareAdoption temp True
      before <- snapshot root
      (code, _, _) <- invoke binary root home ["update", "demo", "--json"]
      code `shouldSatisfy` (/= ExitSuccess)
      snapshot root `shouldReturn` before
  forM_ ["staged link", "unstaged link", "regular file"] $ \kind ->
    it ("protects a " <> kind <> " during adoption") $
      withSystemTempDirectory "checkout-adoption-refused" $ \temp -> do
        (binary, root, home) <- prepareAdoption temp False
        D.removeDirectoryLink (root </> ".local/config")
        if kind == "regular file"
          then TIO.writeFile (root </> ".local/config") "user bytes"
          else D.createDirectoryLink "../other-target" (root </> ".local/config")
        if kind == "staged link"
          then do
            _ <- git root ["add", "-f", ".local/config"]
            -- The working link matches the new declaration, but the index does not.
            D.removeDirectoryLink (root </> ".local/config")
            D.createDirectoryLink "../config/editor" (root </> ".local/config")
          else pure ()
        before <- snapshot root
        (code, _, _) <- invoke binary root home ["update", "demo", "--json"]
        code `shouldSatisfy` (/= ExitSuccess)
        snapshot root `shouldReturn` before
  forM_ [("agent", "agents/skills/demo", ".claude/skills/demo", "agents/skills/demo", ".agents/skills/demo"), ("editor and assets", "config/editor", ".editor-local/config", "assets/shared", ".tool-local/assets")] $ \(domain, source, destination, secondSource, secondDestination) ->
    it ("restores " <> domain <> " links in a clean clone without a producer or commands") $
      withSystemTempDirectory "checkout-clone" $ \temp -> do
        let original = temp </> "original"; clone = temp </> "clone"; home = temp </> "home"
        D.createDirectoryIfMissing True original
        D.createDirectoryIfMissing True home
        (binary, _) <- prepare original home source destination secondSource secondDestination
        _ <- git original ["clone", "-q", original, clone]
        D.doesDirectoryExist (clone </> ".seihou/modules") `shouldReturn` False
        TIO.writeFile (clone </> ".seihou/config.dhall") "deliberately invalid configuration; sync must never evaluate it"
        before <- snapshot clone
        manifest <- BS.readFile (clone </> ".seihou/manifest.json")
        dry <- success binary clone home ["sync", "--dry-run", "--json"]
        dry `shouldSatisfy` isInfixOf "\"not-applied\""
        snapshot clone `shouldReturn` before
        D.doesPathExist (clone </> takeDirectory (T.unpack destination)) `shouldReturn` False
        _ <- success binary clone home ["sync", "--json"]
        BS.readFile (clone </> ".seihou/manifest.json") `shouldReturn` manifest
        snapshot clone `shouldReturn` before
        git clone ["status", "--porcelain"] `shouldReturn` ""
        D.pathIsSymbolicLink (clone </> T.unpack destination) `shouldReturn` True
        D.doesFileExist (clone </> "command-sentinel") `shouldReturn` False
        -- Even a newer producer in the cache cannot change recorded intent.
        let newer = home </> "config/seihou/installed/demo"
        D.createDirectoryIfMissing True (newer </> "schema")
        schemaFiles <- D.listDirectory (original </> ".seihou/modules/demo/schema")
        forM_ schemaFiles $ \file ->
          D.copyFile (original </> ".seihou/modules/demo/schema" </> file) (newer </> "schema" </> file)
        TIO.writeFile (newer </> "module.dhall") (fixtureModule "demo" "config/next" destination secondSource secondDestination "99.0.0" "true")
        repeated <- success binary clone home ["sync", "--json"]
        repeated `shouldSatisfy` isInfixOf "\"plannedAction\":\"unchanged\""
        D.removeDirectoryLink (clone </> T.unpack destination)
        D.removeDirectoryLink (clone </> T.unpack secondDestination)
        D.createDirectoryLink "missing-target" (clone </> T.unpack secondDestination)
        status <- success binary clone home ["status"]
        status `shouldSatisfy` isInfixOf "seihou sync"
        _ <- success binary clone home ["sync", "--json"]
        snapshot clone `shouldReturn` before
        -- A regular collision refuses the whole plan, preserving source bytes.
        D.removeDirectoryLink (clone </> T.unpack destination)
        TIO.writeFile (clone </> T.unpack destination) "local sentinel"
        (code, refusal, _) <- invoke binary clone home ["sync", "--json"]
        code `shouldSatisfy` (/= ExitSuccess)
        refusal `shouldSatisfy` isInfixOf "checkout_destination_occupied"
        TIO.readFile (clone </> T.unpack destination) `shouldReturn` "local sentinel"
        D.removeFile (clone </> T.unpack destination)
        _ <- success binary clone home ["sync"]
        -- A linked worktree has a .git file and the same manifest-only contract.
        let worktree = temp </> "linked-worktree"
        _ <- git original ["worktree", "add", "--detach", "-q", worktree]
        _ <- success binary worktree home ["sync", "--json"]
        git worktree ["status", "--porcelain"] `shouldReturn` ""
        _ <- success binary clone home ["remove", "demo", "--force"]
        D.doesPathExist (clone </> T.unpack destination) `shouldReturn` False
        TIO.readFile (clone </> T.unpack source </> "sentinel") `shouldReturn` "source bytes\n"
  it "preserves agreeing application owners until the last owner is removed" $
    withSystemTempDirectory "checkout-owners" $ \temp -> do
      let root = temp </> "project"
          home = temp </> "home"
      D.createDirectoryIfMissing True root
      D.createDirectoryIfMissing True home
      (binary, demoDir) <- prepare root home "config/editor" ".local/config" "assets/shared" ".tool/assets"
      let other = root </> ".seihou/modules/other"
      D.createDirectoryIfMissing True (other </> "schema")
      files <- D.listDirectory (demoDir </> "schema")
      forM_ files $ \file -> D.copyFile (demoDir </> "schema" </> file) (other </> "schema" </> file)
      TIO.writeFile (other </> "module.dhall") (fixtureModule "other" "config/editor" ".local/config" "assets/shared" ".tool/assets" "1.0.0" "true")
      _ <- success binary root home ["run", "other", "--force", "--no-commands"]
      -- An unselected agreeing owner prevents a unilateral retarget.
      TIO.writeFile (demoDir </> "module.dhall") (fixtureModule "demo" "config/next" ".local/config" "assets/shared" ".tool/assets" "2.0.0" "true")
      (code, _, _) <- invoke binary root home ["update", "demo", "--reconfigure", "--force", "--no-commands"]
      code `shouldSatisfy` (/= ExitSuccess)
      D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/editor"
      _ <- success binary root home ["remove", "demo", "--force"]
      D.pathIsSymbolicLink (root </> ".local/config") `shouldReturn` True
      _ <- success binary root home ["remove", "other", "--force"]
      D.doesPathExist (root </> ".local/config") `shouldReturn` False
      TIO.readFile (root </> "config/editor/sentinel") `shouldReturn` "source bytes\n"
  it "links a source generated in the same composition and preserves other applications" $
    withSystemTempDirectory "checkout-generated" $ \temp -> do
      let root = temp </> "project"; home = temp </> "home"
      D.createDirectoryIfMissing True root
      D.createDirectoryIfMissing True home
      (binary, demoDir) <- prepare root home "config/editor" ".local/config" "assets/shared" ".tool/assets"
      let generated = root </> ".seihou/modules/generated"
      D.createDirectoryIfMissing True (generated </> "files")
      D.createDirectoryIfMissing True (generated </> "schema")
      schemaFiles <- D.listDirectory (demoDir </> "schema")
      forM_ schemaFiles $ \file -> D.copyFile (demoDir </> "schema" </> file) (generated </> "schema" </> file)
      TIO.writeFile (generated </> "files/settings") "generated source bytes"
      TIO.appendFile (root </> ".gitignore") ".generated/\n"
      TIO.writeFile
        (generated </> "module.dhall")
        ( T.unlines
            [ "let S = ./schema/package.dhall in S.Module::{ name = \"generated\", version = Some \"1.0.0\", steps = [",
              "S.Step::{strategy = \"copy\", src = \"settings\", dest = \"generated/editor/settings\"},",
              "S.Step::{strategy = \"symlink\", src = \"generated/editor\", dest = \".generated/editor\", lifecycle = Some \"checkout\"}] }"
            ]
        )
      _ <- success binary root home ["run", "generated", "--dry-run", "--no-commands"]
      D.doesPathExist (root </> "generated") `shouldReturn` False
      D.doesPathExist (root </> ".generated") `shouldReturn` False
      _ <- success binary root home ["run", "generated", "--force", "--no-commands"]
      D.getSymbolicLinkTarget (root </> ".generated/editor") `shouldReturn` "../generated/editor"
      D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/editor"
      TIO.readFile (root </> "generated/editor/settings") `shouldReturn` "generated source bytes"
  it "rolls back link targets and manifest bytes when an update command fails" $
    withSystemTempDirectory "checkout-update-failure" $ \temp -> do
      let root = temp </> "project"; home = temp </> "home"
      D.createDirectoryIfMissing True root
      D.createDirectoryIfMissing True home
      (binary, moduleDir) <- prepare root home "config/editor" ".local/config" "assets/shared" ".tool/assets"
      before <- BS.readFile (root </> ".seihou/manifest.json")
      let candidate = T.replace "touch command-sentinel" "exit 42" (fixtureModule "demo" "config/next" ".local/config" "assets/shared" ".tool/assets" "2.0.0" "true")
      TIO.writeFile (moduleDir </> "module.dhall") candidate
      (code, _, _) <- invoke binary root home ["update", "demo", "--reconfigure", "--force", "--json"]
      code `shouldSatisfy` (/= ExitSuccess)
      D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/editor"
      BS.readFile (root </> ".seihou/manifest.json") `shouldReturn` before
      TIO.readFile (root </> "config/editor/sentinel") `shouldReturn` "source bytes\n"
  it "updates a link-only module, retires a false condition, and preserves an edited retirement" $
    withSystemTempDirectory "checkout-update" $ \temp -> do
      let root = temp </> "project"; home = temp </> "home"
      D.createDirectoryIfMissing True root
      D.createDirectoryIfMissing True home
      (binary, moduleDir) <- prepare root home "config/editor" ".local/config" "assets/shared" ".tool/assets"
      TIO.writeFile (moduleDir </> "module.dhall") (fixtureModule "demo" "config/next" ".local/config" "assets/shared" ".tool/assets" "2.0.0" "true")
      _ <- success binary root home ["update", "demo", "--reconfigure", "--no-commands", "--force", "--json"]
      D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/next"
      D.removeDirectoryLink (root </> ".local/config")
      D.createDirectoryLink "locally-edited" (root </> ".local/config")
      TIO.writeFile (moduleDir </> "module.dhall") (fixtureModule "demo" "config/next" ".local/config" "assets/shared" ".tool/assets" "3.0.0" "false")
      _ <- success binary root home ["update", "demo", "--reconfigure", "--no-commands", "--force", "--json"]
      D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "locally-edited"
      D.doesFileExist (root </> "command-sentinel") `shouldReturn` False