diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Revision history for MiniCI
 
+## 0.1.5 -- 2025-03-20
+
+* Accept job file path on command line
+* Added `checkout` command
+* Reference and checkout other repositories from job file
+* Accept names of jobs to run as command-line arguments
+
 ## 0.1.4 -- 2025-02-04
 
 * Fix invocation of `minici run` without arguments
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -59,16 +59,18 @@
 
 To run jobs for commits that are in local `<branch>`, but not yet in its upstream:
 ```
-minici run <branch>
-```
-or:
-```
 minici run --since-upstream=<branch>
 ```
 
 For current branch, the name can be omitted:
 ```
 minici run
+```
+
+To run selected jobs with the current working tree, including uncommitted
+changes, list the job names on command line:
+```
+minici run <job name> [<job name> ...]
 ```
 
 To watch changes on given `<branch>` and run jobs for each new commit:
diff --git a/minici.cabal b/minici.cabal
--- a/minici.cabal
+++ b/minici.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               minici
-version:            0.1.4
+version:            0.1.5
 synopsis:           Minimalist CI framework to run checks on local machine
 description:
     Runs defined jobs, for example to build and test a project, for each git
@@ -48,8 +48,10 @@
 
     other-modules:
         Command
+        Command.Checkout
         Command.Run
         Config
+        Eval
         Job
         Job.Types
         Paths_minici
@@ -100,7 +102,7 @@
         temporary ^>= { 1.3 },
         text ^>= { 1.2, 2.0, 2.1 },
         th-compat ^>= { 0.1 },
-        unix ^>= { 2.7.3, 2.8.6 },
+        unix ^>= { 2.7.2, 2.8.4 },
 
     hs-source-dirs:   src
     default-language: Haskell2010
diff --git a/src/Command.hs b/src/Command.hs
--- a/src/Command.hs
+++ b/src/Command.hs
@@ -6,34 +6,45 @@
     CommandArgumentsType(..),
 
     CommandExec(..),
+    tfail,
     CommandInput(..),
     getCommonOptions,
     getConfigPath,
     getConfig,
+    getRepo, getDefaultRepo, tryGetDefaultRepo,
+    getEvalInput,
     getTerminalOutput,
+    getStorageDir,
 ) where
 
+import Control.Monad.Catch
 import Control.Monad.Except
 import Control.Monad.Reader
 
 import Data.Kind
 import Data.Text (Text)
 import Data.Text qualified as T
+import Data.Text.IO qualified as T
 
 import System.Console.GetOpt
 import System.Exit
+import System.FilePath
 import System.IO
 
 import Config
+import Eval
+import Repo
 import Terminal
 
 data CommonOptions = CommonOptions
     { optJobs :: Int
+    , optRepo :: [ DeclaredRepo ]
     }
 
 defaultCommonOptions :: CommonOptions
 defaultCommonOptions = CommonOptions
     { optJobs = 2
+    , optRepo = []
     }
 
 class CommandArgumentsType (CommandArguments c) => Command c where
@@ -77,33 +88,66 @@
 
 
 newtype CommandExec a = CommandExec (ReaderT CommandInput IO a)
-    deriving (Functor, Applicative, Monad, MonadIO)
+    deriving (Functor, Applicative, Monad, MonadIO, MonadThrow, MonadCatch, MonadMask)
 
+instance MonadFail CommandExec where
+    fail = tfail . T.pack
+
+tfail :: Text -> CommandExec a
+tfail err = liftIO $ do
+    T.hPutStrLn stderr err
+    exitFailure
+
 data CommandInput = CommandInput
     { ciOptions :: CommonOptions
     , ciConfigPath :: Maybe FilePath
     , ciConfig :: Either String Config
+    , ciContainingRepo :: Maybe Repo
+    , ciOtherRepos :: [ ( RepoName, Repo ) ]
     , ciTerminalOutput :: TerminalOutput
+    , ciStorageDir :: Maybe FilePath
     }
 
 getCommonOptions :: CommandExec CommonOptions
 getCommonOptions = CommandExec (asks ciOptions)
 
 getConfigPath :: CommandExec FilePath
-getConfigPath = CommandExec $ do
-    asks ciConfigPath >>= \case
-        Nothing -> liftIO $ do
-            hPutStrLn stderr "no config file found"
-            exitFailure
+getConfigPath = do
+    CommandExec (asks ciConfigPath) >>= \case
+        Nothing -> tfail $ "no job file found"
         Just path -> return path
 
 getConfig :: CommandExec Config
-getConfig = CommandExec $ do
-    asks ciConfig >>= \case
-        Left err -> liftIO $ do
-            hPutStrLn stderr err
-            exitFailure
+getConfig = do
+    CommandExec (asks ciConfig) >>= \case
+        Left err -> fail err
         Right config -> return config
 
+getRepo :: RepoName -> CommandExec Repo
+getRepo name = do
+    CommandExec (asks (lookup name . ciOtherRepos)) >>= \case
+        Just repo -> return repo
+        Nothing -> tfail $ "repo `" <> textRepoName name <> "' not declared"
+
+getDefaultRepo :: CommandExec Repo
+getDefaultRepo = do
+    tryGetDefaultRepo >>= \case
+        Just repo -> return repo
+        Nothing -> tfail $ "no default repo"
+
+tryGetDefaultRepo :: CommandExec (Maybe Repo)
+tryGetDefaultRepo = CommandExec $ asks ciContainingRepo
+
+getEvalInput :: CommandExec EvalInput
+getEvalInput = CommandExec $ do
+    eiContainingRepo <- asks ciContainingRepo
+    eiOtherRepos <- asks ciOtherRepos
+    return EvalInput {..}
+
 getTerminalOutput :: CommandExec TerminalOutput
 getTerminalOutput = CommandExec (asks ciTerminalOutput)
+
+getStorageDir :: CommandExec FilePath
+getStorageDir = CommandExec (asks ciStorageDir) >>= \case
+    Just dir -> return dir
+    Nothing -> ((</> ".minici") . takeDirectory) <$> getConfigPath
diff --git a/src/Command/Checkout.hs b/src/Command/Checkout.hs
new file mode 100644
--- /dev/null
+++ b/src/Command/Checkout.hs
@@ -0,0 +1,58 @@
+module Command.Checkout (
+    CheckoutCommand,
+) where
+
+import Data.Maybe
+import Data.Text (Text)
+import Data.Text qualified as T
+
+import System.Console.GetOpt
+
+import Command
+import Repo
+
+
+data CheckoutCommand = CheckoutCommand CheckoutOptions (Maybe RepoName) (Maybe Text)
+
+data CheckoutOptions = CheckoutOptions
+    { coDestination :: Maybe FilePath
+    , coSubtree :: Maybe FilePath
+    }
+
+instance Command CheckoutCommand where
+    commandName _ = "checkout"
+    commandDescription _ = "Checkout (part of) a given repository"
+
+    type CommandArguments CheckoutCommand = [ Text ]
+
+    commandUsage _ = T.pack $ unlines $
+        [ "Usage: minici checkout [<repo> [<revision>]] [<option>...]"
+        ]
+
+    type CommandOptions CheckoutCommand = CheckoutOptions
+    defaultCommandOptions _ = CheckoutOptions
+        { coDestination = Nothing
+        , coSubtree = Nothing
+        }
+
+    commandOptions _ =
+        [ Option [] [ "dest" ]
+            (ReqArg (\val opts -> opts { coDestination = Just val }) "<path>")
+            "destination path"
+        , Option [] [ "subtree" ]
+            (ReqArg (\val opts -> opts { coSubtree = Just val }) "<path>")
+            "repository subtree to checkout"
+        ]
+
+    commandInit _ co args = CheckoutCommand co
+        (RepoName <$> listToMaybe args)
+        (listToMaybe $ drop 1 args)
+    commandExec = cmdCheckout
+
+cmdCheckout :: CheckoutCommand -> CommandExec ()
+cmdCheckout (CheckoutCommand CheckoutOptions {..} name mbrev) = do
+    repo <- maybe getDefaultRepo getRepo name
+    mbCommit <- sequence $ fmap (readCommit repo) mbrev
+    root <- getCommitTree =<< maybe (createWipCommit repo) return mbCommit
+    tree <- maybe return (getSubtree mbCommit) coSubtree $ root
+    checkoutAt tree $ maybe "." id coDestination
diff --git a/src/Command/Run.hs b/src/Command/Run.hs
--- a/src/Command/Run.hs
+++ b/src/Command/Run.hs
@@ -6,23 +6,21 @@
 import Control.Concurrent.STM
 import Control.Exception
 import Control.Monad
-import Control.Monad.Reader
+import Control.Monad.IO.Class
 
+import Data.Either
 import Data.List
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Text.IO qualified as T
 
 import System.Console.GetOpt
-import System.Directory
-import System.Exit
-import System.FilePath
 import System.FilePath.Glob
 import System.IO
-import System.Process
 
 import Command
 import Config
+import Eval
 import Job
 import Repo
 import Terminal
@@ -46,8 +44,8 @@
     commandUsage _ = T.pack $ unlines $
         [ "Usage: minici run"
         , "         run jobs for commits on current branch not yet in upstream branch"
-        , "   or: minici run [--since-upstream=]<ref>"
-        , "         run jobs for commits on <ref> not yet in its upstream ref"
+        , "   or: minici run <job>..."
+        , "         run jobs specified on the command line"
         , "   or: minici run [--range=]<commit>..<commit>"
         , "         run jobs for commits in given range"
         , "   or: minici run <option>..."
@@ -83,6 +81,14 @@
 
 data JobSource = JobSource (TMVar (Maybe ( [ JobSet ], JobSource )))
 
+emptyJobSource :: MonadIO m => m JobSource
+emptyJobSource = JobSource <$> liftIO (newTMVarIO Nothing)
+
+oneshotJobSource :: MonadIO m => [ JobSet ] -> m JobSource
+oneshotJobSource jobsets = do
+    next <- emptyJobSource
+    JobSource <$> liftIO (newTMVarIO (Just ( jobsets, next )))
+
 takeJobSource :: JobSource -> STM (Maybe ( [ JobSet ], JobSource ))
 takeJobSource (JobSource tmvar) = takeTMVar tmvar
 
@@ -114,15 +120,29 @@
             Just (Just ( jobsets, x' )) -> return ( jobsets, x' : xs )
 
 
-rangeSource :: Repo -> Text -> Text -> IO JobSource
-rangeSource repo base tip = do
+argumentJobSource :: [ JobName ] -> CommandExec JobSource
+argumentJobSource [] = emptyJobSource
+argumentJobSource names = do
+    config <- getConfig
+    einput <- getEvalInput
+    jobsetJobsEither <- fmap Right $ forM names $ \name ->
+        case find ((name ==) . jobName) (configJobs config) of
+            Just job -> return job
+            Nothing -> tfail $ "job `" <> textJobName name <> "' not found"
+    jobsetCommit <- sequence . fmap createWipCommit =<< tryGetDefaultRepo
+    oneshotJobSource [ evalJobSet einput JobSet {..} ]
+
+rangeSource :: Text -> Text -> CommandExec JobSource
+rangeSource base tip = do
+    repo <- getDefaultRepo
+    einput <- getEvalInput
     commits <- listCommits repo (base <> ".." <> tip)
-    jobsets <- mapM loadJobSetForCommit commits
-    next <- JobSource <$> newTMVarIO Nothing
-    JobSource <$> newTMVarIO (Just ( jobsets, next ))
+    oneshotJobSource . map (evalJobSet einput) =<< mapM loadJobSetForCommit commits
 
-watchBranchSource :: Repo -> Text -> IO JobSource
-watchBranchSource repo branch = do
+watchBranchSource :: Text -> CommandExec JobSource
+watchBranchSource branch = do
+    repo <- getDefaultRepo
+    einput <- getEvalInput
     getCurrentTip <- watchBranch repo branch
     let go prev tmvar = do
             cur <- atomically $ do
@@ -133,91 +153,86 @@
                     Nothing -> retry
 
             commits <- listCommits repo (textCommitId (commitId prev) <> ".." <> textCommitId (commitId cur))
-            jobsets <- mapM loadJobSetForCommit commits
+            jobsets <- map (evalJobSet einput) <$> mapM loadJobSetForCommit commits
             nextvar <- newEmptyTMVarIO
             atomically $ putTMVar tmvar $ Just ( jobsets, JobSource nextvar )
             go cur nextvar
 
-    tmvar <- newEmptyTMVarIO
-    atomically getCurrentTip >>= \case
-        Just commit -> 
-            void $ forkIO $ go commit tmvar
-        Nothing -> do
-            T.hPutStrLn stderr $ "Branch `" <> branch <> "' not found"
-            atomically $ putTMVar tmvar Nothing
-    return $ JobSource tmvar
+    liftIO $ do
+        tmvar <- newEmptyTMVarIO
+        atomically getCurrentTip >>= \case
+            Just commit ->
+                void $ forkIO $ go commit tmvar
+            Nothing -> do
+                T.hPutStrLn stderr $ "Branch `" <> branch <> "' not found"
+                atomically $ putTMVar tmvar Nothing
+        return $ JobSource tmvar
 
-watchTagSource :: Repo -> Pattern -> IO JobSource
-watchTagSource repo pat = do
-    chan <- watchTags repo
+watchTagSource :: Pattern -> CommandExec JobSource
+watchTagSource pat = do
+    chan <- watchTags =<< getDefaultRepo
+    einput <- getEvalInput
 
     let go tmvar = do
             tag <- atomically $ readTChan chan
             if match pat $ T.unpack $ tagTag tag
               then do
-                jobset <- loadJobSetForCommit $ tagObject tag
+                jobset <- evalJobSet einput <$> loadJobSetForCommit (tagObject tag)
                 nextvar <- newEmptyTMVarIO
                 atomically $ putTMVar tmvar $ Just ( [ jobset ], JobSource nextvar )
                 go nextvar
               else do
                 go tmvar
 
-    tmvar <- newEmptyTMVarIO
-    void $ forkIO $ go tmvar
-    return $ JobSource tmvar
+    liftIO $ do
+        tmvar <- newEmptyTMVarIO
+        void $ forkIO $ go tmvar
+        return $ JobSource tmvar
 
 cmdRun :: RunCommand -> CommandExec ()
 cmdRun (RunCommand RunOptions {..} args) = do
     CommonOptions {..} <- getCommonOptions
     tout <- getTerminalOutput
-    configPath <- getConfigPath
-    let baseDir = takeDirectory configPath
+    storageDir <- getStorageDir
 
-    liftIO $ do
-        repo <- openRepo baseDir >>= \case
-            Just repo -> return repo
-            Nothing -> do
-                absPath <- makeAbsolute baseDir
-                T.hPutStrLn stderr $ "No repository found at `" <> T.pack absPath <> "'"
-                exitFailure
+    ( rangeOptions, jobOptions ) <- partitionEithers . concat <$> sequence
+        [ forM roRanges $ \range -> case T.splitOn ".." range of
+            [ base, tip ] -> return $ Left ( Just base, tip )
+            _ -> tfail $ "invalid range: " <> range
+        , forM roSinceUpstream $ return . Left . ( Nothing, )
+        , forM args $ \arg -> case T.splitOn ".." arg of
+            [ base, tip ] -> return $ Left ( Just base, tip )
+            [ _ ] -> do
+                config <- getConfig
+                if any ((JobName arg ==) . jobName) (configJobs config)
+                  then return $ Right $ JobName arg
+                  else do
+                    liftIO $ T.hPutStrLn stderr $ "standalone `" <> arg <> "' argument deprecated, use `--since-upstream=" <> arg <> "' instead"
+                    return $ Left ( Nothing, arg )
+            _ -> tfail $ "invalid argument: " <> arg
+        ]
 
-        rangeOptions <- concat <$> sequence
-            [ forM roRanges $ \range -> case T.splitOn ".." range of
-                [ base, tip ] -> return ( Just base, tip )
-                _ -> do
-                    T.hPutStrLn stderr $ "Invalid range: " <> range
-                    exitFailure
-            , forM roSinceUpstream $ return . ( Nothing, )
-            , forM args $ \arg -> case T.splitOn ".." arg of
-                [ base, tip ] -> return ( Just base, tip )
-                [ ref ] -> return ( Nothing, ref )
-                _ -> do
-                    T.hPutStrLn stderr $ "Invalid argument: " <> arg
-                    exitFailure
-            ]
+    argumentJobs <- argumentJobSource jobOptions
 
-        let rangeOptions'
-                | null rangeOptions, null roNewCommitsOn, null roNewTags = [ ( Nothing, "HEAD" ) ]
-                | otherwise = rangeOptions
+    let rangeOptions'
+            | null rangeOptions, null roNewCommitsOn, null roNewTags, null jobOptions = [ ( Nothing, "HEAD" ) ]
+            | otherwise = rangeOptions
 
-        ranges <- forM rangeOptions' $ \( mbBase, paramTip ) -> do
-            ( base, tip ) <- case mbBase of
-                Just base -> return ( base, paramTip )
-                Nothing -> liftIO $ do
-                    [ deref ] <- readProcessWithExitCode "git" [ "symbolic-ref", "--quiet", T.unpack paramTip ] "" >>= \case
-                        ( ExitSuccess, out, _ ) -> return $ lines out
-                        ( _, _, _ ) -> return [ T.unpack paramTip ]
-                    [ _, tip ] : _ <- fmap words . lines <$> readProcess "git" [ "show-ref", deref ] ""
-                    [ base ] <- lines <$> readProcess "git" [ "for-each-ref", "--format=%(upstream)", tip ] ""
-                    return ( T.pack base, T.pack tip )
-            rangeSource repo base tip
+    ranges <- forM rangeOptions' $ \( mbBase, paramTip ) -> do
+        ( base, tip ) <- case mbBase of
+            Just base -> return ( base, paramTip )
+            Nothing -> do
+                Just base <- flip findUpstreamRef paramTip =<< getDefaultRepo
+                return ( base, paramTip )
+        rangeSource base tip
 
-        branches <- mapM (watchBranchSource repo) roNewCommitsOn
-        tags <- mapM (watchTagSource repo) roNewTags
+    branches <- mapM watchBranchSource roNewCommitsOn
+    tags <- mapM watchTagSource roNewTags
 
-        mngr <- newJobManager (baseDir </> ".minici") optJobs
+    liftIO $ do
+        mngr <- newJobManager storageDir optJobs
 
-        source <- mergeSources $ concat [ ranges, branches, tags ]
+        source <- mergeSources $ concat [ [ argumentJobs ], ranges, branches, tags ]
         headerLine <- newLine tout ""
 
         threadCount <- newTVarIO (0 :: Int)
@@ -238,12 +253,12 @@
                         map ((" " <>) . fitToLength 7 . textJobName) names
 
                 let commit = jobsetCommit jobset
-                    shortCid = T.pack $ take 7 $ showCommitId $ commitId commit
-                shortDesc <- fitToLength 50 <$> getCommitTitle commit
+                    shortCid = T.pack $ take 7 $ maybe (repeat ' ') (showCommitId . commitId) commit
+                shortDesc <- fitToLength 50 <$> maybe (return "") getCommitTitle commit
 
                 case jobsetJobsEither jobset of
                     Right jobs -> do
-                        outs <- runJobs mngr commit jobs
+                        outs <- runJobs mngr tout commit jobs
                         let findJob name = snd <$> find ((name ==) . jobName . fst) outs
                         line <- newLine tout ""
                         mask $ \restore -> do
@@ -273,7 +288,7 @@
     JobWaiting uses -> "\ESC[94m~" <> fitToLength 6 (T.intercalate "," (map textJobName uses)) <> "\ESC[0m"
     JobSkipped      ->  "\ESC[0m-\ESC[0m      "
     JobRunning      -> "\ESC[96m" <> (if blink then "*" else "•") <> "\ESC[0m      "
-    JobError _      -> "\ESC[91m!!\ESC[0m     "
+    JobError fnote  -> "\ESC[91m" <> fitToLength 7 ("!! [" <> T.pack (show (footnoteNumber fnote)) <> "]") <> "\ESC[0m"
     JobFailed       -> "\ESC[91m✗\ESC[0m      "
     JobCancelled    ->  "\ESC[0mC\ESC[0m      "
     JobDone _       -> "\ESC[92m✓\ESC[0m      "
diff --git a/src/Config.hs b/src/Config.hs
--- a/src/Config.hs
+++ b/src/Config.hs
@@ -9,8 +9,10 @@
 
 import Control.Monad
 import Control.Monad.Combinators
+import Control.Monad.IO.Class
 
 import Data.ByteString.Lazy qualified as BS
+import Data.Either
 import Data.List
 import Data.Map qualified as M
 import Data.Maybe
@@ -21,6 +23,7 @@
 
 import System.Directory
 import System.FilePath
+import System.FilePath.Glob
 import System.Process
 
 import Job.Types
@@ -32,17 +35,20 @@
 
 
 data Config = Config
-    { configJobs :: [Job]
+    { configJobs :: [ DeclaredJob ]
+    , configRepos :: [ DeclaredRepo ]
     }
 
 instance Semigroup Config where
     a <> b = Config
         { configJobs = configJobs a ++ configJobs b
+        , configRepos = configRepos a ++ configRepos b
         }
 
 instance Monoid Config where
     mempty = Config
         { configJobs = []
+        , configRepos = []
         }
 
 instance FromYAML Config where
@@ -51,22 +57,49 @@
                            (Mapping pos _ _, _) -> pos
                            (Sequence pos _ _, _) -> pos
                            (Anchor pos _ _, _) -> pos
-        jobs <- fmap catMaybes $ forM (sortBy (comparing $ posLine . getpos) $ M.assocs m) $ \case
-            (Scalar _ (SStr tag), node) | ["job", name] <- T.words tag -> do
-                Just <$> parseJob name node
-            _ -> return Nothing
-        return $ Config jobs
+        foldM go mempty $ sortBy (comparing $ posLine . getpos) $ M.assocs m
+      where
+        go config = \case
+            (Scalar _ (SStr tag), node)
+                | [ "job", name ] <- T.words tag -> do
+                    job <- parseJob name node
+                    return $ config { configJobs = configJobs config ++ [ job ] }
+                | [ "repo", name ] <- T.words tag -> do
+                    repo <- parseRepo name node
+                    return $ config { configRepos = configRepos config ++ [ repo ] }
+            _ -> return config
 
-parseJob :: Text -> Node Pos -> Parser Job
-parseJob name node = flip (withMap "Job") node $ \j -> Job
-    <$> pure (JobName name)
-    <*> choice
+parseJob :: Text -> Node Pos -> Parser DeclaredJob
+parseJob name node = flip (withMap "Job") node $ \j -> do
+    let jobName = JobName name
+    ( jobContainingCheckout, jobOtherCheckout ) <- partitionEithers <$> choice
+        [ parseSingleCheckout =<< j .: "checkout"
+        , parseMultipleCheckouts =<< j .: "checkout"
+        , withNull "no checkout" (return []) =<< j .: "checkout"
+        , return [ Left $ JobCheckout Nothing Nothing ]
+        ]
+    jobRecipe <- choice
         [ cabalJob =<< j .: "cabal"
         , shellJob =<< j .: "shell"
         ]
-    <*> parseArtifacts j
-    <*> (maybe (return []) parseUses =<< j .:? "uses")
+    jobArtifacts <- parseArtifacts j
+    jobUses <- maybe (return []) parseUses =<< j .:? "uses"
+    return Job {..}
 
+parseSingleCheckout :: Node Pos -> Parser [ Either JobCheckout ( JobRepo Declared, Maybe Text, JobCheckout ) ]
+parseSingleCheckout = withMap "checkout definition" $ \m -> do
+    jcSubtree <- fmap T.unpack <$> m .:? "subtree"
+    jcDestination <- fmap T.unpack <$> m .:? "dest"
+    let checkout = JobCheckout {..}
+    m .:? "repo" >>= \case
+        Nothing -> return [ Left checkout ]
+        Just name -> do
+            revision <- m .:? "revision"
+            return [ Right ( DeclaredJobRepo (RepoName name), revision, checkout ) ]
+
+parseMultipleCheckouts :: Node Pos -> Parser [ Either JobCheckout ( JobRepo Declared, Maybe Text, JobCheckout ) ]
+parseMultipleCheckouts = withSeq "checkout definitions" $ fmap concat . mapM parseSingleCheckout
+
 cabalJob :: Node Pos -> Parser [CreateProcess]
 cabalJob = withMap "cabal job" $ \m -> do
     ghcOptions <- m .:? "ghc-options" >>= \case
@@ -80,7 +113,7 @@
 shellJob = withSeq "shell commands" $ \xs -> do
     fmap (map shell) $ forM xs $ withStr "shell command" $ return . T.unpack
 
-parseArtifacts :: Mapping Pos -> Parser [(ArtifactName, CreateProcess)]
+parseArtifacts :: Mapping Pos -> Parser [ ( ArtifactName, Pattern ) ]
 parseArtifacts m = do
     fmap catMaybes $ forM (M.assocs m) $ \case
         (Scalar _ (SStr tag), node) | ["artifact", name] <- T.words tag -> do
@@ -88,8 +121,8 @@
         _ -> return Nothing
   where
     parseArtifact name = withMap "Artifact" $ \am -> do
-        path <- am .: "path"
-        return (ArtifactName name, proc "echo" [ T.unpack path ])
+        pat <- compile . T.unpack <$> am .: "path"
+        return ( ArtifactName name, pat )
 
 parseUses :: Node Pos -> Parser [(JobName, ArtifactName)]
 parseUses = withSeq "Uses list" $ mapM $
@@ -97,6 +130,13 @@
         [job, art] <- return $ T.split (== '.') text
         return (JobName job, ArtifactName art)
 
+
+parseRepo :: Text -> Node Pos -> Parser DeclaredRepo
+parseRepo name node = flip (withMap "Repo") node $ \r -> DeclaredRepo
+    <$> pure (RepoName name)
+    <*> (T.unpack <$> r .: "path")
+
+
 findConfig :: IO (Maybe FilePath)
 findConfig = go "."
   where
@@ -117,16 +157,16 @@
             Left $ prettyPosWithSource pos contents err
         Right conf -> Right conf
 
-loadConfigForCommit :: Commit -> IO (Either String Config)
+loadConfigForCommit :: MonadIO m => Commit -> m (Either String Config)
 loadConfigForCommit commit = do
     readCommittedFile commit configFileName >>= return . \case
         Just content -> either (\_ -> Left $ "failed to parse " <> configFileName) Right $ parseConfig content
         Nothing -> Left $ configFileName <> " not found"
 
-loadJobSetForCommit :: Commit -> IO JobSet
+loadJobSetForCommit :: MonadIO m => Commit -> m DeclaredJobSet
 loadJobSetForCommit commit = toJobSet <$> loadConfigForCommit commit
   where
     toJobSet configEither = JobSet
-        { jobsetCommit = commit
+        { jobsetCommit = Just commit
         , jobsetJobsEither = fmap configJobs configEither
         }
diff --git a/src/Eval.hs b/src/Eval.hs
new file mode 100644
--- /dev/null
+++ b/src/Eval.hs
@@ -0,0 +1,54 @@
+module Eval (
+    EvalInput(..),
+    EvalError(..), textEvalError,
+
+    evalJob,
+    evalJobSet,
+) where
+
+import Control.Monad
+import Control.Monad.Except
+
+import Data.Bifunctor
+import Data.Text (Text)
+import Data.Text qualified as T
+
+import Job.Types
+import Repo
+
+data EvalInput = EvalInput
+    { eiContainingRepo :: Maybe Repo
+    , eiOtherRepos :: [ ( RepoName, Repo ) ]
+    }
+
+data EvalError
+    = OtherEvalError Text
+
+textEvalError :: EvalError -> Text
+textEvalError (OtherEvalError text) = text
+
+evalJob :: EvalInput -> DeclaredJob -> Except EvalError Job
+evalJob EvalInput {..} decl = do
+    otherCheckout <- forM (jobOtherCheckout decl) $ \( DeclaredJobRepo name, revision, checkout ) -> do
+        repo <- maybe (throwError $ OtherEvalError $ "repo `" <> textRepoName name <> "' not defined") return $
+            lookup name eiOtherRepos
+        return ( EvaluatedJobRepo repo, revision, checkout )
+    return Job
+        { jobName = jobName decl
+        , jobContainingCheckout = jobContainingCheckout decl
+        , jobOtherCheckout = otherCheckout
+        , jobRecipe = jobRecipe decl
+        , jobArtifacts = jobArtifacts decl
+        , jobUses = jobUses decl
+        }
+
+evalJobSet :: EvalInput -> DeclaredJobSet -> JobSet
+evalJobSet ei decl = do
+    JobSet
+        { jobsetCommit = jobsetCommit decl
+        , jobsetJobsEither = join $
+            fmap (sequence . map (runExceptStr . evalJob ei)) $
+            jobsetJobsEither decl
+        }
+  where
+    runExceptStr = first (T.unpack . textEvalError) . runExcept
diff --git a/src/Job.hs b/src/Job.hs
--- a/src/Job.hs
+++ b/src/Job.hs
@@ -1,6 +1,6 @@
 module Job (
-    Job(..),
-    JobSet(..), jobsetJobs,
+    Job, DeclaredJob, Job'(..),
+    JobSet, DeclaredJobSet, JobSet'(..), jobsetJobs,
     JobOutput(..),
     JobName(..), stringJobName, textJobName,
     ArtifactName(..),
@@ -21,6 +21,7 @@
 import Data.List
 import Data.Map (Map)
 import Data.Map qualified as M
+import Data.Maybe
 import Data.Set (Set)
 import Data.Set qualified as S
 import Data.Text (Text)
@@ -30,6 +31,7 @@
 import System.Directory
 import System.Exit
 import System.FilePath
+import System.FilePath.Glob
 import System.IO
 import System.IO.Temp
 import System.Posix.Signals
@@ -37,6 +39,7 @@
 
 import Job.Types
 import Repo
+import Terminal
 
 
 data JobOutput = JobOutput
@@ -58,7 +61,7 @@
                  | JobWaiting [JobName]
                  | JobRunning
                  | JobSkipped
-                 | JobError Text
+                 | JobError TerminalFootnote
                  | JobFailed
                  | JobCancelled
                  | JobDone a
@@ -86,7 +89,7 @@
     JobWaiting _ -> "waiting"
     JobRunning -> "running"
     JobSkipped -> "skipped"
-    JobError err -> "error\n" <> err
+    JobError err -> "error\n" <> footnoteText err
     JobFailed -> "failed"
     JobCancelled -> "cancelled"
     JobDone _ -> "done"
@@ -178,12 +181,12 @@
                             writeTVar jmRunningTasks . M.delete tid =<< readTVar jmRunningTasks
 
 
-runJobs :: JobManager -> Commit -> [ Job ] -> IO [ ( Job, TVar (JobStatus JobOutput) ) ]
-runJobs mngr@JobManager {..} commit jobs = do
-    treeId <- getTreeId commit
+runJobs :: JobManager -> TerminalOutput -> Maybe Commit -> [ Job ] -> IO [ ( Job, TVar (JobStatus JobOutput) ) ]
+runJobs mngr@JobManager {..} tout commit jobs = do
+    tree <- sequence $ fmap getCommitTree commit
     results <- atomically $ do
         forM jobs $ \job -> do
-            let jid = JobId [ JobIdTree treeId, JobIdName (jobName job) ]
+            let jid = JobId $ concat [ JobIdTree . treeId <$> maybeToList tree, [ JobIdName (jobName job) ] ]
             tid <- reserveTaskId mngr
             managed <- readTVar jmJobs
             ( job, tid, ) <$> case M.lookup jid managed of
@@ -196,9 +199,12 @@
                     return statusVar
 
     forM_ results $ \( job, tid, outVar ) -> void $ forkIO $ do
-        let handler e = atomically $ writeTVar outVar $ if
-                | Just JobCancelledException <- fromException e -> JobCancelled
-                | otherwise -> JobError (T.pack $ displayException e)
+        let handler e = if
+                | Just JobCancelledException <- fromException e -> do
+                    atomically $ writeTVar outVar $ JobCancelled
+                | otherwise -> do
+                    footnote <- newFootnote tout $ T.pack $ displayException e
+                    atomically $ writeTVar outVar $ JobError footnote
         handle handler $ do
             res <- runExceptT $ do
                 duplicate <- liftIO $ atomically $ do
@@ -210,7 +216,7 @@
 
                 case duplicate of
                     Nothing -> do
-                        uses <- waitForUsedArtifacts job results outVar
+                        uses <- waitForUsedArtifacts tout job results outVar
                         runManagedJob mngr tid (return JobCancelled) $ do
                             liftIO $ atomically $ writeTVar outVar JobRunning
                             prepareJob jmDataDir commit job $ \checkoutPath jdir -> do
@@ -232,21 +238,18 @@
                                   else wait
                         liftIO wait
 
-            case res of
-                Left (JobError err) -> T.putStrLn err
-                _ -> return ()
-
             atomically $ writeTVar outVar $ either id id res
     return $ map (\( job, _, var ) -> ( job, var )) results
 
 waitForUsedArtifacts :: (MonadIO m, MonadError (JobStatus JobOutput) m) =>
+    TerminalOutput ->
     Job -> [ ( Job, TaskId, TVar (JobStatus JobOutput) ) ] -> TVar (JobStatus JobOutput) -> m [ ArtifactOutput ]
-waitForUsedArtifacts job results outVar = do
+waitForUsedArtifacts tout job results outVar = do
     origState <- liftIO $ atomically $ readTVar outVar
     ujobs <- forM (jobUses job) $ \(ujobName@(JobName tjobName), uartName) -> do
         case find (\( j, _, _ ) -> jobName j == ujobName) results of
             Just ( _, _, var ) -> return ( var, ( ujobName, uartName ))
-            Nothing -> throwError $ JobError $ "Job '" <> tjobName <> "' not found"
+            Nothing -> throwError . JobError =<< liftIO (newFootnote tout $ "Job '" <> tjobName <> "' not found")
 
     let loop prev = do
             ustatuses <- atomically $ do
@@ -265,7 +268,7 @@
         case ustatus of
             JobDone out -> case find ((==uartName) . aoutName) $ outArtifacts out of
                 Just art -> return art
-                Nothing -> throwError $ JobError $ "Artifact '" <> tjobName <> "." <> tartName <> "' not found"
+                Nothing -> throwError . JobError =<< liftIO (newFootnote tout $ "Artifact '" <> tjobName <> "." <> tartName <> "' not found")
             _ -> throwError JobSkipped
 
 updateStatusFile :: MonadIO m => FilePath -> TVar (JobStatus JobOutput) -> m ()
@@ -279,13 +282,29 @@
         T.writeFile path $ textJobStatus status <> "\n"
         when (not (jobStatusFinished status)) $ loop $ Just status
 
-prepareJob :: (MonadIO m, MonadMask m, MonadFail m) => FilePath -> Commit -> Job -> (FilePath -> FilePath -> m a) -> m a
-prepareJob dir commit job inner = do
+prepareJob :: (MonadIO m, MonadMask m, MonadFail m) => FilePath -> Maybe Commit -> Job -> (FilePath -> FilePath -> m a) -> m a
+prepareJob dir mbCommit job inner = do
     withSystemTempDirectory "minici" $ \checkoutPath -> do
-        checkoutAt commit checkoutPath
-        tid <- getTreeId commit
+        jdirCommit <- case mbCommit of
+            Just commit -> do
+                tree <- getCommitTree commit
+                forM_ (jobContainingCheckout job) $ \(JobCheckout mbsub dest) -> do
+                    subtree <- maybe return (getSubtree mbCommit) mbsub $ tree
+                    checkoutAt subtree $ checkoutPath </> fromMaybe "" dest
+                return $ showTreeId (treeId tree) </> stringJobName (jobName job)
+            Nothing -> do
+                when (not $ null $ jobContainingCheckout job) $ do
+                    fail $ "no containing repository, can't do checkout"
+                return $ stringJobName (jobName job)
 
-        let jdir = dir </> "jobs" </> showTreeId tid </> stringJobName (jobName job)
+        jdirOther <- forM (jobOtherCheckout job) $ \( EvaluatedJobRepo repo, revision, JobCheckout mbsub dest ) -> do
+            commit <- readCommit repo $ fromMaybe "HEAD" revision
+            tree <- getCommitTree commit
+            subtree <- maybe return (getSubtree (Just commit)) mbsub $ tree
+            checkoutAt subtree $ checkoutPath </> fromMaybe "" dest
+            return $ showTreeId (treeId tree)
+
+        let jdir = dir </> "jobs" </> jdirCommit </> joinPath jdirOther
         liftIO $ createDirectoryIfMissing True jdir
 
         inner checkoutPath jdir
@@ -312,19 +331,26 @@
                     | fromIntegral n == -sigINT -> throwError JobCancelled
                     | otherwise -> throwError JobFailed
 
-    let adir = jdir </> "artifacts"
-    artifacts <- forM (jobArtifacts job) $ \(name@(ArtifactName tname), pathCmd) -> liftIO $ do
-        [path] <- lines <$> readCreateProcess pathCmd { cwd = Just checkoutPath } ""
-        let target = adir </> T.unpack tname
-        createDirectoryIfMissing True adir
-        copyFile (checkoutPath </> path) target
-        return $ ArtifactOutput
-            { aoutName = name
-            , aoutWorkPath = path
-            , aoutStorePath = target
-            }
+        let adir = jdir </> "artifacts"
+        artifacts <- forM (jobArtifacts job) $ \( name@(ArtifactName tname), pathPattern ) -> do
+            path <- liftIO (globDir1 pathPattern checkoutPath) >>= \case
+                [ path ] -> return path
+                found -> do
+                    liftIO $ hPutStrLn logs $
+                        (if null found then "no file" else "multiple files") <> " found matching pattern `" <>
+                        decompile pathPattern <> "' for artifact `" <> T.unpack tname <> "'"
+                    throwError JobFailed
+            let target = adir </> T.unpack tname </> takeFileName path
+            liftIO $ do
+                createDirectoryIfMissing True $ takeDirectory target
+                copyFile (checkoutPath </> path) target
+            return $ ArtifactOutput
+                { aoutName = name
+                , aoutWorkPath = path
+                , aoutStorePath = target
+                }
 
-    return JobOutput
-        { outName = jobName job
-        , outArtifacts = artifacts
-        }
+        return JobOutput
+            { outName = jobName job
+            , outArtifacts = artifacts
+            }
diff --git a/src/Job/Types.hs b/src/Job/Types.hs
--- a/src/Job/Types.hs
+++ b/src/Job/Types.hs
@@ -3,18 +3,27 @@
 import Data.Text (Text)
 import Data.Text qualified as T
 
+import System.FilePath.Glob
 import System.Process
 
 import Repo
 
 
-data Job = Job
+data Declared
+data Evaluated
+
+data Job' d = Job
     { jobName :: JobName
+    , jobContainingCheckout :: [ JobCheckout ]
+    , jobOtherCheckout :: [ ( JobRepo d, Maybe Text, JobCheckout ) ]
     , jobRecipe :: [ CreateProcess ]
-    , jobArtifacts :: [ ( ArtifactName, CreateProcess ) ]
+    , jobArtifacts :: [ ( ArtifactName, Pattern ) ]
     , jobUses :: [ ( JobName, ArtifactName ) ]
     }
 
+type Job = Job' Evaluated
+type DeclaredJob = Job' Declared
+
 data JobName = JobName Text
     deriving (Eq, Ord, Show)
 
@@ -25,14 +34,27 @@
 textJobName (JobName name) = name
 
 
+data JobRepo d where
+    DeclaredJobRepo :: RepoName -> JobRepo Declared
+    EvaluatedJobRepo :: Repo -> JobRepo Evaluated
+
+data JobCheckout = JobCheckout
+    { jcSubtree :: Maybe FilePath
+    , jcDestination :: Maybe FilePath
+    }
+
+
 data ArtifactName = ArtifactName Text
     deriving (Eq, Ord, Show)
 
 
-data JobSet = JobSet
-    { jobsetCommit :: Commit
-    , jobsetJobsEither :: Either String [ Job ]
+data JobSet' d = JobSet
+    { jobsetCommit :: Maybe Commit
+    , jobsetJobsEither :: Either String [ Job' d ]
     }
+
+type JobSet = JobSet' Evaluated
+type DeclaredJobSet = JobSet' Declared
 
 jobsetJobs :: JobSet -> [ Job ]
 jobsetJobs = either (const []) id . jobsetJobsEither
diff --git a/src/Main.hs b/src/Main.hs
--- a/src/Main.hs
+++ b/src/Main.hs
@@ -6,17 +6,22 @@
 
 import Data.ByteString.Lazy qualified as BL
 import Data.List
+import Data.List.NonEmpty qualified as NE
 import Data.Proxy
 import Data.Text qualified as T
 
 import System.Console.GetOpt
+import System.Directory
 import System.Environment
 import System.Exit
+import System.FilePath
 import System.IO
 
 import Command
+import Command.Checkout
 import Command.Run
 import Config
+import Repo
 import Terminal
 import Version
 
@@ -24,6 +29,7 @@
     { optShowHelp :: Bool
     , optShowVersion :: Bool
     , optCommon :: CommonOptions
+    , optStorage :: Maybe FilePath
     }
 
 defaultCmdlineOptions :: CmdlineOptions
@@ -31,26 +37,42 @@
     { optShowHelp = False
     , optShowVersion = False
     , optCommon = defaultCommonOptions
+    , optStorage = Nothing
     }
 
-options :: [OptDescr (CmdlineOptions -> CmdlineOptions)]
+options :: [ OptDescr (CmdlineOptions -> Except String CmdlineOptions) ]
 options =
-    [ Option ['h'] ["help"]
-        (NoArg $ \opts -> opts { optShowHelp = True })
+    [ Option [ 'h' ] [ "help" ]
+        (NoArg $ \opts -> return opts { optShowHelp = True })
         "show this help and exit"
-    , Option ['V'] ["version"]
-        (NoArg $ \opts -> opts { optShowVersion = True })
+    , Option [ 'V' ] [ "version" ]
+        (NoArg $ \opts -> return opts { optShowVersion = True })
         "show version and exit"
-    , Option ['j'] ["jobs"]
-        (ReqArg (\num opts -> opts { optCommon = (optCommon opts) { optJobs = read num }}) "<num>")
+    , Option [ 'j' ] [ "jobs" ]
+        (ReqArg (\num opts -> return opts { optCommon = (optCommon opts) { optJobs = read num }}) "<num>")
         ("number of jobs to run simultaneously (default " <> show (optJobs defaultCommonOptions) <> ")")
+    , Option [] [ "repo" ]
+        (ReqArg (\value opts ->
+            case span (/= ':') value of
+                ( repo, ':' : path ) -> return opts
+                    { optCommon = (optCommon opts)
+                        { optRepo = DeclaredRepo (RepoName $ T.pack repo) path : optRepo (optCommon opts)
+                        }
+                    }
+                _ -> throwError $ "--repo: invalid value `" <> value <> "'"
+        ) "<repo>:<path>")
+        ("override or declare repo path")
+    , Option [] [ "storage" ]
+        (ReqArg (\value opts -> return opts { optStorage = Just value }) "<path>")
+        "set storage path"
     ]
 
 data SomeCommandType = forall c. Command c => SC (Proxy c)
 
-commands :: [ SomeCommandType ]
+commands :: NE.NonEmpty SomeCommandType
 commands =
-    [ SC $ Proxy @RunCommand
+    ( SC $ Proxy @RunCommand) NE.:|
+    [ SC $ Proxy @CheckoutCommand
     ]
 
 lookupCommand :: String -> Maybe SomeCommandType
@@ -61,25 +83,40 @@
 main :: IO ()
 main = do
     args <- getArgs
-    (opts, cmdargs) <- case getOpt RequireOrder options args of
-        (o, cmdargs, []) -> return (foldl (flip id) defaultCmdlineOptions o, cmdargs)
+    let ( mbConfigPath, args' ) = case args of
+            (path : rest)
+                | any isPathSeparator path -> ( Just path, rest )
+            _ -> ( Nothing, args )
+
+    (opts, cmdargs) <- case getOpt RequireOrder options args' of
+        (os, cmdargs, []) -> do
+            let merge :: ([String], CmdlineOptions) -> (CmdlineOptions -> Except String CmdlineOptions) -> ([String], CmdlineOptions)
+                merge ( errs, o ) f = case runExcept $ f o of
+                    Left err -> ( err : errs, o )
+                    Right o' -> ( errs, o' )
+
+            case foldl merge ( [], defaultCmdlineOptions ) os of
+                ( [], opts ) -> return ( opts , cmdargs )
+                ( errs, _ ) -> do
+                    hPutStrLn stderr $ unlines (reverse errs) <> "Try `minici --help' for more information."
+                    exitFailure
         (_, _, errs) -> do
             hPutStrLn stderr $ concat errs <> "Try `minici --help' for more information."
             exitFailure
 
     when (optShowHelp opts) $ do
-        let header = "Usage: minici [<option>...] <command> [<args>]\n\nCommon options are:"
+        let header = "Usage: minici [<job-file>] [<option>...] <command> [<args>]\n\nCommon options are:"
             commandDesc (SC proxy) = "  " <> padCommand (commandName proxy) <> commandDescription proxy
 
             padTo n str = str <> replicate (n - length str) ' '
             padCommand = padTo (maxCommandNameLength + 3)
             commandNameLength (SC proxy) = length $ commandName proxy
-            maxCommandNameLength = maximum $ map commandNameLength commands
+            maxCommandNameLength = maximum $ fmap commandNameLength commands
 
         putStr $ usageInfo header options <> unlines (
             [ ""
             , "Available commands:"
-            ] ++ map commandDesc commands
+            ] ++ map commandDesc (NE.toList commands)
           )
         exitSuccess
 
@@ -87,8 +124,17 @@
         putStrLn versionLine
         exitSuccess
 
-    (ncmd, cargs) <- case cmdargs of
-        [] -> return (head commands, [])
+    ( configPath, cmdargs' ) <- case ( mbConfigPath, cmdargs ) of
+        ( Just path, _ )
+            -> return ( Just path, cmdargs )
+        ( _, path : rest )
+            | any isPathSeparator path
+            -> return ( Just path, rest )
+        _ -> ( , cmdargs ) <$> findConfig
+
+    ( ncmd, cargs ) <- case cmdargs' of
+        [] -> return ( NE.head commands, [] )
+
         (cname : cargs)
             | Just nc <- lookupCommand cname -> return (nc, cargs)
             | otherwise -> do
@@ -98,7 +144,7 @@
                     ]
                 exitFailure
 
-    runSomeCommand (optCommon opts) ncmd cargs
+    runSomeCommand configPath opts ncmd cargs
 
 data FullCommandOptions c = FullCommandOptions
     { fcoSpecific :: CommandOptions c
@@ -120,8 +166,10 @@
         "show this help and exit"
     ]
 
-runSomeCommand :: CommonOptions -> SomeCommandType -> [ String ] -> IO ()
-runSomeCommand ciOptions (SC tproxy) args = do
+runSomeCommand :: Maybe FilePath -> CmdlineOptions -> SomeCommandType -> [ String ] -> IO ()
+runSomeCommand ciConfigPath gopts (SC tproxy) args = do
+    let ciOptions = optCommon gopts
+        ciStorageDir = optStorage gopts
     let exitWithErrors errs = do
             hPutStrLn stderr $ concat errs <> "Try `minici " <> commandName tproxy <> " --help' for more information."
             exitFailure
@@ -138,12 +186,34 @@
         putStr $ usageInfo (T.unpack $ commandUsage tproxy) (fullCommandOptions tproxy)
         exitSuccess
 
-    ciConfigPath <- findConfig
     ciConfig <- case ciConfigPath of
         Just path -> parseConfig <$> BL.readFile path
-        Nothing -> return $ Left "no config file found"
+        Nothing -> return $ Left "no job file found"
 
     let cmd = commandInit tproxy (fcoSpecific opts) cmdargs
     let CommandExec exec = commandExec cmd
+
+    ciContainingRepo <- maybe (return Nothing) (openRepo . takeDirectory) ciConfigPath
+
+    let openDeclaredRepo dir decl = do
+            let path = dir </> repoPath decl
+            openRepo path >>= \case
+                Just repo -> return ( repoName decl, repo )
+                Nothing -> do
+                    absPath <- makeAbsolute path
+                    hPutStrLn stderr $ "Failed to open repo `" <> showRepoName (repoName decl) <> "' at " <> repoPath decl <> " (" <> absPath <> ")"
+                    exitFailure
+
+    cmdlineRepos <- forM (optRepo ciOptions) (openDeclaredRepo "")
+    configRepos <- case ( ciConfigPath, ciConfig ) of
+        ( Just path, Right config ) ->
+            forM (configRepos config) $ \decl -> do
+                case lookup (repoName decl) cmdlineRepos of
+                    Just repo -> return ( repoName decl, repo )
+                    Nothing -> openDeclaredRepo (takeDirectory path) decl
+        _ -> return []
+
+    let ciOtherRepos = configRepos ++ cmdlineRepos
+
     ciTerminalOutput <- initTerminalOutput
     flip runReaderT CommandInput {..} exec
diff --git a/src/Repo.hs b/src/Repo.hs
--- a/src/Repo.hs
+++ b/src/Repo.hs
@@ -1,19 +1,28 @@
 module Repo (
-    Repo, Commit, commitId,
+    Repo,
+    DeclaredRepo(..),
+    RepoName(..), textRepoName, showRepoName,
+    Commit, commitId,
     CommitId, textCommitId, showCommitId,
+    Tree, treeId, treeRepo,
     TreeId, textTreeId, showTreeId,
     Tag(..),
 
     openRepo,
+    readCommit,
     readBranch,
     readTag,
     listCommits,
+    findUpstreamRef,
 
-    getTreeId,
+    getCommitTree,
     getCommitTitle,
     getCommitMessage,
 
+    getSubtree,
+
     checkoutAt,
+    createWipCommit,
     readCommittedFile,
 
     watchBranch,
@@ -22,8 +31,9 @@
 
 import Control.Concurrent
 import Control.Concurrent.STM
-import Control.Exception
+import Control.Exception (IOException)
 import Control.Monad
+import Control.Monad.Catch
 import Control.Monad.IO.Class
 
 import Data.ByteString (ByteString)
@@ -37,10 +47,11 @@
 import Data.Text qualified as T
 import Data.Text.Encoding
 
-import System.Directory
+import System.Environment
 import System.Exit
 import System.FilePath
 import System.INotify
+import System.IO.Temp
 import System.Process
 
 
@@ -52,6 +63,21 @@
         , gitWatchedBranches :: MVar (Map Text [ TVar (Maybe Commit) ])
         }
 
+data DeclaredRepo = DeclaredRepo
+    { repoName :: RepoName
+    , repoPath :: FilePath
+    }
+
+newtype RepoName = RepoName Text
+    deriving (Eq, Ord)
+
+textRepoName :: RepoName -> Text
+textRepoName (RepoName text) = text
+
+showRepoName :: RepoName -> String
+showRepoName = T.unpack . textRepoName
+
+
 data Commit = Commit
     { commitRepo :: Repo
     , commitId_ :: CommitId
@@ -62,11 +88,16 @@
 commitId = commitId_
 
 data CommitDetails = CommitDetails
-    { commitTreeId :: TreeId
+    { commitTree :: Tree
     , commitTitle :: Text
     , commitMessage :: Text
     }
 
+data Tree = Tree
+    { treeRepo :: Repo
+    , treeId :: TreeId
+    }
+
 data Tag a = Tag
     { tagTag :: Text
     , tagObject :: a
@@ -100,6 +131,12 @@
 showTreeId (TreeId tid) = BC.unpack tid
 
 
+runGitCommand :: MonadIO m => Repo -> [ String ] -> m String
+runGitCommand GitRepo {..} args = liftIO $ do
+    withMVar gitLock $ \_ -> do
+        readProcess "git" (("--git-dir=" <> gitDir) : args) ""
+
+
 openRepo :: FilePath -> IO (Maybe Repo)
 openRepo path = do
     findGitDir >>= \case
@@ -121,14 +158,20 @@
                 Just dir -> return (Just dir)
                 _        -> return Nothing
 
-mkCommit :: Repo -> CommitId -> IO Commit
+mkCommit :: MonadIO m => Repo -> CommitId -> m Commit
 mkCommit commitRepo commitId_ = do
-    commitDetails <- newMVar Nothing
+    commitDetails <- liftIO $ newMVar Nothing
     return $ Commit {..}
 
+readCommit :: (MonadIO m, MonadFail m) => Repo -> Text -> m Commit
+readCommit repo@GitRepo {..} ref = liftIO $ do
+    readProcessWithExitCode "git" [ "--git-dir=" <> gitDir, "rev-parse", "--verify", "--quiet", T.unpack ref <> "^{commit}" ] "" >>= \case
+        ( ExitSuccess, out, _ ) | cid : _ <- lines out -> mkCommit repo (CommitId $ BC.pack cid)
+        _                                              -> fail $ "revision `" <> T.unpack ref <> "' not found in `" <> gitDir <> "'"
+
 readCommitFromFile :: MonadIO m => Repo -> FilePath -> m (Maybe Commit)
 readCommitFromFile repo@GitRepo {..} path = liftIO $ do
-    try @IOException (BC.readFile $ gitDir </> path) >>= \case
+    try @IO @IOException (BC.readFile $ gitDir </> path) >>= \case
         Right content | (cid : _) <- BC.lines content -> do
             Just <$> mkCommit repo (CommitId cid)
         _ -> do
@@ -138,11 +181,10 @@
 readBranch repo branch = readCommitFromFile repo ("refs/heads" </> T.unpack branch)
 
 readTag :: MonadIO m => Repo -> Text -> m (Maybe (Tag Commit))
-readTag repo@GitRepo {..} tag = do
+readTag repo tag = do
     ( infoPart, message ) <-
         fmap (fmap (drop 1) . span (not . null) . lines) $
-        liftIO $ withMVar gitLock $ \_ -> do
-            readProcess "git" [ "--git-dir=" <> gitDir, "cat-file", "tag", T.unpack tag ] ""
+        runGitCommand repo [ "cat-file", "tag", T.unpack tag ]
     let info = map (fmap (drop 1) . span (/= ' ')) infoPart
 
     sequence $ do
@@ -157,16 +199,28 @@
 
 listCommits :: MonadIO m => Repo -> Text -> m [ Commit ]
 listCommits commitRepo range = liftIO $ do
-    out <- readProcess "git" [ "log", "--pretty=%H", "--first-parent", "--reverse", T.unpack range ] ""
+    out <- runGitCommand commitRepo [ "log", "--pretty=%H", "--first-parent", "--reverse", T.unpack range ]
     forM (lines out) $ \cid -> do
         let commitId_ = CommitId (BC.pack cid)
         commitDetails <- newMVar Nothing
         return Commit {..}
 
+findUpstreamRef :: MonadIO m => Repo -> Text -> m (Maybe Text)
+findUpstreamRef repo@GitRepo {..} ref = liftIO $ do
+    deref <- readProcessWithExitCode "git" [ "--git-dir=" <> gitDir, "symbolic-ref", "--quiet", T.unpack ref ] "" >>= \case
+        ( ExitSuccess, out, _ ) | [ deref ] <- lines out -> return deref
+        ( _, _, _ ) -> return $ T.unpack ref
+    runGitCommand repo [ "show-ref", deref ] >>= \case
+        out | [ _, fullRef ] : _ <- words <$> lines out
+            -> runGitCommand repo [ "for-each-ref", "--format=%(upstream)", fullRef ] >>= \case
+            out' | [ upstream ] <- lines out'
+                -> return $ Just $ T.pack upstream
+            _ -> return Nothing
+        _ -> return Nothing
 
+
 getCommitDetails :: (MonadIO m, MonadFail m) => Commit -> m CommitDetails
 getCommitDetails Commit {..} = do
-    let GitRepo {..} = commitRepo
     liftIO $ do
         modifyMVar commitDetails $ \case
             cur@(Just details) -> do
@@ -174,19 +228,20 @@
             Nothing -> do
                 ( infoPart, _ : title : message ) <-
                     fmap (span (not . null) . lines) $
-                    withMVar gitLock $ \_ -> do
-                        readProcess "git" [ "--git-dir=" <> gitDir, "cat-file", "commit", showCommitId commitId_ ] ""
+                    runGitCommand commitRepo [ "cat-file", "commit", showCommitId commitId_ ]
                 let info = map (fmap (drop 1) . span (/= ' ')) infoPart
 
-                Just commitTreeId <- return $ TreeId . BC.pack <$> lookup "tree" info
+                Just treeId <- return $ TreeId . BC.pack <$> lookup "tree" info
+                let treeRepo = commitRepo
+                let commitTree = Tree {..}
                 let commitTitle = T.pack title
                 let commitMessage = T.pack $ unlines $ dropWhile null message
 
                 let details = CommitDetails {..}
                 return ( Just details, details )
 
-getTreeId :: (MonadIO m, MonadFail m) => Commit -> m TreeId
-getTreeId = fmap commitTreeId . getCommitDetails
+getCommitTree :: (MonadIO m, MonadFail m) => Commit -> m Tree
+getCommitTree = fmap commitTree . getCommitDetails
 
 getCommitTitle :: (MonadIO m, MonadFail m) => Commit -> m Text
 getCommitTitle = fmap commitTitle . getCommitDetails
@@ -195,16 +250,82 @@
 getCommitMessage = fmap commitMessage . getCommitDetails
 
 
-checkoutAt :: (MonadIO m, MonadFail m) => Commit -> FilePath -> m ()
-checkoutAt Commit {..} dest = do
-    let GitRepo {..} = commitRepo
-    liftIO $ withMVar gitLock $ \_ -> do
-        "" <- readProcess "git" [ "clone", "--quiet", "--shared", "--no-checkout", gitDir, dest ] ""
-        "" <- readProcess "git" [ "-C", dest, "restore", "--worktree", "--source=" <> showCommitId commitId_, "--", "." ] ""
-        removeDirectoryRecursive $ dest </> ".git"
+getSubtree :: (MonadIO m, MonadFail m) => Maybe Commit -> FilePath -> Tree -> m Tree
+getSubtree mbCommit path tree = liftIO $ do
+    let GitRepo {..} = treeRepo tree
+    readProcessWithExitCode "git" [ "--git-dir=" <> gitDir, "rev-parse", "--verify", "--quiet", showTreeId (treeId tree) <> ":" <> path ] "" >>= \case
+        ( ExitSuccess, out, _ ) | tid : _ <- lines out -> do
+            return Tree
+                { treeRepo = treeRepo tree
+                , treeId = TreeId (BC.pack tid)
+                }
+        _ -> do
+            fail $ "subtree `" <> path <> "' not found" <> maybe "" (("in revision `" <>) . (<> "'") . showCommitId . commitId) mbCommit
 
 
-readCommittedFile :: Commit -> FilePath -> IO (Maybe BL.ByteString)
+checkoutAt :: (MonadIO m, MonadFail m) => Tree -> FilePath -> m ()
+checkoutAt Tree {..} dest = do
+    let GitRepo {..} = treeRepo
+    liftIO $ withSystemTempFile "minici-checkout.index" $ \index _ -> do
+        curenv <- getEnvironment
+        let readGitProcess args input =
+                withMVar gitLock $ \_ ->
+                    readCreateProcess (proc "git" args)
+                        { env = Just $ concat
+                            [ [ ( "GIT_INDEX_FILE", index ) ]
+                            , [ ( "GIT_DIR", gitDir ) ]
+                            , [ ( "GIT_WORK_TREE", "." ) ]
+                            , curenv
+                            ]
+                        } input
+        "" <- readGitProcess [ "read-tree", showTreeId treeId ] ""
+        "" <- readGitProcess [ "checkout-index", "--all", "--prefix=" <> addTrailingPathSeparator dest ] ""
+        return ()
+
+createWipCommit :: (MonadIO m, MonadMask m, MonadFail m) => Repo -> m Commit
+createWipCommit repo@GitRepo {..} = do
+    withSystemTempFile "minici-wip.index" $ \index _ -> do
+        curenv <- liftIO getEnvironment
+        let readGitProcess mbWorkTree args input = liftIO $ do
+                withMVar gitLock $ \_ ->
+                    readCreateProcess (proc "git" args)
+                        { env = Just $ concat
+                            [ [ ( "GIT_INDEX_FILE", index ) ]
+                            , [ ( "GIT_DIR", gitDir ) ]
+                            , map (( "GIT_WORK_TREE", ) . T.unpack) $ maybeToList mbWorkTree
+                            , curenv
+                            ]
+                        } input
+            mkPair = fmap (T.dropWhile (== ' ')) . T.break (== ' ')
+        info <- map mkPair . takeWhile (not . T.null) . T.splitOn "\0". T.pack <$>
+            readGitProcess Nothing [ "worktree", "list", "--porcelain", "-z" ] ""
+        case ( lookup "worktree" info, lookup "HEAD" info ) of
+            ( Just worktree, Just headRev ) -> do
+                let readGitProcessW = readGitProcess (Just worktree)
+
+                headCommit <- mkCommit repo (CommitId $ encodeUtf8 headRev)
+                headTree <- getCommitTree headCommit
+
+                "" <- readGitProcessW [ "read-tree", "--empty" ] ""
+                status <- map mkPair . T.splitOn "\0" . T.pack <$>
+                    readGitProcessW [ "status", "--porcelain=v1", "-z", "--untracked-files=all" ] ""
+                "" <- readGitProcessW [ "update-index", "--add", "-z", "--stdin" ] $ T.unpack $ T.intercalate "\0" $ map snd status
+                [ tid ] <- lines <$> readGitProcessW [ "write-tree" ] ""
+
+                if TreeId (BC.pack tid) == treeId headTree
+                  then return headCommit
+                  else do
+                    headMsg <- getCommitTitle headCommit
+                    let wipMsg = case lookup "branch" info of
+                            Just branch -> "WIP on " <> branch <> ": " <> headMsg
+                            Nothing -> "WIP: " <> headMsg
+                    [ cid ] <- lines <$> readGitProcessW [ "commit-tree", "-m", T.unpack wipMsg, "-p", T.unpack headRev, tid ] ""
+                    mkCommit repo (CommitId $ BC.pack cid)
+
+            _ -> readCommit repo "HEAD"
+
+
+readCommittedFile :: MonadIO m => Commit -> FilePath -> m (Maybe BL.ByteString)
 readCommittedFile Commit {..} path = do
     let GitRepo {..} = commitRepo
     liftIO $ withMVar gitLock $ \_ -> do
@@ -256,14 +377,14 @@
     headsDir = gitDir </> "refs/heads"
     tagsDir = gitDir </> "refs/tags"
 
-watchBranch :: Repo -> Text -> IO (STM (Maybe Commit))
-watchBranch repo@GitRepo {..} branch = do
+watchBranch :: MonadIO m => Repo -> Text -> m (STM (Maybe Commit))
+watchBranch repo@GitRepo {..} branch = liftIO $ do
     var <- newTVarIO =<< readBranch repo branch
     void $ repoInotify repo
     modifyMVar_ gitWatchedBranches $ return . M.insertWith (++) branch [ var ]
     return $ readTVar var
 
-watchTags :: Repo -> IO (TChan (Tag Commit))
-watchTags repo = do
+watchTags :: MonadIO m => Repo -> m (TChan (Tag Commit))
+watchTags repo = liftIO $ do
     tagsChan <- snd <$> repoInotify repo
     atomically $ dupTChan tagsChan
diff --git a/src/Terminal.hs b/src/Terminal.hs
--- a/src/Terminal.hs
+++ b/src/Terminal.hs
@@ -1,9 +1,11 @@
 module Terminal (
     TerminalOutput,
     TerminalLine,
+    TerminalFootnote(..),
     initTerminalOutput,
     newLine,
     redrawLine,
+    newFootnote,
     terminalBlinkStatus,
 ) where
 
@@ -11,6 +13,7 @@
 import Control.Concurrent.STM
 import Control.Monad
 
+import Data.Function
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Text.IO qualified as T
@@ -20,17 +23,30 @@
 
 data TerminalOutput = TerminalOutput
     { outNumLines :: MVar Int
+    , outNextFootnote :: MVar Int
     , outBlinkVar :: TVar Bool
     }
 
+instance Eq TerminalOutput where
+    (==) = (==) `on` outNumLines
+
 data TerminalLine = TerminalLine
     { lineOutput :: TerminalOutput
     , lineNum :: Int
     }
+    deriving (Eq)
 
+data TerminalFootnote = TerminalFootnote
+    { footnoteLine :: TerminalLine
+    , footnoteNumber :: Int
+    , footnoteText :: Text
+    }
+    deriving (Eq)
+
 initTerminalOutput :: IO TerminalOutput
 initTerminalOutput = do
     outNumLines <- newMVar 0
+    outNextFootnote <- newMVar 1
     outBlinkVar <- newTVarIO False
     void $ forkIO $ forever $ do
         threadDelay 500000
@@ -51,6 +67,13 @@
         let moveBy = total - lineNum
         T.putStr $ "\ESC[s\ESC[" <> T.pack (show moveBy) <> "F" <> text <> "\ESC[u"
         hFlush stdout
+
+newFootnote :: TerminalOutput -> Text -> IO TerminalFootnote
+newFootnote tout@TerminalOutput {..} footnoteText = do
+    modifyMVar outNextFootnote $ \footnoteNumber -> do
+        footnoteLine <- newLine tout $ "[" <> T.pack (show footnoteNumber) <> "] " <> footnoteText
+        hFlush stdout
+        return ( footnoteNumber + 1, TerminalFootnote {..} )
 
 terminalBlinkStatus :: TerminalOutput -> STM Bool
 terminalBlinkStatus TerminalOutput {..} = readTVar outBlinkVar
