packages feed

seihou-core-0.5.0.0: src/Seihou/Engine/ThreeWayMerge.hs

module Seihou.Engine.ThreeWayMerge
  ( MergeOutcome (..),
    threeWayMerge,
    threeWayMergeWithGit,
  )
where

import Control.Exception (IOException, displayException, try)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.Prelude
import System.Exit (ExitCode (..))
import System.IO.Temp (withSystemTempDirectory)
import System.Process (readProcessWithExitCode)

-- | The result of reconciling a previous generated ancestor, the user's
-- current file, and newly generated content. No outcome writes to the project.
data MergeOutcome
  = MergeClean Text
  | MergeConflicted Text
  | MergeUnavailable Text
  deriving stock (Eq, Show)

-- | Merge generated content in argument order: previous generated baseline,
-- current disk content, then new generated content.
threeWayMerge :: Text -> Text -> Text -> IO MergeOutcome
threeWayMerge = threeWayMergeWithGit "git"

-- | Testable driver variant. Production callers should use 'threeWayMerge';
-- supplying the executable keeps missing-driver behavior directly testable.
threeWayMergeWithGit :: FilePath -> Text -> Text -> Text -> IO MergeOutcome
threeWayMergeWithGit gitExecutable baseline current newGenerated
  | any (T.any (== '\NUL')) [baseline, current, newGenerated] =
      pure (MergeUnavailable "binary content containing NUL cannot be merged")
  | current == baseline = pure (MergeClean newGenerated)
  | newGenerated == baseline = pure (MergeClean current)
  | current == newGenerated = pure (MergeClean current)
  | otherwise = do
      result <-
        try @IOException $
          withSystemTempDirectory "seihou-three-way-merge" $ \tmpDir -> do
            let currentPath = tmpDir </> "CURRENT"
                baselinePath = tmpDir </> "BASE"
                newPath = tmpDir </> "NEW"
            TIO.writeFile currentPath current
            TIO.writeFile baselinePath baseline
            TIO.writeFile newPath newGenerated
            readProcessWithExitCode
              gitExecutable
              [ "merge-file",
                "--stdout",
                "--diff3",
                "-L",
                "current",
                "-L",
                "generated-base",
                "-L",
                "new-generated",
                currentPath,
                baselinePath,
                newPath
              ]
              ""
      pure $ case result of
        Left err -> MergeUnavailable ("git merge-file unavailable: " <> T.pack (displayException err))
        Right (ExitSuccess, stdout, _) -> MergeClean (T.pack stdout)
        Right (ExitFailure _, stdout, stderr)
          | hasCompleteConflictMarkers merged -> MergeConflicted merged
          | otherwise ->
              MergeUnavailable
                ( "git merge-file failed without a usable conflict result"
                    <> conciseStderr stderr
                )
          where
            merged = T.pack stdout

hasCompleteConflictMarkers :: Text -> Bool
hasCompleteConflictMarkers output =
  all
    (\marker -> any (marker `T.isPrefixOf`) (T.lines output))
    [ "<<<<<<< current",
      "||||||| generated-base",
      "=======",
      ">>>>>>> new-generated"
    ]

conciseStderr :: String -> Text
conciseStderr stderr = case T.strip (T.pack stderr) of
  "" -> ""
  message -> ": " <> T.take 240 message