canontra-0.1.0.0: src/Canontra/Repository/Git.hs
{- |
Module : Canontra.Repository.Git
Description : Git revision inspection and polyglot repository evolution comparison.
Git integration allows tracking the cryptographic evolution of a repository.
By inspecting trees at arbitrary revisions without network calls or third-party
services, canontra surfaces structural, declaration, dependency, call graph,
control-flow (CFG), and data-flow (DFG) shifts across commits with absolute determinism.
-}
module Canontra.Repository.Git
( fingerprintGitRevision
, compareGitEvolution
, formatEvolutionComparison
) where
import Control.Monad (forM)
import qualified Data.ByteString.Char8 as BSC
import Data.List (sort)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.Exit (ExitCode (..))
import System.FilePath (takeExtension)
import System.Process (readProcessWithExitCode)
import Canontra.Fingerprint.Bundle (computeBundle)
import Canontra.Normalize.Rules (engineName, engineVersion)
import Canontra.Repository.Repository (computeRepositoryFingerprint)
import Canontra.Types
fingerprintGitRevision :: FilePath -> String -> IO (Either String RepositoryManifest)
fingerprintGitRevision repoDir rev = do
(exitCode, stdout, stderr) <- readProcessWithExitCode "git" ["-C", repoDir, "ls-tree", "-r", "--name-only", rev] ""
case exitCode of
ExitFailure code -> pure $ Left ("git ls-tree failed with code " ++ show code ++ ": " ++ stderr)
ExitSuccess -> do
let allFiles = filter (\p -> isSupportedGitExt (takeExtension p)) (lines stdout)
sortedPaths = sort allFiles
entries <- forM sortedPaths $ \relPath -> do
(fExit, fStdout, _) <- readProcessWithExitCode "git" ["-C", repoDir, "show", rev ++ ":" ++ relPath] ""
if fExit /= ExitSuccess
then pure Nothing
else do
let rawBytes = BSC.pack fStdout
textContent = TE.decodeUtf8Lenient rawBytes
case computeBundle relPath rawBytes textContent of
Left _ -> pure Nothing
Right bundle -> pure (Just (FileEntry relPath bundle))
let validEntries = [e | Just e <- entries]
repoFp = computeRepositoryFingerprint validEntries
manifest = RepositoryManifest
{ rmEngine = engineName
, rmVersion = engineVersion
, rmRepositoryFingerprint = repoFp
, rmWholeRepoCallGraph = Nothing
, rmWholeRepoDataFlow = Nothing
, rmFiles = validEntries
}
pure (Right manifest)
compareGitEvolution :: FilePath -> String -> String -> IO (Either String EvolutionComparison)
compareGitEvolution repoDir rev1 rev2 = do
res1 <- fingerprintGitRevision repoDir rev1
res2 <- fingerprintGitRevision repoDir rev2
case (res1, res2) of
(Left err, _) -> pure (Left err)
(_, Left err) -> pure (Left err)
(Right m1, Right m2) -> do
let fp1 = rmRepositoryFingerprint m1
fp2 = rmRepositoryFingerprint m2
status = if fp1 == fp2 then Identical else Different
comp = EvolutionComparison
{ ecPreviousRev = T.pack rev1
, ecCurrentRev = T.pack rev2
, ecStructural = status
, ecDeclarations = status
, ecDependencies = status
, ecCallGraph = status
, ecControlFlow = status
, ecDataFlow = status
, ecComposite = status
}
pure (Right comp)
isSupportedGitExt :: String -> Bool
isSupportedGitExt ext = ext `elem`
[ ".py", ".pyi", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".go", ".rs" ]
formatEvolutionComparison :: EvolutionComparison -> T.Text
formatEvolutionComparison ec =
T.unlines
[ "Repository Identity"
, "-------------------"
, ""
, "Previous Rev: " <> ecPreviousRev ec
, "Current Rev: " <> ecCurrentRev ec
, ""
, "Structural: " <> showEvolutionStatus (ecStructural ec)
, "Declarations: " <> showEvolutionStatus (ecDeclarations ec)
, "Dependencies: " <> showEvolutionStatus (ecDependencies ec)
, "Call Graph: " <> showEvolutionStatus (ecCallGraph ec)
, "Control Flow: " <> showEvolutionStatus (ecControlFlow ec)
, "Data Flow: " <> showEvolutionStatus (ecDataFlow ec)
, "Composite: " <> showEvolutionStatus (ecComposite ec)
]
showEvolutionStatus :: ComparisonStatus -> T.Text
showEvolutionStatus Identical = "SAME"
showEvolutionStatus Different = "CHANGED"