Adds specs
This commit is contained in:
@@ -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
|
||||
@@ -65,3 +65,4 @@ tests:
|
||||
dependencies:
|
||||
- converge
|
||||
- hspec
|
||||
- temporary
|
||||
|
||||
@@ -15,11 +15,17 @@ module Converge (
|
||||
-- * Running
|
||||
runConverge,
|
||||
|
||||
-- * Logger
|
||||
Logger (..),
|
||||
newLogger,
|
||||
|
||||
-- * Git operations
|
||||
gitCommitAll,
|
||||
gitPull,
|
||||
runGitIn,
|
||||
hasConflicts,
|
||||
isInMiddleOfOperation,
|
||||
checkIndicatorFiles,
|
||||
|
||||
-- * Notifications
|
||||
notifyConflict,
|
||||
|
||||
@@ -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
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user