packages feed

seihou-cli-0.16.0.0: src/Seihou/CLI/Judgement/Hooks.hs

{-# LANGUAGE MultiWayIf #-}

-- | Git pre-commit integration for judgements.
--
-- Seihou owns at most one hook file, recognised by 'hookMarker'. It never
-- overwrites a hook it does not own:
--
-- * No @pre-commit@ hook: Seihou writes its own.
-- * A hook generated by the pre-commit framework: Seihou writes
--   @pre-commit.legacy@, which that framework runs before its own hooks and
--   keeps when it regenerates @pre-commit@. Its formatters fail a commit
--   rather than restage files, so the reviewed index is the committed one.
-- * Any other hook: Seihou renames it, byte for byte (a symlink stays the
--   same literal link), to @pre-commit.seihou-original@ and writes a hook
--   that runs it first and stops the commit if it fails.
--
-- A @core.hooksPath@ inside this repository (git-hooks.nix sets it to
-- @.git/hooks@) is used as is. One that points outside the repository's Git
-- directory and worktree may be shared beyond this checkout, so Seihou
-- refuses and names the line to add instead.
-- Linked worktrees resolve the common hook directory through Git itself.
module Seihou.CLI.Judgement.Hooks
  ( HookPlan (..),
    hookMarker,
    ownedHookScript,
    planHook,
    applyHook,
    renderHookPlan,
    retireOwnedHook,
  )
where

import Control.Exception (IOException, try)
import Data.ByteString qualified as BS
import Data.Generics.Labels ()
import Data.List (isPrefixOf)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Seihou.CLI.Judgement.Evidence (GitRunner)
import Seihou.Prelude
import System.Directory (canonicalizePath, doesPathExist, getPermissions, pathIsSymbolicLink, removeFile, renamePath, setOwnerExecutable, setPermissions)
import System.FilePath (isAbsolute, normalise, splitDirectories)

-- | The first comment line of every hook file Seihou owns.
hookMarker :: Text
hookMarker = "# seihou-judgement-hook v1"

-- | What adoption will do to the hook directory.
data HookPlan
  = -- | Seihou's hook is already in place at this path.
    HookAlreadyInstalled !FilePath
  | -- | Write Seihou's hook at this path; nothing is there.
    HookInstall !FilePath
  | -- | Write @pre-commit.legacy@ beside a pre-commit framework hook.
    HookBesideFramework !FilePath
  | -- | Preserve the existing hook at the second path and write Seihou's
    -- hook, which runs it first, at the first.
    HookChain !FilePath !FilePath
  deriving stock (Eq, Show, Generic)

renderHookPlan :: HookPlan -> Text
renderHookPlan = \case
  HookAlreadyInstalled p -> "pre-commit hook: already runs seihou check (" <> T.pack p <> ")"
  HookInstall p -> "pre-commit hook: install " <> T.pack p <> " to run seihou check"
  HookBesideFramework p -> "pre-commit hook: install " <> T.pack p <> ", which the pre-commit framework runs before its own hooks"
  HookChain p orig -> "pre-commit hook: keep the existing hook as " <> T.pack orig <> " and install " <> T.pack p <> ", which runs it first and then seihou check"

-- | The hook script. It runs a preserved original hook first, refuses to
-- let a commit through when @seihou@ cannot be found, and then runs every
-- active judgement on the staged changes.
ownedHookScript :: Text
ownedHookScript =
  T.unlines
    [ "#!/bin/sh",
      hookMarker,
      "# Managed by seihou: runs the project's active judgements on the staged",
      "# changes. A nonzero status stops the commit. Remove the judgement with",
      "# seihou to retire this hook; do not edit it in place.",
      "here=$(dirname \"$0\")",
      "if [ -e \"$here/pre-commit.seihou-original\" ]; then",
      "  \"$here/pre-commit.seihou-original\" \"$@\" || exit $?",
      "fi",
      "if ! command -v seihou >/dev/null 2>&1; then",
      "  echo \"seihou check: the seihou executable is not on PATH, so the staged changes were not checked; the commit is stopped.\" >&2",
      "  exit 1",
      "fi",
      "exec seihou check --staged"
    ]

-- | Decide the hook change for the repository at @root@ without writing.
planHook :: FilePath -> GitRunner -> IO (Either Text HookPlan)
planHook root git = do
  hooksPathSetting <- git ["config", "--get", "core.hooksPath"]
  let hooksPathSet = either (const False) (not . T.null . T.strip . TE.decodeUtf8) hooksPathSetting
  located <- traverse (fmap (fmap resolve)) [git ["rev-parse", "--git-path", "hooks"], git ["rev-parse", "--git-common-dir"], git ["rev-parse", "--show-toplevel"]]
  case sequence located of
    Left err -> pure (Left ("cannot locate the Git hook directory: " <> err))
    Right [rawDir, commonDir, top] -> do
      dir <- canonicalizePath rawDir
      owned <- mapM canonicalizePath [commonDir, top]
      if hooksPathSet && not (any (\base -> splitDirectories base `isPrefixOf` splitDirectories dir) owned)
        then pure (Left ("core.hooksPath points at " <> T.pack dir <> ", outside this repository, so its hooks may be shared beyond this checkout; add 'seihou check --staged' to that pre-commit hook yourself"))
        else planIn rawDir
    Right _ -> pure (Left "cannot locate the Git hook directory")
  where
    resolve raw =
      let rel = T.unpack (T.strip (TE.decodeUtf8 raw))
       in normalise (if isAbsolute rel then rel else root </> rel)
    planIn dir = do
      let hook = dir </> "pre-commit"
          legacy = dir </> "pre-commit.legacy"
          original = dir </> "pre-commit.seihou-original"
      present <- doesPathExist hook
      if not present
        then pure (Right (HookInstall hook))
        else do
          link <- pathIsSymbolicLink hook
          body <- if link then pure Nothing else readMaybe hook
          case body of
            Just b
              | hookMarker `T.isInfixOf` b -> pure (Right (HookAlreadyInstalled hook))
              | "File generated by pre-commit" `T.isInfixOf` b -> do
                  legacyPresent <- doesPathExist legacy
                  legacyBody <- if legacyPresent then readMaybe legacy else pure Nothing
                  pure $ case legacyBody of
                    _ | not legacyPresent -> Right (HookBesideFramework legacy)
                    Just lb | hookMarker `T.isInfixOf` lb -> Right (HookAlreadyInstalled legacy)
                    _ -> Left ("the pre-commit framework already runs " <> T.pack legacy <> "; add 'seihou check --staged' to it yourself")
            _ -> do
              taken <- doesPathExist original
              pure $
                if taken
                  then Left (T.pack original <> " already exists; add 'seihou check --staged' to the existing pre-commit hook yourself")
                  else Right (HookChain hook original)
    readMaybe p =
      (try (BS.readFile p) :: IO (Either IOException BS.ByteString)) <&> \case
        Right bs -> Just (TE.decodeUtf8With (\_ _ -> Just '?') bs)
        Left _ -> Nothing

-- | Carry out a plan. Writing happens only after the plan was re-derived
-- from the same directory state, so a changed hook is never clobbered.
applyHook :: HookPlan -> IO (Either Text ())
applyHook plan =
  try act <&> \case
    Left (e :: IOException) -> Left ("could not install the pre-commit hook: " <> T.pack (show e))
    Right () -> Right ()
  where
    act = case plan of
      HookAlreadyInstalled _ -> pure ()
      HookInstall p -> writeHook p
      HookBesideFramework p -> writeHook p
      HookChain p orig -> renamePath p orig >> writeHook p
    writeHook p = do
      BS.writeFile p (TE.encodeUtf8 ownedHookScript)
      perms <- getPermissions p
      setPermissions p (setOwnerExecutable True perms)

-- | Remove Seihou's hook once no judgement is active. Only a byte-identical
-- owned hook is removed; a preserved original is restored in its place. An
-- edited hook is left alone and reported.
retireOwnedHook :: FilePath -> GitRunner -> IO (Either Text [Text])
retireOwnedHook root git =
  git ["rev-parse", "--git-path", "hooks"] >>= \case
    Left err -> pure (Left ("cannot locate the Git hook directory: " <> err))
    Right raw -> do
      let rel = T.unpack (T.strip (TE.decodeUtf8 raw))
          dir = normalise (if isAbsolute rel then rel else root </> rel)
          hook = dir </> "pre-commit"
          legacy = dir </> "pre-commit.legacy"
          original = dir </> "pre-commit.seihou-original"
      outcome <-
        try $ do
          legacyMsg <- retireFile legacy Nothing
          hookMsg <- retireFile hook (Just original)
          pure (legacyMsg <> hookMsg)
      pure $ case outcome of
        Left (e :: IOException) -> Left ("could not retire the pre-commit hook: " <> T.pack (show e))
        Right msgs -> Right msgs
  where
    retireFile path restore = do
      present <- doesPathExist path
      link <- if present then pathIsSymbolicLink path else pure False
      if not present || link
        then pure []
        else do
          body <- TE.decodeUtf8With (\_ _ -> Just '?') <$> BS.readFile path
          if
            | body == ownedHookScript -> do
                restored <- maybe (pure False) doesPathExist restore
                case restore of
                  Just orig | restored -> renamePath orig path >> pure ["restored the original pre-commit hook"]
                  _ -> removeFile path >> pure ["removed " <> T.pack path]
            | hookMarker `T.isInfixOf` body -> pure ["left " <> T.pack path <> " in place because it was edited; remove its seihou check line yourself"]
            | otherwise -> pure []