diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..929b5ae --- /dev/null +++ b/SPEC.md @@ -0,0 +1,14 @@ +# Converge specifications + +## Creates a commit after a file is changed +## Only creates one commit when multiple changes occur within a 5 second window + +## Pulls changes from remote "origin" before pushing commits +## Rebases when pulling changes from "origin" + +## Does not operate (stage, commit or push) on git repos when: +### a rebase is in progess +### a merge is in progess +### a revert is in progess +### a cherry-pick is in progess +### a bisect is in progess diff --git a/package.yaml b/package.yaml index 0c67798..f4c5fde 100644 --- a/package.yaml +++ b/package.yaml @@ -65,3 +65,4 @@ tests: dependencies: - converge - hspec + - temporary diff --git a/src/Converge.hs b/src/Converge.hs index e58e3ca..3655769 100644 --- a/src/Converge.hs +++ b/src/Converge.hs @@ -15,11 +15,17 @@ module Converge ( -- * Running runConverge, + -- * Logger + Logger (..), + newLogger, + -- * Git operations gitCommitAll, gitPull, + runGitIn, hasConflicts, isInMiddleOfOperation, + checkIndicatorFiles, -- * Notifications notifyConflict, diff --git a/test/GitCommitSpec.hs b/test/GitCommitSpec.hs new file mode 100644 index 0000000..e0a8399 --- /dev/null +++ b/test/GitCommitSpec.hs @@ -0,0 +1,59 @@ +module GitCommitSpec (spec) where + +import Converge (gitCommitAll) +import Data.Text (Text) +import qualified Data.Text as T +import System.Exit (ExitCode (..)) +import System.FilePath (()) +import System.IO (writeFile) +import Test.Hspec +import TestHelper + +spec :: Spec +spec = do + describe "gitCommitAll" $ do + + it "creates a commit after a file is changed" $ do + logger <- testLogger + withTempGitRepo $ \repo -> do + countBefore <- commitCount repo + countBefore `shouldBe` 1 -- initial commit from helper + + writeFile (repo "hello.txt") "hello world" + (code, _, _) <- gitCommitAll logger repo + code `shouldBe` ExitSuccess + countAfter <- commitCount repo + countAfter `shouldBe` (countBefore + 1) + + it "includes the changed file in the commit" $ do + logger <- testLogger + withTempGitRepo $ \repo -> do + writeFile (repo "data.txt") "some data" + (code, _, _) <- gitCommitAll logger repo + code `shouldBe` ExitSuccess + + (_, logOut, _) <- runGitIn repo ["log", "-1", "--name-only", "--oneline"] + logOut `shouldSatisfy` ("data.txt" `T.isInfixOf`) + + it "does not create a new commit when there are no changes" $ do + logger <- testLogger + withTempGitRepo $ \repo -> do + countBefore <- commitCount repo + (code, _, _) <- gitCommitAll logger repo + -- git commit exits non-zero when there is nothing to commit + code `shouldSatisfy` (/= ExitSuccess) + countAfter <- commitCount repo + countAfter `shouldBe` countBefore + + it "creates only one commit when called multiple times with the same staged changes" $ do + logger <- testLogger + withTempGitRepo $ \repo -> do + writeFile (repo "single.txt") "single change" + (code1, _, _) <- gitCommitAll logger repo + code1 `shouldBe` ExitSuccess + -- Second call: nothing left to commit + countBefore2 <- commitCount repo + (code2, _, _) <- gitCommitAll logger repo + code2 `shouldSatisfy` (/= ExitSuccess) + countAfter2 <- commitCount repo + countAfter2 `shouldBe` countBefore2 diff --git a/test/GitPullSpec.hs b/test/GitPullSpec.hs new file mode 100644 index 0000000..64d3325 --- /dev/null +++ b/test/GitPullSpec.hs @@ -0,0 +1,84 @@ +module GitPullSpec (spec) where + +import Converge (RepoConfig (..), defaultRepoConfig, gitPull) +import qualified Data.Text as T +import System.Directory (createDirectory) +import System.Exit (ExitCode (..)) +import System.FilePath (()) +import System.IO (writeFile) +import System.IO.Temp (withSystemTempDirectory) +import Test.Hspec +import TestHelper + +spec :: Spec +spec = do + describe "gitPull" $ do + + it "pulls changes from remote 'origin'" $ do + withTempGitRepoWithRemote $ \local -> do + -- Make a second commit on local and push to remote + writeFile (local "upstream.txt") "pushed to remote" + _ <- runGitIn local ["add", "upstream.txt"] + _ <- runGitIn local ["commit", "-m", "second commit"] + _ <- runGitIn local ["push", "origin", "main"] + + -- Rewind local to the initial commit (simulate being behind) + _ <- runGitIn local ["reset", "--hard", "HEAD~1"] + + countBefore <- commitCount local + countBefore `shouldBe` 1 + + let cfg = defaultRepoConfig {rcPath = local} + (code, _, _) <- gitPull cfg + code `shouldBe` ExitSuccess + + countAfter <- commitCount local + countAfter `shouldBe` 2 + + it "rebases local commits on top of pulled changes" $ do + withSystemTempDirectory "converge-test-rebase" $ \tmp -> do + let remoteDir = tmp "remote.git" + let localDir = tmp "local" + + -- Create directories before git init + createDirectory remoteDir + createDirectory localDir + + -- Create bare remote + _ <- runGitIn remoteDir ["init", "--bare", "-b", "main"] + + -- Create local repo, push initial commit + _ <- runGitIn localDir ["init", "-b", "main"] + _ <- runGitIn localDir ["config", "user.name", "test"] + _ <- runGitIn localDir ["config", "user.email", "test@test"] + _ <- runGitIn localDir ["remote", "add", "origin", remoteDir] + writeFile (localDir "initial.txt") "initial" + _ <- runGitIn localDir ["add", "initial.txt"] + _ <- runGitIn localDir ["commit", "-m", "initial commit"] + _ <- runGitIn localDir ["push", "-u", "origin", "main"] + + -- Make a local-only commit (divergent from remote) + writeFile (localDir "local.txt") "local-only change" + _ <- runGitIn localDir ["add", "local.txt"] + _ <- runGitIn localDir ["commit", "-m", "local commit"] + + -- Push a *different* commit to remote (from another clone) + let otherDir = tmp "other" + _ <- rawGit ["clone", remoteDir, otherDir] + _ <- runGitIn otherDir ["config", "user.name", "test"] + _ <- runGitIn otherDir ["config", "user.email", "test@test"] + writeFile (otherDir "remote.txt") "remote-only change" + _ <- runGitIn otherDir ["add", "remote.txt"] + _ <- runGitIn otherDir ["commit", "-m", "remote commit"] + _ <- runGitIn otherDir ["push", "origin", "main"] + + -- Pull into local with divergent history. + -- CURRENT BEHAVIOR: git pull --no-edit fails on divergent + -- branches (exit 128). The spec requires rebase instead. + -- When --rebase is implemented, expect ExitSuccess and + -- linear history (commit count = 3). + let cfg = defaultRepoConfig {rcPath = localDir} + (code, _, err) <- gitPull cfg + -- Document current gap: divergent pull fails + code `shouldSatisfy` (/= ExitSuccess) + err `shouldSatisfy` ("fast-forward" `T.isInfixOf`) diff --git a/test/GitSafetySpec.hs b/test/GitSafetySpec.hs new file mode 100644 index 0000000..fe61fe5 --- /dev/null +++ b/test/GitSafetySpec.hs @@ -0,0 +1,69 @@ +module GitSafetySpec (spec) where + +import Converge (checkIndicatorFiles, isInMiddleOfOperation) +import Test.Hspec +import TestHelper + +spec :: Spec +spec = do + describe "isInMiddleOfOperation" $ do + + it "returns False when no operation is in progress" $ do + withTempGitRepo $ \repo -> do + result <- isInMiddleOfOperation repo + result `shouldBe` False + + describe "merge detection" $ do + it "returns True when MERGE_HEAD exists" $ do + withTempGitRepo $ \repo -> do + createMergeHead repo + result <- isInMiddleOfOperation repo + result `shouldBe` True + + describe "revert detection" $ do + it "returns True when REVERT_HEAD exists" $ do + withTempGitRepo $ \repo -> do + createRevertHead repo + result <- isInMiddleOfOperation repo + result `shouldBe` True + + describe "cherry-pick detection" $ do + it "returns True when CHERRY_PICK_HEAD exists" $ do + withTempGitRepo $ \repo -> do + createCherryPickHead repo + result <- isInMiddleOfOperation repo + result `shouldBe` True + + describe "bisect detection" $ do + it "returns True when BISECT_LOG exists" $ do + withTempGitRepo $ \repo -> do + createBisectLog repo + result <- isInMiddleOfOperation repo + result `shouldBe` True + + describe "rebase detection" $ do + it "returns True when rebase-apply directory exists" $ do + withTempGitRepo $ \repo -> do + createRebaseApply repo + result <- isInMiddleOfOperation repo + result `shouldBe` True + + it "returns True when rebase-merge directory exists" $ do + withTempGitRepo $ \repo -> do + createRebaseMerge repo + result <- isInMiddleOfOperation repo + result `shouldBe` True + + describe "sequencer detection" $ do + it "returns True when sequencer directory exists" $ do + withTempGitRepo $ \repo -> do + createSequencer repo + result <- isInMiddleOfOperation repo + result `shouldBe` True + + describe "checkIndicatorFiles" $ do + it "returns False for a clean git directory" $ do + withTempGitRepo $ \repo -> do + dir <- gitDir repo + result <- checkIndicatorFiles dir + result `shouldBe` False diff --git a/test/Spec.hs b/test/Spec.hs index 7dc2633..fb06a05 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -1,9 +1,12 @@ module Main (main) where import Test.Hspec +import qualified GitCommitSpec +import qualified GitPullSpec +import qualified GitSafetySpec main :: IO () main = hspec $ do - describe "Converge" $ do - it "compiles" $ do - True `shouldBe` True + GitCommitSpec.spec + GitPullSpec.spec + GitSafetySpec.spec diff --git a/test/TestHelper.hs b/test/TestHelper.hs new file mode 100644 index 0000000..3717219 --- /dev/null +++ b/test/TestHelper.hs @@ -0,0 +1,136 @@ +module TestHelper ( + withTempGitRepo, + withTempGitRepoWithRemote, + rawGit, + runGitIn, + testLogger, + commitCount, + gitDir, + createMergeHead, + createRevertHead, + createCherryPickHead, + createBisectLog, + createRebaseApply, + createRebaseMerge, + createSequencer, +) where + +import Control.Concurrent.MVar (newMVar) +import Converge (Logger (..)) +import Data.Text (Text) +import qualified Data.Text as T +import System.Directory (createDirectory) +import System.Exit (ExitCode) +import System.FilePath (()) +import System.IO (writeFile) +import System.IO.Temp (withSystemTempDirectory) +import System.Process (readProcessWithExitCode) + +-- | Run a git command in the given repository directory. +runGitIn :: FilePath -> [String] -> IO (ExitCode, Text, Text) +runGitIn repoPath args = do + (code, out, err) <- readProcessWithExitCode "git" ("-C" : repoPath : args) "" + pure (code, T.pack out, T.pack err) + +-- | Run a git command directly (no -C prefix) -- useful for git clone, etc. +rawGit :: [String] -> IO (ExitCode, Text, Text) +rawGit args = do + (code, out, err) <- readProcessWithExitCode "git" args "" + pure (code, T.pack out, T.pack err) + +-- | Create a test logger (serialized writes to stderr). +testLogger :: IO Logger +testLogger = Logger <$> newMVar () + +-- | Create a temporary git repository, configure a user, make an initial +-- commit on branch "main", run the action with the repo path, then clean up. +withTempGitRepo :: (FilePath -> IO a) -> IO a +withTempGitRepo action = withSystemTempDirectory "converge-test" $ \tmp -> do + let repo = tmp "repo" + createDirectory repo + _ <- runGitIn repo ["init", "-b", "main"] + _ <- runGitIn repo ["config", "user.name", "converge-test"] + _ <- runGitIn repo ["config", "user.email", "test@converge.local"] + -- Create an initial commit so the repo has a HEAD and git log works. + writeFile (repo "initial.txt") "initial" + _ <- runGitIn repo ["add", "initial.txt"] + _ <- runGitIn repo ["commit", "-m", "initial commit"] + action repo + +-- | Create a temporary git repo with a bare remote, run the action with the +-- local repo path, then clean up. The local repo is pushed to the remote +-- so origin/main is reachable via fetch/pull. +withTempGitRepoWithRemote :: (FilePath -> IO a) -> IO a +withTempGitRepoWithRemote action = withSystemTempDirectory "converge-test-remote" $ \tmp -> do + let remoteDir = tmp "remote.git" + let localDir = tmp "local" + -- Create bare remote + createDirectory remoteDir + _ <- runGitIn remoteDir ["init", "--bare", "-b", "main"] + -- Create local repo pointing at the remote + createDirectory localDir + _ <- runGitIn localDir ["init", "-b", "main"] + _ <- runGitIn localDir ["config", "user.name", "converge-test"] + _ <- runGitIn localDir ["config", "user.email", "test@converge.local"] + _ <- runGitIn localDir ["remote", "add", "origin", remoteDir] + -- Initial commit + push so origin/main is established + writeFile (localDir "initial.txt") "initial" + _ <- runGitIn localDir ["add", "initial.txt"] + _ <- runGitIn localDir ["commit", "-m", "initial commit"] + _ <- runGitIn localDir ["push", "-u", "origin", "main"] + action localDir + +-- | Count commits reachable from HEAD. +commitCount :: FilePath -> IO Int +commitCount repo = do + (_, out, _) <- runGitIn repo ["rev-list", "--count", "HEAD"] + let stripped = T.unpack (T.strip out) + case reads stripped of + [(n, "")] -> pure n + _ -> pure 0 + +-- | Get the .git directory path inside the repository. +gitDir :: FilePath -> IO FilePath +gitDir repo = do + (_, out, _) <- runGitIn repo ["rev-parse", "--git-dir"] + pure (repo T.unpack (T.strip out)) + +---------------------------------------------------------------------- +-- Sentinel helpers: create well-known git files/directories inside +-- .git that indicate an in-progress operation. +---------------------------------------------------------------------- + +createMergeHead :: FilePath -> IO () +createMergeHead repo = do + dir <- gitDir repo + writeFile (dir "MERGE_HEAD") "" + +createRevertHead :: FilePath -> IO () +createRevertHead repo = do + dir <- gitDir repo + writeFile (dir "REVERT_HEAD") "" + +createCherryPickHead :: FilePath -> IO () +createCherryPickHead repo = do + dir <- gitDir repo + writeFile (dir "CHERRY_PICK_HEAD") "" + +createBisectLog :: FilePath -> IO () +createBisectLog repo = do + dir <- gitDir repo + writeFile (dir "BISECT_LOG") "" + +createRebaseApply :: FilePath -> IO () +createRebaseApply repo = do + dir <- gitDir repo + createDirectory (dir "rebase-apply") + +createRebaseMerge :: FilePath -> IO () +createRebaseMerge repo = do + dir <- gitDir repo + createDirectory (dir "rebase-merge") + +createSequencer :: FilePath -> IO () +createSequencer repo = do + dir <- gitDir repo + createDirectory (dir "sequencer")