packages feed

mit-3qvpPyAi6mH (empty) → 1

raw patch · 3 files changed

+1051/−0 lines, 3 filesdep +basedep +base64dep +clock

Dependencies added: base, base64, clock, directory, process, record-dot-preprocessor, record-hasfield, text, text-ansi, unix

Files

+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2021 Mitchell Rosen++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ Main.hs view
@@ -0,0 +1,982 @@+{-# LANGUAGE TypeApplications #-}+{-# OPTIONS_GHC -fplugin=RecordDotPreprocessor #-}++module Main where++import Control.Category ((>>>))+import Control.Exception (AsyncException (UserInterrupt), IOException, catch, evaluate, throwIO, try)+import Control.Monad+import Data.Char+import Data.Foldable (fold, for_)+import Data.Function+import qualified Data.List as List+import Data.Maybe+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.ANSI as Text+import qualified Data.Text.Encoding.Base64 as Text+import qualified Data.Text.IO as Text+import qualified System.Clock as Clock+import System.Directory (doesDirectoryExist, doesFileExist, removeFile, withCurrentDirectory)+import System.Environment (getArgs, lookupEnv)+import System.Exit (ExitCode (..), exitFailure, exitWith)+import System.IO (Handle, hIsEOF)+import System.IO.Unsafe (unsafePerformIO)+import System.Posix.Terminal (queryTerminal)+import System.Process+import Text.Read (readMaybe)+import Prelude hiding (head)++-- FIXME: nicer "git status" story. in particular the conflict markers in the commits after a merge are a bit+-- ephemeral feeling+-- FIXME bail if active cherry-pick, active revert, active rebase, what else?+-- FIXME rev-list max 11, use ellipses after 10+-- FIXME test file deleted by us/them conflict++-- TODO mit init+-- TODO mit delete-branch+-- TODO tweak things to work with git < 2.30.1+-- TODO rewrite mit commit algorithm in readme+-- TODO git(hub,lab) flow or something?+-- TODO 'mit branch' with dirty working directory - apply changes to new worktree?+-- TODO undo in more cases?+-- TODO recommend merging master if it conflicts++main :: IO ()+main = do+  getArgs >>= \case+    ["branch", branch] -> mitBranch (Text.pack branch)+    ["clone", parseGitRepo . Text.pack -> Just (url, name)] -> mitClone url name+    ["commit"] -> mitCommit+    ["merge", branch] -> mitMerge (Text.pack branch) -- temporary?+    ["sync"] -> mitSync+    ["undo"] -> mitUndo+    _ ->+      putLines+        [ "Usage:",+          "  mit branch ≪branch≫",+          "  mit clone ≪repo≫",+          "  mit commit",+          "  mit merge ≪branch≫",+          "  mit sync",+          "  mit undo"+        ]++dieIfBuggyGit :: IO ()+dieIfBuggyGit = do+  version <- gitVersion+  case foldr (\(ver, err) acc -> if version < ver then (ver, err) : acc else acc) [] validations of+    [] -> pure ()+    errors ->+      die $+        map+          (\(ver, err) -> "Prior to " <> Text.bold "git" <> " version " <> showGitVersion ver <> ", " <> err)+          errors+  where+    validations :: [(GitVersion, Text)]+    validations =+      [ ( GitVersion 2 29 0,+          Text.bold "git commit --patch"+            <> " was broken for new files added with "+            <> Text.bold "git add --intent-to-add"+            <> "."+        ),+        ( GitVersion 2 30 1,+          Text.bold "git stash create"+            <> " was broken for new files added with "+            <> Text.bold "git add --intent-to-add"+            <> "."+        )+      ]++dieIfMergeInProgress :: IO ()+dieIfMergeInProgress =+  whenM gitMergeInProgress (die [Text.bold "git merge" <> " in progress."])++dieIfNotInGitDir :: IO ()+dieIfNotInGitDir =+  try (evaluate gitdir) >>= \case+    Left (_ :: ExitCode) -> exitFailure+    Right _ -> pure ()++die :: [Text] -> IO a+die ss = do+  Text.putStr (Text.red (Text.unlines ss))+  exitFailure++mitBranch :: Text -> IO ()+mitBranch branch = do+  dieIfNotInGitDir++  unlessM (doesDirectoryExist (Text.unpack worktreeDir)) do+    worktrees :: [(Text, Text, Maybe Text)] <-+      gitWorktreeList++    case List.find (\(_, _, worktreeBranch) -> worktreeBranch == Just branch) worktrees of+      Nothing -> do+        git_ ["worktree", "add", "--detach", "--quiet", worktreeDir]+        withCurrentDirectory (Text.unpack worktreeDir) do+          git ["rev-parse", "--quiet", "--verify", "refs/heads/" <> branch] >>= \case+            False -> do+              git_ ["branch", "--no-track", branch]+              git_ ["switch", "--quiet", branch]++              _fetchResult :: Bool <-+                git ["fetch", "--quiet", "origin"]++              whenM (git ["rev-parse", "--quiet", "--verify", "refs/remotes/" <> upstream]) do+                git_ ["reset", "--hard", "--quiet", upstream]+                git_ ["branch", "--set-upstream-to", upstream]+            True ->+              git_ ["switch", "--quiet", branch]+      Just (dir, _, _) ->+        unless (worktreeDir == dir) do+          Text.putStrLn ("Branch " <> Text.bold branch <> " is already checked out in " <> Text.bold dir)+          exitFailure+  where+    upstream :: Text+    upstream =+      "origin/" <> branch++    worktreeDir :: Text+    worktreeDir =+      Text.dropWhileEnd (/= '/') gitdir <> branch++mitClone :: Text -> Text -> IO ()+mitClone url name =+  -- FIXME use 'git config --get init.defaultBranch+  git ["clone", url, "--separate-git-dir", name <> "/.git", name <> "/master"]++mitCommit :: IO ()+mitCommit = do+  dieIfNotInGitDir+  dieIfMergeInProgress+  whenM gitExistUntrackedFiles dieIfBuggyGit++  context <- makeContext+  case context.dirty of+    Differences -> mitCommitWith context+    NoDifferences -> mitSyncWith context -- n.b. not covered by tests (would be dupes of sync tests)++mitCommitWith :: Context -> IO ()+mitCommitWith context = do+  maybeUpstreamHead :: Maybe Text <-+    git ["rev-parse", "refs/remotes/" <> context.upstream] <&> \case+      Left _ -> Nothing+      Right upstreamHead -> Just upstreamHead++  remoteCommits :: [GitCommitInfo] <-+    case maybeUpstreamHead of+      Nothing -> pure []+      Just upstreamHead -> gitCommitsBetween (Just context.head) upstreamHead++  localCommits0 :: [GitCommitInfo] <-+    gitCommitsBetween maybeUpstreamHead "HEAD"++  when (not (null remoteCommits) && null localCommits0) do+    readCommitFile context.branch64 >>= \case+      Just (hash, age) | hash == context.head && age < 10_000_000_000 -> pure ()+      _ -> do+        recordCommitFile context.branch64 context.head+        putLines+          [ "",+            "  " <> Text.italic context.branch <> " is not up to date.",+            "",+            "  Run " <> Text.bold (Text.blue "mit sync") <> " first, or run " <> Text.bold (Text.blue "mit commit")+              <> " again to record a commit anyway.",+            ""+          ]+        exitFailure++  stash :: Text <-+    git ["stash", "create"]++  commitResult :: Bool <-+    gitCommit++  -- If the commit was aborted, reset the timer (allowing another 'mit commit' right away). Otherwise, delete the commit+  -- file, because it's no longer needed.+  case commitResult of+    False -> recordCommitFile context.branch64 context.head+    True -> deleteCommitFile context.branch64++  localCommits :: [GitCommitInfo] <-+    case commitResult of+      False -> pure localCommits0+      True -> gitCommitsBetween maybeUpstreamHead "HEAD"++  pushResult :: PushResult <-+    if+        | null localCommits -> pure (PushNotAttempted PushNoCommits)+        | not (null remoteCommits) -> pure (PushNotAttempted PushWouldConflict)+        | context.fetchFailed -> pure (PushNotAttempted PushOffline)+        | otherwise -> PushAttempted <$> gitPush context.branch++  canUndo :: Bool <- do+    let pushDidntHappen = do+          head <- git ["rev-parse", "HEAD"]+          case head == context.head of+            False -> do+              recordUndoFile context.branch64 [Reset context.head, Apply stash]+              pure True+            True -> pure False+    case pushResult of+      PushAttempted True ->+        case commitResult of+          False -> do+            deleteUndoFile context.branch64+            pure False+          True ->+            case localCommits of+              [_] -> do+                -- FIXME this call wouldn't be necessary if we don't pretty-print local commits right away+                head <- git ["rev-parse", "HEAD"]+                recordUndoFile context.branch64 [Revert head, Apply stash]+                pure True+              _ -> do+                deleteUndoFile context.branch64+                pure False+      PushAttempted False -> pushDidntHappen+      PushNotAttempted _ -> pushDidntHappen++  putSummary+    Summary+      { branch = context.branch,+        canUndo,+        conflicts = [],+        syncs =+          [ Sync+              { commits = localCommits,+                result = pushResultToSyncResult pushResult,+                source = context.branch,+                target = context.upstream+              }+          ]+      }++data PushResult+  = PushAttempted Bool+  | PushNotAttempted PushNotAttemptedReason++data PushNotAttemptedReason+  = PushCommitHasConflicts -- local commit has conflict markers+  | PushNewCommits -- we just pulled remote commits; don't push in case there's something local to address+  | PushNoCommits -- no commits to push+  | PushOffline -- fetch failed, so we seem offline+  | PushWouldConflict -- local history has forked, need to sync++pushResultToSyncResult :: PushResult -> SyncResult+pushResultToSyncResult = \case+  PushAttempted True -> Success+  PushAttempted False -> Failure+  PushNotAttempted PushCommitHasConflicts -> Failure+  PushNotAttempted PushNewCommits -> Pending+  PushNotAttempted PushNoCommits -> Success -- doesnt matter, wont be shown+  PushNotAttempted PushOffline -> Offline+  PushNotAttempted PushWouldConflict -> Failure++mitMerge :: Text -> IO ()+mitMerge target0 = do+  dieIfNotInGitDir+  dieIfMergeInProgress+  whenM gitExistUntrackedFiles dieIfBuggyGit++  context :: Context <-+    makeContext++  -- When given 'mit merge foo', prefer merging 'origin/foo' over 'foo'+  target :: Text <- do+    let remote = "origin/" <> target0+    git ["rev-parse", "--quiet", "--verify", remote] <&> \case+      False -> target0+      True -> remote++  maybeTargetCommit :: Maybe Text <-+    git ["rev-parse", target] <&> \case+      Left _ -> Nothing+      Right targetCommit -> Just targetCommit++  targetCommits :: [GitCommitInfo] <-+    case maybeTargetCommit of+      Nothing -> pure []+      Just targetCommit -> gitCommitsBetween (Just context.head) targetCommit++  maybeStash :: Maybe Text <-+    case (targetCommits, context.dirty) of+      (_ : _, Differences) -> Just <$> gitStash+      _ -> pure Nothing++  mergeConflicts :: Maybe [Text] <-+    case targetCommits of+      [] -> pure Nothing+      _ ->+        fmap Just do+          gitMerge context.branch target >>= \case+            Left commitConflicts -> commitConflicts+            Right () -> pure []++  stashConflicts :: [Text] <-+    case maybeStash of+      Nothing -> pure []+      Just stash -> gitApplyStash stash++  canUndo :: Bool <- do+    head <- git ["rev-parse", "HEAD"]+    if head == context.head+      then pure False+      else do+        recordUndoFile context.branch64 (Reset context.head : maybeToList (Apply <$> maybeStash))+        pure True++  putSummary+    Summary+      { branch = context.branch,+        canUndo,+        conflicts = List.nub (stashConflicts ++ fromMaybe [] mergeConflicts),+        syncs =+          [ Sync+              { commits = targetCommits,+                result =+                  case mergeConflicts of+                    Just (_ : _) -> Failure+                    _ -> Success,+                source = target,+                target = context.branch+              }+          ]+      }++-- TODO implement "lateral sync", i.e. a merge from some local or remote branch, followed by a sync to upstream+mitSync :: IO ()+mitSync = do+  dieIfNotInGitDir+  dieIfMergeInProgress+  whenM gitExistUntrackedFiles dieIfBuggyGit+  context <- makeContext+  mitSyncWith context++mitSyncWith :: Context -> IO ()+mitSyncWith context = do+  maybeUpstreamHead :: Maybe Text <-+    git ["rev-parse", "refs/remotes/" <> context.upstream] <&> \case+      Left _ -> Nothing+      Right upstreamHead -> Just upstreamHead++  remoteCommits :: [GitCommitInfo] <-+    case maybeUpstreamHead of+      Nothing -> pure []+      Just upstreamHead -> gitCommitsBetween (Just context.head) upstreamHead++  maybeStash :: Maybe Text <-+    case (remoteCommits, context.dirty) of+      (_ : _, Differences) -> Just <$> gitStash+      _ -> pure Nothing++  mergeConflicts :: Maybe [Text] <-+    case remoteCommits of+      [] -> pure Nothing+      _ ->+        fmap Just do+          gitMerge context.branch context.upstream >>= \case+            Left commitConflicts -> commitConflicts+            Right () -> pure []++  localCommits :: [GitCommitInfo] <-+    gitCommitsBetween maybeUpstreamHead "HEAD"++  stashConflicts :: [Text] <-+    case maybeStash of+      Nothing -> pure []+      Just stash -> gitApplyStash stash++  pushResult :: PushResult <-+    case localCommits of+      [] -> pure (PushNotAttempted PushNoCommits)+      _ ->+        case mergeConflicts of+          Nothing ->+            case context.fetchFailed of+              False -> PushAttempted <$> gitPush context.branch+              True -> pure (PushNotAttempted PushOffline)+          Just [] -> pure (PushNotAttempted PushNewCommits)+          Just (_ : _) -> pure (PushNotAttempted PushWouldConflict)++  canUndo :: Bool <- do+    let pushDidntHappen = do+          head2 <- git ["rev-parse", "HEAD"]+          if context.head == head2+            then pure False -- head didn't move; nothing to undo+            else do+              recordUndoFile context.branch64 (Reset context.head : maybeToList (Apply <$> maybeStash))+              pure True+    case pushResult of+      PushAttempted True -> do+        deleteUndoFile context.branch64+        pure False+      PushAttempted False -> pushDidntHappen+      PushNotAttempted _ -> pushDidntHappen++  putSummary+    Summary+      { branch = context.branch,+        canUndo,+        conflicts = List.nub (stashConflicts ++ fromMaybe [] mergeConflicts),+        syncs =+          [ Sync+              { commits = remoteCommits,+                result =+                  case mergeConflicts of+                    Just (_ : _) -> Failure+                    _ -> Success,+                source = context.upstream,+                target = context.branch+              },+            Sync+              { commits = localCommits,+                result = pushResultToSyncResult pushResult,+                source = context.branch,+                target = context.upstream+              }+          ]+      }++-- FIXME output what we just undid+mitUndo :: IO ()+mitUndo = do+  dieIfNotInGitDir++  branch64 <- Text.encodeBase64 <$> gitCurrentBranch+  readUndoFile branch64 >>= \case+    Nothing -> exitFailure+    Just undos -> do+      deleteUndoFile branch64+      for_ undos \case+        Apply commit -> git_ ["stash", "apply", "--quiet", commit]+        Reset commit -> git_ ["reset", "--hard", "--quiet", commit]+        Revert commit -> git_ ["revert", commit]+      when (undosContainRevert undos) mitSync+  where+    undosContainRevert :: [Undo] -> Bool+    undosContainRevert = \case+      [] -> False+      Revert _ : _ -> True+      _ : undos -> undosContainRevert undos++data Context = Context+  { branch :: Text,+    branch64 :: Text,+    dirty :: DiffResult,+    fetchFailed :: Bool,+    head :: Text,+    upstream :: Text+  }++makeContext :: IO Context+makeContext = do+  branch <- gitCurrentBranch+  dirty <- gitDiff+  head <- git ["rev-parse", "HEAD"]+  fetchFailed <- not <$> git ["fetch", "--quiet", "origin"]+  pure+    Context+      { branch,+        branch64 = Text.encodeBase64 branch,+        dirty,+        fetchFailed,+        head,+        upstream = "origin/" <> branch+      }++data Summary = Summary+  { branch :: Text,+    canUndo :: Bool,+    conflicts :: [Text],+    syncs :: [Sync]+  }++data Sync = Sync+  { commits :: [GitCommitInfo],+    result :: SyncResult,+    source :: Text,+    target :: Text+  }++data SyncResult+  = Offline+  | Failure+  | Pending+  | Success++-- FIXME show some graph of where local/remote is at+putSummary :: Summary -> IO ()+putSummary summary =+  let output = concatMap syncLines summary.syncs ++ conflictsLines ++ undoLines+   in if null output then pure () else putLines ("" : output)+  where+    conflictsLines :: [Text]+    conflictsLines =+      if null summary.conflicts+        then []+        else "  The following files have conflicts." : map (("    " <>) . Text.red) summary.conflicts ++ [""]+    syncLines :: Sync -> [Text]+    syncLines sync =+      if null sync.commits+        then []+        else+          colorize (Text.italic ("  " <> sync.source <> " → " <> sync.target)) :+          map (("  " <>) . prettyGitCommitInfo) sync.commits+            ++ [""]+      where+        colorize :: Text -> Text+        colorize =+          case sync.result of+            Offline -> Text.brightBlack+            Failure -> Text.red+            Pending -> Text.yellow+            Success -> Text.green+    undoLines :: [Text]+    undoLines =+      if summary.canUndo+        then ["  Run " <> Text.bold (Text.blue "mit undo") <> " to undo this change.", ""]+        else []++-- Commit file utils++deleteCommitFile :: Text -> IO ()+deleteCommitFile branch64 =+  removeFile (commitfile branch64) `catch` \(_ :: IOException) -> pure ()++-- | Read the amount of time that has elapsed (in nanoseconds) since the commit file was created+readCommitFile :: Text -> IO (Maybe (Text, Integer))+readCommitFile branch64 = do+  try (Text.readFile (commitfile branch64)) >>= \case+    Left (_ :: IOException) -> pure Nothing+    Right contents ->+      case Text.split (== ',') contents of+        [hash, text2int -> Just t0] -> do+          t1 <- Clock.getTime Clock.Realtime+          pure (Just (hash, Clock.toNanoSecs t1 - t0))+        _ -> do+          deleteCommitFile branch64+          pure Nothing+  where+    -- FIXME make this faster+    text2int :: Text -> Maybe Integer+    text2int =+      readMaybe . Text.unpack++recordCommitFile :: Text -> Text -> IO ()+recordCommitFile branch64 hash = do+  now <- Clock.getTime Clock.Realtime+  -- FIXME use builder+  let contents = hash <> "," <> int2text (Clock.toNanoSecs now)+  Text.writeFile (commitfile branch64) contents `catch` \(_ :: IOException) -> pure ()+  where+    -- FIXME make this faster+    int2text :: Integer -> Text+    int2text =+      Text.pack . show++commitfile :: Text -> FilePath+commitfile branch64 =+  Text.unpack (gitdir <> "/.mit-" <> branch64 <> "-commit")++-- Undo file utils++data Undo+  = Apply Text -- apply stash+  | Reset Text -- reset to commit+  | Revert Text -- revert commit++showUndos :: [Undo] -> Text+showUndos =+  Text.intercalate "," . map showUndo+  where+    showUndo :: Undo -> Text+    showUndo = \case+      Apply commit -> "apply " <> commit+      Reset commit -> "reset " <> commit+      Revert commit -> "revert " <> commit++parseUndos :: Text -> Maybe [Undo]+parseUndos =+  Text.split (== ',') >>> traverse parseUndo+  where+    parseUndo :: Text -> Maybe Undo+    parseUndo =+      Text.words >>> \case+        ["apply", commit] -> Just (Apply commit)+        ["reset", commit] -> Just (Reset commit)+        ["revert", commit] -> Just (Revert commit)+        _ -> Nothing++deleteUndoFile :: Text -> IO ()+deleteUndoFile branch64 =+  removeFile (undofile branch64) `catch` \(_ :: IOException) -> pure ()++readUndoFile :: Text -> IO (Maybe [Undo])+readUndoFile branch64 =+  try (Text.readFile file) >>= \case+    Left (_ :: IOException) -> pure Nothing+    Right contents ->+      case parseUndos contents of+        Nothing -> do+          when debug (putStrLn ("Corrupt undo file: " ++ file))+          pure Nothing+        Just undos -> pure (Just undos)+  where+    file :: FilePath+    file =+      undofile branch64++-- Record a file for 'mit undo' to use, if invoked.+recordUndoFile :: Text -> [Undo] -> IO ()+recordUndoFile branch64 undos = do+  Text.writeFile (undofile branch64) (showUndos undos) `catch` \(_ :: IOException) -> pure ()++undofile :: Text -> FilePath+undofile branch64 =+  Text.unpack (gitdir <> "/.mit-" <> branch64)++-- Globals++debug :: Bool+debug =+  isJust (unsafePerformIO (lookupEnv "debug"))+{-# NOINLINE debug #-}++gitdir :: Text+gitdir =+  unsafePerformIO (git ["rev-parse", "--absolute-git-dir"])+{-# NOINLINE gitdir #-}++-- Git helpers++data DiffResult+  = Differences+  | NoDifferences++data GitCommitInfo = GitCommitInfo+  { author :: Text,+    date :: Text,+    hash :: Text,+    shorthash :: Text,+    subject :: Text+  }+  deriving stock (Show)++prettyGitCommitInfo :: GitCommitInfo -> Text+prettyGitCommitInfo info =+  -- FIXME use builder+  fold+    [ Text.bold (Text.black info.shorthash),+      " ",+      Text.bold (Text.white info.subject),+      " - ",+      Text.italic (Text.white info.author),+      " ",+      Text.italic (Text.yellow info.date) -- FIXME some other color, magenta?+    ]++data GitVersion+  = GitVersion Int Int Int+  deriving stock (Eq, Ord)++showGitVersion :: GitVersion -> Text+showGitVersion (GitVersion x y z) =+  Text.pack (show x) <> "." <> Text.pack (show y) <> "." <> Text.pack (show z)++-- | Apply stash, return conflicts.+gitApplyStash :: Text -> IO [Text]+gitApplyStash stash = do+  git ["stash", "apply", "--quiet", stash] >>= \case+    False -> do+      conflicts <- git ["diff", "--name-only", "--diff-filter=U"]+      git_ ["reset", "--quiet"] -- unmerged (weird) -> unstaged (normal)+      pure conflicts+    True -> pure []++gitCommit :: IO Bool+gitCommit =+  queryTerminal 0 >>= \case+    False -> do+      message <- lookupEnv "MIT_COMMIT_MESSAGE"+      git ["commit", "--all", "--message", maybe "" Text.pack message, "--quiet"]+    True ->+      git2 ["commit", "--patch", "--quiet"] <&> \case+        ExitFailure _ -> False+        ExitSuccess -> True++gitCommitsBetween :: Maybe Text -> Text -> IO [GitCommitInfo]+gitCommitsBetween commit1 commit2 =+  if commit1 == Just commit2+    then pure []+    else do+      commits <-+        -- --first-parent seems desirable for topic branches+        git+          [ "rev-list",+            "--color=always",+            "--date=human",+            "--format=format:%an\xFEFF%ad\xFEFF%H\xFEFF%h\xFEFF%s",+            "--max-count=10",+            maybe id (\c1 c2 -> c1 <> ".." <> c2) commit1 commit2+          ]+      pure (map parseCommitInfo (dropEvens commits))+  where+    -- git rev-list with a custom format prefixes every commit with a redundant line :|+    dropEvens :: [a] -> [a]+    dropEvens = \case+      _ : x : xs -> x : dropEvens xs+      xs -> xs+    parseCommitInfo :: Text -> GitCommitInfo+    parseCommitInfo line =+      case Text.split (== '\xFEFF') line of+        [author, date, hash, shorthash, subject] -> GitCommitInfo {author, date, hash, shorthash, subject}+        _ -> error (Text.unpack line)++-- | Get the current branch.+gitCurrentBranch :: IO Text+gitCurrentBranch =+  git ["branch", "--show-current"]++-- | Do any untracked files exist?+gitExistUntrackedFiles :: IO Bool+gitExistUntrackedFiles = do+  files :: [Text] <- git ["ls-files", "--exclude-standard", "--other"]+  pure (not (null files))++gitDiff :: IO DiffResult+gitDiff = do+  git_ ["reset", "--quiet"]+  git_ ["add", "--all", "--intent-to-add"]+  git ["diff", "--quiet"] <&> \case+    False -> Differences+    True -> NoDifferences++-- FIXME document what this does+gitMerge :: Text -> Text -> IO (Either (IO [Text]) ())+gitMerge me target = do+  git ["merge", "--ff", "--no-commit", "--quiet", target] >>= \case+    False ->+      (pure . Left) do+        conflicts <- git ["diff", "--name-only", "--diff-filter=U"]+        git_ ["add", "--all"]+        git_ ["commit", "--no-edit", "--quiet", "--message", mergeMessage conflicts]+        pure conflicts+    True -> do+      whenM gitMergeInProgress (git_ ["commit", "--message", mergeMessage []])+      pure (Right ())+  where+    mergeMessage :: [Text] -> Text+    mergeMessage conflicts =+      -- FIXME use builder+      fold+        [ "⅄",+          if null conflicts then "" else "\x0338",+          " ",+          if target' == me then me else target' <> " → " <> me,+          if null conflicts+            then ""+            else " (conflicts)\n\nConflicting files:\n" <> Text.intercalate "\n" (map ("  " <>) conflicts)+        ]+      where+        target' :: Text+        target' =+          fromMaybe target (Text.stripPrefix "origin/" target)++gitMergeInProgress :: IO Bool+gitMergeInProgress =+  doesFileExist (Text.unpack (gitdir <> "/MERGE_HEAD"))++gitPush :: Text -> IO Bool+gitPush branch =+  git ["push", "--quiet", "--set-upstream", "origin", branch <> ":" <> branch]++-- | Create a stash and blow away local changes (like 'git stash push')+gitStash :: IO Text+gitStash = do+  stash <- git ["stash", "create"]+  git_ ["reset", "--hard", "--quiet", "HEAD"]+  pure stash++gitVersion :: IO GitVersion+gitVersion = do+  v0 <- git ["--version"]+  fromMaybe (throwIO (userError ("Could not parse git version from: " <> Text.unpack v0))) do+    ["git", "version", v1] <- Just (Text.words v0)+    [sx, sy, sz] <- Just (Text.split (== '.') v1)+    x <- readMaybe (Text.unpack sx)+    y <- readMaybe (Text.unpack sy)+    z <- readMaybe (Text.unpack sz)+    pure (pure (GitVersion x y z))++-- /dir/one 0efd393c35 [oingo]         -> ("/dir/one", "0efd393c35", Just "oingo")+-- /dir/two dc0c114266 (detached HEAD) -> ("/dir/two", "dc0c114266", Nothing)+gitWorktreeList :: IO [(Text, Text, Maybe Text)]+gitWorktreeList = do+  map f <$> git ["worktree", "list"]+  where+    f :: Text -> (Text, Text, Maybe Text)+    f line =+      case Text.words line of+        [dir, commit, stripBrackets -> Just branch] -> (dir, commit, Just branch)+        [dir, commit, "(detached", "HEAD)"] -> (dir, commit, Nothing)+        _ -> error (Text.unpack line)+      where+        stripBrackets :: Text -> Maybe Text+        stripBrackets =+          Text.stripPrefix "[" >=> Text.stripSuffix "]"++-- git@github.com:mitchellwrosen/mit.git -> Just ("git@github.com:mitchellwrosen/mit.git", "mit")+parseGitRepo :: Text -> Maybe (Text, Text)+parseGitRepo url = do+  url' <- Text.stripSuffix ".git" url+  pure (url, Text.takeWhileEnd (/= '/') url')++-- Some ad-hoc process return value overloading, for cleaner syntax++class ProcessOutput a where+  fromProcessOutput :: [Text] -> [Text] -> ExitCode -> IO a++instance ProcessOutput () where+  fromProcessOutput _ _ code =+    when (code /= ExitSuccess) (exitWith code)++instance ProcessOutput Bool where+  fromProcessOutput _ _ = \case+    ExitFailure _ -> pure False+    ExitSuccess -> pure True++instance ProcessOutput Text where+  fromProcessOutput out _ code = do+    when (code /= ExitSuccess) (exitWith code)+    case out of+      [] -> throwIO (userError "no stdout")+      line : _ -> pure line++instance a ~ Text => ProcessOutput [a] where+  fromProcessOutput out _ code = do+    when (code /= ExitSuccess) (exitWith code)+    pure out++instance a ~ ExitCode => ProcessOutput (Either a Text) where+  fromProcessOutput out _ code =+    case code of+      ExitFailure _ -> pure (Left code)+      ExitSuccess ->+        case out of+          [] -> throwIO (userError "no stdout")+          line : _ -> pure (Right line)++--++git :: ProcessOutput a => [Text] -> IO a+git args = do+  (Nothing, Just stdoutHandle, Just stderrHandle, processHandle) <-+    createProcess+      CreateProcess+        { child_group = Nothing,+          child_user = Nothing,+          close_fds = True,+          cmdspec = RawCommand "git" (map Text.unpack args),+          create_group = False,+          cwd = Nothing,+          delegate_ctlc = False,+          env = Nothing,+          new_session = False,+          std_err = CreatePipe,+          std_in = NoStream,+          std_out = CreatePipe,+          -- windows-only+          create_new_console = False,+          detach_console = False,+          use_process_jobs = False+        }+  exitCode <- waitForProcess processHandle+  stdoutLines <- drainTextHandle stdoutHandle+  stderrLines <- drainTextHandle stderrHandle+  when debug do+    Text.putStrLn . Text.brightBlack $+      Text.unwords ("git" : map quoteText args)+        <> " : "+        <> Text.pack (show (stdoutLines, stderrLines, exitCode))+  fromProcessOutput stdoutLines stderrLines exitCode++git_ :: [Text] -> IO ()+git_ =+  git++-- Yucky interactive/inherity variant (so 'git commit' can open an editor).+git2 :: [Text] -> IO ExitCode+git2 args = do+  when debug do+    let quote :: Text -> Text+        quote s =+          if Text.any isSpace s then "'" <> Text.replace "'" "\\'" s <> "'" else s+    Text.putStrLn (Text.brightBlack (Text.unwords ("git" : map quote args)))+  (Nothing, Nothing, Just stderrHandle, processHandle) <-+    createProcess+      CreateProcess+        { child_group = Nothing,+          child_user = Nothing,+          close_fds = True,+          cmdspec = RawCommand "git" (map Text.unpack args),+          create_group = False,+          cwd = Nothing,+          delegate_ctlc = True,+          env = Nothing,+          new_session = False,+          std_err = CreatePipe,+          std_in = Inherit,+          std_out = Inherit,+          -- windows-only+          create_new_console = False,+          detach_console = False,+          use_process_jobs = False+        }+  exitCode <-+    waitForProcess processHandle `catch` \case+      UserInterrupt -> pure (ExitFailure (-130))+      exception -> throwIO exception+  stderrLines <- drainTextHandle stderrHandle+  when debug do+    Text.putStrLn . Text.brightBlack $+      Text.unwords ("git" : map quoteText args) <> " : " <> Text.pack (show (stderrLines, exitCode))+  pure exitCode++-- Mini prelude extensions++(<&>) :: Functor f => f a -> (a -> b) -> f b+(<&>) =+  flip fmap++drainTextHandle :: Handle -> IO [Text]+drainTextHandle handle = do+  let loop acc =+        hIsEOF handle >>= \case+          False -> do+            line <- Text.hGetLine handle+            loop (line : acc)+          True -> pure (reverse acc)+  loop []++putLines :: [Text] -> IO ()+putLines =+  Text.putStr . Text.unlines++quoteText :: Text -> Text+quoteText s =+  if Text.any isSpace s then "'" <> Text.replace "'" "\\'" s <> "'" else s++unlessM :: Monad m => m Bool -> m () -> m ()+unlessM mx action =+  mx >>= \case+    False -> action+    True -> pure ()++whenM :: Monad m => m Bool -> m () -> m ()+whenM mx action =+  mx >>= \case+    False -> pure ()+    True -> action
+ mit-3qvpPyAi6mH.cabal view
@@ -0,0 +1,48 @@+cabal-version: 3.0++author: Mitchell Rosen+bug-reports: https://github.com/mitchellwrosen/mit/issues+category: CLI+copyright: Copyright (C) 2020 Mitchell Rosen+description: A git wrapper with a streamlined UX.+homepage: https://github.com/mitchellwrosen/mit+license: MIT+license-file: LICENSE+maintainer: Mitchell Rosen <mitchellwrosen@gmail.com>+name: mit-3qvpPyAi6mH+stability: experimental+synopsis: A git wrapper with a streamlined UX+version: 1++executable mit+  build-depends:+    base < 5,+    base64,+    clock,+    directory,+    process,+    record-dot-preprocessor,+    record-hasfield,+    text,+    text-ansi,+    unix,+  default-extensions:+    BlockArguments+    DataKinds+    DerivingStrategies+    DuplicateRecordFields+    FlexibleInstances+    GADTs+    LambdaCase+    MultiParamTypeClasses+    MultiWayIf+    NamedFieldPuns+    NumericUnderscores+    OverloadedStrings+    ScopedTypeVariables+    TypeApplications+    UndecidableInstances+    ViewPatterns+  default-language: Haskell2010+  ghc-options: -Wall -with-rtsopts=-N1+  main-is: Main.hs