mit-3qvpPyAi6mH 6 → 7
raw patch · 9 files changed
+556/−289 lines, 9 files
Files
- mit-3qvpPyAi6mH.cabal +7/−2
- src/Mit.hs +411/−214
- src/Mit/Builder.hs +58/−0
- src/Mit/Config.hs +17/−0
- src/Mit/Git.hs +45/−38
- src/Mit/Globals.hs +0/−10
- src/Mit/Process.hs +0/−15
- src/Mit/Seq.hs +5/−0
- src/Mit/Undo.hs +13/−10
mit-3qvpPyAi6mH.cabal view
@@ -11,7 +11,7 @@ name: mit-3qvpPyAi6mH stability: experimental synopsis: A git wrapper with a streamlined UX-version: 6+version: 7 description: A git wrapper with a streamlined UX.@@ -21,6 +21,10 @@ This package's library component does not follow the Package Versioning Policy, and is only exposed for the test suite. +source-repository head+ type: git+ location: https://github.com/mitchellwrosen/mit.git+ common component build-depends: base == 4.14.3.0,@@ -64,10 +68,11 @@ import: component exposed-modules: Mit+ Mit.Builder Mit.Clock+ Mit.Config Mit.Directory Mit.Git- Mit.Globals Mit.Prelude Mit.Process Mit.Seq
src/Mit.hs view
@@ -1,5 +1,6 @@ module Mit where +import qualified Data.List as List import Data.List.NonEmpty (NonEmpty ((:|))) import qualified Data.List.NonEmpty as List1 import qualified Data.Sequence as Seq@@ -8,9 +9,9 @@ import qualified Data.Text.Builder.ANSI as Text.Builder import qualified Data.Text.Encoding.Base64 as Text import qualified Data.Text.IO as Text-import qualified Data.Text.Lazy as Text.Lazy import qualified Data.Text.Lazy.Builder as Text (Builder) import qualified Data.Text.Lazy.Builder as Text.Builder+import qualified Mit.Builder as Builder import Mit.Clock (getCurrentTime) import Mit.Directory import Mit.Git@@ -37,6 +38,8 @@ -- TODO recommend merging master if it conflicts -- TODO mit log -- TODO optparse-applicative+-- TODO undo revert+-- TODO more specific "undo this change" wording main :: IO () main = do@@ -108,7 +111,7 @@ gitBranchWorktreeDir branch >>= \case Nothing -> do whenM (doesDirectoryExist worktreeDir) (die ["Directory " <> Text.bold worktreeDir <> " already exists."])- gitl_ ["worktree", "add", "--detach", worktreeDir]+ git_ ["worktree", "add", "--detach", worktreeDir] withCurrentDirectory worktreeDir do whenNotM (gitSwitch branch) do gitBranch branch@@ -116,8 +119,8 @@ gitFetch_ "origin" whenM (gitRemoteBranchExists "origin" branch) do let upstream = "origin/" <> branch- gitl_ ["reset", "--hard", upstream]- gitl_ ["branch", "--set-upstream-to", upstream]+ git_ ["reset", "--hard", upstream]+ git_ ["branch", "--set-upstream-to", upstream] Just directory -> when (directory /= worktreeDir) do die [Text.bold branch <> " is already checked out in " <> Text.bold directory <> "."]@@ -139,55 +142,95 @@ mitCommit_ :: IO () mitCommit_ = do- fetched <- gitFetch "origin" branch <- gitCurrentBranch let branch64 = Text.encodeBase64 branch- head <- gitHead- maybeUpstreamHead <- gitRemoteBranchHead "origin" branch- existRemoteCommits <- maybe (pure False) (gitExistCommitsBetween head) maybeUpstreamHead- existLocalCommits <- maybe (pure True) (\upstreamHead -> gitExistCommitsBetween upstreamHead "HEAD") maybeUpstreamHead+ let upstream = "origin/" <> branch+ head0 <- gitHead state0 <- readMitState branch64- stash <- gitCreateStash+ let undos0 = [Reset head0, Apply stash] - let wouldFork = existRemoteCommits && not existLocalCommits+ (fetched, maybeUpstreamHead, existRemoteCommits, wouldFork) <- do+ ago <- mitStateRanCommitAgo state0+ if ago < 10_000_000_000+ then do+ maybeUpstreamHead <- gitRemoteBranchHead "origin" branch+ pure (True, maybeUpstreamHead, True, True)+ else do+ fetched <- gitFetch "origin"+ maybeUpstreamHead <- gitRemoteBranchHead "origin" branch - let shouldWarnAboutFork =- if wouldFork- then do- ago <- mitStateRanCommitAgo state0- if ago < 10_000_000_000- then pure False- else do- git_ ["reset", "--hard", fromJust maybeUpstreamHead]- canMergeCleanly <- git ["stash", "apply", stash]- git_ ["reset", "--hard", head]- git_ ["stash", "apply", stash]- gitUnstageChanges- pure canMergeCleanly- else pure False+ existRemoteCommits <- maybe (pure False) (gitExistCommitsBetween head0) maybeUpstreamHead+ existLocalCommits <-+ maybe+ (pure True)+ (\upstreamHead -> gitExistCommitsBetween upstreamHead head0)+ maybeUpstreamHead - -- Bail out early if we should warn that this commit would fork history- whenM shouldWarnAboutFork do- ranCommitAt <- Just <$> getCurrentTime- writeMitState branch64 state0 {ranCommitAt}- putLines- [ "",- " " <> Text.yellow (Text.italic 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+ let wouldFork = existRemoteCommits && not existLocalCommits + when wouldFork do+ applyUndo (Reset (fromJust maybeUpstreamHead))+ conflicts <- gitApplyStash stash+ for_ undos0 applyUndo++ ranCommitAt <- Just <$> getCurrentTime+ writeMitState branch64 state0 {ranCommitAt}++ putStanzas $+ case List1.nonEmpty conflicts of+ Nothing ->+ [ notSynchronizedStanza+ (Text.Builder.fromText branch)+ (Text.Builder.fromText upstream)+ ", but committing these changes would not put it in conflict.",+ Just+ ( " To avoid making a merge commit, run "+ <> Text.Builder.bold (Text.Builder.blue "mit sync")+ <> " first."+ ),+ Just+ ( " Otherwise, run "+ <> Text.Builder.bold (Text.Builder.blue "mit commit")+ <> " again (within 10 seconds) to record a commit anyway."+ )+ ]+ Just conflicts1 ->+ [ notSynchronizedStanza+ (Text.Builder.fromText branch)+ (Text.Builder.fromText upstream)+ ", and committing these changes would put it in conflict.",+ conflictsStanza "These files would be in conflict:" conflicts1,+ Just+ ( Builder.vcat+ [ " To avoid making a merge commit, run "+ <> Text.Builder.bold (Text.Builder.blue "mit sync")+ <> " first to resolve conflicts.",+ " (If conflicts seem too difficult to resolve, you will be able to "+ <> Text.Builder.bold (Text.Builder.blue "mit undo")+ <> " to back out)."+ ]+ ),+ Just+ ( " Otherwise, run "+ <> Text.Builder.bold (Text.Builder.blue "mit commit")+ <> " again (within 10 seconds) to record a commit anyway."+ )+ ]+ exitFailure++ pure (fetched, maybeUpstreamHead, existRemoteCommits, wouldFork)+ committed <- gitCommit- localCommits <- gitCommitsBetween maybeUpstreamHead "HEAD"+ head1 <- if committed then gitHead else pure head0+ localCommits <- gitCommitsBetween maybeUpstreamHead head1 pushResult <- case (localCommits, existRemoteCommits, fetched) of (Seq.Empty, _, _) -> pure (PushNotAttempted NothingToPush)- (Seq.NonEmpty, True, _) -> pure (PushNotAttempted ForkedHistory)+ (Seq.NonEmpty, True, _) -> do+ conflicts <- gitConflictsWith (fromJust maybeUpstreamHead)+ pure (PushNotAttempted (ForkedHistory conflicts)) (Seq.NonEmpty, False, False) -> pure (PushNotAttempted Offline) (Seq.NonEmpty, False, True) -> PushAttempted <$> gitPush branch @@ -205,38 +248,51 @@ undos <- case (pushed, committed, localCommits) of (False, False, _) -> pure state0.undos- (False, True, _) -> pure [Reset head, Apply stash]- (True, True, Seq.Singleton) -> do- head1 <- gitHead- pure [Revert head1, Apply stash]+ (False, True, _) -> pure undos0+ (True, True, Seq.Singleton) -> pure [Revert head1, Apply stash] _ -> pure [] writeMitState branch64 MitState {head = (), merging = Nothing, ranCommitAt, undos} - putSummary- Summary- { branch,- -- Whether we "can undo" from here is not exactly if the state says we can undo, because of one corner case:- -- we ran 'mit commit', then aborted the commit, and ultimately didn't push any other local changes.- --- -- In this case, the underlying state hasn't changed, so 'mit undo' will still work as if the 'mit commit'- -- was never run, we merely don't want to *say* "run 'mit undo' to undo" as feedback, because that sounds as- -- if it would undo the last command run, namely the 'mit commit' that was aborted.- canUndo = not (null undos) && committed,- conflicts = [],- syncs =- case Seq1.fromSeq localCommits of- Nothing -> []- Just commits ->- [ Sync- { commits,- result = pushResultToSyncResult pushResult,- source = branch,- target = "origin/" <> branch- }- ]- }+ remoteCommits <-+ if existRemoteCommits+ then gitCommitsBetween (Just head1) (fromJust maybeUpstreamHead)+ else pure Seq.empty + putStanzas+ [ isSynchronizedStanza (Text.Builder.fromText branch) pushResult,+ Seq1.fromSeq remoteCommits <&> \commits ->+ syncStanza+ Sync+ { commits,+ result = SyncResult'Failure,+ source = upstream,+ target = branch+ },+ Seq1.fromSeq localCommits <&> \commits ->+ syncStanza+ Sync+ { commits,+ result = pushResultToSyncResult pushResult,+ source = branch,+ target = upstream+ },+ case pushResult of+ PushNotAttempted (ForkedHistory (List1.nonEmpty -> Just conflicts)) ->+ conflictsStanza+ ("These files will be in conflict when you run " <> Text.Builder.bold (Text.Builder.blue "mit sync") <> ":")+ conflicts+ _ -> Nothing,+ whatNextStanza (Text.Builder.fromText branch) pushResult,+ -- Whether we say we can undo from here is not exactly if the state says we can undo, because of one corner case:+ -- we ran 'mit commit', then aborted the commit, and ultimately didn't push any other local changes.+ --+ -- In this case, the underlying state hasn't changed, so 'mit undo' will still work as if the 'mit commit' was+ -- never run, we merely don't want to *say* "run 'mit undo' to undo" as feedback, because that sounds as if it+ -- would undo the last command run, namely the 'mit commit' that was aborted.+ if not (null undos) && committed then Just canUndoStanza else Nothing+ ]+ mitCommitMerge :: IO () mitCommitMerge = do branch <- gitCurrentBranch@@ -244,33 +300,52 @@ head <- gitHead state0 <- readMitState branch64 + -- Make the merge commit. Commonly we'll have gotten here by `mit merge <branch>`, so we'll have a `state0.merging`+ -- that tells us we're merging in <branch>. But we also handle the case that we went `git merge` -> `mit commit`,+ -- because why not. case state0.merging of- Nothing -> gitl_ ["commit", "--all", "--no-edit"]+ Nothing -> git_ ["commit", "--all", "--no-edit"] Just merging -> let message = fold ["⅄ ", if merging == branch then "" else merging <> " → ", branch]- in gitl_ ["commit", "--all", "--message", message]- case listToMaybe [commit | Apply commit <- state0.undos] of- Nothing -> mitSyncWith (Just [Reset head])- Just stash ->- gitApplyStash stash >>= \case+ in git_ ["commit", "--all", "--message", message]++ writeMitState branch64 state0 {merging = Nothing, ranCommitAt = Nothing}++ let stanza0 = do+ merging <- state0.merging+ guard (merging /= branch)+ synchronizedStanza (Text.Builder.fromText branch) (Text.Builder.fromText merging)++ -- Three possible cases:+ -- 1. We had a dirty working directory before `mit merge` (evidence: our undo has a `git stash apply` in it)+ -- a. We can cleanly unstash it, so proceed to sync+ -- b. We cannot cleanly unstash it, so don't sync, because that may *further* conflict, and we don't want nasty+ -- double conflict markers+ -- 2. We had a clean working directory before `mit merge`, so proceed to sync++ case undosStash state0.undos of+ Nothing -> mitSyncWith stanza0 (Just [Reset head])+ Just stash -> do+ conflicts <- gitApplyStash stash+ case List1.nonEmpty conflicts of -- FIXME we just unstashed, now we're about to stash again :/- [] -> mitSyncWith (Just [Reset head, Apply stash])- conflicts -> do- writeMitState branch64 state0 {merging = Nothing, ranCommitAt = Nothing}- putSummary- Summary- { branch,- canUndo = not (null state0.undos),- conflicts,- syncs = []- }+ Nothing -> mitSyncWith stanza0 (Just [Reset head, Apply stash])+ Just conflicts1 ->+ putStanzas+ [ stanza0,+ conflictsStanza "These files are in conflict:" conflicts1,+ -- Fake like we didn't push due to merge conflicts just to print "resolve conflicts and commit"+ whatNextStanza (Text.Builder.fromText branch) (PushNotAttempted MergeConflicts),+ if null state0.undos then Nothing else Just canUndoStanza+ ] data PushResult = PushAttempted Bool | PushNotAttempted PushNotAttemptedReason data PushNotAttemptedReason- = ForkedHistory -- local history has forked, need to sync+ = ForkedHistory [GitConflict] -- local history has forked, need to sync.+ | MergeConflicts -- local merge conflicts that need to be resolved right now | NothingToPush -- no commits to push | Offline -- fetch failed, so we seem offline | UnseenCommits -- we just pulled remote commits; don't push in case there's something local to address@@ -279,7 +354,8 @@ pushResultToSyncResult = \case PushAttempted False -> SyncResult'Failure PushAttempted True -> SyncResult'Success- PushNotAttempted ForkedHistory -> SyncResult'Failure+ PushNotAttempted (ForkedHistory _) -> SyncResult'Failure+ PushNotAttempted MergeConflicts -> SyncResult'Failure PushNotAttempted NothingToPush -> SyncResult'Success -- doesnt matter, wont be shown PushNotAttempted Offline -> SyncResult'Offline PushNotAttempted UnseenCommits -> SyncResult'Pending@@ -301,15 +377,24 @@ maybeMergeStatus <- mitMerge' ("⅄ " <> target <> " → " <> branch) targetCommit + let maybeMergeResult = (.result) <$> maybeMergeStatus++ let conflicts =+ case maybeMergeResult of+ Nothing -> []+ Just (MergeResult'MergeConflicts conflicts1) -> List1.toList conflicts1+ Just (MergeResult'StashConflicts conflicts1) -> List1.toList conflicts1+ Just MergeResult'Success -> []+ writeMitState branch64 MitState { head = (), merging = do- mergeStatus <- maybeMergeStatus- case mergeStatus.result of- MergeResult'MergeConflicts _ -> Just target- MergeResult'StashConflicts _ -> Nothing,+ result <- maybeMergeResult+ if mergeResultCommitted result+ then Nothing+ else Just target, ranCommitAt = Nothing, undos = case maybeMergeStatus of@@ -317,32 +402,44 @@ Just mergeStatus -> List1.toList mergeStatus.undos } - putSummary- Summary- { branch,- canUndo = isJust maybeMergeStatus,- conflicts =- case maybeMergeStatus of- Nothing -> []- Just mergeStatus ->- case mergeStatus.result of- MergeResult'MergeConflicts conflicts -> List1.toList conflicts- MergeResult'StashConflicts conflicts -> conflicts,- syncs = do- mergeStatus <- maybeToList maybeMergeStatus- pure- Sync- { commits = mergeStatus.commits,- result =- case mergeStatus.result of- MergeResult'MergeConflicts _ -> SyncResult'Failure- -- Even if we have conflicts from unstashing, we call this merge a success.- MergeResult'StashConflicts _ -> SyncResult'Success,- source = target,- target = branch- }- }+ let branchb = Text.Builder.fromText branch+ let targetb = Text.Builder.fromText target + putStanzas+ [ case maybeMergeResult of+ Nothing -> synchronizedStanza branchb targetb+ Just result ->+ if mergeResultCommitted result+ then synchronizedStanza branchb targetb+ else notSynchronizedStanza branchb targetb ".",+ maybeMergeStatus <&> \mergeStatus ->+ syncStanza+ Sync+ { commits = mergeStatus.commits,+ result = if mergeResultCommitted mergeStatus.result then SyncResult'Success else SyncResult'Failure,+ source = target,+ target = branch+ },+ do+ result <- maybeMergeResult+ if mergeResultCommitted result+ then isSynchronizedStanza branchb (PushNotAttempted UnseenCommits)+ else Nothing,+ do+ conflicts1 <- List1.nonEmpty conflicts+ conflictsStanza "These files are in conflict:" conflicts1,+ do+ result <- maybeMergeResult+ whatNextStanza+ branchb+ ( case result of+ MergeResult'MergeConflicts _ -> PushNotAttempted MergeConflicts+ MergeResult'StashConflicts _ -> PushNotAttempted UnseenCommits+ MergeResult'Success -> PushNotAttempted UnseenCommits+ ),+ if isJust maybeMergeStatus then Just canUndoStanza else Nothing+ ]+ data MergeStatus = MergeStatus { commits :: Seq1 GitCommitInfo, result :: MergeResult,@@ -351,8 +448,16 @@ data MergeResult = MergeResult'MergeConflicts (List1 GitConflict)- | MergeResult'StashConflicts [GitConflict]+ | MergeResult'StashConflicts (List1 GitConflict)+ | MergeResult'Success +-- | Did this merge commit (possibly leaving behind a conflicting unstash)?+mergeResultCommitted :: MergeResult -> Bool+mergeResultCommitted = \case+ MergeResult'MergeConflicts _ -> False+ MergeResult'StashConflicts _ -> True+ MergeResult'Success -> True+ mitMerge' :: Text -> Text -> IO (Maybe MergeStatus) mitMerge' message target = do head <- gitHead@@ -362,7 +467,7 @@ maybeStash <- gitStash let undos = Reset head :| maybeToList (Apply <$> maybeStash) result <-- gitl ["merge", "--ff", "--no-commit", target] >>= \case+ git ["merge", "--ff", "--no-commit", target] >>= \case False -> do conflicts <- gitConflicts -- error: The following untracked working tree files would be overwritten by merge:@@ -386,9 +491,14 @@ -- Aborting pure (MergeResult'MergeConflicts (List1.fromList conflicts)) True -> do- whenM gitMergeInProgress (gitl_ ["commit", "--message", message])+ whenM gitMergeInProgress (git_ ["commit", "--message", message]) maybeConflicts <- for maybeStash gitApplyStash- pure (MergeResult'StashConflicts (fromMaybe [] maybeConflicts))+ pure+ ( fromMaybe MergeResult'Success do+ conflicts <- maybeConflicts+ conflicts1 <- List1.nonEmpty conflicts+ pure (MergeResult'StashConflicts conflicts1)+ ) pure (Just MergeStatus {commits, result, undos}) -- TODO implement "lateral sync", i.e. a merge from some local or remote branch, followed by a sync to upstream@@ -397,9 +507,9 @@ dieIfNotInGitDir dieIfMergeInProgress whenM gitExistUntrackedFiles dieIfBuggyGit- mitSyncWith Nothing+ mitSyncWith Nothing Nothing --- | @mitSyncWith maybeUndos@+-- | @mitSyncWith _ maybeUndos@ -- -- Whenever recording what 'mit undo' should do after 'mit sync', if 'maybeUndos' is provided, we use them instead. -- This is pulled into a function argument to get better undo behavior after committing a merge.@@ -419,11 +529,11 @@ -- -- Instead, we want to undo to the point before running the 'mit merge' that caused the conflicts, which were later -- resolved by 'mit commit'.-mitSyncWith :: Maybe [Undo] -> IO ()-mitSyncWith maybeUndos = do+mitSyncWith :: Maybe Text.Builder -> Maybe [Undo] -> IO ()+mitSyncWith stanza0 maybeUndos = do fetched <- gitFetch "origin" branch <- gitCurrentBranch- let branch64 = Text.encodeBase64 branch+ let upstream = "origin/" <> branch maybeUpstreamHead <- gitRemoteBranchHead "origin" branch maybeMergeStatus <-@@ -431,16 +541,23 @@ Nothing -> pure Nothing Just upstreamHead -> mitMerge' ("⅄ " <> branch) upstreamHead + let maybeMergeResult = (.result) <$> maybeMergeStatus++ let conflicts =+ case maybeMergeResult of+ Nothing -> []+ Just (MergeResult'MergeConflicts conflicts1) -> List1.toList conflicts1+ Just (MergeResult'StashConflicts conflicts1) -> List1.toList conflicts1+ Just MergeResult'Success -> []+ localCommits <- gitCommitsBetween maybeUpstreamHead "HEAD" pushResult <-- case (localCommits, (.result) <$> maybeMergeStatus, fetched) of+ case (localCommits, maybeMergeResult, fetched) of (Seq.Empty, _, _) -> pure (PushNotAttempted NothingToPush)- -- "forked history" is ok - a bit different than history *already* having forked, in which case a push- -- would just fail, whereas this is just us choosing not to push while in the middle of a merge due to a- -- previous fork in the history- (Seq.NonEmpty, Just (MergeResult'MergeConflicts _), _) -> pure (PushNotAttempted ForkedHistory)+ (Seq.NonEmpty, Just (MergeResult'MergeConflicts _), _) -> pure (PushNotAttempted MergeConflicts) (Seq.NonEmpty, Just (MergeResult'StashConflicts _), _) -> pure (PushNotAttempted UnseenCommits)+ (Seq.NonEmpty, Just MergeResult'Success, _) -> pure (PushNotAttempted UnseenCommits) (Seq.NonEmpty, Nothing, False) -> pure (PushNotAttempted Offline) (Seq.NonEmpty, Nothing, True) -> PushAttempted <$> gitPush branch @@ -455,54 +572,43 @@ True -> [] writeMitState- branch64+ (Text.encodeBase64 branch) MitState { head = (), merging = do- mergeStatus <- maybeMergeStatus- case mergeStatus.result of- MergeResult'MergeConflicts _ -> Just branch- MergeResult'StashConflicts _ -> Nothing,+ result <- maybeMergeResult+ if mergeResultCommitted result+ then Nothing+ else Just branch, ranCommitAt = Nothing, undos } - putSummary- Summary- { branch,- canUndo = not (null undos),- conflicts =- case maybeMergeStatus of- Nothing -> []- Just mergeStatus ->- case mergeStatus.result of- MergeResult'MergeConflicts conflicts -> List1.toList conflicts- MergeResult'StashConflicts conflicts -> conflicts,- syncs =- catMaybes- [ do- mergeStatus <- maybeMergeStatus- pure- Sync- { commits = mergeStatus.commits,- result =- case mergeStatus.result of- MergeResult'MergeConflicts _ -> SyncResult'Failure- MergeResult'StashConflicts _ -> SyncResult'Success,- source = "origin/" <> branch,- target = branch- },- do- commits <- Seq1.fromSeq localCommits- pure- Sync- { commits,- result = pushResultToSyncResult pushResult,- source = branch,- target = "origin/" <> branch- }- ]- }+ putStanzas+ [ stanza0,+ isSynchronizedStanza (Text.Builder.fromText branch) pushResult,+ maybeMergeStatus <&> \mergeStatus ->+ syncStanza+ Sync+ { commits = mergeStatus.commits,+ result = if mergeResultCommitted mergeStatus.result then SyncResult'Success else SyncResult'Failure,+ source = upstream,+ target = branch+ },+ Seq1.fromSeq localCommits <&> \commits ->+ syncStanza+ Sync+ { commits,+ result = pushResultToSyncResult pushResult,+ source = branch,+ target = upstream+ },+ do+ conflicts1 <- List1.nonEmpty conflicts+ conflictsStanza "These files are in conflict:" conflicts1,+ whatNextStanza (Text.Builder.fromText branch) pushResult,+ if not (null undos) then Just canUndoStanza else Nothing+ ] -- FIXME output what we just undid mitUndo :: IO ()@@ -513,7 +619,7 @@ state0 <- readMitState branch64 case List1.nonEmpty state0.undos of Nothing -> exitFailure- Just undos1 -> applyUndos undos1+ Just undos1 -> for_ undos1 applyUndo when (undosContainRevert state0.undos) mitSync where undosContainRevert :: [Undo] -> Bool@@ -522,13 +628,6 @@ Revert _ : _ -> True _ : undos -> undosContainRevert undos -data Summary = Summary- { branch :: Text,- canUndo :: Bool,- conflicts :: [GitConflict],- syncs :: [Sync]- }- data Sync = Sync { commits :: Seq1 GitCommitInfo, result :: SyncResult,@@ -541,44 +640,142 @@ | SyncResult'Offline | SyncResult'Pending | SyncResult'Success+ deriving stock (Eq) --- 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 (map (Text.Lazy.toStrict . Text.Builder.toLazyText) ("" : output))+canUndoStanza :: Text.Builder+canUndoStanza =+ " Run " <> Text.Builder.bold (Text.Builder.blue "mit undo") <> " to undo this change."++conflictsStanza :: Text.Builder -> List1 GitConflict -> Maybe Text.Builder+conflictsStanza prefix conflicts =+ Just $+ " "+ <> prefix+ <> Builder.newline+ <> Builder.vcat ((\conflict -> " " <> Text.Builder.red (showGitConflict conflict)) <$> conflicts)++isSynchronizedStanza :: Text.Builder -> PushResult -> Maybe Text.Builder+isSynchronizedStanza branch = \case+ PushAttempted False ->+ notSynchronizedStanza branch upstream (" because " <> Text.Builder.bold "git push" <> " failed.")+ PushAttempted True -> synchronizedStanza branch upstream+ PushNotAttempted MergeConflicts ->+ notSynchronizedStanza branch upstream " because you have local conflicts to resolve."+ PushNotAttempted (ForkedHistory _) -> notSynchronizedStanza branch upstream "; their commit histories have diverged."+ PushNotAttempted NothingToPush -> synchronizedStanza branch upstream+ PushNotAttempted Offline -> notSynchronizedStanza branch upstream " because you appear to be offline."+ PushNotAttempted UnseenCommits -> notSynchronizedStanza branch upstream "." where- conflictsLines :: [Text.Builder]- conflictsLines =- if null summary.conflicts- then []+ upstream = "origin/" <> branch++notSynchronizedStanza :: Text.Builder -> Text.Builder -> Text.Builder -> Maybe Text.Builder+notSynchronizedStanza branch other suffix =+ Just+ ( " "+ <> Text.Builder.red+ ( Text.Builder.italic branch+ <> " is not synchronized with "+ <> Text.Builder.italic other+ <> suffix+ )+ )++whatNextStanza :: Text.Builder -> PushResult -> Maybe Text.Builder+whatNextStanza branch = \case+ PushAttempted False ->+ Just $+ " Run "+ <> sync+ <> " to synchronize "+ <> Text.Builder.italic branch+ <> " with "+ <> Text.Builder.italic upstream+ <> "."+ PushAttempted True -> Nothing+ PushNotAttempted (ForkedHistory conflicts) ->+ Just $+ if null conflicts+ then+ " Run "+ <> sync+ <> ", examine the repository, then run "+ <> sync+ <> " again to synchronize "+ <> Text.Builder.italic branch+ <> " with "+ <> Text.Builder.italic upstream+ <> "." else- " The following files have conflicts." :- map ((" " <>) . Text.Builder.red . showGitConflict) summary.conflicts ++ [""]- syncLines :: Sync -> [Text.Builder]- syncLines sync =- colorize- ( Text.Builder.italic- (" " <> Text.Builder.fromText sync.source <> " → " <> Text.Builder.fromText sync.target)- ) :- map ((" " <>) . prettyGitCommitInfo) (toList @Seq commits')- ++ (if more then [" ...", ""] else [""])- where- colorize :: Text.Builder -> Text.Builder- colorize =- case sync.result of- SyncResult'Failure -> Text.Builder.red- SyncResult'Offline -> Text.Builder.brightBlack- SyncResult'Pending -> Text.Builder.yellow- SyncResult'Success -> Text.Builder.green- (commits', more) =- case Seq1.length sync.commits > 10 of- False -> (Seq1.toSeq sync.commits, False)- True -> (Seq1.dropEnd 1 sync.commits, True)- undoLines :: [Text.Builder]- undoLines =- if summary.canUndo- then [" Run " <> Text.Builder.bold (Text.Builder.blue "mit undo") <> " to undo this change.", ""]- else []+ " Run "+ <> sync+ <> ", resolve the conflicts, then run "+ <> commit+ <> " to synchronize "+ <> Text.Builder.italic branch+ <> " with "+ <> Text.Builder.italic upstream+ <> "."+ PushNotAttempted MergeConflicts ->+ Just (" Resolve the merge conflicts, then run " <> commit <> ".")+ PushNotAttempted NothingToPush -> Nothing+ PushNotAttempted Offline ->+ Just $+ " When you come online, run "+ <> sync+ <> " to synchronize "+ <> Text.Builder.italic branch+ <> " with "+ <> Text.Builder.italic upstream+ <> "."+ PushNotAttempted UnseenCommits ->+ Just $+ " Examine the repository, then run "+ <> sync+ <> " to synchronize "+ <> Text.Builder.italic branch+ <> " with "+ <> Text.Builder.italic upstream+ <> "."+ where+ commit = Text.Builder.bold (Text.Builder.blue "mit commit")+ sync = Text.Builder.bold (Text.Builder.blue "mit sync")+ upstream = "origin/" <> branch --- Undo file utils+syncStanza :: Sync -> Text.Builder+syncStanza sync =+ Text.Builder.italic+ (colorize (" " <> Text.Builder.fromText sync.source <> " → " <> Text.Builder.fromText sync.target))+ <> "\n"+ <> (Builder.vcat ((\commit -> " " <> prettyGitCommitInfo commit) <$> commits'))+ <> (if more then " ..." else Builder.empty)+ where+ colorize :: Text.Builder -> Text.Builder+ colorize =+ case sync.result of+ SyncResult'Failure -> Text.Builder.red+ SyncResult'Offline -> Text.Builder.brightBlack+ SyncResult'Pending -> Text.Builder.yellow+ SyncResult'Success -> Text.Builder.green+ (commits', more) =+ case Seq1.length sync.commits > 10 of+ False -> (Seq1.toSeq sync.commits, False)+ True -> (Seq1.dropEnd 1 sync.commits, True)++synchronizedStanza :: Text.Builder -> Text.Builder -> Maybe Text.Builder+synchronizedStanza branch other =+ Just+ ( " "+ <> Text.Builder.green+ (Text.Builder.italic branch <> " is synchronized with " <> Text.Builder.italic other <> ".")+ )++renderStanzas :: [Maybe Text.Builder] -> Maybe Text.Builder+renderStanzas stanzas =+ case catMaybes stanzas of+ [] -> Nothing+ stanzas' -> Just (mconcat (List.intersperse (Builder.newline <> Builder.newline) stanzas'))++putStanzas :: [Maybe Text.Builder] -> IO ()+putStanzas stanzas =+ whenJust (renderStanzas stanzas) \s ->+ Text.putStr (Builder.build (Builder.newline <> s <> Builder.newline <> Builder.newline))
+ src/Mit/Builder.hs view
@@ -0,0 +1,58 @@+module Mit.Builder+ ( empty,+ build,+ hcat,+ newline,+ put,+ putln,+ space,+ squoted,+ vcat,+ )+where++import qualified Data.List as List+import qualified Data.Text.IO as Text+import qualified Data.Text.Lazy as Text.Lazy+import Data.Text.Lazy.Builder+import Mit.Prelude++empty :: Builder+empty =+ mempty++build :: Builder -> Text+build =+ Text.Lazy.toStrict . toLazyText++hcat :: Foldable f => f Builder -> Builder+hcat =+ mconcat . List.intersperse space . toList++newline :: Builder+newline =+ singleton '\n'++put :: Builder -> IO ()+put =+ Text.putStr . build++putln :: Builder -> IO ()+putln =+ Text.putStrLn . build++space :: Builder+space =+ singleton ' '++squote :: Builder+squote =+ singleton '\''++squoted :: Builder -> Builder+squoted s =+ squote <> s <> squote++vcat :: Foldable f => f Builder -> Builder+vcat =+ mconcat . List.intersperse newline . toList
+ src/Mit/Config.hs view
@@ -0,0 +1,17 @@+module Mit.Config+ ( verbose,+ )+where++import Mit.Prelude+import System.Environment (lookupEnv)+import System.IO.Unsafe (unsafePerformIO)++verbose :: Int+verbose =+ unsafePerformIO do+ lookupEnv "MIT_VERBOSE" <&> \case+ Just "1" -> 1+ Just "2" -> 2+ _ -> 0+{-# NOINLINE verbose #-}
src/Mit/Git.hs view
@@ -3,13 +3,13 @@ import qualified Data.List as List import qualified Data.Sequence as Seq import qualified Data.Text as Text-import qualified Data.Text.ANSI as Text import qualified Data.Text.Builder.ANSI as Text.Builder import qualified Data.Text.IO as Text import qualified Data.Text.Lazy.Builder as Text (Builder) import qualified Data.Text.Lazy.Builder as Text.Builder import qualified Ki-import Mit.Globals (debug)+import qualified Mit.Builder as Builder+import Mit.Config (verbose) import Mit.Prelude import Mit.Process import System.Directory (doesFileExist)@@ -51,11 +51,11 @@ prettyGitCommitInfo info = fold [ Text.Builder.bold (Text.Builder.black (Text.Builder.fromText info.shorthash)),- " ",+ Builder.space, Text.Builder.bold (Text.Builder.white (Text.Builder.fromText info.subject)), " - ", Text.Builder.italic (Text.Builder.white (Text.Builder.fromText info.author)),- " ",+ Builder.space, Text.Builder.italic (Text.Builder.yellow (Text.Builder.fromText info.date)) -- FIXME some other color, magenta? ] @@ -115,7 +115,7 @@ gitApplyStash :: Text -> IO [GitConflict] gitApplyStash stash = do conflicts <-- git ["stash", "apply", stash] >>= \case+ git ["stash", "apply", "--quiet", stash] >>= \case False -> gitConflicts True -> pure [] gitUnstageChanges@@ -124,7 +124,7 @@ -- | Create a branch. gitBranch :: Text -> IO () gitBranch branch =- gitl_ ["branch", "--no-track", branch]+ git_ ["branch", "--no-track", branch] -- | Does the given local branch (refs/heads/...) exist? gitBranchExists :: Text -> IO Bool@@ -151,9 +151,9 @@ queryTerminal 0 >>= \case False -> do message <- lookupEnv "MIT_COMMIT_MESSAGE"- gitl ["commit", "--all", "--message", maybe "" Text.pack message]+ git ["commit", "--all", "--message", maybe "" Text.pack message] True ->- gitl2 ["commit", "--patch", "--quiet"] <&> \case+ git2 ["commit", "--patch", "--quiet"] <&> \case ExitFailure _ -> False ExitSuccess -> True @@ -189,6 +189,20 @@ gitConflicts = mapMaybe parseGitConflict <$> git ["status", "--no-renames", "--porcelain=v1"] +-- | Get the conflicts with the given commitish.+--+-- Precondition: there is no merge in progress.+gitConflictsWith :: Text -> IO [GitConflict]+gitConflictsWith commit = do+ maybeStash <- gitStash+ conflicts <-+ git ["merge", "--no-commit", "--no-ff", commit] >>= \case+ False -> gitConflicts+ True -> pure []+ git_ ["merge", "--abort"]+ whenJust maybeStash \stash -> git_ ["stash", "apply", "--quiet", stash]+ pure conflicts+ gitCreateStash :: IO Text gitCreateStash = do git_ ["add", "--all"] -- it seems certain things (like renames), unless staged, cannot be stashed@@ -227,7 +241,7 @@ gitFetch :: Text -> IO Bool gitFetch remote =- gitl ["fetch", remote]+ git ["fetch", remote] gitFetch_ :: Text -> IO () gitFetch_ =@@ -248,7 +262,7 @@ gitPush :: Text -> IO Bool gitPush branch =- gitl ["push", "--set-upstream", "origin", branch <> ":" <> branch]+ git ["push", "--set-upstream", "origin", "--quiet", branch <> ":" <> branch] -- | Does the given remote branch (refs/remotes/...) exist? gitRemoteBranchExists :: Text -> Text -> IO Bool@@ -283,7 +297,7 @@ gitUnstageChanges :: IO () gitUnstageChanges = do- git_ ["reset", "--mixed"]+ git_ ["reset", "--mixed", "--quiet"] untrackedFiles <- gitListUntrackedFiles unless (null untrackedFiles) (git_ ("add" : "--intent-to-add" : untrackedFiles)) @@ -291,7 +305,7 @@ 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)+ "git" : "version" : v1 : _ <- Just (Text.words v0) [sx, sy, sz] <- Just (Text.split (== '.') v1) x <- readMaybe (Text.unpack sx) y <- readMaybe (Text.unpack sy)@@ -328,15 +342,7 @@ pure (url, Text.takeWhileEnd (/= '/') url') git :: ProcessOutput a => [Text] -> IO a-git =- git' False--gitl :: ProcessOutput a => [Text] -> IO a-gitl =- git' True--git' :: ProcessOutput a => Bool -> [Text] -> IO a-git' effectful args = do+git args = do let spec :: CreateProcess spec = CreateProcess@@ -357,7 +363,6 @@ detach_console = False, use_process_jobs = False }- when effectful (logProcess "git" args) bracket (createProcess spec) cleanup \(_maybeStdin, maybeStdout, maybeStderr, processHandle) -> Ki.scoped \scope -> do stdoutThread <- Ki.fork scope (drainTextHandle (fromJust maybeStdout))@@ -391,14 +396,9 @@ git_ = git -gitl_ :: [Text] -> IO ()-gitl_ =- gitl- -- Yucky interactive/inherity variant (so 'git commit' can open an editor).-gitl2 :: [Text] -> IO ExitCode-gitl2 args = do- logProcess "git" args+git2 :: [Text] -> IO ExitCode+git2 args = do (Nothing, Nothing, Just stderrHandle, processHandle) <- createProcess CreateProcess@@ -429,18 +429,25 @@ debugPrintGit :: [Text] -> Seq Text -> Seq Text -> ExitCode -> IO () debugPrintGit args stdoutLines stderrLines exitCode =- when debug do- putLines do- let output :: [Text]- output =- map (Text.brightBlack . (" " <>)) (toList @Seq (stdoutLines <> stderrLines))- Text.bold (Text.brightBlack (Text.unwords (marker <> " git" : map quoteText args))) : output+ case verbose of+ 1 -> Builder.putln (Text.Builder.brightBlack v1)+ 2 -> Builder.putln (Text.Builder.brightBlack (v1 <> v2))+ _ -> pure () where- marker :: Text+ v1 = Text.Builder.bold (marker <> " git " <> Builder.hcat (map quote args))+ v2 = foldMap (\line -> "\n " <> Text.Builder.fromText line) (stdoutLines <> stderrLines)++ quote :: Text -> Text.Builder+ quote s =+ if Text.any isSpace s+ then Builder.squoted (Text.Builder.fromText (Text.replace "'" "\\'" s))+ else Text.Builder.fromText s++ marker :: Text.Builder marker = case exitCode of- ExitFailure _ -> "✗"- ExitSuccess -> "✓"+ ExitFailure _ -> Text.Builder.singleton '✗'+ ExitSuccess -> Text.Builder.singleton '✓' drainTextHandle :: Handle -> IO (Seq Text) drainTextHandle handle = do
− src/Mit/Globals.hs
@@ -1,10 +0,0 @@-module Mit.Globals where--import Mit.Prelude-import System.Environment (lookupEnv)-import System.IO.Unsafe (unsafePerformIO)--debug :: Bool-debug =- isJust (unsafePerformIO (lookupEnv "debug"))-{-# NOINLINE debug #-}
src/Mit/Process.hs view
@@ -4,22 +4,7 @@ import qualified Data.Sequence as Seq import Mit.Prelude-import qualified Data.Text as Text-import qualified Data.Text.IO as Text-import qualified Data.Text.Lazy as Text.Lazy-import qualified Data.List as List-import qualified Data.Text.Builder.ANSI as Text.Builder-import qualified Data.Text.Lazy.Builder as Text.Builder import System.Exit (ExitCode (..), exitWith)--logProcess :: Text -> [Text] -> IO ()-logProcess s0 ss =- Text.putStrLn (Text.Lazy.toStrict (Text.Builder.toLazyText line))- where- line = "» " <> Text.Builder.bold (Text.Builder.fromText s0 <> space <> formatArgs ss)- formatArgs = mconcat . List.intersperse space . map formatArg- formatArg s = Text.Builder.fromText (if Text.elem ' ' s then s else s)- space = Text.Builder.singleton ' ' class ProcessOutput a where fromProcessOutput :: Seq Text -> Seq Text -> ExitCode -> IO a
src/Mit/Seq.hs view
@@ -1,5 +1,6 @@ module Mit.Seq where +import qualified Data.Foldable as Foldable import Data.Sequence pattern NonEmpty :: Seq a@@ -9,3 +10,7 @@ pattern Singleton :: Seq a pattern Singleton <- _ :<| Empty++toList :: Seq a -> [a]+toList =+ Foldable.toList
src/Mit/Undo.hs view
@@ -33,13 +33,16 @@ error (show text) ] -applyUndos :: List1 Undo -> IO ()-applyUndos =- traverse_ \case- Apply commit -> do- gitl_ ["stash", "apply", commit]- gitUnstageChanges- Reset commit -> do- gitl_ ["clean", "-d", "--force"]- gitl ["reset", "--hard", commit]- Revert commit -> gitl_ ["revert", commit]+applyUndo :: Undo -> IO ()+applyUndo = \case+ Apply commit -> do+ git_ ["stash", "apply", "--quiet", commit]+ gitUnstageChanges+ Reset commit -> do+ git_ ["clean", "-d", "--force"]+ git ["reset", "--hard", commit]+ Revert commit -> git_ ["revert", commit]++undosStash :: [Undo] -> Maybe Text+undosStash undos =+ listToMaybe [commit | Apply commit <- undos]