diff --git a/mit-3qvpPyAi6mH.cabal b/mit-3qvpPyAi6mH.cabal
--- a/mit-3qvpPyAi6mH.cabal
+++ b/mit-3qvpPyAi6mH.cabal
@@ -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
diff --git a/src/Mit.hs b/src/Mit.hs
--- a/src/Mit.hs
+++ b/src/Mit.hs
@@ -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))
diff --git a/src/Mit/Builder.hs b/src/Mit/Builder.hs
new file mode 100644
--- /dev/null
+++ b/src/Mit/Builder.hs
@@ -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
diff --git a/src/Mit/Config.hs b/src/Mit/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Mit/Config.hs
@@ -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 #-}
diff --git a/src/Mit/Git.hs b/src/Mit/Git.hs
--- a/src/Mit/Git.hs
+++ b/src/Mit/Git.hs
@@ -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
diff --git a/src/Mit/Globals.hs b/src/Mit/Globals.hs
deleted file mode 100644
--- a/src/Mit/Globals.hs
+++ /dev/null
@@ -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 #-}
diff --git a/src/Mit/Process.hs b/src/Mit/Process.hs
--- a/src/Mit/Process.hs
+++ b/src/Mit/Process.hs
@@ -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
diff --git a/src/Mit/Seq.hs b/src/Mit/Seq.hs
--- a/src/Mit/Seq.hs
+++ b/src/Mit/Seq.hs
@@ -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
diff --git a/src/Mit/Undo.hs b/src/Mit/Undo.hs
--- a/src/Mit/Undo.hs
+++ b/src/Mit/Undo.hs
@@ -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]
