diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,12 @@
 # dnf-repo releases
 
+## 0.5 (2022-10-23)
+- support repo Glob patterns
+- repo pattern now only matches one (first) repo by default (without glob)
+- add --clear-expires command; expire commands now prompt
+- abort if no match for repopat action
+- simple testsuite
+
 ## 0.4 (2022-10-20)
 - only list all repos if --list or no repo actions
 - repo patterns can now use a ^ prefix and $ suffix
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -3,36 +3,38 @@
 A wrapper of the dnf package manager for fine control of
 enabled/disabled yum repos (eg coprs).
 
-DNF is not terribly fast at handling repos partly because it likes
+dnf is the package manager used by Fedora Linux and also modern RHEL.
+
+DNF is a bit slow at handling multiple repos because it attempts
 to refresh the cached repodata frequently. So it can be advantageous
 to disable smaller repos by default and only enable them as needed
 (eg periodically).
 
-This tool can temporarily enable/disable repos selected by substring.
-Repo states can also be saved or their cache individually expired.
+This tool can temporarily enable/disable repo(s) selected by substring(s).
+Changes to repos' enabled states can be saved too.
+It is also possible to expire repos' caches individually.
 
-There are options to enable/disable testing/modular repos,
+There are also smart options to enable/disable testing/modular repos
+(and even source/debuginfo repos),
 and also to create a repo file for a Copr or Koji repo.
 
 ## Help
 
 ```shellsession
 $ dnf-repo --version
-0.4
+0.5
 $ dnf-repo --help
 DNF wrapper repo tool
 
-Usage: dnf-repo [--version] [-n|--dryrun] [-D|--debug] [-l|--list] [--exact]
-                [-s|--save] [(-w|--weak-deps) | (-W|--no-weak-deps)]
-                [
-                  [(-c|--add-copr COPR) | (-k|--add-koji REPO) |
-                    (-d|--disable REPOPAT) | (-e|--enable REPOPAT) |
-                    (-x|--expire REPOPAT) | (-E|--delete-repofile REPOPAT) |
-                    (-t|--enable-testing) | (-T|--disable-testing) |
-                    (-m|--enable-modular) | (-M|--disable-modular) |
-                    --enable-debuginfo | --disable-debuginfo | --enable-source |
-                    --disable-source] |
-                  --enable-defaults | --disable-defaults] [DNFARGS]
+Usage: dnf-repo [--version] [-n|--dryrun] [-D|--debug] [-l|--list] [-s|--save]
+                [(-w|--weak-deps) | (-W|--no-weak-deps)] [--exact]
+                [(-d|--disable REPOPAT) | (-e|--enable REPOPAT) |
+                  (-x|--expire REPOPAT) | (-X|--clear-expires) |
+                  (-E|--delete-repofile REPOPAT) | (-t|--enable-testing) |
+                  (-T|--disable-testing) | (-m|--enable-modular) |
+                  (-M|--disable-modular) | --enable-debuginfo |
+                  --disable-debuginfo | --enable-source | --disable-source |
+                  (-c|--add-copr COPR) | (-k|--add-koji REPO)] [DNFARGS]
   see https://github.com/juhp/dnf-repo#readme
 
 Available options:
@@ -41,15 +43,14 @@
   -n,--dryrun              Dry run
   -D,--debug               Debug output
   -l,--list                List all repos
-  --exact                  Match repo names exactly
   -s,--save                Save the repo enable/disable state
   -w,--weak-deps           Use weak dependencies
   -W,--no-weak-deps        Disable weak dependencies
-  -c,--add-copr COPR       Create repo file for copr repo
-  -k,--add-koji REPO       Create repo file for koji repo
+  --exact                  Match repo names exactly
   -d,--disable REPOPAT     Disable repos
   -e,--enable REPOPAT      Enable repos
   -x,--expire REPOPAT      Expire repo cache
+  -X,--clear-expires       Undo cache expirations
   -E,--delete-repofile REPOPAT
                            Remove unwanted .repo file
   -t,--enable-testing      Enable testing repos
@@ -60,17 +61,18 @@
   --disable-debuginfo      Disable debuginfo repos
   --enable-source          Enable source repos
   --disable-source         Disable source repos
-  --enable-defaults        Enable modular and Cisco h264 repos
-  --disable-defaults       Disable modular and Cisco h264 repos
+  -c,--add-copr COPR       Create repo file for copr repo
+  -k,--add-koji REPO       Create repo file for koji repo (f37-build, rawhide,
+                           epel9-build, etc)
 ```
 
 ## Usage examples
 List repos:
 ```shellsession
-$ dnf-repo
+$ dnf-repo [--list]
 ```
 
-Update with testing repos:
+Update with testing repos enabled:
 ```shellsession
 $ dnf-repo -t update
 ```
@@ -79,12 +81,12 @@
 there is no need to run dnf-repo with sudo.
 
 ### Copr
-List disabled copr repos (actually shows "copr" repos that would be enabled):
+List disabled copr repos (ie lists copr repos that would be enabled):
 ```shellsession
 $ dnf-repo -e copr
 ```
 
-Disable all copr repos for update:
+Disable active copr repos for update:
 ```shellsession
 $ dnf-repo -d copr update
 ```
@@ -93,7 +95,7 @@
 ```shellsession
 $ dnf-repo -c varlad/helix install helix
 ```
-(note the copr repo is not permanently enabled by default).
+(note the copr repo is not permanently enabled).
 
 Later update with the copr:
 ```shellsession
@@ -101,17 +103,28 @@
 ```
 
 ### Changing system repo config
-Disable modular and cisco h264 repos permanently:
+Disable fedora modular and cisco openh264 repos permanently:
 ```shellsession
-$ dnf-repo --disable-defaults --save
+$ dnf-repo --M -d h264 --save
 ```
 
-To only disable modular you can use: `dnf-repo -M -s`.
+### Use only source repos
+```shellsession
+$ dnf-repo -d \* --enable-source
+with 'fedora-source' enabled
+with 'updates-source' enabled
+with 'fedora' disabled
+with 'updates' disabled
+```
 
 ### Switch system from rawhide
 Switch a system from Rawhide to F37:
 ```shellsession
-$ dnf-repo --exact -d rawhide -e fedora distrosync --releasever 37 fedora\*
+$ dnf-repo -d rawhide -e fedora distrosync --releasever 37 fedora-\*
+with 'rawhide' disabled
+with 'fedora' enabled
+
+:
 ```
 
 ### Repo patterns
@@ -121,9 +134,25 @@
 But you can also prepend `^`/append `$` (or both) to match a repo name
 from its beginning/end (or exactly).
 
+You can also use glob patterns to match one or more repo names:
+see the [supported Glob syntax](https://hackage.haskell.org/package/Glob/docs/System-FilePath-Glob.html#v:compile).
+
+Without a glob (eg '*') a shortest common repo match will be sought,
+otherwise all matching repos will be considered.
+
+An initial `^` (final `$`) in a glob pattern prevents
+the automatic prepending (appending) of `*` before (after) the pattern.
+
 ## Installation
 A copr repo is available:
 <https://copr.fedorainfracloud.org/coprs/petersen/dnf-repo/>
 
 ## Building
 Use {cabal,stack,cabal-rpm} install.
+
+## Contributing
+The source repository is https://github.com/juhp/dnf-repo/
+
+dnf-repo is currently distributed under a BSD license.
+
+Contributions including suggestions for improvement are welcome.
diff --git a/dnf-repo.cabal b/dnf-repo.cabal
--- a/dnf-repo.cabal
+++ b/dnf-repo.cabal
@@ -1,5 +1,5 @@
 name:                dnf-repo
-version:             0.4
+version:             0.5
 synopsis:            DNF wrapper tool to control repos
 description:
         A command-line wrapper of the dnf package manager to
@@ -41,6 +41,7 @@
                        directory,
                        extra,
                        filepath,
+                       Glob,
                        simple-cmd,
                        simple-cmd-args >= 0.1.8
   default-language:    Haskell2010
@@ -58,3 +59,15 @@
                        -Wpartial-fields
   if impl(ghc >= 8.10)
     ghc-options:       -Wunused-packages
+
+test-suite test
+    main-is: tests.hs
+    type: exitcode-stdio-1.0
+    hs-source-dirs: test
+
+    default-language: Haskell2010
+
+    ghc-options:   -Wall
+    build-depends: base >= 4 && < 5,
+                   simple-cmd
+    build-tool-depends: dnf-repo:dnf-repo
diff --git a/src/ExpireRepos.hs b/src/ExpireRepos.hs
--- a/src/ExpireRepos.hs
+++ b/src/ExpireRepos.hs
@@ -1,27 +1,49 @@
-module ExpireRepos (expireRepos) where
+module ExpireRepos (
+  expireRepo,
+  clearExpired
+  ) where
 
+import Control.Monad
 import Data.List (nub)
-import SimpleCmd (error')
 
 import Sudo
 
 expiredFile :: FilePath
 expiredFile = "/var/cache/dnf/expired_repos.json"
 
-expireRepos :: Bool -> Bool -> [String] -> IO ()
-expireRepos _ _ [] = error' "no repos to expire given"
-expireRepos dryrun debug repos = do
+expireRepo :: Bool -> Bool -> String -> IO ()
+expireRepo dryrun debug repo = do
   old <- read <$> readFile expiredFile :: IO [String]
-  let expired = nub $ old ++ repos
-  doSudo dryrun debug "sed" ["-i", "-e",
-                       "s/" ++ renderShow old ++ "/" ++ renderShow expired ++ "/",
-                       expiredFile]
-  putStrLn $ "expired now: " ++ show expired
-  where
-    renderShow :: [String] -> String
-    renderShow = render . show
+  let expired = nub $ old ++ [repo]
+  ok <- yesno $ "Expire cache of " ++ repo
+  when ok $ do
+    doSudo dryrun debug "sed" ["-i", "-e",
+                               "s/" ++ renderShow old ++ "/" ++ renderShow expired ++ "/",
+                               expiredFile]
+    unless dryrun $ do
+      putStrLn ""
+      putStrLn $ repo ++ " marked expired in " ++ expiredFile
 
+renderShow :: [String] -> String
+renderShow = render . show
+  where
     render :: String -> String
     render "" = ""
     render (c:cs) =
       (if c `elem` "[]" then ['\\',c] else [c]) ++ render cs
+
+clearExpired :: Bool -> Bool -> IO ()
+clearExpired dryrun debug = do
+  old <- read <$> readFile expiredFile :: IO [String]
+  if null old
+    then return ()
+    else do
+    mapM_ putStrLn old
+    putStrLn ""
+    ok <- yesno "Unset cache expirations"
+    when ok $ do
+      doSudo dryrun debug "sed" ["-i", "-e",
+                               "s/" ++ renderShow old ++ "/" ++ renderShow [] ++ "/",
+                               expiredFile]
+      putStrLn ""
+      putStrLn $ "expirations cleared in " ++ expiredFile
diff --git a/src/Main.hs b/src/Main.hs
--- a/src/Main.hs
+++ b/src/Main.hs
@@ -7,7 +7,6 @@
 import Control.Monad.Extra
 import Data.Bifunctor (bimap)
 import Data.List.Extra
-import Data.Maybe (mapMaybe)
 import SimpleCmd
 import SimpleCmdArgs
 import System.Directory
@@ -17,7 +16,7 @@
 import System.Time.Extra (sleep)
 
 import Paths_dnf_repo (getDataFileName, version)
-import ExpireRepos (expireRepos)
+import ExpireRepos
 import Sudo
 import YumRepoFile
 
@@ -30,29 +29,21 @@
     <$> switchWith 'n' "dryrun" "Dry run"
     <*> switchWith 'D' "debug" "Debug output"
     <*> switchWith 'l' "list" "List all repos"
-    <*> switchLongWith "exact" "Match repo names exactly"
     <*> switchWith 's' "save" "Save the repo enable/disable state"
     <*> optional (flagWith' True 'w' "weak-deps" "Use weak dependencies" <|>
                   flagWith' False 'W' "no-weak-deps" "Disable weak dependencies")
-    <*> (many modeOpt <|>
-         (flagLongWith' [Specific EnableModular,
-                         EnableRepo "^fedora-cisco-openh264$"]
-          "enable-defaults" "Enable modular and Cisco h264 repos"
-          <|>
-          flagLongWith [] [Specific DisableModular,
-                           DisableRepo "^fedora-cisco-openh264$"]
-          "disable-defaults" "Disable modular and Cisco h264 repos"))
+    <*> switchLongWith "exact" "Match repo names exactly"
+    <*> many modeOpt
     <*> many (strArg "DNFARGS")
   where
     repoOptionWith =
       (fmap . fmap . fmap . fmap) (replace "/" ":") . strOptionWith
 
     modeOpt =
-      AddCopr <$> repoOptionWith 'c' "add-copr" "COPR" "Create repo file for copr repo" <|>
-      AddKoji <$> repoOptionWith 'k' "add-koji" "REPO" "Create repo file for koji repo" <|>
       DisableRepo <$> repoOptionWith 'd' "disable" "REPOPAT" "Disable repos" <|>
       EnableRepo <$> repoOptionWith 'e' "enable" "REPOPAT" "Enable repos" <|>
       ExpireRepo <$> repoOptionWith 'x' "expire" "REPOPAT" "Expire repo cache" <|>
+      flagWith' ClearExpires 'X' "clear-expires" "Undo cache expirations" <|>
       DeleteRepo <$> repoOptionWith 'E' "delete-repofile" "REPOPAT" "Remove unwanted .repo file" <|>
       flagWith' (Specific EnableTesting) 't' "enable-testing" "Enable testing repos" <|>
       flagWith' (Specific DisableTesting) 'T' "disable-testing" "Disable testing repos" <|>
@@ -61,7 +52,9 @@
       flagLongWith' (Specific EnableDebuginfo) "enable-debuginfo" "Enable debuginfo repos" <|>
       flagLongWith' (Specific DisableDebuginfo) "disable-debuginfo" "Disable debuginfo repos" <|>
       flagLongWith' (Specific EnableSource) "enable-source" "Enable source repos" <|>
-      flagLongWith' (Specific DisableSource) "disable-source" "Disable source repos"
+      flagLongWith' (Specific DisableSource) "disable-source" "Disable source repos" <|>
+      AddCopr <$> repoOptionWith 'c' "add-copr" "COPR" "Create repo file for copr repo" <|>
+      AddKoji <$> repoOptionWith 'k' "add-koji" "REPO" "Create repo file for koji repo (f37-build, rawhide, epel9-build, etc)"
 
 coprRepoTemplate :: FilePath
 coprRepoTemplate = "copr.fedorainfracloud.orgCOLONOWNERCOLONREPO.repo"
@@ -73,127 +66,120 @@
 -- FIXME --enable-all-coprs (for updating etc)
 -- FIXME confirm repos if many
 -- FIXME --disable-non-cores (modular,testing,cisco, etc)
-runMain :: Bool -> Bool -> Bool -> Bool -> Bool -> Maybe Bool -> [Mode]
+runMain :: Bool -> Bool -> Bool -> Bool -> Maybe Bool -> Bool -> [Mode]
         -> [String] -> IO ()
-runMain dryrun debug listrepos exact save mweakdeps modes args = do
+runMain dryrun debug listrepos save mweakdeps exact modes args = do
   hSetBuffering stdout NoBuffering
   withCurrentDirectory "/etc/yum.repos.d" $ do
     forM_ modes $
       \case
-        AddCopr copr -> addCoprRepo copr
-        AddKoji repo -> addKojiRepo repo
+        AddCopr copr -> addCoprRepo dryrun debug copr
+        AddKoji repo -> addKojiRepo dryrun debug repo
         _ -> return ()
     repofiles <- filesWithExtension "." "repo"
     -- when debug $ print repofiles
     nameStates <- sort <$> concatMapM readRepos repofiles
-    let repoActs = selectRepo exact nameStates modes
-    unless (null repoActs) $ do
-      mapM_ print repoActs
-      putStrLn ""
-    forM_ modes $
+    let actions = selectRepo exact nameStates modes
+        moreoutput = not (null args) || null actions || listrepos
+    unless (null actions) $ do
+      mapM_ (printAction save) actions
+      when moreoutput $
+        putStrLn ""
+    outputs <-
+      forM actions $
       \case
-        ExpireRepo _ -> do
-          putStrLn ""
-          expireRepos dryrun debug $ mapMaybe expiring repoActs
-        DeleteRepo _ ->
-          mapM_ deleteRepos $ mapMaybe deleting repoActs
-        _ -> return ()
-    when save $
-      if null repoActs
-        then putStrLn "no changes to save\n"
+        Expire repo -> do
+          expireRepo dryrun debug repo
+          return True
+        UnExpire -> do
+          clearExpired dryrun debug
+          return True
+        Delete repofile True -> do
+          deleteRepo dryrun debug repofile
+          return True
+        _ -> return False
+    when (or outputs && (save || moreoutput)) $
+      putStrLn ""
+    when save $ do
+      if null actions
+        then putStrLn "no changes to save"
         else do
         prompt_ "Press Enter to save repo enabled state"
         doSudo dryrun debug "dnf" $
-          "config-manager" :
-          concatMap saveRepo repoActs
+          "config-manager" : concatMap saveRepo actions
+      putStrLn ""
     if null args
       then
-      when (null repoActs || listrepos) $
-      listRepos $ map (updateState repoActs) nameStates
+      when (null actions || listrepos) $
+      listRepos $ map (updateState actions) nameStates
       else do
       sleep 1
-      putStrLn ""
-      let repoargs = concatMap changeRepo repoActs
+      let repoargs = concatMap changeRepo actions
           weakdeps = maybe [] (\w -> ["--setopt=install_weak_deps=" ++ show w]) mweakdeps
         in doSudo dryrun debug "dnf" $ repoargs ++ weakdeps ++ args
-    where
-      -- FIXME pull non-fedora copr repo file
-      -- FIXME delete created copr repo file if repo doesn't exist
-      addCoprRepo :: String -> IO ()
-      addCoprRepo repo = do
-        case stripInfix ":" repo of
-          Nothing -> error' $ "invalid copr: " ++ repo
-          Just (copr_owner,copr_repo) -> do
-            template <- getDataFileName coprRepoTemplate
-            repodef <- cmd "sed" ["-e", "s/@COPR_OWNER@/" ++ copr_owner ++ "/g", "-e", "s/@COPR_REPO@/" ++ copr_repo ++ "/g", template]
-            let repofile = ("_copr:" ++) $
-                           replace "COLON" ":" $
-                           replace "OWNER" copr_owner $
-                           replace "REPO" copr_repo coprRepoTemplate
-            exists <- doesFileExist repofile
-            if exists
-              then error' $ "repo already defined: " ++ repofile
-              else putStrLn $ "Setting up copr repo " ++ repo
-            withTempDir $ \ tmpdir -> do
-              let tmpfile = tmpdir </> repofile
-              unless dryrun $ writeFile tmpfile repodef
-              doSudo dryrun debug "cp" [tmpfile, repofile]
 
-      addKojiRepo :: String -> IO ()
-      addKojiRepo repo = do
-        template <- getDataFileName kojiRepoTemplate
-        repodef <- cmd "sed" ["-e", "s/@REPO@/" ++ repo ++ "/g", template]
-        let repofile = replace "REPO" repo kojiRepoTemplate
-        exists <- doesFileExist repofile
-        if exists
-          then error' $ "repo already defined: " ++ repofile
-          else putStrLn $ "Setting up koji repo " ++ repo
-        withTempDir $ \ tmpdir -> do
-          let tmpfile = tmpdir </> repofile
-          unless dryrun $ writeFile tmpfile repodef
-          doSudo dryrun debug "cp" [tmpfile, repofile]
-
-      listRepos :: [RepoState] -> IO ()
-      listRepos repoStates = do
-        let (on,off) =
-              -- can't this be simplified?
-              bimap (map fst) (map fst) $ partition (fst . snd) repoStates
-        putStrLn "Enabled:"
-        mapM_ putStrLn on
+-- FIXME pull non-fedora copr repo file
+-- FIXME delete created copr repo file if repo doesn't exist
+addCoprRepo :: Bool -> Bool -> String -> IO ()
+addCoprRepo dryrun debug repo = do
+  case stripInfix ":" repo of
+    Nothing -> error' $ "invalid copr: " ++ repo
+    Just (copr_owner,copr_repo) -> do
+      template <- getDataFileName coprRepoTemplate
+      repodef <- cmd "sed" ["-e", "s/@COPR_OWNER@/" ++ copr_owner ++ "/g", "-e", "s/@COPR_REPO@/" ++ copr_repo ++ "/g", template]
+      let repofile = ("_copr:" ++) $
+                     replace "COLON" ":" $
+                     replace "OWNER" copr_owner $
+                     replace "REPO" copr_repo coprRepoTemplate
+      exists <- doesFileExist repofile
+      if exists
+        then error' $ "repo already defined: " ++ repofile
+        else putStrLn $ "Setting up copr repo " ++ repo
+      withTempDir $ \ tmpdir -> do
+        let tmpfile = tmpdir </> repofile
+        unless dryrun $ writeFile tmpfile repodef
+        doSudo dryrun debug "cp" [tmpfile, repofile]
         putStrLn ""
-        putStrLn "Disabled:"
-        mapM_ putStrLn off
 
-      deleteRepos :: FilePath -> IO ()
-      deleteRepos repofile = do
-        mowned <- cmdMaybe "rpm" ["-qf", "/etc/yum.repos.d" </> repofile]
-        case mowned of
-          Just owner -> error' $ repofile +-+ "owned by" +-+ owner
-          Nothing -> do
-            ok <- yesno $ "Remove " ++ takeFileName repofile
-            when ok $ doSudo dryrun debug "rm" [repofile]
+-- FIXME check url exists!
+addKojiRepo :: Bool -> Bool -> String -> IO ()
+addKojiRepo dryrun debug repo = do
+  template <- getDataFileName kojiRepoTemplate
+  repodef <- cmd "sed" ["-e", "s/@REPO@/" ++ repo ++ "/g", template]
+  let repofile = replace "REPO" repo kojiRepoTemplate
+  exists <- doesFileExist repofile
+  if exists
+    then error' $ "repo already defined: " ++ repofile
+    else putStrLn $ "Setting up koji repo " ++ repo
+  withTempDir $ \ tmpdir -> do
+    let tmpfile = tmpdir </> repofile
+    unless dryrun $ writeFile tmpfile repodef
+    doSudo dryrun debug "cp" [tmpfile, repofile]
+    putStrLn ""
 
+listRepos :: [RepoState] -> IO ()
+listRepos repoStates = do
+  let (on,off) =
+        -- can't this be simplified?
+        bimap (map fst) (map fst) $ partition (fst . snd) repoStates
+  putStrLn "Enabled:"
+  mapM_ putStrLn on
+  putStrLn ""
+  putStrLn "Disabled:"
+  mapM_ putStrLn off
+
+deleteRepo :: Bool -> Bool -> FilePath -> IO ()
+deleteRepo dryrun debug repofile = do
+  mowned <- cmdMaybe "rpm" ["-qf", "/etc/yum.repos.d" </> repofile]
+  case mowned of
+    Just owner -> warning $ repofile +-+ "owned by" +-+ owner
+    Nothing -> do
+      ok <- yesno $ "Remove " ++ takeFileName repofile
+      when ok $ do
+        doSudo dryrun debug "rm" [repofile]
+
 #if !MIN_VERSION_simple_cmd(0,2,4)
 filesWithExtension :: FilePath -> String -> IO [FilePath]
 filesWithExtension dir ext =
   filter (ext `isExtensionOf`) <$> listDirectory dir
 #endif
-
-prompt :: String -> IO String
-prompt desc = do
-  putStr $ desc ++ ": "
-  getLine
-
-prompt_ :: String -> IO ()
-prompt_ desc = do
-  void $ prompt desc
-
-yesno :: String -> IO Bool
-yesno desc = do
-  inp <- prompt $ desc ++ "? [y/n]"
-  case lower inp of
-    "y" -> return True
-    "yes" -> return True
-    "n" -> return False
-    "no" -> return False
-    _ ->  yesno desc
diff --git a/src/Sudo.hs b/src/Sudo.hs
--- a/src/Sudo.hs
+++ b/src/Sudo.hs
@@ -1,6 +1,11 @@
-module Sudo (doSudo) where
+module Sudo (
+  doSudo,
+  prompt_,
+  yesno
+  ) where
 
-import Control.Monad (when)
+import Control.Monad
+import Data.List.Extra (lower)
 import SimpleCmd (cmdN, sudo_)
 
 -- FIXME make this silent (simple-cmd-0.2.7) unless debug
@@ -11,4 +16,22 @@
     else do
     when debug $ cmdN c args
     sudo_ c args
-  putStrLn ""
+
+prompt :: String -> IO String
+prompt desc = do
+  putStr $ desc ++ ": "
+  getLine
+
+prompt_ :: String -> IO ()
+prompt_ desc = do
+  void $ prompt desc
+
+yesno :: String -> IO Bool
+yesno desc = do
+  inp <- prompt $ desc ++ "? [y/n]"
+  case lower inp of
+    "y" -> return True
+    "yes" -> return True
+    "n" -> return False
+    "no" -> return False
+    _ ->  yesno desc
diff --git a/src/YumRepoFile.hs b/src/YumRepoFile.hs
--- a/src/YumRepoFile.hs
+++ b/src/YumRepoFile.hs
@@ -1,10 +1,10 @@
-{-# LANGUAGE OverloadedStrings #-}
-
 module YumRepoFile (
   Mode(..),
   SpecificChange(..),
   readRepos,
   RepoState,
+  ChangeEnable(Expire,UnExpire,Delete),
+  printAction,
   selectRepo,
   changeRepo,
   saveRepo,
@@ -14,23 +14,36 @@
   )
 where
 
-import Data.List.Extra (isPrefixOf, isInfixOf, isSuffixOf, sort, stripInfix,
-                        trim)
-import SimpleCmd (error')
+import Data.List.Extra (isPrefixOf, isInfixOf, isSuffixOf, nub, sort, sortOn,
+                        stripInfix, trim)
+import Data.Maybe (mapMaybe)
+import SimpleCmd (error', warning)
+import System.FilePath.Glob (compile, match)
 
 type RepoState = (String,(Bool,FilePath))
 
 data Mode = AddCopr String | AddKoji String
           | EnableRepo String | DisableRepo String
-          | ExpireRepo String | DeleteRepo String
+          | ExpireRepo String | ClearExpires
+          | DeleteRepo String
           | Specific SpecificChange
-  deriving (Eq, Ord)
+  deriving (Eq, Ord, Show)
 
+modePattern :: Mode -> Maybe String
+modePattern (AddCopr c) = Just c
+modePattern (AddKoji k) = Just k
+modePattern (EnableRepo r) = Just r
+modePattern (DisableRepo r) = Just r
+modePattern (ExpireRepo r) = Just r
+modePattern ClearExpires = Nothing
+modePattern (DeleteRepo r) = Just r
+modePattern (Specific _) = Nothing
+
 data SpecificChange = EnableModular | DisableModular
                     | EnableTesting | DisableTesting
                     | EnableDebuginfo | DisableDebuginfo
                     | EnableSource | DisableSource
-  deriving (Eq, Ord)
+  deriving (Eq, Ord, Show)
 
 repoSubstr :: SpecificChange -> String
 repoSubstr EnableModular = "-modular"
@@ -42,18 +55,55 @@
 repoSubstr EnableSource = "-source"
 repoSubstr DisableSource = "-source"
 
-data ChangeEnable = Disable String | Enable String | Expire String
-                  | Delete FilePath
+data ChangeEnable = Disable String Bool
+                  | Enable String Bool
+                  | Expire String
+                  | UnExpire
+                  | Delete FilePath Bool
   deriving (Eq,Ord,Show)
 
+printAction :: Bool -> ChangeEnable -> IO ()
+printAction save (Disable r s) =
+  if s
+  then putStrLn $
+       if save
+       then "disable " ++ quote r
+       else "with " ++ quote r ++ " disabled"
+  else warning $ quote r ++ " already disabled"
+printAction save (Enable r s) =
+  if s
+  then putStrLn $
+       if save
+       then "enable " ++ quote r
+       else "with " ++ quote r ++ " enabled"
+  else warning $ quote r ++ " already enabled"
+printAction _ (Expire r) =
+  putStrLn $ "expire " ++ quote r
+printAction _ UnExpire =
+  putStrLn "unexpire:"
+printAction _ (Delete f s) =
+  if s
+  then putStrLn $ "delete " ++ quote f
+  else warning $ quote f ++ " deletion skipped"
+
+quote :: String -> String
+quote s = '\'' : s ++ "'"
+
+maybeRepoName :: ChangeEnable -> Maybe (ChangeEnable, String)
+maybeRepoName d@(Disable r _) = Just (d, r)
+maybeRepoName e@(Enable r _) = Just (e, r)
+maybeRepoName x@(Expire r) = Just (x, r)
+maybeRepoName UnExpire = Nothing
+maybeRepoName (Delete _ _) = Nothing
+
 changeRepo :: ChangeEnable -> [String]
-changeRepo (Disable r) = ["--disablerepo", r]
-changeRepo (Enable r) = ["--enablerepo", r]
+changeRepo (Disable r True) = ["--disablerepo", r]
+changeRepo (Enable r True) = ["--enablerepo", r]
 changeRepo _ = []
 
 saveRepo :: ChangeEnable -> [String]
-saveRepo (Disable r) = ["--disable", r]
-saveRepo (Enable r) = ["--enable", r]
+saveRepo (Disable r True) = ["--disable", r]
+saveRepo (Enable r True) = ["--enable", r]
 saveRepo _ = []
 
 expiring :: ChangeEnable -> Maybe String
@@ -61,69 +111,121 @@
 expiring _ = Nothing
 
 deleting :: ChangeEnable -> Maybe String
-deleting (Delete r) = Just r
+deleting (Delete r True) = Just r
 deleting _ = Nothing
 
 updateState :: [ChangeEnable] -> RepoState -> RepoState
 updateState [] rs = rs
 updateState (ce:ces) re@(repo,(enabled,file)) =
   case ce of
-    Disable r | r == repo && enabled -> (repo,(False,file))
-    Enable r | r == repo && not enabled -> (repo,(True,file))
+    -- FIXME can probably drop enable/not enabled
+    Disable r True | r == repo && enabled -> (repo,(False,file))
+    Enable r True | r == repo && not enabled -> (repo,(True,file))
     _ -> updateState ces re
 
 selectRepo :: Bool -> [RepoState] -> [Mode] -> [ChangeEnable]
 selectRepo exact repostates modes =
-  foldr selectRepo' [] (sort modes)
+  nub $ foldr selectRepo' [] (nub (sort modes))
   where
     selectRepo' :: Mode -> [ChangeEnable] -> [ChangeEnable]
     selectRepo' mode acc =
-      acc ++ concatMap (selectRepoMode mode acc) repostates
+      let results = nub $ mapMaybe (selectRepoMode mode acc) repostates
+      in
+        case results of
+          [] -> error' ("no match for repo pattern action: " ++ show mode)
+          [_] -> acc ++ results
+          _ ->
+            acc ++
+            case modePattern mode of
+              Nothing -> results
+              Just p ->
+                if isGlob p
+                then results
+                else
+                  let actNames = mapMaybe maybeRepoName results
+                  in
+                    if null actNames
+                    then results
+                    else
+                      let base = head $ sortOn (length . snd) actNames
+                      in
+                        if all ((snd base `isPrefixOf`) . snd) actNames
+                        then [fst base]
+                        else results
 
-    selectRepoMode :: Mode -> [ChangeEnable] -> RepoState -> [ChangeEnable]
+    selectRepoMode :: Mode -> [ChangeEnable] -> RepoState
+                   -> Maybe ChangeEnable
     selectRepoMode mode acc (name,(enabled,file)) =
       case mode of
         AddCopr repo ->
-          [Enable name | repo `isSuffixOf` name, not enabled]
+          maybeChange repo isSuffixOf (not enabled) (Enable name)
         AddKoji repo ->
-          [Enable name | repo `isSuffixOf` name, not enabled]
+          maybeChange repo isSuffixOf (not enabled) (Enable name)
         EnableRepo pat ->
-          [Enable name | pat `matchesRepo` name, not enabled]
+          maybeChange pat matchesRepo (not enabled) (Enable name)
         DisableRepo pat ->
-          [Disable name | pat `matchesRepo` name, enabled]
-        ExpireRepo pat -> [Expire name | pat `matchesRepo` name]
+          maybeChange pat matchesRepo enabled (Disable name)
+        ExpireRepo pat ->
+          maybeChange pat matchesRepo True (const (Expire name))
+        ClearExpires -> Just UnExpire
         DeleteRepo pat ->
-          if pat `matchesRepo` name
-          then if enabled
-               then error' $ "disable repo before deleting: " ++ name
-               else [Delete file]
-          else []
+          maybeChange pat matchesRepo
+          (not enabled || error ("disable repo before deleting: " ++ name))
+          (Delete file)
         Specific change ->
           let substr =  repoSubstr change in
             if change `elem`
                [EnableModular,EnableTesting,EnableDebuginfo,EnableSource]
-            then [Enable name | substr `isInfixOf` name, not enabled,
-                  repoStatus acc (removeInfix substr name) True]
-            else [Disable name | substr `isInfixOf` name, enabled]
+            then
+              maybeChange substr
+              (\p n -> p `isInfixOf` n &&
+                       repoStatus acc (removeInfix substr name))
+              (not enabled) (Enable name)
+            else
+              maybeChange substr isInfixOf enabled (Disable name)
+      where
+        maybeChange :: String -> (String -> String -> Bool) -> Bool
+                    -> (Bool -> ChangeEnable) -> Maybe ChangeEnable
+        maybeChange pat matcher state change =
+          if pat `matcher` name
+          then
+            if state
+            then Just $ change True
+            else
+              if isGlob pat
+              then Nothing
+              else Just $ change False
+          else Nothing
 
-    repoStatus :: [ChangeEnable] -> String -> Bool -> Bool
-    repoStatus acc repo state =
+    repoStatus :: [ChangeEnable] -> String -> Bool
+    repoStatus acc repo =
       case lookup repo repostates of
         Just (enabled,_) ->
-          enabled == state || Enable repo `elem` acc
+          enabled || Enable repo True `elem` acc
         Nothing -> False
 
+    isGlob :: String -> Bool
+    isGlob pat = any (`elem` pat) "*?["
+
     matchesRepo :: String -> String -> Bool
     matchesRepo "" = error' "empty repo pattern"
-    matchesRepo pat =
-      if exact
-      then (pat ==)
-      else
-        case ('^' == head pat,'$' == last pat) of
-          (True,True) -> (init (tail pat) ==)
-          (True,False) -> (tail pat `isPrefixOf`)
-          (False,True) -> (init pat `isSuffixOf`)
-          _ -> (pat `isInfixOf`)
+    matchesRepo pat
+      | isGlob pat = (match . compile) $
+        if exact
+        then pat
+        else
+          case ('^' == head pat,'$' == last pat) of
+            (True,True) -> init (tail pat)
+            (True,False) -> tail pat ++ ['*' | last pat /= '*']
+            (False,True) -> ['*' | head pat /= '*'] ++ init pat
+            _ -> ['*' | head pat /= '*'] ++ pat ++ ['*' | last pat /= '*']
+      | exact = (pat ==)
+      | otherwise =
+          case ('^' == head pat,'$' == last pat) of
+            (True,True) -> (init (tail pat) ==)
+            (True,False) -> (tail pat `isPrefixOf`)
+            (False,True) -> (init pat `isSuffixOf`)
+            _ -> (pat `isInfixOf`)
 
 readRepos :: FilePath -> IO [RepoState]
 readRepos file =
diff --git a/test/tests.hs b/test/tests.hs
new file mode 100644
--- /dev/null
+++ b/test/tests.hs
@@ -0,0 +1,25 @@
+import SimpleCmd
+import System.IO
+
+program :: [String] -> IO ()
+program test = do
+  cmdLog "dnf-repo" test
+  putStrLn ""
+
+tests :: [[String]]
+tests =
+  [["-t"]
+  ,["-m"]
+  ,["-e", "copr"]
+  ,["-e", "openh264"]
+  ,["-e", "rawhide"]
+  ,["-d", "update"]
+  ,["--enable-source"]
+  ,["-d", "*", "--enable-source"]
+  ]
+
+main :: IO ()
+main = do
+  hSetBuffering stdout NoBuffering
+  mapM_ program tests
+  putStrLn $ "\n" ++ show (length tests) ++ " command tests run"
