diff --git a/Annex.hs b/Annex.hs
--- a/Annex.hs
+++ b/Annex.hs
@@ -183,8 +183,8 @@
 	, hashobjecthandle :: Maybe (ResourcePool HashObjectHandle)
 	, checkattrhandle :: Maybe (ResourcePool CheckAttrHandle)
 	, checkignorehandle :: Maybe (ResourcePool CheckIgnoreHandle)
-	, globalnumcopies :: Maybe NumCopies
-	, globalmincopies :: Maybe MinCopies
+	, globalnumcopies :: Maybe (Maybe NumCopies)
+	, globalmincopies :: Maybe (Maybe MinCopies)
 	, limit :: ExpandableMatcher Annex
 	, timelimit :: Maybe (Duration, POSIXTime)
 	, sizelimit :: Maybe (TVar Integer)
diff --git a/Annex/Action.hs b/Annex/Action.hs
--- a/Annex/Action.hs
+++ b/Annex/Action.hs
@@ -37,14 +37,14 @@
 action a = tryNonAsync a >>= \case
 	Right () -> return True
 	Left e -> do
-		warning (show e)
+		warning (UnquotedString (show e))
 		return False
 
 verifiedAction :: Annex Verification -> Annex (Bool, Verification)
 verifiedAction a = tryNonAsync a >>= \case
 	Right v -> return (True, v)
 	Left e -> do
-		warning (show e)
+		warning (UnquotedString (show e))
 		return (False, UnVerified)
 
 
diff --git a/Annex/AdjustedBranch.hs b/Annex/AdjustedBranch.hs
--- a/Annex/AdjustedBranch.hs
+++ b/Annex/AdjustedBranch.hs
@@ -67,7 +67,9 @@
 import qualified Database.Keys
 import Config
 import Logs.View (is_branchView)
+import Logs.AdjustedBranchUpdate
 
+import Data.Time.Clock.POSIX
 import qualified Data.Map as M
 
 class AdjustTreeItem t where
@@ -209,7 +211,7 @@
 		let adjbranch = adjBranch $ originalToAdjusted origbranch adj
 		ifM (inRepo (Git.Ref.exists adjbranch) <&&> (not <$> Annex.getRead Annex.force) <&&> pure (not (is_branchView origbranch)))
 			( do
-				mapM_ (warning . unwords)
+				mapM_ (warning . UnquotedString . unwords)
 					[ [ "adjusted branch"
 					  , Git.Ref.describe adjbranch
 					  , "already exists."
@@ -223,9 +225,13 @@
 					]
 				return False
 			, do
+				starttime <- liftIO getPOSIXTime
 				b <- preventCommits $ const $ 
 					adjustBranch adj origbranch
-				checkoutAdjustedBranch b False
+				ok <- checkoutAdjustedBranch b False
+				when ok $
+					recordAdjustedBranchUpdateFinished starttime
+				return ok
 			)
 
 checkoutAdjustedBranch :: AdjBranch -> Bool -> Annex Bool
@@ -255,20 +261,22 @@
 			_ <- propigateAdjustedCommits' origbranch adj commitlck
 			
 			origheadfile <- inRepo $ readFileStrict . Git.Ref.headFile
+			origheadsha <- inRepo (Git.Ref.sha currbranch)
+			
+			b <- adjustBranch adj origbranch
 
 			-- Git normally won't do anything when asked to check
 			-- out the currently checked out branch, even when its
 			-- ref has changed. Work around this by writing a raw
 			-- sha to .git/HEAD.
-			newheadfile <- inRepo (Git.Ref.sha currbranch) >>= \case
-				Just headsha -> do
+			newheadfile <- case origheadsha of
+				Just s -> do
 					inRepo $ \r -> do
-						let newheadfile = fromRef headsha
+						let newheadfile = fromRef s
 						writeFile (Git.Ref.headFile r) newheadfile
 						return (Just newheadfile)
 				_ -> return Nothing
 	
-			b <- adjustBranch adj origbranch
 			return (b, origheadfile, newheadfile)
 	
 		-- Make git checkout quiet to avoid warnings about
@@ -302,18 +310,22 @@
 adjustedBranchRefresh :: AssociatedFile -> Annex a -> Annex a
 adjustedBranchRefresh _af a = do
 	r <- a
-	annexAdjustedBranchRefresh <$> Annex.getGitConfig >>= \case
-		0 -> return ()
-		n -> go n
+	go
 	return r
   where
-	go n = getCurrentBranch >>= \case
+	go = getCurrentBranch >>= \case
 		(Just origbranch, Just adj) ->
-			unless (adjustmentIsStable adj) $
-				ifM (checkcounter n)
-					( update adj origbranch
+			unless (adjustmentIsStable adj) $ do
+				recordAdjustedBranchUpdateNeeded
+				n <- annexAdjustedBranchRefresh <$> Annex.getGitConfig
+				unless (n == 0) $ ifM (checkcounter n)
+					-- This is slow, it would be better to incrementally
+					-- adjust the AssociatedFile, and only call this once
+					-- at shutdown to handle cases where not all
+					-- AssociatedFiles are known.
+					( adjustedBranchRefreshFull' adj origbranch
 					, Annex.addCleanupAction AdjustedBranchUpdate $
-						adjustedBranchRefreshFull adj origbranch
+						adjustedBranchRefreshFull' adj origbranch
 					)
 		_ -> return ()
 	
@@ -327,23 +339,24 @@
 			    !s' = s { Annex.adjustedbranchrefreshcounter = c' }
 			    in pure (s', enough)
 
-	-- This is slow, it would be better to incrementally
-	-- adjust the AssociatedFile, and only call this once
-	-- at shutdown to handle cases where not all
-	-- AssociatedFiles are known.
-	update adj origbranch =
-		adjustedBranchRefreshFull adj origbranch
-
 {- Slow, but more dependable version of adjustedBranchRefresh that
  - does not rely on all AssociatedFiles being known. -}
 adjustedBranchRefreshFull :: Adjustment -> OrigBranch -> Annex ()
-adjustedBranchRefreshFull adj origbranch = do
+adjustedBranchRefreshFull adj origbranch =
+	whenM isAdjustedBranchUpdateNeeded $ do
+		adjustedBranchRefreshFull' adj origbranch
+
+adjustedBranchRefreshFull' :: Adjustment -> OrigBranch -> Annex ()
+adjustedBranchRefreshFull' adj origbranch = do
 	-- Restage pointer files so modifications to them due to get/drop
 	-- do not prevent checking out the updated adjusted branch.
 	restagePointerFiles =<< Annex.gitRepo
+	starttime <- liftIO getPOSIXTime
 	let adjbranch = originalToAdjusted origbranch adj
-	unlessM (updateAdjustedBranch adj adjbranch origbranch) $
-		warning $ unwords [ "Updating adjusted branch failed." ]
+	ifM (updateAdjustedBranch adj adjbranch origbranch)
+		( recordAdjustedBranchUpdateFinished starttime
+		, warning "Updating adjusted branch failed."
+		)
 
 adjustToCrippledFileSystem :: Annex ()
 adjustToCrippledFileSystem = do
@@ -497,7 +510,7 @@
 			Just currcommit ->
 				newcommits >>= go origsha False >>= \case
 					Left e -> do
-						warning e
+						warning (UnquotedString e)
 						return (Nothing, return ())
 					Right newparent -> return
 						( Just newparent
@@ -505,7 +518,8 @@
 						)
 			Nothing -> return (Nothing, return ())
 		Nothing -> do
-			warning $ "Cannot find basis ref " ++ fromRef basis ++ "; not propagating adjusted commits to original branch " ++ fromRef origbranch
+			warning $ UnquotedString $ 
+				"Cannot find basis ref " ++ fromRef basis ++ "; not propagating adjusted commits to original branch " ++ fromRef origbranch
 			return (Nothing, return ())
   where
 	(BasisBranch basis) = basisBranch adjbranch
diff --git a/Annex/AdjustedBranch/Merge.hs b/Annex/AdjustedBranch/Merge.hs
--- a/Annex/AdjustedBranch/Merge.hs
+++ b/Annex/AdjustedBranch/Merge.hs
@@ -98,7 +98,7 @@
 				-- (for an unknown reason).
 				-- http://thread.gmane.org/gmane.comp.version-control.git/297237
 				inRepo $ Git.Command.run [Param "reset", Param "HEAD", Param "--quiet"]
-				showAction $ "Merging into " ++ fromRef (Git.Ref.base origbranch)
+				showAction $ UnquotedString $ "Merging into " ++ fromRef (Git.Ref.base origbranch)
 				merged <- autoMergeFrom' tomerge Nothing mergeconfig commitmode True
 					(const $ resolveMerge (Just updatedorig) tomerge True)
 				if merged
diff --git a/Annex/BloomFilter.hs b/Annex/BloomFilter.hs
--- a/Annex/BloomFilter.hs
+++ b/Annex/BloomFilter.hs
@@ -27,7 +27,8 @@
 	accuracy <- bloomAccuracy
 	case safeSuggestSizing capacity (1 / fromIntegral accuracy) of
 		Left e -> do
-			warning $ "bloomfilter " ++ e ++ "; falling back to sane value"
+			warning $ UnquotedString $
+				"bloomfilter " ++ e ++ "; falling back to sane value"
 			-- precaulculated value for 500000 (1/10000000)
 			return (16777216,23)
 		Right v -> return v
diff --git a/Annex/Branch.hs b/Annex/Branch.hs
--- a/Annex/Branch.hs
+++ b/Annex/Branch.hs
@@ -130,7 +130,7 @@
 			, Param $ fromRef name
 			, Param $ fromRef originname
 			]
-		fromMaybe (error $ "failed to create " ++ fromRef name)
+		fromMaybe (giveup $ "failed to create " ++ fromRef name)
 			<$> branchsha
 	go False = withIndex' True $ do
 		-- Create the index file. This is not necessary,
@@ -243,7 +243,7 @@
 				" into " ++ fromRef name
 		localtransitions <- getLocalTransitions
 		unless (null tomerge) $ do
-			showSideAction merge_desc
+			showSideAction (UnquotedString merge_desc)
 			mapM_ checkBranchDifferences refs
 			mergeIndex jl refs
 		let commitrefs = nub $ fullname:refs
diff --git a/Annex/CheckAttr.hs b/Annex/CheckAttr.hs
--- a/Annex/CheckAttr.hs
+++ b/Annex/CheckAttr.hs
@@ -6,6 +6,7 @@
  -}
 
 module Annex.CheckAttr (
+	annexAttrs,
 	checkAttr,
 	checkAttrs,
 	checkAttrStop,
@@ -29,8 +30,11 @@
 	]
 
 checkAttr :: Git.Attr -> RawFilePath -> Annex String
-checkAttr attr file = withCheckAttrHandle $ \h -> 
-	liftIO $ Git.checkAttr h attr file
+checkAttr attr file = withCheckAttrHandle $ \h -> do
+	r <- liftIO $ Git.checkAttr h attr file
+	if r == Git.unspecifiedAttr
+		then return ""
+		else return r
 
 checkAttrs :: [Git.Attr] -> RawFilePath -> Annex [String]
 checkAttrs attrs file = withCheckAttrHandle $ \h -> 
diff --git a/Annex/Common.hs b/Annex/Common.hs
--- a/Annex/Common.hs
+++ b/Annex/Common.hs
@@ -10,6 +10,7 @@
 import Annex.Locations as X
 import Annex.Debug as X (fastDebug, debug)
 import Messages as X
+import Git.Quote as X
 #ifndef mingw32_HOST_OS
 import System.Posix.IO as X hiding (createPipe)
 #endif
diff --git a/Annex/Content.hs b/Annex/Content.hs
--- a/Annex/Content.hs
+++ b/Annex/Content.hs
@@ -6,6 +6,7 @@
  -}
 
 {-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 module Annex.Content (
 	inAnnex,
@@ -101,6 +102,7 @@
 import Utility.InodeCache
 import Utility.CopyFile
 import Utility.Metered
+import Utility.FileMode
 import qualified Utility.RawFilePath as R
 
 import qualified System.FilePath.ByteString as P
@@ -170,7 +172,7 @@
 type ContentLocker = RawFilePath -> Maybe LockFile -> (Annex (Maybe LockHandle), Maybe (Annex (Maybe LockHandle)))
 
 #ifndef mingw32_HOST_OS
-posixLocker :: (Maybe FileMode -> LockFile -> Annex (Maybe LockHandle)) -> LockFile -> Annex (Maybe LockHandle)
+posixLocker :: (Maybe ModeSetter -> LockFile -> Annex (Maybe LockHandle)) -> LockFile -> Annex (Maybe LockHandle)
 posixLocker takelock lockfile = do
 	mode <- annexFileMode
 	modifyContentDirWhenExists lockfile $
@@ -447,7 +449,7 @@
 checkSecureHashes' key = checkSecureHashes key >>= \case
 	Nothing -> return True
 	Just msg -> do
-		warning $ msg ++ "to annex objects"
+		warning $ UnquotedString $ msg ++ "to annex objects"
 		return False
 
 data LinkAnnexResult = LinkAnnexOk | LinkAnnexFailed | LinkAnnexNoop
@@ -760,9 +762,10 @@
 	go [] [] = return False
 	go [] errs@((_, err):_) = do
 		if listfailedurls
-			then warning $ unlines $ flip map errs $ \(u, err') ->
-				u ++ " " ++ err'
-			else warning err
+			then warning $ UnquotedString $
+				unlines $ flip map errs $ \(u, err') ->
+					u ++ " " ++ err'
+			else warning $ UnquotedString err
 		return False
 
 {- Copies a key's content, when present, to a temp file.
diff --git a/Annex/Content/LowLevel.hs b/Annex/Content/LowLevel.hs
--- a/Annex/Content/LowLevel.hs
+++ b/Annex/Content/LowLevel.hs
@@ -126,7 +126,8 @@
 				let delta = need + reserve - have - alreadythere + inprogress
 				let ok = delta <= 0
 				unless ok $
-					warning $ needMoreDiskSpace delta
+					warning $ UnquotedString $ 
+						needMoreDiskSpace delta
 				return ok
 			_ -> return True
 	)
diff --git a/Annex/Difference.hs b/Annex/Difference.hs
--- a/Annex/Difference.hs
+++ b/Annex/Difference.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Annex.Difference (
 	module Types.Difference,
 	setDifferences,
diff --git a/Annex/Export.hs b/Annex/Export.hs
--- a/Annex/Export.hs
+++ b/Annex/Export.hs
@@ -15,6 +15,7 @@
 import Types.Key
 import qualified Git
 import qualified Types.Remote as Remote
+import Git.Quote
 import Messages
 
 import Data.Maybe
@@ -63,7 +64,7 @@
 		(False, True) -> ("imported from", "git-annex import")
 		(True, False) -> ("exported to", "git-annex export")
 		_ -> ("exported to and/or imported from", "git-annex export")
-	toplevelWarning True $ unwords
+	toplevelWarning True $ UnquotedString $ unwords
 		[ "Conflict detected. Different trees have been"
 		, ops, Remote.name r ++ ". Use"
 		, resolvcmd
diff --git a/Annex/FileMatcher.hs b/Annex/FileMatcher.hs
--- a/Annex/FileMatcher.hs
+++ b/Annex/FileMatcher.hs
@@ -43,7 +43,6 @@
 import Types.Remote (RemoteConfig)
 import Types.ProposedAccepted
 import Annex.CheckAttr
-import Git.CheckAttr (unspecifiedAttr)
 import qualified Git.Config
 #ifdef WITH_MAGICMIME
 import Annex.Magic
@@ -233,7 +232,7 @@
 		return $ const $ return matcher
 	go v = return $ \file -> do
 		expr <- checkAttr "annex.largefiles" file
-		if null expr || expr == unspecifiedAttr
+		if null expr
 			then case v of
 				HasGlobalConfig (Just expr') ->
 					mkmatcher expr' "git-annex config"
diff --git a/Annex/GitOverlay.hs b/Annex/GitOverlay.hs
--- a/Annex/GitOverlay.hs
+++ b/Annex/GitOverlay.hs
@@ -73,7 +73,7 @@
 	(const a)
   where
 	modlocation l@(Local {}) = l { worktree = Just (toRawFilePath d) }
-	modlocation _ = error "withWorkTree of non-local git repo"
+	modlocation _ = giveup "withWorkTree of non-local git repo"
 
 {- Runs an action with the git index file and HEAD, and a few other
  - files that are related to the work tree coming from an overlay
diff --git a/Annex/Hook.hs b/Annex/Hook.hs
--- a/Annex/Hook.hs
+++ b/Annex/Hook.hs
@@ -66,7 +66,8 @@
 hookWarning :: Git.Hook -> String -> Annex ()
 hookWarning h msg = do
 	r <- gitRepo
-	warning $ Git.hookName h ++ " hook (" ++ Git.hookFile h r ++ ") " ++ msg
+	warning $ UnquotedString $
+		Git.hookName h ++ " hook (" ++ Git.hookFile h r ++ ") " ++ msg
 
 {- Runs a hook. To avoid checking if the hook exists every time,
  - the existing hooks are cached. -}
@@ -84,4 +85,4 @@
   where
 	run = unlessM (inRepo $ Git.runHook hook) $ do
 		h <- fromRepo $ Git.hookFile hook
-		warning $ h ++ " failed"
+		warning $ UnquotedString $ h ++ " failed"
diff --git a/Annex/Import.hs b/Annex/Import.hs
--- a/Annex/Import.hs
+++ b/Annex/Import.hs
@@ -1,6 +1,6 @@
 {- git-annex import from remotes
  -
- - Copyright 2019-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2019-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -14,6 +14,9 @@
 	buildImportTrees,
 	recordImportTree,
 	canImportKeys,
+	ImportResult(..),
+	Imported,
+	importChanges,
 	importKeys,
 	makeImportMatcher,
 	getImportableContents,
@@ -27,6 +30,7 @@
 import Git.Sha
 import Git.FilePath
 import Git.History
+import qualified Git.DiffTree
 import qualified Git.Ref
 import qualified Git.Branch
 import qualified Annex
@@ -38,6 +42,7 @@
 import Annex.HashObject
 import Annex.Transfer
 import Annex.CheckIgnore
+import Annex.CatFile
 import Annex.VectorClock
 import Command
 import Backend
@@ -46,6 +51,8 @@
 import Messages.Progress
 import Utility.DataUnits
 import Utility.Metered
+import Utility.Hash (sha1s)
+import Logs.Import
 import Logs.Export
 import Logs.Location
 import Logs.PreferredContent
@@ -62,6 +69,7 @@
 import qualified Data.Set as S
 import qualified System.FilePath.Posix.ByteString as Posix
 import qualified System.FilePath.ByteString as P
+import qualified Data.ByteArray.Encoding as BA
 
 {- Configures how to build an import tree. -}
 data ImportTreeConfig
@@ -98,9 +106,9 @@
 	:: Remote
 	-> ImportTreeConfig
 	-> ImportCommitConfig
-	-> ImportableContentsChunkable Annex (Either Sha Key)
+	-> Imported
 	-> Annex (Maybe Ref)
-buildImportCommit remote importtreeconfig importcommitconfig importable =
+buildImportCommit remote importtreeconfig importcommitconfig imported =
 	case importCommitTracking importcommitconfig of
 		Nothing -> go Nothing
 		Just trackingcommit -> inRepo (Git.Ref.tree trackingcommit) >>= \case
@@ -108,8 +116,8 @@
 			Just _ -> go (Just trackingcommit)
   where
 	go trackingcommit = do
-		(imported, updatestate) <- recordImportTree remote importtreeconfig importable
-		buildImportCommit' remote importcommitconfig trackingcommit imported >>= \case
+		(importedtree, updatestate) <- recordImportTree remote importtreeconfig imported
+		buildImportCommit' remote importcommitconfig trackingcommit importedtree >>= \case
 			Just finalcommit -> do
 				updatestate
 				return (Just finalcommit)
@@ -123,11 +131,11 @@
 recordImportTree
 	:: Remote
 	-> ImportTreeConfig
-	-> ImportableContentsChunkable Annex (Either Sha Key)
+	-> Imported
 	-> Annex (History Sha, Annex ())
-recordImportTree remote importtreeconfig importable = do
-	imported@(History finaltree _) <- buildImportTrees basetree subdir importable
-	return (imported, updatestate finaltree)
+recordImportTree remote importtreeconfig imported = do
+	importedtree@(History finaltree _) <- buildImportTrees basetree subdir imported
+	return (importedtree, updatestate finaltree)
   where
 	basetree = case importtreeconfig of
 		ImportTree -> emptyTree
@@ -259,25 +267,129 @@
 			parents <- mapM (mknewcommits oldhc old) (S.toList hs)
 			mkcommit parents importedtree
 
-{- Builds a history of git trees reflecting the ImportableContents.
+{- Builds a history of git trees for an import.
  -
- - When a subdir is provided, imported tree is grafted into the basetree at
- - that location, replacing any object that was there.
+ - When a subdir is provided, the imported tree is grafted into 
+ - the basetree at that location, replacing any object that was there.
  -}
 buildImportTrees
 	:: Ref
 	-> Maybe TopFilePath
-	-> ImportableContentsChunkable Annex (Either Sha Key)
+	-> Imported
 	-> Annex (History Sha)
-buildImportTrees basetree msubdir (ImportableContentsComplete importable) = do
+buildImportTrees basetree msubdir (ImportedFull imported) = 
+	buildImportTreesGeneric convertImportTree basetree msubdir imported
+buildImportTrees basetree msubdir (ImportedDiff (LastImportedTree oldtree) imported) = do
+	importtree <- if null (importableContents imported)
+		then pure oldtree
+		else applydiff
 	repo <- Annex.gitRepo
-	withMkTreeHandle repo $ buildImportTrees' basetree msubdir importable
-buildImportTrees basetree msubdir importable@(ImportableContentsChunked {}) = do
+	t <- withMkTreeHandle repo $
+		graftImportTree basetree msubdir importtree
+	-- Diffing is not currently implemented when the history is not empty.
+	return (History t mempty)
+  where
+	applydiff = do
+		let (removed, new) = partition isremoved
+			(importableContents imported)
+		newtreeitems <- catMaybes <$> mapM mktreeitem new
+		let removedfiles = map (mkloc . fst) removed
+		inRepo $ adjustTree
+			(pure . Just) 
+			-- ^ keep files that are not added/removed the same
+			newtreeitems
+			(\_oldti newti -> newti)
+			-- ^ prefer newly added version of file
+			removedfiles
+			oldtree
+	
+	mktreeitem (loc, DiffChanged v) = 
+		Just <$> mkImportTreeItem msubdir loc v
+	mktreeitem (_, DiffRemoved) = 
+		pure Nothing
+
+	mkloc = asTopFilePath . fromImportLocation
+		
+	isremoved (_, v) = v == DiffRemoved
+
+convertImportTree :: Maybe TopFilePath -> [(ImportLocation, Either Sha Key)] -> Annex Tree
+convertImportTree msubdir ls = 
+	treeItemsToTree <$> mapM (uncurry $ mkImportTreeItem msubdir) ls
+
+mkImportTreeItem :: Maybe TopFilePath -> ImportLocation -> Either Sha Key -> Annex TreeItem
+mkImportTreeItem msubdir loc v = case v of
+	Right k -> do
+		relf <- fromRepo $ fromTopFilePath topf
+		symlink <- calcRepo $ gitAnnexLink relf k
+		linksha <- hashSymlink symlink
+		return $ TreeItem treepath (fromTreeItemType TreeSymlink) linksha
+	Left sha -> 
+		return $ TreeItem treepath (fromTreeItemType TreeFile) sha
+  where
+	lf = fromImportLocation loc
+	treepath = asTopFilePath lf
+	topf = asTopFilePath $
+		maybe lf (\sd -> getTopFilePath sd P.</> lf) msubdir
+
+{- Builds a history of git trees using ContentIdentifiers.
+ -
+ - These are not the final trees that are generated by the import, which
+ - use Keys. The purpose of these trees is to allow quickly determining
+ - which files in the import have changed, and which are unchanged, to
+ - avoid needing to look up the Keys for unchanged ContentIdentifiers.
+ - When the import has a large number of files, that can be slow.
+ -}
+buildContentIdentifierTree
+	:: ImportableContentsChunkable Annex (ContentIdentifier, ByteSize)
+	-> Annex (History Sha, M.Map Sha (ContentIdentifier, ByteSize))
+buildContentIdentifierTree importable = do
+	mv <- liftIO $ newTVarIO M.empty
+	r <- buildImportTreesGeneric (convertContentIdentifierTree mv) emptyTree Nothing importable
+	m <- liftIO $ atomically $ readTVar mv
+	return (r, m)
+
+{- For speed, and to avoid bloating the repository, the ContentIdentifiers
+ - are not actually checked into git, instead a sha1 hash is calculated
+ - internally.
+ -}
+convertContentIdentifierTree
+	:: TVar (M.Map Sha (ContentIdentifier, ByteSize))
+	-> Maybe TopFilePath
+	-> [(ImportLocation, (ContentIdentifier, ByteSize))]
+	-> Annex Tree
+convertContentIdentifierTree mv _ ls = do
+	let (tis, ml) = unzip (map mktreeitem ls)
+	liftIO $ atomically $ modifyTVar' mv $
+		M.union (M.fromList ml)
+	return (treeItemsToTree tis)
+  where
+	mktreeitem (loc, v@((ContentIdentifier cid), _sz)) =
+		(TreeItem p mode sha1, (sha1, v))
+	  where
+		p = asTopFilePath (fromImportLocation loc)
+		mode = fromTreeItemType TreeFile
+		-- Note that this hardcodes sha1, even if git has started
+		-- defaulting to some other checksum method. That should be
+		-- ok, hopefully. This checksum never needs to be verified
+		-- by git, which is why this does not bother to prefix the
+		-- cid with its length, like git would.
+		sha1 = Ref $ BA.convertToBase BA.Base16 $ sha1s cid
+
+buildImportTreesGeneric
+	:: (Maybe TopFilePath -> [(ImportLocation, v)] -> Annex Tree)
+	-> Ref
+	-> Maybe TopFilePath
+	-> ImportableContentsChunkable Annex v
+	-> Annex (History Sha)
+buildImportTreesGeneric converttree basetree msubdir (ImportableContentsComplete importable) = do
 	repo <- Annex.gitRepo
+	withMkTreeHandle repo $ buildImportTreesGeneric' converttree basetree msubdir importable
+buildImportTreesGeneric converttree basetree msubdir importable@(ImportableContentsChunked {}) = do
+	repo <- Annex.gitRepo
 	withMkTreeHandle repo $ \hdl ->
 		History
 			<$> go hdl
-			<*> buildImportTreesHistory basetree msubdir
+			<*> buildImportTreesHistory converttree basetree msubdir
 				(importableHistoryComplete importable) hdl
   where
 	go hdl = do
@@ -291,7 +403,7 @@
 		let fullprefix = asTopFilePath $ case msubdir of
 			Nothing -> subdir
 			Just d -> getTopFilePath d Posix.</> subdir
-		Tree ts <- convertImportTree (Just fullprefix) $
+		Tree ts <- converttree (Just fullprefix) $
 			map (\(p, i) -> (mkImportLocation p, i))
 				(importableContentsSubTree c)
 		-- Record this subtree before getting next chunk, this
@@ -302,24 +414,26 @@
 			Nothing -> return (Tree (tc:l))
 			Just c' -> gochunks (tc:l) c' hdl
 
-buildImportTrees'
-	:: Ref
+buildImportTreesGeneric'
+	:: (Maybe TopFilePath -> [(ImportLocation, v)] -> Annex Tree)
+	-> Ref
 	-> Maybe TopFilePath
-	-> ImportableContents (Either Sha Key)
+	-> ImportableContents v
 	-> MkTreeHandle
 	-> Annex (History Sha)
-buildImportTrees' basetree msubdir importable hdl = History
-	<$> buildImportTree basetree msubdir (importableContents importable) hdl
-	<*> buildImportTreesHistory basetree msubdir (importableHistory importable) hdl
+buildImportTreesGeneric' converttree basetree msubdir importable hdl = History
+	<$> buildImportTree converttree basetree msubdir (importableContents importable) hdl
+	<*> buildImportTreesHistory converttree basetree msubdir (importableHistory importable) hdl
 
 buildImportTree
-	:: Ref
+	:: (Maybe TopFilePath -> [(ImportLocation, v)] -> Annex Tree)
+	-> Ref
 	-> Maybe TopFilePath
-	-> [(ImportLocation, Either Sha Key)]
+	-> [(ImportLocation, v)]
 	-> MkTreeHandle
 	-> Annex Sha
-buildImportTree basetree msubdir ls hdl = do
-	importtree <- liftIO . recordTree' hdl =<< convertImportTree msubdir ls
+buildImportTree converttree basetree msubdir ls hdl = do
+	importtree <- liftIO . recordTree' hdl =<< converttree msubdir ls
 	graftImportTree basetree msubdir importtree hdl
 
 graftImportTree
@@ -333,31 +447,15 @@
 	Just subdir -> inRepo $ \repo ->
 		graftTree' tree subdir basetree repo hdl
 
-convertImportTree :: Maybe TopFilePath -> [(ImportLocation, Either Sha Key)] -> Annex Tree
-convertImportTree msubdir ls = treeItemsToTree <$> mapM mktreeitem ls
-  where
-	mktreeitem (loc, v) = case v of
-		Right k -> do
-			relf <- fromRepo $ fromTopFilePath topf
-			symlink <- calcRepo $ gitAnnexLink relf k
-			linksha <- hashSymlink symlink
-			return $ TreeItem treepath (fromTreeItemType TreeSymlink) linksha
-		Left sha -> 
-			return $ TreeItem treepath (fromTreeItemType TreeFile) sha
-	  where
-		lf = fromImportLocation loc
-		treepath = asTopFilePath lf
-		topf = asTopFilePath $
-			maybe lf (\sd -> getTopFilePath sd P.</> lf) msubdir
-
 buildImportTreesHistory
-	:: Ref
+	:: (Maybe TopFilePath -> [(ImportLocation, v)] -> Annex Tree)
+	-> Ref
 	-> Maybe TopFilePath
-	-> [ImportableContents (Either Sha Key)]
+	-> [ImportableContents v]
 	-> MkTreeHandle
 	-> Annex (S.Set (History Sha))
-buildImportTreesHistory basetree msubdir history hdl = S.fromList
-	<$> mapM (\ic -> buildImportTrees' basetree msubdir ic hdl) history
+buildImportTreesHistory converttree basetree msubdir history hdl = S.fromList
+	<$> mapM (\ic -> buildImportTreesGeneric' converttree basetree msubdir ic hdl) history
 
 canImportKeys :: Remote -> Bool -> Bool
 canImportKeys remote importcontent =
@@ -365,6 +463,135 @@
   where
 	ia = Remote.importActions remote
 
+-- Result of an import. ImportUnfinished indicates that some file failed to
+-- be imported. Running again should resume where it left off.
+data ImportResult t
+	= ImportFinished t
+	| ImportUnfinished
+
+data Diffed t
+	= DiffChanged t
+	| DiffRemoved
+	deriving (Eq)
+
+data Imported
+	= ImportedFull (ImportableContentsChunkable Annex (Either Sha Key))
+	| ImportedDiff LastImportedTree (ImportableContents (Diffed (Either Sha Key)))
+
+newtype LastImportedTree = LastImportedTree Sha
+
+{- Diffs between the previous and current ContentIdentifier trees, and 
+ - runs importKeys on only the changed files.
+ -
+ - This will download the same content as if importKeys were run on all
+ - files, but this speeds it up significantly when there are a lot of files
+ - and only a few have changed. importKeys has to look up each
+ - ContentIdentifier to see if a Key is known for it. This avoids doing
+ - that lookup on files that have not changed.
+ -
+ - Diffing is not currently implemented when there is a History.
+ -}
+importChanges
+	:: Remote
+	-> ImportTreeConfig
+	-> Bool
+	-> Bool
+	-> ImportableContentsChunkable Annex (ContentIdentifier, ByteSize)
+	-> Annex (ImportResult Imported)
+importChanges remote importtreeconfig importcontent thirdpartypopulated importablecontents = do
+	((History currcidtree currhistory), cidtreemap) <- buildContentIdentifierTree importablecontents
+	-- diffimport below does not handle history, so when there is
+	-- history, do a full import.
+	if not (S.null currhistory)
+		then fullimport currcidtree
+		else do
+			getContentIdentifierTree (Remote.uuid remote) >>= \case
+				Nothing -> fullimport currcidtree
+				Just prevcidtree -> candiffimport prevcidtree >>= \case
+					Nothing -> fullimport currcidtree
+					Just lastimportedtree -> diffimport cidtreemap prevcidtree currcidtree lastimportedtree
+  where
+	remember = recordContentIdentifierTree (Remote.uuid remote)
+
+	-- In order to use a diff, the previous ContentIdentifier tree must
+	-- not have been garbage collected. Which can happen since there
+	-- are no git refs to it.
+	--
+	-- Also, a tree must have been imported before, and that tree must
+	-- also have not been garbage collected (which is less likely to
+	-- happen due to the remote tracking branch).
+	candiffimport prevcidtree =
+		catObjectMetaData prevcidtree >>= \case
+			Nothing -> return Nothing
+			Just _ -> getLastImportedTree remote >>= \case
+				Nothing -> return Nothing
+				Just lastimported@(LastImportedTree t) -> 
+					ifM (isJust <$> catObjectMetaData t)
+						( return (Just lastimported)
+						, return Nothing
+						)
+
+	fullimport currcidtree = 
+		importKeys remote importtreeconfig importcontent thirdpartypopulated importablecontents >>= \case
+			ImportUnfinished -> return ImportUnfinished
+			ImportFinished r -> do
+				remember currcidtree
+	 			return $ ImportFinished $ ImportedFull r
+		
+	diffimport cidtreemap prevcidtree currcidtree lastimportedtree = do
+		(diff, cleanup) <- inRepo $ Git.DiffTree.diffTreeRecursive
+			prevcidtree
+			currcidtree
+		let (removed, changed) = partition isremoval diff
+		let mkicchanged ti = do
+			v <- M.lookup (Git.DiffTree.dstsha ti) cidtreemap
+			return (mkloc ti, v)
+		let ic = ImportableContentsComplete $ ImportableContents
+			{ importableContents = mapMaybe mkicchanged changed
+			, importableHistory = []
+			}
+		importKeys remote importtreeconfig importcontent thirdpartypopulated ic >>= \case
+			ImportUnfinished -> do
+				void $ liftIO cleanup
+				return ImportUnfinished
+			ImportFinished (ImportableContentsComplete ic') -> 
+				liftIO cleanup >>= \case
+					False -> return ImportUnfinished
+					True -> do
+						remember currcidtree
+						return $ ImportFinished $ 
+							ImportedDiff lastimportedtree
+								(mkdiff ic' removed)
+			-- importKeys is not passed ImportableContentsChunked
+			-- above, so it cannot return it
+			ImportFinished (ImportableContentsChunked {}) -> error "internal"
+		
+	isremoval ti = Git.DiffTree.dstsha ti `elem` nullShas
+	
+	mkloc = mkImportLocation . getTopFilePath . Git.DiffTree.file
+
+	mkdiff ic removed = ImportableContents
+		{ importableContents = diffremoved ++ diffchanged
+		, importableHistory = []
+		}
+	  where
+		diffchanged = map
+			(\(loc, v) -> (loc, DiffChanged v))
+			(importableContents ic)
+		diffremoved = map
+			(\ti -> (mkloc ti, DiffRemoved))
+			removed
+
+{- Gets the tree that was last imported from the remote
+ - (or exported to it if an export happened after the last import).
+ -}
+getLastImportedTree :: Remote -> Annex (Maybe LastImportedTree)
+getLastImportedTree remote = do
+	db <- Export.openDb (Remote.uuid remote)
+	mtree <- liftIO $ Export.getExportTreeCurrent db
+	Export.closeDb db
+	return (LastImportedTree <$> mtree)
+
 {- Downloads all new ContentIdentifiers, or when importcontent is False,
  - generates Keys without downloading.
  -
@@ -374,9 +601,6 @@
  -
  - Supports concurrency when enabled.
  -
- - If it fails on any file, the whole thing fails with Nothing, 
- - but it will resume where it left off.
- -
  - Note that, when a ContentIdentifier has been imported before,
  - generates the same thing that was imported before, so annex.largefiles
  - is not reapplied.
@@ -387,7 +611,7 @@
 	-> Bool
 	-> Bool
 	-> ImportableContentsChunkable Annex (ContentIdentifier, ByteSize)
-	-> Annex (Maybe (ImportableContentsChunkable Annex (Either Sha Key)))
+	-> Annex (ImportResult (ImportableContentsChunkable Annex (Either Sha Key)))
 importKeys remote importtreeconfig importcontent thirdpartypopulated importablecontents = do
 	unless (canImportKeys remote importcontent) $
 		giveup "This remote does not support importing without downloading content."
@@ -402,9 +626,9 @@
 	-- avoid two threads both importing the same content identifier.
 	importing <- liftIO $ newTVarIO S.empty
 	withciddb $ \db -> do
-		CIDDb.needsUpdateFromLog db
-			>>= maybe noop (CIDDb.updateFromLog db)
-		(prepclock (run cidmap importing db))
+		db' <- CIDDb.needsUpdateFromLog db
+			>>= maybe (pure db) (CIDDb.updateFromLog db)
+		(prepclock (run cidmap importing db'))
   where
 	-- When not importing content, reuse the same vector
 	-- clock for all state that's recorded. This can save
@@ -425,13 +649,13 @@
 		case importablecontents of
 			ImportableContentsComplete ic ->
 				go False largematcher cidmap importing db ic >>= return . \case
-					Nothing -> Nothing
-					Just v -> Just $ ImportableContentsComplete v
+					Nothing -> ImportUnfinished
+					Just v -> ImportFinished $ ImportableContentsComplete v
 			ImportableContentsChunked {} -> do
 				c <- gochunked db (importableContentsChunk importablecontents)
 				gohistory largematcher cidmap importing db (importableHistoryComplete importablecontents) >>= return . \case
-					Nothing -> Nothing
-					Just h -> Just $ ImportableContentsChunked
+					Nothing -> ImportUnfinished
+					Just h -> ImportFinished $ ImportableContentsChunked
 						{ importableContentsChunk = c
 						, importableHistoryComplete = h
 						}
@@ -458,10 +682,10 @@
 	gochunked db c
 		-- Downloading cannot be done when chunked, since only
 		-- the first chunk is processed before returning.
-		| importcontent = error "importKeys does not support downloading chunked import"
+		| importcontent = giveup "importKeys does not support downloading chunked import"
 		-- Chunked import is currently only used by thirdpartypopulated
 		-- remotes.
-		| not thirdpartypopulated = error "importKeys does not support chunked import when not thirdpartypopulated"
+		| not thirdpartypopulated = giveup "importKeys does not support chunked import when not thirdpartypopulated"
 		| otherwise = do
 			l <- forM (importableContentsSubTree c) $ \(loc, i) -> do
 				let loc' = importableContentsChunkFullLocation (importableContentsSubDir c) loc
@@ -503,14 +727,14 @@
 			in return $ Left $ Just (loc, v)
 		[] -> do
 			job <- liftIO $ newEmptyTMVarIO
-			let ai = ActionItemOther (Just (fromRawFilePath (fromImportLocation loc)))
+			let ai = ActionItemOther (Just (QuotedPath (fromImportLocation loc)))
 			let si = SeekInput []
 			let importaction = starting ("import " ++ Remote.name remote) ai si $ do
 				when oldversion $
 					showNote "old version"
-				tryNonAsync (importordownload cidmap db i largematcher) >>= \case
+				tryNonAsync (importordownload cidmap i largematcher) >>= \case
 					Left e -> next $ do
-						warning (show e)
+						warning (UnquotedString (show e))
 						liftIO $ atomically $
 							putTMVar job Nothing
 						return False
@@ -530,15 +754,15 @@
 			Just importkey ->
 				tryNonAsync (importkey loc cid sz nullMeterUpdate) >>= \case
 					Right (Just k) -> do
-						recordcidkey' db cid k
+						recordcidkeyindb db cid k
 						logChange k (Remote.uuid remote) InfoPresent
 						return $ Just (loc, Right k)
 					Right Nothing -> return Nothing
 					Left e -> do
-						warning (show e)
+						warning (UnquotedString (show e))
 						return Nothing
 	
-	importordownload cidmap db (loc, (cid, sz)) largematcher= do
+	importordownload cidmap (loc, (cid, sz)) largematcher= do
 		f <- locworktreefile loc
 		matcher <- largematcher f
 		-- When importing a key is supported, always use it rather
@@ -551,9 +775,9 @@
 					then dodownload
 					else doimport
 			else doimport
-		act cidmap db (loc, (cid, sz)) f matcher
+		act cidmap (loc, (cid, sz)) f matcher
 
-	doimport cidmap db (loc, (cid, sz)) f matcher =
+	doimport cidmap (loc, (cid, sz)) f matcher =
 		case Remote.importKey ia of
 			Nothing -> error "internal" -- checked earlier
 			Just importkey -> do
@@ -570,15 +794,15 @@
 				let bwlimit = remoteAnnexBwLimit (Remote.gitconfig remote)
 				islargefile <- checkMatcher' matcher mi mempty
 				metered Nothing sz bwlimit $ const $ if islargefile
-					then doimportlarge importkey cidmap db loc cid sz f
-					else doimportsmall cidmap db loc cid sz
+					then doimportlarge importkey cidmap loc cid sz f
+					else doimportsmall cidmap loc cid sz
 	
-	doimportlarge importkey cidmap db loc cid sz f p =
+	doimportlarge importkey cidmap loc cid sz f p =
 		tryNonAsync importer >>= \case
 			Right (Just (k, True)) -> return $ Just (loc, Right k)
 			Right _ -> return Nothing
 			Left e -> do
-				warning (show e)
+				warning (UnquotedString (show e))
 				return Nothing
 	  where
 		importer = do
@@ -591,7 +815,7 @@
 				Nothing -> return Nothing
 				Just k -> checkSecureHashes k >>= \case
 					Nothing -> do
-						recordcidkey cidmap db cid k
+						recordcidkey cidmap cid k
 						logChange k (Remote.uuid remote) InfoPresent
 						if importcontent
 							then getcontent k
@@ -618,7 +842,7 @@
 	-- The file is small, so is added to git, so while importing
 	-- without content does not retrieve annexed files, it does
 	-- need to retrieve this file.
-	doimportsmall cidmap db loc cid sz p = do
+	doimportsmall cidmap loc cid sz p = do
 		let downloader tmpfile = do
 			(k, _) <- Remote.retrieveExportWithContentIdentifier
 				ia loc [cid] (fromRawFilePath tmpfile)
@@ -626,7 +850,7 @@
 				p
 			case keyGitSha k of
 				Just sha -> do
-					recordcidkey cidmap db cid k
+					recordcidkey cidmap cid k
 					return sha
 				Nothing -> error "internal"
 		checkDiskSpaceToGet tmpkey Nothing $
@@ -634,13 +858,13 @@
 				tryNonAsync (downloader tmpfile) >>= \case
 					Right sha -> return $ Just (loc, Left sha)
 					Left e -> do
-						warning (show e)
+						warning (UnquotedString (show e))
 						return Nothing
 	  where
 		tmpkey = importKey cid sz
 		mkkey tmpfile = gitShaKey <$> hashFile tmpfile
 	
-	dodownload cidmap db (loc, (cid, sz)) f matcher = do
+	dodownload cidmap (loc, (cid, sz)) f matcher = do
 		let af = AssociatedFile (Just f)
 		let downloader tmpfile p = do
 			(k, _) <- Remote.retrieveExportWithContentIdentifier
@@ -651,18 +875,18 @@
 				Nothing -> do
 					ok <- moveAnnex k af tmpfile
 					when ok $ do
-						recordcidkey cidmap db cid k
+						recordcidkey cidmap cid k
 						logStatus k InfoPresent
 						logChange k (Remote.uuid remote) InfoPresent
 					return (Right k, ok)
 				Just sha -> do
-					recordcidkey cidmap db cid k
+					recordcidkey cidmap cid k
 					return (Left sha, True)
 		let rundownload tmpfile p = tryNonAsync (downloader tmpfile p) >>= \case
 			Right (v, True) -> return $ Just (loc, v)
 			Right (_, False) -> return Nothing
 			Left e -> do
-				warning (show e)
+				warning (UnquotedString (show e))
 				return Nothing
 		let bwlimit = remoteAnnexBwLimit (Remote.gitconfig remote)
 		checkDiskSpaceToGet tmpkey Nothing $
@@ -701,17 +925,29 @@
 				getTopFilePath subdir P.</> fromImportLocation loc
 
 	getcidkey cidmap db cid = liftIO $
-		CIDDb.getContentIdentifierKeys db rs cid >>= \case
-			[] -> atomically $
-				maybeToList . M.lookup cid <$> readTVar cidmap
-			l -> return l
+		-- Avoiding querying the database when it's empty speeds up
+		-- the initial import.
+		if CIDDb.databaseIsEmpty db
+			then getcidkeymap cidmap cid
+			else CIDDb.getContentIdentifierKeys db rs cid >>= \case
+				[] -> getcidkeymap cidmap cid
+				l -> return l
 
-	recordcidkey cidmap db cid k = do
+	getcidkeymap cidmap cid =
+		atomically $ maybeToList . M.lookup cid <$> readTVar cidmap
+
+	recordcidkey cidmap cid k = do
 		liftIO $ atomically $ modifyTVar' cidmap $
 			M.insert cid k
-		recordcidkey' db cid k
-	recordcidkey' db cid k = do
+		-- Only record in log now; the database will be updated
+		-- later from the log, and the cidmap will be used for now.
+		recordcidkeyinlog cid k
+	
+	recordcidkeyindb db cid k = do
 		liftIO $ CIDDb.recordContentIdentifier db rs cid k
+		recordcidkeyinlog cid k
+	
+	recordcidkeyinlog cid k =
 		CIDLog.recordContentIdentifier rs cid k
 
 	rs = Remote.remoteStateHandle remote
@@ -813,7 +1049,10 @@
 				Just c' -> Just <$> filterunwantedchunk dbhandle c'
 			)
 
-	opendbhandle = Export.openDb (Remote.uuid r)
+	opendbhandle = do
+		h <- Export.openDb (Remote.uuid r)
+		void $ Export.updateExportTreeFromLog h
+		return h
 
 	wanted dbhandle (loc, (_cid, sz))
 		| ingitdir = pure False
diff --git a/Annex/Ingest.hs b/Annex/Ingest.hs
--- a/Annex/Ingest.hs
+++ b/Annex/Ingest.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Annex.Ingest (
 	LockedDown(..),
 	LockDownConfig(..),
@@ -85,7 +87,7 @@
  -}
 lockDown :: LockDownConfig-> FilePath -> Annex (Maybe LockedDown)
 lockDown cfg file = either 
-		(\e -> warning (show e) >> return Nothing)
+		(\e -> warning (UnquotedString (show e)) >> return Nothing)
 		(return . Just)
 	=<< lockDown' cfg file
 
@@ -133,16 +135,16 @@
 		
 	setperms = when (lockingFile cfg) $ do
 		freezeContent file'
-		when (checkWritePerms cfg) $
-			maybe noop giveup =<< checkLockedDownWritePerms file' file'
+		when (checkWritePerms cfg) $ do
+			qp <- coreQuotePath <$> Annex.getGitConfig
+			maybe noop (giveup . decodeBS . quote qp)
+				=<< checkLockedDownWritePerms file' file'
 
-checkLockedDownWritePerms :: RawFilePath -> RawFilePath -> Annex (Maybe String)
+checkLockedDownWritePerms :: RawFilePath -> RawFilePath -> Annex (Maybe StringContainingQuotedPath)
 checkLockedDownWritePerms file displayfile = checkContentWritePerm file >>= return . \case
-	Just False -> Just $ unwords
-		[ "Unable to remove all write permissions from"
-		, fromRawFilePath displayfile
-		, "-- perhaps it has an xattr or ACL set."
-		]
+	Just False -> Just $ "Unable to remove all write permissions from "
+		<> QuotedPath displayfile
+		<> " -- perhaps it has an xattr or ACL set."
 	_ -> Nothing
 
 {- Ingests a locked down file into the annex. Updates the work tree and
@@ -224,7 +226,7 @@
 		return (Just k, mcache)
 
 	failure msg = do
-		warning $ fromRawFilePath (keyFilename source) ++ " " ++ msg
+		warning $ QuotedPath (keyFilename source) <> " " <> UnquotedString msg
 		cleanCruft source
 		return (Nothing, Nothing)
 
@@ -296,7 +298,7 @@
 		-- content in the annex, and make a copy back to the file.
 		obj <- fromRawFilePath <$> calcRepo (gitAnnexLocation key)
 		unlessM (liftIO $ copyFileExternal CopyTimeStamps obj (fromRawFilePath file)) $
-			warning $ "Unable to restore content of " ++ fromRawFilePath file ++ "; it should be located in " ++ obj
+			warning $ "Unable to restore content of " <> QuotedPath file <> "; it should be located in " <> QuotedPath (toRawFilePath obj)
 		thawContent file
 	throwM e
 
@@ -409,11 +411,10 @@
 addingExistingLink f k a = do
 	unlessM (isKnownKey k <||> inAnnex k) $ do
 		islink <- isJust <$> isAnnexLink f
-		warning $ unwords
-			[ fromRawFilePath f
-			, "is a git-annex"
-			, if islink then "symlink." else "pointer file."
-			, "Its content is not available in this repository."
-			, "(Maybe " ++ fromRawFilePath f ++ " was copied from another repository?)"
-			]
+		warning $
+			QuotedPath f
+			<> " is a git-annex "
+			<> if islink then "symlink." else "pointer file."
+			<> " Its content is not available in this repository."
+			<> " (Maybe " <> QuotedPath f <> " was copied from another repository?)"
 	a
diff --git a/Annex/Init.hs b/Annex/Init.hs
--- a/Annex/Init.hs
+++ b/Annex/Init.hs
@@ -79,7 +79,7 @@
 	Just noannexmsg -> do
 		warning "Initialization prevented by .noannex file (remove the file to override)"
 		unless (null noannexmsg) $
-			warning noannexmsg
+			warning (UnquotedString noannexmsg)
 		giveup "Not initialized."
 
 initializeAllowed :: Annex Bool
@@ -272,7 +272,7 @@
 		(Just (freezeContent' UnShared))
 		(Just (thawContent' UnShared))
 		=<< hasFreezeHook
-	mapM_ warning warnings
+	mapM_ (warning . UnquotedString) warnings
 	return r
 
 probeCrippledFileSystem'
@@ -404,8 +404,8 @@
 		Right () -> return ()
 		Left e -> do
 			showLongNote $ "Detected a filesystem where Sqlite does not work."
-			showLongNote $ "(" ++ show e ++ ")"
-			showLongNote $ "To work around this problem, you can set annex.dbdir " ++
+			showLongNote $ UnquotedString $ "(" ++ show e ++ ")"
+			showLongNote $ "To work around this problem, you can set annex.dbdir " <>
 				"to a directory on another filesystem."
 			showLongNote $ "For example: git config annex.dbdir $HOME/cache/git-annex"
 			giveup "Not initialized."
diff --git a/Annex/InodeSentinal.hs b/Annex/InodeSentinal.hs
--- a/Annex/InodeSentinal.hs
+++ b/Annex/InodeSentinal.hs
@@ -93,6 +93,8 @@
 		s <- annexSentinalFile
 		createAnnexDirectory (parentDir (sentinalFile s))
 		liftIO $ writeSentinalFile s
+		setAnnexFilePerm (sentinalFile s)
+		setAnnexFilePerm (sentinalCacheFile s)
   where
 	alreadyexists = liftIO. sentinalFileExists =<< annexSentinalFile
 	hasobjects
diff --git a/Annex/Journal.hs b/Annex/Journal.hs
--- a/Annex/Journal.hs
+++ b/Annex/Journal.hs
@@ -91,7 +91,10 @@
 	let tmpfile = tmp P.</> jfile
 	liftIO $ withFile (fromRawFilePath tmpfile) WriteMode $ \h ->
 		writeJournalHandle h content
-	let mv = liftIO $ moveFile tmpfile (jd P.</> jfile)
+	let dest = jd P.</> jfile
+	let mv = do
+		liftIO $ moveFile tmpfile dest
+		setAnnexFilePerm dest
 	-- avoid overhead of creating the journal directory when it already
 	-- exists
 	mv `catchIO` (const (createAnnexDirectory jd >> mv))
@@ -227,8 +230,13 @@
 {- Directory handle open on a journal directory. -}
 withJournalHandle :: (Git.Repo -> RawFilePath) -> (DirectoryHandle -> IO a) -> Annex a
 withJournalHandle getjournaldir a = do
-	d <- fromRawFilePath <$> fromRepo getjournaldir
-	bracketIO (openDirectory d) closeDirectory (liftIO . a)
+	d <- fromRepo getjournaldir
+	bracket (opendir d) (liftIO . closeDirectory) (liftIO . a)
+  where
+	-- avoid overhead of creating the journal directory when it already
+	-- exists
+	opendir d = liftIO (openDirectory (fromRawFilePath d))
+		`catchIO` (const (createAnnexDirectory d >> opendir d))
 
 {- Checks if there are changes in the journal. -}
 journalDirty :: (Git.Repo -> RawFilePath) -> Annex Bool
diff --git a/Annex/Link.hs b/Annex/Link.hs
--- a/Annex/Link.hs
+++ b/Annex/Link.hs
@@ -186,7 +186,7 @@
 restagePointerFile :: Restage -> RawFilePath -> InodeCache -> Annex ()
 restagePointerFile (Restage False) f orig = do
 	flip writeRestageLog orig =<< inRepo (toTopFilePath f)
-	toplevelWarning True $ unableToRestage $ Just $ fromRawFilePath f
+	toplevelWarning True $ unableToRestage $ Just f
 restagePointerFile (Restage True) f orig = do
 	flip writeRestageLog orig =<< inRepo (toTopFilePath f)
 	-- Avoid refreshing the index if run by the
@@ -319,16 +319,15 @@
 		ck = ConfigKey "filter.annex.process"
 		ckd = ConfigKey "filter.annex.process-temp-disabled"
 
-unableToRestage :: Maybe FilePath -> String
-unableToRestage mf = unwords
-	[ "git status will show " ++ fromMaybe "some files" mf
-	, "to be modified, since content availability has changed"
-	, "and git-annex was unable to update the index."
-	, "This is only a cosmetic problem affecting git status; git add,"
-	, "git commit, etc won't be affected."
-	, "To fix the git status display, you can run:"
-	, "git-annex restage"
-	]
+unableToRestage :: Maybe RawFilePath -> StringContainingQuotedPath
+unableToRestage mf =
+	"git status will show " <> maybe "some files" QuotedPath mf
+	<> " to be modified, since content availability has changed"
+	<> " and git-annex was unable to update the index."
+	<> " This is only a cosmetic problem affecting git status; git add,"
+	<> " git commit, etc won't be affected."
+	<> " To fix the git status display, you can run:"
+	<> " git-annex restage"
 
 {- Parses a symlink target or a pointer file to a Key.
  -
diff --git a/Annex/Locations.hs b/Annex/Locations.hs
--- a/Annex/Locations.hs
+++ b/Annex/Locations.hs
@@ -1,6 +1,6 @@
 {- git-annex file locations
  -
- - Copyright 2010-2022 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -52,6 +52,8 @@
 	gitAnnexRestageLog,
 	gitAnnexRestageLogOld,
 	gitAnnexRestageLock,
+	gitAnnexAdjustedBranchUpdateLog,
+	gitAnnexAdjustedBranchUpdateLock,
 	gitAnnexMoveLog,
 	gitAnnexMoveLock,
 	gitAnnexExportDir,
@@ -59,6 +61,8 @@
 	gitAnnexExportLock,
 	gitAnnexExportUpdateLock,
 	gitAnnexExportExcludeLog,
+	gitAnnexImportDir,
+	gitAnnexImportLog,
 	gitAnnexContentIdentifierDbDir,
 	gitAnnexContentIdentifierLock,
 	gitAnnexScheduleState,
@@ -393,6 +397,14 @@
 gitAnnexRestageLock :: Git.Repo -> RawFilePath
 gitAnnexRestageLock r = gitAnnexDir r P.</> "restage.lck"
 
+{- .git/annex/adjust.log is used to log when the adjusted branch needs to
+ - be updated. -}
+gitAnnexAdjustedBranchUpdateLog :: Git.Repo -> RawFilePath
+gitAnnexAdjustedBranchUpdateLog r = gitAnnexDir r P.</> "adjust.log"
+
+gitAnnexAdjustedBranchUpdateLock :: Git.Repo -> RawFilePath
+gitAnnexAdjustedBranchUpdateLock r = gitAnnexDir r P.</> "adjust.lck"
+
 {- .git/annex/move.log is used to log moves that are in progress,
  - to better support resuming an interrupted move. -}
 gitAnnexMoveLog :: Git.Repo -> RawFilePath
@@ -437,6 +449,16 @@
 {- Lock file for writing to the content id database. -}
 gitAnnexContentIdentifierLock :: Git.Repo -> GitConfig -> RawFilePath
 gitAnnexContentIdentifierLock r c = gitAnnexContentIdentifierDbDir r c <> ".lck"
+
+{- .git/annex/import/ is used to store information about
+ - imports from special remotes. -}
+gitAnnexImportDir :: Git.Repo -> GitConfig -> RawFilePath
+gitAnnexImportDir r c = fromMaybe (gitAnnexDir r) (annexDbDir c) P.</> "import"
+
+{- File containing state about the last import done from a remote. -}
+gitAnnexImportLog :: UUID -> Git.Repo -> GitConfig -> RawFilePath
+gitAnnexImportLog u r c = 
+	gitAnnexImportDir r c P.</> fromUUID u P.</> "log"
 
 {- .git/annex/schedulestate is used to store information about when
  - scheduled jobs were last run. -}
diff --git a/Annex/LockFile.hs b/Annex/LockFile.hs
--- a/Annex/LockFile.hs
+++ b/Annex/LockFile.hs
@@ -36,7 +36,7 @@
 	go Nothing = do
 #ifndef mingw32_HOST_OS
 		mode <- annexFileMode
-		lockhandle <- noUmask mode $ lockShared (Just mode) file
+		lockhandle <- lockShared (Just mode) file
 #else
 		lockhandle <- liftIO $ waitToLock $ lockShared file
 #endif
@@ -69,7 +69,7 @@
 	bracket (lock mode lockfile) (liftIO . dropLock) (const a)
   where
 #ifndef mingw32_HOST_OS
-	lock mode = noUmask mode . lockShared (Just mode)
+	lock mode = lockShared (Just mode)
 #else
 	lock _mode = liftIO . waitToLock . lockShared
 #endif
@@ -90,7 +90,7 @@
 	lock mode lockfile
   where
 #ifndef mingw32_HOST_OS
-	lock mode = noUmask mode . lockExclusive (Just mode)
+	lock mode = lockExclusive (Just mode)
 #else
 	lock _mode = liftIO . waitToLock . lockExclusive
 #endif
@@ -104,7 +104,7 @@
 	bracket (lock mode lockfile) (liftIO . unlock) go
   where
 #ifndef mingw32_HOST_OS
-	lock mode = noUmask mode . tryLockExclusive (Just mode)
+	lock mode = tryLockExclusive (Just mode)
 #else
 	lock _mode = liftIO . lockExclusive
 #endif
diff --git a/Annex/LockPool/PosixOrPid.hs b/Annex/LockPool/PosixOrPid.hs
--- a/Annex/LockPool/PosixOrPid.hs
+++ b/Annex/LockPool/PosixOrPid.hs
@@ -26,25 +26,27 @@
 import qualified Utility.LockPool.Posix as Posix
 import qualified Utility.LockPool.PidLock as Pid
 import qualified Utility.LockPool.LockHandle as H
+import Utility.FileMode
 import Utility.LockPool.LockHandle (LockHandle, dropLock)
 import Utility.LockFile.Posix (openLockFile)
 import Utility.LockPool.STM (LockFile, LockMode(..))
 import Utility.LockFile.LockStatus
 import Config (pidLockFile)
 import Messages (warning)
+import Git.Quote
 
 import System.Posix
 
-lockShared :: Maybe FileMode -> LockFile -> Annex LockHandle
+lockShared :: Maybe ModeSetter -> LockFile -> Annex LockHandle
 lockShared m f = pidLock m f LockShared $ Posix.lockShared m f
 
-lockExclusive :: Maybe FileMode -> LockFile -> Annex LockHandle
+lockExclusive :: Maybe ModeSetter -> LockFile -> Annex LockHandle
 lockExclusive m f = pidLock m f LockExclusive $ Posix.lockExclusive m f
 
-tryLockShared :: Maybe FileMode -> LockFile -> Annex (Maybe LockHandle)
+tryLockShared :: Maybe ModeSetter -> LockFile -> Annex (Maybe LockHandle)
 tryLockShared m f = tryPidLock m f LockShared $ Posix.tryLockShared m f
 
-tryLockExclusive :: Maybe FileMode -> LockFile -> Annex (Maybe LockHandle)
+tryLockExclusive :: Maybe ModeSetter -> LockFile -> Annex (Maybe LockHandle)
 tryLockExclusive m f = tryPidLock m f LockExclusive $ Posix.tryLockExclusive m f
 
 checkLocked :: LockFile -> Annex (Maybe Bool)
@@ -67,16 +69,16 @@
 pidLockCheck posixcheck pidcheck = debugLocks $
 	liftIO . maybe posixcheck pidcheck =<< pidLockFile
 
-pidLock :: Maybe FileMode -> LockFile -> LockMode -> IO LockHandle -> Annex LockHandle
+pidLock :: Maybe ModeSetter -> LockFile -> LockMode -> IO LockHandle -> Annex LockHandle
 pidLock m f lockmode posixlock = debugLocks $ go =<< pidLockFile
   where
 	go Nothing = liftIO posixlock
 	go (Just pidlock) = do
 		timeout <- annexPidLockTimeout <$> Annex.getGitConfig
 		liftIO $ dummyPosixLock m f
-		Pid.waitLock f lockmode timeout pidlock warning
+		Pid.waitLock f lockmode timeout pidlock (warning . UnquotedString)
 
-tryPidLock :: Maybe FileMode -> LockFile -> LockMode -> IO (Maybe LockHandle) -> Annex (Maybe LockHandle)
+tryPidLock :: Maybe ModeSetter -> LockFile -> LockMode -> IO (Maybe LockHandle) -> Annex (Maybe LockHandle)
 tryPidLock m f lockmode posixlock = debugLocks $ liftIO . go =<< pidLockFile
   where
 	go Nothing = posixlock
@@ -87,5 +89,5 @@
 -- The posix lock file is created even when using pid locks, in order to
 -- avoid complicating any code that might expect to be able to see that
 -- lock file. But, it's not locked.
-dummyPosixLock :: Maybe FileMode -> LockFile -> IO ()
+dummyPosixLock :: Maybe ModeSetter -> LockFile -> IO ()
 dummyPosixLock m f = bracket (openLockFile ReadLock m f) closeFd (const noop)
diff --git a/Annex/MetaData.hs b/Annex/MetaData.hs
--- a/Annex/MetaData.hs
+++ b/Annex/MetaData.hs
@@ -56,7 +56,7 @@
 					dateMetaData (posixSecondsToUTCTime mtime) old
 			Nothing -> noop
   where
-	warncopied = warning $ 
+	warncopied = warning $ UnquotedString $
 		"Copied metadata from old version of " ++ fromRawFilePath file ++ " to new version. " ++ 
 		"If you don't want this copied metadata, run: git annex metadata --remove-all " ++ fromRawFilePath file
 	-- If the only fields copied were date metadata, and they'll
diff --git a/Annex/NumCopies.hs b/Annex/NumCopies.hs
--- a/Annex/NumCopies.hs
+++ b/Annex/NumCopies.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE ScopedTypeVariables, DeriveDataTypeable #-}
+{-# LANGUAGE ScopedTypeVariables, DeriveDataTypeable, OverloadedStrings #-}
 
 module Annex.NumCopies (
 	module Types.NumCopies,
@@ -277,17 +277,17 @@
 notEnoughCopies key neednum needmin have skip bad nolocmsg lockunsupported = do
 	showNote "unsafe"
 	if length have < fromNumCopies neednum
-		then showLongNote $
+		then showLongNote $ UnquotedString $
 			"Could only verify the existence of " ++
 			show (length have) ++ " out of " ++ show (fromNumCopies neednum) ++ 
 			" necessary " ++ pluralcopies (fromNumCopies neednum)
 		else do
-			showLongNote $ "Unable to lock down " ++ show (fromMinCopies needmin) ++ 
+			showLongNote $ UnquotedString $ "Unable to lock down " ++ show (fromMinCopies needmin) ++ 
 				" " ++ pluralcopies (fromMinCopies needmin) ++ 
 				" of file necessary to safely drop it."
 			if null lockunsupported
 				then showLongNote "(This could have happened because of a concurrent drop, or because a remote has too old a version of git-annex-shell installed.)"
-				else showLongNote $ "These remotes do not support locking: "
+				else showLongNote $ UnquotedString $ "These remotes do not support locking: "
 					++ Remote.listRemoteNames lockunsupported
 
 	Remote.showTriedRemotes bad
diff --git a/Annex/Perms.hs b/Annex/Perms.hs
--- a/Annex/Perms.hs
+++ b/Annex/Perms.hs
@@ -1,6 +1,6 @@
 {- git-annex file permissions
  -
- - Copyright 2012-2022 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -15,7 +15,6 @@
 	annexFileMode,
 	createAnnexDirectory,
 	createWorkTreeDirectory,
-	noUmask,
 	freezeContent,
 	freezeContent',
 	freezeContent'',
@@ -44,7 +43,7 @@
 import Utility.Directory.Create
 import qualified Utility.RawFilePath as R
 
-import System.PosixCompat.Files (fileMode, intersectFileModes, nullFileMode, groupWriteMode, ownerWriteMode, ownerReadMode, groupReadMode, stdFileMode, ownerExecuteMode, groupExecuteMode)
+import System.PosixCompat.Files (fileMode, intersectFileModes, nullFileMode, groupWriteMode, ownerWriteMode, ownerReadMode, groupReadMode, otherReadMode, stdFileMode, ownerExecuteMode, groupExecuteMode, otherExecuteMode, setGroupIDMode)
 
 withShared :: (SharedRepository -> Annex a) -> Annex a
 withShared a = a =<< coreSharedRepository <$> Annex.getGitConfig
@@ -60,23 +59,27 @@
  - don't change the mode, but with core.sharedRepository set,
  - allow the group to write, etc. -}
 setAnnexPerm :: Bool -> RawFilePath -> Annex ()
-setAnnexPerm = setAnnexPerm' Nothing
+setAnnexPerm isdir file = setAnnexPerm' Nothing isdir >>= \go -> liftIO (go file)
 
-setAnnexPerm' :: Maybe ([FileMode] -> FileMode -> FileMode) -> Bool -> RawFilePath -> Annex ()
-setAnnexPerm' modef isdir file = unlessM crippledFileSystem $
-	withShared $ liftIO . go
+setAnnexPerm' :: Maybe ([FileMode] -> FileMode -> FileMode) -> Bool -> Annex (RawFilePath -> IO ())
+setAnnexPerm' modef isdir = ifM crippledFileSystem
+	( return (const noop)
+	, withShared $ \s -> return $ \file -> go s file
+	)
   where
-	go GroupShared = void $ tryIO $ modifyFileMode file $ modef' $
+	go GroupShared file = void $ tryIO $ modifyFileMode file $ modef' $
 		groupSharedModes ++
 		if isdir then [ ownerExecuteMode, groupExecuteMode ] else []
-	go AllShared = void $ tryIO $ modifyFileMode file $ modef' $
+	go AllShared file = void $ tryIO $ modifyFileMode file $ modef' $
 		readModes ++
 		[ ownerWriteMode, groupWriteMode ] ++
 		if isdir then executeModes else []
-	go _ = case modef of
+	go UnShared file = case modef of
 		Nothing -> noop
 		Just f -> void $ tryIO $
 			modifyFileMode file $ f []
+	go (UmaskShared n) file = void $ tryIO $ R.setFileMode file $
+		if isdir then umaskSharedDirectory n else n
 	modef' = fromMaybe addModes modef
 
 resetAnnexFilePerm :: RawFilePath -> Annex ()
@@ -94,19 +97,19 @@
 resetAnnexPerm isdir file = unlessM crippledFileSystem $ do
 	defmode <- liftIO defaultFileMode
 	let modef moremodes _oldmode = addModes moremodes defmode
-	setAnnexPerm' (Just modef) isdir file
+	setAnnexPerm' (Just modef) isdir >>= \go -> liftIO (go file)
 
-{- Gets the appropriate mode to use for creating a file in the annex
- - (other than content files, which are locked down more). The umask is not
- - taken into account; this is for use with actions that create the file
- - and apply the umask automatically. -}
-annexFileMode :: Annex FileMode
-annexFileMode = withShared $ return . go
+{- Creates a ModeSetter which can be used for creating a file in the annex
+ - (other than content files, which are locked down more). -}
+annexFileMode :: Annex ModeSetter
+annexFileMode = do
+	modesetter <- setAnnexPerm' Nothing False
+	withShared (\s -> pure $ mk s modesetter)
   where
-	go GroupShared = sharedmode
-	go AllShared = combineModes (sharedmode:readModes)
-	go _ = stdFileMode
-	sharedmode = combineModes groupSharedModes
+	mk GroupShared = ModeSetter stdFileMode
+	mk AllShared = ModeSetter stdFileMode
+	mk UnShared = ModeSetter stdFileMode
+	mk (UmaskShared mode) = ModeSetter mode
 
 {- Creates a directory inside the gitAnnexDir (or possibly the dbdir), 
  - creating any parent directories up to and including the gitAnnexDir.
@@ -168,6 +171,7 @@
 	unlessM crippledFileSystem $ go sr
 	freezeHook file
   where
+	go UnShared = liftIO $ nowriteadd [ownerReadMode]
 	go GroupShared = if versionNeedsWritableContentFiles rv
 		then liftIO $ ignoresharederr $ modmode $ addModes
 			[ownerReadMode, groupReadMode, ownerWriteMode, groupWriteMode]
@@ -178,7 +182,13 @@
 			(readModes ++ writeModes)
 		else liftIO $ ignoresharederr $ 
 			nowriteadd readModes
-	go _ = liftIO $ nowriteadd [ownerReadMode]
+	go (UmaskShared n) = if versionNeedsWritableContentFiles rv
+		-- Assume that the configured mode includes write bits
+		-- for all users who should be able to lock the file, so
+		-- don't need to add any write modes.
+		then liftIO $ ignoresharederr $ modmode $ const n
+		else liftIO $ ignoresharederr $ modmode $ const $
+			removeModes writeModes n
 
 	ignoresharederr = void . tryIO
 
@@ -206,14 +216,15 @@
 	, do
 		rv <- getVersion
 		hasfreezehook <- hasFreezeHook
-		withShared $ \sr -> liftIO $
-			checkContentWritePerm' sr file rv hasfreezehook
+		withShared $ \sr ->
+			liftIO $ checkContentWritePerm' sr file rv hasfreezehook
 	)
 
 checkContentWritePerm' :: SharedRepository -> RawFilePath -> Maybe RepoVersion -> Bool -> IO (Maybe Bool)
 checkContentWritePerm' sr file rv hasfreezehook
 	| hasfreezehook = return (Just True)
 	| otherwise = case sr of
+		UnShared -> want Just (excludemodes writeModes)
 		GroupShared
 			| versionNeedsWritableContentFiles rv -> want sharedret
 				(includemodes [ownerWriteMode, groupWriteMode])
@@ -222,7 +233,11 @@
 			| versionNeedsWritableContentFiles rv -> 
 				want sharedret (includemodes writeModes)
 			| otherwise -> want sharedret (excludemodes writeModes)
-		_ -> want Just (excludemodes writeModes)
+		UmaskShared n
+			| versionNeedsWritableContentFiles rv -> want sharedret
+				(\havemode -> havemode == n)
+			| otherwise -> want sharedret
+				(\havemode -> havemode == removeModes writeModes n)
   where
 	want mk f = catchMaybeIO (fileMode <$> R.getFileStatus file)
 		>>= return . \case
@@ -247,7 +262,8 @@
   where
 	go GroupShared = liftIO $ void $ tryIO $ groupWriteRead file
 	go AllShared = liftIO $ void $ tryIO $ groupWriteRead file
-	go _ = liftIO $ allowWrite file
+	go UnShared = liftIO $ allowWrite file
+	go (UmaskShared n) = liftIO $ void $ tryIO $ R.setFileMode file n
 
 {- Runs an action that thaws a file's permissions. This will probably
  - fail on a crippled filesystem. But, if file modes are supported on a
@@ -262,7 +278,7 @@
 {- Blocks writing to the directory an annexed file is in, to prevent the
  - file accidentally being deleted. However, if core.sharedRepository
  - is set, this is not done, since the group must be allowed to delete the
- - file.
+ - file without eing able to thaw the directory.
  -}
 freezeContentDir :: RawFilePath -> Annex ()
 freezeContentDir file = do
@@ -271,16 +287,26 @@
 	freezeHook dir
   where
 	dir = parentDir file
+	go UnShared = liftIO $ preventWrite dir
 	go GroupShared = liftIO $ void $ tryIO $ groupWriteRead dir
 	go AllShared = liftIO $ void $ tryIO $ groupWriteRead dir
-	go _ = liftIO $ preventWrite dir
+	go (UmaskShared n) = liftIO $ void $ tryIO $ R.setFileMode dir $
+		umaskSharedDirectory $ 
+			-- If n includes group or other write mode, leave them set
+			-- to allow them to delete the file without being able to
+			-- thaw the directory.
+			removeModes [ownerWriteMode] n
 
 thawContentDir :: RawFilePath -> Annex ()
 thawContentDir file = do
 	fastDebug "Annex.Perms" ("thawing content directory " ++ fromRawFilePath dir)
-	thawPerms (liftIO $ allowWrite dir) (thawHook dir)
+	thawPerms (withShared (liftIO . go)) (thawHook dir)
   where
 	dir = parentDir file
+	go UnShared = allowWrite dir
+	go GroupShared = allowWrite dir
+	go AllShared = allowWrite dir
+	go (UmaskShared n) = R.setFileMode dir n
 
 {- Makes the directory tree to store an annexed file's content,
  - with appropriate permissions on each level. -}
@@ -332,3 +358,17 @@
 	go basecmd = void $ liftIO $
 		boolSystem "sh" [Param "-c", Param $ gencmd basecmd]
 	gencmd = massReplace [ ("%path", shellEscape (fromRawFilePath p)) ]
+
+{- Calculate mode to use for a directory from the mode to use for a file.
+ -
+ - This corresponds to git's handling of core.sharedRepository=0xxx
+ -}
+umaskSharedDirectory :: FileMode -> FileMode
+umaskSharedDirectory n = flip addModes n $ map snd $ filter fst
+	[ (isset ownerReadMode, ownerExecuteMode)
+	, (isset groupReadMode, groupExecuteMode)
+	, (isset otherReadMode, otherExecuteMode)
+	, (isset groupReadMode || isset groupWriteMode, setGroupIDMode)
+	]
+  where
+	isset v = checkMode v n
diff --git a/Annex/SpecialRemote.hs b/Annex/SpecialRemote.hs
--- a/Annex/SpecialRemote.hs
+++ b/Annex/SpecialRemote.hs
@@ -23,6 +23,7 @@
 import Logs.Trust
 import qualified Types.Remote as Remote
 import Git.Types (RemoteName)
+import Utility.SafeOutput
 
 import qualified Data.Map as M
 
@@ -95,11 +96,16 @@
 			Just (Sameas u') -> u'
 			Nothing -> cu
 		case (lookupName c, findType c) of
-			(Just name, Right t) -> do
-				showSideAction $ "Auto enabling special remote " ++ name
+			-- Avoid auto-enabling when the name contains a
+			-- control character, because git does not avoid
+			-- displaying control characters in the name of a
+			-- remote, and an attacker could leverage
+			-- autoenabling it as part of an attack.
+			(Just name, Right t) | safeOutput name == name -> do
+				showSideAction $ UnquotedString $ "Auto enabling special remote " ++ name
 				dummycfg <- liftIO dummyRemoteGitConfig
 				tryNonAsync (setup t (AutoEnable c) (Just u) Nothing c dummycfg) >>= \case
-					Left e -> warning (show e)
+					Left e -> warning (UnquotedString (show e))
 					Right (_c, _u) ->
 						when (cu /= u) $
 							setConfig (remoteAnnexConfig c "config-uuid") (fromUUID cu)
diff --git a/Annex/SpecialRemote/Config.hs b/Annex/SpecialRemote/Config.hs
--- a/Annex/SpecialRemote/Config.hs
+++ b/Annex/SpecialRemote/Config.hs
@@ -109,8 +109,7 @@
 	, optionalStringParser sameasUUIDField HiddenField
 	, optionalStringParser typeField
 		(FieldDesc "type of special remote")
-	, trueFalseParser autoEnableField (Just False)
-		(FieldDesc "automatically enable special remote")
+	, autoEnableFieldParser
 	, costParser costField
 		(FieldDesc "default cost of this special remote")
 	, yesNoParser exportTreeField (Just False)
@@ -121,6 +120,10 @@
 		(FieldDesc "directory whose content is preferred")
 	]
 
+autoEnableFieldParser :: RemoteConfigFieldParser
+autoEnableFieldParser = trueFalseParser autoEnableField (Just False)
+	(FieldDesc "automatically enable special remote")
+
 {- A remote with sameas-uuid set will inherit these values from the config
  - of that uuid. These values cannot be overridden in the remote's config. -}
 sameasInherits :: S.Set RemoteConfigField
@@ -134,8 +137,12 @@
 	, pubkeysField
 	-- legacy chunking was either enabled or not, so has to be the same
 	-- across configs for remotes that access the same data
-	-- (new-style chunking does not have that limitation)
 	, chunksizeField
+	-- (new-style chunking does not have that limitation)
+	-- but there is no benefit to picking a different chunk size
+	-- for the sameas remote, since it's reading whatever chunks were
+	-- stored
+	, chunkField
 	]
 
 {- Each RemoteConfig that has a sameas-uuid inherits some fields
diff --git a/Annex/Ssh.hs b/Annex/Ssh.hs
--- a/Annex/Ssh.hs
+++ b/Annex/Ssh.hs
@@ -120,8 +120,8 @@
 	
 	warnnocaching whynocaching =
 		whenM (annexAdviceNoSshCaching <$> Annex.getGitConfig) $ do
-			warning nocachingwarning
-			warning whynocaching
+			warning $ UnquotedString nocachingwarning
+			warning $ UnquotedString whynocaching
 	
 	nocachingwarning = unwords
 		[ "You have enabled concurrency, but git-annex is not able"
@@ -311,7 +311,7 @@
 		let lockfile = socket2lock socketfile
 		unlockFile lockfile
 		mode <- annexFileMode
-		noUmask mode (tryLockExclusive (Just mode) lockfile) >>= \case
+		tryLockExclusive (Just mode) lockfile >>= \case
 			Nothing -> noop
 			Just lck -> do
 				forceStopSsh socketfile
@@ -432,7 +432,7 @@
 			( unchanged
 			, do
 				let port = Git.Url.port remote
-				let sshhost = either error id (mkSshHost host)
+				let sshhost = either giveup id (mkSshHost host)
 				(msockfile, cacheparams) <- sshCachingInfo (sshhost, port)
 				case msockfile of
 					Nothing -> use []
diff --git a/Annex/Transfer.hs b/Annex/Transfer.hs
--- a/Annex/Transfer.hs
+++ b/Annex/Transfer.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE CPP, BangPatterns #-}
+{-# LANGUAGE CPP, BangPatterns, OverloadedStrings #-}
 
 module Annex.Transfer (
 	module X,
@@ -31,6 +31,7 @@
 import Annex.Action
 import Utility.Metered
 import Utility.ThreadScheduler
+import Utility.FileMode
 import Annex.LockPool
 import Types.Key
 import qualified Types.Remote as Remote
@@ -144,7 +145,7 @@
 					else recordFailedTransfer t info
 				return v
 	
-	prep :: RawFilePath -> Annex () -> FileMode -> Annex (Maybe LockHandle, Bool)
+	prep :: RawFilePath -> Annex () -> ModeSetter -> Annex (Maybe LockHandle, Bool)
 #ifndef mingw32_HOST_OS
 	prep tfile createtfile mode = catchPermissionDenied (const prepfailed) $ do
 		let lck = transferLockFile tfile
@@ -200,7 +201,7 @@
 				| observeBool v -> return v
 				| otherwise -> checkretry
 			Left e -> do
-				warning (show e)
+				warning (UnquotedString (show e))
 				checkretry
 	  where
 		checkretry = do
@@ -289,7 +290,7 @@
 			)
 		)
 	blocked variety = do
-		warning $ "annex.securehashesonly blocked transfer of " ++ decodeBS (formatKeyVariety variety) ++ " key"
+		warning $ UnquotedString $ "annex.securehashesonly blocked transfer of " ++ decodeBS (formatKeyVariety variety) ++ " key"
 		return observeFailure
 
 type NumRetries = Integer
@@ -339,7 +340,7 @@
 	if numretries < maxretries
 		then do
 			let retrydelay = Seconds (initretrydelay * 2^(numretries-1))
-			showSideAction $ "Delaying " ++ show (fromSeconds retrydelay) ++ "s before retrying."
+			showSideAction $ UnquotedString $ "Delaying " ++ show (fromSeconds retrydelay) ++ "s before retrying."
 			liftIO $ threadDelaySeconds retrydelay
 			return True
 		else return False
diff --git a/Annex/UntrustedFilePath.hs b/Annex/UntrustedFilePath.hs
--- a/Annex/UntrustedFilePath.hs
+++ b/Annex/UntrustedFilePath.hs
@@ -10,6 +10,8 @@
 import Data.Char
 import System.FilePath
 
+import Utility.SafeOutput
+
 {- Given a string that we'd like to use as the basis for FilePath, but that
  - was provided by a third party and is not to be trusted, returns the closest
  - sane FilePath.
@@ -52,8 +54,10 @@
 sanitizeLeadingFilePathCharacter ('/':s) = '_':s
 sanitizeLeadingFilePathCharacter s = s
 
-escapeSequenceInFilePath :: FilePath -> Bool
-escapeSequenceInFilePath f = '\ESC' `elem` f
+controlCharacterInFilePath :: FilePath -> Bool
+controlCharacterInFilePath = any (not . safechar)
+  where
+	safechar c = safeOutputChar c && c /= '\n'
 
 {- ../ is a path traversal, no matter where it appears.
  -
diff --git a/Annex/Url.hs b/Annex/Url.hs
--- a/Annex/Url.hs
+++ b/Annex/Url.hs
@@ -6,6 +6,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Annex.Url (
 	withUrlOptions,
 	withUrlOptionsPromptingCreds,
@@ -166,13 +168,13 @@
 checkBoth url expected_size uo =
 	liftIO (U.checkBoth url expected_size uo) >>= \case
 		Right r -> return r
-		Left err -> warning err >> return False
+		Left err -> warning (UnquotedString err) >> return False
 
 download :: MeterUpdate -> Maybe IncrementalVerifier -> U.URLString -> FilePath -> U.UrlOptions -> Annex Bool
 download meterupdate iv url file uo =
 	liftIO (U.download meterupdate iv url file uo) >>= \case
 		Right () -> return True
-		Left err -> warning err >> return False
+		Left err -> warning (UnquotedString err) >> return False
 
 download' :: MeterUpdate -> Maybe IncrementalVerifier -> U.URLString -> FilePath -> U.UrlOptions -> Annex (Either String ())
 download' meterupdate iv url file uo =
@@ -181,7 +183,7 @@
 exists :: U.URLString -> U.UrlOptions -> Annex Bool
 exists url uo = liftIO (U.exists url uo) >>= \case
 	Right b -> return b
-	Left err -> warning err >> return False
+	Left err -> warning (UnquotedString err) >> return False
 
 getUrlInfo :: U.URLString -> U.UrlOptions -> Annex (Either String U.UrlInfo)
 getUrlInfo url uo = liftIO (U.getUrlInfo url uo)
diff --git a/Annex/Verify.hs b/Annex/Verify.hs
--- a/Annex/Verify.hs
+++ b/Annex/Verify.hs
@@ -6,6 +6,7 @@
  -}
 
 {-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 module Annex.Verify (
 	shouldVerify,
@@ -127,7 +128,7 @@
 			liftIO $ catchDefaultIO (Just False) $
 				finalizeIncrementalVerifier iv
 		| otherwise = do
-			showAction (descIncrementalVerifier iv)
+			showAction (UnquotedString (descIncrementalVerifier iv))
 			liftIO $ catchDefaultIO (Just False) $
 				withBinaryFile (fromRawFilePath f) ReadMode $ \h -> do
 					hSeek h AbsoluteSeek endpos
@@ -152,7 +153,7 @@
 	Nothing -> return True
 
 warnUnverifiableInsecure :: Key -> Annex ()
-warnUnverifiableInsecure k = warning $ unwords
+warnUnverifiableInsecure k = warning $ UnquotedString $ unwords
 	[ "Getting " ++ kv ++ " keys with this remote is not secure;"
 	, "the content cannot be verified to be correct."
 	, "(Use annex.security.allow-unverified-downloads to bypass"
diff --git a/Annex/YoutubeDl.hs b/Annex/YoutubeDl.hs
--- a/Annex/YoutubeDl.hs
+++ b/Annex/YoutubeDl.hs
@@ -1,6 +1,6 @@
-{- youtube-dl integration for git-annex
+{- yt-dlp (and deprecated youtube-dl) integration for git-annex
  -
- - Copyright 2017-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2017-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -12,6 +12,7 @@
 	youtubeDlCheck,
 	youtubeDlFileName,
 	youtubeDlFileNameHtmlOnly,
+	youtubeDlCommand,
 ) where
 
 import Annex.Common
@@ -22,13 +23,11 @@
 import Utility.HtmlDetect
 import Utility.Process.Transcript
 import Utility.Metered
-import Utility.DataUnits
 import Messages.Progress
 import Logs.Transfer
 
 import Network.URI
 import Control.Concurrent.Async
-import Data.Char
 import Text.Read
 
 -- youtube-dl can follow redirects to anywhere, including potentially
@@ -39,10 +38,10 @@
 
 youtubeDlNotAllowedMessage :: String
 youtubeDlNotAllowedMessage = unwords
-	[ "This url is supported by youtube-dl, but"
-	, "youtube-dl could potentially access any address, and the"
+	[ "This url is supported by yt-dlp, but"
+	, "yt-dlp could potentially access any address, and the"
 	, "configuration of annex.security.allowed-ip-addresses"
-	, "does not allow that. Not using youtube-dl."
+	, "does not allow that. Not using yt-dlp (or youtube-dl)."
 	]
 
 -- Runs youtube-dl in a work directory, to download a single media file
@@ -50,16 +49,18 @@
 --
 -- Displays a progress meter as youtube-dl downloads.
 --
--- If youtube-dl fails without writing any files to the work directory, 
--- or is not installed, returns Right Nothing.
---
--- The work directory can contain files from a previous run of youtube-dl
--- and it will resume. It should not contain any other files though,
--- and youtube-dl needs to finish up with only one file in the directory
--- so we know which one it downloaded.
+-- If no file is downloaded, or the program is not installed,
+-- returns Right Nothing.
 --
--- (Note that we can't use --output to specify the file to download to,
--- due to <https://github.com/rg3/youtube-dl/issues/14864>)
+-- youtube-dl can write to multiple files, either temporary files, or
+-- multiple videos found at the url, and git-annex needs only one file.
+-- So we need to find the destination file, and make sure there is not
+-- more than one. With yt-dlp use --print-to-file to make it record the 
+-- file(s) it downloads. With youtube-dl, the best that can be done is
+-- to require that the work directory end up with only 1 file in it.
+-- (This can fail, but youtube-dl is deprecated, and they closed my
+-- issue requesting something like --print-to-file; 
+-- <https://github.com/rg3/youtube-dl/issues/14864>)
 youtubeDl :: URLString -> FilePath -> MeterUpdate -> Annex (Either String (Maybe FilePath))
 youtubeDl url workdir p = ifM ipAddressesUnlimited
 	( withUrlOptions $ youtubeDl' url workdir p
@@ -68,28 +69,38 @@
 
 youtubeDl' :: URLString -> FilePath -> MeterUpdate -> UrlOptions -> Annex (Either String (Maybe FilePath))
 youtubeDl' url workdir p uo
-	| supportedScheme uo url = ifM (liftIO . inSearchPath =<< youtubeDlCommand)
-		( runcmd >>= \case
-			Right True -> workdirfiles >>= \case
-				(f:[]) -> return (Right (Just f))
-				[] -> return nofiles
-				fs -> return (toomanyfiles fs)
-			Right False -> workdirfiles >>= \case
-				[] -> return (Right Nothing)
-				_ -> return (Left "youtube-dl download is incomplete. Run the command again to resume.")
-			Left msg -> return (Left msg)
-		, return (Right Nothing)
-		)
+	| supportedScheme uo url = do
+		cmd <- youtubeDlCommand
+		ifM (liftIO $ inSearchPath cmd)
+			( runcmd cmd >>= \case
+				Right True -> downloadedfiles cmd >>= \case
+					(f:[]) -> return (Right (Just f))
+					[] -> return (nofiles cmd)
+					fs -> return (toomanyfiles cmd fs)
+				Right False -> workdirfiles >>= \case
+					[] -> return (Right Nothing)
+					_ -> return (Left $ cmd ++ " download is incomplete. Run the command again to resume.")
+				Left msg -> return (Left msg)
+			, return (Right Nothing)
+			)
 	| otherwise = return (Right Nothing)
   where
-	nofiles = Left "youtube-dl did not put any media in its work directory, perhaps it's been configured to store files somewhere else?"
-	toomanyfiles fs = Left $ "youtube-dl downloaded multiple media files; git-annex is only able to deal with one per url: " ++ show fs
-	workdirfiles = liftIO $ filterM (doesFileExist) =<< dirContents workdir
-	runcmd = youtubeDlMaxSize workdir >>= \case
+	nofiles cmd = Left $ cmd ++ " did not put any media in its work directory, perhaps it's been configured to store files somewhere else?"
+	toomanyfiles cmd fs = Left $ cmd ++ " downloaded multiple media files; git-annex is only able to deal with one per url: " ++ show fs
+	downloadedfiles cmd
+		| isytdlp cmd = liftIO $ 
+			(lines <$> readFile filelistfile)
+				`catchIO` (pure . const [])
+		| otherwise = workdirfiles
+	workdirfiles = liftIO $ filter (/= filelistfile) 
+		<$> (filterM (doesFileExist) =<< dirContents workdir)
+	filelistfile = workdir </> filelistfilebase
+	filelistfilebase = "git-annex-file-list-file"
+	isytdlp cmd = cmd == "yt-dlp"
+	runcmd cmd = youtubeDlMaxSize workdir >>= \case
 		Left msg -> return (Left msg)
 		Right maxsize -> do
-			cmd <- youtubeDlCommand
-			opts <- youtubeDlOpts (dlopts ++ maxsize)
+			opts <- youtubeDlOpts (dlopts cmd ++ maxsize)
 			oh <- mkOutputHandlerQuiet
 			-- The size is unknown to start. Once youtube-dl
 			-- outputs some progress, the meter will be updated
@@ -97,21 +108,31 @@
 			-- meter is passed into commandMeter'
 			let unknownsize = Nothing :: Maybe FileSize
 			ok <- metered (Just p) unknownsize Nothing $ \meter meterupdate ->
-				liftIO $ commandMeter' 
-					parseYoutubeDlProgress oh (Just meter) meterupdate cmd opts
+				liftIO $ commandMeter'
+					(if isytdlp cmd then parseYtdlpProgress else parseYoutubeDlProgress)
+					oh (Just meter) meterupdate cmd opts
 					(\pr -> pr { cwd = Just workdir })
 			return (Right ok)
-	dlopts = 
+	dlopts cmd = 
 		[ Param url
-		-- To make youtube-dl only download one file when given a
+		-- To make it only download one file when given a
 		-- page with a video and a playlist, download only the video.
 		, Param "--no-playlist"
 		-- And when given a page with only a playlist, download only
 		-- the first video on the playlist. (Assumes the video is
 		-- somewhat stable, but this is the only way to prevent
-		-- youtube-dl from downloading the whole playlist.)
+		-- it from downloading the whole playlist.)
 		, Param "--playlist-items", Param "0"
-		]
+		] ++
+			if isytdlp cmd
+				then 
+					[ Param "--progress-template"
+					, Param progressTemplate
+					, Param "--print-to-file"
+					, Param "after_move:filepath"
+					, Param filelistfilebase
+					]
+				else []
 
 -- To honor annex.diskreserve, ask youtube-dl to not download too
 -- large a media file. Factors in other downloads that are in progress,
@@ -148,7 +169,7 @@
 				return (Just True)
 			Right Nothing -> return (Just False)
 			Left msg -> do
-				warning msg
+				warning (UnquotedString msg)
 				return Nothing
 	return (fromMaybe False res)
 
@@ -251,7 +272,10 @@
 youtubeDlCommand :: Annex String
 youtubeDlCommand = annexYoutubeDlCommand <$> Annex.getGitConfig >>= \case
 	Just c -> pure c
-	Nothing -> fromMaybe "yt-dlp" <$> liftIO (searchPath "youtube-dl")
+	Nothing -> ifM (liftIO $ inSearchPath "yt-dlp")
+		( return "yt-dlp"
+		, return "youtube-dl"
+		)
 
 supportedScheme :: UrlOptions -> URLString -> Bool
 supportedScheme uo url = case parseURIRelaxed url of
@@ -264,41 +288,39 @@
 		"ftp:" -> False
 		_ -> allowedScheme uo u
 
-{- Strategy: Look for chunks prefixed with \r, which look approximately
- - like this for youtube-dl:
- - "ESC[K[download]  26.6% of 60.22MiB at 254.69MiB/s ETA 00:00"
- - or for yt-dlp, like this:
- - "\r[download]   1.8% of    1.14GiB at    1.04MiB/s ETA 18:23"
- - Look at the number before "% of " and the number and unit after,
- - to determine the number of bytes.
+progressTemplate :: String
+progressTemplate = "ANNEX %(progress.downloaded_bytes)i %(progress.total_bytes_estimate)i %(progress.total_bytes)i ANNEX"
+
+{- The progressTemplate makes output look like "ANNEX 10 100 NA ANNEX" or
+ - "ANNEX 10 NA 100 ANNEX" depending on whether the total bytes are estimated
+ - or known. That makes parsing much easier (and less fragile) than parsing
+ - the usual progress output.
  -}
-parseYoutubeDlProgress :: ProgressParser
-parseYoutubeDlProgress = go [] . reverse . progresschunks
+parseYtdlpProgress :: ProgressParser
+parseYtdlpProgress = go [] . reverse . progresschunks
   where
 	delim = '\r'
 
-	progresschunks = drop 1 . splitc delim
+	progresschunks = splitc delim
 
 	go remainder [] = (Nothing, Nothing, remainder)
-	go remainder (x:xs) = case split "% of " x of
-		(p:r:[]) -> case (parsepercent p, parsebytes r) of
-			(Just percent, Just total) ->
-				( Just (toBytesProcessed (calc percent total))
-				, Just (TotalSize total)
-				, remainder
-				)
-			_ -> go (delim:x++remainder) xs
-		_ -> go (delim:x++remainder) xs
-
-	calc :: Double -> Integer -> Integer
-	calc percent total = round (percent * fromIntegral total / 100)
-
-	parsepercent :: String -> Maybe Double
-	parsepercent = readMaybe 
-		. reverse . takeWhile (not . isSpace) . reverse
-		. dropWhile isSpace 
-
-	parsebytes = readSize units . takeWhile (not . isSpace) 
-		. dropWhile isSpace
+	go remainder (x:xs) = case splitc ' ' x of
+			("ANNEX":downloaded_bytes_s:total_bytes_estimate_s:total_bytes_s:"ANNEX":[]) ->
+				case (readMaybe downloaded_bytes_s, readMaybe total_bytes_estimate_s, readMaybe total_bytes_s) of
+					(Just downloaded_bytes, Nothing, Just total_bytes) ->
+						( Just (BytesProcessed downloaded_bytes)
+						, Just (TotalSize total_bytes)
+						, remainder
+						)
+					(Just downloaded_bytes, Just total_bytes_estimate, _) ->
+						( Just (BytesProcessed downloaded_bytes)
+						, Just (TotalSize total_bytes_estimate)
+						, remainder
+						)
+					_ -> go (remainder++x) xs
+			_ -> go (remainder++x) xs
 
-	units = committeeUnits ++ storageUnits
+{- youtube-dl is deprecated, parsing its progress was attempted before but
+ - was buggy and is no longer done. -}
+parseYoutubeDlProgress :: ProgressParser
+parseYoutubeDlProgress _ = (Nothing, Nothing, "")
diff --git a/Assistant/DeleteRemote.hs b/Assistant/DeleteRemote.hs
--- a/Assistant/DeleteRemote.hs
+++ b/Assistant/DeleteRemote.hs
@@ -31,7 +31,7 @@
  - Remote data. -}
 disableRemote :: UUID -> Assistant Remote
 disableRemote uuid = do
-	remote <- fromMaybe (error "unknown remote")
+	remote <- fromMaybe (giveup "unknown remote")
 		<$> liftAnnex (Remote.remoteFromUUID uuid)
 	liftAnnex $ do
 		inRepo $ Git.Remote.Remove.remove (Remote.name remote)
@@ -57,7 +57,7 @@
 	Just keys
 		| null keys -> finishRemovingRemote urlrenderer uuid
 		| otherwise -> do
-			r <- fromMaybe (error "unknown remote")
+			r <- fromMaybe (giveup "unknown remote")
 				<$> liftAnnex (Remote.remoteFromUUID uuid)
 			mapM_ (queueremaining r) keys
 	Nothing -> noop
diff --git a/Assistant/MakeRemote.hs b/Assistant/MakeRemote.hs
--- a/Assistant/MakeRemote.hs
+++ b/Assistant/MakeRemote.hs
@@ -47,7 +47,7 @@
 addRemote a = do
 	name <- a
 	remotesChanged
-	maybe (error "failed to add remote") return
+	maybe (giveup "failed to add remote") return
 		=<< Remote.byName (Just name)
 
 {- Inits a rsync special remote, and returns its name. -}
@@ -94,7 +94,7 @@
 enableSpecialRemote :: SpecialRemoteMaker
 enableSpecialRemote name remotetype mcreds config =
 	Annex.SpecialRemote.findExisting name >>= \case
-		[] -> error $ "Cannot find a special remote named " ++ name
+		[] -> giveup $ "Cannot find a special remote named " ++ name
 		((u, c, mcu):_) -> setupSpecialRemote' False name remotetype config mcreds (Just u, R.Enable c, c) mcu
 
 setupSpecialRemote :: RemoteName -> RemoteType -> R.RemoteConfig -> Maybe CredPair -> (Maybe UUID, R.SetupStage, R.RemoteConfig) -> Maybe (Annex.SpecialRemote.ConfigFrom UUID) -> Annex RemoteName
diff --git a/Assistant/MakeRepo.hs b/Assistant/MakeRepo.hs
--- a/Assistant/MakeRepo.hs
+++ b/Assistant/MakeRepo.hs
@@ -34,7 +34,7 @@
 		(transcript, ok) <-
 			processTranscript "git" (toCommand params) Nothing
 		unless ok $
-			error $ "git init failed!\nOutput:\n" ++ transcript
+			giveup $ "git init failed!\nOutput:\n" ++ transcript
 		return True
 	)
   where
diff --git a/Assistant/Pairing/MakeRemote.hs b/Assistant/Pairing/MakeRemote.hs
--- a/Assistant/Pairing/MakeRemote.hs
+++ b/Assistant/Pairing/MakeRemote.hs
@@ -24,11 +24,11 @@
  - side can immediately begin syncing. -}
 setupAuthorizedKeys :: PairMsg -> FilePath -> IO ()
 setupAuthorizedKeys msg repodir = case validateSshPubKey $ remoteSshPubKey $ pairMsgData msg of
-	Left err -> error err
+	Left err -> giveup err
 	Right pubkey -> do
 		absdir <- fromRawFilePath <$> absPath (toRawFilePath repodir)
 		unlessM (liftIO $ addAuthorizedKeys True absdir pubkey) $
-			error "failed setting up ssh authorized keys"
+			giveup "failed setting up ssh authorized keys"
 
 {- When local pairing is complete, this is used to set up the remote for
  - the host we paired with. -}
diff --git a/Assistant/Ssh.hs b/Assistant/Ssh.hs
--- a/Assistant/Ssh.hs
+++ b/Assistant/Ssh.hs
@@ -68,7 +68,7 @@
 
 {- user@host or host -}
 genSshHost :: Text -> Maybe Text -> SshHost
-genSshHost host user = either error id $ mkSshHost $
+genSshHost host user = either giveup id $ mkSshHost $
 	maybe "" (\v -> T.unpack v ++ "@") user ++ T.unpack host
 
 {- Generates a ssh or rsync url from a SshData. -}
@@ -218,7 +218,7 @@
 		, Param "-f", File $ dir </> "key"
 		]
 	unless ok $
-		error "ssh-keygen failed"
+		giveup "ssh-keygen failed"
 	SshKeyPair
 		<$> readFile (dir </> "key.pub")
 		<*> readFile (dir </> "key")
diff --git a/Assistant/Threads/Committer.hs b/Assistant/Threads/Committer.hs
--- a/Assistant/Threads/Committer.hs
+++ b/Assistant/Threads/Committer.hs
@@ -1,11 +1,11 @@
 {- git-annex assistant commit thread
  -
- - Copyright 2012-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE CPP #-}
+{-# LANGUAGE CPP, OverloadedStrings #-}
 
 module Assistant.Threads.Committer where
 
@@ -38,6 +38,8 @@
 import Utility.InodeCache
 import qualified Database.Keys
 import qualified Command.Sync
+import qualified Command.Add
+import Config.GitConfig
 import Utility.Tuple
 import Utility.Metered
 import qualified Utility.RawFilePath as R
@@ -56,6 +58,7 @@
 	delayadd <- liftAnnex $
 		fmap Seconds . annexDelayAdd <$> Annex.getGitConfig
 	largefilematcher <- liftAnnex largeFilesMatcher
+	annexdotfiles <- liftAnnex $ getGitConfigVal annexDotFiles
 	msg <- liftAnnex Command.Sync.commitMsg
 	lockdowndir <- liftAnnex $ fromRepo gitAnnexTmpWatcherDir
 	liftAnnex $ do
@@ -65,7 +68,7 @@
 			(fromRawFilePath lockdowndir)
 		void $ createAnnexDirectory lockdowndir
 	waitChangeTime $ \(changes, time) -> do
-		readychanges <- handleAdds (fromRawFilePath lockdowndir) havelsof largefilematcher delayadd $
+		readychanges <- handleAdds (fromRawFilePath lockdowndir) havelsof largefilematcher annexdotfiles delayadd $
 			simplifyChanges changes
 		if shouldCommit False time (length readychanges) readychanges
 			then do
@@ -261,8 +264,8 @@
  - Any pending adds that are not ready yet are put back into the ChangeChan,
  - where they will be retried later.
  -}
-handleAdds :: FilePath -> Bool -> GetFileMatcher -> Maybe Seconds -> [Change] -> Assistant [Change]
-handleAdds lockdowndir havelsof largefilematcher delayadd cs = returnWhen (null incomplete) $ do
+handleAdds :: FilePath -> Bool -> GetFileMatcher -> Bool -> Maybe Seconds -> [Change] -> Assistant [Change]
+handleAdds lockdowndir havelsof largefilematcher annexdotfiles delayadd cs = returnWhen (null incomplete) $ do
 	let (pending, inprocess) = partition isPendingAddChange incomplete
 	let lockdownconfig = LockDownConfig
 		{ lockingFile = False
@@ -289,15 +292,22 @@
 		| c = return otherchanges
 		| otherwise = a
 
-	checksmall change =
-		ifM (liftAnnex $ checkFileMatcher largefilematcher (toRawFilePath (changeFile change)))
-			( return (Left change)
-			, return (Right change)
-			)
+	checksmall change
+		| not annexdotfiles && dotfile f =
+			return (Right change)
+		| otherwise =
+			ifM (liftAnnex $ checkFileMatcher largefilematcher f)
+				( return (Left change)
+				, return (Right change)
+				)
+	  where
+		f = toRawFilePath (changeFile change)
 
 	addsmall [] = noop
-	addsmall toadd = liftAnnex $ Annex.Queue.addCommand [] "add"
-		[ Param "--force", Param "--"] (map changeFile toadd)
+	addsmall toadd = liftAnnex $ void $ tryIO $
+		forM (map (toRawFilePath . changeFile) toadd) $ \f ->
+			Command.Add.addFile Command.Add.Small f
+				=<< liftIO (R.getSymbolicLinkStatus f)
 
 	{- Avoid overhead of re-injesting a renamed unlocked file, by
 	 - examining the other Changes to see if a removed file has the
@@ -333,7 +343,7 @@
 	  	ks = keySource ld
 		doadd = sanitycheck ks $ do
 			(mkey, _mcache) <- liftAnnex $ do
-				showStart "add" (keyFilename ks) (SeekInput [])
+				showStartMessage (StartMessage "add" (ActionItemOther (Just (QuotedPath (keyFilename ks)))) (SeekInput []))
 				ingest nullMeterUpdate (Just $ LockedDown lockdownconfig ks) Nothing
 			maybe (failedingest change) (done change $ fromRawFilePath $ keyFilename ks) mkey
 	addannexed' _ _ = return Nothing
@@ -433,8 +443,8 @@
 
 	canceladd (InProcessAddChange { lockedDown = ld }) = do
 		let ks = keySource ld
-		warning $ fromRawFilePath (keyFilename ks)
-			++ " still has writers, not adding"
+		warning $ QuotedPath (keyFilename ks)
+			<> " still has writers, not adding"
 		-- remove the hard link
 		when (contentLocation ks /= keyFilename ks) $
 			void $ liftIO $ tryIO $ removeFile $ fromRawFilePath $ contentLocation ks
diff --git a/Assistant/Threads/Merger.hs b/Assistant/Threads/Merger.hs
--- a/Assistant/Threads/Merger.hs
+++ b/Assistant/Threads/Merger.hs
@@ -58,7 +58,7 @@
 
 {- Called when there's an error with inotify. -}
 onErr :: Handler
-onErr = error
+onErr = giveup
 
 {- Called when a new branch ref is written, or a branch ref is modified.
  -
diff --git a/Assistant/Threads/MountWatcher.hs b/Assistant/Threads/MountWatcher.hs
--- a/Assistant/Threads/MountWatcher.hs
+++ b/Assistant/Threads/MountWatcher.hs
@@ -74,7 +74,7 @@
 	onerr :: E.SomeException -> Assistant ()
 	onerr e = do
 		liftAnnex $
-			warning $ "dbus failed; falling back to mtab polling (" ++ show e ++ ")"
+			warning $ UnquotedString $ "dbus failed; falling back to mtab polling (" ++ show e ++ ")"
 		pollingThread urlrenderer
 
 {- Examine the list of services connected to dbus, to see if there
diff --git a/Assistant/Threads/NetWatcher.hs b/Assistant/Threads/NetWatcher.hs
--- a/Assistant/Threads/NetWatcher.hs
+++ b/Assistant/Threads/NetWatcher.hs
@@ -78,7 +78,7 @@
 		sendRemoteControl RESUME
 	onerr e _ = do
 		liftAnnex $
-			warning $ "lost dbus connection; falling back to polling (" ++ show e ++ ")"
+			warning $ UnquotedString $ "lost dbus connection; falling back to polling (" ++ show e ++ ")"
 		{- Wait, in hope that dbus will come back -}
 		liftIO $ threadDelaySeconds (Seconds 60)
 
diff --git a/Assistant/Threads/PairListener.hs b/Assistant/Threads/PairListener.hs
--- a/Assistant/Threads/PairListener.hs
+++ b/Assistant/Threads/PairListener.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Assistant.Threads.PairListener where
 
 import Assistant.Common
@@ -49,7 +51,7 @@
 					debug ["ignoring message that looped back"]
 					go reqs cache sock
 				(_, _, False, _) -> do
-					liftAnnex $ warning $
+					liftAnnex $ warning $ UnquotedString $
 						"illegal control characters in pairing message; ignoring (" ++ show (pairMsgData m) ++ ")"
 					go reqs cache sock
 				-- PairReq starts a pairing process, so a
diff --git a/Assistant/Threads/SanityChecker.hs b/Assistant/Threads/SanityChecker.hs
--- a/Assistant/Threads/SanityChecker.hs
+++ b/Assistant/Threads/SanityChecker.hs
@@ -127,7 +127,7 @@
 		return r
 
 	showerr e = do
-		liftAnnex $ warning $ show e
+		liftAnnex $ warning $ UnquotedString $ show e
 		return False
 
 {- Only run one check per day, from the time of the last check. -}
@@ -198,7 +198,7 @@
 	toonew timestamp now = now < (realToFrac (timestamp + slop) :: POSIXTime)
 	slop = fromIntegral tenMinutes
 	insanity msg = do
-		liftAnnex $ warning msg
+		liftAnnex $ warning (UnquotedString msg)
 		void $ addAlert $ sanityCheckFixAlert msg
 	addsymlink file s = do
 		Watcher.runHandler Watcher.onAddSymlink file s
diff --git a/Assistant/Threads/TransferWatcher.hs b/Assistant/Threads/TransferWatcher.hs
--- a/Assistant/Threads/TransferWatcher.hs
+++ b/Assistant/Threads/TransferWatcher.hs
@@ -15,6 +15,7 @@
 import Utility.DirWatcher
 import Utility.DirWatcher.Types
 import qualified Remote
+import qualified Annex
 import Annex.Perms
 
 import Control.Concurrent
@@ -52,7 +53,7 @@
 
 {- Called when there's an error with inotify. -}
 onErr :: Handler
-onErr = error
+onErr = giveup
 
 {- Called when a new transfer information file is written. -}
 onAdd :: Handler
@@ -62,7 +63,8 @@
   where
 	go _ Nothing = noop -- transfer already finished
 	go t (Just info) = do
-		debug [ "transfer starting:", describeTransfer t info ]
+		qp <- liftAnnex $ coreQuotePath <$> Annex.getGitConfig
+		debug [ "transfer starting:", describeTransfer qp t info ]
 		r <- liftAnnex $ Remote.remoteFromUUID $ transferUUID t
 		updateTransferInfo t info { transferRemote = r }
 
diff --git a/Assistant/Threads/Watcher.hs b/Assistant/Threads/Watcher.hs
--- a/Assistant/Threads/Watcher.hs
+++ b/Assistant/Threads/Watcher.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE DeriveDataTypeable, CPP #-}
+{-# LANGUAGE DeriveDataTypeable, OverloadedStrings, CPP #-}
 
 module Assistant.Threads.Watcher (
 	watchThread,
@@ -184,7 +184,7 @@
 runHandler handler file filestatus = void $ do
 	r <- tryIO <~> handler (normalize file) filestatus
 	case r of
-		Left e -> liftAnnex $ warning $ show e
+		Left e -> liftAnnex $ warning $ UnquotedString $ show e
 		Right Nothing -> noop
 		Right (Just change) -> recordChange change
   where
@@ -371,6 +371,6 @@
 {- Called when there's an error with inotify or kqueue. -}
 onErr :: Handler
 onErr msg _ = do
-	liftAnnex $ warning msg
+	liftAnnex $ warning (UnquotedString msg)
 	void $ addAlert $ warningAlert "watcher" msg
 	noChange
diff --git a/Assistant/TransferQueue.hs b/Assistant/TransferQueue.hs
--- a/Assistant/TransferQueue.hs
+++ b/Assistant/TransferQueue.hs
@@ -31,6 +31,7 @@
 import Types.Remote
 import qualified Remote
 import qualified Types.Remote as Remote
+import qualified Annex
 import Annex.Wanted
 import Utility.TList
 
@@ -139,7 +140,8 @@
 	| otherwise = go snocTList
   where
 	go modlist = whenM (add modlist) $ do
-		debug [ "queued", describeTransfer t info, ": " ++ reason ]
+		qp <- liftAnnex $ coreQuotePath <$> Annex.getGitConfig
+		debug [ "queued", describeTransfer qp t info, ": " ++ reason ]
 		notifyTransfer
 	add modlist = do
 		q <- getAssistant transferQueue
diff --git a/Assistant/TransferSlots.hs b/Assistant/TransferSlots.hs
--- a/Assistant/TransferSlots.hs
+++ b/Assistant/TransferSlots.hs
@@ -123,14 +123,16 @@
 			return Nothing
 		, ifM (liftAnnex $ shouldTransfer t info)
 			( do
-				debug [ "Transferring:" , describeTransfer t info ]
+				qp <- liftAnnex $ coreQuotePath <$> Annex.getGitConfig
+				debug [ "Transferring:" , describeTransfer qp t info ]
 				notifyTransfer
 				let sd = remoteAnnexStallDetection
 					(Remote.gitconfig remote)
 				return $ Just (t, info, go remote sd)
 			, do
+				qp <- liftAnnex $ coreQuotePath <$> Annex.getGitConfig
 				debug [ "Skipping unnecessary transfer:",
-					describeTransfer t info ]
+					describeTransfer qp t info ]
 				void $ removeTransfer t
 				finishedTransfer t (Just info)
 				return Nothing
@@ -241,9 +243,11 @@
 				Later (transferKey t) (associatedFile info) Upload
 	| otherwise = dodrops True
   where
-	dodrops fromhere = handleDrops
-		("drop wanted after " ++ describeTransfer t info)
-		fromhere (transferKey t) (associatedFile info) []
+	dodrops fromhere = do
+		qp <- liftAnnex $ coreQuotePath <$> Annex.getGitConfig
+		handleDrops
+			("drop wanted after " ++ describeTransfer qp t info)
+			fromhere (transferKey t) (associatedFile info) []
 finishedTransfer _ _ = noop
 
 {- Pause a running transfer. -}
diff --git a/Assistant/Upgrade.hs b/Assistant/Upgrade.hs
--- a/Assistant/Upgrade.hs
+++ b/Assistant/Upgrade.hs
@@ -205,7 +205,7 @@
 				, Param "--directory", File tmpdir
 				]
 			unless tarok $
-				error $ "failed to untar " ++ distributionfile
+				giveup $ "failed to untar " ++ distributionfile
 			sanitycheck $ tmpdir </> installBase
 			installby R.rename newdir (tmpdir </> installBase)
 		let deleteold = do
@@ -218,7 +218,7 @@
 #endif
 	sanitycheck dir = 
 		unlessM (doesDirectoryExist dir) $
-			error $ "did not find " ++ dir ++ " in " ++ distributionfile
+			giveup $ "did not find " ++ dir ++ " in " ++ distributionfile
 	makeorigsymlink olddir = do
 		let origdir = fromRawFilePath (parentDir (toRawFilePath olddir)) </> installBase
 		removeWhenExistsWith R.removeLink (toRawFilePath origdir)
@@ -227,7 +227,7 @@
 {- Finds where the old version was installed. -}
 oldVersionLocation :: IO FilePath
 oldVersionLocation = readProgramFile >>= \case
-	Nothing -> error "Cannot find old distribution bundle; not upgrading."
+	Nothing -> giveup "Cannot find old distribution bundle; not upgrading."
 	Just pf -> do
 		let pdir = fromRawFilePath $ parentDir $ toRawFilePath pf
 #ifdef darwin_HOST_OS
@@ -240,7 +240,7 @@
 		let olddir = pdir
 #endif
 		when (null olddir) $
-			error $ "Cannot find old distribution bundle; not upgrading. (Looked in " ++ pdir ++ ")"
+			giveup $ "Cannot find old distribution bundle; not upgrading. (Looked in " ++ pdir ++ ")"
 		return olddir
 
 {- Finds a place to install the new version.
diff --git a/Assistant/WebApp/Configurators/Delete.hs b/Assistant/WebApp/Configurators/Delete.hs
--- a/Assistant/WebApp/Configurators/Delete.hs
+++ b/Assistant/WebApp/Configurators/Delete.hs
@@ -34,7 +34,7 @@
 		then redirect DeleteCurrentRepositoryR
 		else go =<< liftAnnex (Remote.remoteFromUUID uuid)
   where
-	go Nothing = error "Unknown UUID"
+	go Nothing = giveup "Unknown UUID"
 	go (Just _) = a
 
 getDeleteRepositoryR :: UUID -> Handler Html
@@ -45,7 +45,7 @@
 
 getStartDeleteRepositoryR :: UUID -> Handler Html
 getStartDeleteRepositoryR uuid = do
-	remote <- fromMaybe (error "unknown remote")
+	remote <- fromMaybe (giveup "unknown remote")
 		<$> liftAnnex (Remote.remoteFromUUID uuid)
 	liftAnnex $ do
 		trustSet uuid UnTrusted
diff --git a/Assistant/WebApp/Configurators/Edit.hs b/Assistant/WebApp/Configurators/Edit.hs
--- a/Assistant/WebApp/Configurators/Edit.hs
+++ b/Assistant/WebApp/Configurators/Edit.hs
@@ -203,7 +203,7 @@
 		mremote <- liftAnnex $ Remote.remoteFromUUID uuid
 		when (mremote == Nothing) $
 			whenM ((/=) uuid <$> liftAnnex getUUID) $
-				error "unknown remote"
+				giveup "unknown remote"
 		curr <- liftAnnex $ getRepoConfig uuid mremote
 		liftAnnex $ checkAssociatedDirectory curr mremote
 		mrepo <- liftAnnex $
diff --git a/Assistant/WebApp/Configurators/Pairing.hs b/Assistant/WebApp/Configurators/Pairing.hs
--- a/Assistant/WebApp/Configurators/Pairing.hs
+++ b/Assistant/WebApp/Configurators/Pairing.hs
@@ -216,10 +216,10 @@
 	 - background. -}
 	thread <- liftAssistant $ asIO $ do
 		keypair <- liftIO $ genSshKeyPair
-		let pubkey = either error id $ validateSshPubKey $ sshPubKey keypair
+		let pubkey = either giveup id $ validateSshPubKey $ sshPubKey keypair
 		pairdata <- liftIO $ PairData
 			<$> getHostname
-			<*> (either error id <$> myUserName)
+			<*> (either giveup id <$> myUserName)
 			<*> pure reldir
 			<*> pure pubkey
 			<*> (maybe genUUID return muuid)
diff --git a/Assistant/WebApp/Page.hs b/Assistant/WebApp/Page.hs
--- a/Assistant/WebApp/Page.hs
+++ b/Assistant/WebApp/Page.hs
@@ -67,7 +67,7 @@
 					addScript $ StaticR js_longpolling_js
 				$(widgetFile "page")
 			withUrlRenderer $(Hamlet.hamletFile $ hamletTemplate "bootstrap")
-		Just msg -> error msg
+		Just msg -> giveup msg
   where
 	navdetails i = (navBarName i, navBarRoute i, Just i == navbaritem)
 
diff --git a/Backend.hs b/Backend.hs
--- a/Backend.hs
+++ b/Backend.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Backend (
 	builtinList,
 	defaultBackend,
@@ -66,7 +68,8 @@
 getBackend file k = maybeLookupBackendVariety (fromKey keyVariety k) >>= \case
 	Just backend -> return $ Just backend
 	Nothing -> do
-		warning $ "skipping " ++ file ++ " (" ++ unknownBackendVarietyMessage (fromKey keyVariety k) ++ ")"
+		warning $ "skipping " <> QuotedPath (toRawFilePath file) <> " (" <>
+			UnquotedString (unknownBackendVarietyMessage (fromKey keyVariety k)) <> ")"
 		return Nothing
 
 unknownBackendVarietyMessage :: KeyVariety -> String
diff --git a/Backend/External.hs b/Backend/External.hs
--- a/Backend/External.hs
+++ b/Backend/External.hs
@@ -139,7 +139,8 @@
 		loop
   where
 	handleExceptionalMessage _ (ERROR err) = do
-		warning ("external special remote error: " ++ err)
+		warning $ UnquotedString $
+			"external special remote error: " ++ err
 		whenunavail
 	handleExceptionalMessage loop (DEBUG msg) = do
 		fastDebug "Backend.External" msg
@@ -237,7 +238,7 @@
   where
 	basecmd = externalBackendProgram ebname
 	warnonce msg = when (pid == 1) $
-		warning msg
+		warning (UnquotedString msg)
 
 externalBackendProgram :: ExternalBackendName -> String
 externalBackendProgram (ExternalBackendName bname) = "git-annex-backend-X" ++ decodeBS bname
diff --git a/Backend/Hash.hs b/Backend/Hash.hs
--- a/Backend/Hash.hs
+++ b/Backend/Hash.hs
@@ -124,13 +124,13 @@
 	exists <- liftIO $ R.doesPathExist file
 	case (exists, fast) of
 		(True, False) -> do
-			showAction descChecksum
+			showAction (UnquotedString descChecksum)
 			sameCheckSum key 
 				<$> hashFile hash file nullMeterUpdate
 		_ -> return True
   where
 	hwfault e = do
-		warning $ "hardware fault: " ++ show e
+		warning $ UnquotedString $ "hardware fault: " ++ show e
 		return False
 
 sameCheckSum :: Key -> String -> Bool
@@ -229,7 +229,7 @@
 	| hashsize == 224 = mkHasher sha2_224 sha2_224_context
 	| hashsize == 384 = mkHasher sha2_384 sha2_384_context
 	| hashsize == 512 = mkHasher sha2_512 sha2_512_context
-	| otherwise = error $ "unsupported SHA2 size " ++ show hashsize
+	| otherwise = giveup $ "unsupported SHA2 size " ++ show hashsize
 
 sha3Hasher :: HashSize -> Hasher
 sha3Hasher (HashSize hashsize)
@@ -237,13 +237,13 @@
 	| hashsize == 224 = mkHasher sha3_224 sha3_224_context
 	| hashsize == 384 = mkHasher sha3_384 sha3_384_context
 	| hashsize == 512 = mkHasher sha3_512 sha3_512_context
-	| otherwise = error $ "unsupported SHA3 size " ++ show hashsize
+	| otherwise = giveup $ "unsupported SHA3 size " ++ show hashsize
 
 skeinHasher :: HashSize -> Hasher
 skeinHasher (HashSize hashsize)
 	| hashsize == 256 = mkHasher skein256 skein256_context
 	| hashsize == 512 = mkHasher skein512 skein512_context
-	| otherwise = error $ "unsupported SKEIN size " ++ show hashsize
+	| otherwise = giveup $ "unsupported SKEIN size " ++ show hashsize
 
 blake2bHasher :: HashSize -> Hasher
 blake2bHasher (HashSize hashsize)
@@ -252,25 +252,25 @@
 	| hashsize == 160 = mkHasher blake2b_160 blake2b_160_context
 	| hashsize == 224 = mkHasher blake2b_224 blake2b_224_context
 	| hashsize == 384 = mkHasher blake2b_384 blake2b_384_context
-	| otherwise = error $ "unsupported BLAKE2B size " ++ show hashsize
+	| otherwise = giveup $ "unsupported BLAKE2B size " ++ show hashsize
 
 blake2bpHasher :: HashSize -> Hasher
 blake2bpHasher (HashSize hashsize)
 	| hashsize == 512 = mkHasher blake2bp_512 blake2bp_512_context
-	| otherwise = error $ "unsupported BLAKE2BP size " ++ show hashsize
+	| otherwise = giveup $ "unsupported BLAKE2BP size " ++ show hashsize
 
 blake2sHasher :: HashSize -> Hasher
 blake2sHasher (HashSize hashsize)
 	| hashsize == 256 = mkHasher blake2s_256 blake2s_256_context
 	| hashsize == 160 = mkHasher blake2s_160 blake2s_160_context
 	| hashsize == 224 = mkHasher blake2s_224 blake2s_224_context
-	| otherwise = error $ "unsupported BLAKE2S size " ++ show hashsize
+	| otherwise = giveup $ "unsupported BLAKE2S size " ++ show hashsize
 
 blake2spHasher :: HashSize -> Hasher
 blake2spHasher (HashSize hashsize)
 	| hashsize == 256 = mkHasher blake2sp_256 blake2sp_256_context
 	| hashsize == 224 = mkHasher blake2sp_224 blake2sp_224_context
-	| otherwise = error $ "unsupported BLAKE2SP size " ++ show hashsize
+	| otherwise = giveup $ "unsupported BLAKE2SP size " ++ show hashsize
 
 sha1Hasher :: Hasher
 sha1Hasher = mkHasher sha1 sha1_context
diff --git a/Build/mdwn2man b/Build/mdwn2man
--- a/Build/mdwn2man
+++ b/Build/mdwn2man
@@ -7,7 +7,7 @@
 print ".TH $prog $section\n";
 
 while (<>) {
-	s{(\\?)\[\[([^\s\|\]]+)(\|[^\s\]]+)?\]\]}{$1 ? "[[$2]]" : $2}eg;
+	s{(\\?)\[\[([^:\s\|\]]+)(\|[^\s\]]+)?\]\]}{$1 ? "[[$2]]" : $2}eg;
 	s/\`([^\`]*)\`/\\fB$1\\fP/g;
 	s/\`//g;
 	s/^ *\./\\&./g;
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,103 @@
+git-annex (10.20230626) upstream; urgency=medium
+
+  * Split out two new commands, git-annex pull and git-annex push.
+    Those plus a git commit are equivilant to git-annex sync.
+    (Note that the new commands default to syncing content, unless
+    annex.synccontent is explicitly set to false.)
+  * assist: New command, which is the same as git-annex sync but with
+    new files added and content transferred by default.
+  * sync: Started a transition to --content being enabled by default.
+    When used without --content or --no-content, warn about the upcoming
+    transition, and suggest using one of the options, or setting
+    annex.synccontent.
+  * sync: Added -g as a short option for --no-content.
+  * Many commands now quote filenames that contain unusual characters the
+    same way that git does, to avoid exposing control characters to the
+    terminal.
+  * Support core.quotePath, which can be set to false to display utf8
+    characters as-is in filenames.
+  * Control characters in non-filename data coming from the repository or
+    other possible untrusted sources are filtered out of the display of many
+    commands. When the command output is intended for use in scripting,
+    control characters are only filtered out when displaying to the
+    terminal.
+  * find, findkeys, examinekey: When outputting to a terminal and --format
+    is not used, quote control characters. Output to a pipe is unchanged.
+    (Similar to the behavior of GNU find.)
+  * addurl --preserve-filename now rejects filenames that contain other
+    control characters, besides the escape sequences it already rejected.
+  * init: Avoid autoenabling special remotes that have control characters
+    in their names.
+  * Support core.sharedRepository=0xxx at long last.
+  * Support --json and --json-error-messages in many more commands
+    (addunused, configremote, dead, describe, dropunused, enableremote,
+    expire, fix, importfeed, init, initremote, log, merge, migrate, reinit,
+    reinject, rekey, renameremote, rmurl, semitrust, setpresentkey, trust,
+    unannex, undo, uninit, untrust, unused, upgrade)
+  * importfeed: Support -J
+  * importfeed: Support --json-progress
+  * httpalso: Support being used with special remotes that use chunking.
+  * Several significant speedups to importing large trees from special
+    remotes. Imports that took over an hour now take only a few minutes.
+  * Cache negative lookups of global numcopies and mincopies. 
+    Speeds up eg git-annex sync --content by up to 50%.
+  * Speed up sync in an adjusted branch by avoiding re-adjusting the branch
+    unncessarily, particularly when it is adjusted with --hide-missing
+    or --unlock-present.
+  * config: Added the --show-origin and --for-file options.
+  * config: Support annex.numcopies and annex.mincopies.
+  * whereused: Fix display of branch:file when run in a subdirectory.
+  * enableremote: Support enableremote of a git remote (that was previously
+    set up with initremote) when additional parameters such as autoenable=
+    are passed.
+  * configremote: New command, currently limited to changing autoenable=
+    setting of a special remote.
+  * Honor --force option when operating on a local git remote.
+  * When a nonexistant file is passed to a command and 
+    --json-error-messages is enabled, output a JSON object indicating the
+    problem. (But git ls-files --error-unmatch still displays errors about
+    such files in some situations.)
+  * Bug fix: Create .git/annex/, .git/annex/fsckdb,
+    .git/annex/sentinal, .git/annex/sentinal.cache, and
+    .git/annex/journal/* with permissions configured by core.sharedRepository.
+  * Bug fix: Lock files were created with wrong modes for some combinations
+    of core.sharedRepository and umask.
+  * initremote: Avoid creating a remote that is not encrypted when gpg is
+    broken.
+  * log: When --raw-date is used, display only seconds from the epoch, as
+    documented, omitting a trailing "s" that was included in the output
+    before.
+  * addunused: Displays the names of the files that it adds.
+  * reinject: Fix support for operating on multiple pairs of files and keys.
+  * sync: Fix buggy handling of --no-pull and --no-push when syncing
+    --content. With --no-pull, avoid downloading content, and with
+    --no-push avoid uploading content. This was done before, but
+    inconsistently.
+  * uninit: Avoid buffering the names of all annexed files in memory.
+  * Fix bug in -z handling of trailing NUL in input.
+  * version: Avoid error message when entire output is not read.
+  * Fix excessive CPU usage when parsing yt-dlp (or youtube-dl) progress
+    output fails.
+  * Use --progress-template with yt-dlp to fix a failure to parse
+    progress output when only an estimated total size is known.
+  * When yt-dlp is available, default to using it in preference to
+    youtube-dl. Using youtube-dl is now deprecated, and git-annex no longer
+    tries to parse its output to display download progress
+  * Improve resuming interrupted download when using yt-dlp or youtube-dl.
+  * assistant: Add dotfiles to git by default, unless annex.dotfiles
+    is configured, the same as git-annex add does.
+  * assistant --autostop: Avoid crashing when ~/.config/git-annex/autostart
+    lists a directory that it cannot chdir to.
+  * Fix display when run with -J1.
+  * assistant: Fix a crash when a small file is deleted immediately after
+    being created.
+  * repair: Fix handling of git ref names on Windows.
+  * repair: Fix a crash when .git/annex/journal/ does not exist.
+  * Support building with optparse-applicative 0.18.1
+    (Thanks, Peter Simons)
+
+ -- Joey Hess <id@joeyh.name>  Mon, 26 Jun 2023 10:37:36 -0400
+
 git-annex (10.20230407) upstream; urgency=medium
 
   * Fix laziness bug introduced in last release that breaks use
diff --git a/CmdLine.hs b/CmdLine.hs
--- a/CmdLine.hs
+++ b/CmdLine.hs
@@ -116,7 +116,7 @@
 		<*> getparser c
 		<*> parserAnnexOptions (cmdannexoptions c)
 	synopsis n d = n ++ " - " ++ d
-	intro = mconcat $ concatMap (\l -> [H.text l, H.line])
+	intro = mconcat $ concatMap (\l -> [H.pretty l, H.line])
 		(synopsis progname progdesc : commandList allcmds)
 
 {- Selects the Command that matches the subcommand name.
diff --git a/CmdLine/Action.hs b/CmdLine/Action.hs
--- a/CmdLine/Action.hs
+++ b/CmdLine/Action.hs
@@ -74,9 +74,7 @@
 	st <- Annex.getState id
 	case getConcurrency' (Annex.concurrency st) of
 		NonConcurrent -> runnonconcurrent (Annex.sizelimit st)
-		Concurrent n
-			| n > 1 -> runconcurrent (Annex.sizelimit st) (Annex.workers st)
-			| otherwise -> runnonconcurrent (Annex.sizelimit st)
+		Concurrent _ -> runconcurrent (Annex.sizelimit st) (Annex.workers st)
 		ConcurrentPerCpu -> runconcurrent (Annex.sizelimit st) (Annex.workers st)
   where
 	runnonconcurrent sizelimit = start >>= \case
@@ -192,7 +190,7 @@
 	Left err -> case fromException err of
 		Just exitcode -> liftIO $ exitWith exitcode
 		Nothing -> do
-			toplevelWarning True (show err)
+			toplevelWarning True (UnquotedString (show err))
 			showEndMessage startmsg False
 			incerr
   where
@@ -235,8 +233,11 @@
 	let usegitcfg = setConcurrency (ConcurrencyGitConfig fromgitcfg)
 	case (fromcmdline, fromgitcfg) of
 		(NonConcurrent, NonConcurrent) -> a
-		(Concurrent n, _) ->
-			goconcurrent n
+		(Concurrent n, _) 
+			| n > 1 -> goconcurrent n
+			| otherwise -> do
+				setConcurrency' NonConcurrent ConcurrencyCmdLine
+				a
 		(ConcurrentPerCpu, _) ->
 			goconcurrentpercpu
 		(NonConcurrent, Concurrent n) -> do
diff --git a/CmdLine/Batch.hs b/CmdLine/Batch.hs
--- a/CmdLine/Batch.hs
+++ b/CmdLine/Batch.hs
@@ -115,7 +115,17 @@
   where
 	splitter = case sep of
 		BatchLine -> lines
-		BatchNull -> splitc '\0'
+		BatchNull -> elimemptyend . splitc '\0'
+
+	-- When there is a trailing null on the input, eliminate the empty
+	-- string that splitc generates. Other empty strings elsewhere in
+	-- the list are preserved. This is the same effect as how `lines`
+	-- handles a trailing newline.
+	elimemptyend [] = []
+	elimemptyend (x:[])
+		| null x = []
+		| otherwise = [x]
+	elimemptyend (x:rest) = x : elimemptyend rest
 
 -- When concurrency is enabled at the command line, it is used in batch
 -- mode. But, if it's only set in git config, don't use it, because the
diff --git a/CmdLine/GitAnnex.hs b/CmdLine/GitAnnex.hs
--- a/CmdLine/GitAnnex.hs
+++ b/CmdLine/GitAnnex.hs
@@ -56,6 +56,7 @@
 import qualified Command.Describe
 import qualified Command.InitRemote
 import qualified Command.EnableRemote
+import qualified Command.ConfigRemote
 import qualified Command.RenameRemote
 import qualified Command.EnableTor
 import qualified Command.Multicast
@@ -99,6 +100,9 @@
 import qualified Command.Config
 import qualified Command.Vicfg
 import qualified Command.Sync
+import qualified Command.Assist
+import qualified Command.Pull
+import qualified Command.Push
 import qualified Command.Mirror
 import qualified Command.AddUrl
 import qualified Command.ImportFeed
@@ -144,6 +148,9 @@
 	, Command.Unlock.editcmd
 	, Command.Lock.cmd
 	, Command.Sync.cmd
+	, Command.Assist.cmd
+	, Command.Pull.cmd
+	, Command.Push.cmd
 	, Command.Mirror.cmd
 	, Command.AddUrl.cmd
 	, Command.ImportFeed.cmd
@@ -154,6 +161,7 @@
 	, Command.Describe.cmd
 	, Command.InitRemote.cmd
 	, Command.EnableRemote.cmd
+	, Command.ConfigRemote.cmd
 	, Command.RenameRemote.cmd
 	, Command.EnableTor.cmd
 	, Command.Multicast.cmd
diff --git a/CmdLine/GitAnnex/Options.hs b/CmdLine/GitAnnex/Options.hs
--- a/CmdLine/GitAnnex/Options.hs
+++ b/CmdLine/GitAnnex/Options.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE TypeSynonymInstances, FlexibleInstances #-}
+{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, OverloadedStrings #-}
 
 module CmdLine.GitAnnex.Options where
 
diff --git a/CmdLine/GitAnnexShell/Checks.hs b/CmdLine/GitAnnexShell/Checks.hs
--- a/CmdLine/GitAnnexShell/Checks.hs
+++ b/CmdLine/GitAnnexShell/Checks.hs
@@ -78,7 +78,7 @@
 {- Modifies a Command to check that it is run in either a git-annex
  - repository, or a repository with a gcrypt-id set. -}
 gitAnnexShellCheck :: Command -> Command
-gitAnnexShellCheck = addCheck okforshell . dontCheck repoExists
+gitAnnexShellCheck = addCheck GitAnnexShellOk okforshell . dontCheck repoExists
   where
 	okforshell = unlessM (isInitialized <||> isJust . gcryptId <$> Annex.getGitConfig) $
 		giveup "Not a git-annex or gcrypt repository."
diff --git a/CmdLine/GitRemoteTorAnnex.hs b/CmdLine/GitRemoteTorAnnex.hs
--- a/CmdLine/GitRemoteTorAnnex.hs
+++ b/CmdLine/GitRemoteTorAnnex.hs
@@ -25,7 +25,7 @@
 		"capabilities" -> putStrLn "connect" >> ready
 		"connect git-upload-pack" -> go UploadPack
 		"connect git-receive-pack" -> go ReceivePack
-		l -> error $ "git-remote-helpers protocol error at " ++ show l
+		l -> giveup $ "git-remote-helpers protocol error at " ++ show l
   where
 	(onionaddress, onionport)
 		| '/' `elem` address = parseAddressPort $
diff --git a/CmdLine/Option.hs b/CmdLine/Option.hs
--- a/CmdLine/Option.hs
+++ b/CmdLine/Option.hs
@@ -56,7 +56,7 @@
 		)
 	, annexOption setdebugfilter $ strOption
 		( long "debugfilter" <> metavar "NAME[,NAME..]"
-		<> help "show debug messages coming from a module"
+		<> help "show debug messages coming from the specified module"
 		<> hidden
 		)
 	]
diff --git a/CmdLine/Seek.hs b/CmdLine/Seek.hs
--- a/CmdLine/Seek.hs
+++ b/CmdLine/Seek.hs
@@ -9,6 +9,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module CmdLine.Seek where
 
 import Annex.Common
@@ -145,9 +147,9 @@
 	pairs c (x:y:xs) = pairs ((x,y):c) xs
 	pairs _ _ = giveup "expected pairs"
 
-withFilesToBeCommitted :: ((SeekInput, RawFilePath) -> CommandSeek) -> WorkTreeItems -> CommandSeek
-withFilesToBeCommitted a l = seekFiltered (const (pure True)) a $
-	seekHelper id WarnUnmatchWorkTreeItems (const LsFiles.stagedNotDeleted) l
+withFilesToBeCommitted :: WarnUnmatchWhen -> ((SeekInput, RawFilePath) -> CommandSeek) -> WorkTreeItems -> CommandSeek
+withFilesToBeCommitted ww a l = seekFiltered (const (pure True)) a $
+	seekHelper id ww (const LsFiles.stagedNotDeleted) l
 
 {- unlocked pointer files that are staged, and whose content has not been
  - modified-}
@@ -313,7 +315,7 @@
 					in keyaction lt (SeekInput [], k, bfp)
 				Nothing -> noop
 			unlessM (liftIO cleanup) $
-				error ("git ls-tree " ++ Git.fromRef b ++ " failed")
+				giveup ("git ls-tree " ++ Git.fromRef b ++ " failed")
 	
 	runfailedtransfers = do
 		keyaction <- mkkeyaction
@@ -510,15 +512,15 @@
 		and <$> sequence cleanups
 seekHelper _ _ _ NoWorkTreeItems = return ([], pure True)
 
-data WarnUnmatchWhen = WarnUnmatchLsFiles | WarnUnmatchWorkTreeItems
+data WarnUnmatchWhen = WarnUnmatchLsFiles String | WarnUnmatchWorkTreeItems String
 
 seekOptions :: WarnUnmatchWhen -> Annex [LsFiles.Options]
-seekOptions WarnUnmatchLsFiles =
+seekOptions (WarnUnmatchLsFiles _) =
 	ifM (annexSkipUnknown <$> Annex.getGitConfig)
 		( return [] 
 		, return [LsFiles.ErrorUnmatch]
 		)
-seekOptions WarnUnmatchWorkTreeItems = return []
+seekOptions (WarnUnmatchWorkTreeItems _) = return []
 
 -- Items in the work tree, which may be files or directories.
 data WorkTreeItems
@@ -552,23 +554,23 @@
 
 workTreeItems' :: AllowHidden -> WarnUnmatchWhen -> CmdParams -> Annex WorkTreeItems
 workTreeItems' (AllowHidden allowhidden) ww ps = case ww of
-	WarnUnmatchWorkTreeItems -> runcheck
-	WarnUnmatchLsFiles -> 
+	(WarnUnmatchWorkTreeItems action) -> runcheck action
+	(WarnUnmatchLsFiles action) -> 
 		ifM (annexSkipUnknown <$> Annex.getGitConfig)
-			( runcheck
+			( runcheck action
 			, return $ WorkTreeItems ps
 			)
   where
-	runcheck = do
+	runcheck action = do
 		currbranch <- getCurrentBranch
 		stopattop <- prepviasymlink
 		ps' <- flip filterM ps $ \p -> do
 			let p' = toRawFilePath p
 			relf <- liftIO $ relPathCwdToFile p'
 			ifM (not <$> (exists p' <||> hidden currbranch relf))
-				( prob (p ++ " not found")
+				( prob action FileNotFound p' "not found"
 				, ifM (viasymlink stopattop (upFrom relf))
-					( prob (p ++ " is beyond a symbolic link")
+					( prob action FileBeyondSymbolicLink p' "is beyond a symbolic link"
 					, return True
 					)
 				)
@@ -603,8 +605,8 @@
 			<$> catObjectMetaDataHidden f currbranch
 		| otherwise = return False
 
-	prob msg = do
-		toplevelWarning False msg
+	prob action errorid p msg = do
+		toplevelFileProblem False errorid msg action p Nothing (SeekInput [fromRawFilePath p])
 		Annex.incError
 		return False
 	
@@ -628,7 +630,7 @@
 						swapTVar warningshownv True
 					unless warningshown $ do
 						Annex.changeState $ \s -> s { Annex.reachedlimit = True }
-						warning $ "Time limit (" ++ fromDuration duration ++ ") reached! Shutting down..."
+						warning $ UnquotedString $ "Time limit (" ++ fromDuration duration ++ ") reached! Shutting down..."
 						cleanup
 				else a
 
diff --git a/Command.hs b/Command.hs
--- a/Command.hs
+++ b/Command.hs
@@ -25,7 +25,7 @@
 import Annex.Init
 import Utility.Daemon
 import Types.Transfer
-import Types.ActionItem
+import Types.ActionItem as ReExported
 import Types.WorkerPool as ReExported
 import Remote.List
 
@@ -125,17 +125,17 @@
 commonChecks = [repoExists]
 
 repoExists :: CommandCheck
-repoExists = CommandCheck 0 (ensureInitialized remoteList)
+repoExists = CommandCheck RepoExists (ensureInitialized remoteList)
 
 notBareRepo :: Command -> Command
-notBareRepo = addCheck checkNotBareRepo
+notBareRepo = addCheck CheckNotBareRepo checkNotBareRepo
 
 checkNotBareRepo :: Annex ()
 checkNotBareRepo = whenM (fromRepo Git.repoIsLocalBare) $
 	giveup "You cannot run this command in a bare repository."
 
 noDaemonRunning :: Command -> Command
-noDaemonRunning = addCheck $ whenM (isJust <$> daemonpid) $
+noDaemonRunning = addCheck NoDaemonRunning $ whenM (isJust <$> daemonpid) $
 	giveup "You cannot run this command while git-annex watch or git-annex assistant is running."
   where
 	daemonpid = liftIO . checkDaemon . fromRawFilePath
@@ -144,9 +144,9 @@
 dontCheck :: CommandCheck -> Command -> Command
 dontCheck check cmd = mutateCheck cmd $ \c -> filter (/= check) c
 
-addCheck :: Annex () -> Command -> Command
-addCheck check cmd = mutateCheck cmd $ \c ->
-	CommandCheck (length c + 100) check : c
+addCheck :: CommandCheckId -> Annex () -> Command -> Command
+addCheck cid check cmd = mutateCheck cmd $ \c ->
+	CommandCheck cid check : c
 
 mutateCheck :: Command -> ([CommandCheck] -> [CommandCheck]) -> Command
 mutateCheck cmd@(Command { cmdcheck = c }) a = cmd { cmdcheck = a c }
diff --git a/Command/Add.hs b/Command/Add.hs
--- a/Command/Add.hs
+++ b/Command/Add.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.Add where
 
 import Command
@@ -84,7 +86,10 @@
 		(help "Do not check .gitignore when adding files")
 
 seek :: AddOptions -> CommandSeek
-seek o = startConcurrency commandStages $ do
+seek o = startConcurrency commandStages (seek' o)
+
+seek' :: AddOptions -> CommandSeek
+seek' o = do
 	largematcher <- largeFilesMatcher
 	addunlockedmatcher <- addUnlockedMatcher
 	annexdotfiles <- getGitConfigVal annexDotFiles 
@@ -117,7 +122,7 @@
 			-- are not known to git yet, since this will add
 			-- them. Instead, have workTreeItems warn about other
 			-- problems, like files that don't exist.
-			let ww = WarnUnmatchWorkTreeItems
+			let ww = WarnUnmatchWorkTreeItems "add"
 			l <- workTreeItems ww (addThese o)
 			let go b a = a ww (commandAction . gofile b) l
 			unless (updateOnly o) $
@@ -160,7 +165,10 @@
 		then hashBlob =<< liftIO (R.readSymbolicLink file)
 		else if isRegularFile s
 			then hashFile file
-			else giveup $ fromRawFilePath file ++ " is not a regular file"
+			else do
+				qp <- coreQuotePath <$> Annex.getGitConfig
+				giveup $ decodeBS $ quote qp $
+					file <> " is not a regular file"
 	let treetype = if isSymbolicLink s
 		then TreeSymlink
 		else if intersectFileModes ownerExecuteMode (fileMode s) /= 0
@@ -169,7 +177,7 @@
 	s' <- liftIO $ catchMaybeIO $ R.getSymbolicLinkStatus file
 	if maybe True (changed s) s'
 		then do
-			warning $ fromRawFilePath file ++ " changed while it was being added"
+			warning $ QuotedPath file <> " changed while it was being added"
 			return False
 		else do
 			case smallorlarge of
diff --git a/Command/AddUnused.hs b/Command/AddUnused.hs
--- a/Command/AddUnused.hs
+++ b/Command/AddUnused.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2012 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -15,28 +15,29 @@
 import Command.Unused (withUnusedMaps, UnusedMaps(..), startUnused)
 
 cmd :: Command
-cmd = command "addunused" SectionMaintenance 
-	"add back unused files"
-	(paramRepeating paramNumRange) (withParams seek)
+cmd = withAnnexOptions [jsonOptions] $
+	command "addunused" SectionMaintenance 
+		"add back unused files"
+		(paramRepeating paramNumRange) (withParams seek)
 
 seek :: CmdParams -> CommandSeek
 seek = withUnusedMaps start
 
 start :: UnusedMaps -> Int -> CommandStart
-start = startUnused "addunused" perform
-	(performOther "bad")
-	(performOther "tmp")
-
-perform :: Key -> CommandPerform
-perform key = next $ do
-	logStatus key InfoPresent
-	addSymlink file key Nothing
-	return True
+start = startUnused go (other "bad") (other "tmp")
   where
-	file = "unused." <> keyFile key
+	go n key = do
+		let file = "unused." <> keyFile key
+		starting "addunused"
+			(ActionItemTreeFile file)
+			(SeekInput [show n]) $
+			next $ do
+				logStatus key InfoPresent
+				addSymlink file key Nothing
+				return True
 
-{- The content is not in the annex, but in another directory, and
- - it seems better to error out, rather than moving bad/tmp content into
- - the annex. -}
-performOther :: String -> Key -> CommandPerform
-performOther other _ = giveup $ "cannot addunused " ++ other ++ "content"
+	{- The content is not in the annex, but in another directory, and
+	 - it seems better to error out, rather than moving bad/tmp content
+	 - into the annex. -}
+	other n _ _ = giveup $ "cannot addunused " ++ n ++ "content"
+
diff --git a/Command/AddUrl.hs b/Command/AddUrl.hs
--- a/Command/AddUrl.hs
+++ b/Command/AddUrl.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.AddUrl where
 
 import Command
@@ -145,13 +147,13 @@
 	pathmax <- liftIO $ fileNameLengthLimit "."
 	let deffile = fromMaybe (urlString2file u (pathdepthOption o) pathmax) (fileOption (downloadOptions o))
 	go deffile =<< maybe
-		(error $ "unable to checkUrl of " ++ Remote.name r)
+		(giveup $ "unable to checkUrl of " ++ Remote.name r)
 		(tryNonAsync . flip id u)
 		(Remote.checkUrl r)
   where
 
 	go _ (Left e) = void $ commandAction $ startingAddUrl si u o $ do
-		warning (show e)
+		warning (UnquotedString (show e))
 		next $ return False
 	go deffile (Right (UrlContents sz mf)) = do
 		f <- maybe (pure deffile) (sanitizeOrPreserveFilePath o) mf
@@ -180,8 +182,8 @@
 	let file' = joinPath $ map (truncateFilePath pathmax) $
 		splitDirectories file
 	startingAddUrl si uri o $ do
-		showNote $ "from " ++ Remote.name r 
-		showDestinationFile file'
+		showNote $ UnquotedString $ "from " ++ Remote.name r 
+		showDestinationFile (toRawFilePath file')
 		performRemote addunlockedmatcher r o uri (toRawFilePath file') sz
 
 performRemote :: AddUnlockedMatcher -> Remote -> AddUrlOptions -> URLString -> RawFilePath -> Maybe Integer -> CommandPerform
@@ -231,7 +233,7 @@
 			else Url.withUrlOptions (Url.getUrlInfo urlstring) >>= \case
 				Right urlinfo -> go' url urlinfo
 				Left err -> do
-					warning err
+					warning (UnquotedString err)
 					next $ return False
 	go' url urlinfo = do
 		pathmax <- liftIO $ fileNameLengthLimit "."
@@ -262,16 +264,18 @@
 -- (and probably others, but at least this catches the most egrarious ones).
 checkPreserveFileNameSecurity :: FilePath -> Annex ()
 checkPreserveFileNameSecurity f = do
-	checksecurity escapeSequenceInFilePath False "escape sequence"
-	checksecurity pathTraversalInFilePath True "path traversal"
-	checksecurity gitDirectoryInFilePath True "contains a .git directory"
+	checksecurity controlCharacterInFilePath "control character"
+	checksecurity pathTraversalInFilePath "path traversal"
+	checksecurity gitDirectoryInFilePath "contains a .git directory"
   where
-	checksecurity p canshow d = when (p f) $
-		giveup $ concat
-			[ "--preserve-filename was used, but the filename "
-			, if canshow then "(" ++ f ++ ") " else ""
-			, "has a security problem (" ++ d ++ "), not adding."
-			]
+	checksecurity p d = when (p f) $ do
+		qp <- coreQuotePath <$> Annex.getGitConfig
+		giveup $ decodeBS $ quote qp $
+			"--preserve-filename was used, but the filename ("
+				<> QuotedPath (toRawFilePath f)
+				<> ") has a security problem ("
+				<> d
+				<> "), not adding."
 
 performWeb :: AddUnlockedMatcher -> AddUrlOptions -> URLString -> RawFilePath -> Url.UrlInfo -> CommandPerform
 performWeb addunlockedmatcher o url file urlinfo = lookupKey file >>= \case
@@ -282,7 +286,7 @@
 	addurl = addUrlChecked o url file webUUID $ \k ->
 		ifM (pure (not (rawOption (downloadOptions o))) <&&> youtubeDlSupported url)
 			( return (Just (True, True, setDownloader url YoutubeDownloader))
-			, checkRaw Nothing (downloadOptions o) Nothing $
+			, checkRaw Nothing (downloadOptions o) (pure Nothing) $
 				return (Just (Url.urlExists urlinfo, Url.urlSize urlinfo == fromKey keySize k, url))
 			)
 
@@ -292,7 +296,7 @@
 addUrlChecked o url file u checkexistssize key =
 	ifM ((elem url <$> getUrls key) <&&> (elem u <$> loggedLocations key))
 		( do
-			showDestinationFile (fromRawFilePath file)
+			showDestinationFile file
 			next $ return True
 		, checkexistssize key >>= \case
 			Just (exists, samesize, url')
@@ -301,7 +305,7 @@
 					logChange key u InfoPresent
 					next $ return True
 				| otherwise -> do
-					warning $ "while adding a new url to an already annexed file, " ++ if exists
+					warning $ UnquotedString $ "while adding a new url to an already annexed file, " ++ if exists
 						then "url does not have expected file size (use --relaxed to bypass this check) " ++ url
 						else "failed to verify url exists: " ++ url
 					stop
@@ -333,36 +337,41 @@
 		, normalfinish tmp backend
 		)
 	normalfinish tmp backend = checkCanAdd o file $ \canadd -> do
-		showDestinationFile (fromRawFilePath file)
+		showDestinationFile file
 		createWorkTreeDirectory (parentDir file)
 		Just <$> finishDownloadWith canadd addunlockedmatcher tmp backend webUUID url file
 	-- Ask youtube-dl what filename it will download first, 
 	-- so it's only used when the file contains embedded media.
 	tryyoutubedl tmp backend = youtubeDlFileNameHtmlOnly url >>= \case
-		Right mediafile -> 
+		Right mediafile -> do
+			liftIO $ liftIO $ removeWhenExistsWith R.removeLink tmp
 			let f = youtubeDlDestFile o file (toRawFilePath mediafile)
-			in lookupKey f >>= \case
-				Just k -> alreadyannexed (fromRawFilePath f) k
+			lookupKey f >>= \case
+				Just k -> alreadyannexed f k
 				Nothing -> dl f
-		Left err -> checkRaw (Just err) o Nothing (normalfinish tmp backend)
+		Left err -> checkRaw (Just err) o (pure Nothing) (normalfinish tmp backend)
 	  where
 		dl dest = withTmpWorkDir mediakey $ \workdir -> do
 			let cleanuptmp = pruneTmpWorkDirBefore tmp (liftIO . removeWhenExistsWith R.removeLink)
-			showNote "using youtube-dl"
+			dlcmd <- youtubeDlCommand
+			showNote ("using " <> UnquotedString dlcmd)
 			Transfer.notifyTransfer Transfer.Download url $
-				Transfer.download' webUUID mediakey (AssociatedFile Nothing) Nothing Transfer.noRetry $ \p ->
+				Transfer.download' webUUID mediakey (AssociatedFile Nothing) Nothing Transfer.noRetry $ \p -> do
+					showDestinationFile dest
 					youtubeDl url (fromRawFilePath workdir) p >>= \case
 						Right (Just mediafile) -> do
 							cleanuptmp
 							checkCanAdd o dest $ \canadd -> do
-								showDestinationFile (fromRawFilePath dest)
 								addWorkTree canadd addunlockedmatcher webUUID mediaurl dest mediakey (Just (toRawFilePath mediafile))
 								return $ Just mediakey
-						Right Nothing -> checkRaw Nothing o Nothing (normalfinish tmp backend)
 						Left msg -> do
 							cleanuptmp
-							warning msg
+							warning (UnquotedString msg)
 							return Nothing
+						Right Nothing -> do
+							cleanuptmp
+							warning (UnquotedString dlcmd <> " did not download anything")
+							return Nothing
 		mediaurl = setDownloader url YoutubeDownloader
 		mediakey = Backend.URL.fromUrl mediaurl Nothing
 		-- Does the already annexed file have the mediaurl
@@ -372,17 +381,17 @@
 			if mediaurl `elem` us
 				then return (Just k)
 				else do
-					warning $ dest ++ " already exists; not overwriting"
+					warning $ QuotedPath dest <> " already exists; not overwriting"
 					return Nothing
 	
-checkRaw :: (Maybe String) -> DownloadOptions -> a -> Annex a -> Annex a
+checkRaw :: (Maybe String) -> DownloadOptions -> Annex a -> Annex a -> Annex a
 checkRaw failreason o f a
 	| noRawOption o = do
-		warning $ "Unable to use youtube-dl or a special remote and --no-raw was specified" ++
+		warning $ UnquotedString $ "Unable to use youtube-dl or a special remote and --no-raw was specified" ++
 			case failreason of
 				Just msg -> ": " ++ msg
 				Nothing -> ""
-		return f
+		f
 	| otherwise = a
 
 {- The destination file is not known at start time unless the user provided
@@ -402,13 +411,13 @@
 	-- available and get added to it. That's ok, this is only
 	-- used to prevent two threads running concurrently when that would
 	-- likely fail.
-	ai = OnlyActionOn urlkey (ActionItemOther (Just url))
+	ai = OnlyActionOn urlkey (ActionItemOther (Just (UnquotedString url)))
 	urlkey = Backend.URL.fromUrl url Nothing
 
-showDestinationFile :: FilePath -> Annex ()
+showDestinationFile :: RawFilePath -> Annex ()
 showDestinationFile file = do
-	showNote ("to " ++ file)
-	maybeShowJSON $ JSONChunk [("file", file)]
+	showNote ("to " <> QuotedPath file)
+	maybeShowJSON $ JSONChunk [("file", fromRawFilePath file)]
 
 {- The Key should be a dummy key, based on the URL, which is used
  - for this download, before we can examine the file and find its real key.
@@ -500,9 +509,9 @@
 		then nomedia
 		else youtubeDlFileName url >>= \case
 			Right mediafile -> usemedia (toRawFilePath mediafile)
-			Left err -> checkRaw (Just err) o Nothing nomedia
+			Left err -> checkRaw (Just err) o (pure Nothing) nomedia
 	| otherwise = do
-		warning $ "unable to access url: " ++ url
+		warning $ UnquotedString $ "unable to access url: " ++ url
 		return Nothing
   where
 	nomedia = do
@@ -521,7 +530,7 @@
 
 nodownloadWeb' :: DownloadOptions -> AddUnlockedMatcher -> URLString -> Key -> RawFilePath -> Annex (Maybe Key)
 nodownloadWeb' o addunlockedmatcher url key file = checkCanAdd o file $ \canadd -> do
-	showDestinationFile (fromRawFilePath file)
+	showDestinationFile file
 	createWorkTreeDirectory (parentDir file)
 	addWorkTree canadd addunlockedmatcher webUUID url file key Nothing
 	return (Just key)
@@ -560,11 +569,11 @@
 checkCanAdd :: DownloadOptions -> RawFilePath -> (CanAddFile -> Annex (Maybe a)) -> Annex (Maybe a)
 checkCanAdd o file a = ifM (isJust <$> (liftIO $ catchMaybeIO $ R.getSymbolicLinkStatus file))
 	( do
-		warning $ fromRawFilePath file ++ " already exists; not overwriting"
+		warning $ QuotedPath file <> " already exists; not overwriting"
 		return Nothing
 	, ifM (checkIgnored (checkGitIgnoreOption o) file)
 		( do
-			warning $ "not adding " ++ fromRawFilePath file ++ " which is .gitignored (use --no-check-gitignore to override)"
+			warning $ "not adding " <> QuotedPath file <> " which is .gitignored (use --no-check-gitignore to override)"
 			return Nothing
 		, a CanAddFile
 		)
diff --git a/Command/Assist.hs b/Command/Assist.hs
new file mode 100644
--- /dev/null
+++ b/Command/Assist.hs
@@ -0,0 +1,42 @@
+{- git-annex command
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Command.Assist (cmd) where
+
+import Command
+import qualified Command.Sync
+import qualified Command.Add
+import Annex.CheckIgnore
+import qualified Annex.Queue
+
+cmd :: Command
+cmd = withAnnexOptions [jobsOption, backendOption] $
+	command "assist" SectionCommon 
+		"add files and sync changes with remotes"
+		(paramRepeating paramRemote)
+		(myseek <--< Command.Sync.optParser Command.Sync.AssistMode)
+
+myseek :: Command.Sync.SyncOptions -> CommandSeek
+myseek o = startConcurrency transferStages $ do
+	-- Changes to top of repository, so when this is run in a
+	-- subdirectory, it will still default to adding files anywhere in
+	-- the working tree.
+	Command.Sync.prepMerge
+
+	Command.Add.seek Command.Add.AddOptions
+		{ Command.Add.addThese = Command.Sync.contentOfOption o
+		, Command.Add.batchOption = NoBatch
+		, Command.Add.updateOnly = False
+		, Command.Add.largeFilesOverride = Nothing
+		, Command.Add.checkGitIgnoreOption = CheckGitIgnore True
+		, Command.Add.dryRunOption = DryRun False
+		}
+	waitForAllRunningCommandActions
+	-- Flush added files to index so they will be committed.
+	Annex.Queue.flush
+
+	Command.Sync.seek' o
diff --git a/Command/Assistant.hs b/Command/Assistant.hs
--- a/Command/Assistant.hs
+++ b/Command/Assistant.hs
@@ -24,7 +24,7 @@
 cmd = dontCheck repoExists $ notBareRepo $
 	noRepo (startNoRepo <$$> optParser) $
 		command "assistant" SectionCommon
-			"automatically sync changes"
+			"daemon to add files and automatically sync changes"
 			paramNothing (seek <$$> optParser)
 
 data AssistantOptions = AssistantOptions
@@ -130,8 +130,11 @@
 	program <- programPath
 	forM_ dirs $ \d -> do
 		putStrLn $ "git-annex autostop in " ++ d
-		setCurrentDirectory d
-		ifM (boolSystem program [Param "assistant", Param "--stop"])
-			( putStrLn "ok"
-			, putStrLn "failed"
-			)
+		tryIO (setCurrentDirectory d) >>= \case
+			Right () -> ifM (boolSystem program [Param "assistant", Param "--stop"])
+				( putStrLn "ok"
+				, putStrLn "failed"
+				)
+			Left e -> do
+				putStrLn (show e)
+				putStrLn "failed"
diff --git a/Command/CalcKey.hs b/Command/CalcKey.hs
--- a/Command/CalcKey.hs
+++ b/Command/CalcKey.hs
@@ -11,6 +11,8 @@
 import Backend (genKey, defaultBackend)
 import Types.KeySource
 import Utility.Metered
+import Utility.Terminal
+import Utility.SafeOutput
 
 cmd :: Command
 cmd = noCommit $ noMessages $ dontCheck repoExists $
@@ -23,7 +25,9 @@
 run :: () -> SeekInput -> String -> Annex Bool
 run _ _ file = tryNonAsync (genKey ks nullMeterUpdate =<< defaultBackend) >>= \case
 	Right (k, _) -> do
-		liftIO $ putStrLn $ serializeKey k
+		IsTerminal isterminal <- liftIO $ checkIsTerminal stdout
+		let sk = serializeKey k
+		liftIO $ putStrLn $ if isterminal then safeOutput sk else sk
 		return True
 	Left _err -> return False
   where
diff --git a/Command/CheckPresentKey.hs b/Command/CheckPresentKey.hs
--- a/Command/CheckPresentKey.hs
+++ b/Command/CheckPresentKey.hs
@@ -10,6 +10,7 @@
 import Command
 import qualified Remote
 import Remote.List
+import Utility.SafeOutput
 
 cmd :: Command
 cmd = noCommit $ noMessages $
@@ -68,7 +69,7 @@
 exitResult Present = liftIO exitSuccess
 exitResult NotPresent = liftIO exitFailure
 exitResult (CheckFailure msg) = liftIO $ do
-	hPutStrLn stderr msg
+	hPutStrLn stderr (safeOutput msg)
 	exitWith $ ExitFailure 100
 
 batchResult :: Result -> Annex ()
diff --git a/Command/Config.hs b/Command/Config.hs
--- a/Command/Config.hs
+++ b/Command/Config.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2017-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2017-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -13,7 +13,12 @@
 import Logs.Config
 import Config
 import Types.GitConfig (globalConfigs)
-import Git.Types (fromConfigValue)
+import Git.Types (fromConfigValue, fromConfigKey)
+import qualified Git.Command
+import Utility.SafeOutput
+import Annex.CheckAttr
+import Types.NumCopies
+import Logs.NumCopies
 
 import qualified Data.ByteString.Char8 as S8
 
@@ -26,12 +31,13 @@
 	= SetConfig ConfigKey ConfigValue
 	| GetConfig ConfigKey
 	| UnsetConfig ConfigKey
+	| ShowOrigin ConfigKey (Maybe FilePath)
 
 type Name = String
 type Value = String
 
 optParser :: CmdParamsDesc -> Parser Action
-optParser _ = setconfig <|> getconfig <|> unsetconfig
+optParser _ = setconfig <|> getconfig <|> unsetconfig <|> showorigin
   where
 	setconfig = SetConfig
 		<$> strOption
@@ -52,40 +58,158 @@
 		<> help "unset configuration"
 		<> metavar paramName
 		)
+	showorigin = ShowOrigin
+		<$> strOption
+			( long "show-origin"
+			<> help "explain where a value is configured"
+			<> metavar paramName
+			)
+		<*> optional (strOption
+			( long "for-file"
+			<> help "filename to check for in gitattributes"
+			<> metavar paramFile
+			))
 
 seek :: Action -> CommandSeek
-seek (SetConfig ck@(ConfigKey name) val) = checkIsGlobalConfig ck $ commandAction $
-	startingUsualMessages (decodeBS name) ai si $ do
-		setGlobalConfig ck val
+seek (SetConfig ck@(ConfigKey name) val) = checkIsGlobalConfig ck $ \setter _unsetter _getter ->
+	commandAction $ startingUsualMessages (decodeBS name) ai si $ do
+		setter val
 		when (needLocalUpdate ck) $
 			setConfig ck (fromConfigValue val)
 		next $ return True
   where
-	ai = ActionItemOther (Just (fromConfigValue val))
+	ai = ActionItemOther (Just (UnquotedString (fromConfigValue val)))
 	si = SeekInput [decodeBS name]
-seek (UnsetConfig ck@(ConfigKey name)) = checkIsGlobalConfig ck $ commandAction $
-	startingUsualMessages (decodeBS name) ai si $ do
-		unsetGlobalConfig ck
+seek (UnsetConfig ck@(ConfigKey name)) = checkIsGlobalConfig ck $ \_setter unsetter _getter ->
+	commandAction $ startingUsualMessages (decodeBS name) ai si $ do
+		unsetter
 		when (needLocalUpdate ck) $
 			unsetConfig ck
 		next $ return True
   where
 	ai = ActionItemOther (Just "unset")
 	si = SeekInput [decodeBS name]
-seek (GetConfig ck) = checkIsGlobalConfig ck $ commandAction $
-	startingCustomOutput ai $ do
-		getGlobalConfig ck >>= \case
-			Just (ConfigValue v) -> liftIO $ S8.putStrLn v
+seek (GetConfig ck) = checkIsGlobalConfig ck $ \_setter _unsetter getter ->
+	commandAction $	startingCustomOutput ai $ do
+		getter >>= \case
+			Just (ConfigValue v) -> liftIO $ S8.putStrLn $ safeOutput v
 			Just NoConfigValue -> return ()
 			Nothing -> return ()
 		next $ return True
   where
 	ai = ActionItemOther Nothing
+seek (ShowOrigin ck@(ConfigKey name) forfile) = commandAction $
+	startingCustomOutput ai $ next $ checknotconfigured $
+		case checkIsGlobalConfig' ck of
+			Just (_setter, _unsetter, getter) ->
+				ifM gitconfigorigin
+					( return True
+					, checkattrs (checkconfigbranch getter)
+					)
+			Nothing -> ifM gitconfigorigin
+				( return True
+				, checkattrs checkgitconfigunderride
+				)
+  where
+	ai = ActionItemOther Nothing
 
-checkIsGlobalConfig :: ConfigKey -> Annex a -> Annex a
-checkIsGlobalConfig ck@(ConfigKey name) a
-	| elem ck globalConfigs = a
-	| otherwise = giveup $ decodeBS name ++ " is not a configuration setting that can be stored in the git-annex branch"
+	gitconfigorigin
+		| name `elem` gitconfigdoesnotoverride = return False
+		| otherwise = gitconfigorigin'
+	gitconfigorigin' = inRepo $ Git.Command.runBool
+			[ Param "config"
+			, Param "--show-origin"
+			, Param (decodeBS name)
+			]
+	
+	-- git configs for these do not override values from git attributes
+	-- or the branch
+	gitconfigdoesnotoverride =
+		[ "annex.numcopies"
+		, "annex.mincopies"
+		]
+
+	-- the git config for annex.numcopies is a special case; it's only
+	-- used if not configured anywhere else
+	checkgitconfigunderride
+		| name == "annex.numcopies" = gitconfigorigin'
+		| otherwise = return False
+
+	-- Display similar to git config --show-origin
+	showval loc v = liftIO $ do
+		putStrLn $ loc ++ "\t" ++ v
+		return True
+	
+	configbranch v
+		| needLocalUpdate ck = checkgitconfigunderride
+		| otherwise = showval "branch:git-annex" (decodeBS v)
+	
+	checkconfigbranch getter = getter >>= \case
+		Just (ConfigValue v) -> configbranch v
+		_ -> checkgitconfigunderride
+	
+	checkattrs cont
+		| decodeBS name `elem` annexAttrs =
+			case forfile of
+				Just file -> do
+					v <- checkAttr (decodeBS name) (toRawFilePath file)
+					if null v
+						then cont
+						else showval "gitattributes" v		
+				Nothing -> do
+					warnforfile
+					cont
+		| otherwise = cont
+	
+	warnforfile = warning $ UnquotedString $ configKeyMessage ck $ unwords
+		[ "may be configured in gitattributes."
+		, "Pass --for-file= with a filename to check"
+		]
+	
+	checknotconfigured a = do
+		ok <- a
+		unless ok $
+			warning $ UnquotedString $ configKeyMessage ck
+				"is not configured"
+		return ok
+
+type Setter = ConfigValue -> Annex ()
+type Unsetter = Annex ()
+type Getter = Annex (Maybe ConfigValue)
+
+checkIsGlobalConfig :: ConfigKey -> (Setter -> Unsetter -> Getter -> Annex a) -> Annex a
+checkIsGlobalConfig ck a = case checkIsGlobalConfig' ck of
+	Just (setter, unsetter, getter) -> a setter unsetter getter
+	Nothing -> giveup $ configKeyMessage ck "is not a configuration setting that can be stored in the git-annex branch"
+
+checkIsGlobalConfig' :: ConfigKey -> Maybe (Setter, Unsetter, Getter)
+checkIsGlobalConfig' ck
+	| elem ck globalConfigs = Just
+		( setGlobalConfig ck
+		, unsetGlobalConfig ck
+		, getGlobalConfig ck
+		)
+	-- These came before this command, but are also global configs,
+	-- so support them here as well.
+	| ck == ConfigKey "annex.numcopies" = Just
+		( mksetter (setGlobalNumCopies . configuredNumCopies)
+		, error "unsetting annex.numcopies is not supported"
+		, mkgetter fromNumCopies getGlobalNumCopies
+		)
+	| ck == ConfigKey "annex.mincopies" = Just
+		( mksetter (setGlobalMinCopies . configuredMinCopies)
+		, error "unsetting annex.mincopies is not supported"
+		, mkgetter fromMinCopies getGlobalMinCopies
+		)
+	| otherwise = Nothing
+  where
+	mksetter f = 
+		maybe (error ("invalid value for " ++ fromConfigKey ck)) f 
+			. readish . decodeBS . fromConfigValue
+	mkgetter f g = fmap (ConfigValue . encodeBS . show . f) <$> g
+
+configKeyMessage :: ConfigKey -> String -> String
+configKeyMessage (ConfigKey name) msg = decodeBS name ++ " " ++ msg
 
 needLocalUpdate :: ConfigKey -> Bool
 needLocalUpdate (ConfigKey "annex.securehashesonly") = True
diff --git a/Command/ConfigRemote.hs b/Command/ConfigRemote.hs
new file mode 100644
--- /dev/null
+++ b/Command/ConfigRemote.hs
@@ -0,0 +1,64 @@
+{- git-annex command
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module Command.ConfigRemote where
+
+import Command
+import qualified Logs.Remote
+import qualified Git.Types as Git
+import qualified Annex.SpecialRemote as SpecialRemote
+import qualified Types.Remote as Remote
+import Types.ProposedAccepted
+import Command.EnableRemote (unknownNameError, startSpecialRemote', PerformSpecialRemote, deadLast)
+
+import qualified Data.Map as M
+
+cmd :: Command
+cmd = withAnnexOptions [jsonOptions] $
+	command "configremote" SectionSetup
+		"changes special remote configuration"
+		(paramPair paramName $ paramOptional $ paramRepeating paramParamValue)
+		(withParams seek)
+
+seek :: CmdParams -> CommandSeek
+seek = withWords (commandAction . start)
+
+start :: [String] -> CommandStart
+start [] = unknownNameError "Specify the remote to configure."
+start (name:inputconfig) = deadLast name $
+	startSpecialRemote (checkSafeConfig inputconfig) name
+		(Logs.Remote.keyValToConfig Proposed inputconfig)
+
+{- Since this command stores config without calling the remote's setup
+ - method to validate it, it can only be used on fields that are known to
+ - be safe to change in all remotes. -}
+checkSafeConfig :: [String] -> Annex ()
+checkSafeConfig cs = do
+	let rc = Logs.Remote.keyValToConfig Proposed cs
+	forM_ (M.keys rc) $ \k ->
+		when (fromProposedAccepted k `notElem` safefields) $
+			giveup $ "Cannot change field \"" ++ fromProposedAccepted k  ++ "\" with this command. Use git-annex enableremote instead."
+	case SpecialRemote.parseRemoteConfig rc (Remote.RemoteConfigParser ps Nothing) of
+		Left err -> giveup err
+		Right _ -> return ()
+  where
+	ps = [ SpecialRemote.autoEnableFieldParser ]
+	safefields = [ fromProposedAccepted SpecialRemote.autoEnableField ]
+
+startSpecialRemote :: Annex () -> Git.RemoteName -> Remote.RemoteConfig -> [(UUID, Remote.RemoteConfig, Maybe (SpecialRemote.ConfigFrom UUID))] -> CommandStart
+startSpecialRemote = startSpecialRemote' "configremote" . performSpecialRemote
+
+performSpecialRemote :: Annex () -> PerformSpecialRemote
+performSpecialRemote precheck _ u _ c _ mcu = do
+	precheck
+	case mcu of
+		Nothing -> Logs.Remote.configSet u c
+		Just (SpecialRemote.ConfigFrom cu) ->
+			Logs.Remote.configSet cu c
+	next $ return True
diff --git a/Command/Copy.hs b/Command/Copy.hs
--- a/Command/Copy.hs
+++ b/Command/Copy.hs
@@ -60,7 +60,7 @@
 		Batch fmt -> batchOnly (keyOptions o) (copyFiles o) $
 			batchAnnexed fmt seeker keyaction
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "copy"
 	
 	seeker = AnnexedFileSeeker
 		{ startAction = start o fto
diff --git a/Command/Dead.hs b/Command/Dead.hs
--- a/Command/Dead.hs
+++ b/Command/Dead.hs
@@ -15,8 +15,9 @@
 import Git.Types
 
 cmd :: Command
-cmd = command "dead" SectionSetup "hide a lost repository or key"
-	(paramRepeating paramRepository) (seek <$$> optParser)
+cmd = withAnnexOptions [jsonOptions] $
+	command "dead" SectionSetup "hide a lost repository or key"
+		(paramRepeating paramRepository) (seek <$$> optParser)
 
 data DeadOptions = DeadRemotes [RemoteName] | DeadKeys [Key]
 
diff --git a/Command/Describe.hs b/Command/Describe.hs
--- a/Command/Describe.hs
+++ b/Command/Describe.hs
@@ -23,10 +23,10 @@
 start :: [String] -> CommandStart
 start (name:description) | not (null description) = do
 	u <- Remote.nameToUUID name
+	let ai = ActionItemUUID u (UnquotedString name)
 	starting "describe" ai si $
 		perform u $ unwords description
   where
-	ai = ActionItemOther (Just name)
 	si = SeekInput [name]
 start _ = giveup "Specify a repository and a description."	
 
diff --git a/Command/Drop.hs b/Command/Drop.hs
--- a/Command/Drop.hs
+++ b/Command/Drop.hs
@@ -74,7 +74,7 @@
 		Batch fmt -> batchOnly (keyOptions o) (dropFiles o) $
 			batchAnnexed fmt seeker (startKeys o from)
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "drop"
 
 start :: DropOptions -> Maybe Remote -> SeekInput -> RawFilePath -> Key -> CommandStart
 start o from si file key = start' o from key afile ai si
@@ -233,10 +233,10 @@
 			if afile == afile'
 				then showLongNote "That file is required content. It cannot be dropped!"
 				else showLongNote $ "That file has the same content as another file"
-					++ case afile' of
-						AssociatedFile (Just f) -> " (" ++ fromRawFilePath f ++ "),"
+					<> case afile' of
+						AssociatedFile (Just f) -> " (" <> QuotedPath f <> "),"
 						AssociatedFile Nothing -> ""
-					++ " which is required content. It cannot be dropped!"
+					<> " which is required content. It cannot be dropped!"
 			showLongNote "(Use --force to override this check, or adjust required content configuration.)"
 			return False
 
diff --git a/Command/DropUnused.hs b/Command/DropUnused.hs
--- a/Command/DropUnused.hs
+++ b/Command/DropUnused.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.DropUnused where
 
 import Command
@@ -18,9 +20,10 @@
 import qualified Utility.RawFilePath as R
 
 cmd :: Command
-cmd = command "dropunused" SectionMaintenance
-	"drop unused file content"
-	(paramRepeating paramNumRange) (seek <$$> optParser)
+cmd = withAnnexOptions [jsonOptions] $
+	command "dropunused" SectionMaintenance
+		"drop unused file content"
+		(paramRepeating paramNumRange) (seek <$$> optParser)
 
 data DropUnusedOptions = DropUnusedOptions
 	{ rangesToDrop :: CmdParams
@@ -40,15 +43,20 @@
 	withUnusedMaps (start from numcopies mincopies) (rangesToDrop o)
 
 start :: Maybe Remote -> NumCopies -> MinCopies -> UnusedMaps -> Int -> CommandStart
-start from numcopies mincopies = startUnused "dropunused"
-	(perform from numcopies mincopies)
-	(performOther gitAnnexBadLocation)
-	(performOther gitAnnexTmpObjectLocation)
+start from numcopies mincopies = startUnused
+	(go (perform from numcopies mincopies))
+	(go (performOther gitAnnexBadLocation))
+	(go (performOther gitAnnexTmpObjectLocation))
+  where
+	go a n key = starting "dropunused" 
+		(ActionItemOther $ Just $ UnquotedString $ show n)
+		(SeekInput [show n])
+		(a key)
 
 perform :: Maybe Remote -> NumCopies -> MinCopies -> Key -> CommandPerform
 perform from numcopies mincopies key = case from of
 	Just r -> do
-		showAction $ "from " ++ Remote.name r
+		showAction $ UnquotedString $ "from " ++ Remote.name r
 		Command.Drop.performRemote pcc key (AssociatedFile Nothing) numcopies mincopies r ud
 	Nothing -> ifM (inAnnex key)
 		( droplocal
diff --git a/Command/EnableRemote.hs b/Command/EnableRemote.hs
--- a/Command/EnableRemote.hs
+++ b/Command/EnableRemote.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2013-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2013-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -30,10 +30,11 @@
 import qualified Data.Map as M
 
 cmd :: Command
-cmd = command "enableremote" SectionSetup
-	"enables git-annex to use a remote"
-	(paramPair paramName $ paramOptional $ paramRepeating paramParamValue)
-	(withParams seek)
+cmd = withAnnexOptions [jsonOptions] $
+	command "enableremote" SectionSetup
+		"enables git-annex to use a remote"
+		(paramPair paramName $ paramOptional $ paramRepeating paramParamValue)
+		(withParams seek)
 
 seek :: CmdParams -> CommandSeek
 seek = withWords (commandAction . start)
@@ -43,61 +44,60 @@
 start (name:rest) = go =<< filter matchingname <$> Annex.getGitRemotes
   where
 	matchingname r = Git.remoteName r == Just name
-	go [] = 
-		let use = startSpecialRemote name (Logs.Remote.keyValToConfig Proposed rest)
-		in SpecialRemote.findExisting' name >>= \case
-			-- enable dead remote only when there is no
-			-- other remote with the same name
-			([], l) -> use l
-			(l, _) -> use l
-	go (r:_) = do
-		-- This could be either a normal git remote or a special
-		-- remote that has an url (eg gcrypt).
-		rs <- Remote.remoteList
-		case filter (\rmt -> Remote.name rmt == name) rs of
-			(rmt:_) | Remote.remotetype rmt == Remote.Git.remote ->
-				startNormalRemote name rest r
-			_  -> go []
+	go [] = deadLast name $ 
+		startSpecialRemote name (Logs.Remote.keyValToConfig Proposed rest)
+	go (r:_)
+		| not (null rest) = go []
+		| otherwise = do
+			-- This could be either a normal git remote or a special
+			-- remote that has an url (eg gcrypt).
+			rs <- Remote.remoteList
+			case filter (\rmt -> Remote.name rmt == name) rs of
+				(rmt:_) | Remote.remotetype rmt == Remote.Git.remote ->
+					startNormalRemote name r
+				_  -> go []
 
--- Normal git remotes are special-cased; enableremote retries probing
--- the remote uuid.
-startNormalRemote :: Git.RemoteName -> [String] -> Git.Repo -> CommandStart
-startNormalRemote name restparams r
-	| null restparams = starting "enableremote" ai si $ do
-		setRemoteIgnore r False
-		r' <- Remote.Git.configRead False r
-		u <- getRepoUUID r'
-		next $ return $ u /= NoUUID
-	| otherwise = giveup $
-		"That is a normal git remote; passing these parameters does not make sense: " ++ unwords restparams
+-- enableremote of a normal git remote with no added parameters is a special case
+-- that retries probing the remote uuid.
+startNormalRemote :: Git.RemoteName -> Git.Repo -> CommandStart
+startNormalRemote name r = starting "enableremote (normal)" ai si $ do
+	setRemoteIgnore r False
+	r' <- Remote.Git.configRead False r
+	u <- getRepoUUID r'
+	next $ return $ u /= NoUUID
   where
-	ai = ActionItemOther (Just name)
+	ai = ActionItemOther (Just (UnquotedString name))
 	si = SeekInput [name]
 
 startSpecialRemote :: Git.RemoteName -> Remote.RemoteConfig -> [(UUID, Remote.RemoteConfig, Maybe (SpecialRemote.ConfigFrom UUID))] -> CommandStart
-startSpecialRemote name config [] = do
+startSpecialRemote = startSpecialRemote' "enableremote" performSpecialRemote
+
+type PerformSpecialRemote = RemoteType -> UUID -> R.RemoteConfig -> R.RemoteConfig -> RemoteGitConfig -> Maybe (SpecialRemote.ConfigFrom UUID) -> CommandPerform
+
+startSpecialRemote' :: String -> PerformSpecialRemote -> Git.RemoteName -> Remote.RemoteConfig -> [(UUID, Remote.RemoteConfig, Maybe (SpecialRemote.ConfigFrom UUID))] -> CommandStart
+startSpecialRemote' cname perform name config [] = do
 	m <- SpecialRemote.specialRemoteMap
 	confm <- Logs.Remote.remoteConfigMap
 	Remote.nameToUUID' name >>= \case
 		([u], _) | u `M.member` m ->
-			startSpecialRemote name config $
+			startSpecialRemote' cname perform name config $
 				[(u, fromMaybe M.empty (M.lookup u confm), Nothing)]
 		(_, msg) -> unknownNameError msg
-startSpecialRemote name config ((u, c, mcu):[]) =
-	starting "enableremote" ai si $ do
+startSpecialRemote' cname perform name config ((u, c, mcu):[]) =
+	starting cname ai si $ do
 		let fullconfig = config `M.union` c	
 		t <- either giveup return (SpecialRemote.findType fullconfig)
 		gc <- maybe (liftIO dummyRemoteGitConfig) 
 			(return . Remote.gitconfig)
 			=<< Remote.byUUID u
-		performSpecialRemote t u c fullconfig gc mcu
+		perform t u c fullconfig gc mcu
   where
-	ai = ActionItemOther (Just name)
+	ai = ActionItemOther (Just (UnquotedString name))
 	si = SeekInput [name]
-startSpecialRemote _ _ _ =
-	giveup "Multiple remotes have that name. Either use git-annex renameremote to rename them, or specify the uuid of the remote to enable."
+startSpecialRemote' _ _ _ _ _ =
+	giveup "Multiple remotes have that name. Either use git-annex renameremote to rename them, or specify the uuid of the remote."
 
-performSpecialRemote :: RemoteType -> UUID -> R.RemoteConfig -> R.RemoteConfig -> RemoteGitConfig -> Maybe (SpecialRemote.ConfigFrom UUID) -> CommandPerform
+performSpecialRemote :: PerformSpecialRemote
 performSpecialRemote t u oldc c gc mcu = do
 	(c', u') <- R.setup t (R.Enable oldc) (Just u) Nothing c gc
 	next $ cleanupSpecialRemote t u' c' mcu
@@ -105,8 +105,7 @@
 cleanupSpecialRemote :: RemoteType -> UUID -> R.RemoteConfig -> Maybe (SpecialRemote.ConfigFrom UUID) -> CommandCleanup
 cleanupSpecialRemote t u c mcu = do
 	case mcu of
-		Nothing -> 
-			Logs.Remote.configSet u c
+		Nothing -> Logs.Remote.configSet u c
 		Just (SpecialRemote.ConfigFrom cu) -> do
 			setConfig (remoteAnnexConfig c "config-uuid") (fromUUID cu)
 			Logs.Remote.configSet cu c
@@ -140,3 +139,11 @@
 		, liftIO . getDynamicConfig . remoteAnnexIgnore
 			=<< Annex.getRemoteGitConfig r
 		]
+
+-- Use dead remote only when there is no other remote
+-- with the same name
+deadLast :: Git.RemoteName -> ([(UUID, Remote.RemoteConfig, Maybe (SpecialRemote.ConfigFrom UUID))] -> Annex a) -> Annex a
+deadLast name use =
+	SpecialRemote.findExisting' name >>= \case
+		([], l) -> use l
+		(l, _) -> use l
diff --git a/Command/EnableTor.hs b/Command/EnableTor.hs
--- a/Command/EnableTor.hs
+++ b/Command/EnableTor.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings, CPP #-}
 
 module Command.EnableTor where
 
@@ -61,7 +61,7 @@
 			let ps = [Param (cmdname cmd), Param (show curruserid)]
 			sucommand <- liftIO $ mkSuCommand gitannex ps
 			cleanenv <- liftIO $ cleanStandaloneEnvironment
-			maybe noop showLongNote
+			maybe noop (showLongNote . UnquotedString)
 				(describePasswordPrompt' sucommand)
 			ifM (liftIO $ runSuCommand sucommand cleanenv)
 				( next checkHiddenService
@@ -111,7 +111,7 @@
 		-- we just want to know if the tor circuit works.
 		liftIO (tryNonAsync $ connectPeer g addr) >>= \case
 			Left e -> do
-				warning $ "Unable to connect to hidden service. It may not yet have propigated to the Tor network. (" ++ show e ++ ") Will retry.."
+				warning $ UnquotedString $ "Unable to connect to hidden service. It may not yet have propigated to the Tor network. (" ++ show e ++ ") Will retry.."
 				liftIO $ threadDelaySeconds (Seconds 2)
 				check (n-1) addrs
 			Right conn -> do
diff --git a/Command/ExamineKey.hs b/Command/ExamineKey.hs
--- a/Command/ExamineKey.hs
+++ b/Command/ExamineKey.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.ExamineKey where
 
 import Command
@@ -14,6 +16,7 @@
 import Backend
 import Types.Backend
 import Types.Key
+import Utility.Terminal
 
 import Data.Char
 import qualified Data.ByteString as B
@@ -54,7 +57,8 @@
 	
 	objectpath <- calcRepo $ gitAnnexLocation k
 	let objectpointer = formatPointer k
-	showFormatted (format o) (serializeKey' k) $
+	isterminal <- liftIO $ checkIsTerminal stdout
+	showFormatted isterminal (format o) (serializeKey' k) $
 		[ ("objectpath", fromRawFilePath objectpath)
 		, ("objectpointer", fromRawFilePath objectpointer)
 		] ++ formatVars k af
diff --git a/Command/Expire.hs b/Command/Expire.hs
--- a/Command/Expire.hs
+++ b/Command/Expire.hs
@@ -22,9 +22,10 @@
 import qualified Data.Map as M
 
 cmd :: Command
-cmd = command "expire" SectionMaintenance
-	"expire inactive repositories"
-	paramExpire (seek <$$> optParser)
+cmd = withAnnexOptions [jsonOptions] $
+	command "expire" SectionMaintenance
+		"expire inactive repositories"
+		paramExpire (seek <$$> optParser)
 
 paramExpire :: String
 paramExpire = (paramRepeating $ paramOptional paramRemote ++ ":" ++ paramTime)
@@ -61,13 +62,13 @@
 	case lastact of
 		Just ent | notexpired ent -> checktrust (== DeadTrusted) $
 			starting "unexpire" ai si $ do
-				showNote =<< whenactive
+				showNote . UnquotedString =<< whenactive
 				unless noact $
 					trustSet u SemiTrusted
 				next $ return True
 		_ -> checktrust (/= DeadTrusted) $
 			starting "expire" ai si $ do
-				showNote =<< whenactive
+				showNote . UnquotedString =<< whenactive
 				unless noact $
 					trustSet u DeadTrusted
 				next $ return True
@@ -79,7 +80,7 @@
 			return $ "last active: " ++ fromDuration d ++ " ago"
 		_  -> return "no activity"
 	desc = fromUUID u ++ " " ++ fromUUIDDesc (fromMaybe mempty (M.lookup u descs))
-	ai = ActionItemOther (Just desc)
+	ai = ActionItemUUID u (UnquotedString desc)
 	si = SeekInput []
 	notexpired ent = case ent of
 		Unknown -> False
diff --git a/Command/Export.hs b/Command/Export.hs
--- a/Command/Export.hs
+++ b/Command/Export.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2017-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2017-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -413,13 +413,15 @@
 	loc = mkExportLocation f'
 	f' = getTopFilePath f
 	tmploc = exportTempName ek
-	ai = ActionItemOther $ Just $ fromRawFilePath f' ++ " -> " ++ fromRawFilePath (fromExportLocation tmploc)
+	ai = ActionItemOther $ Just $ 
+		QuotedPath f' <> " -> " <> QuotedPath (fromExportLocation tmploc)
 	si = SeekInput []
 
 startMoveFromTempName :: Remote -> ExportHandle -> Key -> TopFilePath -> CommandStart
 startMoveFromTempName r db ek f = do
 	let tmploc = exportTempName ek
-	let ai = ActionItemOther (Just (fromRawFilePath (fromExportLocation tmploc) ++ " -> " ++ fromRawFilePath f'))
+	let ai = ActionItemOther $ Just $
+		QuotedPath (fromExportLocation tmploc) <> " -> " <> QuotedPath f'
 	stopUnless (liftIO $ elem tmploc <$> getExportedLocation db ek) $
 		starting ("rename " ++ name r) ai si $
 			performRename r db ek tmploc loc
@@ -433,7 +435,7 @@
 	tryNonAsync (renameExport (exportActions r) ek src dest) >>= \case
 		Right (Just ()) -> next $ cleanupRename r db ek src dest
 		Left err -> do
-			warning $ "rename failed (" ++ show err ++ "); deleting instead"
+			warning $ UnquotedString $ "rename failed (" ++ show err ++ "); deleting instead"
 			fallbackdelete
 		-- remote does not support renaming
 		Right Nothing -> fallbackdelete
diff --git a/Command/FilterBranch.hs b/Command/FilterBranch.hs
--- a/Command/FilterBranch.hs
+++ b/Command/FilterBranch.hs
@@ -192,4 +192,4 @@
 	c <- inRepo $ Git.commitTree cmode cmessage [] t
 	liftIO $ putStrLn (fromRef c)
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "filter-branch"
diff --git a/Command/Find.hs b/Command/Find.hs
--- a/Command/Find.hs
+++ b/Command/Find.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2010-2018 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -19,6 +19,7 @@
 import Git.FilePath
 import qualified Utility.Format
 import Utility.DataUnits
+import Utility.Terminal
 
 cmd :: Command
 cmd = withAnnexOptions [annexedMatchingOptions] $ mkCommand $
@@ -60,20 +61,21 @@
 seek o = do
 	unless (isJust (keyOptions o)) $
 		checkNotBareRepo
+	isterminal <- liftIO $ checkIsTerminal stdout
 	seeker <- contentPresentUnlessLimited $ AnnexedFileSeeker
-		{ startAction = start o
+		{ startAction = start o isterminal
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
 	case batchOption o of
 		NoBatch -> withKeyOptions (keyOptions o) False seeker
-			(commandAction . startKeys o)
+			(commandAction . startKeys o isterminal)
 			(withFilesInGitAnnex ww seeker)
 			=<< workTreeItems ww (findThese o)
 		Batch fmt -> batchOnly (keyOptions o) (findThese o) $
 			batchAnnexedFiles fmt seeker
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "find"
 
 -- Default to needing content to be present, but if the user specified a
 -- limit, content does not need to be present.
@@ -86,22 +88,25 @@
 			else Just True
 		}
 
-start :: FindOptions -> SeekInput -> RawFilePath -> Key -> CommandStart
-start o _ file key = startingCustomOutput key $ do
-	showFormatted (formatOption o) file 
+start :: FindOptions -> IsTerminal -> SeekInput -> RawFilePath -> Key -> CommandStart
+start o isterminal _ file key = startingCustomOutput key $ do
+	showFormatted isterminal (formatOption o) file
 		(formatVars key (AssociatedFile (Just file)))
 	next $ return True
 
-startKeys :: FindOptions -> (SeekInput, Key, ActionItem) -> CommandStart
-startKeys o (si, key, ActionItemBranchFilePath (BranchFilePath _ topf) _) = 
-	start o si (getTopFilePath topf) key
-startKeys _ _ = stop
+startKeys :: FindOptions -> IsTerminal -> (SeekInput, Key, ActionItem) -> CommandStart
+startKeys o isterminal (si, key, ActionItemBranchFilePath (BranchFilePath _ topf) _) = 
+	start o isterminal si (getTopFilePath topf) key
+startKeys _ _ _ = stop
 
-showFormatted :: Maybe Utility.Format.Format -> S.ByteString -> [(String, String)] -> Annex ()
-showFormatted format unformatted vars =
+showFormatted :: IsTerminal -> Maybe Utility.Format.Format -> S.ByteString -> [(String, String)] -> Annex ()
+showFormatted (IsTerminal isterminal) format unformatted vars =
 	unlessM (showFullJSON $ JSONChunk vars) $
 		case format of
-			Nothing -> liftIO $ S8.putStrLn unformatted
+			Nothing -> do
+				liftIO $ S8.putStrLn $ if isterminal
+					then Utility.Format.encode_c (const False) unformatted
+					else unformatted
 			Just formatter -> liftIO $ putStr $
 				Utility.Format.format formatter $
 					M.fromList vars
diff --git a/Command/FindKeys.hs b/Command/FindKeys.hs
--- a/Command/FindKeys.hs
+++ b/Command/FindKeys.hs
@@ -8,8 +8,9 @@
 module Command.FindKeys where
 
 import Command
-import qualified Utility.Format
 import qualified Command.Find
+import qualified Utility.Format
+import Utility.Terminal
 
 cmd :: Command
 cmd = withAnnexOptions [keyMatchingOptions] $ Command.Find.mkCommand $
@@ -26,22 +27,23 @@
 
 seek :: FindKeysOptions -> CommandSeek
 seek o = do
+	isterminal <- liftIO $ checkIsTerminal stdout
 	seeker <- Command.Find.contentPresentUnlessLimited $ AnnexedFileSeeker
 		{ checkContentPresent = Nothing
 		, usesLocationLog = False
 		-- startAction is not actually used since this
 		-- is not used to seek files
-		, startAction = \_ _ key -> start' o key
+		, startAction = \_ _ key -> start' o isterminal key
 		}
 	withKeyOptions (Just WantAllKeys) False seeker
-		(commandAction . start o)
+		(commandAction . start o isterminal)
 		(const noop) (WorkTreeItems [])
 
-start :: FindKeysOptions -> (SeekInput, Key, ActionItem) -> CommandStart
-start o (_si, key, _ai) = start' o key
+start :: FindKeysOptions -> IsTerminal -> (SeekInput, Key, ActionItem) -> CommandStart
+start o isterminal (_si, key, _ai) = start' o isterminal key
 
-start' :: FindKeysOptions -> Key -> CommandStart
-start' o key = startingCustomOutput key $ do
-	Command.Find.showFormatted (formatOption o) (serializeKey' key)
+start' :: FindKeysOptions -> IsTerminal -> Key -> CommandStart
+start' o isterminal key = startingCustomOutput key $ do
+	Command.Find.showFormatted isterminal (formatOption o) (serializeKey' key)
 		(Command.Find.formatVars key (AssociatedFile Nothing))
 	next $ return True
diff --git a/Command/Fix.hs b/Command/Fix.hs
--- a/Command/Fix.hs
+++ b/Command/Fix.hs
@@ -26,7 +26,7 @@
 #endif
 
 cmd :: Command
-cmd = noCommit $ withAnnexOptions [annexedMatchingOptions] $
+cmd = noCommit $ withAnnexOptions [annexedMatchingOptions, jsonOptions] $
 	command "fix" SectionMaintenance
 		"fix up links to annexed content"
 		paramPaths (withParams seek)
@@ -35,7 +35,7 @@
 seek ps = unlessM crippledFileSystem $
 	withFilesInGitAnnex ww seeker =<< workTreeItems ww ps
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "fix"
 	seeker = AnnexedFileSeeker
 		{ startAction = start FixAll
 		, checkContentPresent = Nothing
@@ -76,7 +76,7 @@
 		let tmp' = toRawFilePath tmp
 		mode <- liftIO $ catchMaybeIO $ fileMode <$> R.getFileStatus file
 		unlessM (checkedCopyFile key obj tmp' mode) $
-			error "unable to break hard link"
+			giveup "unable to break hard link"
 		thawContent tmp'
 		Database.Keys.storeInodeCaches key [tmp']
 		modifyContentDir obj $ freezeContent obj
@@ -87,7 +87,7 @@
 	replaceWorkTreeFile (fromRawFilePath file) $ \tmp -> do
 		mode <- liftIO $ catchMaybeIO $ fileMode <$> R.getFileStatus file
 		linkFromAnnex' key (toRawFilePath tmp) mode >>= \case
-			LinkAnnexFailed -> error "unable to make hard link"
+			LinkAnnexFailed -> giveup "unable to make hard link"
 			_ -> noop
 	next $ return True
 
diff --git a/Command/Forget.hs b/Command/Forget.hs
--- a/Command/Forget.hs
+++ b/Command/Forget.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.Forget where
 
 import Command
@@ -42,7 +44,7 @@
 		else basets
 	perform ts =<< Annex.getRead Annex.force
   where
-	ai = ActionItemOther (Just (fromRef Branch.name))
+	ai = ActionItemOther (Just (UnquotedString (fromRef Branch.name)))
 	si = SeekInput []
 
 perform :: Transitions -> Bool -> CommandPerform
diff --git a/Command/FromKey.hs b/Command/FromKey.hs
--- a/Command/FromKey.hs
+++ b/Command/FromKey.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.FromKey where
 
 import Command
@@ -130,7 +132,7 @@
 		| otherwise -> hasothercontent
   where
 	hasothercontent = do
-		warning $ fromRawFilePath file ++ " already exists with different content"
+		warning $ QuotedPath file <> " already exists with different content"
 		next $ return False
 	
 	linkunlocked = linkFromAnnex key file Nothing >>= \case
diff --git a/Command/Fsck.hs b/Command/Fsck.hs
--- a/Command/Fsck.hs
+++ b/Command/Fsck.hs
@@ -42,7 +42,6 @@
 import qualified Database.Fsck as FsckDb
 import Types.CleanupActions
 import Types.Key
-import Types.ActionItem
 import qualified Utility.RawFilePath as R
 
 import Data.Time.Clock.POSIX
@@ -114,7 +113,7 @@
 	cleanupIncremental i
 	void $ tryIO $ recordActivity Fsck u
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "fsck"
 
 checkDeadRepo :: UUID -> Annex ()
 checkDeadRepo u =
@@ -160,14 +159,15 @@
 	dispatch =<< Remote.hasKey remote key
   where
 	dispatch (Left err) = do
-		showNote err
+		showNote (UnquotedString err)
 		return False
 	dispatch (Right True) = withtmp $ \tmpfile ->
 		getfile tmpfile >>= \case
 			Nothing -> go True Nothing
 			Just (Right verification) -> go True (Just (tmpfile, verification))
 			Just (Left _) -> do
-				warning (decodeBS (actionItemDesc ai) ++ ": failed to download file from remote")
+				warning $ actionItemDesc ai
+					<> ": failed to download file from remote"
 				void $ go True Nothing
 				return False
 	dispatch (Right False) = go False Nothing
@@ -320,7 +320,7 @@
 			KeyLockedThin -> thawContent obj
 			_ -> freezeContent obj
 		checkContentWritePerm obj >>= \case
-			Nothing -> warning $ "** Unable to set correct write mode for " ++ fromRawFilePath obj ++ " ; perhaps you don't own that file, or perhaps it has an xattr or ACL set"
+			Nothing -> warning $ "** Unable to set correct write mode for " <> QuotedPath obj <> " ; perhaps you don't own that file, or perhaps it has an xattr or ACL set"
 			_ -> return ()
 	whenM (liftIO $ R.doesPathExist $ parentDir obj) $
 		freezeContentDir obj
@@ -331,7 +331,7 @@
 	 - config was set. -}
 	whenM (pure present <&&> (not <$> Backend.isCryptographicallySecure key)) $
 		whenM (annexSecureHashesOnly <$> Annex.getGitConfig) $
-			warning $ "** Despite annex.securehashesonly being set, " ++ fromRawFilePath obj ++ " has content present in the annex using an insecure " ++ decodeBS (formatKeyVariety (fromKey keyVariety key)) ++ " key"
+			warning $ "** Despite annex.securehashesonly being set, " <> QuotedPath obj <> " has content present in the annex using an insecure " <> UnquotedString (decodeBS (formatKeyVariety (fromKey keyVariety key))) <> " key"
 
 	verifyLocationLog' key ai present u (logChange key u)
 
@@ -351,9 +351,9 @@
 		(False, True) -> do
 			fix InfoMissing
 			warning $
-				"** Based on the location log, " ++
-				decodeBS (actionItemDesc ai) ++
-				"\n** was expected to be present, " ++
+				"** Based on the location log, " <>
+				actionItemDesc ai <>
+				"\n** was expected to be present, " <>
 				"but its content is missing."
 			return False
 		(False, False) -> do
@@ -391,10 +391,10 @@
 			else do
 				missingrequired <- Remote.prettyPrintUUIDs "missingrequired" missinglocs
 				warning $
-					"** Required content " ++
-					decodeBS (actionItemDesc ai) ++
-					" is missing from these repositories:\n" ++
-					missingrequired
+					"** Required content " <>
+					actionItemDesc ai <>
+					" is missing from these repositories:\n" <>
+					UnquotedString missingrequired
 				return False
 verifyRequiredContent _ _ = return True
 
@@ -465,13 +465,11 @@
 		return same
 	badsize a b = do
 		msg <- bad key
-		warning $ concat
-			[ decodeBS (actionItemDesc ai)
-			, ": Bad file size ("
-			, compareSizes storageUnits True a b
-			, "); "
-			, msg
-			]
+		warning $ actionItemDesc ai
+			<> ": Bad file size ("
+			<> UnquotedString (compareSizes storageUnits True a b)
+			<> "); "
+			<> UnquotedString msg
 
 {- Check for keys that are upgradable.
  -
@@ -483,13 +481,12 @@
 checkKeyUpgrade backend key ai (AssociatedFile (Just file)) =
 	case Types.Backend.canUpgradeKey backend of
 		Just a | a key -> do
-			warning $ concat
-				[ decodeBS (actionItemDesc ai)
-				, ": Can be upgraded to an improved key format. "
-				, "You can do so by running: git annex migrate --backend="
-				, decodeBS (formatKeyVariety (fromKey keyVariety key)) ++ " "
-				, decodeBS file
-				]
+			warning $ actionItemDesc ai
+				<> ": Can be upgraded to an improved key format. "
+				<> "You can do so by running: git annex migrate --backend="
+				<> UnquotedByteString (formatKeyVariety (fromKey keyVariety key))
+				<> " "
+				<> QuotedPath file
 			return True
 		_ -> return True
 checkKeyUpgrade _ _ _ (AssociatedFile Nothing) =
@@ -534,11 +531,9 @@
 			ok <- verifier key file
 			unless ok $ do
 				msg <- bad key
-				warning $ concat
-					[ decodeBS (actionItemDesc ai)
-					, ": Bad file content; "
-					, msg
-					]
+				warning $ actionItemDesc ai
+					<> ": Bad file content; "
+					<> UnquotedString msg
 			return ok
 		Nothing -> return True
 
@@ -562,17 +557,15 @@
 				withTSDelta (liftIO . genInodeCache content) >>= \case
 					Nothing -> noop
 					Just ic' -> whenM (compareInodeCaches ic ic') $ do
-						warning $ concat
-							[ decodeBS (actionItemDesc ai)
-							, ": Stale or missing inode cache; updating."
-							]
+						warning $ actionItemDesc ai
+							<> ": Stale or missing inode cache; updating."
 						Database.Keys.addInodeCaches key [ic]
 
 checkKeyNumCopies :: Key -> AssociatedFile -> NumCopies -> Annex Bool
 checkKeyNumCopies key afile numcopies = do
 	let (desc, hasafile) = case afile of
-		AssociatedFile Nothing -> (serializeKey key, False)
-		AssociatedFile (Just af) -> (fromRawFilePath af, True)
+		AssociatedFile Nothing -> (serializeKey' key, False)
+		AssociatedFile (Just af) -> (af, True)
 	locs <- loggedLocations key
 	(untrustedlocations, otherlocations) <- trustPartition UnTrusted locs
 	(deadlocations, safelocations) <- trustPartition DeadTrusted otherlocations
@@ -592,21 +585,21 @@
 			)
 		else return True
 
-missingNote :: String -> Int -> NumCopies -> String -> String -> String
+missingNote :: RawFilePath -> Int -> NumCopies -> String -> String -> StringContainingQuotedPath
 missingNote file 0 _ [] dead = 
-		"** No known copies exist of " ++ file ++ honorDead dead
+		"** No known copies exist of " <> QuotedPath file <> UnquotedString (honorDead dead)
 missingNote file 0 _ untrusted dead =
-		"Only these untrusted locations may have copies of " ++ file ++
-		"\n" ++ untrusted ++
-		"Back it up to trusted locations with git-annex copy." ++ honorDead dead
+		"Only these untrusted locations may have copies of " <> QuotedPath file <>
+		"\n" <> UnquotedString untrusted <>
+		"Back it up to trusted locations with git-annex copy." <> UnquotedString (honorDead dead)
 missingNote file present needed [] _ =
-		"Only " ++ show present ++ " of " ++ show (fromNumCopies needed) ++ 
-		" trustworthy copies exist of " ++ file ++
+		"Only " <> UnquotedString (show present) <> " of " <> UnquotedString (show (fromNumCopies needed)) <>
+		" trustworthy copies exist of " <> QuotedPath file <>
 		"\nBack it up with git-annex copy."
 missingNote file present needed untrusted dead = 
-		missingNote file present needed [] dead ++
-		"\nThe following untrusted locations may also have copies: " ++
-		"\n" ++ untrusted
+		missingNote file present needed [] dead <>
+		"\nThe following untrusted locations may also have copies: " <>
+		"\n" <> UnquotedString untrusted
 	
 honorDead :: String -> String
 honorDead dead
diff --git a/Command/FuzzTest.hs b/Command/FuzzTest.hs
--- a/Command/FuzzTest.hs
+++ b/Command/FuzzTest.hs
@@ -37,7 +37,7 @@
 start = do
 	guardTest
 	logf <- fromRepo gitAnnexFuzzTestLogFile
-	showStart "fuzztest" (toRawFilePath logf) (SeekInput [])
+	showStartMessage (StartMessage "fuzztest" (ActionItemOther (Just (UnquotedString logf))) (SeekInput []))
 	logh <- liftIO $ openFile logf WriteMode
 	void $ forever $ fuzz logh
 	stop
diff --git a/Command/Get.hs b/Command/Get.hs
--- a/Command/Get.hs
+++ b/Command/Get.hs
@@ -53,7 +53,7 @@
 		Batch fmt -> batchOnly (keyOptions o) (getFiles o) $
 			batchAnnexed fmt seeker (startKeys from)
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "get"
 
 start :: GetOptions -> Maybe Remote -> SeekInput -> RawFilePath -> Key -> CommandStart
 start o from si file key = start' expensivecheck from key afile ai si
@@ -93,7 +93,7 @@
 getKey' key afile = dispatch
   where
 	dispatch [] = do
-		showNote "not available"
+		showNote (UnquotedString "not available")
 		showlocs []
 		return False
 	dispatch remotes = notifyTransfer Download afile $ \witness -> do
@@ -116,6 +116,6 @@
 			either (const False) id <$> Remote.hasKey r key
 		| otherwise = return True
 	docopy r witness = do
-		showAction $ "from " ++ Remote.name r
+		showAction $ UnquotedString $ "from " ++ Remote.name r
 		logStatusAfter key $
 			download r key afile stdRetry witness
diff --git a/Command/Group.hs b/Command/Group.hs
--- a/Command/Group.hs
+++ b/Command/Group.hs
@@ -11,6 +11,7 @@
 import qualified Remote
 import Logs.Group
 import Types.Group
+import Utility.SafeOutput
 
 import qualified Data.Set as S
 
@@ -27,12 +28,12 @@
 	startingUsualMessages "group" ai si $
 		setGroup u (toGroup g)
   where
-	ai = ActionItemOther (Just name)
+	ai = ActionItemOther (Just (UnquotedString name))
 	si = SeekInput ps
 start (name:[]) = do
 	u <- Remote.nameToUUID name
 	startingCustomOutput (ActionItemOther Nothing) $ do
-		liftIO . putStrLn . unwords . map fmt . S.toList
+		liftIO . putStrLn . safeOutput . unwords . map fmt . S.toList
 			=<< lookupGroups u
 		next $ return True
   where
diff --git a/Command/GroupWanted.hs b/Command/GroupWanted.hs
--- a/Command/GroupWanted.hs
+++ b/Command/GroupWanted.hs
@@ -27,6 +27,6 @@
 start ps@(g:expr:[]) = startingUsualMessages "groupwanted" ai si $
 	performSet groupPreferredContentSet expr (toGroup g)
   where
-	ai = ActionItemOther (Just g)
+	ai = ActionItemOther (Just (UnquotedString g))
 	si = SeekInput ps
 start _ = giveup "Specify a group."
diff --git a/Command/Import.hs b/Command/Import.hs
--- a/Command/Import.hs
+++ b/Command/Import.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE ApplicativeDo, OverloadedStrings #-}
 
 module Command.Import where
 
@@ -125,7 +125,10 @@
 	inrepops <- liftIO $ filter (dirContains repopath)
 		<$> mapM (absPath . toRawFilePath) (importFiles o)
 	unless (null inrepops) $ do
-		giveup $ "cannot import files from inside the working tree (use git annex add instead): " ++ unwords (map fromRawFilePath inrepops)
+		qp <- coreQuotePath <$> Annex.getGitConfig
+		giveup $ decodeBS $ quote qp $ 
+			"cannot import files from inside the working tree (use git annex add instead): "
+				<> quotedPaths inrepops
 	largematcher <- largeFilesMatcher
 	addunlockedmatcher <- addUnlockedMatcher
 	(commandAction . startLocal o addunlockedmatcher largematcher (duplicateMode o))
@@ -151,7 +154,7 @@
 	si = SeekInput []
 
 	deletedup k = do
-		showNote $ "duplicate of " ++ serializeKey k
+		showNote $ UnquotedString $ "duplicate of " ++ serializeKey k
 		verifyExisting k destfile
 			( do
 				liftIO $ R.removeLink srcfile
@@ -167,7 +170,7 @@
 		ignored <- checkIgnored (checkGitIgnoreOption o) destfile
 		if ignored
 			then do
-				warning $ "not importing " ++ fromRawFilePath destfile ++ " which is .gitignored (use --no-check-gitignore to override)"
+				warning $ "not importing " <> QuotedPath destfile <> " which is .gitignored (use --no-check-gitignore to override)"
 				stop
 			else do
 				existing <- liftIO (catchMaybeIO $ R.getSymbolicLinkStatus destfile)
@@ -195,7 +198,7 @@
 			Just s
 				| isDirectory s -> cont
 				| otherwise -> do
-					warning $ "not importing " ++ fromRawFilePath destfile ++ " because " ++ fromRawFilePath destdir ++ " is not a directory"
+					warning $ "not importing " <> QuotedPath destfile <> " because " <> QuotedPath destdir <> " is not a directory"
 					stop
 
 	importfilechecked ld k = do
@@ -221,7 +224,8 @@
 			checkLockedDownWritePerms destfile srcfile >>= \case
 				Just err -> do
 					liftIO unwind
-					giveup err
+					qp <- coreQuotePath <$> Annex.getGitConfig
+					giveup (decodeBS $ quote qp err)
 				Nothing -> noop
 		-- Get the inode cache of the dest file. It should be
 		-- weakly the same as the originally locked down file's
@@ -252,7 +256,7 @@
 			, Command.Add.addSmall (DryRun False) destfile s
 			)
 	notoverwriting why = do
-		warning $ "not overwriting existing " ++ fromRawFilePath destfile ++ " " ++ why
+		warning $ "not overwriting existing " <> QuotedPath destfile <> " " <> UnquotedString why
 		stop
 	lockdown a = do
 		let mi = MatchingFile $ FileInfo
@@ -296,7 +300,9 @@
 			(reinject k)
 			(importfile ld k)
 		_ -> importfile ld k
-	skipbecause s = showNote (s ++ "; skipping") >> next (return True)
+	skipbecause s = do
+		showNote (s <> "; skipping")
+		next (return True)
 
 verifyExisting :: Key -> RawFilePath -> (CommandPerform, CommandPerform) -> CommandPerform
 verifyExisting key destfile (yes, no) = do
@@ -329,13 +335,13 @@
 	void $ includeCommandAction (listContents remote importtreeconfig ci importabletvar)
 	liftIO (atomically (readTVar importabletvar)) >>= \case
 		Nothing -> return ()
-		Just importable -> importKeys remote importtreeconfig importcontent False importable >>= \case
-			Nothing -> warning $ concat
+		Just importable -> importChanges remote importtreeconfig importcontent False importable >>= \case
+			ImportUnfinished -> warning $ UnquotedString $ concat
 				[ "Failed to import some files from "
 				, Remote.name remote
 				, ". Re-run command to resume import."
 				]
-			Just imported -> void $
+			ImportFinished imported -> void $
 				includeCommandAction $ 
 					commitimport imported
   where
@@ -351,7 +357,7 @@
 		liftIO $ atomically $ writeTVar tvar importable
 		next $ return True
   where
-	ai = ActionItemOther (Just (Remote.name remote))
+	ai = ActionItemOther (Just (UnquotedString (Remote.name remote)))
 	si = SeekInput []
 
 listContents' :: Remote -> ImportTreeConfig -> CheckGitIgnore -> (Maybe (ImportableContentsChunkable Annex (ContentIdentifier, Remote.ByteSize)) -> Annex a) -> Annex a
@@ -367,13 +373,13 @@
 			, err
 			]
 
-commitRemote :: Remote -> Branch -> RemoteTrackingBranch -> Maybe Sha -> ImportTreeConfig -> ImportCommitConfig -> ImportableContentsChunkable Annex (Either Sha Key) -> CommandStart
-commitRemote remote branch tb trackingcommit importtreeconfig importcommitconfig importable =
+commitRemote :: Remote -> Branch -> RemoteTrackingBranch -> Maybe Sha -> ImportTreeConfig -> ImportCommitConfig -> Imported -> CommandStart
+commitRemote remote branch tb trackingcommit importtreeconfig importcommitconfig imported =
 	starting "update" ai si $ do
-		importcommit <- buildImportCommit remote importtreeconfig importcommitconfig importable
+		importcommit <- buildImportCommit remote importtreeconfig importcommitconfig imported
 		next $ updateremotetrackingbranch importcommit
   where
-	ai = ActionItemOther (Just $ fromRef $ fromRemoteTrackingBranch tb)
+	ai = ActionItemOther (Just $ UnquotedString $ fromRef $ fromRemoteTrackingBranch tb)
 	si = SeekInput []
 	-- Update the tracking branch. Done even when there
 	-- is nothing new to import, to make sure it exists.
@@ -383,5 +389,5 @@
 				setRemoteTrackingBranch tb c
 				return True
 			Nothing -> do
-				warning $ "Nothing to import and " ++ fromRef branch ++ " does not exist."
+				warning $ UnquotedString $ "Nothing to import and " ++ fromRef branch ++ " does not exist."
 				return False
diff --git a/Command/ImportFeed.hs b/Command/ImportFeed.hs
--- a/Command/ImportFeed.hs
+++ b/Command/ImportFeed.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2013-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2013-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -16,11 +16,11 @@
 import Text.Feed.Types
 import qualified Data.Set as S
 import qualified Data.Map as M
-import Data.Char
 import Data.Time.Clock
 import Data.Time.Format
 import Data.Time.Calendar
 import Data.Time.LocalTime
+import Control.Concurrent.STM
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as TE
 import qualified System.FilePath.ByteString as P
@@ -37,7 +37,7 @@
 import qualified Utility.Format
 import Utility.Tmp
 import Utility.Metered
-import Command.AddUrl (addUrlFile, downloadRemoteFile, parseDownloadOptions, DownloadOptions(..), checkCanAdd)
+import Command.AddUrl (addUrlFile, downloadRemoteFile, parseDownloadOptions, DownloadOptions(..), checkCanAdd, addWorkTree, checkRaw)
 import Annex.UUID
 import Backend.URL (fromUrl)
 import Annex.Content
@@ -47,16 +47,17 @@
 import Logs.MetaData
 import Annex.MetaData
 import Annex.FileMatcher
-import Command.AddUrl (addWorkTree, checkRaw)
 import Annex.UntrustedFilePath
 import qualified Annex.Branch
 import Logs
 import qualified Utility.RawFilePath as R
 
 cmd :: Command
-cmd = notBareRepo $ withAnnexOptions [backendOption] $
+cmd = notBareRepo $ withAnnexOptions os $
 	command "importfeed" SectionCommon "import files from podcast feeds"
 		(paramRepeating paramUrl) (seek <$$> optParser)
+  where
+	os = [jobsOption, jsonOptions, jsonProgressOption, backendOption]
 
 data ImportFeedOptions = ImportFeedOptions
 	{ feedUrls :: CmdParams
@@ -74,42 +75,103 @@
 	<*> parseDownloadOptions False
 
 seek :: ImportFeedOptions -> CommandSeek
-seek o = do
+seek o = startConcurrency commandStages $ do
 	addunlockedmatcher <- addUnlockedMatcher
 	cache <- getCache (templateOption o)
-	forM_ (feedUrls o) (getFeed addunlockedmatcher o cache)
+	dlst <- liftIO $ newTMVarIO M.empty
+	checkst <- liftIO $ newTVarIO M.empty
 
-getFeed :: AddUnlockedMatcher -> ImportFeedOptions -> Cache -> URLString -> CommandSeek
-getFeed addunlockedmatcher opts cache url = do
-	showStartOther "importfeed" (Just url) (SeekInput [])
-	withTmpFile "feed" $ \tmpf h -> do
+	forM_ (feedUrls o) $ \url -> do
+		liftIO $ atomically $ do
+			m <- takeTMVar dlst
+			putTMVar dlst (M.insert url Nothing m)
+		commandAction $ getFeed url dlst
+		startpendingdownloads addunlockedmatcher cache dlst checkst False
+	
+	startpendingdownloads addunlockedmatcher cache dlst checkst True
+
+	clearfeedproblems checkst
+  where
+  	getpendingdownloads dlst blocking
+		| blocking = do
+			m <- takeTMVar dlst
+			if M.null m
+				then do
+					putTMVar dlst m
+					return m
+				else
+					let (pending, rest) = M.partition ispending m
+					in if M.null pending
+						then retry
+						else do
+							putTMVar dlst rest
+							return pending
+		| otherwise = do
+			m <- takeTMVar dlst
+			let (pending, rest) = M.partition ispending m
+			putTMVar dlst rest
+			return pending
+	  where
+		ispending Nothing = False
+		ispending (Just _) = True
+
+	startpendingdownloads addunlockedmatcher cache dlst checkst blocking = do
+		m <- liftIO $ atomically $ getpendingdownloads dlst blocking
+		
+		forM_ (M.toList m) $ \(url, v) -> case v of
+			Nothing -> noop
+			Just Nothing -> noop
+			Just (Just is) -> 
+				forM_ is $ \i -> do
+					cv <- liftIO newEmptyTMVarIO
+					liftIO $ atomically $ modifyTVar checkst $
+						M.insertWith (++) url [cv]
+					commandAction $
+						startDownload addunlockedmatcher o cache cv i
+	
+	clearfeedproblems checkst = do
+		m <- liftIO $ atomically $ readTVar checkst
+		forM_ (M.toList m) $ \(url, cvl) ->
+			whenM (and <$> mapM (liftIO . atomically . takeTMVar) cvl) $
+				clearFeedProblem url
+
+getFeed
+	:: URLString
+	-> TMVar (M.Map URLString (Maybe (Maybe [ToDownload])))
+	-> CommandStart
+getFeed url st =
+	starting "importfeed" (ActionItemOther (Just (UnquotedString url))) (SeekInput [url]) $
+		get `onException` recordfail
+  where
+	record v = liftIO $ atomically $ do
+		m <- takeTMVar st
+		putTMVar st (M.insert url v m)
+	recordfail = record (Just Nothing)
+	
+	get = withTmpFile "feed" $ \tmpf h -> do
 		liftIO $ hClose h
 		ifM (downloadFeed url tmpf)
-			( go tmpf
-			, showEndResult =<< feedProblem url
-				"downloading the feed failed"
+			( parse tmpf
+			, do
+				recordfail
+				next $ feedProblem url
+					"downloading the feed failed"
 			)
-  where
+
 	-- Use parseFeedFromFile rather than reading the file
 	-- ourselves because it goes out of its way to handle encodings.
-	go tmpf = liftIO (parseFeedFromFile' tmpf) >>= \case
+	parse tmpf = liftIO (parseFeedFromFile' tmpf) >>= \case
 		Nothing -> debugfeedcontent tmpf "parsing the feed failed"
 		Just f -> do
-			case map sanitizetitle $ decodeBS $ fromFeedText $ getFeedTitle f of
+			case decodeBS $ fromFeedText $ getFeedTitle f of
 				"" -> noop
-				t -> showNote ('"' : t ++ "\"")
+				t -> showNote (UnquotedString ('"' : t ++ "\""))
 			case findDownloads url f of
 				[] -> debugfeedcontent tmpf "bad feed content; no enclosures to download"
 				l -> do
-					showEndOk
-					ifM (and <$> mapM (performDownload addunlockedmatcher opts cache) l)
-						( clearFeedProblem url
-						, void $ feedProblem url 
-							"problem downloading some item(s) from feed"
-						)
-	sanitizetitle c
-		| isControl c = '_'
-		| otherwise = c
+					record (Just (Just l))
+					next $ return True
+	
 	debugfeedcontent tmpf msg = do
 		feedcontent <- liftIO $ readFile tmpf
 		fastDebug "Command.ImportFeed" $ unlines
@@ -117,7 +179,8 @@
 			, feedcontent
 			, "end of feed content"
 			]
-		showEndResult =<< feedProblem url
+		recordfail
+		next $ feedProblem url
 			(msg ++ " (use --debug --debugfilter=ImportFeed to see the feed content that was downloaded)")
 
 parseFeedFromFile' :: FilePath -> IO (Maybe Feed)
@@ -148,9 +211,12 @@
 getCache opttemplate = ifM (Annex.getRead Annex.force)
 	( ret S.empty S.empty
 	, do
-		showStart "importfeed" "gathering known urls" (SeekInput [])
+		j <- jsonOutputEnabled
+		unless j $
+			showStartMessage (StartMessage "importfeed" (ActionItemOther (Just "gathering known urls")) (SeekInput []))
 		(us, is) <- knownItems
-		showEndOk
+		unless j
+			showEndOk
 		ret (S.fromList us) (S.fromList is)
 	)
   where
@@ -203,51 +269,9 @@
 	| otherwise = Url.withUrlOptions $
 		Url.download nullMeterUpdate Nothing url f
 
-performDownload :: AddUnlockedMatcher -> ImportFeedOptions -> Cache -> ToDownload -> Annex Bool
-performDownload = performDownload' False
-
-performDownload' :: Bool -> AddUnlockedMatcher -> ImportFeedOptions -> Cache -> ToDownload -> Annex Bool
-performDownload' started addunlockedmatcher opts cache todownload = case location todownload of
-	Enclosure url -> checkknown url $ do
-		starturl url
-		rundownload url (takeWhile (/= '?') $ takeExtension url) $ \f -> do
-			let f' = fromRawFilePath f
-			r <- Remote.claimingUrl url
-			if Remote.uuid r == webUUID || rawOption (downloadOptions opts)
-				then checkRaw (Just url) (downloadOptions opts) Nothing $ do
-					let dlopts = (downloadOptions opts)
-						-- force using the filename
-						-- chosen here
-						{ fileOption = Just f'
-						-- don't use youtube-dl
-						, rawOption = True
-						}
-					let go urlinfo = Just . maybeToList <$> addUrlFile addunlockedmatcher dlopts url urlinfo f
-					if relaxedOption (downloadOptions opts)
-						then go Url.assumeUrlExists
-						else Url.withUrlOptions (Url.getUrlInfo url) >>= \case
-							Right urlinfo -> go urlinfo
-							Left err -> do
-								warning err
-								return (Just [])
-				else do
-					res <- tryNonAsync $ maybe
-						(error $ "unable to checkUrl of " ++ Remote.name r)
-						(flip id url)
-						(Remote.checkUrl r)
-					case res of
-						Left _ -> return (Just [])
-						Right (UrlContents sz _) ->
-							Just . maybeToList <$>
-								downloadRemoteFile addunlockedmatcher r (downloadOptions opts) url f sz
-						Right (UrlMulti l) -> do
-							kl <- forM l $ \(url', sz, subf) ->
-								let dest = f P.</> toRawFilePath (sanitizeFilePath subf)
-								in downloadRemoteFile addunlockedmatcher r (downloadOptions opts) url' dest sz
-							return $ Just $ if all isJust kl
-								then catMaybes kl
-								else []
-							
+startDownload :: AddUnlockedMatcher -> ImportFeedOptions -> Cache -> TMVar Bool -> ToDownload -> CommandStart
+startDownload addunlockedmatcher opts cache cv todownload = case location todownload of
+	Enclosure url -> startdownloadenclosure url
 	MediaLink linkurl -> do
 		let mediaurl = setDownloader linkurl YoutubeDownloader
 		let mediakey = Backend.URL.fromUrl mediaurl Nothing
@@ -267,114 +291,186 @@
 	 - associated with a file in the annex, unless forced. -}
 	checkknown url a
 		| knownitemid || S.member url (knownurls cache)
-			= ifM forced (a, return True)
+			= ifM forced (a, nothingtodo)
 		| otherwise = a
 
+	nothingtodo = recordsuccess >> stop
+
+	recordsuccess = liftIO $ atomically $ putTMVar cv True
+	
+	startdownloadenclosure :: URLString -> CommandStart
+	startdownloadenclosure url = checkknown url $ startUrlDownload cv todownload url $
+		downloadEnclosure addunlockedmatcher opts cache cv todownload url 
+
 	knownitemid = case getItemId (item todownload) of
 		Just (_, itemid) ->
 			S.member (decodeBS $ fromFeedText itemid) (knownitems cache)
 		_ -> False
 
-	rundownload url extension getter = do
-		dest <- makeunique url (1 :: Integer) $
-			feedFile (template cache) todownload extension
-		case dest of
-			Nothing -> return True
-			Just f -> getter (toRawFilePath f) >>= \case
-				Just ks
-					-- Download problem.
-					| null ks -> do
-						showEndFail
-						checkFeedBroken (feedurl todownload)
-					| otherwise -> do
-						forM_ ks $ \key ->
-							ifM (annexGenMetaData <$> Annex.getGitConfig)
-								( addMetaData key $ extractMetaData todownload
-								, addMetaData key $ minimalMetaData todownload
-								)
-						showEndOk
-						return True
-				-- Was not able to add anything, but not
-				-- because of a download problem.
-				Nothing -> do
-					showEndFail
-					return False
-
-	{- Find a unique filename to save the url to.
-	 - If the file exists, prefixes it with a number.
-	 - When forced, the file may already exist and have the same
-	 - url, in which case Nothing is returned as it does not need
-	 - to be re-downloaded. -}
-	makeunique url n file = ifM alreadyexists
-		( ifM forced
-			( lookupKey (toRawFilePath f) >>= \case
-				Just k -> checksameurl k
-				Nothing -> tryanother
-			, tryanother
-			)
-		, return $ Just f
-		)
-	  where
-		f = if n < 2
-			then file
-			else
-				let (d, base) = splitFileName file
-				in d </> show n ++ "_" ++ base
-		tryanother = makeunique url (n + 1) file
-		alreadyexists = liftIO $ isJust <$> catchMaybeIO (R.getSymbolicLinkStatus (toRawFilePath f))
-		checksameurl k = ifM (elem url <$> getUrls k)
-			( return Nothing
-			, tryanother
-			)
-	
 	downloadmedia linkurl mediaurl mediakey
-		| rawOption (downloadOptions opts) = downloadlink False
+		| rawOption (downloadOptions opts) = startdownloadlink
 		| otherwise = ifM (youtubeDlSupported linkurl)
-			( do
-				starturl linkurl
-				r <- withTmpWorkDir mediakey $ \workdir -> do
+			( startUrlDownload cv todownload linkurl $
+				withTmpWorkDir mediakey $ \workdir -> do
 					dl <- youtubeDl linkurl (fromRawFilePath workdir) nullMeterUpdate
 					case dl of
 						Right (Just mediafile) -> do
 							let ext = case takeExtension mediafile of
 								[] -> ".m"
 								s -> s
-							ok <- rundownload linkurl ext $ \f ->
+							runDownload todownload linkurl ext cache cv $ \f ->
 								checkCanAdd (downloadOptions opts) f $ \canadd -> do
 									addWorkTree canadd addunlockedmatcher webUUID mediaurl f mediakey (Just (toRawFilePath mediafile))
 									return (Just [mediakey])
-							return (Just ok)
 						-- youtube-dl didn't support it, so
 						-- download it as if the link were
 						-- an enclosure.
-						Right Nothing -> Just <$> downloadlink True
+						Right Nothing -> contdownloadlink
 						Left msg -> do
-							warning $ linkurl ++ ": " ++ msg
-							return Nothing
-				return (fromMaybe False r)
-			, downloadlink False
+							warning $ UnquotedString $ linkurl ++ ": " ++ msg
+							liftIO $ atomically $ putTMVar cv False
+							next $ return False
+			, startdownloadlink
 			)
 	  where
-		downloadlink started' = checkRaw (Just linkurl) (downloadOptions opts) False $
-			performDownload' started' addunlockedmatcher opts cache todownload
-				{ location = Enclosure linkurl }
+		startdownloadlink = checkRaw (Just linkurl) (downloadOptions opts) nothingtodo $
+			startdownloadenclosure linkurl
+		contdownloadlink = downloadEnclosure addunlockedmatcher opts cache cv todownload linkurl
 
 	addmediafast linkurl mediaurl mediakey =
 		ifM (pure (not (rawOption (downloadOptions opts)))
 		     <&&> youtubeDlSupported linkurl)
-			( do
-				starturl linkurl
-				rundownload linkurl ".m" $ \f ->
+			( startUrlDownload cv todownload linkurl $ do
+				runDownload todownload linkurl ".m" cache cv $ \f ->
 					checkCanAdd (downloadOptions opts) f $ \canadd -> do
 						addWorkTree canadd addunlockedmatcher webUUID mediaurl f mediakey Nothing
 						return (Just [mediakey])
-			, performDownload' started addunlockedmatcher opts cache todownload
-				{ location = Enclosure linkurl }
+			, startdownloadenclosure linkurl
 			)
 
-	starturl u = unless started $
-		showStartOther "addurl" (Just u) (SeekInput [])
+downloadEnclosure :: AddUnlockedMatcher -> ImportFeedOptions -> Cache -> TMVar Bool -> ToDownload -> URLString -> CommandPerform
+downloadEnclosure addunlockedmatcher opts cache cv todownload url = 
+	runDownload todownload url (takeWhile (/= '?') $ takeExtension url) cache cv $ \f -> do
+		let f' = fromRawFilePath f
+		r <- Remote.claimingUrl url
+		if Remote.uuid r == webUUID || rawOption (downloadOptions opts)
+			then checkRaw (Just url) (downloadOptions opts) (pure Nothing) $ do
+				let dlopts = (downloadOptions opts)
+					-- force using the filename
+					-- chosen here
+					{ fileOption = Just f'
+					-- don't use youtube-dl
+					, rawOption = True
+					}
+				let go urlinfo = Just . maybeToList <$> addUrlFile addunlockedmatcher dlopts url urlinfo f
+				if relaxedOption (downloadOptions opts)
+					then go Url.assumeUrlExists
+					else Url.withUrlOptions (Url.getUrlInfo url) >>= \case
+						Right urlinfo -> go urlinfo
+						Left err -> do
+							warning (UnquotedString err)
+							return (Just [])
+			else do
+				res <- tryNonAsync $ maybe
+					(giveup $ "unable to checkUrl of " ++ Remote.name r)
+					(flip id url)
+					(Remote.checkUrl r)
+				case res of
+					Left _ -> return (Just [])
+					Right (UrlContents sz _) ->
+						Just . maybeToList <$>
+							downloadRemoteFile addunlockedmatcher r (downloadOptions opts) url f sz
+					Right (UrlMulti l) -> do
+						kl <- forM l $ \(url', sz, subf) ->
+							let dest = f P.</> toRawFilePath (sanitizeFilePath subf)
+							in downloadRemoteFile addunlockedmatcher r (downloadOptions opts) url' dest sz
+						return $ Just $ if all isJust kl
+							then catMaybes kl
+							else []
 
+runDownload
+	:: ToDownload
+	-> URLString
+	-> String
+	-> Cache
+	-> TMVar Bool
+	-> (RawFilePath -> Annex (Maybe [Key]))
+	-> CommandPerform
+runDownload todownload url extension cache cv getter = do
+	dest <- makeunique (1 :: Integer) $
+		feedFile (template cache) todownload extension
+	case dest of
+		Nothing -> do
+			recordsuccess
+			stop
+		Just f -> getter (toRawFilePath f) >>= \case
+			Just ks
+				-- Download problem.
+				| null ks -> do
+					broken <- checkFeedBroken (feedurl todownload)
+					when broken $
+						void $ feedProblem url "download failed"
+					liftIO $ atomically $ putTMVar cv broken
+					next $ return False
+				| otherwise -> do
+					forM_ ks $ \key ->
+						ifM (annexGenMetaData <$> Annex.getGitConfig)
+							( addMetaData key $ extractMetaData todownload
+							, addMetaData key $ minimalMetaData todownload
+							)
+					recordsuccess
+					next $  return True
+			-- Was not able to add anything, but not
+			-- because of a download problem.
+			Nothing -> do
+				recordsuccess
+				next $ return False
+  where
+	recordsuccess = liftIO $ atomically $ putTMVar cv True
+
+	forced = Annex.getRead Annex.force
+
+	{- Find a unique filename to save the url to.
+	 - If the file exists, prefixes it with a number.
+	 - When forced, the file may already exist and have the same
+	 - url, in which case Nothing is returned as it does not need
+	 - to be re-downloaded. -}
+	makeunique n file = ifM alreadyexists
+		( ifM forced
+			( lookupKey (toRawFilePath f) >>= \case
+				Just k -> checksameurl k
+				Nothing -> tryanother
+			, tryanother
+			)
+		, return $ Just f
+		)
+	  where
+		f = if n < 2
+			then file
+			else
+				let (d, base) = splitFileName file
+				in d </> show n ++ "_" ++ base
+		tryanother = makeunique (n + 1) file
+		alreadyexists = liftIO $ isJust <$> catchMaybeIO (R.getSymbolicLinkStatus (toRawFilePath f))
+		checksameurl k = ifM (elem url <$> getUrls k)
+			( return Nothing
+			, tryanother
+			)
+
+startUrlDownload :: TMVar Bool -> ToDownload -> URLString -> CommandPerform -> CommandStart
+startUrlDownload cv todownload url a = do
+	starting "addurl"
+		(ActionItemOther (Just (UnquotedString url)))
+		(SeekInput [feedurl todownload])
+		(go `onException` recordfailure)
+  where
+	recordfailure = do
+		void $ feedProblem url "download failed"
+		liftIO $ atomically $ tryPutTMVar cv False
+	go = do
+		maybeAddJSONField "url" url
+		a
+
 defaultTemplate :: String
 defaultTemplate = "${feedtitle}/${itemtitle}${extension}"
 
@@ -477,10 +573,10 @@
 feedProblem :: URLString -> String -> Annex Bool
 feedProblem url message = ifM (checkFeedBroken url)
 	( do
-		warning $ message ++ " (having repeated problems with feed: " ++ url ++ ")"
+		warning $ UnquotedString $ message ++ " (having repeated problems with feed: " ++ url ++ ")"
 		return False
 	, do
-		warning $ "warning: " ++ message
+		warning $ UnquotedString $ "warning: " ++ message ++ " (feed: " ++ url ++ ")"
 		return True
 	)
 
diff --git a/Command/Info.hs b/Command/Info.hs
--- a/Command/Info.hs
+++ b/Command/Info.hs
@@ -43,7 +43,6 @@
 import Types.Key
 import Types.TrustLevel
 import Types.FileMatcher
-import Types.ActionItem
 import qualified Limit
 import Messages.JSON (DualDisp(..), ObjectMap(..))
 import Annex.BloomFilter
@@ -183,8 +182,12 @@
 
 noInfo :: String -> SeekInput -> String -> Annex ()
 noInfo s si msg = do
-	showStart "info" (encodeBS s) si
-	showNote msg
+	-- The string may not really be a file, but use ActionItemTreeFile,
+	-- rather than ActionItemOther to avoid breaking back-compat of
+	-- json output.
+	let ai = ActionItemTreeFile (toRawFilePath s)
+	showStartMessage (StartMessage "info" ai si)
+	showNote (UnquotedString msg)
 	showEndFail
 	Annex.incError
 
@@ -455,15 +458,16 @@
 	uuidmap <- Remote.remoteMap id
 	ts <- getTransfers
 	maybeShowJSON $ JSONChunk [(desc, V.fromList $ map (uncurry jsonify) ts)]
+	qp <- coreQuotePath <$> Annex.getGitConfig
 	return $ if null ts
 		then "none"
 		else multiLine $
-			map (uncurry $ line uuidmap) $ sort ts
+			map (uncurry $ line qp uuidmap) $ sort ts
   where
 	desc = "transfers in progress"
-	line uuidmap t i = unwords
+	line qp uuidmap t i = unwords
 		[ fromRawFilePath (formatDirection (transferDirection t)) ++ "ing"
-		, fromRawFilePath $ actionItemDesc $ mkActionItem
+		, fromRawFilePath $ quote qp $ actionItemDesc $ mkActionItem
 			(transferKey t, associatedFile i)
 		, if transferDirection t == Upload then "to" else "from"
 		, maybe (fromUUID $ transferUUID t) Remote.name $
diff --git a/Command/Init.hs b/Command/Init.hs
--- a/Command/Init.hs
+++ b/Command/Init.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.Init where
 
 import Command
@@ -17,7 +19,7 @@
 import qualified Data.Map as M
 	
 cmd :: Command
-cmd = dontCheck repoExists $
+cmd = dontCheck repoExists $ withAnnexOptions [jsonOptions] $
 	command "init" SectionSetup "initialize git-annex"
 		paramDesc (seek <$$> optParser)
 
@@ -62,7 +64,7 @@
 		starting "init" (ActionItemOther (Just "autoenable")) si $
 			performAutoEnableOnly
 	| otherwise = 
-		starting "init" (ActionItemOther (Just $ initDesc os)) si $
+		starting "init" (ActionItemOther (Just $ UnquotedString $ initDesc os)) si $
 			perform os
   where
 	si = SeekInput []
diff --git a/Command/InitRemote.hs b/Command/InitRemote.hs
--- a/Command/InitRemote.hs
+++ b/Command/InitRemote.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2011-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -9,8 +9,6 @@
 
 module Command.InitRemote where
 
-import qualified Data.Map as M
-
 import Command
 import Annex.SpecialRemote
 import qualified Remote
@@ -24,11 +22,15 @@
 import Config
 import Git.Config
 
+import qualified Data.Map as M
+import qualified Data.Text as T
+
 cmd :: Command
-cmd = command "initremote" SectionSetup
-	"creates a special (non-git) remote"
-	(paramPair paramName $ paramOptional $ paramRepeating paramParamValue)
-	(seek <$$> optParser)
+cmd = withAnnexOptions [jsonOptions] $
+	command "initremote" SectionSetup
+		"creates a special (non-git) remote"
+		(paramPair paramName $ paramOptional $ paramRepeating paramParamValue)
+		(seek <$$> optParser)
 
 data InitRemoteOptions = InitRemoteOptions
 	{ cmdparams :: CmdParams
@@ -64,29 +66,35 @@
 
 start :: InitRemoteOptions -> [String] -> CommandStart
 start _ [] = giveup "Specify a name for the remote."
-start o (name:ws) = ifM (not . null <$> findExisting name)
-	( giveup $ "There is already a special remote named \"" ++ name ++
-		"\". (Use enableremote to enable an existing special remote.)"
-	, ifM (isJust <$> Remote.byNameOnly name)
-		( giveup $ "There is already a remote named \"" ++ name ++ "\""
-		, do
-			sameasuuid <- maybe
-				(pure Nothing)
-				(Just . Sameas <$$> getParsed)
-				(sameas o) 
-			c <- newConfig name sameasuuid
-				(Logs.Remote.keyValToConfig Proposed ws)
-				<$> remoteConfigMap
-			t <- either giveup return (findType c)
-			if whatElse o
-				then startingCustomOutput (ActionItemOther Nothing) $
-					describeOtherParamsFor c t
-				else starting "initremote" (ActionItemOther (Just name)) si $
-					perform t name c o
-		)
-	)
+start o (name:ws) = do
+	if whatElse o
+		then ifM jsonOutputEnabled
+			( starting "initremote" ai si $ prep $ \c t ->
+				describeOtherParamsFor c t
+			, startingCustomOutput (ActionItemOther Nothing) $ prep $ \c t ->
+				describeOtherParamsFor c t
+			)
+		else starting "initremote" ai si $ prep $ \c t ->
+			perform t name c o
   where
-	si = SeekInput [name]
+	prep a = do
+		whenM (not . null <$> findExisting name) $
+			giveup $ "There is already a special remote named \"" ++ name ++
+				"\". (Use enableremote to enable an existing special remote.)"
+		whenM (isJust <$> Remote.byNameOnly name) $
+			giveup $ "There is already a remote named \"" ++ name ++ "\""
+		sameasuuid <- maybe
+			(pure Nothing)
+			(Just . Sameas <$$> getParsed)
+			(sameas o) 
+		c <- newConfig name sameasuuid
+			(Logs.Remote.keyValToConfig Proposed ws)
+			<$> remoteConfigMap
+		t <- either giveup return (findType c)
+		a c t
+	
+	si = SeekInput (name:ws)
+	ai = ActionItemOther (Just (UnquotedString name))
 
 perform :: RemoteType -> String -> R.RemoteConfig -> InitRemoteOptions -> CommandPerform
 perform t name c o = do
@@ -126,18 +134,36 @@
 	cp <- R.configParser t c
 	let l = map mk (filter notinconfig $ remoteConfigFieldParsers cp)
 		++ map mk' (maybe [] snd (remoteConfigRestPassthrough cp))
-	liftIO $ forM_ l $ \(p, fd, vd) -> case fd of
-		HiddenField -> return ()
-		FieldDesc d -> do
-			putStrLn p
-			putStrLn ("\t" ++ d)
-			case vd of
-				Nothing -> return ()
-				Just (ValueDesc d') ->
-					putStrLn $ "\t(" ++ d' ++ ")"
+	ifM jsonOutputEnabled
+		( maybeAddJSONField "whatelse" $ M.fromList $ mkjson l
+		, liftIO $ forM_ l $ \(p, fd, vd) -> case fd of
+			HiddenField -> return ()
+			FieldDesc d -> do
+				putStrLn p
+				putStrLn ("\t" ++ d)
+				case vd of
+					Nothing -> return ()
+					Just (ValueDesc d') ->
+						putStrLn $ "\t(" ++ d' ++ ")"
+		
+		)
 	next $ return True
   where
+	mkjson = mapMaybe $ \(p, fd, vd) ->
+		case fd of
+			HiddenField -> Nothing
+			FieldDesc d -> Just 
+				( T.pack p
+				, M.fromList
+					[ ("description" :: T.Text, d)
+					, ("valuedescription", case vd of
+						Nothing -> ""
+						Just (ValueDesc d') -> d')
+					]
+				)
+
 	notinconfig fp = not (M.member (parserForField fp) c)
+
 	mk fp = ( fromProposedAccepted (parserForField fp)
 		, fieldDesc fp
 		, valueDesc fp
diff --git a/Command/Inprogress.hs b/Command/Inprogress.hs
--- a/Command/Inprogress.hs
+++ b/Command/Inprogress.hs
@@ -9,6 +9,8 @@
 
 import Command
 import Annex.Transfer
+import Utility.Terminal
+import Utility.SafeOutput
 
 import qualified Data.Set as S
 
@@ -29,33 +31,34 @@
 
 seek :: InprogressOptions -> CommandSeek
 seek o = do
+	isterminal <- liftIO $ checkIsTerminal stdout
 	ts <- map (transferKey . fst) <$> getTransfers
 	case keyOptions o of
 		Just WantAllKeys ->
-			forM_ ts $ commandAction . start'
+			forM_ ts $ commandAction . (start' isterminal)
 		Just (WantSpecificKey k)
-			| k `elem` ts -> commandAction (start' k)
+			| k `elem` ts -> commandAction (start' isterminal k)
 			| otherwise -> commandAction stop
 		_ -> do
 			let s = S.fromList ts
 			let seeker = AnnexedFileSeeker
-				{ startAction = start s
+				{ startAction = start isterminal s
 				, checkContentPresent = Nothing
 				, usesLocationLog = False
 				}
 			withFilesInGitAnnex ww seeker
 				=<< workTreeItems ww (inprogressFiles o)
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "inprogress"
 
-start :: S.Set Key -> SeekInput -> RawFilePath -> Key -> CommandStart
-start s _si _file k
-	| S.member k s = start' k
+start :: IsTerminal -> S.Set Key -> SeekInput -> RawFilePath -> Key -> CommandStart
+start isterminal s _si _file k
+	| S.member k s = start' isterminal k
 	| otherwise = stop
 
-start' :: Key -> CommandStart
-start' k = startingCustomOutput k $ do
+start' :: IsTerminal -> Key -> CommandStart
+start' (IsTerminal isterminal) k = startingCustomOutput k $ do
 	tmpf <- fromRawFilePath <$> fromRepo (gitAnnexTmpObjectLocation k)
 	whenM (liftIO $ doesFileExist tmpf) $
-		liftIO $ putStrLn tmpf
+		liftIO $ putStrLn (if isterminal then safeOutput tmpf else tmpf)
 	next $ return True
diff --git a/Command/List.hs b/Command/List.hs
--- a/Command/List.hs
+++ b/Command/List.hs
@@ -6,20 +6,25 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.List where
 
 import qualified Data.Set as S
 import qualified Data.Map as M
 import Data.Function
 import Data.Ord
+import qualified Data.ByteString.Char8 as B8
 
 import Command
 import Remote
+import qualified Annex
 import Logs.Trust
 import Logs.UUID
 import Annex.UUID
 import Git.Types (RemoteName)
 import Utility.Tuple
+import Utility.SafeOutput
 
 cmd :: Command
 cmd = noCommit $ withAnnexOptions [annexedMatchingOptions] $
@@ -51,7 +56,7 @@
 		}
 	withFilesInGitAnnex ww seeker =<< workTreeItems ww (listThese o)
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "list"
 
 getList :: ListOptions -> Annex [(UUID, RemoteName, TrustLevel)]
 getList o
@@ -75,12 +80,14 @@
 			filter (\t -> thd3 t /= DeadTrusted) rs3
 
 printHeader :: [(UUID, RemoteName, TrustLevel)] -> Annex ()
-printHeader l = liftIO $ putStrLn $ lheader $ map (\(_, n, t) -> (n, t)) l
+printHeader l = liftIO $ putStrLn $ safeOutput $ lheader $ map (\(_, n, t) -> (n, t)) l
 
 start :: [(UUID, RemoteName, TrustLevel)] -> SeekInput -> RawFilePath -> Key -> CommandStart
 start l _si file key = do
 	ls <- S.fromList <$> keyLocations key
-	liftIO $ putStrLn $ format (map (\(u, _, t) -> (t, S.member u ls)) l) file
+	qp <- coreQuotePath <$> Annex.getGitConfig
+	liftIO $ B8.putStrLn $ quote qp $
+		format (map (\(u, _, t) -> (t, S.member u ls)) l) file
 	stop
 
 type Present = Bool
@@ -93,8 +100,8 @@
 	trust UnTrusted = " (untrusted)"
 	trust _ = ""
 
-format :: [(TrustLevel, Present)] -> RawFilePath -> String
-format remotes file = thereMap ++ " " ++ fromRawFilePath file
+format :: [(TrustLevel, Present)] -> RawFilePath -> StringContainingQuotedPath
+format remotes file = UnquotedString (thereMap) <> " " <> QuotedPath file
   where 
 	thereMap = concatMap there remotes
 	there (UnTrusted, True) = "x"
diff --git a/Command/Lock.hs b/Command/Lock.hs
--- a/Command/Lock.hs
+++ b/Command/Lock.hs
@@ -32,7 +32,7 @@
 seek :: CmdParams -> CommandSeek
 seek ps = withFilesInGitAnnex ww seeker =<< workTreeItems ww ps
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "lock"
 	seeker = AnnexedFileSeeker
 		{ startAction = start
 		, checkContentPresent = Nothing
diff --git a/Command/Log.hs b/Command/Log.hs
--- a/Command/Log.hs
+++ b/Command/Log.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.Log where
 
 import qualified Data.Set as S
@@ -12,6 +14,7 @@
 import Data.Char
 import Data.Time.Clock.POSIX
 import Data.Time
+import qualified Data.ByteString.Char8 as B8
 import qualified System.FilePath.ByteString as P
 
 import Command
@@ -36,7 +39,7 @@
 type Outputter = LogChange -> POSIXTime -> [UUID] -> Annex ()
 
 cmd :: Command
-cmd = withAnnexOptions [annexedMatchingOptions] $
+cmd = withAnnexOptions [jsonOptions, annexedMatchingOptions] $
 	command "log" SectionQuery "shows location log"
 		paramPaths (seek <$$> optParser)
 
@@ -86,7 +89,7 @@
 	( do
 		m <- Remote.uuidDescriptions
 		zone <- liftIO getCurrentTimeZone
-		let outputter = mkOutputter m zone o
+		outputter <- mkOutputter m zone o <$> jsonOutputEnabled
 		let seeker = AnnexedFileSeeker
 			{ startAction = start o outputter
 			, checkContentPresent = Nothing
@@ -102,19 +105,20 @@
 	, giveup "This repository is read-only, and there are unmerged git-annex branches, which prevents displaying location log changes. (Set annex.merge-annex-branches to false to ignore the unmerged git-annex branches.)"
 	)
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "log"
 
-start :: LogOptions -> (FilePath -> Outputter) -> SeekInput -> RawFilePath -> Key -> CommandStart
-start o outputter _ file key = do
+start :: LogOptions -> (ActionItem -> SeekInput -> Outputter) -> SeekInput -> RawFilePath -> Key -> CommandStart
+start o outputter si file key = do
 	(changes, cleanup) <- getKeyLog key (passthruOptions o)
-	showLogIncremental (outputter (fromRawFilePath file)) changes
+	let ai = mkActionItem (file, key)
+	showLogIncremental (outputter ai si) changes
 	void $ liftIO cleanup
 	stop
 
-startAll :: LogOptions -> (String -> Outputter) -> CommandStart
+startAll :: LogOptions -> (ActionItem -> SeekInput -> Outputter) -> CommandStart
 startAll o outputter = do
 	(changes, cleanup) <- getAllLog (passthruOptions o)
-	showLog outputter changes
+	showLog (\ai -> outputter ai (SeekInput [])) changes
 	void $ liftIO cleanup
 	stop
 
@@ -149,42 +153,64 @@
 {- Displays changes made. Streams, and can display changes affecting
  - different keys, but does twice as much reading of logged values
  - as showLogIncremental. -}
-showLog :: (String -> Outputter) -> [RefChange] -> Annex ()
+showLog :: (ActionItem -> Outputter) -> [RefChange] -> Annex ()
 showLog outputter cs = forM_ cs $ \c -> do
-	let keyname = serializeKey (changekey c)
+	let ai = mkActionItem (changekey c)
 	new <- S.fromList <$> loggedLocationsRef (newref c)
 	old <- S.fromList <$> loggedLocationsRef (oldref c)
-	sequence_ $ compareChanges (outputter keyname)
+	sequence_ $ compareChanges (outputter ai)
 		[(changetime c, new, old)]
 
-mkOutputter :: UUIDDescMap -> TimeZone -> LogOptions -> FilePath -> Outputter
-mkOutputter m zone o file
-	| rawDateOption o = normalOutput lookupdescription file show
-	| gourceOption o = gourceOutput lookupdescription file
-	| otherwise = normalOutput lookupdescription file (showTimeStamp zone)
+mkOutputter :: UUIDDescMap -> TimeZone -> LogOptions -> Bool -> ActionItem -> SeekInput -> Outputter
+mkOutputter m zone o jsonenabled ai si
+	| jsonenabled = jsonOutput m ai si
+	| rawDateOption o = normalOutput lookupdescription ai rawTimeStamp
+	| gourceOption o = gourceOutput lookupdescription ai 
+	| otherwise = normalOutput lookupdescription ai (showTimeStamp zone)
   where
 	lookupdescription u = maybe (fromUUID u) (fromUUIDDesc) (M.lookup u m)
 
-normalOutput :: (UUID -> String) -> FilePath -> (POSIXTime -> String) -> Outputter
-normalOutput lookupdescription file formattime logchange ts us =
-	liftIO $ mapM_ (putStrLn . format) us
+normalOutput :: (UUID -> String) -> ActionItem -> (POSIXTime -> String) -> Outputter
+normalOutput lookupdescription ai formattime logchange ts us = do
+	qp <- coreQuotePath <$> Annex.getGitConfig
+	liftIO $ mapM_ (B8.putStrLn . quote qp . format) us
   where
 	time = formattime ts
 	addel = case logchange of
 		Added -> "+"
 		Removed -> "-"
-	format u = unwords [ addel, time, file, "|", 
-		fromUUID u ++ " -- " ++ lookupdescription u ]
+	format u = UnquotedString addel <> " " 
+		<> UnquotedString time <> " " 
+		<> actionItemDesc ai <> " | " 
+		<> UnquotedByteString (fromUUID u) <> " -- "
+		<> UnquotedString (lookupdescription u)
 
-gourceOutput :: (UUID -> String) -> FilePath -> Outputter
-gourceOutput lookupdescription file logchange ts us =
+jsonOutput :: UUIDDescMap -> ActionItem -> SeekInput -> Outputter
+jsonOutput m ai si logchange ts us = do
+	showStartMessage $ StartMessage "log" ai si
+	maybeShowJSON $ JSONChunk
+		[ ("logged", case logchange of
+			Added -> "addition"
+			Removed -> "removal")
+		, ("date", rawTimeStamp ts)
+		]
+	void $ Remote.prettyPrintUUIDsDescs "locations" m us
+	showEndOk
+
+gourceOutput :: (UUID -> String) -> ActionItem -> Outputter
+gourceOutput lookupdescription ai logchange ts us =
 	liftIO $ mapM_ (putStrLn . intercalate "|" . format) us
   where
 	time = takeWhile isDigit $ show ts
 	addel = case logchange of
 		Added -> "A" 
 		Removed -> "M"
-	format u = [ time, lookupdescription u, addel, file ]
+	format u =
+		[ time
+		, lookupdescription u
+		, addel
+		, decodeBS (noquote (actionItemDesc ai))
+		]
 
 {- Generates a display of the changes.
  - Uses a formatter to generate a display of items that are added and
@@ -192,10 +218,12 @@
 compareChanges :: Ord a => (LogChange -> POSIXTime -> [a] -> b) -> [(POSIXTime, S.Set a, S.Set a)] -> [b]
 compareChanges format changes = concatMap diff changes
   where
-	diff (ts, new, old) =
-		[ format Added ts   $ S.toList $ S.difference new old
-		, format Removed ts $ S.toList $ S.difference old new
-		]
+	diff (ts, new, old)
+		| new == old = []
+		| otherwise = 
+			[ format Added ts   $ S.toList $ S.difference new old
+			, format Removed ts $ S.toList $ S.difference old new
+			]
 
 {- Streams the git log for a given key's location log file.
  -
@@ -281,9 +309,12 @@
 	go _ = Nothing
 
 parseTimeStamp :: String -> POSIXTime
-parseTimeStamp = utcTimeToPOSIXSeconds . fromMaybe (error "bad timestamp") .
+parseTimeStamp = utcTimeToPOSIXSeconds . fromMaybe (giveup "bad timestamp") .
 	parseTimeM True defaultTimeLocale "%s"
 
 showTimeStamp :: TimeZone -> POSIXTime -> String
 showTimeStamp zone = formatTime defaultTimeLocale rfc822DateFormat 
 	. utcToZonedTime zone . posixSecondsToUTCTime
+
+rawTimeStamp :: POSIXTime -> String
+rawTimeStamp t = filter (/= 's') (show t)
diff --git a/Command/LookupKey.hs b/Command/LookupKey.hs
--- a/Command/LookupKey.hs
+++ b/Command/LookupKey.hs
@@ -10,6 +10,8 @@
 import Command
 import Annex.CatFile
 import qualified Git.LsFiles
+import Utility.Terminal
+import Utility.SafeOutput
 
 cmd :: Command
 cmd = notBareRepo $ noCommit $ noMessages $
@@ -23,7 +25,9 @@
 	Nothing -> return False
 	Just file' -> catKeyFile file' >>= \case
 		Just k  -> do
-			liftIO $ putStrLn $ serializeKey k
+			IsTerminal isterminal <- liftIO $ checkIsTerminal stdout
+			let sk = serializeKey k
+			liftIO $ putStrLn $ if isterminal then safeOutput sk else sk
 			return True
 		Nothing -> return False
 
diff --git a/Command/Map.hs b/Command/Map.hs
--- a/Command/Map.hs
+++ b/Command/Map.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.Map where
 
 import qualified Data.Map as M
@@ -62,11 +64,11 @@
 
 runViewer :: FilePath -> [(String, [CommandParam])] -> Annex Bool
 runViewer file [] = do
-	showLongNote $ "left map in " ++ file
+	showLongNote $ UnquotedString $ "left map in " ++ file
 	return True
 runViewer file ((c, ps):rest) = ifM (liftIO $ inSearchPath c)
 	( do
-		showLongNote $ "running: " ++ c ++ unwords (toCommand ps)
+		showLongNote $ UnquotedString $ "running: " ++ c ++ unwords (toCommand ps)
 		showOutput
 		liftIO $ boolSystem c ps
 	, runViewer file rest
@@ -196,7 +198,7 @@
 {- reads the config of a remote, with progress display -}
 scan :: Git.Repo -> Annex Git.Repo
 scan r = do
-	showStartOther "map" (Just $ Git.repoDescribe r) (SeekInput [])
+	showStartMessage (StartMessage "map" (ActionItemOther (Just $ UnquotedString $ Git.repoDescribe r)) (SeekInput []))
 	v <- tryScan r
 	case v of
 		Just r' -> do
diff --git a/Command/Merge.hs b/Command/Merge.hs
--- a/Command/Merge.hs
+++ b/Command/Merge.hs
@@ -16,9 +16,10 @@
 import Git.Types
 
 cmd :: Command
-cmd = command "merge" SectionMaintenance
-	"merge changes from remotes"
-	(paramOptional paramRef) (seek <$$> optParser)
+cmd = withAnnexOptions [jsonOptions] $
+	command "merge" SectionMaintenance
+		"merge changes from remotes"
+		(paramOptional paramRef) (seek <$$> optParser)
 
 data MergeOptions = MergeOptions
 	{ mergeBranches :: [String]
@@ -48,7 +49,7 @@
 	Annex.Branch.commit =<< Annex.Branch.commitMessage
 	next $ return True
   where
-	ai = ActionItemOther (Just (fromRef Annex.Branch.name))
+	ai = ActionItemOther (Just (UnquotedString (fromRef Annex.Branch.name)))
 	si = SeekInput []
 
 mergeSyncedBranch :: MergeOptions -> CommandStart
@@ -63,5 +64,5 @@
 	let so = def { notOnlyAnnexOption = True }
 	next $ merge currbranch mc so Git.Branch.ManualCommit r
   where
-	ai = ActionItemOther (Just (Git.fromRef r))
+	ai = ActionItemOther (Just (UnquotedString (Git.fromRef r)))
 	si = SeekInput []
diff --git a/Command/MetaData.hs b/Command/MetaData.hs
--- a/Command/MetaData.hs
+++ b/Command/MetaData.hs
@@ -12,10 +12,10 @@
 import Annex.VectorClock
 import Logs.MetaData
 import Annex.WorkTree
-import Messages.JSON (JSONActionItem(..), AddJSONActionItemFields(..))
 import Types.Messages
-import Utility.Aeson
+import Utility.SafeOutput
 import Limit
+import Messages.JSON (JSONActionItem(..), eitherDecode)
 
 import qualified Data.Set as S
 import qualified Data.Map as M
@@ -75,7 +75,7 @@
 seek o = case batchOption o of
 	NoBatch -> do
 		c <- currentVectorClock
-		let ww = WarnUnmatchLsFiles
+		let ww = WarnUnmatchLsFiles "metadata"
 		let seeker = AnnexedFileSeeker
 			{ startAction = start c o
 			, checkContentPresent = Nothing
@@ -109,7 +109,7 @@
 	Get f -> startingCustomOutput k $ do
 		l <- S.toList . currentMetaDataValues f <$> getCurrentMetaData k
 		liftIO $ forM_ l $
-			B8.putStrLn . fromMetaValue
+			B8.putStrLn . safeOutput . fromMetaValue
 		next $ return True
 	_ -> starting "metadata" ai si $
 		perform c o k
@@ -126,10 +126,8 @@
 cleanup :: Key -> CommandCleanup
 cleanup k = do
 	m <- getCurrentMetaData k
-	case toJSON' (AddJSONActionItemFields m) of
-		Object o -> maybeShowJSON $ AesonObject o
-		_ -> noop
-	showLongNote $ unlines $ concatMap showmeta $
+	maybeAddJSONField "fields" m
+	showLongNote $ UnquotedString $ unlines $ concatMap showmeta $
 		map unwrapmeta (fromMetaData m)
 	return True
   where
diff --git a/Command/Migrate.hs b/Command/Migrate.hs
--- a/Command/Migrate.hs
+++ b/Command/Migrate.hs
@@ -20,7 +20,7 @@
 import Utility.Metered
 
 cmd :: Command
-cmd = withAnnexOptions [backendOption, annexedMatchingOptions] $
+cmd = withAnnexOptions [backendOption, annexedMatchingOptions, jsonOptions] $
 	command "migrate" SectionUtility 
 		"switch data to different backend"
 		paramPaths (seek <$$> optParser)
@@ -41,7 +41,7 @@
 seek :: MigrateOptions -> CommandSeek
 seek o = withFilesInGitAnnex ww seeker =<< workTreeItems ww (migrateThese o)
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "migrate"
 	seeker = AnnexedFileSeeker
 		{ startAction = start o
 		, checkContentPresent = Nothing
diff --git a/Command/MinCopies.hs b/Command/MinCopies.hs
--- a/Command/MinCopies.hs
+++ b/Command/MinCopies.hs
@@ -35,5 +35,5 @@
 	setGlobalMinCopies $ configuredMinCopies n
 	next $ return True
   where
-	ai = ActionItemOther (Just $ show n)
+	ai = ActionItemOther (Just $ UnquotedString $ show n)
 	si = SeekInput [show n]
diff --git a/Command/Mirror.hs b/Command/Mirror.hs
--- a/Command/Mirror.hs
+++ b/Command/Mirror.hs
@@ -50,7 +50,7 @@
 	stages = case fromToOptions o of
 		FromRemote _ -> transferStages
 		ToRemote _ -> commandStages
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "mirror"
 	seeker = AnnexedFileSeeker
 		{ startAction = start o
 		, checkContentPresent = Nothing
diff --git a/Command/Move.hs b/Command/Move.hs
--- a/Command/Move.hs
+++ b/Command/Move.hs
@@ -84,7 +84,7 @@
 		, usesLocationLog = True
 		}
 	keyaction = startKey fto (removeWhen o)
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "move"
 
 stages :: FromToHereOptions -> UsedStages
 stages (FromOrToRemote (FromRemote _)) = transferStages
@@ -157,10 +157,10 @@
 	srcuuid <- getUUID
 	case isthere of
 		Left err -> do
-			showNote err
+			showNote (UnquotedString err)
 			stop
 		Right False -> logMove srcuuid destuuid False key $ \deststartedwithcopy -> do
-			showAction $ "to " ++ Remote.name dest
+			showAction $ UnquotedString $ "to " ++ Remote.name dest
 			ok <- notifyTransfer Upload afile $
 				upload dest key afile stdRetry
 			if ok
@@ -260,7 +260,7 @@
 
 fromPerform' :: Bool -> Bool -> Remote -> Key -> AssociatedFile -> Annex (RemoveWhen -> CommandPerform)
 fromPerform' present updatelocationlog src key afile = do
-	showAction $ "from " ++ Remote.name src
+	showAction $ UnquotedString $ "from " ++ Remote.name src
 	destuuid <- getUUID
 	logMove (Remote.uuid src) destuuid present key $ \deststartedwithcopy ->
 		if present
@@ -314,7 +314,7 @@
 
 	faileddropremote = do
 		showLongNote "(Use --force to override this check, or adjust numcopies.)"
-		showLongNote $ "Content not dropped from " ++ Remote.name src ++ "."
+		showLongNote $ UnquotedString $ "Content not dropped from " ++ Remote.name src ++ "."
 		logMoveCleanup deststartedwithcopy
 		next $ return False
 
@@ -394,11 +394,13 @@
 		haskey <- Remote.hasKey dest key
 		case haskey of
                 	Left err -> do                   
-				showNote err       
+				showNote (UnquotedString err)
 				stop
 			Right True -> do
-				showAction $ "from " ++ Remote.name src
-				showAction $ "to " ++ Remote.name dest
+				showAction $ UnquotedString $
+					"from " ++ Remote.name src
+				showAction $ UnquotedString $
+					"to " ++ Remote.name dest
 				-- The log may not indicate dest's copy
 				-- yet, so make sure it does.
 				logChange key (Remote.uuid dest) InfoPresent
diff --git a/Command/Multicast.hs b/Command/Multicast.hs
--- a/Command/Multicast.hs
+++ b/Command/Multicast.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE CPP #-}
+{-# LANGUAGE CPP, OverloadedStrings #-}
 
 module Command.Multicast where
 
@@ -131,7 +131,7 @@
 	-- expensive.
 	starting "sending files" (ActionItemOther Nothing) (SeekInput []) $
 		withTmpFile "send" $ \t h -> do
-			let ww = WarnUnmatchLsFiles
+			let ww = WarnUnmatchLsFiles "multicast"
 			(fs', cleanup) <- seekHelper id ww LsFiles.inRepo
 				=<< workTreeItems ww fs
 			matcher <- Limit.getMatcher
@@ -211,7 +211,7 @@
 storeReceived f = do
 	case deserializeKey (takeFileName f) of
 		Nothing -> do
-			warning $ "Received a file " ++ f ++ " that is not a git-annex key. Deleting this file."
+			warning $ "Received a file " <> QuotedPath (toRawFilePath f) <> " that is not a git-annex key. Deleting this file."
 			liftIO $ removeWhenExistsWith R.removeLink (toRawFilePath f)
 		Just k -> void $ logStatusAfter k $
 			getViaTmpFromDisk RetrievalVerifiableKeysSecure AlwaysVerify k (AssociatedFile Nothing) $ \dest -> unVerified $
diff --git a/Command/NumCopies.hs b/Command/NumCopies.hs
--- a/Command/NumCopies.hs
+++ b/Command/NumCopies.hs
@@ -49,5 +49,5 @@
 	setGlobalNumCopies $ configuredNumCopies n
 	next $ return True
   where
-	ai = ActionItemOther (Just $ show n)
+	ai = ActionItemOther (Just $ UnquotedString $ show n)
 	si = SeekInput [show n]
diff --git a/Command/P2P.hs b/Command/P2P.hs
--- a/Command/P2P.hs
+++ b/Command/P2P.hs
@@ -24,6 +24,7 @@
 import Utility.Tmp.Dir
 import Utility.FileMode
 import Utility.ThreadScheduler
+import Utility.SafeOutput
 import qualified Utility.RawFilePath as R
 import qualified Utility.MagicWormhole as Wormhole
 
@@ -92,7 +93,7 @@
 	authtoken <- liftIO $ genAuthToken 128
 	storeP2PAuthToken authtoken
 	earlyWarning "These addresses allow access to this git-annex repository. Only share them with people you trust with that access, using trusted communication channels!"
-	liftIO $ putStr $ unlines $
+	liftIO $ putStr $ safeOutput $ unlines $
 		map formatP2PAddress $
 			map (`P2PAddressAuth` authtoken) addrs
 
@@ -101,7 +102,7 @@
 linkRemote remotename = starting "p2p link" ai si $
 	next promptaddr
   where
-	ai = ActionItemOther (Just remotename)
+	ai = ActionItemOther (Just (UnquotedString remotename))
 	si = SeekInput []
 	promptaddr = do
 		liftIO $ putStrLn ""
@@ -131,7 +132,7 @@
 	, giveup "Magic Wormhole is not installed, and is needed for pairing. Install it from your distribution or from https://github.com/warner/magic-wormhole/"
 	)
   where
-	ai = ActionItemOther (Just remotename)
+	ai = ActionItemOther (Just (UnquotedString remotename))
 	si = SeekInput []
 
 performPairing :: RemoteName -> [P2PAddress] -> CommandPerform
@@ -152,7 +153,7 @@
 				warning "Failed receiving data from pair."
 				return False
 			LinkFailed e -> do
-				warning $ "Failed linking to pair: " ++ e
+				warning $ UnquotedString $ "Failed linking to pair: " ++ e
 				return False
   where
 	ui observer producer = do
diff --git a/Command/PreCommit.hs b/Command/PreCommit.hs
--- a/Command/PreCommit.hs
+++ b/Command/PreCommit.hs
@@ -32,10 +32,10 @@
 
 seek :: CmdParams -> CommandSeek
 seek ps = do
-	let ww = WarnUnmatchWorkTreeItems
+	let ww = WarnUnmatchWorkTreeItems "pre-commit"
 	l <- workTreeItems ww ps
 	-- fix symlinks to files being committed
-	flip withFilesToBeCommitted l $ \(si, f) -> commandAction $
+	flip (withFilesToBeCommitted ww) l $ \(si, f) -> commandAction $
 		maybe stop (Command.Fix.start Command.Fix.FixSymlinks si f)
 			=<< isAnnexLink f
 	-- after a merge conflict or git cherry-pick or stash, pointer
@@ -72,7 +72,7 @@
 	return True
 
 showMetaDataChange :: MetaData -> Annex ()
-showMetaDataChange = showLongNote . unlines . concatMap showmeta . fromMetaData
+showMetaDataChange = showLongNote . UnquotedString . unlines . concatMap showmeta . fromMetaData
   where
 	showmeta (f, vs) = map (showmetavalue f) $ S.toList vs
 	showmetavalue f v = T.unpack (fromMetaField f) <> showset v <> "=" <> decodeBS (fromMetaValue v)
diff --git a/Command/Pull.hs b/Command/Pull.hs
new file mode 100644
--- /dev/null
+++ b/Command/Pull.hs
@@ -0,0 +1,17 @@
+{- git-annex command
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Command.Pull (cmd) where
+
+import Command
+import Command.Sync hiding (cmd)
+
+cmd :: Command
+cmd = withAnnexOptions [jobsOption, backendOption] $
+	command "pull" SectionCommon 
+		"pull content from remotes"
+		(paramRepeating paramRemote) (seek <--< optParser PullMode)
diff --git a/Command/Push.hs b/Command/Push.hs
new file mode 100644
--- /dev/null
+++ b/Command/Push.hs
@@ -0,0 +1,17 @@
+{- git-annex command
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Command.Push (cmd) where
+
+import Command
+import Command.Sync hiding (cmd)
+
+cmd :: Command
+cmd = withAnnexOptions [jobsOption, backendOption] $
+	command "push" SectionCommon 
+		"push content to remotes"
+		(paramRepeating paramRemote) (seek <--< optParser PushMode)
diff --git a/Command/ReKey.hs b/Command/ReKey.hs
--- a/Command/ReKey.hs
+++ b/Command/ReKey.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.ReKey where
 
 import Command
@@ -23,10 +25,11 @@
 import System.PosixCompat.Files (linkCount, fileMode)
 
 cmd :: Command
-cmd = command "rekey" SectionPlumbing
-	"change keys used for files"
-	(paramRepeating $ paramPair paramPath paramKey)
-	(seek <$$> optParser)
+cmd = withAnnexOptions [jsonOptions] $ 
+	command "rekey" SectionPlumbing
+		"change keys used for files"
+		(paramRepeating $ paramPair paramPath paramKey)
+		(seek <$$> optParser)
 
 data ReKeyOptions = ReKeyOptions
 	{ reKeyThese :: CmdParams
@@ -80,8 +83,10 @@
 	ifM (inAnnex oldkey) 
 		( unlessM (linkKey file oldkey newkey) $
 			giveup "failed creating link from old to new key"
-		, unlessM (Annex.getRead Annex.force) $
-			giveup $ fromRawFilePath file ++ " is not available (use --force to override)"
+		, unlessM (Annex.getRead Annex.force) $ do
+			qp <- coreQuotePath <$> Annex.getGitConfig
+			giveup $ decodeBS $ quote qp $ QuotedPath file
+				<> " is not available (use --force to override)"
 		)
 	next $ cleanup file newkey
 
@@ -108,12 +113,12 @@
 				replaceWorkTreeFile (fromRawFilePath file) $ \tmp -> do
 					let tmp' = toRawFilePath tmp
 					unlessM (checkedCopyFile oldkey oldobj tmp' Nothing) $
-						error "can't lock old key"
+						giveup "can't lock old key"
 					thawContent tmp'
 		ic <- withTSDelta (liftIO . genInodeCache file)
 		case v of
 			Left e -> do
-				warning (show e)
+				warning (UnquotedString (show e))
 				return False
 			Right () -> do
 				r <- linkToAnnex newkey file ic
diff --git a/Command/RegisterUrl.hs b/Command/RegisterUrl.hs
--- a/Command/RegisterUrl.hs
+++ b/Command/RegisterUrl.hs
@@ -63,7 +63,7 @@
 	starting "registerurl" ai si $
 		perform a o key url
   where
-	ai = ActionItemOther (Just url)
+	ai = ActionItemOther (Just (UnquotedString url))
 
 perform :: (Remote -> Key -> URLString -> Annex ()) -> RegisterUrlOptions -> Key -> URLString -> CommandPerform
 perform a o key url = do
@@ -73,7 +73,7 @@
 		_ -> Remote.claimingUrl url
 	case needremote of
 		Just nr | nr /= r -> do
-			showNote $ "The url " ++ url ++ " is claimed by remote " ++ Remote.name r
+			showNote $ UnquotedString $ "The url " ++ url ++ " is claimed by remote " ++ Remote.name r
 			next $ return False
 		_ -> do
 			a r key (setDownloader' url r)
diff --git a/Command/Reinit.hs b/Command/Reinit.hs
--- a/Command/Reinit.hs
+++ b/Command/Reinit.hs
@@ -14,7 +14,7 @@
 import qualified Annex.SpecialRemote
 	
 cmd :: Command
-cmd = dontCheck repoExists $
+cmd = dontCheck repoExists $ withAnnexOptions [jsonOptions] $
 	command "reinit" SectionUtility 
 		"initialize repository, reusing old UUID"
 		(paramUUID ++ "|" ++ paramDesc)
@@ -24,9 +24,10 @@
 seek = withWords (commandAction . start)
 
 start :: [String] -> CommandStart
-start ws = starting "reinit" (ActionItemOther (Just s)) (SeekInput ws) $
+start ws = starting "reinit" ai (SeekInput ws) $
 	perform s
   where
+	ai = ActionItemOther (Just (UnquotedString s))
 	s = unwords ws
 
 perform :: String -> CommandPerform
diff --git a/Command/Reinject.hs b/Command/Reinject.hs
--- a/Command/Reinject.hs
+++ b/Command/Reinject.hs
@@ -1,10 +1,12 @@
 {- git-annex command
  -
- - Copyright 2011-2016 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.Reinject where
 
 import Command
@@ -15,9 +17,10 @@
 import Utility.Metered
 import Annex.WorkTree
 import qualified Git
+import qualified Annex
 
 cmd :: Command
-cmd = withAnnexOptions [backendOption] $
+cmd = withAnnexOptions [backendOption, jsonOptions] $
 	command "reinject" SectionUtility 
 		"inject content of file back into annex"
 		(paramRepeating (paramPair "SRC" "DEST"))
@@ -40,56 +43,64 @@
 seek :: ReinjectOptions -> CommandSeek
 seek os
 	| knownOpt os = withStrings (commandAction . startKnown) (params os)
-	| otherwise = withWords (commandAction . startSrcDest) (params os)
+	| otherwise = withPairs (commandAction . startSrcDest) (params os)
 
-startSrcDest :: [FilePath] -> CommandStart
-startSrcDest ps@(src:dest:[])
+startSrcDest :: (SeekInput, (String, String)) -> CommandStart
+startSrcDest (si, (src, dest))
 	| src == dest = stop
-	| otherwise = notAnnexed src' $
+	| otherwise = starting "reinject" ai si $ notAnnexed src' $
 		lookupKey (toRawFilePath dest) >>= \case
-			Just k -> go k
-			Nothing -> giveup $ src ++ " is not an annexed file"
+			Just key -> ifM (verifyKeyContent key src')
+				( perform src' key
+				, do
+					qp <- coreQuotePath <$> Annex.getGitConfig
+					giveup $ decodeBS $ quote qp $ QuotedPath src'
+						<> " does not have expected content of "
+						<> QuotedPath (toRawFilePath dest)
+				)
+			Nothing -> do
+				qp <- coreQuotePath <$> Annex.getGitConfig
+				giveup $ decodeBS $ quote qp $ QuotedPath src'
+					<> " is not an annexed file"
   where
 	src' = toRawFilePath src
-	go key = starting "reinject" ai si $
-		ifM (verifyKeyContent key src')
-			( perform src' key
-			, giveup $ src ++ " does not have expected content of " ++ dest
-			)
-	ai = ActionItemOther (Just src)
-	si = SeekInput ps
-startSrcDest _ = giveup "specify a src file and a dest file"
+	ai = ActionItemOther (Just (QuotedPath src'))
 
 startKnown :: FilePath -> CommandStart
-startKnown src = notAnnexed src' $
-	starting "reinject" ai si $ do
-		(key, _) <- genKey ks nullMeterUpdate =<< defaultBackend
-		ifM (isKnownKey key)
-			( perform src' key
-			, do
-				warning "Not known content; skipping"
-				next $ return True
-			)
+startKnown src = starting "reinject" ai si $ notAnnexed src' $ do
+	(key, _) <- genKey ks nullMeterUpdate =<< defaultBackend
+	ifM (isKnownKey key)
+		( perform src' key
+		, do
+			warning "Not known content; skipping"
+			next $ return True
+		)
   where
 	src' = toRawFilePath src
 	ks = KeySource src' src' Nothing
-	ai = ActionItemOther (Just src)
+	ai = ActionItemOther (Just (QuotedPath src'))
 	si = SeekInput [src]
 
-notAnnexed :: RawFilePath -> CommandStart -> CommandStart
+notAnnexed :: RawFilePath -> CommandPerform -> CommandPerform
 notAnnexed src a = 
 	ifM (fromRepo Git.repoIsLocalBare)
 		( a
 		, lookupKey src >>= \case
-			Just _ -> giveup $ "cannot used annexed file as src: " ++ fromRawFilePath src
+			Just _ -> do
+				qp <- coreQuotePath <$> Annex.getGitConfig
+				giveup $ decodeBS $ quote qp $ 
+					"cannot used annexed file as src: "
+						<> QuotedPath src
 			Nothing -> a
 		)
 
 perform :: RawFilePath -> Key -> CommandPerform
-perform src key = ifM move
-	( next $ cleanup key
-	, error "failed"
-	)
+perform src key = do
+	maybeAddJSONField "key" (serializeKey key)
+	ifM move
+		( next $ cleanup key
+		, giveup "failed"
+		)
   where
 	move = checkDiskSpaceToGet key False $
 		moveAnnex key (AssociatedFile Nothing) src
diff --git a/Command/RemoteDaemon.hs b/Command/RemoteDaemon.hs
--- a/Command/RemoteDaemon.hs
+++ b/Command/RemoteDaemon.hs
@@ -24,7 +24,7 @@
 
 run :: DaemonOptions -> CommandSeek
 run o
-	| stopDaemonOption o = error "--stop not implemented for remotedaemon"
+	| stopDaemonOption o = giveup "--stop not implemented for remotedaemon"
 	| foregroundDaemonOption o = liftIO runInteractive
 	| otherwise = do
 #ifndef mingw32_HOST_OS
diff --git a/Command/RenameRemote.hs b/Command/RenameRemote.hs
--- a/Command/RenameRemote.hs
+++ b/Command/RenameRemote.hs
@@ -18,10 +18,11 @@
 import qualified Data.Map as M
 
 cmd :: Command
-cmd = command "renameremote" SectionSetup
-	"changes name of special remote"
-	(paramPair paramName paramName)
-	(withParams seek)
+cmd = withAnnexOptions [jsonOptions] $
+	command "renameremote" SectionSetup
+		"changes name of special remote"
+		(paramPair paramName paramName)
+		(withParams seek)
 
 seek :: CmdParams -> CommandSeek
 seek = withWords (commandAction . start)
diff --git a/Command/ResolveMerge.hs b/Command/ResolveMerge.hs
--- a/Command/ResolveMerge.hs
+++ b/Command/ResolveMerge.hs
@@ -28,7 +28,7 @@
 	us <- fromMaybe nobranch <$> inRepo Git.Branch.current
 	d <- fromRawFilePath <$> fromRepo Git.localGitDir
 	let merge_head = d </> "MERGE_HEAD"
-	them <- fromMaybe (error nomergehead) . extractSha
+	them <- fromMaybe (giveup nomergehead) . extractSha
 		<$> liftIO (S.readFile merge_head)
 	ifM (resolveMerge (Just us) them False)
 		( do
diff --git a/Command/RmUrl.hs b/Command/RmUrl.hs
--- a/Command/RmUrl.hs
+++ b/Command/RmUrl.hs
@@ -12,7 +12,7 @@
 import Annex.WorkTree
 
 cmd :: Command
-cmd = notBareRepo $
+cmd = notBareRepo $ withAnnexOptions [jsonOptions] $
 	command "rmurl" SectionCommon 
 		"record file is not available at url"
 		(paramRepeating (paramPair paramFile paramUrl))
diff --git a/Command/Schedule.hs b/Command/Schedule.hs
--- a/Command/Schedule.hs
+++ b/Command/Schedule.hs
@@ -11,6 +11,7 @@
 import qualified Remote
 import Logs.Schedule
 import Types.ScheduledActivity
+import Utility.SafeOutput
 
 import qualified Data.Set as S
 
@@ -31,7 +32,7 @@
 			performGet u
 	parse ps@(name:expr:[]) = do
 		u <- Remote.nameToUUID name
-		let ai = ActionItemOther (Just name)
+		let ai = ActionItemOther (Just (UnquotedString name))
 		let si = SeekInput ps
 		startingUsualMessages "schedule" ai si $
 			performSet expr u
@@ -40,7 +41,7 @@
 performGet :: UUID -> CommandPerform
 performGet uuid = do
 	s <- scheduleGet uuid
-	liftIO $ putStrLn $ intercalate "; " $ 
+	liftIO $ putStrLn $ safeOutput $ intercalate "; " $ 
 		map fromScheduledActivity $ S.toList s
 	next $ return True
 
diff --git a/Command/Semitrust.hs b/Command/Semitrust.hs
--- a/Command/Semitrust.hs
+++ b/Command/Semitrust.hs
@@ -12,9 +12,10 @@
 import Command.Trust (trustCommand)
 
 cmd :: Command
-cmd = command "semitrust" SectionSetup 
-	"return repository to default trust level"
-	(paramRepeating paramRepository) (withParams seek)
+cmd = withAnnexOptions [jsonOptions] $
+	command "semitrust" SectionSetup 
+		"return repository to default trust level"
+		(paramRepeating paramRepository) (withParams seek)
 
 seek :: CmdParams -> CommandSeek
 seek = trustCommand "semitrust" SemiTrusted
diff --git a/Command/SetKey.hs b/Command/SetKey.hs
--- a/Command/SetKey.hs
+++ b/Command/SetKey.hs
@@ -21,10 +21,11 @@
 
 start :: [String] -> CommandStart
 start ps@(keyname:file:[]) = starting "setkey" ai si $
-	perform (toRawFilePath file) (keyOpt keyname)
+	perform file' (keyOpt keyname)
   where
-	ai = ActionItemOther (Just file)
+	ai = ActionItemOther (Just (QuotedPath file'))
 	si = SeekInput ps
+	file' = toRawFilePath file
 start _ = giveup "specify a key and a content file"
 
 keyOpt :: String -> Key
@@ -43,7 +44,7 @@
 		else return True
 	if ok
 		then next $ cleanup key
-		else error "mv failed!"
+		else giveup "move failed!"
 
 cleanup :: Key -> CommandCleanup
 cleanup key = do
diff --git a/Command/SetPresentKey.hs b/Command/SetPresentKey.hs
--- a/Command/SetPresentKey.hs
+++ b/Command/SetPresentKey.hs
@@ -12,7 +12,7 @@
 import Logs.Presence.Pure
 
 cmd :: Command
-cmd = noCommit $ 
+cmd = noCommit $ withAnnexOptions [jsonOptions] $
 	command "setpresentkey" SectionPlumbing
 		"change records of where key is present"
 		(paramPair paramKey (paramPair paramUUID "[1|0]"))
diff --git a/Command/Smudge.hs b/Command/Smudge.hs
--- a/Command/Smudge.hs
+++ b/Command/Smudge.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.Smudge where
 
 import Command
@@ -142,7 +144,7 @@
 		Right Nothing -> notpointer
 		Left InvalidAppendedPointerFile -> do
 			toplevelWarning False $
-				"The file \"" ++ fromRawFilePath file ++ "\" looks like git-annex pointer file that has had other content appended to it"
+				"The file " <> QuotedPath file <> " looks like git-annex pointer file that has had other content appended to it"
 			notpointer
 
 	notpointer = inRepo (Git.Ref.fileRef file) >>= \case
@@ -191,7 +193,7 @@
 	postingest (Just k, _) = do
 		logStatus k InfoPresent
 		return k
-	postingest _ = error "could not add file to the annex"
+	postingest _ = giveup "could not add file to the annex"
 
 	cfg = LockDownConfig
 		{ lockingFile = False
@@ -329,5 +331,5 @@
 					else Database.Keys.addInodeCaches k [ic]
 			Nothing -> liftIO (isPointerFile f) >>= \case
 				Just k' | k' == k -> toplevelWarning False $
-					"unable to populate worktree file " ++ fromRawFilePath f
+					"unable to populate worktree file " <> QuotedPath f
 				_ -> noop
diff --git a/Command/Status.hs b/Command/Status.hs
--- a/Command/Status.hs
+++ b/Command/Status.hs
@@ -8,9 +8,12 @@
 module Command.Status where
 
 import Command
+import qualified Annex
 import Git.Status
 import Git.FilePath
 
+import Data.ByteString.Char8 as B8
+
 cmd :: Command
 cmd = notBareRepo $ noCommit $ noMessages $
 	withAnnexOptions [jsonOptions] $
@@ -61,6 +64,8 @@
 displayStatus s = do
 	let c = statusChar s
 	absf <- fromRepo $ fromTopFilePath (statusFile s)
-	f <- liftIO $ fromRawFilePath <$> relPathCwdToFile absf
-	unlessM (showFullJSON $ JSONChunk [("status", [c]), ("file", f)]) $
-		liftIO $ putStrLn $ [c] ++ " " ++ f
+	f <- liftIO $ relPathCwdToFile absf
+	qp <- coreQuotePath <$> Annex.getGitConfig
+	unlessM (showFullJSON $ JSONChunk [("status", [c]), ("file", fromRawFilePath f)]) $
+		liftIO $ B8.putStrLn $ quote qp $
+			UnquotedString (c : " ") <> QuotedPath f
diff --git a/Command/Sync.hs b/Command/Sync.hs
--- a/Command/Sync.hs
+++ b/Command/Sync.hs
@@ -11,6 +11,8 @@
 
 module Command.Sync (
 	cmd,
+	seek,
+	seek',
 	CurrBranch,
 	mergeConfig,
 	merge,
@@ -23,8 +25,10 @@
 	updateBranch,
 	updateBranches,
 	seekExportContent,
+	optParser,
 	parseUnrelatedHistoriesOption,
 	SyncOptions(..),
+	OperationMode(..),
 ) where
 
 import Command
@@ -73,6 +77,7 @@
 import Annex.Import
 import Annex.CheckIgnore
 import Types.FileMatcher
+import Types.GitConfig
 import qualified Database.Export as Export
 import Utility.Bloom
 import Utility.OptParse
@@ -88,8 +93,11 @@
 cmd = withAnnexOptions [jobsOption, backendOption] $
 	command "sync" SectionCommon 
 		"synchronize local repository with remotes"
-		(paramRepeating paramRemote) (seek <--< optParser)
+		(paramRepeating paramRemote) (seek <--< optParser SyncMode)
 
+data OperationMode = SyncMode | PullMode | PushMode | AssistMode
+	deriving (Eq, Show)
+
 data SyncOptions = SyncOptions
 	{ syncWith :: CmdParams
 	, onlyAnnexOption :: Bool
@@ -99,13 +107,14 @@
 	, messageOption :: Maybe String
 	, pullOption :: Bool
 	, pushOption :: Bool
-	, contentOption :: Bool
-	, noContentOption :: Bool
+	, contentOption :: Maybe Bool
+	, noContentOption :: Maybe Bool
 	, contentOfOption :: [FilePath]
 	, cleanupOption :: Bool
 	, keyOptions :: Maybe KeyOptions
 	, resolveMergeOverride :: Bool
 	, allowUnrelatedHistories :: Bool
+	, operationMode :: OperationMode
 	}
 
 instance Default SyncOptions where
@@ -118,17 +127,18 @@
 		, messageOption = Nothing
 		, pullOption = False
 		, pushOption = False
-		, contentOption = False
-		, noContentOption = False
+		, contentOption = Just False
+		, noContentOption = Just False
 		, contentOfOption = []
 		, cleanupOption = False
 		, keyOptions = Nothing
 		, resolveMergeOverride = False
 		, allowUnrelatedHistories = False
+		, operationMode = SyncMode
 		}
 
-optParser :: CmdParamsDesc -> Parser SyncOptions
-optParser desc = SyncOptions
+optParser :: OperationMode -> CmdParamsDesc -> Parser SyncOptions
+optParser mode desc = SyncOptions
 	<$> (many $ argument str
 		( metavar desc
 		<> completeRemotes
@@ -136,53 +146,75 @@
 	<*> switch 
 		( long "only-annex"
 		<> short 'a'
-		<> help "only sync git-annex branch and annexed file contents"
+		<> help "do not operate on git branches"
 		)
 	<*> switch 
 		( long "not-only-annex"
-		<> help "sync git branches as well as annex"
-		)
-	<*> switch
-		( long "commit"
-		<> help "commit changes to git"
+		<> help "operate on git branches as well as annex"
 		)
-	<*> switch
+	<*> case mode of
+		SyncMode -> switch
+			( long "commit"
+			<> help "commit changes to git"
+			)
+		PushMode -> pure False
+		PullMode -> pure False
+		AssistMode -> pure True
+	<*> unlessmode [SyncMode] True (switch
 		( long "no-commit"
 		<> help "avoid git commit" 
-		)
-	<*> optional (strOption
+		))
+	<*> unlessmode [SyncMode, AssistMode] Nothing (optional (strOption
 		( long "message" <> short 'm' <> metavar "MSG"
 		<> help "commit message"
-		))
-	<*> invertableSwitch "pull" True
-		( help "avoid git pulls from remotes" 
-		)
-	<*> invertableSwitch "push" True
-		( help "avoid git pushes to remotes" 
-		)
-	<*> switch 
+		)))
+	<*> case mode of
+		SyncMode -> invertableSwitch "pull" True
+			( help "avoid git pulls from remotes" 
+			)
+		PullMode -> pure True
+		PushMode -> pure False
+		AssistMode -> pure True
+	<*> case mode of
+		SyncMode -> invertableSwitch "push" True
+			( help "avoid git pushes to remotes" 
+			)
+		PullMode -> pure False
+		PushMode -> pure True
+		AssistMode -> pure True
+	<*> optional (flag' True 
 		( long "content"
 		<> help "transfer annexed file contents" 
-		)
-	<*> switch
+		))
+	<*> optional (flag' True
 		( long "no-content"
+		<> short 'g'
 		<> help "do not transfer annexed file contents"
-		)
+		))
 	<*> many (strOption
 		( long "content-of"
 		<> short 'C'
 		<> help "transfer contents of annexed files in a given location"
 		<> metavar paramPath
 		))
-	<*> switch
+	<*> whenmode [PullMode] False (switch
 		( long "cleanup"
-		<> help "remove synced/ branches from previous sync"
-		)
+		<> help "remove synced/ branches"
+		))
 	<*> optional parseAllOption
-	<*> invertableSwitch "resolvemerge" True
+	<*> whenmode [PushMode] False (invertableSwitch "resolvemerge" True
 		( help "do not automatically resolve merge conflicts"
-		)
-	<*> parseUnrelatedHistoriesOption
+		))
+	<*> whenmode [PushMode] False
+		parseUnrelatedHistoriesOption
+	<*> pure mode
+  where
+	whenmode m v a
+		| mode `elem` m = pure v
+		| otherwise = a
+	unlessmode m v a
+		| mode `elem` m = a
+		| otherwise = pure v
 
 parseUnrelatedHistoriesOption :: Parser Bool
 parseUnrelatedHistoriesOption = 
@@ -209,12 +241,16 @@
 		<*> pure (keyOptions v)
 		<*> pure (resolveMergeOverride v)
 		<*> pure (allowUnrelatedHistories v)
+		<*> pure (operationMode v)
 
 seek :: SyncOptions -> CommandSeek
 seek o = do
+	warnSyncContentTransition o
+	
 	prepMerge
-	startConcurrency transferStages (seek' o)
 	
+	startConcurrency transferStages (seek' o)
+
 seek' :: SyncOptions -> CommandSeek
 seek' o = do
 	let withbranch a = a =<< getCurrentBranch
@@ -241,7 +277,7 @@
 				[ [ commit o ]
 				, [ withbranch (mergeLocal mc o) ]
 				, map (withbranch . pullRemote o mc) gitremotes
-				,  [ mergeAnnex ]
+				, [ mergeAnnex ]
 				]
 			
 			content <- shouldSyncContent o
@@ -259,13 +295,16 @@
 					seekExportContent (Just o)
 						(filter isExport contentremotes)
 
-				-- Sync content with remotes, but not with
-				-- export or import remotes, which handle content
-				-- syncing as part of export and import.
+				-- Sync content with remotes, including
+				-- importing from import remotes (since
+				-- importing only downloads new files not
+				-- old files)
+				let shouldsynccontent r
+					| isExport r && not (isImport r) = False
+					| otherwise = True
 				syncedcontent <- withbranch $
-					seekSyncContent o $ filter
-						(\r -> not (isExport r || isImport r))
-						contentremotes
+					seekSyncContent o
+						(filter shouldsynccontent contentremotes)
 
 				-- Transferring content can take a while,
 				-- and other changes can be pushed to the
@@ -392,7 +431,7 @@
 	needMerge currbranch branch >>= \case
 		Nothing -> stop
 		Just syncbranch -> do
-			let ai = ActionItemOther (Just $ Git.Ref.describe syncbranch)
+			let ai = ActionItemOther (Just $ UnquotedString $ Git.Ref.describe syncbranch)
 			let si = SeekInput []
 			starting "merge" ai si $
 				next $ merge currbranch mergeconfig o Git.Branch.ManualCommit syncbranch
@@ -400,10 +439,10 @@
 	Just branch -> needMerge currbranch branch >>= \case
 		Nothing -> stop
 		Just syncbranch -> do
-			let ai = ActionItemOther (Just $ Git.Ref.describe syncbranch)
+			let ai = ActionItemOther (Just $ UnquotedString $ Git.Ref.describe syncbranch)
 			let si = SeekInput []
 			starting "merge" ai si $ do
-				warning $ "There are no commits yet to branch " ++ Git.fromRef branch ++ ", so cannot merge " ++ Git.fromRef syncbranch ++ " into it."
+				warning $ UnquotedString $ "There are no commits yet to branch " ++ Git.fromRef branch ++ ", so cannot merge " ++ Git.fromRef syncbranch ++ " into it."
 				next $ return False
 	Nothing -> stop
 
@@ -513,7 +552,7 @@
 				, Just $ Param $ Remote.name remote
 				] ++ map Param bs
 	wantpull = remoteAnnexPull (Remote.gitconfig remote)
-	ai = ActionItemOther (Just (Remote.name remote))
+	ai = ActionItemOther (Just (UnquotedString (Remote.name remote)))
 	si = SeekInput []
 
 importRemote :: Bool -> SyncOptions -> Remote -> CurrBranch -> CommandSeek
@@ -533,7 +572,7 @@
 					-- mergeing it.
 					mc <- mergeConfig True
 					void $ mergeRemote remote currbranch mc o
-				else warning $ "Cannot import from " ++ Remote.name remote ++ " when not syncing content."
+				else warning $ UnquotedString $ "Cannot import from " ++ Remote.name remote ++ " when not syncing content."
   where
 	wantpull = remoteAnnexPull (Remote.gitconfig remote)
 
@@ -550,16 +589,16 @@
 	| otherwise = void $ includeCommandAction $ starting "list" ai si $
 		Command.Import.listContents' remote ImportTree (CheckGitIgnore False) go
   where
-	go (Just importable) = importKeys remote ImportTree False True importable >>= \case
-		Just importablekeys -> do
-			(_imported, updatestate) <- recordImportTree remote ImportTree importablekeys
+	go (Just importable) = importChanges remote ImportTree False True importable >>= \case
+		ImportFinished imported -> do
+			(_t, updatestate) <- recordImportTree remote ImportTree imported
 			next $ do
 				updatestate
 				return True
-		Nothing -> next $ return False
+		ImportUnfinished -> next $ return False
 	go Nothing = next $ return True -- unchanged from before
 
-	ai = ActionItemOther (Just (Remote.name remote))
+	ai = ActionItemOther (Just (UnquotedString (Remote.name remote)))
 	si = SeekInput []
 	
 	wantpull = remoteAnnexPull (Remote.gitconfig remote)
@@ -604,10 +643,10 @@
 			if ok
 				then postpushupdate repo
 				else do
-					warning $ unwords [ "Pushing to " ++ Remote.name remote ++ " failed." ]
+					warning $ UnquotedString $ unwords [ "Pushing to " ++ Remote.name remote ++ " failed." ]
 					return ok
   where
-	ai = ActionItemOther (Just (Remote.name remote))
+	ai = ActionItemOther (Just (UnquotedString (Remote.name remote)))
 	si = SeekInput []
 	gc = Remote.gitconfig remote
 	needpush mainbranch
@@ -794,7 +833,12 @@
 		in seekFiltered (const (pure True)) filterer $
 			seekHelper id ww (LsFiles.inRepoOrBranch origbranch) l 
 
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles $
+		case operationMode o of
+			SyncMode -> "sync"
+			PullMode -> "pull"
+			PushMode -> "push"
+			AssistMode -> "assist"
 
 	gofile bloom mvar _ f k = 
 		go (Right bloom) mvar (AssociatedFile (Just f)) k
@@ -805,7 +849,7 @@
 	go ebloom mvar af k = do
 		let ai = OnlyActionOn k (ActionItemKey k)
 		startingNoMessage ai $ do
-			whenM (syncFile ebloom rs af k) $
+			whenM (syncFile o ebloom rs af k) $
 				void $ liftIO $ tryPutMVar mvar ()
 			next $ return True
 
@@ -828,8 +872,8 @@
  -
  - Returns True if any file transfers were made.
  -}
-syncFile :: Either (Maybe (Bloom Key)) (Key -> Annex ()) -> [Remote] -> AssociatedFile -> Key -> Annex Bool
-syncFile ebloom rs af k = do
+syncFile :: SyncOptions -> Either (Maybe (Bloom Key)) (Key -> Annex ()) -> [Remote] -> AssociatedFile -> Key -> Annex Bool
+syncFile o ebloom rs af k = do
 	inhere <- inAnnex k
 	locs <- map Remote.uuid <$> Remote.keyPossibilities k
 	let (have, lack) = partition (\r -> Remote.uuid r `elem` locs) rs
@@ -866,7 +910,8 @@
 	return (got || not (null putrs))
   where
 	wantget have inhere = allM id 
-		[ pure (not $ null have)
+		[ pure (pullOption o)
+		, pure (not $ null have)
 		, pure (not inhere)
 		, wantGet True (Just k) af
 		]
@@ -879,7 +924,9 @@
 			next $ return True
 
 	wantput r
+		| pushOption o == False = return False
 		| Remote.readonly r || remoteAnnexReadOnly (Remote.gitconfig r) = return False
+		| isExport r = return False
 		| isThirdPartyPopulated r = return False
 		| otherwise = wantGetBy True (Just k) af (Remote.uuid r)
 	handleput lack inhere
@@ -912,7 +959,7 @@
 seekExportContent o rs (currbranch, _) = or <$> forM rs go
   where
 	go r
-		| not (maybe True pullOption o) = return False
+		| not (maybe True pushOption o) = return False
 		| not (remoteAnnexPush (Remote.gitconfig r)) = return False
 		| otherwise = bracket
 			(Export.openDb (Remote.uuid r))
@@ -948,14 +995,14 @@
 	warncannotupdateexport r mtb exported currb = case mtb of
 		Nothing -> inRepo (Git.Ref.tree currb) >>= \case
 			Just currt | not (any (== currt) (exportedTreeishes exported)) ->
-				showLongNote $ unwords
+				showLongNote $ UnquotedString $ unwords
 					[ notupdating
 					, "to reflect changes to the tree, because export"
 					, "tracking is not enabled. "
 					, "(Set " ++ gitconfig ++ " to enable it.)"
 					]
 			_ -> noop
-		Just b -> showLongNote $ unwords
+		Just b -> showLongNote $ UnquotedString $ unwords
 			[ notupdating
 			, "because " ++ Git.fromRef b ++ " does not exist."
 			, "(As configured by " ++ gitconfig ++ ")"
@@ -1003,14 +1050,43 @@
 				Git.Ref.base $ Annex.Branch.name
 			]
   where
-	ai = ActionItemOther (Just (Remote.name remote))
+	ai = ActionItemOther (Just (UnquotedString (Remote.name remote)))
 	si = SeekInput []
 
 shouldSyncContent :: SyncOptions -> Annex Bool
 shouldSyncContent o
-	| noContentOption o = pure False
-	| contentOption o || not (null (contentOfOption o)) = pure True
-	| otherwise = getGitConfigVal annexSyncContent <||> onlyAnnex o
+	| fromMaybe False (noContentOption o) = pure False
+	-- For git-annex pull and git-annex push and git-annex assist,
+	-- annex.syncontent defaults to True unless set
+	| operationMode o /= SyncMode = annexsynccontent True
+	| fromMaybe False (contentOption o) || not (null (contentOfOption o)) = pure True
+	-- For git-annex sync, 
+	-- annex.syncontent defaults to False unless set
+	| otherwise = annexsynccontent False <||> onlyAnnex o
+  where
+	annexsynccontent d = 
+		getGitConfigVal' annexSyncContent >>= \case
+			HasGlobalConfig (Just c) -> return c
+			HasGitConfig (Just c) -> return c
+			_ -> return d
+
+-- Transition started May 2023, should wait until that has been in a Debian
+-- stable release before completing the transition.
+warnSyncContentTransition :: SyncOptions -> Annex ()
+warnSyncContentTransition o
+	| operationMode o /= SyncMode = noop
+	| isJust (noContentOption o) || isJust (contentOption o) = noop
+	| not (null (contentOfOption o)) = noop
+	| otherwise = getGitConfigVal' annexSyncContent >>= \case
+		HasGlobalConfig (Just _) -> noop
+		HasGitConfig (Just _) -> noop
+		_ -> showwarning
+  where
+	showwarning = earlyWarning $
+		"git-annex sync will change default behavior to operate on"
+		<> " --content in a future version of git-annex. Recommend"
+		<> " you explicitly use --no-content (or -g) to prepare for"
+		<> " that change. (Or you can configure annex.synccontent)"
 
 notOnlyAnnex :: SyncOptions -> Annex Bool
 notOnlyAnnex o = not <$> onlyAnnex o
diff --git a/Command/TestRemote.hs b/Command/TestRemote.hs
--- a/Command/TestRemote.hs
+++ b/Command/TestRemote.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE RankNTypes, DeriveFunctor, PackageImports #-}
+{-# LANGUAGE RankNTypes, DeriveFunctor, PackageImports, OverloadedStrings #-}
 
 module Command.TestRemote where
 
@@ -73,7 +73,7 @@
 seek = commandAction . start 
 
 start :: TestRemoteOptions -> CommandStart
-start o = starting "testremote" (ActionItemOther (Just (testRemote o))) si $ do
+start o = starting "testremote" (ActionItemOther (Just (UnquotedString (testRemote o)))) si $ do
 	fast <- Annex.getRead Annex.fast
 	cache <- liftIO newRemoteVariantCache
 	r <- either giveup (disableExportTree cache)
@@ -84,7 +84,7 @@
 			else do
 				showAction "generating test keys"
 				mapM randKey (keySizes basesz fast)
-		fs -> mapM (getReadonlyKey r) fs
+		fs -> mapM (getReadonlyKey r . toRawFilePath) fs
 	let r' = if null (testReadonlyFile o)
 		then r
 		else r { Remote.readonly = True }
@@ -151,7 +151,7 @@
 
 -- Variant of a remote with exporttree disabled.
 disableExportTree :: RemoteVariantCache -> Remote -> Annex Remote
-disableExportTree cache r = maybe (error "failed disabling exportree") return 
+disableExportTree cache r = maybe (giveup "failed disabling exportree") return 
 		=<< adjustRemoteConfig cache r (M.delete exportTreeField)
 
 -- Variant of a remote with exporttree enabled.
@@ -441,15 +441,17 @@
 	_ <- moveAnnex k (AssociatedFile Nothing) (toRawFilePath f)
 	return k
 
-getReadonlyKey :: Remote -> FilePath -> Annex Key
-getReadonlyKey r f = lookupKey (toRawFilePath f) >>= \case
-	Nothing -> giveup $ f ++ " is not an annexed file"
-	Just k -> do
-		unlessM (inAnnex k) $
-			giveup $ f ++ " does not have its content locally present, cannot test it"
-		unlessM ((Remote.uuid r `elem`) <$> loggedLocations k) $
-			giveup $ f ++ " is not stored in the remote being tested, cannot test it"
-		return k
+getReadonlyKey :: Remote -> RawFilePath -> Annex Key
+getReadonlyKey r f = do
+	qp <- coreQuotePath <$> Annex.getGitConfig
+	lookupKey f >>= \case
+		Nothing -> giveup $ decodeBS $ quote qp $ QuotedPath f <> " is not an annexed file"
+		Just k -> do
+			unlessM (inAnnex k) $
+				giveup $ decodeBS $ quote qp $ QuotedPath f <> " does not have its content locally present, cannot test it"
+			unlessM ((Remote.uuid r `elem`) <$> loggedLocations k) $
+				giveup $ decodeBS $ quote qp $ QuotedPath f <> " is not stored in the remote being tested, cannot test it"
+			return k
 
 runBool :: Monad m => m () -> m Bool
 runBool a = do
diff --git a/Command/TransferKey.hs b/Command/TransferKey.hs
--- a/Command/TransferKey.hs
+++ b/Command/TransferKey.hs
@@ -57,7 +57,7 @@
 				Remote.logStatus remote key InfoPresent
 				return True
 			Left e -> do
-				warning (show e)
+				warning (UnquotedString (show e))
 				return False
 
 fromPerform :: Key -> AssociatedFile -> Remote -> CommandPerform
@@ -67,7 +67,7 @@
 			tryNonAsync (Remote.retrieveKeyFile remote key file (fromRawFilePath t) p vc) >>= \case
 				Right v -> return (True, v)	
 				Left e -> do
-					warning (show e)
+					warning (UnquotedString (show e))
 					return (False, UnVerified)
   where
 	vc = RemoteVerify remote
diff --git a/Command/TransferKeys.hs b/Command/TransferKeys.hs
--- a/Command/TransferKeys.hs
+++ b/Command/TransferKeys.hs
@@ -43,7 +43,7 @@
 			upload' (Remote.uuid remote) key file Nothing stdRetry $ \p -> do
 				tryNonAsync (Remote.storeKey remote key file p) >>= \case
 					Left e -> do
-						warning (show e)
+						warning (UnquotedString (show e))
 						return False
 					Right () -> do
 						Remote.logStatus remote key InfoPresent
@@ -53,7 +53,7 @@
 				logStatusAfter key $ getViaTmp (Remote.retrievalSecurityPolicy remote) (RemoteVerify remote) key file $ \t -> do
 					r <- tryNonAsync (Remote.retrieveKeyFile remote key file (fromRawFilePath t) p (RemoteVerify remote)) >>= \case
 						Left e -> do
-							warning (show e)
+							warning (UnquotedString (show e))
 							return (False, UnVerified)
 						Right v -> return (True, v)
 					-- Make sure we get the current
@@ -83,7 +83,7 @@
 		go rest
 	go [] = noop
 	go [""] = noop
-	go v = error $ "transferkeys protocol error: " ++ show v
+	go v = giveup $ "transferkeys protocol error: " ++ show v
 
 	readrequests = liftIO $ split fieldSep <$> hGetContents readh
 	sendresult b = liftIO $ do
diff --git a/Command/Transferrer.hs b/Command/Transferrer.hs
--- a/Command/Transferrer.hs
+++ b/Command/Transferrer.hs
@@ -64,7 +64,7 @@
 			upload' (Remote.uuid remote) key file Nothing stdRetry $ \p -> do
 				tryNonAsync (Remote.storeKey remote key file p) >>= \case
 					Left e -> do
-						warning (show e)
+						warning (UnquotedString (show e))
 						return False
 					Right () -> do
 						Remote.logStatus remote key InfoPresent
@@ -75,7 +75,7 @@
 				logStatusAfter key $ getViaTmp (Remote.retrievalSecurityPolicy remote) (RemoteVerify remote) key file $ \t -> do
 					r <- tryNonAsync (Remote.retrieveKeyFile remote key file (fromRawFilePath t) p (RemoteVerify remote)) >>= \case
 						Left e -> do
-							warning (show e)
+							warning (UnquotedString (show e))
 							return (False, UnVerified)
 						Right v -> return (True, v)
 					-- Make sure we get the current
diff --git a/Command/Trust.hs b/Command/Trust.hs
--- a/Command/Trust.hs
+++ b/Command/Trust.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2010-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -17,8 +17,9 @@
 import qualified Data.Set as S
 
 cmd :: Command
-cmd = command "trust" SectionSetup "trust a repository"
-	(paramRepeating paramRepository) (withParams seek)
+cmd = withAnnexOptions [jsonOptions] $
+	command "trust" SectionSetup "trust a repository"
+		(paramRepeating paramRepository) (withParams seek)
 
 seek :: CmdParams -> CommandSeek
 seek = trustCommand "trust" Trusted
@@ -30,7 +31,7 @@
 	start name = do
 		u <- Remote.nameToUUID name
 		let si = SeekInput [name]
-		starting c (ActionItemOther (Just name)) si (perform name u)
+		starting c (ActionItemUUID u (UnquotedString name)) si (perform name u)
 	perform name uuid = do
 		when (level >= Trusted) $
 			unlessM (Annex.getRead Annex.force) $
@@ -40,7 +41,7 @@
 			groupSet uuid S.empty
 		l <- lookupTrust uuid
 		when (l /= level) $
-			warning $ "This remote's trust level is overridden to " ++ showTrustLevel l ++ "."
+			warning $ UnquotedString $ "This remote's trust level is overridden to " ++ showTrustLevel l ++ "."
 		next $ return True
 
 trustedNeedsForce :: String -> String
diff --git a/Command/Unannex.hs b/Command/Unannex.hs
--- a/Command/Unannex.hs
+++ b/Command/Unannex.hs
@@ -22,7 +22,7 @@
 import System.PosixCompat.Files (linkCount)
 
 cmd :: Command
-cmd = withAnnexOptions [annexedMatchingOptions] $
+cmd = withAnnexOptions [jsonOptions, annexedMatchingOptions] $
 	command "unannex" SectionUtility
 		"undo accidental add command"
 		paramPaths (withParams seek)
@@ -30,7 +30,7 @@
 seek :: CmdParams -> CommandSeek
 seek ps = withFilesInGitAnnex ww (seeker False) =<< workTreeItems ww ps
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "unannex"
 
 seeker :: Bool -> AnnexedFileSeeker
 seeker fast = AnnexedFileSeeker
diff --git a/Command/Undo.hs b/Command/Undo.hs
--- a/Command/Undo.hs
+++ b/Command/Undo.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.Undo where
 
 import Command
@@ -12,6 +14,7 @@
 import Git.FilePath
 import Git.UpdateIndex
 import Git.Sha
+import qualified Annex
 import qualified Git.LsFiles as LsFiles
 import qualified Git.Command as Git
 import qualified Git.Branch
@@ -19,7 +22,7 @@
 import qualified Utility.RawFilePath as R
 
 cmd :: Command
-cmd = notBareRepo $
+cmd = notBareRepo $ withAnnexOptions [jsonOptions] $
 	command "undo" SectionCommon 
 		"undo last change to a file or directory"
 		paramPaths (withParams seek)
@@ -29,8 +32,11 @@
 	-- Safety first; avoid any undo that would touch files that are not
 	-- in the index.
 	(fs, cleanup) <- inRepo $ LsFiles.notInRepo [] False (map toRawFilePath ps)
-	unless (null fs) $
-		giveup $ "Cannot undo changes to files that are not checked into git: " ++ unwords (map fromRawFilePath fs)
+	unless (null fs) $ do
+		qp <- coreQuotePath <$> Annex.getGitConfig
+		giveup $ decodeBS $ quote qp $ 
+			"Cannot undo changes to files that are not checked into git: "
+				<> quotedPaths fs
 	void $ liftIO $ cleanup
 
 	-- Committing staged changes before undo allows later
@@ -45,7 +51,7 @@
 start p = starting "undo" ai si $
 	perform p
   where
-	ai = ActionItemOther (Just p)
+	ai = ActionItemOther (Just (QuotedPath (toRawFilePath p)))
 	si = SeekInput [p]
 
 perform :: FilePath -> CommandPerform
diff --git a/Command/Ungroup.hs b/Command/Ungroup.hs
--- a/Command/Ungroup.hs
+++ b/Command/Ungroup.hs
@@ -24,8 +24,9 @@
 start :: [String] -> CommandStart
 start (name:g:[]) = do
 	u <- Remote.nameToUUID name
-	starting "ungroup" (ActionItemOther (Just name)) (SeekInput [name, g]) $
-		perform u (toGroup g)
+	starting "ungroup" (ActionItemOther (Just (UnquotedString name))) 
+		(SeekInput [name, g]) $
+			perform u (toGroup g)
 start _ = giveup "Specify a repository and a group."
 
 perform :: UUID -> Group -> CommandPerform
diff --git a/Command/Uninit.hs b/Command/Uninit.hs
--- a/Command/Uninit.hs
+++ b/Command/Uninit.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2010-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -11,6 +11,7 @@
 import qualified Git
 import qualified Git.Command
 import qualified Git.Ref
+import qualified Git.Branch
 import qualified Command.Unannex
 import qualified Annex.Branch
 import qualified Annex.Queue
@@ -23,23 +24,59 @@
 import qualified Utility.RawFilePath as R
 
 import System.PosixCompat.Files (linkCount)
+import Control.Concurrent.STM
 
 cmd :: Command
-cmd = addCheck check $ 
+cmd = withAnnexOptions [jsonOptions] $
 	command "uninit" SectionUtility
 		"de-initialize git-annex and clean out repository"
-		paramPaths (withParams seek)
+		paramNothing (withParams seek)
 
-check :: Annex ()
-check = do
-	b <- current_branch
-	when (b == Just Annex.Branch.name) $ giveup $
-		"cannot uninit when the " ++ Git.fromRef Annex.Branch.name ++ " branch is checked out"
-	top <- fromRepo Git.repoPath
-	currdir <- liftIO R.getCurrentDirectory
-	whenM ((/=) <$> liftIO (absPath top) <*> liftIO (absPath currdir)) $
-		giveup "can only run uninit from the top of the git repository"
+seek :: CmdParams -> CommandSeek
+seek = withNothing $ do
+	ok <- liftIO $ newTVarIO False
+	let checkok v a = do
+		liftIO $ atomically $ writeTVar ok v
+		() <- a	
+		liftIO $ atomically $ readTVar ok
+	let recordok = do
+		liftIO $ atomically $ writeTVar ok True
+		return True
+	let recordnotok = liftIO $ atomically $ writeTVar ok False
+
+	whenM (checkok False $ commandAction $ checkCanUninit recordok) $ do
+		let symlinksok = checkok True $ withFilesNotInGit
+			(CheckGitIgnore False)
+			(WarnUnmatchWorkTreeItems "uninit")
+			(checksymlinks recordnotok)
+			=<< workTreeItems ww []
+		whenM symlinksok $ do
+			withFilesInGitAnnex ww (Command.Unannex.seeker True)
+				=<< workTreeItems ww []
+			whenM (checkok False $ commandAction $ removeAnnexDir recordok) $ 
+				commandAction completeUnitialize
   where
+	ww = WarnUnmatchLsFiles "uninit"
+	checksymlinks recordnotok (_, f) = 
+		commandAction $ lookupKey f >>= \case
+			Nothing -> stop
+			Just k -> startCheckIncomplete recordnotok f k
+
+checkCanUninit :: CommandCleanup -> CommandStart
+checkCanUninit recordok = 
+	starting "uninit check" (ActionItemOther Nothing) (SeekInput []) $ do
+		runchecks
+		next recordok
+  where
+	runchecks = do
+		b <- current_branch
+		when (b == Just Annex.Branch.name) $ giveup $
+			"cannot uninit when the " ++ Git.fromRef Annex.Branch.name ++ " branch is checked out"
+		top <- fromRepo Git.repoPath
+		currdir <- liftIO R.getCurrentDirectory
+		whenM ((/=) <$> liftIO (absPath top) <*> liftIO (absPath currdir)) $
+			giveup "can only run uninit from the top of the git repository"
+	
 	current_branch = 
 		ifM (inRepo Git.Ref.headExists)
 			( Just . Git.Ref . encodeBS . Prelude.head . lines . decodeBS <$> revhead
@@ -48,65 +85,49 @@
 	revhead = inRepo $ Git.Command.pipeReadStrict
 		[Param "rev-parse", Param "--abbrev-ref", Param "HEAD"]
 
-seek :: CmdParams -> CommandSeek
-seek ps = do
-	l <- workTreeItems ww ps
-	withFilesNotInGit
-		(CheckGitIgnore False)
-		WarnUnmatchWorkTreeItems
-		checksymlinks
-		l
-	withFilesInGitAnnex ww (Command.Unannex.seeker True) l
-	finish
-  where
-	ww = WarnUnmatchLsFiles
-	checksymlinks (_, f) = 
-		commandAction $ lookupKey f >>= \case
-			Nothing -> stop
-			Just k -> startCheckIncomplete (fromRawFilePath f) k
-
 {- git annex symlinks that are not checked into git could be left by an
  - interrupted add. -}
-startCheckIncomplete :: FilePath -> Key -> CommandStart
-startCheckIncomplete file _ = giveup $ unlines
-	[ file ++ " points to annexed content, but is not checked into git."
-	, "Perhaps this was left behind by an interrupted git annex add?"
-	, "Not continuing with uninit; either delete or git annex add the file and retry."
-	]
+startCheckIncomplete :: Annex () -> RawFilePath -> Key -> CommandStart
+startCheckIncomplete recordnotok file key =
+	starting "uninit check" (mkActionItem (file, key)) (SeekInput []) $ do
+		recordnotok
+		giveup $ unlines err
+  where
+	err =
+		[ fromRawFilePath file ++ " points to annexed content, but is not checked into git."
+		, "Perhaps this was left behind by an interrupted git annex add?"
+		, "Not continuing with uninit; either delete or git annex add the file and retry."
+		]
 
-finish :: Annex ()
-finish = do
+removeAnnexDir :: CommandCleanup -> CommandStart
+removeAnnexDir recordok = do
 	Annex.Queue.flush
 	annexdir <- fromRawFilePath <$> fromRepo gitAnnexDir
 	annexobjectdir <- fromRepo gitAnnexObjectDir
-	leftovers <- removeUnannexed =<< listKeys InAnnex
-	prepareRemoveAnnexDir annexdir
-	if null leftovers
-		then liftIO $ removeDirectoryRecursive annexdir
-		else giveup $ unlines
-			[ "Not fully uninitialized"
-			, "Some annexed data is still left in " ++ fromRawFilePath annexobjectdir
-			, "This may include deleted files, or old versions of modified files."
-			, ""
-			, "If you don't care about preserving the data, just delete the"
-			, "directory."
-			, ""
-			, "Or, you can move it to another location, in case it turns out"
-			, "something in there is important."
-			, ""
-			, "Or, you can run `git annex unused` followed by `git annex dropunused`"
-			, "to remove data that is not used by any tag or branch, which might"
-			, "take care of all the data."
-			, ""
-			, "Then run `git annex uninit` again to finish."
-			]
-	uninitialize
-	-- avoid normal shutdown
-	saveState False
-	whenM (inRepo $ Git.Ref.exists Annex.Branch.fullname) $
-		inRepo $ Git.Command.run
-			[Param "branch", Param "-D", Param $ Git.fromRef Annex.Branch.name]
-	liftIO exitSuccess
+	starting ("uninit objects") (ActionItemOther Nothing) (SeekInput []) $ do
+		leftovers <- removeUnannexed =<< listKeys InAnnex
+		prepareRemoveAnnexDir annexdir
+		if null leftovers
+			then do
+				liftIO $ removeDirectoryRecursive annexdir
+				next recordok
+			else giveup $ unlines
+				[ "Not fully uninitialized"
+				, "Some annexed data is still left in " ++ fromRawFilePath annexobjectdir
+				, "This may include deleted files, or old versions of modified files."
+				, ""
+				, "If you don't care about preserving the data, just delete the"
+				, "directory."
+				, ""
+				, "Or, you can move it to another location, in case it turns out"
+				, "something in there is important."
+				, ""
+				, "Or, you can run `git annex unused` followed by `git annex dropunused`"
+				, "to remove data that is not used by any tag or branch, which might"
+				, "take care of all the data."
+				, ""
+				, "Then run `git annex uninit` again to finish."
+				]
 
 {- Turn on write bits in all remaining files in the annex directory, in
  - preparation for removal. 
@@ -140,3 +161,17 @@
 	enoughlinks f = catchBoolIO $ do
 		s <- R.getFileStatus f
 		return $ linkCount s > 1
+
+completeUnitialize :: CommandStart
+completeUnitialize =
+	starting ("uninit finish") (ActionItemOther Nothing) (SeekInput []) $ do
+		uninitialize
+		removeAnnexBranch
+		next $ return True
+
+removeAnnexBranch :: Annex ()
+removeAnnexBranch = do
+	-- avoid normal shutdown commit to the branch
+	saveState False
+	whenM (inRepo $ Git.Ref.exists Annex.Branch.fullname) $
+		inRepo $ Git.Branch.delete Annex.Branch.name
diff --git a/Command/Unlock.hs b/Command/Unlock.hs
--- a/Command/Unlock.hs
+++ b/Command/Unlock.hs
@@ -33,7 +33,7 @@
 seek :: CmdParams -> CommandSeek
 seek ps = withFilesInGitAnnex ww seeker =<< workTreeItems ww ps
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "unlock"
 	seeker = AnnexedFileSeeker
 		{ startAction = start
 		, checkContentPresent = Nothing
@@ -58,7 +58,7 @@
 				case r of
 					LinkAnnexOk -> return ()
 					LinkAnnexNoop -> return ()
-					LinkAnnexFailed -> error "unlock failed"
+					LinkAnnexFailed -> giveup "unlock failed"
 			, liftIO $ writePointerFile (toRawFilePath tmp) key destmode
 			)
 		withTSDelta (liftIO . genInodeCache (toRawFilePath tmp))
diff --git a/Command/Untrust.hs b/Command/Untrust.hs
--- a/Command/Untrust.hs
+++ b/Command/Untrust.hs
@@ -12,8 +12,9 @@
 import Command.Trust (trustCommand)
 
 cmd :: Command
-cmd = command "untrust" SectionSetup "do not trust a repository"
-	(paramRepeating paramRepository) (withParams seek)
+cmd = withAnnexOptions [jsonOptions] $
+	command "untrust" SectionSetup "do not trust a repository"
+		(paramRepeating paramRepository) (withParams seek)
 
 seek :: CmdParams -> CommandSeek
 seek = trustCommand "untrust" UnTrusted
diff --git a/Command/Unused.hs b/Command/Unused.hs
--- a/Command/Unused.hs
+++ b/Command/Unused.hs
@@ -38,11 +38,13 @@
 import qualified Data.Map as M
 import qualified Data.ByteString as S
 import qualified Data.ByteString.Char8 as S8
+import qualified Data.Text as T
 import Data.Char
 
 cmd :: Command
-cmd = command "unused" SectionMaintenance "look for unused file content"
-	paramNothing (seek <$$> optParser)
+cmd = withAnnexOptions [jsonOptions] $
+	command "unused" SectionMaintenance "look for unused file content"
+		paramNothing (seek <$$> optParser)
 
 data UnusedOptions = UnusedOptions
 	{ fromRemote :: Maybe RemoteName
@@ -73,7 +75,7 @@
 		Just "." -> (".", checkUnused refspec)
 		Just "here" -> (".", checkUnused refspec)
 		Just n -> (n, checkRemoteUnused n refspec)
-	starting "unused" (ActionItemOther (Just name)) (SeekInput []) perform
+	starting "unused" (ActionItemOther (Just (UnquotedString name))) (SeekInput []) perform
 
 checkUnused :: RefSpec -> CommandPerform
 checkUnused refspec = chain 0
@@ -105,12 +107,16 @@
 		Just ks -> excludeReferenced refspec ks
 		Nothing -> giveup "This repository is read-only."
 
-check :: FilePath -> ([(Int, Key)] -> String) -> Annex [Key] -> Int -> Annex Int
-check file msg a c = do
+check :: String -> ([(Int, Key)] -> String) -> Annex [Key] -> Int -> Annex Int
+check fileprefix msg a c = do
 	l <- a
 	let unusedlist = number c l
-	unless (null l) $ showLongNote $ msg unusedlist
-	updateUnusedLog (toRawFilePath file) (M.fromList unusedlist)
+	unless (null l) $
+		showLongNote $ UnquotedString $ msg unusedlist
+	maybeAddJSONField
+		((if null fileprefix then "unused" else fileprefix) ++ "-list")
+		(M.fromList $ map (\(n,  k) -> (T.pack (show n), serializeKey k)) unusedlist)
+	updateUnusedLog (toRawFilePath fileprefix) (M.fromList unusedlist)
 	return $ c + length l
 
 number :: Int -> [a] -> [(Int, a)]
@@ -249,7 +255,7 @@
  - differ from those referenced in the index. -}
 withKeysReferencedDiffGitRef :: (Key -> Annex ()) -> Git.Ref -> Annex ()
 withKeysReferencedDiffGitRef a ref = do
-	showAction $ "checking " ++ Git.Ref.describe ref
+	showAction $ UnquotedString $ "checking " ++ Git.Ref.describe ref
 	withKeysReferencedDiff a
 		(inRepo $ DiffTree.diffIndex ref)
 		DiffTree.srcsha
@@ -321,12 +327,12 @@
 
 {- Seek action for unused content. Finds the number in the maps, and
  - calls one of 3 actions, depending on the type of unused file. -}
-startUnused :: String
-	-> (Key -> CommandPerform)
-	-> (Key -> CommandPerform) 
-	-> (Key -> CommandPerform)
+startUnused
+	:: (Int -> Key -> CommandStart)
+	-> (Int -> Key -> CommandStart) 
+	-> (Int -> Key -> CommandStart)
 	-> UnusedMaps -> Int -> CommandStart
-startUnused message unused badunused tmpunused maps n = search
+startUnused unused badunused tmpunused maps n = search
 	[ (unusedMap maps, unused)
 	, (unusedBadMap maps, badunused)
 	, (unusedTmpMap maps, tmpunused)
@@ -336,7 +342,4 @@
 	search ((m, a):rest) =
 		case M.lookup n m of
 			Nothing -> search rest
-			Just key -> starting message
-				(ActionItemOther $ Just $ show n)
-				(SeekInput [])
-				(a key)
+			Just key -> a n key
diff --git a/Command/Upgrade.hs b/Command/Upgrade.hs
--- a/Command/Upgrade.hs
+++ b/Command/Upgrade.hs
@@ -20,6 +20,7 @@
 	repoExists $
 	-- avoid upgrading repo out from under daemon
 	noDaemonRunning $
+	withAnnexOptions [jsonOptions] $
 	command "upgrade" SectionMaintenance "upgrade repository"
 		paramNothing (seek <$$> optParser)
 
diff --git a/Command/VAdd.hs b/Command/VAdd.hs
--- a/Command/VAdd.hs
+++ b/Command/VAdd.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.VAdd where
 
 import Command
diff --git a/Command/VCycle.hs b/Command/VCycle.hs
--- a/Command/VCycle.hs
+++ b/Command/VCycle.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.VCycle where
 
 import Command
diff --git a/Command/VPop.hs b/Command/VPop.hs
--- a/Command/VPop.hs
+++ b/Command/VPop.hs
@@ -48,6 +48,6 @@
 
 	num = fromMaybe 1 $ readish =<< headMaybe ps 
 	
-	ai = ActionItemOther (Just $ show num)
+	ai = ActionItemOther (Just $ UnquotedString $ show num)
 	
 	si = SeekInput ps
diff --git a/Command/Version.hs b/Command/Version.hs
--- a/Command/Version.hs
+++ b/Command/Version.hs
@@ -78,5 +78,6 @@
 	putStr BuildInfo.packageversion
 	hFlush stdout -- no newline, so flush
 
+-- Ignore failure to write so that this command can eg be piped to head.
 vinfo :: String -> String -> IO ()
-vinfo k v = putStrLn $ k ++ ": " ++ v
+vinfo k v = void $ tryIO $ putStrLn $ k ++ ": " ++ v
diff --git a/Command/View.hs b/Command/View.hs
--- a/Command/View.hs
+++ b/Command/View.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.View where
 
 import Command
@@ -118,7 +120,7 @@
 		forM_ l (removeemptydir top)
 		liftIO $ void cleanup
 		unlessM (liftIO $ doesDirectoryExist here) $ do
-			showLongNote (cwdmissing (fromRawFilePath top))
+			showLongNote $ UnquotedString $ cwdmissing (fromRawFilePath top)
 	return ok
   where
 	removeemptydir top d = do
diff --git a/Command/Wanted.hs b/Command/Wanted.hs
--- a/Command/Wanted.hs
+++ b/Command/Wanted.hs
@@ -11,6 +11,7 @@
 import qualified Remote
 import Logs.PreferredContent
 import Types.StandardGroups
+import Utility.SafeOutput
 
 import qualified Data.Map as M
 
@@ -39,7 +40,7 @@
 	start ps@(rname:expr:[]) = do
 		u <- Remote.nameToUUID rname
 		let si = SeekInput ps
-		let ai = ActionItemOther (Just rname)
+		let ai = ActionItemOther (Just (UnquotedString rname))
 		startingUsualMessages name ai si $
 			performSet setter expr u
 	start _ = giveup "Specify a repository."
@@ -47,7 +48,7 @@
 performGet :: Ord a => Annex (M.Map a PreferredContentExpression) -> a -> CommandPerform
 performGet getter a = do
 	m <- getter
-	liftIO $ putStrLn $ fromMaybe "" $ M.lookup a m
+	liftIO $ putStrLn $ safeOutput $ fromMaybe "" $ M.lookup a m
 	next $ return True
 
 performSet :: (a -> PreferredContentExpression -> Annex ()) -> String -> a -> CommandPerform
diff --git a/Command/Watch.hs b/Command/Watch.hs
--- a/Command/Watch.hs
+++ b/Command/Watch.hs
@@ -14,7 +14,7 @@
 cmd :: Command
 cmd = notBareRepo $
 	command "watch" SectionCommon 
-		"watch for changes and autocommit"
+		"daemon to watch for changes and autocommit"
 		paramNothing (seek <$$> const (parseDaemonOptions True))
 
 seek :: DaemonOptions -> CommandSeek
diff --git a/Command/WhereUsed.hs b/Command/WhereUsed.hs
--- a/Command/WhereUsed.hs
+++ b/Command/WhereUsed.hs
@@ -5,13 +5,14 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Command.WhereUsed where
 
 import Command
 import Git
 import Git.Sha
 import Git.FilePath
-import qualified Git.Ref
 import qualified Git.Command
 import qualified Git.DiffTree as DiffTree
 import qualified Annex
@@ -21,6 +22,7 @@
 
 import Data.Char
 import qualified Data.ByteString as S
+import qualified Data.ByteString.Char8 as S8
 import qualified Data.ByteString.Lazy as L
 
 cmd :: Command
@@ -55,10 +57,11 @@
 
 start :: WhereUsedOptions -> (SeekInput, Key, ActionItem) -> CommandStart
 start o (_, key, _) = startingCustomOutput key $ do
-	fs <- filterM stillassociated 
+	fs <- filterM stillassociated
+		=<< mapM (liftIO . relPathCwdToFile)
 		=<< mapM (fromRepo . fromTopFilePath)
 		=<< getAssociatedFiles key
-	liftIO $ forM_ fs $ display key . fromRawFilePath
+	forM_ fs $ display key . QuotedPath
 
 	when (historicalOption o && null fs) $
 		findHistorical key
@@ -71,8 +74,11 @@
 		Just k | k == key -> return True
 		_ -> return False
 
-display :: Key -> String -> IO ()
-display key loc = putStrLn (serializeKey key ++ " " ++ loc)
+display :: Key -> StringContainingQuotedPath -> Annex ()
+display key loc = do
+	qp <- coreQuotePath <$> Annex.getGitConfig
+	liftIO $ S8.putStrLn $ quote qp $
+		UnquotedByteString (serializeKey' key) <> " " <> loc
 
 findHistorical :: Key -> Annex ()
 findHistorical key = do
@@ -89,15 +95,15 @@
 		, Param "--tags=*"
 		-- Output the commit hash
 		, Param "--pretty=%H"
-		] $ \h fs repo -> do
-			commitsha <- getSha "log" (pure h)
+		] $ \h fs -> do
+			commitsha <- liftIO $ getSha "log" (pure h)
 			commitdesc <- S.takeWhile (/= fromIntegral (ord '\n'))
-				<$> Git.Command.pipeReadStrict
+				<$> inRepo (Git.Command.pipeReadStrict
 					[ Param "describe"
 					, Param "--contains"
 					, Param "--all"
 					, Param (fromRef commitsha)
-					] repo
+					])
 			if S.null commitdesc
 				then return False
 				else process fs $
@@ -108,27 +114,28 @@
 			[ Param "--walk-reflogs"
 			-- Output the reflog selector
 			, Param "--pretty=%gd"
-			] $ \h fs _ -> process fs $
+			] $ \h fs -> process fs $
 				displayreffile (Ref h)
   where
 	process fs a = or <$> forM fs a
 
 	displayreffile r f = do
-		let fref = Git.Ref.branchFileRef r f
-		display key (fromRef fref)
+		tf <- inRepo $ toTopFilePath f
+		display key (descBranchFilePath (BranchFilePath r tf))
 		return True
 
-searchLog :: Key -> [CommandParam] -> (S.ByteString -> [RawFilePath] -> Repo -> IO Bool) -> Annex Bool
-searchLog key ps a = Annex.inRepo $ \repo -> do
-	(output, cleanup) <- Git.Command.pipeNullSplit ps' repo
+searchLog :: Key -> [CommandParam] -> (S.ByteString -> [RawFilePath] -> Annex Bool) -> Annex Bool
+searchLog key ps a = do
+	(output, cleanup) <- Annex.inRepo $ Git.Command.pipeNullSplit ps'
 	found <- case output of
 		(h:rest) -> do
 			let diff = DiffTree.parseDiffRaw rest
+			repo <- Annex.gitRepo
 			let fs = map (flip fromTopFilePath repo . DiffTree.file) diff
-			rfs <- mapM relPathCwdToFile fs
-			a (L.toStrict h) rfs repo
+			rfs <- liftIO $ mapM relPathCwdToFile fs
+			a (L.toStrict h) rfs
 		_ -> return False
-	void cleanup
+	liftIO $ void cleanup
 	return found
   where
 	ps' = 
diff --git a/Command/Whereis.hs b/Command/Whereis.hs
--- a/Command/Whereis.hs
+++ b/Command/Whereis.hs
@@ -17,7 +17,6 @@
 import Annex.UUID
 import qualified Utility.Format
 import qualified Command.Find
-import Types.ActionItem
 
 import qualified Data.Map as M
 import qualified Data.Vector as V
@@ -65,7 +64,7 @@
 		Batch fmt -> batchOnly (keyOptions o) (whereisFiles o) $
 			batchAnnexed fmt seeker (startKeys o m)
   where
-	ww = WarnUnmatchLsFiles
+	ww = WarnUnmatchLsFiles "whereis"
 
 start :: WhereisOptions -> M.Map UUID Remote -> SeekInput -> RawFilePath -> Key -> CommandStart
 start o remotemap si file key = 
@@ -88,12 +87,14 @@
 	case formatOption o of
 		Nothing -> do
 			let num = length safelocations
-			showNote $ show num ++ " " ++ copiesplural num
+			showNote $ UnquotedString $ show num ++ " " ++ copiesplural num
 			pp <- ppwhereis "whereis" safelocations urls
-			unless (null safelocations) $ showLongNote pp
+			unless (null safelocations) $
+				showLongNote (UnquotedString pp)
 			pp' <- ppwhereis "untrusted" untrustedlocations urls
-			unless (null untrustedlocations) $ showLongNote $ untrustedheader ++ pp'
-		
+			unless (null untrustedlocations) $
+				showLongNote $ UnquotedString $
+					untrustedheader ++ pp'
 			mapM_ (showRemoteUrls remotemap) urls
 		Just formatter -> liftIO $ do
 			let vs = Command.Find.formatVars key
@@ -143,7 +144,7 @@
 		Just w -> tryNonAsync (w key) >>= \case
 			Right l -> pure l
 			Left e -> do
-				warning $ unwords
+				warning $ UnquotedString $ unwords
 					[ "unable to query remote"
 					, name remote
 					, "for urls:"
@@ -161,6 +162,6 @@
 showRemoteUrls remotemap (uu, us)
 	| null us = noop
 	| otherwise = case M.lookup uu remotemap of
-		Just r -> showLongNote $ 
+		Just r -> showLongNote $ UnquotedString $
 			unlines $ map (\u -> name r ++ ": " ++ u) us 
 		Nothing -> noop
diff --git a/Creds.hs b/Creds.hs
--- a/Creds.hs
+++ b/Creds.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Creds (
 	module Types.Creds,
 	CredPairStorage(..),
@@ -150,13 +152,13 @@
 			writeCacheCredPair credpair storage
 
 			return $ Just credpair
-		_ -> error "bad creds"
+		_ -> giveup "bad creds"
 
 getRemoteCredPairFor :: String -> ParsedRemoteConfig -> RemoteGitConfig -> CredPairStorage -> Annex (Maybe CredPair)
 getRemoteCredPairFor this c gc storage = go =<< getRemoteCredPair c gc storage
   where
 	go Nothing = do
-		warning $ missingCredPairFor this storage
+		warning $ UnquotedString $ missingCredPairFor this storage
 		return Nothing
 	go (Just credpair) = return $ Just credpair
 
diff --git a/Crypto.hs b/Crypto.hs
--- a/Crypto.hs
+++ b/Crypto.hs
@@ -73,7 +73,7 @@
 
 cipherPassphrase :: Cipher -> String
 cipherPassphrase (Cipher c) = drop cipherBeginning c
-cipherPassphrase (MacOnlyCipher _) = error "MAC-only cipher"
+cipherPassphrase (MacOnlyCipher _) = giveup "MAC-only cipher"
 
 cipherMac :: Cipher -> String
 cipherMac (Cipher c) = take cipherBeginning c
diff --git a/Database/Benchmark.hs b/Database/Benchmark.hs
--- a/Database/Benchmark.hs
+++ b/Database/Benchmark.hs
@@ -44,7 +44,7 @@
 			]
 		]
 #else
-benchmarkDbs _ = error "not built with criterion, cannot benchmark"
+benchmarkDbs _ = giveup "not built with criterion, cannot benchmark"
 #endif
 
 #ifdef WITH_BENCHMARK
diff --git a/Database/ContentIdentifier.hs b/Database/ContentIdentifier.hs
--- a/Database/ContentIdentifier.hs
+++ b/Database/ContentIdentifier.hs
@@ -1,6 +1,6 @@
 {- Sqlite database of ContentIdentifiers imported from special remotes.
  -
- - Copyright 2019 Joey Hess <id@joeyh.name>
+ - Copyright 2019-2023 Joey Hess <id@joeyh.name>
  -:
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -19,6 +19,7 @@
 
 module Database.ContentIdentifier (
 	ContentIdentifierHandle,
+	databaseIsEmpty,
 	openDb,
 	closeDb,
 	flushDbQueue,
@@ -57,14 +58,29 @@
 import qualified System.FilePath.ByteString as P
 import qualified Data.Text as T
 
-data ContentIdentifierHandle = ContentIdentifierHandle H.DbQueue
+data ContentIdentifierHandle = ContentIdentifierHandle H.DbQueue Bool
 
+databaseIsEmpty :: ContentIdentifierHandle -> Bool
+databaseIsEmpty (ContentIdentifierHandle _ b) = b
+
+-- Note on indexes: ContentIndentifiersKeyRemoteCidIndex etc are really
+-- uniqueness constraints, which cause sqlite to automatically add indexes.
+-- So when adding indexes, have to take care to only add ones that work as
+-- uniqueness constraints. (Unfortunately persistent does not support indexes
+-- that are not uniqueness constraints; 
+-- https://github.com/yesodweb/persistent/issues/109)
+-- 
+-- ContentIndentifiersKeyRemoteCidIndex speeds up queries like 
+-- getContentIdentifiers, but it is not used for
+-- getContentIdentifierKeys. ContentIndentifiersCidRemoteKeyIndex was
+-- addedto speed that up.
 share [mkPersist sqlSettings, mkMigrate "migrateContentIdentifier"] [persistLowerCase|
 ContentIdentifiers
   remote UUID
   cid ContentIdentifier
   key Key
   ContentIndentifiersKeyRemoteCidIndex key remote cid
+  ContentIndentifiersCidRemoteKeyIndex cid remote key
 -- The last git-annex branch tree sha that was used to update
 -- ContentIdentifiers
 AnnexBranch
@@ -81,23 +97,22 @@
 openDb = do
 	dbdir <- calcRepo' gitAnnexContentIdentifierDbDir
 	let db = dbdir P.</> "db"
-	ifM (liftIO $ not <$> R.doesPathExist db)
-		( initDb db $ void $ 
+	isnew <- liftIO $ not <$> R.doesPathExist db
+	if isnew
+		then initDb db $ void $ 
 			runMigrationSilent migrateContentIdentifier
-		-- Migrate from old version of database, which had
-		-- an incorrect uniqueness constraint on the
-		-- ContentIdentifiers table.
-		, liftIO $ runSqlite (T.pack (fromRawFilePath db)) $ void $
+		-- Migrate from old versions of database, which had buggy
+		-- and suboptimal uniqueness constraints.
+		else liftIO $ runSqlite (T.pack (fromRawFilePath db)) $ void $
 			runMigrationSilent migrateContentIdentifier
-		)
 	h <- liftIO $ H.openDbQueue db "content_identifiers"
-	return $ ContentIdentifierHandle h
+	return $ ContentIdentifierHandle h isnew
 
 closeDb :: ContentIdentifierHandle -> Annex ()
-closeDb (ContentIdentifierHandle h) = liftIO $ H.closeDbQueue h
+closeDb (ContentIdentifierHandle h _) = liftIO $ H.closeDbQueue h
 
 queueDb :: ContentIdentifierHandle -> SqlPersistM () -> IO ()
-queueDb (ContentIdentifierHandle h) = H.queueDb h checkcommit
+queueDb (ContentIdentifierHandle h _) = H.queueDb h checkcommit
   where
 	-- commit queue after 1000 changes
 	checkcommit sz _lastcommittime
@@ -105,7 +120,7 @@
 		| otherwise = return False
 
 flushDbQueue :: ContentIdentifierHandle -> IO ()
-flushDbQueue (ContentIdentifierHandle h) = H.flushDbQueue h
+flushDbQueue (ContentIdentifierHandle h _) = H.flushDbQueue h
 
 -- Be sure to also update the git-annex branch when using this.
 recordContentIdentifier :: ContentIdentifierHandle -> RemoteStateHandle -> ContentIdentifier -> Key -> IO ()
@@ -113,7 +128,7 @@
 	void $ insertUniqueFast $ ContentIdentifiers u cid k
 
 getContentIdentifiers :: ContentIdentifierHandle -> RemoteStateHandle -> Key -> IO [ContentIdentifier]
-getContentIdentifiers (ContentIdentifierHandle h) (RemoteStateHandle u) k = 
+getContentIdentifiers (ContentIdentifierHandle h _) (RemoteStateHandle u) k = 
 	H.queryDbQueue h $ do
 		l <- selectList
 			[ ContentIdentifiersKey ==. k
@@ -122,7 +137,7 @@
 		return $ map (contentIdentifiersCid . entityVal) l
 
 getContentIdentifierKeys :: ContentIdentifierHandle -> RemoteStateHandle -> ContentIdentifier -> IO [Key]
-getContentIdentifierKeys (ContentIdentifierHandle h) (RemoteStateHandle u) cid = 
+getContentIdentifierKeys (ContentIdentifierHandle h _) (RemoteStateHandle u) cid = 
 	H.queryDbQueue h $ do
 		l <- selectList
 			[ ContentIdentifiersCid ==. cid
@@ -132,15 +147,15 @@
 
 recordAnnexBranchTree :: ContentIdentifierHandle -> Sha -> IO ()
 recordAnnexBranchTree h s = queueDb h $ do
-        deleteWhere ([] :: [Filter AnnexBranch])
-        void $ insertUniqueFast $ AnnexBranch $ toSSha s
+	deleteWhere ([] :: [Filter AnnexBranch])
+	void $ insertUniqueFast $ AnnexBranch $ toSSha s
 
 getAnnexBranchTree :: ContentIdentifierHandle -> IO Sha
-getAnnexBranchTree (ContentIdentifierHandle h) = H.queryDbQueue h $ do
-        l <- selectList ([] :: [Filter AnnexBranch]) []
-        case l of
-                (s:[]) -> return $ fromSSha $ annexBranchTree $ entityVal s
-                _ -> return emptyTree
+getAnnexBranchTree (ContentIdentifierHandle h _) = H.queryDbQueue h $ do
+	l <- selectList ([] :: [Filter AnnexBranch]) []
+	case l of
+		(s:[]) -> return $ fromSSha $ annexBranchTree $ entityVal s
+		_ -> return emptyTree
 
 {- Check if the git-annex branch has been updated and the database needs
  - to be updated with any new content identifiers in it. -}
@@ -153,8 +168,8 @@
 		_ -> return Nothing
 
 {- The database should be locked for write when calling this. -}
-updateFromLog :: ContentIdentifierHandle -> (Sha, Sha) -> Annex ()
-updateFromLog db (oldtree, currtree) = do
+updateFromLog :: ContentIdentifierHandle -> (Sha, Sha) -> Annex ContentIdentifierHandle
+updateFromLog db@(ContentIdentifierHandle h _) (oldtree, currtree) = do
 	(l, cleanup) <- inRepo $
 		DiffTree.diffTreeRecursive oldtree currtree
 	mapM_ go l
@@ -162,6 +177,7 @@
 	liftIO $ do
 		recordAnnexBranchTree db currtree
 		flushDbQueue db
+	return (ContentIdentifierHandle h False)
   where
 	go ti = case extLogFileKey remoteContentIdentifierExt (getTopFilePath (DiffTree.file ti)) of
 		Nothing -> return ()
diff --git a/Database/Handle.hs b/Database/Handle.hs
--- a/Database/Handle.hs
+++ b/Database/Handle.hs
@@ -84,7 +84,7 @@
 		Right r -> either throwIO return r
 		Left BlockedIndefinitelyOnMVar -> do
 			err <- takeMVar errvar
-			error $ "sqlite worker thread crashed: " ++ err
+			giveup $ "sqlite worker thread crashed: " ++ err
 
 {- Writes a change to the database.
  -
@@ -96,32 +96,36 @@
  - process at least once each 30 seconds.
  -}
 commitDb :: DbHandle -> SqlPersistM () -> IO ()
-commitDb h@(DbHandle db _ _ _) wa = 
+commitDb h@(DbHandle db _ _ errvar) wa = 
 	robustly (commitDb' h wa) maxretries emptyDatabaseInodeCache
   where
 	robustly a retries ic = do
 		r <- a
 		case r of
-			Right _ -> return ()
-			Left err -> do
+			Right (Right _) -> return ()
+			Right (Left err) -> do
 				threadDelay briefdelay
 				retryHelper "write to" err maxretries db retries ic $ 
 					robustly a
+			Left BlockedIndefinitelyOnMVar -> do
+				err <- takeMVar errvar
+				giveup $ "sqlite worker thread crashed: " ++ err
 	
 	briefdelay = 100000 -- 1/10th second
 
 	maxretries = 300 :: Int -- 30 seconds of briefdelay
 
-commitDb' :: DbHandle -> SqlPersistM () -> IO (Either SomeException ())
+commitDb' :: DbHandle -> SqlPersistM () -> IO (Either BlockedIndefinitelyOnMVar (Either SomeException ()))
 commitDb' (DbHandle _ _ jobs _) a = do
 	debug "Database.Handle" "commitDb start"
 	res <- newEmptyMVar
 	putMVar jobs $ ChangeJob $
 		debugLocks $ liftIO . putMVar res =<< tryNonAsync a
-	r <- debugLocks $ takeMVar res
+	r <- debugLocks $ takeMVarSafe res
 	case r of
-		Right () -> debug "Database.Handle" "commitDb done"
-		Left e -> debug "Database.Handle" ("commitDb failed: " ++ show e)
+		Right (Right ()) -> debug "Database.Handle" "commitDb done"
+		Right (Left e) -> debug "Database.Handle" ("commitDb failed: " ++ show e)
+		Left BlockedIndefinitelyOnMVar -> debug "Database.Handle" ("commitDb BlockedIndefinitelyOnMVar")
 
 	return r
 
diff --git a/Database/Init.hs b/Database/Init.hs
--- a/Database/Init.hs
+++ b/Database/Init.hs
@@ -10,10 +10,8 @@
 module Database.Init where
 
 import Annex.Common
-import qualified Annex
 import Annex.Perms
 import Utility.FileMode
-import Utility.Directory.Create
 import qualified Utility.RawFilePath as R
 
 import Database.Persist.Sqlite
@@ -35,14 +33,8 @@
 	let tmpdbdir = dbdir <> ".tmp"
 	let tmpdb = tmpdbdir P.</> "db"
 	let tdb = T.pack (fromRawFilePath tmpdb)
-	gc <- Annex.getGitConfig
-	top <- parentDir <$> fromRepo gitAnnexDir
-	let tops = case annexDbDir gc of
-		Just topdbdir -> [top, parentDir (parentDir topdbdir)]
-		Nothing -> [top]
-	liftIO $ do
-		createDirectoryUnder tops tmpdbdir
-		runSqliteInfo (enableWAL tdb) migration
+	createAnnexDirectory tmpdbdir
+	liftIO $ runSqliteInfo (enableWAL tdb) migration
 	setAnnexDirPerm tmpdbdir
 	-- Work around sqlite bug that prevents it from honoring
 	-- less restrictive umasks.
diff --git a/Git.hs b/Git.hs
--- a/Git.hs
+++ b/Git.hs
@@ -68,7 +68,7 @@
 repoLocation Repo { location = Local { worktree = Just dir } } = fromRawFilePath dir
 repoLocation Repo { location = Local { gitdir = dir } } = fromRawFilePath dir
 repoLocation Repo { location = LocalUnknown dir } = fromRawFilePath dir
-repoLocation Repo { location = Unknown } = error "unknown repoLocation"
+repoLocation Repo { location = Unknown } = giveup "unknown repoLocation"
 
 {- Path to a repository. For non-bare, this is the worktree, for bare, 
  - it's the gitdir, and for URL repositories, is the path on the remote
@@ -78,8 +78,8 @@
 repoPath Repo { location = Local { worktree = Just d } } = d
 repoPath Repo { location = Local { gitdir = d } } = d
 repoPath Repo { location = LocalUnknown dir } = dir
-repoPath Repo { location = Unknown } = error "unknown repoPath"
-repoPath Repo { location = UnparseableUrl _u } = error "unknown repoPath"
+repoPath Repo { location = Unknown } = giveup "unknown repoPath"
+repoPath Repo { location = UnparseableUrl _u } = giveup "unknown repoPath"
 
 repoWorkTree :: Repo -> Maybe RawFilePath
 repoWorkTree Repo { location = Local { worktree = Just d } } = Just d
@@ -88,7 +88,7 @@
 {- Path to a local repository's .git directory. -}
 localGitDir :: Repo -> RawFilePath
 localGitDir Repo { location = Local { gitdir = d } } = d
-localGitDir _ = error "unknown localGitDir"
+localGitDir _ = giveup "unknown localGitDir"
 
 {- Some code needs to vary between URL and normal repos,
  - or bare and non-bare, these functions help with that. -}
@@ -129,7 +129,7 @@
 
 assertLocal :: Repo -> a -> a
 assertLocal repo action
-	| repoIsUrl repo = error $ unwords
+	| repoIsUrl repo = giveup $ unwords
 		[ "acting on non-local git repo"
 		, repoDescribe repo
 		, "not supported"
diff --git a/Git/CatFile.hs b/Git/CatFile.hs
--- a/Git/CatFile.hs
+++ b/Git/CatFile.hs
@@ -120,7 +120,7 @@
 			content <- readObjectContent from r
 			return $ Just (content, sha, objtype)
 		Just DNE -> return Nothing
-		Nothing -> error $ "unknown response from git cat-file " ++ show (header, object)
+		Nothing -> giveup $ "unknown response from git cat-file " ++ show (header, object)
   where
 	-- Slow fallback path for filenames containing newlines.
 	newlinefallback = queryObjectType object (catFileGitRepo h) >>= \case
@@ -144,7 +144,7 @@
 	eatchar expected = do
 		c <- hGetChar h
 		when (c /= expected) $
-			error $ "missing " ++ (show expected) ++ " from git cat-file"
+			giveup $ "missing " ++ (show expected) ++ " from git cat-file"
 readObjectContent _ DNE = error "internal"
 
 {- Gets the size and type of an object, without reading its content. -}
diff --git a/Git/CheckAttr.hs b/Git/CheckAttr.hs
--- a/Git/CheckAttr.hs
+++ b/Git/CheckAttr.hs
@@ -54,7 +54,7 @@
 	getvals l (x:xs) = case map snd $ filter (\(attr, _) -> attr == x) l of
 			["unspecified"] -> "" : getvals l xs
 			[v] -> v : getvals l xs
-			_ -> error $ "unable to determine " ++ x ++ " attribute of " ++ fromRawFilePath file
+			_ -> giveup $ "unable to determine " ++ x ++ " attribute of " ++ fromRawFilePath file
 
 	send to = B.hPutStr to $ file' `B.snoc` 0
 	receive c from = do
diff --git a/Git/Config.hs b/Git/Config.hs
--- a/Git/Config.hs
+++ b/Git/Config.hs
@@ -104,19 +104,23 @@
 hRead :: Repo -> ConfigStyle -> Handle -> IO Repo
 hRead repo st h = do
 	val <- S.hGetContents h
-	store val st repo
+	let c = parse val st
+	debug (DebugSource "Git.Config") $ "git config read: " ++
+		show (map (\(k, v) -> (show k, map show v)) (M.toList c))
+	storeParsed c repo
 
 {- Stores a git config into a Repo, returning the new version of the Repo.
  - The git config may be multiple lines, or a single line.
  - Config settings can be updated incrementally.
  -}
 store :: S.ByteString -> ConfigStyle -> Repo -> IO Repo
-store s st repo = do
-	let c = parse s st
-	updateLocation $ repo
-		{ config = (M.map Prelude.head c) `M.union` config repo
-		, fullconfig = M.unionWith (++) c (fullconfig repo)
-		}
+store s st = storeParsed (parse s st)
+
+storeParsed :: M.Map ConfigKey [ConfigValue] -> Repo -> IO Repo
+storeParsed c repo = updateLocation $ repo
+	{ config = (M.map Prelude.head c) `M.union` config repo
+	, fullconfig = M.unionWith (++) c (fullconfig repo)
+	}
 
 {- Stores a single config setting in a Repo, returning the new version of
  - the Repo. Config settings can be updated incrementally. -}
diff --git a/Git/ConfigTypes.hs b/Git/ConfigTypes.hs
--- a/Git/ConfigTypes.hs
+++ b/Git/ConfigTypes.hs
@@ -1,6 +1,6 @@
 {- git config types
  -
- - Copyright 2012, 2017 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -10,14 +10,16 @@
 module Git.ConfigTypes where
 
 import Data.Char
+import Numeric
 import qualified Data.ByteString.Char8 as S8
+import System.PosixCompat.Types
 
 import Common
 import Git
 import Git.Types
 import qualified Git.Config
 
-data SharedRepository = UnShared | GroupShared | AllShared | UmaskShared Int
+data SharedRepository = UnShared | GroupShared | AllShared | UmaskShared FileMode
 	deriving (Eq)
 
 getSharedRepository :: Repo -> SharedRepository
@@ -31,7 +33,8 @@
 			"all" -> AllShared
 			"world" -> AllShared
 			"everybody" -> AllShared
-			_ -> maybe UnShared UmaskShared (readish (decodeBS v))
+			_ -> maybe UnShared UmaskShared 
+				(fmap fst $ headMaybe $ readOct $ decodeBS v)
 		Just NoConfigValue -> UnShared
 		Nothing -> UnShared
 
diff --git a/Git/Construct.hs b/Git/Construct.hs
--- a/Git/Construct.hs
+++ b/Git/Construct.hs
@@ -86,7 +86,7 @@
 fromAbsPath dir
 	| absoluteGitPath dir = fromPath dir
 	| otherwise =
-		error $ "internal error, " ++ show dir ++ " is not absolute"
+		giveup $ "internal error, " ++ show dir ++ " is not absolute"
 
 {- Construct a Repo for a remote's url.
  -
diff --git a/Git/DiffTree.hs b/Git/DiffTree.hs
--- a/Git/DiffTree.hs
+++ b/Git/DiffTree.hs
@@ -29,7 +29,7 @@
 import Git.Command
 import Git.FilePath
 import Git.DiffTreeItem
-import qualified Git.Filename
+import qualified Git.Quote
 import qualified Git.Ref
 import Utility.Attoparsec
 
@@ -113,8 +113,8 @@
 	go [] = []
 	go (info:f:rest) = case A.parse (parserDiffRaw (L.toStrict f)) info of
 		A.Done _ r -> r : go rest
-		A.Fail _ _ err -> error $ "diff-tree parse error: " ++ err
-	go (s:[]) = error $ "diff-tree parse error near \"" ++ decodeBL s ++ "\""
+		A.Fail _ _ err -> giveup $ "diff-tree parse error: " ++ err
+	go (s:[]) = giveup $ "diff-tree parse error near \"" ++ decodeBL s ++ "\""
 
 -- :<srcmode> SP <dstmode> SP <srcsha> SP <dstsha> SP <status>
 --
@@ -133,6 +133,6 @@
 	<*> (maybe (fail "bad dstsha") return . extractSha =<< nextword)
 	<* A8.char ' '
 	<*> A.takeByteString
-	<*> pure (asTopFilePath $ fromInternalGitPath $ Git.Filename.decode f)
+	<*> pure (asTopFilePath $ fromInternalGitPath $ Git.Quote.unquote f)
   where
 	nextword = A8.takeTill (== ' ')
diff --git a/Git/FilePath.hs b/Git/FilePath.hs
--- a/Git/FilePath.hs
+++ b/Git/FilePath.hs
@@ -5,7 +5,7 @@
  - top of the repository even when run in a subdirectory. Adding some
  - types helps keep that straight.
  -
- - Copyright 2012-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -30,12 +30,12 @@
 
 import Common
 import Git
+import Git.Quote
 
 import qualified System.FilePath.ByteString as P
 import qualified System.FilePath.Posix.ByteString
 import GHC.Generics
 import Control.DeepSeq
-import qualified Data.ByteString as S
 
 {- A RawFilePath, relative to the top of the git repository. -}
 newtype TopFilePath = TopFilePath { getTopFilePath :: RawFilePath }
@@ -46,11 +46,11 @@
 {- A file in a branch or other treeish. -}
 data BranchFilePath = BranchFilePath Ref TopFilePath
 	deriving (Show, Eq, Ord)
-
+ 
 {- Git uses the branch:file form to refer to a BranchFilePath -}
-descBranchFilePath :: BranchFilePath -> S.ByteString
+descBranchFilePath :: BranchFilePath -> StringContainingQuotedPath
 descBranchFilePath (BranchFilePath b f) =
-	fromRef' b <> ":" <> getTopFilePath f
+	UnquotedByteString (fromRef' b) <> ":" <> QuotedPath (getTopFilePath f)
 
 {- Path to a TopFilePath, within the provided git repo. -}
 fromTopFilePath :: TopFilePath -> Git.Repo -> RawFilePath
diff --git a/Git/Filename.hs b/Git/Filename.hs
deleted file mode 100644
--- a/Git/Filename.hs
+++ /dev/null
@@ -1,49 +0,0 @@
-{- Some git commands output encoded filenames, in a rather annoyingly complex
- - C-style encoding.
- -
- - Copyright 2010, 2011 Joey Hess <id@joeyh.name>
- -
- - Licensed under the GNU AGPL version 3 or higher.
- -}
-
-module Git.Filename where
-
-import Common
-import Utility.Format (decode_c, encode_c)
-import Utility.QuickCheck
-
-import Data.Char
-import Data.Word
-import qualified Data.ByteString as S
-
--- encoded filenames will be inside double quotes
-decode :: S.ByteString -> RawFilePath
-decode b = case S.uncons b of
-	Nothing -> b
-	Just (h, t)
-		| h /= q -> b
-		| otherwise -> case S.unsnoc t of
-			Nothing -> b
-			Just (i, l)
-				| l /= q -> b
-				| otherwise ->
-					encodeBS $ decode_c $ decodeBS i
-  where
-  	q :: Word8
-	q = fromIntegral (ord '"')
-
-{- Should not need to use this, except for testing decode. -}
-encode :: RawFilePath -> S.ByteString
-encode s = encodeBS $ "\"" ++ encode_c (decodeBS s) ++ "\""
-
--- Encoding and then decoding roundtrips only when the string does not
--- contain high unicode, because eg,  both "\12345" and "\227\128\185"
--- are encoded to "\343\200\271".
---
--- That is not a real-world problem, and using TestableFilePath
--- limits what's tested to ascii, so avoids running into it.
-prop_encode_decode_roundtrip :: TestableFilePath -> Bool
-prop_encode_decode_roundtrip ts = 
-	s == fromRawFilePath (decode (encode (toRawFilePath s)))
-  where
-	s = fromTestableFilePath ts
diff --git a/Git/LsTree.hs b/Git/LsTree.hs
--- a/Git/LsTree.hs
+++ b/Git/LsTree.hs
@@ -23,7 +23,7 @@
 import Git
 import Git.Command
 import Git.FilePath
-import qualified Git.Filename
+import qualified Git.Quote
 import Utility.Attoparsec
 
 import Numeric
@@ -137,7 +137,7 @@
 		-- sha
 		<*> (Ref <$> A8.takeTill A8.isSpace)
 
-	fileparser = asTopFilePath . Git.Filename.decode <$> A.takeByteString
+	fileparser = asTopFilePath . Git.Quote.unquote <$> A.takeByteString
 
 	sizeparser = fmap Just A8.decimal
 
diff --git a/Git/PktLine.hs b/Git/PktLine.hs
--- a/Git/PktLine.hs
+++ b/Git/PktLine.hs
@@ -31,6 +31,7 @@
 
 import Utility.PartialPrelude
 import Utility.FileSystemEncoding
+import Utility.Exception
 
 {- This is a variable length binary string, but its size is limited to
  - maxPktLineLength. Its serialization includes a 4 byte hexadecimal
@@ -96,7 +97,7 @@
 stringPktLine :: String -> PktLine
 stringPktLine s
 	| length s > maxPktLineLength =
-		error "textPktLine called with too-long value"
+		giveup "textPktLine called with too-long value"
 	| otherwise = PktLine (encodeBS s <> "\n")
 
 {- Sends a PktLine to a Handle, and flushes it so that it will be
diff --git a/Git/Quote.hs b/Git/Quote.hs
new file mode 100644
--- /dev/null
+++ b/Git/Quote.hs
@@ -0,0 +1,122 @@
+{- Some git commands output quoted filenames, in a rather annoyingly complex
+ - C-style encoding.
+ -
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+{-# LANGUAGE OverloadedStrings, TypeSynonymInstances #-}
+
+module Git.Quote (
+	unquote,
+	quote,
+	noquote,
+	QuotePath(..),
+	StringContainingQuotedPath(..),
+	quotedPaths,
+	prop_quote_unquote_roundtrip,
+) where
+
+import Common
+import Utility.Format (decode_c, encode_c, encode_c', isUtf8Byte)
+import Utility.QuickCheck
+import Utility.SafeOutput
+
+import Data.Char
+import Data.Word
+import Data.String
+import qualified Data.ByteString as S
+import qualified Data.Semigroup as Sem
+import Prelude
+
+unquote :: S.ByteString -> RawFilePath
+unquote b = case S.uncons b of
+	Nothing -> b
+	Just (h, t)
+		| h /= q -> b
+		| otherwise -> case S.unsnoc t of
+			Nothing -> b
+			Just (i, l)
+				| l /= q -> b
+				| otherwise -> decode_c i
+  where
+  	q :: Word8
+	q = fromIntegral (ord '"')
+
+-- always encodes and double quotes, even in cases that git does not
+quoteAlways :: RawFilePath -> S.ByteString
+quoteAlways s = "\"" <> encode_c needencode s <> "\""
+  where
+	needencode c = isUtf8Byte c || c == fromIntegral (ord '"')
+
+-- git config core.quotePath controls whether to quote unicode characters
+newtype QuotePath = QuotePath Bool
+
+class Quoteable t where
+	-- double quotes and encodes when git would
+	quote :: QuotePath -> t -> S.ByteString
+
+	noquote :: t -> S.ByteString
+
+instance Quoteable RawFilePath where
+	quote (QuotePath qp) s = case encode_c' needencode s of
+		Nothing -> s
+		Just s' -> "\"" <> s' <> "\""
+	  where
+		needencode c
+			| c == fromIntegral (ord '"') = True
+			| qp = isUtf8Byte c
+			| otherwise = False
+
+	noquote = id
+
+-- Allows building up a string that contains paths, which will get quoted.
+-- With OverloadedStrings, strings are passed through without quoting.
+-- Eg: QuotedPath f <> ": not found"
+data StringContainingQuotedPath
+	= UnquotedString String 
+	| UnquotedByteString S.ByteString 
+	| QuotedPath RawFilePath
+	| StringContainingQuotedPath :+: StringContainingQuotedPath
+	deriving (Show, Eq)
+
+quotedPaths :: [RawFilePath] -> StringContainingQuotedPath
+quotedPaths [] = mempty
+quotedPaths (p:ps) = QuotedPath p <> if null ps
+	then mempty
+	else " " <> quotedPaths ps
+
+instance Quoteable StringContainingQuotedPath where
+	quote _ (UnquotedString s) = safeOutput (encodeBS s)
+	quote _ (UnquotedByteString s) = safeOutput s
+	quote qp (QuotedPath p) = quote qp p
+	quote qp (a :+: b) = quote qp a <> quote qp b
+
+	noquote (UnquotedString s) = encodeBS s
+	noquote (UnquotedByteString s) = s
+	noquote (QuotedPath p) = p
+	noquote (a :+: b) = noquote a <> noquote b
+
+instance IsString StringContainingQuotedPath where
+	fromString = UnquotedByteString . encodeBS
+
+instance Sem.Semigroup StringContainingQuotedPath where
+	UnquotedString a <> UnquotedString b = UnquotedString (a <> b)
+	UnquotedByteString a <> UnquotedByteString b = UnquotedByteString (a <> b)
+	a <> b = a :+: b
+
+instance Monoid StringContainingQuotedPath where
+	mempty = UnquotedByteString mempty
+
+-- Encoding and then decoding roundtrips only when the string does not
+-- contain high unicode, because eg, both "\12345" and "\227\128\185"
+-- are encoded to "\343\200\271".
+--
+-- That is not a real-world problem, and using TestableFilePath
+-- limits what's tested to ascii, so avoids running into it.
+prop_quote_unquote_roundtrip :: TestableFilePath -> Bool
+prop_quote_unquote_roundtrip ts = 
+	s == fromRawFilePath (unquote (quoteAlways (toRawFilePath s)))
+  where
+	s = fromTestableFilePath ts
diff --git a/Git/Repair.hs b/Git/Repair.hs
--- a/Git/Repair.hs
+++ b/Git/Repair.hs
@@ -30,6 +30,7 @@
 import Git.Fsck
 import Git.Index
 import Git.Env
+import Git.FilePath
 import qualified Git.Config as Config
 import qualified Git.Construct as Construct
 import qualified Git.LsTree as LsTree
@@ -252,7 +253,8 @@
 getAllRefs' :: FilePath -> IO [Ref]
 getAllRefs' refdir = do
 	let topsegs = length (splitPath refdir) - 1
-	let toref = Ref . encodeBS . joinPath . drop topsegs . splitPath
+	let toref = Ref . toInternalGitPath . encodeBS 
+		. joinPath . drop topsegs . splitPath
 	map toref <$> dirContentsRecursive refdir
 
 explodePackedRefsFile :: Repo -> IO ()
diff --git a/Git/Sha.hs b/Git/Sha.hs
--- a/Git/Sha.hs
+++ b/Git/Sha.hs
@@ -20,7 +20,7 @@
 getSha :: String -> IO S.ByteString -> IO Sha
 getSha subcommand a = maybe bad return =<< extractSha <$> a
   where
-	bad = error $ "failed to read sha from git " ++ subcommand
+	bad = giveup $ "failed to read sha from git " ++ subcommand
 
 {- Extracts the Sha from a ByteString. 
  -
diff --git a/Git/Tree.hs b/Git/Tree.hs
--- a/Git/Tree.hs
+++ b/Git/Tree.hs
@@ -62,7 +62,7 @@
 getTree :: LsTree.LsTreeRecursive -> Ref -> Repo -> IO Tree
 getTree recursive r repo = do
 	(l, cleanup) <- lsTreeWithObjects recursive r repo
-	let !t = either (\e -> error ("ls-tree parse error:" ++ e)) id
+	let !t = either (\e -> giveup ("ls-tree parse error:" ++ e)) id
 		(extractTree l)
 	void cleanup
 	return t
@@ -77,7 +77,7 @@
 withMkTreeHandle repo a = bracketIO setup cleanup (a . MkTreeHandle)
   where
 	setup = gitCoProcessStart False ps repo
-	ps = [Param "mktree", Param "--batch", Param "-z"]
+	ps = [Param "mktree", Param "--missing", Param "--batch", Param "-z"]
 	cleanup = CoProcess.stop
 
 {- Records a Tree in the Repo, returning its Sha.
@@ -254,7 +254,7 @@
 					Just (TreeItem f m s) -> 
 						let commit = TreeCommit f m s
 						in go h wasmodified (commit:c) depth intree is
-			_ -> error ("unexpected object type \"" ++ decodeBS (LsTree.typeobj i) ++ "\"")
+			_ -> giveup ("unexpected object type \"" ++ decodeBS (LsTree.typeobj i) ++ "\"")
 		| otherwise = return (c, wasmodified, i:is)
 
 	adjustlist h depth ishere underhere l = do
diff --git a/Git/UnionMerge.hs b/Git/UnionMerge.hs
--- a/Git/UnionMerge.hs
+++ b/Git/UnionMerge.hs
@@ -78,7 +78,7 @@
 	go [] = noop
 	go (info:file:rest) = mergeFile info file hashhandle ch >>=
 		maybe (go rest) (\l -> streamer l >> go rest)
-	go (_:[]) = error $ "parse error " ++ show differ
+	go (_:[]) = giveup $ "parse error " ++ show differ
 
 {- Given an info line from a git raw diff, and the filename, generates
  - a line suitable for update-index that union merges the two sides of the
diff --git a/Limit.hs b/Limit.hs
--- a/Limit.hs
+++ b/Limit.hs
@@ -87,7 +87,7 @@
 
 {- Adds a new syntax token. -}
 addSyntaxToken :: String -> Annex ()
-addSyntaxToken = either error add . Utility.Matcher.syntaxToken
+addSyntaxToken = either giveup add . Utility.Matcher.syntaxToken
 
 {- Adds a new limit. -}
 addLimit :: Either String (MatchFiles Annex) -> Annex ()
diff --git a/Logs/AdjustedBranchUpdate.hs b/Logs/AdjustedBranchUpdate.hs
new file mode 100644
--- /dev/null
+++ b/Logs/AdjustedBranchUpdate.hs
@@ -0,0 +1,84 @@
+{- git-annex log file that indicates when the adjusted branch needs to be
+ - updated due to changes in content availability.
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module Logs.AdjustedBranchUpdate (
+	recordAdjustedBranchUpdateNeeded,
+	recordAdjustedBranchUpdateFinished,
+	isAdjustedBranchUpdateNeeded,
+) where
+
+import Annex.Common
+import Logs.File
+import Utility.TimeStamp
+
+import qualified Data.ByteString.Lazy as L
+import Data.Time.Clock.POSIX
+
+-- | Updates the log to indicate that an update is needed.
+recordAdjustedBranchUpdateNeeded :: Annex ()
+recordAdjustedBranchUpdateNeeded = do
+	now <- liftIO getPOSIXTime
+	logf <- fromRepo gitAnnexAdjustedBranchUpdateLog
+	lckf <- fromRepo gitAnnexAdjustedBranchUpdateLock
+	-- Replace any other log entries, because an update is needed now,
+	-- so an entry that says an update finished must be in the past.
+	-- And, if there were clock skew, an entry that says an update is
+	-- needed in the future would be wrong information.
+	modifyLogFile logf lckf (const [formatAdjustLog True now])
+
+-- | Called after an update has finished. The time is when the update
+-- started. If recordAdjustedBranchUpdateNeeded was called during the
+-- update, the log is left indicating that an update is still needed.
+recordAdjustedBranchUpdateFinished :: POSIXTime -> Annex ()
+recordAdjustedBranchUpdateFinished starttime = do
+	now <- liftIO getPOSIXTime
+	logf <- fromRepo gitAnnexAdjustedBranchUpdateLog
+	lckf <- fromRepo gitAnnexAdjustedBranchUpdateLock
+	modifyLogFile logf lckf (go now)
+  where
+	go now logged
+		| null $ filter (isnewer now) $ mapMaybe parseAdjustLog logged =
+			[formatAdjustLog False starttime]
+		| otherwise = logged
+	
+	-- If the logged time is in the future, there was clock skew,
+	-- so disregard that log entry.
+	isnewer now (_, loggedtime) = 
+		loggedtime >= starttime && loggedtime <= now
+
+isAdjustedBranchUpdateNeeded :: Annex Bool
+isAdjustedBranchUpdateNeeded = do
+	logf <- fromRepo gitAnnexAdjustedBranchUpdateLog
+	lckf <- fromRepo gitAnnexAdjustedBranchUpdateLock
+	calcLogFile logf lckf Nothing go >>= return . \case
+		Just b -> b
+		-- No log, so assume an update is needed.
+		-- This handles upgrades from before this log was written.
+		Nothing -> True
+  where
+ 	go l p = case parseAdjustLog l of
+		Nothing -> p
+		Just (b, _t) -> case p of
+			Nothing -> Just b
+			Just b' -> Just (b' || b)
+
+formatAdjustLog :: Bool -> POSIXTime -> L.ByteString
+formatAdjustLog b t = encodeBL (show t) <> " " <> if b then "1" else "0"
+
+parseAdjustLog :: L.ByteString -> Maybe (Bool, POSIXTime)
+parseAdjustLog l = 
+	let (ts, bs) = separate (== ' ') (decodeBL l)
+	in do
+		b <- case bs of
+			"1" -> Just True
+			"0" -> Just False
+			_ -> Nothing
+		t <- parsePOSIXTime ts
+		return (b, t)
diff --git a/Logs/Import.hs b/Logs/Import.hs
new file mode 100644
--- /dev/null
+++ b/Logs/Import.hs
@@ -0,0 +1,37 @@
+{- git-annex import logs
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Logs.Import (
+	recordContentIdentifierTree,
+	getContentIdentifierTree
+) where
+
+import Annex.Common
+import Git.Types
+import Git.Sha
+import Logs.File
+
+import qualified Data.ByteString.Lazy as L
+
+{- Records the sha of a tree that contains hashes of ContentIdentifiers
+ - that were imported from a remote. -}
+recordContentIdentifierTree :: UUID -> Sha -> Annex ()
+recordContentIdentifierTree u t = do
+	l <- calcRepo' (gitAnnexImportLog u)
+	writeLogFile l (fromRef t)
+
+{- Gets the tree last recorded for a remote. -}
+getContentIdentifierTree :: UUID -> Annex (Maybe Sha)
+getContentIdentifierTree u = do
+	l <- calcRepo' (gitAnnexImportLog u)
+	-- This is safe because the log file is written atomically.
+	calcLogFileUnsafe l Nothing update
+  where
+	update l Nothing = extractSha (L.toStrict l)
+	-- Subsequent lines are ignored. This leaves room for future
+	-- expansion of what is logged.
+	update _l (Just l) = Just l
diff --git a/Logs/NumCopies.hs b/Logs/NumCopies.hs
--- a/Logs/NumCopies.hs
+++ b/Logs/NumCopies.hs
@@ -45,22 +45,22 @@
 
 {- Value configured in the numcopies log. Cached for speed. -}
 getGlobalNumCopies :: Annex (Maybe NumCopies)
-getGlobalNumCopies = maybe globalNumCopiesLoad (return . Just)
+getGlobalNumCopies = maybe globalNumCopiesLoad return
 	=<< Annex.getState Annex.globalnumcopies
 
 {- Value configured in the mincopies log. Cached for speed. -}
 getGlobalMinCopies :: Annex (Maybe MinCopies)
-getGlobalMinCopies = maybe globalMinCopiesLoad (return . Just)
+getGlobalMinCopies = maybe globalMinCopiesLoad return
 	=<< Annex.getState Annex.globalmincopies
 
 globalNumCopiesLoad :: Annex (Maybe NumCopies)
 globalNumCopiesLoad = do
 	v <- getLog numcopiesLog
-	Annex.changeState $ \s -> s { Annex.globalnumcopies = v }
+	Annex.changeState $ \s -> s { Annex.globalnumcopies = Just v }
 	return v
 
 globalMinCopiesLoad :: Annex (Maybe MinCopies)
 globalMinCopiesLoad = do
 	v <- getLog mincopiesLog
-	Annex.changeState $ \s -> s { Annex.globalmincopies = v }
+	Annex.changeState $ \s -> s { Annex.globalmincopies = Just v }
 	return v
diff --git a/Logs/Transfer.hs b/Logs/Transfer.hs
--- a/Logs/Transfer.hs
+++ b/Logs/Transfer.hs
@@ -14,6 +14,7 @@
 import Types.ActionItem
 import Annex.Common
 import qualified Git
+import qualified Git.Quote
 import Utility.Metered
 import Utility.Percentage
 import Utility.PID
@@ -31,11 +32,11 @@
 import qualified Data.ByteString.Char8 as B8
 import qualified System.FilePath.ByteString as P
 
-describeTransfer :: Transfer -> TransferInfo -> String
-describeTransfer t info = unwords
+describeTransfer :: Git.Quote.QuotePath -> Transfer -> TransferInfo -> String
+describeTransfer qp t info = unwords
 	[ show $ transferDirection t
 	, show $ transferUUID t
-	, decodeBS $ actionItemDesc $ ActionItemAssociatedFile
+	, decodeBS $ quote qp $ actionItemDesc $ ActionItemAssociatedFile
 		(associatedFile info)
 		(transferKey t)
 	, show $ bytesComplete info
diff --git a/Messages.hs b/Messages.hs
--- a/Messages.hs
+++ b/Messages.hs
@@ -1,15 +1,13 @@
 {- git-annex output messages
  -
- - Copyright 2010-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-}
 
 module Messages (
-	showStart,
-	showStartOther,
 	showStartMessage,
 	showEndMessage,
 	StartMessage(..),
@@ -29,6 +27,8 @@
 	showEndFail,
 	showEndResult,
 	endResult,
+	MessageId(..),
+	toplevelFileProblem,
 	toplevelWarning,
 	warning,
 	earlyWarning,
@@ -36,6 +36,8 @@
 	indent,
 	JSON.JSONChunk(..),
 	maybeShowJSON,
+	maybeShowJSON',
+	maybeAddJSONField,
 	showFullJSON,
 	showCustom,
 	showHeader,
@@ -50,11 +52,16 @@
 	MessageState,
 	prompt,
 	mkPrompter,
+	sanitizeTopLevelExceptionMessages,
 ) where
 
 import Control.Concurrent
 import Control.Monad.IO.Class
 import qualified Data.ByteString as S
+import qualified Data.ByteString.Char8 as S8
+import System.Exit
+import qualified Control.Monad.Catch as M
+import Data.String
 
 import Common
 import Types
@@ -62,41 +69,21 @@
 import Types.ActionItem
 import Types.Concurrency
 import Types.Command (StartMessage(..), SeekInput)
-import Types.Transfer (transferKey)
 import Messages.Internal
 import Messages.Concurrent
 import Annex.Debug
 import Annex.Concurrent.Utility
+import Utility.SafeOutput
+import Git.Quote
 import qualified Messages.JSON as JSON
 import qualified Annex
 
-showStart :: String -> RawFilePath -> SeekInput -> Annex ()
-showStart command file si = outputMessage json $
-	encodeBS command <> " " <> file <> " "
-  where
-	json = JSON.start command (Just file) Nothing si
-
-showStartKey :: String -> Key -> ActionItem -> SeekInput -> Annex ()
-showStartKey command key ai si = outputMessage json $
-	encodeBS command <> " " <> actionItemDesc ai <> " "
-  where
-	json = JSON.start command (actionItemFile ai) (Just key) si
-
-showStartOther :: String -> Maybe String -> SeekInput -> Annex ()
-showStartOther command mdesc si = outputMessage json $ encodeBS $
-	command ++ (maybe "" (" " ++) mdesc) ++ " "
-  where
-	json = JSON.start command Nothing Nothing si
-
 showStartMessage :: StartMessage -> Annex ()
-showStartMessage (StartMessage command ai si) = case ai of
-	ActionItemAssociatedFile _ k -> showStartKey command k ai si
-	ActionItemKey k -> showStartKey command k ai si
-	ActionItemBranchFilePath _ k -> showStartKey command k ai si
-	ActionItemFailedTransfer t _ -> showStartKey command (transferKey t) ai si
-	ActionItemTreeFile file -> showStart command file si
-	ActionItemOther msg -> showStartOther command msg si
-	OnlyActionOn _ ai' -> showStartMessage (StartMessage command ai' si)
+showStartMessage (StartMessage command ai si) =
+	outputMessage json id $
+		UnquotedString command <> " " <> actionItemDesc ai <> " "
+  where
+	json = JSON.startActionItem command ai si
 showStartMessage (StartUsualMessages command ai si) = do
 	outputType <$> Annex.getState Annex.output >>= \case
 		QuietOutput -> Annex.setOutput NormalOutput
@@ -115,13 +102,13 @@
 showEndMessage (StartNoMessage _) = const noop
 showEndMessage (CustomOutput _) = const noop
 
-showNote :: String -> Annex ()
-showNote s = outputMessage (JSON.note s) $ encodeBS $ "(" ++ s ++ ") "
+showNote :: StringContainingQuotedPath -> Annex ()
+showNote s = outputMessage (JSON.note (decodeBS (noquote s))) id $ "(" <> s <> ") "
 
-showAction :: String -> Annex ()
-showAction s = showNote $ s ++ "..."
+showAction :: StringContainingQuotedPath -> Annex ()
+showAction s = showNote $ s <> "..."
 
-showSideAction :: String -> Annex ()
+showSideAction :: StringContainingQuotedPath -> Annex ()
 showSideAction m = Annex.getState Annex.output >>= go
   where
 	go st
@@ -131,7 +118,7 @@
 			Annex.changeState $ \s -> s { Annex.output = st' }
 		| sideActionBlock st == InBlock = return ()
 		| otherwise = go'
-	go' = outputMessage JSON.none $ encodeBS $ "(" ++ m ++ "...)\n"
+	go' = outputMessage JSON.none id $ "(" <> m <> "...)\n"
 			
 showStoringStateAction :: Annex ()
 showStoringStateAction = showSideAction "recording state in git"
@@ -172,19 +159,18 @@
 {- Make way for subsequent output of a command. -}
 showOutput :: Annex ()
 showOutput = unlessM commandProgressDisabled $
-	outputMessage JSON.none "\n"
+	outputMessage JSON.none id "\n"
 
-showLongNote :: String -> Annex ()
-showLongNote s = outputMessage (JSON.note s) (encodeBS (formatLongNote s))
+showLongNote :: StringContainingQuotedPath -> Annex ()
+showLongNote s = outputMessage (JSON.note (decodeBS (noquote s))) formatLongNote s
 
-formatLongNote :: String -> String
-formatLongNote s = '\n' : indent s ++ "\n"
+formatLongNote :: S.ByteString -> S.ByteString
+formatLongNote s = "\n" <> indent s <> "\n"
 
 -- Used by external special remote, displayed same as showLongNote
 -- to console, but json object containing the info is emitted immediately.
-showInfo :: String -> Annex ()
-showInfo s = outputMessage' outputJSON (JSON.info s) $
-	encodeBS (formatLongNote s)
+showInfo :: StringContainingQuotedPath -> Annex ()
+showInfo s = outputMessage' outputJSON (JSON.info (decodeBS (noquote s))) formatLongNote s
 
 showEndOk :: Annex ()
 showEndOk = showEndResult True
@@ -193,41 +179,61 @@
 showEndFail = showEndResult False
 
 showEndResult :: Bool -> Annex ()
-showEndResult ok = outputMessage (JSON.end ok) $ endResult ok <> "\n"
+showEndResult ok = outputMessage (JSON.end ok) id $
+	UnquotedByteString (endResult ok) <> "\n"
 
 endResult :: Bool -> S.ByteString
 endResult True = "ok"
 endResult False = "failed"
 
-toplevelWarning :: Bool -> String -> Annex ()
-toplevelWarning makeway s = warning' makeway ("git-annex: " ++ s)
+toplevelMsg :: (Semigroup t, IsString t) => t -> t
+toplevelMsg s = fromString "git-annex: " <> s
 
-warning :: String -> Annex ()
-warning = warning' True . indent
+toplevelFileProblem :: Bool -> MessageId -> StringContainingQuotedPath -> String -> RawFilePath -> Maybe Key -> SeekInput -> Annex ()
+toplevelFileProblem makeway messageid msg action file mkey si = do
+	maybeShowJSON' $ JSON.start action (Just file) mkey si
+	maybeShowJSON' $ JSON.messageid messageid
+	warning' makeway id (toplevelMsg (QuotedPath file <> " " <> msg))
+	maybeShowJSON' $ JSON.end False
 
-earlyWarning :: String -> Annex ()
-earlyWarning = warning' False
+toplevelWarning :: Bool -> StringContainingQuotedPath -> Annex ()
+toplevelWarning makeway s = warning' makeway id (toplevelMsg s)
 
-warning' :: Bool -> String -> Annex ()
-warning' makeway w = do
+warning :: StringContainingQuotedPath -> Annex ()
+warning = warning' True indent
+
+earlyWarning :: StringContainingQuotedPath -> Annex ()
+earlyWarning = warning' False id
+
+warning' :: Bool -> (S.ByteString -> S.ByteString) -> StringContainingQuotedPath -> Annex ()
+warning' makeway consolewhitespacef msg = do
 	when makeway $
-		outputMessage JSON.none "\n"
-	outputError (w ++ "\n")
+		outputMessage JSON.none id "\n"
+	outputError (\s -> consolewhitespacef s <> "\n") msg
 
 {- Not concurrent output safe. -}
 warningIO :: String -> IO ()
 warningIO w = do
 	putStr "\n"
 	hFlush stdout
-	hPutStrLn stderr w
+	hPutStrLn stderr (safeOutput w)
 
-indent :: String -> String
-indent = intercalate "\n" . map (\l -> "  " ++ l) . lines
+indent :: S.ByteString -> S.ByteString
+indent = S.intercalate "\n" . map ("  " <>) . S8.lines
 
 {- Shows a JSON chunk only when in json mode. -}
 maybeShowJSON :: JSON.JSONChunk v -> Annex ()
 maybeShowJSON v = void $ withMessageState $ bufferJSON (JSON.add v)
 
+maybeShowJSON' :: JSON.JSONBuilder -> Annex ()
+maybeShowJSON' v = void $ withMessageState $ bufferJSON v
+
+{- Adds a field to the current json object. -}
+maybeAddJSONField :: JSON.ToJSON' v => String -> v -> Annex ()
+maybeAddJSONField f v = case JSON.toJSON' (JSON.AddJSONActionItemField f v) of
+	JSON.Object o -> maybeShowJSON $ JSON.AesonObject o
+	_ -> noop
+
 {- Shows a complete JSON value, only when in json mode. -}
 showFullJSON :: JSON.JSONChunk v -> Annex Bool
 showFullJSON v = withMessageState $ bufferJSON (JSON.complete v)
@@ -235,19 +241,19 @@
 {- Performs an action that outputs nonstandard/customized output, and
  - in JSON mode wraps its output in JSON.start and JSON.end, so it's
  - a complete JSON document.
- - This is only needed when showStart and showEndOk is not used.
+ - This is only needed when showStartMessage and showEndOk is not used.
  -}
 showCustom :: String -> SeekInput -> Annex Bool -> Annex ()
 showCustom command si a = do
-	outputMessage (JSON.start command Nothing Nothing si) ""
+	outputMessage (JSON.start command Nothing Nothing si) id ""
 	r <- a
-	outputMessage (JSON.end r) ""
+	outputMessage (JSON.end r) id ""
 
 showHeader :: S.ByteString -> Annex ()
-showHeader h = outputMessage JSON.none (h <> ": ")
+showHeader h = outputMessage JSON.none id (UnquotedByteString h <> ": ")
 
 showRaw :: S.ByteString -> Annex ()
-showRaw s = outputMessage JSON.none (s <> "\n")
+showRaw s = outputMessage JSON.none id (UnquotedByteString s <> "\n")
 
 setupConsole :: IO ()
 setupConsole = do
@@ -272,7 +278,7 @@
 	-- that are displayed at the same time from mixing together.
 	lock <- newMVar ()
 	return $ \s -> withMVar lock $ \() -> do
-		S.hPutStr stderr (s <> "\n")
+		S.hPutStr stderr (safeOutput s <> "\n")
 		hFlush stderr
 
 {- Should commands that normally output progress messages have that
@@ -328,3 +334,16 @@
 				(takeMVar l)
 				(\v -> putMVar l v >> cleanup)
 				(const $ run a)
+
+{- Catch all (non-async and not ExitCode) exceptions and display, 
+ - santizing any control characters in the exceptions.
+ -
+ - Exits nonzero on exception, so should only be used at topmost level.
+ -}
+sanitizeTopLevelExceptionMessages :: IO a -> IO a
+sanitizeTopLevelExceptionMessages a = a `catches`
+	((M.Handler (\ (e :: ExitCode) -> throwM e)) : nonAsyncHandler go)
+  where
+	go e = do
+		hPutStrLn stderr $ safeOutput $ toplevelMsg (show e)
+		exitWith $ ExitFailure 1
diff --git a/Messages/Internal.hs b/Messages/Internal.hs
--- a/Messages/Internal.hs
+++ b/Messages/Internal.hs
@@ -1,6 +1,6 @@
 {- git-annex output messages, including concurrent output to display regions
  -
- - Copyright 2010-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -13,28 +13,33 @@
 import Messages.Concurrent
 import qualified Messages.JSON as JSON
 import Messages.JSON (JSONBuilder)
+import Git.Quote
+import Types.GitConfig
 
 import qualified Data.ByteString as S
 
 withMessageState :: (MessageState -> Annex a) -> Annex a
 withMessageState a = Annex.getState Annex.output >>= a
 
-outputMessage :: JSONBuilder -> S.ByteString -> Annex ()
+outputMessage :: JSONBuilder -> (S.ByteString -> S.ByteString) -> StringContainingQuotedPath -> Annex ()
 outputMessage = outputMessage' bufferJSON
 
-outputMessage' :: (JSONBuilder -> MessageState -> Annex Bool) -> JSONBuilder -> S.ByteString -> Annex ()
-outputMessage' jsonoutputter jsonbuilder msg = withMessageState $ \s -> case outputType s of
+outputMessage' :: (JSONBuilder -> MessageState -> Annex Bool) -> JSONBuilder -> (S.ByteString -> S.ByteString) -> StringContainingQuotedPath -> Annex ()
+outputMessage' jsonoutputter jsonbuilder consolewhitespacef msg = withMessageState $ \s -> case outputType s of
 	NormalOutput
 		| concurrentOutputEnabled s -> do
+			qp <- coreQuotePath <$> Annex.getGitConfig
 			liftIO $ clearProgressMeter s
-			concurrentMessage s False (decodeBS msg) q
+			concurrentMessage s False (decodeBS (consolewhitespacef (quote qp msg))) q
 		| otherwise -> do
+			qp <- coreQuotePath <$> Annex.getGitConfig
 			liftIO $ clearProgressMeter s
-			liftIO $ flushed $ S.putStr msg
+			liftIO $ flushed $ S.putStr (consolewhitespacef (quote qp msg))
 	JSONOutput _ -> void $ jsonoutputter jsonbuilder s
 	QuietOutput -> q
 	SerializedOutput h _ -> do
-		liftIO $ outputSerialized h $ OutputMessage msg
+		qp <- coreQuotePath <$> Annex.getGitConfig
+		liftIO $ outputSerialized h $ OutputMessage $ consolewhitespacef $ quote qp msg
 		void $ jsonoutputter jsonbuilder s
 
 -- Buffer changes to JSON until end is reached and then emit it.
@@ -75,22 +80,27 @@
 			(fst <$> jsonbuilder Nothing)
 		return True
 
-outputError :: String -> Annex ()
-outputError msg = withMessageState $ \s -> case (outputType s, jsonBuffer s) of
+outputError :: (S.ByteString -> S.ByteString) -> StringContainingQuotedPath -> Annex ()
+outputError consolewhitespacef msg = withMessageState $ \s -> case (outputType s, jsonBuffer s) of
         (JSONOutput jsonoptions, Just jb) | jsonErrorMessages jsonoptions ->
-		let jb' = Just (JSON.addErrorMessage (lines msg) jb)
+		let jb' = Just (JSON.addErrorMessage (lines (decodeBS (noquote msg))) jb)
 		in Annex.changeState $ \st ->
 			st { Annex.output = s { jsonBuffer = jb' } }
-	(SerializedOutput h _, _) -> 
-		liftIO $ outputSerialized h $ OutputError msg
+	(SerializedOutput h _, _) -> do
+		qp <- coreQuotePath <$> Annex.getGitConfig
+		liftIO $ outputSerialized h $ OutputError $ decodeBS $
+			consolewhitespacef $ quote qp msg
 	_
-		| concurrentOutputEnabled s -> concurrentMessage s True msg go
+		| concurrentOutputEnabled s -> do
+			qp <- coreQuotePath <$> Annex.getGitConfig
+			concurrentMessage s True (decodeBS $ consolewhitespacef $ quote qp msg) go
 		| otherwise -> go
   where
-	go = liftIO $ do
-		hFlush stdout
-		hPutStr stderr msg
-		hFlush stderr
+	go = do
+		qp <- coreQuotePath <$> Annex.getGitConfig
+		liftIO $ hFlush stdout
+		liftIO $ S.hPutStr stderr (consolewhitespacef $ quote qp msg)
+		liftIO $ hFlush stderr
 
 q :: Monad m => m ()
 q = noop
@@ -105,4 +115,4 @@
 waitOutputSerializedResponse :: (IO (Maybe SerializedOutputResponse)) -> SerializedOutputResponse -> IO ()
 waitOutputSerializedResponse getr r = tryIO getr >>= \case
 	Right (Just r') | r' == r -> return ()
-	v -> error $ "serialized output protocol error; expected " ++ show r ++ " got " ++ show v
+	v -> giveup $ "serialized output protocol error; expected " ++ show r ++ " got " ++ show v
diff --git a/Messages/JSON.hs b/Messages/JSON.hs
--- a/Messages/JSON.hs
+++ b/Messages/JSON.hs
@@ -1,6 +1,6 @@
 {- git-annex command-line JSON output and input
  -
- - Copyright 2011-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -12,21 +12,23 @@
 	JSONChunk(..),
 	emit,
 	emit',
-	encode,
 	none,
 	start,
+	startActionItem,
 	end,
 	finalize,
 	addErrorMessage,
 	note,
 	info,
+	messageid,
 	add,
 	complete,
 	progress,
 	DualDisp(..),
 	ObjectMap(..),
 	JSONActionItem(..),
-	AddJSONActionItemFields(..),
+	AddJSONActionItemField(..),
+	module Utility.Aeson,
 ) where
 
 import Control.Applicative
@@ -46,11 +48,14 @@
 import Prelude
 
 import Types.Command (SeekInput(..))
+import Types.ActionItem
+import Types.UUID
 import Key
 import Utility.Metered
 import Utility.Percentage
 import Utility.Aeson
 import Utility.FileSystemEncoding
+import Types.Messages
 
 -- A global lock to avoid concurrent threads emitting json at the same time.
 {-# NOINLINE emitLock #-}
@@ -68,7 +73,8 @@
 	putMVar emitLock ()
 
 -- Building up a JSON object can be done by first using start,
--- then add and note any number of times, and finally complete.
+-- then add and note and messageid any number of times, and finally
+-- complete.
 type JSONBuilder = Maybe (Object, Bool) -> Maybe (Object, Bool)
 
 none :: JSONBuilder
@@ -83,10 +89,25 @@
 		{ itemCommand = Just command
 		, itemKey = key
 		, itemFile = fromRawFilePath <$> file
+		, itemUUID = Nothing
 		, itemFields = Nothing :: Maybe Bool
 		, itemSeekInput = si
 		}
 
+startActionItem :: String -> ActionItem -> SeekInput -> JSONBuilder
+startActionItem command ai si _ = case j of
+	Object o -> Just (o, False)
+	_ -> Nothing
+  where
+	j = toJSON' $ JSONActionItem
+		{ itemCommand = Just command
+		, itemKey = actionItemKey ai
+		, itemFile = fromRawFilePath <$> actionItemFile ai
+		, itemUUID = actionItemUUID ai
+		, itemFields = Nothing :: Maybe Bool
+		, itemSeekInput = si
+		}
+
 end :: Bool -> JSONBuilder
 end b (Just (o, _)) = Just (HM.insert "success" (toJSON' b) o, True)
 end _ Nothing = Nothing
@@ -112,6 +133,12 @@
 		String (old <> "\n" <> new)
 	combinelines new _old = new
 
+messageid :: MessageId -> JSONBuilder
+messageid _ Nothing = Nothing
+messageid mid (Just (o, e)) = Just (HM.unionWith replaceold (HM.singleton "message-id" (toJSON' (show mid))) o, e)
+  where
+	replaceold new _old = new
+
 info :: String -> JSONBuilder
 info s _ = case j of
 	Object o -> Just (o, True)
@@ -184,6 +211,7 @@
 	{ itemCommand :: Maybe String
 	, itemKey :: Maybe Key
 	, itemFile :: Maybe FilePath
+	, itemUUID :: Maybe UUID
 	, itemFields :: Maybe a
 	, itemSeekInput :: SeekInput
 	}
@@ -195,10 +223,15 @@
 		, case itemKey i of
 			Just k -> Just $ "key" .= toJSON' k
 			Nothing -> Nothing
-		, Just $ "file" .= toJSON' (itemFile i)
+		, case itemFile i of
+			Just f -> Just $ "file" .= toJSON' f
+			Nothing -> Nothing
 		, case itemFields i of
 			Just f -> Just $ "fields" .= toJSON' f
 			Nothing -> Nothing
+		, case itemUUID i of
+			Just u -> Just $ "uuid" .= toJSON' u
+			Nothing -> Nothing
 		, Just $ "input" .= fromSeekInput (itemSeekInput i)
 		]
 
@@ -207,14 +240,17 @@
 		<$> (v .:? "command")
 		<*> (maybe (return Nothing) parseJSON =<< (v .:? "key"))
 		<*> (v .:? "file")
+		<*> (v .:? "uuid")
 		<*> (v .:? "fields")
+		-- ^ fields is used for metadata, which is currently the
+		-- only json that gets parsed
 		<*> pure (SeekInput [])
 	parseJSON _ = mempty
 
--- This can be used to populate the "fields" after a JSONActionItem
+-- This can be used to populate a field after a JSONActionItem
 -- has already been started.
-newtype AddJSONActionItemFields a = AddJSONActionItemFields a
+data AddJSONActionItemField a = AddJSONActionItemField String a
 	deriving (Show)
 
-instance ToJSON' a => ToJSON' (AddJSONActionItemFields a) where
-	toJSON' (AddJSONActionItemFields a) = object [ ("fields", toJSON' a) ]
+instance ToJSON' a => ToJSON' (AddJSONActionItemField a) where
+	toJSON' (AddJSONActionItemField f a) = object [ (textKey (packString f), toJSON' a) ]
diff --git a/Messages/Progress.hs b/Messages/Progress.hs
--- a/Messages/Progress.hs
+++ b/Messages/Progress.hs
@@ -23,6 +23,7 @@
 import qualified Messages.JSON as JSON
 import Messages.Concurrent
 import Messages.Internal
+import Utility.SafeOutput
 
 import qualified System.Console.Regions as Regions
 import qualified System.Console.Concurrent as Console
@@ -177,7 +178,7 @@
 
 {- Progress dots. -}
 showProgressDots :: Annex ()
-showProgressDots = outputMessage JSON.none "."
+showProgressDots = outputMessage JSON.none id "."
 
 {- Runs a command, that may output progress to either stdout or
  - stderr, as well as other messages.
@@ -220,5 +221,5 @@
 mkStderrEmitter = withMessageState go
   where
 	go s
-		| concurrentOutputEnabled s = return Console.errorConcurrent
-		| otherwise = return (hPutStrLn stderr)
+		| concurrentOutputEnabled s = return (Console.errorConcurrent . safeOutput)
+		| otherwise = return (hPutStrLn stderr . safeOutput)
diff --git a/Messages/Serialized.hs b/Messages/Serialized.hs
--- a/Messages/Serialized.hs
+++ b/Messages/Serialized.hs
@@ -21,6 +21,7 @@
 import Messages.Progress
 import qualified Messages.JSON as JSON
 import Utility.Metered (BytesProcessed, setMeterTotalSize)
+import Git.Quote
 
 import Control.Monad.IO.Class (MonadIO)
 
@@ -51,10 +52,11 @@
 			runannex $ outputMessage'
 				(\_ _ -> return False)
 				id
-				msg
+				id
+				(UnquotedByteString msg)
 			loop st
 		Left (OutputError msg) -> do
-			runannex $ outputError msg
+			runannex $ outputError id $ UnquotedString msg
 			loop st		
 		Left (JSONObject b) -> do
 			runannex $ withMessageState $ \s -> case outputType s of
diff --git a/NEWS b/NEWS
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,12 @@
+git-annex (10.20230626) upstream; urgency=medium
+
+  Many commands now quote filenames that contain unusual characters the
+  same way that git does, to avoid exposing control characters to the
+  terminal. The core.quotePath config can be set to false to disable this
+  quoting.
+
+ -- Joey Hess <id@joeyh.name>  Mon, 26 Jun 2023 10:38:07 -0400
+
 git-annex (10.20220218) upstream; urgency=medium
 
   Transition notice: Commands like `git-annex get foo*` have changed to error
diff --git a/P2P/Annex.hs b/P2P/Annex.hs
--- a/P2P/Annex.hs
+++ b/P2P/Annex.hs
@@ -225,7 +225,7 @@
 						-- known. Force content
 						-- verification.
 						return (rightsize, MustVerify)
-			Left e -> error $ describeProtoFailure e
+			Left e -> giveup $ describeProtoFailure e
 	
 	sinkfile f (Offset o) checkchanged sender p ti = bracket setup cleanup go
 	  where
diff --git a/Remote.hs b/Remote.hs
--- a/Remote.hs
+++ b/Remote.hs
@@ -365,13 +365,13 @@
 				"Maybe add some of these git remotes (git remote add ...)"
 			ppuuidsskipped <- pp "skipped" uuidsskipped
 				"Also these untrusted repositories may contain the file"
-			showLongNote $ case ppremotesmakeavailable ++ ppenablespecialremotes ++ ppaddgitremotes ++ ppuuidsskipped of
+			showLongNote $ UnquotedString $ case ppremotesmakeavailable ++ ppenablespecialremotes ++ ppaddgitremotes ++ ppuuidsskipped of
 				[] -> nolocmsg
 				s -> s
 		)
 	ignored <- filterM (liftIO . getDynamicConfig . remoteAnnexIgnore . gitconfig) remotes
 	unless (null ignored) $
-		showLongNote $ "(Note that these git remotes have annex-ignore set: " ++ unwords (map name ignored) ++ ")"
+		showLongNote $ UnquotedString $ "(Note that these git remotes have annex-ignore set: " ++ unwords (map name ignored) ++ ")"
   where
 	filteruuids l x = filter (`notElem` x) l
 
@@ -383,7 +383,7 @@
 showTriedRemotes :: [Remote] -> Annex ()
 showTriedRemotes [] = noop
 showTriedRemotes remotes =
-	showLongNote $ "Unable to access these remotes: "
+	showLongNote $ UnquotedString $ "Unable to access these remotes: "
 		++ listRemoteNames remotes
 
 listRemoteNames :: [Remote] -> String
diff --git a/Remote/Adb.hs b/Remote/Adb.hs
--- a/Remote/Adb.hs
+++ b/Remote/Adb.hs
@@ -6,6 +6,7 @@
  -}
 
 {-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 module Remote.Adb (remote) where
 
diff --git a/Remote/BitTorrent.hs b/Remote/BitTorrent.hs
--- a/Remote/BitTorrent.hs
+++ b/Remote/BitTorrent.hs
@@ -47,7 +47,7 @@
 	, enumerate = list
 	, generate = gen
 	, configParser = mkRemoteConfigParser []
-	, setup = error "not supported"
+	, setup = giveup "not supported"
 	, exportSupported = exportUnsupported
 	, importSupported = importUnsupported
 	, thirdPartyPopulated = False
diff --git a/Remote/Ddar.hs b/Remote/Ddar.hs
--- a/Remote/Ddar.hs
+++ b/Remote/Ddar.hs
@@ -139,7 +139,7 @@
 
 {- Convert remote DdarRepo to host and path on remote end -}
 splitRemoteDdarRepo :: DdarRepo -> (SshHost, String)
-splitRemoteDdarRepo ddarrepo = (either error id $ mkSshHost host, ddarrepo')
+splitRemoteDdarRepo ddarrepo = (either giveup id $ mkSshHost host, ddarrepo')
   where
 	(host, remainder) = span (/= ':') (ddarRepoLocation ddarrepo)
 	ddarrepo' = drop 1 remainder
@@ -228,7 +228,7 @@
 	directoryExists <- ddarDirectoryExists ddarrepo
 	case directoryExists of
 		Left e -> error e
-		Right True -> either error return
+		Right True -> either giveup return
 			=<< inDdarManifest ddarrepo key
 		Right False -> return False
 
diff --git a/Remote/External.hs b/Remote/External.hs
--- a/Remote/External.hs
+++ b/Remote/External.hs
@@ -504,7 +504,7 @@
 		mapM_ (send . VALUE) =<< getUrlsWithPrefix key prefix
 		send (VALUE "") -- end of list
 	handleRemoteRequest (DEBUG msg) = fastDebug "Remote.External" msg
-	handleRemoteRequest (INFO msg) = showInfo msg
+	handleRemoteRequest (INFO msg) = showInfo (UnquotedString msg)
 	handleRemoteRequest (VERSION _) = senderror "too late to send VERSION"
 
 	handleExceptionalMessage (ERROR err) = giveup $ "external special remote error: " ++ err
@@ -583,7 +583,7 @@
 				Just msg -> maybe (protocolError True s) id (handleexceptional msg)
 				Nothing -> protocolError False s
 	protocolError parsed s = do
-		warning $ "external special remote protocol error, unexpectedly received \"" ++ s ++ "\" " ++
+		warning $ UnquotedString $ "external special remote protocol error, unexpectedly received \"" ++ s ++ "\" " ++
 			if parsed
 				then "(command not allowed at this time)"
 				else "(unable to parse command)"
@@ -713,7 +713,7 @@
 				] ++ exrest
 
 	unusable msg = do
-		warning msg
+		warning (UnquotedString msg)
 		giveup ("unable to use external special remote " ++ basecmd)
 
 stopExternal :: External -> Annex ()
diff --git a/Remote/External/AsyncExtension.hs b/Remote/External/AsyncExtension.hs
--- a/Remote/External/AsyncExtension.hs
+++ b/Remote/External/AsyncExtension.hs
@@ -7,6 +7,7 @@
 
 {-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 module Remote.External.AsyncExtension (runRelayToExternalAsync) where
 
@@ -86,7 +87,7 @@
 	Nothing -> closeandshutdown
   where
 	protoerr s = do
-		annexrunner $ warning $ "async external special remote protocol error: " ++ s
+		annexrunner $ warning $ "async external special remote protocol error: " <> s
 		closeandshutdown
 	
 	closeandshutdown = do
diff --git a/Remote/GCrypt.hs b/Remote/GCrypt.hs
--- a/Remote/GCrypt.hs
+++ b/Remote/GCrypt.hs
@@ -122,7 +122,7 @@
 				setConfig (Git.GCrypt.remoteConfigKey "gcrypt-id" remotename) gcryptid
 				gen' r u' pc gc rs
 			_ -> do
-				warning $ "not using unknown gcrypt repository pointed to by remote " ++ Git.repoDescribe r
+				warning $ UnquotedString $ "not using unknown gcrypt repository pointed to by remote " ++ Git.repoDescribe r
 				return Nothing
 
 gen' :: Git.Repo -> UUID -> ParsedRemoteConfig -> RemoteGitConfig -> RemoteStateHandle -> Annex (Maybe Remote)
@@ -197,7 +197,7 @@
 		let rsyncpath = if "/~/" `isPrefixOf` path
 			then drop 3 path
 			else path
-		    sshhost = either error id (mkSshHost host)
+		    sshhost = either giveup id (mkSshHost host)
 		    mkopts = rsyncShell . (Param "ssh" :) 
 			<$> sshOptions ConsumeStdin (sshhost, Nothing) gc []
 		in (mkopts, fromSshHost sshhost ++ ":" ++ rsyncpath, AccessGitAnnexShell)
@@ -239,7 +239,7 @@
 				]
 			(r:_)
 				| Git.repoLocation r == url -> noop
-				| otherwise -> error "Another remote with the same name already exists."		
+				| otherwise -> giveup "Another remote with the same name already exists."		
 
 		pc <- either giveup return . parseRemoteConfig c'
 			=<< configParser remote c'
@@ -505,7 +505,7 @@
 	| Git.repoIsLocal r || Git.repoIsLocalUnknown r = extract <$>
 		liftIO (catchMaybeIO $ Git.Config.read r)
 	| not fast = extract . liftM fst3 <$> getM (eitherToMaybe <$>)
-		[ Ssh.onRemote NoConsumeStdin r (\f p -> liftIO (Git.Config.fromPipe r f p Git.Config.ConfigList), return (Left $ error "configlist failed")) "configlist" [] []
+		[ Ssh.onRemote NoConsumeStdin r (\f p -> liftIO (Git.Config.fromPipe r f p Git.Config.ConfigList), return (Left $ giveup "configlist failed")) "configlist" [] []
 		, getConfigViaRsync r gc
 		]
 	| otherwise = return (Nothing, r)
diff --git a/Remote/Git.hs b/Remote/Git.hs
--- a/Remote/Git.hs
+++ b/Remote/Git.hs
@@ -131,13 +131,18 @@
 
 enableRemote :: Maybe UUID -> RemoteConfig -> Annex (RemoteConfig, UUID)
 enableRemote (Just u) c = do
-	inRepo $ Git.Command.run
-		[ Param "remote"
-		, Param "add"
-		, Param $ fromMaybe (giveup "no name") (SpecialRemote.lookupName c)
-		, Param $ maybe (giveup "no location") fromProposedAccepted (M.lookup locationField c)
-		]
+	rs <- Annex.getGitRemotes
+	unless (any (\r -> Git.remoteName r == Just cname) rs) $
+		inRepo $ Git.Command.run
+			[ Param "remote"
+			, Param "add"
+			, Param cname
+			, Param clocation
+			]
 	return (c, u)
+  where
+	cname = fromMaybe (giveup "no name") (SpecialRemote.lookupName c)
+	clocation = maybe (giveup "no location") fromProposedAccepted (M.lookup locationField c)
 enableRemote Nothing _ = giveup "unable to enable git remote with no specified uuid"
 
 {- It's assumed to be cheap to read the config of non-URL remotes, so this is
@@ -275,12 +280,12 @@
 		case v of
 			Right (r', val, _err) -> do
 				unless (isUUIDConfigured r' || S.null val || not mustincludeuuuid) $ do
-					warning $ "Failed to get annex.uuid configuration of repository " ++ Git.repoDescribe r
-					warning $ "Instead, got: " ++ show val
-					warning $ "This is unexpected; please check the network transport!"
+					warning $ UnquotedString $ "Failed to get annex.uuid configuration of repository " ++ Git.repoDescribe r
+					warning $ UnquotedString $ "Instead, got: " ++ show val
+					warning "This is unexpected; please check the network transport!"
 				return $ Right r'
 			Left l -> do
-				warning $ "Unable to parse git config from " ++ configloc
+				warning $ UnquotedString $ "Unable to parse git config from " ++ configloc
 				return $ Left (show l)
 
 	geturlconfig = Url.withUrlOptionsPromptingCreds $ \uo -> do
@@ -306,7 +311,7 @@
 				return r'
 			Left err -> do
 				set_ignore "not usable by git-annex" False
-				warning $ url ++ " " ++ err
+				warning $ UnquotedString $ url ++ " " ++ err
 				return r
 
 	{- Is this remote just not available, or does
@@ -323,9 +328,9 @@
 		case Git.remoteName r of
 			Nothing -> noop
 			Just n -> do
-				warning $ "Remote " ++ n ++ " " ++ msg ++ "; setting annex-ignore"
+				warning $ UnquotedString $ "Remote " ++ n ++ " " ++ msg ++ "; setting annex-ignore"
 				when longmessage $
-					warning $ "This could be a problem with the git-annex installation on the remote. Please make sure that git-annex-shell is available in PATH when you ssh into the remote. Once you have fixed the git-annex installation, run: git annex enableremote " ++ n
+					warning $ UnquotedString $ "This could be a problem with the git-annex installation on the remote. Please make sure that git-annex-shell is available in PATH when you ssh into the remote. Once you have fixed the git-annex installation, run: git annex enableremote " ++ n
 		setremote setRemoteIgnore True
 	
 	setremote setter v = case Git.remoteName r of
@@ -348,7 +353,7 @@
 		let check = do
 			Annex.BranchState.disableUpdate
 			catchNonAsync (autoInitialize (pure [])) $ \e ->
-				warning $ "Remote " ++ Git.repoDescribe r ++
+				warning $ UnquotedString $ "Remote " ++ Git.repoDescribe r ++
 					": "  ++ show e
 			Annex.getState Annex.repo
 		s <- newLocal r
@@ -359,7 +364,7 @@
 		unless hasuuid $ case Git.remoteName r of
 			Nothing -> noop
 			Just n -> do
-				warning $ "Remote " ++ n ++ " cannot currently be accessed."
+				warning $ UnquotedString $ "Remote " ++ n ++ " cannot currently be accessed."
 		return r
 		
 	configlistfields = if autoinit
@@ -632,9 +637,11 @@
 	(st, rd) <- liftIO $ Annex.new repo
 	debugenabled <- Annex.getRead Annex.debugenabled
 	debugselector <- Annex.getRead Annex.debugselector
+	force <- Annex.getRead Annex.force
 	return (st,  rd
 		{ Annex.debugenabled = debugenabled
 		, Annex.debugselector = debugselector
+		, Annex.force = force
 		})
 
 onLocal' :: LocalRemoteAnnex -> Annex a -> Annex a
@@ -770,7 +777,7 @@
 					let ok = u' == u
 					void $ liftIO $ tryPutMVar cv ok
 					unless ok $
-						warning $ Git.repoDescribe r ++ " is not the expected repository. The remote's annex-checkuuid configuration prevented noticing the change until now."
+						warning $ UnquotedString $ Git.repoDescribe r ++ " is not the expected repository. The remote's annex-checkuuid configuration prevented noticing the change until now."
 					return ok
 				, liftIO $ readMVar cv
 				)
diff --git a/Remote/GitLFS.hs b/Remote/GitLFS.hs
--- a/Remote/GitLFS.hs
+++ b/Remote/GitLFS.hs
@@ -253,7 +253,7 @@
 			warning "Unable to parse ssh url for git-lfs remote."
 			return Nothing
 		Just (Left err) -> do
-			warning err
+			warning (UnquotedString err)
 			return Nothing
 		Just (Right hostuser) -> do
 			let port = Git.Url.port r
@@ -275,11 +275,11 @@
 			(sshcommand, sshparams) <- sshCommand NoConsumeStdin (hostuser, port) (remoteGitConfig h) remotecmd
 			liftIO (tryIO (readProcess sshcommand (toCommand sshparams))) >>= \case
 				Left err -> do
-					warning $ "ssh connection to git-lfs remote failed: " ++ show err
+					warning $ UnquotedString $ "ssh connection to git-lfs remote failed: " ++ show err
 					return Nothing
 				Right resp -> case LFS.parseSshDiscoverEndpointResponse (fromString resp) of
 					Nothing -> do
-						warning $ "unexpected response from git-lfs remote when doing ssh endpoint discovery"
+						warning "unexpected response from git-lfs remote when doing ssh endpoint discovery"
 						return Nothing
 					Just endpoint -> return (Just endpoint)
 	
diff --git a/Remote/Helper/Chunked/Legacy.hs b/Remote/Helper/Chunked/Legacy.hs
--- a/Remote/Helper/Chunked/Legacy.hs
+++ b/Remote/Helper/Chunked/Legacy.hs
@@ -100,7 +100,7 @@
 		| otherwise = storechunks sz [] dests content
 		
 	onerr e = do
-		annexrunner $ warning (show e)
+		annexrunner $ warning (UnquotedString (show e))
 		return []
 	
 	storechunks _ _ [] _ = return [] -- ran out of dests
diff --git a/Remote/Helper/Encryptable.hs b/Remote/Helper/Encryptable.hs
--- a/Remote/Helper/Encryptable.hs
+++ b/Remote/Helper/Encryptable.hs
@@ -193,9 +193,9 @@
 		Left _ -> True
 	encsetup a = use "encryption setup" . a =<< highRandomQuality
 	use m a = do
-		showNote m
+		showNote (UnquotedString m)
 		cipher <- liftIO a
-		showNote (describeCipher cipher)
+		showNote (UnquotedString (describeCipher cipher))
 		return (storeCipher cipher c', EncryptionIsSetup)
 	highRandomQuality = ifM (Annex.getRead Annex.fast)
 		( return False
@@ -331,4 +331,4 @@
 fromB64bs :: String -> String
 fromB64bs s = either (const bad) (w82s . B.unpack) (B64.decode $ B.pack $ s2w8 s)
   where
-	bad = error "bad base64 encoded data"
+	bad = giveup "bad base64 encoded data"
diff --git a/Remote/Helper/ExportImport.hs b/Remote/Helper/ExportImport.hs
--- a/Remote/Helper/ExportImport.hs
+++ b/Remote/Helper/ExportImport.hs
@@ -280,14 +280,14 @@
 			Nothing -> ifM (liftIO $ atomically $ tryPutTMVar lcklckv ())
 				( do
 					db <- ContentIdentifier.openDb
-					ContentIdentifier.needsUpdateFromLog db >>= \case
+					db' <- ContentIdentifier.needsUpdateFromLog db >>= \case
 						Just v -> do
 							cidlck <- calcRepo' gitAnnexContentIdentifierLock 
 							withExclusiveLock cidlck $
 								ContentIdentifier.updateFromLog db v
-						Nothing -> noop
-					liftIO $ atomically $ putTMVar dbtv db
-					return db
+						Nothing -> pure db
+					liftIO $ atomically $ putTMVar dbtv db'
+					return db'
 				-- loser waits for winner to open the db and
 				-- can then also use its handle
 				, liftIO $ atomically (readTMVar dbtv)
diff --git a/Remote/Helper/Hooks.hs b/Remote/Helper/Hooks.hs
--- a/Remote/Helper/Hooks.hs
+++ b/Remote/Helper/Hooks.hs
@@ -87,7 +87,7 @@
 		unlockFile lck
 #ifndef mingw32_HOST_OS
 		mode <- annexFileMode
-		v <- noUmask mode $ tryLockExclusive (Just mode) lck
+		v <- tryLockExclusive (Just mode) lck
 #else
 		v <- liftIO $ lockExclusive lck
 #endif
diff --git a/Remote/Helper/Messages.hs b/Remote/Helper/Messages.hs
--- a/Remote/Helper/Messages.hs
+++ b/Remote/Helper/Messages.hs
@@ -29,4 +29,4 @@
 cantCheck v = giveup $ "unable to check " ++ describe v
 
 showLocking :: Describable a => a -> Annex ()
-showLocking v = showAction $ "locking " ++ describe v
+showLocking v = showAction $ UnquotedString $ "locking " ++ describe v
diff --git a/Remote/Helper/Ssh.hs b/Remote/Helper/Ssh.hs
--- a/Remote/Helper/Ssh.hs
+++ b/Remote/Helper/Ssh.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Remote.Helper.Ssh where
 
 import Annex.Common
@@ -32,7 +34,7 @@
 toRepo cs r gc remotecmd = do
 	let host = maybe
 		(giveup "bad ssh url")
-		(either error id . mkSshHost)
+		(either giveup id . mkSshHost)
 		(Git.Url.hostuser r)
 	sshCommand cs (host, Git.Url.port r) gc remotecmd
 
@@ -298,7 +300,7 @@
 		-- When runFullProto fails, the connection is no longer
 		-- usable, so close it.
 		Left e -> do
-			warning $ "Lost connection (" ++ P2P.describeProtoFailure e ++ ")"
+			warning $ UnquotedString $ "Lost connection (" ++ P2P.describeProtoFailure e ++ ")"
 			conn' <- fst <$> liftIO (closeP2PSshConnection conn)
 			return (conn', Nothing)
 
diff --git a/Remote/Hook.hs b/Remote/Hook.hs
--- a/Remote/Hook.hs
+++ b/Remote/Hook.hs
@@ -128,7 +128,7 @@
 			fallback <- fromConfigValue <$> getConfig hookfallback mempty
 			if null fallback
 				then do
-					warning $ "missing configuration for " ++ fromConfigKey hook ++ " or " ++ fromConfigKey hookfallback
+					warning $ UnquotedString $ "missing configuration for " ++ fromConfigKey hook ++ " or " ++ fromConfigKey hookfallback
 					return Nothing
 				else return $ Just fallback
 		else return $ Just command
@@ -153,7 +153,7 @@
 		ifM (progressCommandEnv "sh" [Param "-c", Param command] =<< liftIO (hookEnv action k f))
 			( a
 			, do
-				warning $ hook ++ " hook exited nonzero!"
+				warning $ UnquotedString $ hook ++ " hook exited nonzero!"
 				return False
 			)
 
diff --git a/Remote/HttpAlso.hs b/Remote/HttpAlso.hs
--- a/Remote/HttpAlso.hs
+++ b/Remote/HttpAlso.hs
@@ -1,10 +1,12 @@
 {- HttpAlso remote (readonly).
  -
- - Copyright 2020-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2020-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE RankNTypes #-}
+
 module Remote.HttpAlso (remote) where
 
 import Annex.Common
@@ -18,7 +20,6 @@
 import Config
 import Logs.Web
 import Creds
-import Messages.Progress
 import Utility.Metered
 import Annex.Verify
 import qualified Annex.Url as Url
@@ -30,7 +31,7 @@
 import Control.Concurrent.STM
 
 remote :: RemoteType
-remote = RemoteType
+remote = specialRemoteType $ RemoteType
 	{ typename = "httpalso"
 	, enumerate = const (findSpecialRemotes "httpalso")
 	, generate = gen
@@ -53,21 +54,26 @@
 	cst <- remoteCost gc c expensiveRemoteCost
 	let url = getRemoteConfigValue urlField c
 	ll <- liftIO newLearnedLayout
-	return $ Just $ this url ll c cst
+	return $ Just $ specialRemote c
+		cannotModify
+		(downloadKey url ll)
+		cannotModify
+		(checkKey url ll)
+		(this url c cst)
   where
-	this url ll c cst = Remote
+	this url c cst = Remote
 		{ uuid = u
 		, cost = cst
 		, name = Git.repoDescribe r
 		, storeKey = cannotModify
-		, retrieveKeyFile = downloadKey url ll
+		, retrieveKeyFile = retrieveKeyFileDummy
 		, retrieveKeyFileCheap = Nothing
 		-- HttpManagerRestricted is used here, so this is
 		-- secure.
 		, retrievalSecurityPolicy = RetrievalAllKeysSecure
 		, removeKey = cannotModify
 		, lockContent = Nothing
-		, checkPresent = checkKey url ll
+		, checkPresent = checkPresentDummy
 		, checkPresentCheap = False
 		, exportActions = ExportActions
 			{ storeExport = cannotModify
@@ -103,7 +109,7 @@
 
 httpAlsoSetup :: SetupStage -> Maybe UUID -> Maybe CredPair -> RemoteConfig -> RemoteGitConfig -> Annex (RemoteConfig, UUID)
 httpAlsoSetup _ Nothing _ _ _ =
-	error "Must use --sameas when initializing a httpalso remote."
+	giveup "Must use --sameas when initializing a httpalso remote."
 httpAlsoSetup _ (Just u) _ c gc = do
 	_url <- maybe (giveup "Specify url=")
 		(return . fromProposedAccepted)
@@ -114,24 +120,22 @@
 	gitConfigSpecialRemote u c' [("httpalso", "true")]
 	return (c', u)
 
-downloadKey :: Maybe URLString -> LearnedLayout -> Key -> AssociatedFile -> FilePath -> MeterUpdate -> VerifyConfig -> Annex Verification
-downloadKey baseurl ll key _af dest p vc = do
-	verifyKeyContentIncrementally vc key $ \iv ->
-		downloadAction dest p iv key (keyUrlAction baseurl ll key)
+downloadKey :: Maybe URLString -> LearnedLayout -> Retriever
+downloadKey baseurl ll = fileRetriever' $ \dest key p iv ->
+	downloadAction (fromRawFilePath dest) p iv (keyUrlAction baseurl ll key)
 
 retriveExportHttpAlso :: Maybe URLString -> Key -> ExportLocation -> FilePath -> MeterUpdate -> Annex Verification
 retriveExportHttpAlso baseurl key loc dest p = do
 	verifyKeyContentIncrementally AlwaysVerify key $ \iv ->
-		downloadAction dest p iv key (exportLocationUrlAction baseurl loc)
+		downloadAction dest p iv (exportLocationUrlAction baseurl loc)
 
-downloadAction :: FilePath -> MeterUpdate -> Maybe IncrementalVerifier -> Key -> ((URLString -> Annex (Either String ())) -> Annex (Either String ())) -> Annex ()
-downloadAction dest p iv key run =
+downloadAction :: FilePath -> MeterUpdate -> Maybe IncrementalVerifier -> ((URLString -> Annex (Either String ())) -> Annex (Either String ())) -> Annex ()
+downloadAction dest p iv run =
 	Url.withUrlOptions $ \uo ->
-		meteredFile dest (Just p) key $
-			run (\url -> Url.download' p iv url dest uo)
-				>>= either giveup (const (return ()))
+		run (\url -> Url.download' p iv url dest uo)
+			>>= either giveup (const (return ()))
 
-checkKey :: Maybe URLString -> LearnedLayout -> Key -> Annex Bool
+checkKey :: Maybe URLString -> LearnedLayout -> CheckPresent
 checkKey baseurl ll key =
 	isRight <$> keyUrlAction baseurl ll key (checkKey' key)
 
@@ -150,9 +154,9 @@
 newLearnedLayout :: IO LearnedLayout
 newLearnedLayout = newTVarIO Nothing
 
--- Learns which layout the special remote uses, so the once any
--- action on an url succeeds, subsequent calls will continue to use that
--- layout (or related layouts).
+-- Learns which layout the special remote uses, so once any action on an
+-- url succeeds, subsequent calls will continue to use that layout
+-- (or related layouts).
 keyUrlAction
 	:: Maybe URLString
 	-> LearnedLayout
diff --git a/Remote/P2P.hs b/Remote/P2P.hs
--- a/Remote/P2P.hs
+++ b/Remote/P2P.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Remote.P2P (
 	remote,
 	chainGen
@@ -38,7 +40,7 @@
 	, enumerate = const (return [])
 	, generate = \_ _ _ _ _ -> return Nothing
 	, configParser = mkRemoteConfigParser []
-	, setup = error "P2P remotes are set up using git-annex p2p"
+	, setup = giveup "P2P remotes are set up using git-annex p2p"
 	, exportSupported = exportUnsupported
 	, importSupported = importUnsupported
 	, thirdPartyPopulated = False
@@ -105,7 +107,7 @@
 	-- so close it.
 	case v of
 		Left e -> do
-			warning $ "Lost connection to peer (" ++ describeProtoFailure e ++ ")"
+			warning $ UnquotedString $ "Lost connection to peer (" ++ describeProtoFailure e ++ ")"
 			liftIO $ closeConnection conn
 			return (ClosedConnection, Nothing)
 		Right r -> return (c, Just r)
@@ -163,9 +165,9 @@
 					liftIO $ closeConnection conn
 					return ClosedConnection
 				Left e -> do
-					warning $ "Problem communicating with peer. (" ++ describeProtoFailure e ++ ")"
+					warning $ UnquotedString $ "Problem communicating with peer. (" ++ describeProtoFailure e ++ ")"
 					liftIO $ closeConnection conn
 					return ClosedConnection
 		Left e -> do
-			warning $ "Unable to connect to peer. (" ++ show e ++ ")"
+			warning $ UnquotedString $ "Unable to connect to peer. (" ++ show e ++ ")"
 			return ClosedConnection
diff --git a/Remote/Rsync.hs b/Remote/Rsync.hs
--- a/Remote/Rsync.hs
+++ b/Remote/Rsync.hs
@@ -5,7 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE CPP #-}
+{-# LANGUAGE CPP, OverloadedStrings #-}
 
 module Remote.Rsync (
 	remote,
@@ -179,7 +179,7 @@
 		case fromNull ["ssh"] (remoteAnnexRsyncTransport gc) of
 			"ssh":sshopts -> do
 				let (port, sshopts') = sshReadPort sshopts
-				    userhost = either error id $ mkSshHost $ 
+				    userhost = either giveup id $ mkSshHost $ 
 				    	takeWhile (/= ':') url
 				return $ (Param "ssh":) <$> sshOptions ConsumeStdin
 					(userhost, port) gc
diff --git a/Remote/S3.hs b/Remote/S3.hs
--- a/Remote/S3.hs
+++ b/Remote/S3.hs
@@ -423,13 +423,13 @@
 	Right h -> 
 		eitherS3VersionID info rs c k (T.pack $ bucketObject info k) >>= \case
 			Left failreason -> do
-				warning failreason
+				warning (UnquotedString failreason)
 				giveup "cannot download content"
 			Right loc -> retrieveHelper info h loc (fromRawFilePath f) p iv
 	Left S3HandleNeedCreds ->
 		getPublicWebUrls' (uuid r) rs info c k >>= \case
 			Left failreason -> do
-				warning failreason
+				warning (UnquotedString failreason)
 				giveup "cannot download content"
 			Right us -> unlessM (withUrlOptions $ downloadUrl False k p iv us (fromRawFilePath f)) $
 				giveup "failed to download content"
@@ -470,13 +470,13 @@
 checkKey hv r rs c info k = withS3Handle hv $ \case
 	Right h -> eitherS3VersionID info rs c k (T.pack $ bucketObject info k) >>= \case
 		Left failreason -> do
-			warning failreason
+			warning (UnquotedString failreason)
 			giveup "cannot check content"
 		Right loc -> checkKeyHelper info h loc
 	Left S3HandleNeedCreds ->
 		getPublicWebUrls' (uuid r) rs info c k >>= \case
 			Left failreason -> do
-				warning failreason
+				warning (UnquotedString failreason)
 				giveup "cannot check content"
 			Right us -> do
 				let check u = withUrlOptions $ 
@@ -774,7 +774,7 @@
 		case r of
 			Right True -> noop
 			_ -> do
-				showAction $ "creating bucket in " ++ datacenter
+				showAction $ UnquotedString $ "creating bucket in " ++ datacenter
 				void $ liftIO $ runResourceT $ sendS3Handle h $ 
 					(S3.putBucket (bucket info))
 						{ S3.pbCannedAcl = acl info
@@ -865,7 +865,7 @@
 
 giveupS3HandleProblem :: S3HandleProblem -> UUID -> Annex a
 giveupS3HandleProblem S3HandleNeedCreds u = do
-	warning $ needS3Creds u
+	warning $ UnquotedString $ needS3Creds u
 	giveup "No S3 credentials configured"
 giveupS3HandleProblem S3HandleAnonymousOldAws _ =
 	giveup "This S3 special remote is configured with signature=anonymous, but git-annex is built with too old a version of the aws library to support that."
diff --git a/Remote/WebDAV.hs b/Remote/WebDAV.hs
--- a/Remote/WebDAV.hs
+++ b/Remote/WebDAV.hs
@@ -321,7 +321,7 @@
 
 	user = toDavUser u
 	pass = toDavPass p
-testDav _ Nothing = error "Need to configure webdav username and password."
+testDav _ Nothing = giveup "Need to configure webdav username and password."
 
 {- Tries to make all the parent directories in the WebDAV urls's path,
  - right down to the root.
@@ -407,7 +407,7 @@
 choke f = do
 	x <- f
 	case x of
-		Left e -> error e
+		Left e -> giveup e
 		Right r -> return r
 
 data DavHandle = DavHandle DAVContext DavUser DavPass URLString
@@ -491,11 +491,11 @@
 				inLocation l $
 					snd <$> getContentM
   where
-	onerr = error "download failed"
+	onerr = giveup "download failed"
 
 checkKeyLegacyChunked :: DavHandle -> CheckPresent
 checkKeyLegacyChunked dav k = liftIO $
-	either error id <$> withStoredFilesLegacyChunked k dav onerr check
+	either giveup id <$> withStoredFilesLegacyChunked k dav onerr check
   where
 	check [] = return $ Right True
 	check (l:ls) = do
diff --git a/RemoteDaemon/Core.hs b/RemoteDaemon/Core.hs
--- a/RemoteDaemon/Core.hs
+++ b/RemoteDaemon/Core.hs
@@ -40,7 +40,7 @@
 	let reader = forever $ do
 		l <- hGetLine readh
 		case parseMessage l of
-			Nothing -> error $ "protocol error: " ++ l
+			Nothing -> giveup $ "protocol error: " ++ l
 			Just cmd -> atomically $ writeTChan ichan cmd
 	let writer = forever $ do
 		msg <- atomically $ readTChan ochan
diff --git a/Test.hs b/Test.hs
--- a/Test.hs
+++ b/Test.hs
@@ -30,7 +30,7 @@
 
 import qualified Utility.ShellEscape
 import qualified Annex
-import qualified Git.Filename
+import qualified Git.Quote
 import qualified Git.Types
 import qualified Git.Ref
 import qualified Git.LsTree
@@ -128,7 +128,7 @@
 runner opts = parallelTestRunner opts tests
 
 tests :: Int -> Bool -> Bool -> TestOptions -> [TestTree]
-tests n crippledfilesystem adjustedbranchok opts = 
+tests numparts crippledfilesystem adjustedbranchok opts = 
 	properties 
 		: withTestMode remotetestmode testRemotes
 		: concatMap mkrepotests testmodes
@@ -147,11 +147,11 @@
 		| otherwise = Nothing
 	mkrepotests (d, te) = map 
 		(\uts -> withTestMode te uts)
-		(repoTests d n)
+		(repoTests d numparts)
 
 properties :: TestTree
 properties = localOption (QuickCheckTests 1000) $ testGroup "QuickCheck" $
-	[ testProperty "prop_encode_decode_roundtrip" Git.Filename.prop_encode_decode_roundtrip
+	[ testProperty "prop_quote_unquote_roundtrip" Git.Quote.prop_quote_unquote_roundtrip
 	, testProperty "prop_encode_c_decode_c_roundtrip" Utility.Format.prop_encode_c_decode_c_roundtrip
 	, testProperty "prop_isomorphic_key_encode" Key.prop_isomorphic_key_encode
 	, testProperty "prop_isomorphic_shellEscape" Utility.ShellEscape.prop_isomorphic_shellEscape
@@ -239,7 +239,7 @@
 		innewrepo $ do
 			git_annex "init" [reponame, "--quiet"] "init"
 			setupremote remotename
-			r <- annexeval $ either error return 
+			r <- annexeval $ either giveup return 
 				=<< Remote.byName' remotename
 			cache <- Command.TestRemote.newRemoteVariantCache
 			unavailr <- annexeval $ Types.Remote.mkUnavailable r
@@ -282,8 +282,8 @@
 	, testCase "readonly remote" test_readonly_remote
 	, testCase "ignore deleted files" test_ignore_deleted_files
 	, testCase "metadata" test_metadata
-	, testCase "export_import" test_export_import
-	, testCase "export_import_subdir" test_export_import_subdir
+	, testCase "export and import" test_export_import
+	, testCase "export and import of subdir" test_export_import_subdir
 	, testCase "shared clone" test_shared_clone
 	, testCase "log" test_log
 	, testCase "view" test_view
@@ -354,6 +354,7 @@
 	, testCase "required_content" test_required_content
 	, testCase "add subdirs" test_add_subdirs
 	, testCase "addurl" test_addurl
+	, testCase "repair" test_repair
 	]
   where
 	mk l = testGroup groupname (initTests : map adddep l)
@@ -439,7 +440,7 @@
 				git_annex "get" [annexedfile] "get failed in first repo"
 			make_readonly r1
 			indir r2 $ do
-				git_annex "sync" ["r1", "--no-push"] "sync with readonly repo"
+				git_annex "sync" ["r1", "--no-push", "--no-content"] "sync with readonly repo"
 				git_annex "get" [annexedfile, "--from", "r1"] "get from readonly repo"
 				git "remote" ["rm", "origin"] "remote rm"
 				git_annex "drop" [annexedfile] "drop vs readonly repo"
@@ -511,7 +512,7 @@
 	writeFile "text" "test\n" 
 	git_annex "add" ["binary", "text"]
 		"git-annex add with mimeencoding in largefiles"
-	git_annex "sync" []
+	git_annex "sync" ["--no-content"]
 		"git-annex sync"
 	(isJust <$> annexeval (Annex.CatFile.catKeyFile (encodeBS "binary")))
 		@? "binary file not added to annex despite mimeencoding config"
@@ -1051,13 +1052,13 @@
 	checkunused [] "after rm"
 	-- commit the rm, and when on an adjusted branch, sync it back to
 	-- the master branch
-	git_annex "sync" ["--no-push", "--no-pull"] "git-annex sync"
+	git_annex "sync" ["--no-push", "--no-pull", "--no-content"] "git-annex sync"
 	checkunused [] "after commit"
 	-- unused checks origin/master; once it's gone it is really unused
 	git "remote" ["rm", "origin"] "git remote rm origin"
 	checkunused [annexedfilekey] "after origin branches are gone"
 	git "rm" ["-fq", sha1annexedfile] "git rm"
-	git_annex "sync" ["--no-push", "--no-pull"] "git-annex sync"
+	git_annex "sync" ["--no-push", "--no-pull", "--no-content"] "git-annex sync"
 	checkunused [annexedfilekey, sha1annexedfilekey] "after rm sha1annexedfile"
 
 	-- good opportunity to test dropkey also
@@ -1183,7 +1184,7 @@
 
 test_sync :: Assertion
 test_sync = intmpclonerepo $ do
-	git_annex "sync" [] "sync"
+	git_annex "sync" ["--no-content"] "sync"
 	{- Regression test for bug fixed in
 	 - 039e83ed5d1a11fd562cce55b8429c840d72443e, where a present
 	 - wanted file was dropped. -}
@@ -1239,11 +1240,11 @@
 					git_annex "get" [annexedfile] "get"
 					git "remote" ["rm", "origin"] "remote rm"
 				forM_ [r3, r2, r1] $ \r -> indir r $
-					git_annex "sync" [] ("sync in " ++ r)
+					git_annex "sync" ["--no-content"] ("sync in " ++ r)
 				forM_ [r3, r2] $ \r -> indir r $
 					git_annex "drop" ["--force", annexedfile] ("drop in " ++ r)
 				indir r1 $ do
-					git_annex "sync" [] "sync in r1"
+					git_annex "sync" ["--no-content"] "sync in r1"
 					git_annex_expectoutput "find" ["--in", "r3"] []
 					{- This was the bug. The sync
 					 - mangled location log data and it
@@ -1269,7 +1270,7 @@
 		{- Sync twice in r1 so it gets the conflict resolution
 		 - update from r2 -}
 		forM_ [r1, r2, r1] $ \r -> indir r $
-			git_annex "sync" ["--force"] ("sync in " ++ rname r)
+			git_annex "sync" ["--force", "--no-content"] ("sync in " ++ rname r)
 		{- After the sync, it should be possible to get all
 		 - files. This includes both sides of the conflict,
 		 - although the filenames are not easily predictable.
@@ -1289,15 +1290,15 @@
 				disconnectOrigin
 				writecontent conflictor "conflictor1"
 				add_annex conflictor "add conflicter"
-				git_annex "sync" [] "sync in r1"
+				git_annex "sync" ["--no-content"] "sync in r1"
 			indir r2 $ do
 				disconnectOrigin
 				writecontent conflictor "conflictor2"
 				add_annex conflictor "add conflicter"
-				git_annex "sync" [] "sync in r2"
+				git_annex "sync" ["--no-content"] "sync in r2"
 			pair r1 r2
 			forM_ [r1,r2,r1] $ \r -> indir r $
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 			checkmerge "r1" r1
 			checkmerge "r2" r2
   where
@@ -1322,19 +1323,19 @@
 				disconnectOrigin
 				writecontent conflictor "conflictor1"
 				add_annex conflictor "add conflicter"
-				git_annex "sync" [] "sync in r1"
+				git_annex "sync" ["--no-content"] "sync in r1"
 			indir r2 $ do
 				disconnectOrigin
 				writecontent conflictor "conflictor2"
 				add_annex conflictor "add conflicter"
-				git_annex "sync" [] "sync in r2"
+				git_annex "sync" ["--no-content"] "sync in r2"
 				-- We might be in an adjusted branch
 				-- already, when eg on a crippled
 				-- filesystem. So, --force it.
 				git_annex "adjust" ["--unlock", "--force"] "adjust"
 			pair r1 r2
 			forM_ [r1,r2,r1] $ \r -> indir r $
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 			checkmerge "r1" r1
 			checkmerge "r2" r2
   where
@@ -1363,17 +1364,17 @@
 				disconnectOrigin
 				writecontent conflictor "conflictor"
 				add_annex conflictor "add conflicter"
-				git_annex "sync" [] "sync in r1"
+				git_annex "sync" ["--no-content"] "sync in r1"
 			indir r2 $ do
 				disconnectOrigin
 				createDirectory conflictor
 				writecontent subfile "subfile"
 				add_annex conflictor "add conflicter"
-				git_annex "sync" [] "sync in r2"
+				git_annex "sync" ["--no-content"] "sync in r2"
 			pair r1 r2
 			let l = if inr1 then [r1, r2] else [r2, r1]
 			forM_ l $ \r -> indir r $
-				git_annex "sync" [] "sync in mixed conflict"
+				git_annex "sync" ["--no-content"] "sync in mixed conflict"
 			checkmerge "r1" r1
 			checkmerge "r2" r2
 	conflictor = "conflictor"
@@ -1405,12 +1406,12 @@
 				disconnectOrigin
 				writecontent conflictor "conflictor"
 				add_annex conflictor "add conflicter"
-				git_annex "sync" [] "sync in r1"
+				git_annex "sync" ["--no-content"] "sync in r1"
 			indir r2 $
 				disconnectOrigin
 			pair r1 r2
 			indir r2 $ do
-				git_annex "sync" [] "sync in r2"
+				git_annex "sync" ["--no-content"] "sync in r2"
 				git_annex "get" [conflictor] "get conflictor"
 				git_annex "unlock" [conflictor] "unlock conflictor"
 				writecontent conflictor "newconflictor"
@@ -1418,7 +1419,7 @@
 				removeWhenExistsWith R.removeLink (toRawFilePath conflictor)
 			let l = if inr1 then [r1, r2, r1] else [r2, r1, r2]
 			forM_ l $ \r -> indir r $
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 			checkmerge "r1" r1
 			checkmerge "r2" r2
 	conflictor = "conflictor"
@@ -1445,7 +1446,7 @@
 				disconnectOrigin
 				writecontent conflictor "conflictor"
 				add_annex conflictor "add conflicter"
-				git_annex "sync" [] "sync in r1"
+				git_annex "sync" ["--no-content"] "sync in r1"
 			indir r2 $ do
 				disconnectOrigin
 				writecontent conflictor nonannexed_content
@@ -1454,11 +1455,11 @@
 					, "exclude=" ++ ingitfile ++ " and exclude=" ++ conflictor
 					] "git config annex.largefiles"
 				git "add" [conflictor] "git add conflictor"
-				git_annex "sync" [] "sync in r2"
+				git_annex "sync" ["--no-content"] "sync in r2"
 			pair r1 r2
 			let l = if inr1 then [r1, r2] else [r2, r1]
 			forM_ l $ \r -> indir r $
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 			checkmerge "r1" r1
 			checkmerge "r2" r2
 	conflictor = "conflictor"
@@ -1495,16 +1496,16 @@
 					disconnectOrigin
 					writecontent conflictor "conflictor"
 					add_annex conflictor "add conflicter"
-					git_annex "sync" [] "sync in r1"
+					git_annex "sync" ["--no-content"] "sync in r1"
 				indir r2 $ do
 					disconnectOrigin
 					R.createSymbolicLink (toRawFilePath symlinktarget) (toRawFilePath "conflictor")
 					git "add" [conflictor] "git add conflictor"
-					git_annex "sync" [] "sync in r2"
+					git_annex "sync" ["--no-content"] "sync in r2"
 				pair r1 r2
 				let l = if inr1 then [r1, r2] else [r2, r1]
 				forM_ l $ \r -> indir r $
-					git_annex "sync" [] "sync"
+					git_annex "sync" ["--no-content"] "sync"
 				checkmerge "r1" r1
 				checkmerge "r2" r2
 	conflictor = "conflictor"
@@ -1543,14 +1544,14 @@
 				createDirectoryIfMissing True (fromRawFilePath (parentDir (toRawFilePath remoteconflictor)))
 				writecontent remoteconflictor annexedcontent
 				add_annex conflictor "add remoteconflicter"
-				git_annex "sync" [] "sync in r1"
+				git_annex "sync" ["--no-content"] "sync in r1"
 			indir r2 $ do
 				disconnectOrigin
 				writecontent conflictor localcontent
 			pair r1 r2
 			-- this case is intentionally not handled
 			-- since the user can recover on their own easily
-			indir r2 $ git_annex_shouldfail "sync" [] "sync should not succeed"
+			indir r2 $ git_annex_shouldfail "sync" ["--no-content"] "sync should not succeed"
 	conflictor = "conflictor"
 	localcontent = "local"
 	annexedcontent = "annexed"
@@ -1566,18 +1567,18 @@
 				indir r1 $ do
 					writecontent conflictor "conflictor"
 					git_annex "add" [conflictor] "add conflicter"
-					git_annex "sync" [] "sync in r1"
+					git_annex "sync" ["--no-content"] "sync in r1"
 					check_is_link conflictor "r1"
 				indir r2 $ do
 					createDirectory conflictor
 					writecontent (conflictor </> "subfile") "subfile"
 					git_annex "add" [conflictor] "add conflicter"
-					git_annex "sync" [] "sync in r2"
+					git_annex "sync" ["--no-content"] "sync in r2"
 					check_is_link (conflictor </> "subfile") "r2"
 				indir r3 $ do
 					writecontent conflictor "conflictor"
 					git_annex "add" [conflictor] "add conflicter"
-					git_annex "sync" [] "sync in r1"
+					git_annex "sync" ["--no-content"] "sync in r1"
 					check_is_link (conflictor </> "subfile") "r3"
   where
 	conflictor = "conflictor"
@@ -1598,16 +1599,16 @@
 				disconnectOrigin
 				writecontent conflictor "conflictor"
 				git_annex "add" [conflictor] "add conflicter"
-				git_annex "sync" [] "sync in r1"
+				git_annex "sync" ["--no-content"] "sync in r1"
 			indir r2 $ do
 				disconnectOrigin
 				writecontent conflictor "conflictor"
 				git_annex "add" [conflictor] "add conflicter"
 				git_annex "unlock" [conflictor] "unlock conflicter"
-				git_annex "sync" [] "sync in r2"
+				git_annex "sync" ["--no-content"] "sync in r2"
 			pair r1 r2
 			forM_ [r1,r2,r1] $ \r -> indir r $
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 			checkmerge "r1" r1
 			checkmerge "r2" r2
   where
@@ -1644,9 +1645,9 @@
 		git_annex "adjust" ["--unlock", "--force"] "adjust"
 		writecontent conflictor "conflictor"
 		git_annex "add" [conflictor] "add conflicter"
-		git_annex "sync" [] "sync"
+		git_annex "sync" ["--no-content"] "sync"
 	checkmerge what d = indir d $ whensupported $ do
-		git_annex "sync" [] ("sync should not work in " ++ what)
+		git_annex "sync" ["--no-content"] ("sync should not work in " ++ what)
 		l <- getDirectoryContents "."
 		conflictor `elem` l
 			@? ("conflictor not present after merge in " ++ what)
@@ -1667,11 +1668,11 @@
 			createDirectoryIfMissing True "a/b/c"
 			writecontent "a/b/c/d" "foo"
 			git_annex "add" ["a/b/c"] "add a/b/c"
-			git_annex "sync" [] "sync"
+			git_annex "sync" ["--no-content"] "sync"
 			createDirectoryIfMissing True "a/b/x"
 			writecontent "a/b/x/y" "foo"
 			git_annex "add" ["a/b/x"] "add a/b/x"
-			git_annex "sync" [] "sync"
+			git_annex "sync" ["--no-content"] "sync"
 			git "checkout" [origbranch] "git checkout"
 			doesFileExist "a/b/x/y" @? ("a/b/x/y missing from master after adjusted branch sync")
 
@@ -1807,7 +1808,7 @@
 		testProcess "borg" ["create", borgdir++"::backup1", "."] Nothing (== True) (const True) "borg create"
 
 		git_annex "initremote" (words $ "borg type=borg borgrepo="++borgdir) "initremote"
-		git_annex "sync" ["borg"] "sync borg"
+		git_annex "sync" ["--no-content", "borg"] "sync borg"
 		git_annex_expectoutput "find" ["--in=borg"] []
 
 		git_annex "get" [annexedfile] "get of file"
@@ -1815,7 +1816,7 @@
 		git_annex_expectoutput "find" ["--in=borg"] []
 		
 		testProcess "borg" ["create", borgdir++"::backup2", "."] Nothing (== True) (const True) "borg create"
-		git_annex "sync" ["borg"] "sync borg after getting file"
+		git_annex "sync" ["--no-content", "borg"] "sync borg after getting file"
 		git_annex_expectoutput "find" ["--in=borg"] [annexedfile]
 
 		git "remote" ["rm", "origin"] "remote rm"
@@ -1932,7 +1933,7 @@
 	{- Regression test for Windows bug where symlinks were not
 	 - calculated correctly for files in subdirs. -}
 	unlessM (hasUnlockedFiles <$> getTestMode) $ do
-		git_annex "sync" [] "sync"
+		git_annex "sync" ["--no-content"] "sync"
 		l <- annexeval $ Utility.FileSystemEncoding.decodeBL
 			<$> Annex.CatFile.catObject (Git.Types.Ref (encodeBS "HEAD:dir/foo"))
 		"../.git/annex/" `isPrefixOf` l @? ("symlink from subdir to .git/annex is wrong: " ++ l)
@@ -2008,7 +2009,7 @@
 	-- When on an adjusted branch, this updates the master branch
 	-- to match it, which is necessary since the master branch is going
 	-- to be exported.
-	commitchanges = git_annex "sync" ["--no-pull", "--no-push"] "sync"
+	commitchanges = git_annex "sync" ["--no-pull", "--no-push", "--no-content"] "sync"
 
 test_export_import_subdir :: Assertion
 test_export_import_subdir = intmpclonerepo $ do
@@ -2024,7 +2025,7 @@
 	-- When on an adjusted branch, this updates the master branch
 	-- to match it, which is necessary since the master branch is going
 	-- to be exported.
-	git_annex "sync" ["--no-pull", "--no-push"] "sync"
+	git_annex "sync" ["--no-pull", "--no-push", "--no-content"] "sync"
 
 	-- Run three times because there was a bug that took a couple
 	-- of runs to lead to the wrong tree being written to the remote
@@ -2066,12 +2067,12 @@
 				disconnectOrigin
 				writecontent wormannexedfile $ content wormannexedfile
 				git_annex "add" [wormannexedfile, "--backend=WORM"] "add"
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 			indir r2 $ do
 				disconnectOrigin
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 			indir r1 $ do
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 			indir r2 $ do
 				git_annex "get" [wormannexedfile] "get"
 				git_annex "drop" [wormannexedfile] "drop"
@@ -2079,15 +2080,20 @@
 				git_annex "drop" [wormannexedfile] "drop"
 			indir r1 $ do
 				git_annex "drop" ["--force", wormannexedfile] "drop"
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 				git_annex "forget" ["--force"] "forget"
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 				emptylog
 			indir r2 $ do
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 				emptylog
 			indir r1 $ do
-				git_annex "sync" [] "sync"
+				git_annex "sync" ["--no-content"] "sync"
 				emptylog
   where
 	emptylog = git_annex_expectoutput "log" [wormannexedfile] []
+
+test_repair :: Assertion
+test_repair = intmpclonerepo $
+	-- Simply running repair used to fail on Windows.
+	git_annex "repair" [] "repair"
diff --git a/Test/Framework.hs b/Test/Framework.hs
--- a/Test/Framework.hs
+++ b/Test/Framework.hs
@@ -23,6 +23,7 @@
 import System.Environment (getArgs)
 import System.Console.Concurrent
 import System.Console.ANSI
+import Data.Time.Clock
 import GHC.Conc
 import System.IO.Unsafe (unsafePerformIO)
 import System.PosixCompat.Files (isSymbolicLink, isRegularFile, fileMode, unionFileModes, ownerWriteMode)
@@ -63,6 +64,7 @@
 import qualified Utility.ThreadScheduler
 import qualified Utility.Tmp.Dir
 import qualified Utility.Metered
+import qualified Utility.HumanTime
 import qualified Command.Uninit
 
 -- Run a process. The output and stderr is captured, and is only
@@ -799,11 +801,20 @@
 			(_, _, _, pid) <- createProcessConcurrent p
 			waitForProcess pid
 		nvar <- newTVarIO (1, length ts)
+		starttime <- getCurrentTime
 		exitcodes <- forConcurrently [1..numjobs] $ \_ -> 
 			worker [] nvar runone
 		unless (keepFailuresOption opts) finalCleanup
+		duration <- Utility.HumanTime.durationSince starttime
 		case nub (filter (/= ExitSuccess) (concat exitcodes)) of
-			[] -> exitSuccess
+			[] -> do
+				putStrLn ""
+				putStrLn $ "All tests succeeded. (Ran "
+					++ show (length ts) 
+					++ " test groups in " 
+					++ Utility.HumanTime.fromDuration duration
+					++ ")"
+				exitSuccess
 			[ExitFailure 1] -> do
 				putStrLn "  (Failures above could be due to a bug in git-annex, or an incompatibility"
 				putStrLn "   with utilities, such as git, installed on this system.)"
diff --git a/Types/ActionItem.hs b/Types/ActionItem.hs
--- a/Types/ActionItem.hs
+++ b/Types/ActionItem.hs
@@ -1,32 +1,36 @@
 {- items that a command can act on
  -
- - Copyright 2016-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2016-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
 {-# LANGUAGE TypeSynonymInstances, FlexibleInstances #-}
 
-module Types.ActionItem where
+module Types.ActionItem (
+	module Types.ActionItem,
+	StringContainingQuotedPath(..),
+) where
 
 import Key
 import Types.Transfer
+import Types.UUID
 import Git.FilePath
+import Git.Quote (StringContainingQuotedPath(..))
 import Utility.FileSystemEncoding
 
-import Data.Maybe
-import qualified Data.ByteString as S
-
 data ActionItem 
 	= ActionItemAssociatedFile AssociatedFile Key
 	| ActionItemKey Key
 	| ActionItemBranchFilePath BranchFilePath Key
 	| ActionItemFailedTransfer Transfer TransferInfo
 	| ActionItemTreeFile RawFilePath
-	| ActionItemOther (Maybe String)
-	-- Use to avoid more than one thread concurrently processing the
-	-- same Key.
+	| ActionItemUUID UUID StringContainingQuotedPath
+	-- ^ UUID with a description or name of the repository
+	| ActionItemOther (Maybe StringContainingQuotedPath)
 	| OnlyActionOn Key ActionItem
+	-- ^ Use to avoid more than one thread concurrently processing the
+	-- same Key.
 	deriving (Show, Eq)
 
 class MkActionItem t where
@@ -56,16 +60,21 @@
 instance MkActionItem (Transfer, TransferInfo) where
 	mkActionItem = uncurry ActionItemFailedTransfer
 
-actionItemDesc :: ActionItem -> S.ByteString
-actionItemDesc (ActionItemAssociatedFile (AssociatedFile (Just f)) _) = f
+actionItemDesc :: ActionItem -> StringContainingQuotedPath
+actionItemDesc (ActionItemAssociatedFile (AssociatedFile (Just f)) _) = 
+	QuotedPath f
 actionItemDesc (ActionItemAssociatedFile (AssociatedFile Nothing) k) = 
-	serializeKey' k
-actionItemDesc (ActionItemKey k) = serializeKey' k
-actionItemDesc (ActionItemBranchFilePath bfp _) = descBranchFilePath bfp
+	UnquotedByteString (serializeKey' k)
+actionItemDesc (ActionItemKey k) =
+	UnquotedByteString (serializeKey' k)
+actionItemDesc (ActionItemBranchFilePath bfp _) =
+	descBranchFilePath bfp
 actionItemDesc (ActionItemFailedTransfer t i) = actionItemDesc $
 	ActionItemAssociatedFile (associatedFile i) (transferKey t)
-actionItemDesc (ActionItemTreeFile f) = f
-actionItemDesc (ActionItemOther s) = encodeBS (fromMaybe "" s)
+actionItemDesc (ActionItemTreeFile f) = QuotedPath f
+actionItemDesc (ActionItemUUID _ desc) = desc
+actionItemDesc (ActionItemOther Nothing) = mempty
+actionItemDesc (ActionItemOther (Just v)) = v
 actionItemDesc (OnlyActionOn _ ai) = actionItemDesc ai
 
 actionItemKey :: ActionItem -> Maybe Key
@@ -74,14 +83,20 @@
 actionItemKey (ActionItemBranchFilePath _ k) = Just k
 actionItemKey (ActionItemFailedTransfer t _) = Just (transferKey t)
 actionItemKey (ActionItemTreeFile _) = Nothing
+actionItemKey (ActionItemUUID _ _) = Nothing
 actionItemKey (ActionItemOther _) = Nothing
 actionItemKey (OnlyActionOn _ ai) = actionItemKey ai
 
 actionItemFile :: ActionItem -> Maybe RawFilePath
 actionItemFile (ActionItemAssociatedFile (AssociatedFile af) _) = af
 actionItemFile (ActionItemTreeFile f) = Just f
+actionItemFile (ActionItemUUID _ _) = Nothing
 actionItemFile (OnlyActionOn _ ai) = actionItemFile ai
 actionItemFile _ = Nothing
+
+actionItemUUID :: ActionItem -> Maybe UUID
+actionItemUUID (ActionItemUUID uuid _) = Just uuid
+actionItemUUID _ = Nothing
 
 actionItemTransferDirection :: ActionItem -> Maybe Direction
 actionItemTransferDirection (ActionItemFailedTransfer t _) = Just $
diff --git a/Types/Command.hs b/Types/Command.hs
--- a/Types/Command.hs
+++ b/Types/Command.hs
@@ -24,7 +24,7 @@
 type CommandParser = Parser CommandSeek
 {- b. The check stage runs checks, that error out if
  -    anything prevents the command from running. -}
-data CommandCheck = CommandCheck { idCheck :: Int, runCheck :: Annex () }
+data CommandCheck = CommandCheck { idCheck :: CommandCheckId, runCheck :: Annex () }
 {- c. The seek stage is passed input from the parser, looks through
  -    the repo to find things to act on (ie, new files to add), and
  -    runs commandAction to handle all necessary actions. -}
@@ -136,3 +136,10 @@
 
 newtype DryRun = DryRun Bool
 	deriving (Show)
+
+data CommandCheckId 
+	= CheckNotBareRepo
+	| RepoExists
+	| NoDaemonRunning
+	| GitAnnexShellOk
+	deriving (Show, Ord, Eq)
diff --git a/Types/GitConfig.hs b/Types/GitConfig.hs
--- a/Types/GitConfig.hs
+++ b/Types/GitConfig.hs
@@ -32,6 +32,7 @@
 import Git.ConfigTypes
 import Git.Remote (isRemoteKey, remoteKeyToRemoteName)
 import Git.Branch (CommitMode(..))
+import Git.Quote (QuotePath(..))
 import Utility.DataUnits
 import Config.Cost
 import Types.UUID
@@ -92,7 +93,7 @@
 	, annexHttpHeadersCommand :: Maybe String
 	, annexAutoCommit :: GlobalConfigurable Bool
 	, annexResolveMerge :: GlobalConfigurable Bool
-	, annexSyncContent :: GlobalConfigurable Bool
+	, annexSyncContent :: GlobalConfigurable (Maybe Bool)
 	, annexSyncOnlyAnnex :: GlobalConfigurable Bool
 	, annexDebug :: Bool
 	, annexDebugFilter :: Maybe String
@@ -140,6 +141,7 @@
 	, annexSupportUnlocked :: Bool
 	, coreSymlinks :: Bool
 	, coreSharedRepository :: SharedRepository
+	, coreQuotePath :: QuotePath
 	, receiveDenyCurrentBranch :: DenyCurrentBranch
 	, gcryptId :: Maybe String
 	, gpgCmd :: GpgCmd
@@ -179,7 +181,7 @@
 		getmaybebool (annexConfig "autocommit")
 	, annexResolveMerge = configurable True $ 
 		getmaybebool (annexConfig "resolvemerge")
-	, annexSyncContent = configurable False $ 
+	, annexSyncContent = configurablemaybe $ 
 		getmaybebool (annexConfig "synccontent")
 	, annexSyncOnlyAnnex = configurable False $ 
 		getmaybebool (annexConfig "synconlyannex")
@@ -250,6 +252,7 @@
 	, annexSupportUnlocked = getbool (annexConfig "supportunlocked") True
 	, coreSymlinks = getbool "core.symlinks" True
 	, coreSharedRepository = getSharedRepository r
+	, coreQuotePath = QuotePath (getbool "core.quotepath" True)
 	, receiveDenyCurrentBranch = getDenyCurrentBranch r
 	, gcryptId = getmaybe "core.gcrypt-id"
 	, gpgCmd = mkGpgCmd (getmaybe "gpg.program")
@@ -284,6 +287,11 @@
 	configurable _ (Just v) = case configsource of
 		FromGitConfig -> HasGitConfig v
 		FromGlobalConfig -> HasGlobalConfig v
+	
+	configurablemaybe Nothing = DefaultConfig Nothing
+	configurablemaybe (Just v) = case configsource of
+		FromGitConfig -> HasGitConfig (Just v)
+		FromGlobalConfig -> HasGlobalConfig (Just v)
 
 	onemegabyte = 1000000
 	
diff --git a/Types/Import.hs b/Types/Import.hs
--- a/Types/Import.hs
+++ b/Types/Import.hs
@@ -1,11 +1,11 @@
 {- git-annex import types
  -
- - Copyright 2019-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2019-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveGeneric, DeriveFunctor #-}
 
 module Types.Import where
 
@@ -67,7 +67,7 @@
 	-- locations. So, if a remote does not support Key/Value access,
 	-- it should not populate the importableHistory.
 	}
-	deriving (Show, Generic)
+	deriving (Show, Generic, Functor)
 
 instance NFData info => NFData (ImportableContents info)
 
@@ -81,6 +81,7 @@
 		, importableHistoryComplete :: [ImportableContents info]
 		-- ^ Chunking the history is not supported
 		}
+	deriving (Functor)
 
 {- A chunk of ImportableContents, which is the entire content of a subtree
  - of the main tree. Nested subtrees are not allowed. -}
@@ -92,6 +93,7 @@
 	-- ^ Continuation to get the next chunk.
 	-- Returns Nothing when there are no more chunks.
 	}
+	deriving (Functor)
 
 newtype ImportChunkSubDir = ImportChunkSubDir { importChunkSubDir :: RawFilePath }
 
diff --git a/Types/Messages.hs b/Types/Messages.hs
--- a/Types/Messages.hs
+++ b/Types/Messages.hs
@@ -84,3 +84,9 @@
 data SerializedOutputResponse
 	= ReadyPrompt
 	deriving (Eq, Show)
+
+-- | Message identifiers. Avoid changing these.
+data MessageId
+	= FileNotFound
+	| FileBeyondSymbolicLink
+	deriving (Show)
diff --git a/Types/Transferrer.hs b/Types/Transferrer.hs
--- a/Types/Transferrer.hs
+++ b/Types/Transferrer.hs
@@ -15,6 +15,7 @@
 import Utility.Metered (TotalSize(..))
 
 import Data.Char
+import qualified Data.ByteString.Lazy as L
 
 -- Sent to start a transfer.
 data TransferRequest
@@ -82,9 +83,9 @@
 
 instance Proto.Sendable TransferResponse where
 	formatMessage (TransferOutput (OutputMessage m)) =
-		["om", Proto.serialize (encode_c (decodeBS m))]
+		["om", Proto.serialize (decodeBS (encode_c isUtf8Byte m))]
 	formatMessage (TransferOutput (OutputError e)) =
-		["oe", Proto.serialize (encode_c e)]
+		["oe", Proto.serialize (decodeBS (encode_c isUtf8Byte (encodeBS e)))]
 	formatMessage (TransferOutput BeginProgressMeter) =
 		["opb"]
 	formatMessage (TransferOutput (UpdateProgressMeterTotalSize (TotalSize sz))) =
@@ -98,7 +99,7 @@
 	formatMessage (TransferOutput EndPrompt) =
 		["opre"]
 	formatMessage (TransferOutput (JSONObject b)) =
-		["oj", Proto.serialize (encode_c (decodeBL b))]
+		["oj", Proto.serialize (decodeBS (encode_c isUtf8Byte (L.toStrict b)))]
 	formatMessage (TransferResult True) =
 		["t"]
 	formatMessage (TransferResult False) =
@@ -106,9 +107,9 @@
 
 instance Proto.Receivable TransferResponse where
 	parseCommand "om" = Proto.parse1 $
-		TransferOutput . OutputMessage . encodeBS . decode_c
+		TransferOutput . OutputMessage . decode_c . encodeBS
 	parseCommand "oe" = Proto.parse1 $
-		TransferOutput . OutputError . decode_c
+		TransferOutput . OutputError . decodeBS . decode_c . encodeBS
 	parseCommand "opb" = Proto.parse0 $
 		TransferOutput BeginProgressMeter
 	parseCommand "ops" = Proto.parse1 $
@@ -122,7 +123,7 @@
 	parseCommand "opre" = Proto.parse0 $
 		TransferOutput EndPrompt
 	parseCommand "oj" = Proto.parse1 $
-		TransferOutput . JSONObject . encodeBL . decode_c
+		TransferOutput . JSONObject . L.fromStrict . decode_c . encodeBS
 	parseCommand "t" = Proto.parse0 $
 		TransferResult True
 	parseCommand "f" = Proto.parse0 $
@@ -140,20 +141,22 @@
 	serialize (TransferRemoteUUID u) = 'u':fromUUID u
 	-- A remote name could contain whitespace or newlines, which needs
 	-- to be escaped for the protocol. Use C-style encoding.
-	serialize (TransferRemoteName r) = 'r':encode_c' isSpace r
+	serialize (TransferRemoteName r) = 'r':decodeBS (encode_c is_space_or_unicode (encodeBS r))
+	  where
+		is_space_or_unicode c = isUtf8Byte c || isSpace (chr (fromIntegral c))
 
 	deserialize ('u':u) = Just (TransferRemoteUUID (toUUID u))
-	deserialize ('r':r) = Just (TransferRemoteName (decode_c r))
+	deserialize ('r':r) = Just (TransferRemoteName (decodeBS (decode_c (encodeBS r))))
 	deserialize _ = Nothing
 
 instance Proto.Serializable TransferAssociatedFile where
 	-- Comes last, so whitespace is ok. But, in case the filename
 	-- contains eg a newline, escape it. Use C-style encoding.
 	serialize (TransferAssociatedFile (AssociatedFile (Just f))) =
-		encode_c (fromRawFilePath f)
+		decodeBS (encode_c isUtf8Byte f)
 	serialize (TransferAssociatedFile (AssociatedFile Nothing)) = ""
 
 	deserialize "" = Just $ TransferAssociatedFile $
 		AssociatedFile Nothing
 	deserialize s = Just $ TransferAssociatedFile $
-		AssociatedFile $ Just $ toRawFilePath $ decode_c s
+		AssociatedFile $ Just $ decode_c $ encodeBS s
diff --git a/Types/UUID.hs b/Types/UUID.hs
--- a/Types/UUID.hs
+++ b/Types/UUID.hs
@@ -1,6 +1,6 @@
 {- git-annex UUID type
  -
- - Copyright 2011-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -10,6 +10,7 @@
 module Types.UUID where
 
 import qualified Data.ByteString as B
+import qualified Data.Text as T
 import qualified Data.Map as M
 import qualified Data.UUID as U
 import Data.Maybe
@@ -20,6 +21,7 @@
 import Git.Types (ConfigValue(..))
 import Utility.FileSystemEncoding
 import Utility.QuickCheck
+import Utility.Aeson
 import qualified Utility.SimpleProtocol as Proto
 
 -- A UUID is either an arbitrary opaque string, or UUID info may be missing.
@@ -64,6 +66,18 @@
 -- be NoUUID or perhaps contain something not allowed in a canonical UUID.
 instance ToUUID U.UUID where
 	toUUID = toUUID . U.toASCIIBytes
+
+instance ToJSON' UUID where
+	toJSON' (UUID u) = toJSON' u
+	toJSON' NoUUID = toJSON' ""
+
+instance FromJSON UUID where
+	parseJSON (String t)
+		| isUUID s = pure (toUUID s)
+		| otherwise = mempty
+	  where
+		s = T.unpack t
+	parseJSON _ = mempty
 
 buildUUID :: UUID -> Builder
 buildUUID (UUID b) = byteString b
diff --git a/Upgrade/V0.hs b/Upgrade/V0.hs
--- a/Upgrade/V0.hs
+++ b/Upgrade/V0.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Upgrade.V0 where
 
 import Annex.Common
diff --git a/Upgrade/V1.hs b/Upgrade/V1.hs
--- a/Upgrade/V1.hs
+++ b/Upgrade/V1.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Upgrade.V1 where
 
 import System.Posix.Types
@@ -211,7 +213,7 @@
 		Nothing -> do
 			unless (null kname || null bname ||
 			        not (isLinkToAnnex (toRawFilePath l))) $
-				warning skip
+				warning (UnquotedString skip)
 			return Nothing
 		Just backend -> return $ Just (k, backend)
 	  where
diff --git a/Upgrade/V2.hs b/Upgrade/V2.hs
--- a/Upgrade/V2.hs
+++ b/Upgrade/V2.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Upgrade.V2 where
 
 import Annex.Common
@@ -120,7 +122,7 @@
 			-- no origin exists, so just let the user
 			-- know about the new branch
 			void Annex.Branch.update
-			showLongNote $
+			showLongNote $ UnquotedString $
 				"git-annex branch created\n" ++
 				"Be sure to push this branch when pushing to remotes.\n"
 
diff --git a/Upgrade/V5.hs b/Upgrade/V5.hs
--- a/Upgrade/V5.hs
+++ b/Upgrade/V5.hs
@@ -59,7 +59,7 @@
 	return UpgradeSuccess
   where
 	onexception e = do
-		warning $ "caught exception: " ++ show e
+		warning $ UnquotedString $ "caught exception: " ++ show e
 		return UpgradeFailed
 
 -- git before 2.22 would OOM running git status on a large file.
diff --git a/Upgrade/V6.hs b/Upgrade/V6.hs
--- a/Upgrade/V6.hs
+++ b/Upgrade/V6.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Upgrade.V6 where
 
 import Annex.Common
diff --git a/Upgrade/V8.hs b/Upgrade/V8.hs
--- a/Upgrade/V8.hs
+++ b/Upgrade/V8.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Upgrade.V8 where
 
 import Annex.Common
diff --git a/Upgrade/V9.hs b/Upgrade/V9.hs
--- a/Upgrade/V9.hs
+++ b/Upgrade/V9.hs
@@ -5,6 +5,8 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Upgrade.V9 where
 
 import Annex.Common
@@ -29,7 +31,7 @@
 		)
 	| otherwise = ifM (oldprocessesdanger <&&> (not <$> Annex.getRead Annex.force))
 		( do
-			warning $ unlines unsafeupgrade
+			warning $ UnquotedString $ unlines unsafeupgrade
 			return UpgradeDeferred
 		, performUpgrade automatic
 		)
diff --git a/Utility/Aeson.hs b/Utility/Aeson.hs
--- a/Utility/Aeson.hs
+++ b/Utility/Aeson.hs
@@ -2,7 +2,7 @@
  -
  - Import instead of Data.Aeson
  -
- - Copyright 2018-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2018-2023 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -33,6 +33,7 @@
 import qualified Data.ByteString.Lazy as L
 import qualified Data.ByteString as S
 import qualified Data.Set
+import qualified Data.Map
 import qualified Data.Vector
 import Prelude
 
@@ -99,6 +100,11 @@
 -- Aeson generates the same JSON for a Set as for a list.
 instance ToJSON' s => ToJSON' (Data.Set.Set s) where
 	toJSON' = toJSON . map toJSON' . Data.Set.toList
+
+instance (ToJSON' v) => ToJSON' (Data.Map.Map T.Text v) where
+	toJSON' m = object $ map go (Data.Map.toList m)
+	  where
+		go (k, v) = (textKey k, toJSON' v)
 
 instance (ToJSON' a, ToJSON a) => ToJSON' (Maybe a) where
 	toJSON' (Just a) = toJSON (Just (toJSON' a))
diff --git a/Utility/AuthToken.hs b/Utility/AuthToken.hs
--- a/Utility/AuthToken.hs
+++ b/Utility/AuthToken.hs
@@ -20,6 +20,7 @@
 
 import qualified Utility.SimpleProtocol as Proto
 import Utility.Hash
+import Utility.Exception
 
 import Data.SecureMem
 import Data.Maybe
@@ -79,8 +80,8 @@
 	g <- newGenIO :: IO SystemRandom
 	return $
 		case genBytes 512 g of
-			Left e -> error $ "failed to generate auth token: " ++ show e
-			Right (s, _) -> fromMaybe (error "auth token encoding failed") $
+			Left e -> giveup $ "failed to generate auth token: " ++ show e
+			Right (s, _) -> fromMaybe (giveup "auth token encoding failed") $
 				toAuthToken $ T.pack $ take len $
 					show $ sha2_512 $ L.fromChunks [s]
 
diff --git a/Utility/Base64.hs b/Utility/Base64.hs
--- a/Utility/Base64.hs
+++ b/Utility/Base64.hs
@@ -11,6 +11,7 @@
 
 import Utility.FileSystemEncoding
 import Utility.QuickCheck
+import Utility.Exception
 
 import qualified "sandi" Codec.Binary.Base64 as B64
 import Data.Maybe
@@ -36,12 +37,12 @@
 fromB64 :: String -> String
 fromB64 = fromMaybe bad . fromB64Maybe
   where
-	bad = error "bad base64 encoded data"
+	bad = giveup "bad base64 encoded data"
 
 fromB64' :: B.ByteString -> B.ByteString
 fromB64' = fromMaybe bad . fromB64Maybe'
   where
-	bad = error "bad base64 encoded data"
+	bad = giveup "bad base64 encoded data"
 
 -- Only ascii strings are tested, because an arbitrary string may contain
 -- characters not encoded using the FileSystemEncoding, which would thus
diff --git a/Utility/Exception.hs b/Utility/Exception.hs
--- a/Utility/Exception.hs
+++ b/Utility/Exception.hs
@@ -1,6 +1,6 @@
 {- Simple IO exception handling (and some more)
  -
- - Copyright 2011-2016 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2023 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -20,6 +20,7 @@
 	bracketIO,
 	catchNonAsync,
 	tryNonAsync,
+	nonAsyncHandler,
 	tryWhenExists,
 	catchIOErrorType,
 	IOErrorType(..),
@@ -28,21 +29,24 @@
 
 import Control.Monad.Catch as X hiding (Handler)
 import qualified Control.Monad.Catch as M
-import Control.Exception (IOException, AsyncException)
-import Control.Exception (SomeAsyncException)
+import Control.Exception (IOException, AsyncException, SomeAsyncException)
 import Control.Monad
 import Control.Monad.IO.Class (liftIO, MonadIO)
 import System.IO.Error (isDoesNotExistError, ioeGetErrorType)
 import GHC.IO.Exception (IOErrorType(..))
 
 import Utility.Data
+import Utility.SafeOutput
 
 {- Like error, this throws an exception. Unlike error, if this exception
  - is not caught, it won't generate a backtrace. So use this for situations
  - where there's a problem that the user is expected to see in some
- - circumstances. -}
+ - circumstances.
+ -
+ - Also, control characters are filtered out of the message.
+ -}
 giveup :: [Char] -> a
-giveup = errorWithoutStackTrace
+giveup = errorWithoutStackTrace . safeOutput
 
 {- Catches IO errors and returns a Bool -}
 catchBoolIO :: MonadCatch m => m Bool -> m Bool
@@ -81,11 +85,7 @@
  - ThreadKilled and UserInterrupt get through.
  -}
 catchNonAsync :: MonadCatch m => m a -> (SomeException -> m a) -> m a
-catchNonAsync a onerr = a `catches`
-	[ M.Handler (\ (e :: AsyncException) -> throwM e)
-	, M.Handler (\ (e :: SomeAsyncException) -> throwM e)
-	, M.Handler (\ (e :: SomeException) -> onerr e)
-	]
+catchNonAsync a onerr = a `catches` (nonAsyncHandler onerr)
 
 tryNonAsync :: MonadCatch m => m a -> m (Either SomeException a)
 tryNonAsync a = go `catchNonAsync` (return . Left)
@@ -93,6 +93,13 @@
 	go = do
 		v <- a
 		return (Right v)
+
+nonAsyncHandler :: MonadCatch m => (SomeException -> m a) -> [M.Handler m a]
+nonAsyncHandler onerr = 
+	[ M.Handler (\ (e :: AsyncException) -> throwM e)
+	, M.Handler (\ (e :: SomeAsyncException) -> throwM e)
+	, M.Handler (\ (e :: SomeException) -> onerr e)
+	]
 
 {- Catches only DoesNotExist exceptions, and lets all others through. -}
 tryWhenExists :: MonadCatch m => m a -> m (Maybe a)
diff --git a/Utility/FileMode.hs b/Utility/FileMode.hs
--- a/Utility/FileMode.hs
+++ b/Utility/FileMode.hs
@@ -1,6 +1,6 @@
 {- File mode utilities.
  -
- - Copyright 2010-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -103,17 +103,20 @@
 isExecutable :: FileMode -> Bool
 isExecutable mode = combineModes executeModes `intersectFileModes` mode /= 0
 
-{- Runs an action without that pesky umask influencing it, unless the
- - passed FileMode is the standard one. -}
-noUmask :: (MonadIO m, MonadMask m) => FileMode -> m a -> m a
-#ifndef mingw32_HOST_OS
-noUmask mode a
-	| mode == stdFileMode = a
-	| otherwise = withUmask nullFileMode a
-#else
-noUmask _ a = a
-#endif
+data ModeSetter = ModeSetter FileMode (RawFilePath -> IO ())
 
+{- Runs an action which should create the file, passing it the desired
+ - initial file mode. Then runs the ModeSetter's action on the file, which
+ - can adjust the initial mode if umask prevented the file from being
+ - created with the right mode. -}
+applyModeSetter :: Maybe ModeSetter -> RawFilePath -> (Maybe FileMode -> IO a) -> IO a
+applyModeSetter (Just (ModeSetter mode modeaction)) file a = do
+	r <- a (Just mode)
+	void $ tryIO $ modeaction file
+	return r
+applyModeSetter Nothing _ a = 
+	a Nothing
+
 withUmask :: (MonadIO m, MonadMask m) => FileMode -> m a -> m a
 #ifndef mingw32_HOST_OS
 withUmask umask a = bracket setup cleanup go
@@ -172,10 +175,10 @@
 	(\h -> hPutStr h content)
 
 writeFileProtected' :: RawFilePath -> (Handle -> IO ()) -> IO ()
-writeFileProtected' file writer = protectedOutput $
-	withFile (fromRawFilePath file) WriteMode $ \h -> do
-		void $ tryIO $ modifyFileMode file $ removeModes otherGroupModes
-		writer h
+writeFileProtected' file writer = do
+	h <- protectedOutput $ openFile (fromRawFilePath file) WriteMode
+	void $ tryIO $ modifyFileMode file $ removeModes otherGroupModes
+	writer h
 
 protectedOutput :: IO a -> IO a
 protectedOutput = withUmask 0o0077
diff --git a/Utility/Format.hs b/Utility/Format.hs
--- a/Utility/Format.hs
+++ b/Utility/Format.hs
@@ -1,6 +1,6 @@
 {- Formatted string handling.
  -
- - Copyright 2010-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -9,10 +9,12 @@
 	Format,
 	gen,
 	format,
+	escapedFormat,
 	formatContainsVar,
 	decode_c,
 	encode_c,
 	encode_c',
+	isUtf8Byte,
 	prop_encode_c_decode_c_roundtrip
 ) where
 
@@ -21,12 +23,11 @@
 import Data.Maybe (fromMaybe)
 import Data.Word (Word8)
 import Data.List (isPrefixOf)
-import qualified Codec.Binary.UTF8.String
 import qualified Data.Map as M
+import qualified Data.ByteString as S
 
 import Utility.PartialPrelude
-
-type FormatString = String
+import Utility.FileSystemEncoding
 
 {- A format consists of a list of fragments. -}
 type Format = [Frag]
@@ -53,7 +54,8 @@
   where
 	expand (Const s) = s
 	expand (Var name j esc)
-		| esc = justify j $ encode_c' isSpace $ getvar name
+		| esc = justify j $ decodeBS $ escapedFormat $
+			encodeBS $ getvar name
 		| otherwise = justify j $ getvar name
 	getvar name = fromMaybe "" $ M.lookup name vars
 	justify UnJustified s        = s
@@ -62,6 +64,13 @@
 	pad i s = take (i - length s) spaces
 	spaces = repeat ' '
 
+escapedFormat :: S.ByteString -> S.ByteString
+escapedFormat = encode_c needescape
+  where
+	needescape c = isUtf8Byte c ||
+		isSpace (chr (fromIntegral c)) ||
+		c == fromIntegral (ord '"')
+
 {- Generates a Format that can be used to expand variables in a
  - format string, such as "${foo} ${bar;10} ${baz;-10}\n"
  -
@@ -69,8 +78,8 @@
  -
  - Also, "${escaped_foo}" will apply encode_c to the value of variable foo.
  -}
-gen :: FormatString -> Format
-gen = filter (not . empty) . fuse [] . scan [] . decode_c
+gen :: String -> Format
+gen = filter (not . empty) . fuse [] . scan [] . decodeBS . decode_c . encodeBS
   where
 	-- The Format is built up in reverse, for efficiency,
 	-- and can have many adjacent Consts. Fusing it fixes both
@@ -122,33 +131,50 @@
 {- Decodes a C-style encoding, where \n is a newline (etc),
  - \NNN is an octal encoded character, and \xNN is a hex encoded character.
  -}
-decode_c :: FormatString -> String
-decode_c [] = []
-decode_c s = unescape ("", s)
+decode_c :: S.ByteString -> S.ByteString
+decode_c s
+	| S.null s = S.empty
+	| otherwise = unescape (S.empty, s)
   where
-	e = '\\'
-	unescape (b, []) = b
-	-- look for escapes starting with '\'
-	unescape (b, v) = b ++ fst pair ++ unescape (handle $ snd pair)
+	e = fromIntegral (ord '\\')
+	x = fromIntegral (ord 'x')
+	isescape c = c == e
+	unescape (b, v)
+		| S.null v = b
+		| otherwise = b <> fst pair <> unescape (handle $ snd pair)
 	  where
-		pair = span (/= e) v
-	isescape x = x == e
-	handle (x:'x':n1:n2:rest)
-		| isescape x && allhex = (fromhex, rest)
+		pair = S.span (not . isescape) v
+	handle b
+		| S.length b >= 1 && isescape (S.index b 0) = handle' b
+		| otherwise = (S.empty, b)
+	
+	handle' b
+		| S.length b >= 4
+			&& S.index b 1 == x
+			&& allhex = (fromhex, rest)
 	  where
+		n1 = chr (fromIntegral (S.index b 2))
+		n2 = chr (fromIntegral (S.index b 3))
+	  	rest = S.drop 4 b
 		allhex = isHexDigit n1 && isHexDigit n2
-		fromhex = [chr $ readhex [n1, n2]]
+		fromhex = encodeBS [chr $ readhex [n1, n2]]
 		readhex h = Prelude.read $ "0x" ++ h :: Int
-	handle (x:n1:n2:n3:rest)
-		| isescape x && alloctal = (fromoctal, rest)
+	handle' b
+		| S.length b >= 4 && alloctal = (fromoctal, rest)
 	  where
+		n1 = chr (fromIntegral (S.index b 1))
+		n2 = chr (fromIntegral (S.index b 2))
+		n3 = chr (fromIntegral (S.index b 3))
+	  	rest = S.drop 4 b
 		alloctal = isOctDigit n1 && isOctDigit n2 && isOctDigit n3
-		fromoctal = [chr $ readoctal [n1, n2, n3]]
+		fromoctal = encodeBS [chr $ readoctal [n1, n2, n3]]
 		readoctal o = Prelude.read $ "0o" ++ o :: Int
-	-- \C is used for a few special characters
-	handle (x:nc:rest)
-		| isescape x = ([echar nc], rest)
+	handle' b
+		| S.length b >= 2 = 
+			(S.singleton (fromIntegral (ord (echar nc))), rest)
 	  where
+		nc = chr (fromIntegral (S.index b 1))
+		rest = S.drop 2 b
 		echar 'a' = '\a'
 		echar 'b' = '\b'
 		echar 'f' = '\f'
@@ -156,39 +182,51 @@
 		echar 'r' = '\r'
 		echar 't' = '\t'
 		echar 'v' = '\v'
-		echar a = a
-	handle n = ("", n)
+		echar a = a -- \\ decodes to '\', and \" to '"'
+	handle' b = (S.empty, b)
 
-{- Inverse of decode_c. -}
-encode_c :: String -> FormatString
-encode_c = encode_c' (const False)
+{- Inverse of decode_c. Encodes ascii control characters as well as
+ - bytes that match the predicate. (And also '\' itself.)
+ -}
+encode_c :: (Word8 -> Bool) -> S.ByteString -> S.ByteString
+encode_c p s = fromMaybe s (encode_c' p s)
 
-{- Encodes special characters, as well as any matching the predicate. -}
-encode_c' :: (Char -> Bool) -> String -> FormatString
-encode_c' p = concatMap echar
+{- Returns Nothing when nothing needs to be escaped in the input ByteString. -}
+encode_c' :: (Word8 -> Bool) -> S.ByteString -> Maybe S.ByteString
+encode_c' p s
+	| S.any needencode s = Just (S.concatMap echar s)
+	| otherwise = Nothing
   where
-	e c = '\\' : [c]
-	echar '\a' = e 'a'
-	echar '\b' = e 'b'
-	echar '\f' = e 'f'
-	echar '\n' = e 'n'
-	echar '\r' = e 'r'
-	echar '\t' = e 't'
-	echar '\v' = e 'v'
-	echar '\\' = e '\\'
-	echar '"'  = e '"'
+	e = fromIntegral (ord '\\')
+	q = fromIntegral (ord '"')
+	del = 0x7F
+	iscontrol c = c < 0x20
+
+	echar 0x7 = ec 'a'
+	echar 0x8 = ec 'b'
+	echar 0x0C = ec 'f'
+	echar 0x0A = ec 'n'
+	echar 0x0D = ec 'r'
+	echar 0x09 = ec 't'
+	echar 0x0B = ec 'v'
 	echar c
-		| ord c < 0x20 = e_asc c -- low ascii
-		| ord c >= 256 = e_utf c -- unicode
-		| ord c > 0x7E = e_asc c -- high ascii
-		| p c          = e_asc c
-		| otherwise    = [c]
-	-- unicode character is decomposed to individual Word8s,
-	-- and each is shown in octal
-	e_utf c = showoctal =<< (Codec.Binary.UTF8.String.encode [c] :: [Word8])
-	e_asc c = showoctal $ ord c
-	showoctal i = '\\' : printf "%03o" i
+		| iscontrol c = showoctal c -- other control characters
+		| c == e = ec '\\' -- escape the escape character itself
+		| c == del = showoctal c
+		| p c = if c == q
+			then ec '"' -- escape double quote
+			else showoctal c
+		| otherwise = S.singleton c
+	
+	needencode c = iscontrol c || c == e || c == del || p c
 
+	ec c = S.pack [e, fromIntegral (ord c)]
+
+	showoctal i = encodeBS ('\\' : printf "%03o" i)
+
+isUtf8Byte :: Word8 -> Bool
+isUtf8Byte c = c >= 0x80
+
 {- For quickcheck. 
  -
  - Encoding and then decoding roundtrips only when
@@ -198,6 +236,7 @@
  - This property papers over the problem, by only testing ascii.
  -}
 prop_encode_c_decode_c_roundtrip :: String -> Bool
-prop_encode_c_decode_c_roundtrip s = s' == decode_c (encode_c s')
+prop_encode_c_decode_c_roundtrip s = s' == 
+	decodeBS (decode_c (encode_c isUtf8Byte (encodeBS s')))
   where
 	s' = filter isAscii s
diff --git a/Utility/Gpg.hs b/Utility/Gpg.hs
--- a/Utility/Gpg.hs
+++ b/Utility/Gpg.hs
@@ -248,7 +248,7 @@
 		-- If the userid contains a ":" or a few other special
 		-- characters, gpg will hex-escape it. Use decode_c to
 		-- undo.
-		extract ((keyid, decode_c userid):c) Nothing rest
+		extract ((keyid, decodeBS (decode_c (encodeBS userid))):c) Nothing rest
 	extract c (Just keyid) rest@(("sec":_):_) =
 		extract ((keyid, ""):c) Nothing rest
 	extract c (Just keyid) (_:rest) =
@@ -302,7 +302,10 @@
  - It is armored, to avoid newlines, since gpg only reads ciphers up to the
  - first newline. -}
 genRandom :: GpgCmd -> Bool -> Size -> IO String
-genRandom cmd highQuality size = checksize <$> readStrict cmd params
+genRandom cmd highQuality size = do
+	s <- readStrict cmd params
+	checksize s
+	return s
   where
 	params = 
 		[ Param "--gen-random"
@@ -325,9 +328,8 @@
 	expectedlength = size * 8 `div` 6
 
 	checksize s = let len = length s in
-		if len >= expectedlength
-			then s
-			else shortread len
+		unless (len >= expectedlength) $
+			shortread len
 
 	shortread got = giveup $ unwords
 		[ "Not enough bytes returned from gpg", show params
diff --git a/Utility/Hash.hs b/Utility/Hash.hs
--- a/Utility/Hash.hs
+++ b/Utility/Hash.hs
@@ -10,6 +10,7 @@
 module Utility.Hash (
 	sha1,
 	sha1_context,
+	sha1s,
 	sha2_224,
 	sha2_224_context,
 	sha2_256,
@@ -83,6 +84,9 @@
 
 sha1_context :: Context SHA1
 sha1_context = hashInit
+
+sha1s :: S.ByteString -> Digest SHA1
+sha1s = hash
 
 sha2_224 :: L.ByteString -> Digest SHA224
 sha2_224 = hashlazy
diff --git a/Utility/LockFile/PidLock.hs b/Utility/LockFile/PidLock.hs
--- a/Utility/LockFile/PidLock.hs
+++ b/Utility/LockFile/PidLock.hs
@@ -83,8 +83,7 @@
 trySideLock lockfile a = do
 	sidelock <- sideLockFile lockfile
 	mlck <- catchDefaultIO Nothing $ 
-		withUmask nullFileMode $
-			Posix.tryLockExclusive (Just mode) sidelock
+		Posix.tryLockExclusive (Just modesetter) sidelock
 	-- Check the lock we just took, in case we opened a side lock file
 	-- belonging to another process that will have since deleted it.
 	case mlck of
@@ -100,6 +99,7 @@
 	-- delete another user's lock file there, so could not
 	-- delete a stale lock.
 	mode = combineModes (readModes ++ writeModes)
+	modesetter = ModeSetter mode (\f -> modifyFileMode f (const mode))
 
 dropSideLock :: SideLockHandle -> IO ()
 dropSideLock Nothing = return ()
diff --git a/Utility/LockFile/Posix.hs b/Utility/LockFile/Posix.hs
--- a/Utility/LockFile/Posix.hs
+++ b/Utility/LockFile/Posix.hs
@@ -1,6 +1,6 @@
 {- Posix lock files
  -
- - Copyright 2014 Joey Hess <id@joeyh.name>
+ - Copyright 2014-2023 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -22,6 +22,7 @@
 
 import Utility.Exception
 import Utility.Applicative
+import Utility.FileMode
 import Utility.LockFile.LockStatus
 
 import System.IO
@@ -36,31 +37,31 @@
 newtype LockHandle = LockHandle Fd
 
 -- Takes a shared lock, blocking until the lock is available.
-lockShared :: Maybe FileMode -> LockFile -> IO LockHandle
+lockShared :: Maybe ModeSetter -> LockFile -> IO LockHandle
 lockShared = lock ReadLock
 
 -- Takes an exclusive lock, blocking until the lock is available.
-lockExclusive :: Maybe FileMode -> LockFile -> IO LockHandle
+lockExclusive :: Maybe ModeSetter -> LockFile -> IO LockHandle
 lockExclusive = lock WriteLock
 
 -- Tries to take a shared lock, but does not block.
-tryLockShared :: Maybe FileMode -> LockFile -> IO (Maybe LockHandle)
+tryLockShared :: Maybe ModeSetter -> LockFile -> IO (Maybe LockHandle)
 tryLockShared = tryLock ReadLock
 
 -- Tries to take an exclusive lock, but does not block.
-tryLockExclusive :: Maybe FileMode -> LockFile -> IO (Maybe LockHandle)
+tryLockExclusive :: Maybe ModeSetter -> LockFile -> IO (Maybe LockHandle)
 tryLockExclusive = tryLock WriteLock
 
 -- Setting the FileMode allows creation of a new lock file.
 -- If it's Nothing then this only succeeds when the lock file already exists.
-lock :: LockRequest -> Maybe FileMode -> LockFile -> IO LockHandle
+lock :: LockRequest -> Maybe ModeSetter -> LockFile -> IO LockHandle
 lock lockreq mode lockfile = do
 	l <- openLockFile lockreq mode lockfile
 	waitToSetLock l (lockreq, AbsoluteSeek, 0, 0)
 	return (LockHandle l)
 
 -- Tries to take an lock, but does not block.
-tryLock :: LockRequest -> Maybe FileMode -> LockFile -> IO (Maybe LockHandle)
+tryLock :: LockRequest -> Maybe ModeSetter -> LockFile -> IO (Maybe LockHandle)
 tryLock lockreq mode lockfile = uninterruptibleMask_ $ do
 	l <- openLockFile lockreq mode lockfile
 	v <- tryIO $ setLock l (lockreq, AbsoluteSeek, 0, 0)
@@ -71,9 +72,10 @@
 		Right _ -> return $ Just $ LockHandle l
 
 -- Close on exec flag is set so child processes do not inherit the lock.
-openLockFile :: LockRequest -> Maybe FileMode -> LockFile -> IO Fd
+openLockFile :: LockRequest -> Maybe ModeSetter -> LockFile -> IO Fd
 openLockFile lockreq filemode lockfile = do
-	l <- openFd lockfile openfor filemode defaultFileFlags
+	l <- applyModeSetter filemode lockfile $ \filemode' ->
+		openFd lockfile openfor filemode' defaultFileFlags
 	setFdOption l CloseOnExec True
 	return l
   where
diff --git a/Utility/LockPool/Posix.hs b/Utility/LockPool/Posix.hs
--- a/Utility/LockPool/Posix.hs
+++ b/Utility/LockPool/Posix.hs
@@ -24,6 +24,7 @@
 import qualified Utility.LockPool.STM as P
 import Utility.LockPool.STM (LockFile, LockMode(..))
 import Utility.LockPool.LockHandle
+import Utility.FileMode
 
 import System.IO
 import System.Posix
@@ -32,25 +33,25 @@
 import Prelude
 
 -- Takes a shared lock, blocking until the lock is available.
-lockShared :: Maybe FileMode -> LockFile -> IO LockHandle
+lockShared :: Maybe ModeSetter -> LockFile -> IO LockHandle
 lockShared mode file = fst <$> makeLockHandle P.lockPool file
 	(\p f -> P.waitTakeLock p f LockShared)
 	(\f _ -> mk <$> F.lockShared mode f)
 
 -- Takes an exclusive lock, blocking until the lock is available.
-lockExclusive :: Maybe FileMode -> LockFile -> IO LockHandle
+lockExclusive :: Maybe ModeSetter -> LockFile -> IO LockHandle
 lockExclusive mode file = fst <$> makeLockHandle P.lockPool file
 	(\p f -> P.waitTakeLock p f LockExclusive)
 	(\f _ -> mk <$> F.lockExclusive mode f)
 
 -- Tries to take a shared lock, but does not block.
-tryLockShared :: Maybe FileMode -> LockFile -> IO (Maybe LockHandle)
+tryLockShared :: Maybe ModeSetter -> LockFile -> IO (Maybe LockHandle)
 tryLockShared mode file = fmap fst <$> tryMakeLockHandle P.lockPool file
 	(\p f -> P.tryTakeLock p f LockShared)
 	(\f _ -> fmap mk <$> F.tryLockShared mode f)
 
 -- Tries to take an exclusive lock, but does not block.
-tryLockExclusive :: Maybe FileMode -> LockFile -> IO (Maybe LockHandle)
+tryLockExclusive :: Maybe ModeSetter -> LockFile -> IO (Maybe LockHandle)
 tryLockExclusive mode file = fmap fst <$> tryMakeLockHandle P.lockPool file
 	(\p f -> P.tryTakeLock p f LockExclusive)
 	(\f _ -> fmap mk <$> F.tryLockExclusive mode f)
diff --git a/Utility/Lsof.hs b/Utility/Lsof.hs
--- a/Utility/Lsof.hs
+++ b/Utility/Lsof.hs
@@ -110,4 +110,4 @@
 
 	splitnull = splitc '\0'
 
-	parsefail = error $ "failed to parse lsof output: " ++ show s
+	parsefail = giveup $ "failed to parse lsof output: " ++ show s
diff --git a/Utility/Metered.hs b/Utility/Metered.hs
--- a/Utility/Metered.hs
+++ b/Utility/Metered.hs
@@ -53,6 +53,7 @@
 import Utility.HumanTime
 import Utility.SimpleProtocol as Proto
 import Utility.ThreadScheduler
+import Utility.SafeOutput
 
 import qualified Data.ByteString.Lazy as L
 import qualified Data.ByteString as S
@@ -321,7 +322,7 @@
   where
 	stdouthandler l = 
 		unless (quietMode oh) $
-			putStrLn l
+			putStrLn (safeOutput l)
 
 {- To suppress progress output, while displaying other messages,
  - filter out lines that contain \r (typically used to reset to the
diff --git a/Utility/Process.hs b/Utility/Process.hs
--- a/Utility/Process.hs
+++ b/Utility/Process.hs
@@ -189,11 +189,13 @@
 debugProcess :: CreateProcess -> ProcessHandle -> IO ()
 debugProcess p h = do
 	pid <- getPid h
-	debug "Utility.Process" $ unwords
+	debug "Utility.Process" $ unwords $
 		[ describePid pid
 		, action ++ ":"
 		, showCmd p
-		]
+		] ++ case cwd p of
+			Nothing -> []
+			Just c -> ["in", show c]
   where
 	action
 		| piped (std_in p) && piped (std_out p) = "chat"
diff --git a/Utility/SafeOutput.hs b/Utility/SafeOutput.hs
new file mode 100644
--- /dev/null
+++ b/Utility/SafeOutput.hs
@@ -0,0 +1,36 @@
+{- Safe output to the terminal of possibly attacker-controlled strings,
+ - avoiding displaying control characters.
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, CPP #-}
+{-# OPTIONS_GHC -fno-warn-tabs #-}
+
+module Utility.SafeOutput (
+	safeOutput,
+	safeOutputChar,
+) where
+
+import Data.Char
+import qualified Data.ByteString as S
+
+class SafeOutputtable t where
+	safeOutput :: t -> t
+
+instance SafeOutputtable String where
+	safeOutput = filter safeOutputChar
+
+instance SafeOutputtable S.ByteString where
+	safeOutput = S.filter (safeOutputChar . chr . fromIntegral)
+
+safeOutputChar :: Char -> Bool
+safeOutputChar c
+	| not (isControl c) = True
+	| c == '\n' = True
+	| c == '\t' = True
+	| c == '\DEL' = False
+	| ord c > 31 = True
+	| otherwise = False
diff --git a/Utility/Terminal.hs b/Utility/Terminal.hs
new file mode 100644
--- /dev/null
+++ b/Utility/Terminal.hs
@@ -0,0 +1,45 @@
+{- Determining if output is to a terminal.
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+{-# LANGUAGE CPP, ScopedTypeVariables #-}
+{-# OPTIONS_GHC -fno-warn-tabs #-}
+
+module Utility.Terminal (
+	IsTerminal(..),
+	checkIsTerminal,
+) where
+
+import System.IO
+#ifdef mingw32_HOST_OS
+import System.Win32.MinTTY (isMinTTYHandle)
+import System.Win32.File
+import System.Win32.Types
+import Graphics.Win32.Misc
+import Control.Exception
+#endif
+
+newtype IsTerminal = IsTerminal Bool
+
+checkIsTerminal :: Handle -> IO IsTerminal
+checkIsTerminal h = do
+#ifndef mingw32_HOST_OS
+	b <- hIsTerminalDevice h
+	return (IsTerminal b)
+#else
+	b <- hIsTerminalDevice h
+	if b
+		then return (IsTerminal b)
+		else do
+			h' <- getStdHandle sTD_OUTPUT_HANDLE
+				`catch` \(_ :: IOError) ->
+					return nullHANDLE
+			if h' == nullHANDLE
+				then return (IsTerminal False)
+				else do
+					b' <- isMinTTYHandle h'
+					return (IsTerminal b)
+#endif
diff --git a/Utility/WebApp.hs b/Utility/WebApp.hs
--- a/Utility/WebApp.hs
+++ b/Utility/WebApp.hs
@@ -87,7 +87,7 @@
 	-- getAddrInfo didn't used to work on windows; current status
 	-- unknown.
 	when (isJust h) $
-		error "getSocket with HostName not supported on this OS"
+		giveup "getSocket with HostName not supported on this OS"
 	let addr = tupleToHostAddress (127,0,0,1)
 	sock <- socket AF_INET Stream defaultProtocol
 	preparesocket sock
@@ -99,7 +99,7 @@
 	case (partition (\a -> addrFamily a == AF_INET) addrs) of
 		(v4addr:_, _) -> go v4addr
 		(_, v6addr:_) -> go v6addr
-		_ -> error "unable to bind to a local socket"
+		_ -> giveup "unable to bind to a local socket"
   where
 	hostname = fromMaybe localhost h
 	localhost = "localhost"
@@ -108,7 +108,7 @@
 	 - unknown reason on OSX. -} 
 	go addr = go' 100 addr
 	go' :: Int -> AddrInfo -> IO Socket
-	go' 0 _ = error "unable to bind to local socket"
+	go' 0 _ = giveup "unable to bind to local socket"
 	go' n addr = do
 		r <- tryIO $ bracketOnError (open addr) close (useaddr addr)
 		either (const $ go' (pred n) addr) return r
@@ -129,9 +129,9 @@
 webAppSessionBackend _ = do
 	g <- newGenIO :: IO SystemRandom
 	case genBytes 96 g of
-		Left e -> error $ "failed to generate random key: " ++ show e
+		Left e -> giveup $ "failed to generate random key: " ++ show e
 		Right (s, _) -> case CS.initKey s of
-			Left e -> error $ "failed to initialize key: " ++ show e
+			Left e -> giveup $ "failed to initialize key: " ++ show e
 			Right key -> use key
   where
 	timeout = 120 * 60 -- 120 minutes
diff --git a/doc/git-annex-add.mdwn b/doc/git-annex-add.mdwn
--- a/doc/git-annex-add.mdwn
+++ b/doc/git-annex-add.mdwn
@@ -15,18 +15,20 @@
 Files that are already checked into git and are unmodified, or that
 git has been configured to ignore will be silently skipped.
 
-If annex.largefiles is configured, and does not match a file, 
-`git annex add` will behave the same as `git add` and add the
-non-large file directly to the git repository, instead of to the annex.
-(By default dotfiles are assumed to not be large, and are added directly
-to git, but annex.dotfiles can be configured to annex those too.)
-See the git-annex manpage for documentation of these and other
-configuration settings.
+If annex.largefiles is configured (in git config, gitattributes, or
+git-annex config), and does not match a file, `git annex add` will behave
+the same as `git add` and add the non-large file directly to the git
+repository, instead of to the annex. (By default dotfiles are assumed to
+not be large, and are added directly to git, but annex.dotfiles can be
+configured to annex those too.) See the git-annex manpage for documentation
+of these and other configuration settings.
 
-Large files are added to the annex in locked form, which prevents further
-modification of their content unless unlocked by [[git-annex-unlock]](1).
-(This is not the case however when a repository is in a filesystem not
-supporting symlinks.)
+By default, large files are added to the annex in locked form, which
+prevents further modification of their content until
+unlocked by [[git-annex-unlock]](1). (This is not the case however
+when a repository is in a filesystem not supporting symlinks.)
+The annex.addunlocked git config (and git-annex config) can be used to
+change this behavior.
 
 This command can also be used to add symbolic links, both symlinks to
 annexed content, and other symlinks.
@@ -93,7 +95,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * `--batch`
 
diff --git a/doc/git-annex-addunused.mdwn b/doc/git-annex-addunused.mdwn
--- a/doc/git-annex-addunused.mdwn
+++ b/doc/git-annex-addunused.mdwn
@@ -15,7 +15,17 @@
 
 # OPTIONS
 
-* The [[git-annex-common-options]](1) can be used.
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* The [[git-annex-common-options]](1) can also be used.
 
 # SEE ALSO
 
diff --git a/doc/git-annex-addurl.mdwn b/doc/git-annex-addurl.mdwn
--- a/doc/git-annex-addurl.mdwn
+++ b/doc/git-annex-addurl.mdwn
@@ -10,7 +10,7 @@
 
 Downloads each url to its own file, which is added to the annex.
 
-When `youtube-dl` is installed, it can be used to check for a video
+When `yt-dlp` is installed, it can be used to check for a video
 embedded in  a web page at the url, and that is added to the annex instead.
 (However, this is disabled by default as it can be a security risk. 
 See the documentation of annex.security.allowed-ip-addresses
@@ -45,13 +45,13 @@
   
 * `--raw`
 
-  Prevent special handling of urls by youtube-dl, bittorrent, and other
+  Prevent special handling of urls by yt-dlp, bittorrent, and other
   special remotes. This will for example, make addurl
   download the .torrent file and not the contents it points to.
 
 * `--no-raw`
 
-  Require content pointed to by the url to be downloaded using youtube-dl
+  Require content pointed to by the url to be downloaded using yt-dlp
   or a special remote, rather than the raw content of the url. if that
   cannot be done, the add will fail.
 
@@ -70,7 +70,7 @@
    other modifications.
 
    git-annex will still check the filename for safety, and if the filename
-   has a security problem such as path traversal or an escape sequence,
+   has a security problem such as path traversal or a control character,
    it will refuse to add it.
 
 * `--pathdepth=N`
@@ -134,7 +134,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * `--backend`
 
diff --git a/doc/git-annex-assist.mdwn b/doc/git-annex-assist.mdwn
new file mode 100644
--- /dev/null
+++ b/doc/git-annex-assist.mdwn
@@ -0,0 +1,66 @@
+# NAME
+
+git-annex assist - add files and sync changes with remotes
+
+# SYNOPSIS
+
+git annex assist `[remote ...]`
+
+# DESCRIPTION
+
+This command assists you in checking files into the repository
+and syncing with remotes. It's the simplest possible way to use git-annex
+at the command line, since only this one command needs to be run on a
+regular basis.
+
+This command first adds any new files to the repository, and commits those
+as well as any modified files. Then it does the equivilant of running
+[[git-annex-pull](1) followed by [[git-annex-push]](1).
+
+This command operates on all files in the whole working tree,
+even when ran in a subdirectory. To limit it to operating on files in a
+subdirectory, use the `--content-of` option.
+
+To block some files from being added to the repository, use `.gitignore`
+files.
+
+By default, all files that are added are added to the annex, the same
+as when you run `git annex add`. If you configure annex.largefiles,
+files that it does not match will instead be added with `git add`.
+
+# OPTIONS
+
+* `--message=msg` `-m msg`
+
+  Use this option to specify a commit message.
+
+* `--content-of=path` `-C path`
+
+  Only add, pull, and push files in the given path.
+
+  This option can be repeated multiple times with different paths.
+
+* Also all options supported by [[git-annex-pull]](1) and
+  [[git-annex-push]](1) can be used.
+
+* Also the [[git-annex-common-options]](1) can be used.
+
+# SEE ALSO
+
+[[git-annex]](1)
+
+[[git-annex-add]](1)
+
+[[git-annex-pull]](1)
+
+[[git-annex-push]](1)
+
+[[git-annex-sync]](1)
+
+[[git-annex-assistant]](1)
+
+# AUTHOR
+
+Joey Hess <id@joeyh.name>
+
+Warning: Automatically converted into a man page by mdwn2man. Edit with care.
diff --git a/doc/git-annex-assistant.mdwn b/doc/git-annex-assistant.mdwn
--- a/doc/git-annex-assistant.mdwn
+++ b/doc/git-annex-assistant.mdwn
@@ -1,6 +1,6 @@
 # NAME
 
-git-annex assistant - automatically sync changes
+git-annex assistant - daemon to add files and automatically sync changes
 
 # SYNOPSIS
 
@@ -9,8 +9,18 @@
 # DESCRIPTION
 
 Watches for changes to files in the current directory and its subdirectories,
-and automatically syncs them to other remotes.
+and automatically syncs them to other remotes. This includes adding new
+files. New files published to remotes by others are also automatically
+downloaded.
 
+By default, all new files in the directory will be added to the repository.
+(Including dotfiles.) To block some files from being added, use
+`.gitignore` files.
+  
+By default, all files that are added are added to the annex, the same
+as when you run `git annex add`. If you configure annex.largefiles,
+files that it does not match will instead be added with `git add`.
+
 # OPTIONS
 
 * `--autostart`
@@ -47,6 +57,8 @@
 [[git-annex]](1)
 
 [[git-annex-watch]](1)
+
+[[git-annex-assist]](1)
 
 [[git-annex-schedule]](1)
 
diff --git a/doc/git-annex-config.mdwn b/doc/git-annex-config.mdwn
--- a/doc/git-annex-config.mdwn
+++ b/doc/git-annex-config.mdwn
@@ -10,6 +10,8 @@
 
 git annex config --unset name
 
+git annex config --show-origin name
+
 # DESCRIPTION
 
 Set or get configuration settings stored in the git-annex branch.
@@ -29,13 +31,66 @@
 
 # SUPPORTED SETTINGS
 
+* `annex.numcopies`
+
+  Tells git-annex how many copies it should preserve of files, over all
+  repositories. The default is 1.
+
+  When git-annex is asked to drop a file, it first verifies that the
+  number of copies can be satisfied among all the other
+  repositories that have a copy of the file.
+  
+  In unusual situations, involving special remotes that do not support
+  locking, and concurrent drops of the same content from multiple
+  repositories, git-annex may violate the numcopies setting. It still
+  guarantees at least 1 copy is preserved. This can be configured by
+  setting annex.mincopies.
+
+  This is the same setting that the [[git-annex-numcopies]](1) command
+  configures. It can be overridden on a per-file basis
+  by the annex.numcopies setting in `.gitattributes` files.
+
+* `annex.mincopies`
+
+  Tells git-annex how many copies it is required to preserve of files, 
+  over all repositories. The default is 1.
+
+  This supplements the annex.numcopies setting. 
+  In unusual situations, involving special remotes that do not support
+  locking, and concurrent drops of the same content from multiple
+  repositories, git-annex may violate the numcopies setting.
+  In these unusual situations, git-annex ensures that the number of copies
+  never goes below mincopies.
+  
+  It is a good idea to not only rely on only setting mincopies. Set
+  numcopies as well, to a larger number, and keep mincopies at the
+  bare minimum you're comfortable with. Setting mincopies to a large
+  number, rather than setting numcopies will in some cases prevent
+  droping content in entirely safe situations.
+
+  This is the same setting that the [[git-annex-mincopies]](1) command
+  configures. It can be overridden on a per-file basis
+  by the annex.mincopies setting in `.gitattributes` files.
+
 * `annex.largefiles`
 
   Used to configure which files are large enough to be added to the annex.
   It is an expression that matches the large files, eg
   "`include=*.mp3 or largerthan(500kb)`".
   See [[git-annex-matching-expression]](1) for details on the syntax.
+  
+  This configures the behavior of both git-annex and git when adding
+  files to the repository. By default, `git-annex add` adds all files
+  to the annex (except dotfiles), and `git add` adds files to git
+  (unless they were added to the annex previously).
+  When annex.largefiles is configured, both
+  `git annex add` and `git add` will add matching large files to the
+  annex, and the other files to git.
 
+  Other git-annex commands also honor annex.largefiles, including
+  `git annex import`, `git annex addurl`, `git annex importfeed`,
+  `git-annex assist`, and the `git-annex assistant`.
+
   This sets a default, which can be overridden by annex.largefiles
   attributes in `.gitattributes` files, or by `git config`.
 
@@ -64,8 +119,9 @@
 
 * `annex.autocommit`
 
-  Set to false to prevent the `git-annex assistant` and `git-annex sync`
-  from automatically committing changes to files in the repository.
+  Set to false to prevent the `git-annex assistant`, `git-annex assist`
+  and `git-annex sync` from automatically committing changes to files
+  in the repository.
    
   This sets a default, which can be overridden by annex.autocommit
   in `git config`.
@@ -74,23 +130,27 @@
 
   Set to false to prevent merge conflicts in the checked out branch
   being automatically resolved by the `git-annex assitant`,
-  `git-annex sync`, `git-annex merge`, and the `git-annex post-receive`
-  hook.
+  `git-annex sync`, `git-annex pull`, ``git-annex merge`, 
+  and the `git-annex post-receive` hook.
    
   This sets a default, which can be overridden by annex.resolvemerge
   in `git config`.
 
 * `annex.synccontent`
 
-  Set to true to make git-annex sync default to syncing annexed content.
+  Set to true to make `git-annex sync` default to transferring
+  annexed content.
   
+  Set to false to prevent `git-annex pull` and `git-annex` push from
+  transferring annexed content.
+  
   This sets a default, which can be overridden by annex.synccontent
   in `git config`.
 
 * `annex.synconlyannex`
 
-  Set to true to make git-annex sync default to only sync the git-annex
-  branch and annexed content.
+  Set to true to make `git-annex sync`, `git-annex pull` and `git-annex
+  push` default to only operate on the git-annex branch and annexed content.
   
   This sets a default, which can be overridden by annex.synconlyannex
   in `git config`.
@@ -125,6 +185,24 @@
 * `--unset`
 
   Unset a value.
+
+* `--show-origin name`
+
+  Explain where the value is configured, whether in the git-annex branch,
+  or in a `git config` file, or `.gitattributes` file. When a value is
+  configured in multiple places, displays the place and the value that
+  will be used.
+
+  Note that the parameter can be the name of one of the settings listed
+  above, but also any other configuration setting supported by git-annex.
+  For example, "annex.backend" cannot be set in the git-annex branch, but
+  it can be set in `.gitattributes` or `git config` and this option can
+  explain which setting will be used for it.
+
+* `--for-file file`
+
+  Can be used in combination with `--show-origin` to specify what
+  filename to check for in `.gitattributes`.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-configremote.mdwn b/doc/git-annex-configremote.mdwn
new file mode 100644
--- /dev/null
+++ b/doc/git-annex-configremote.mdwn
@@ -0,0 +1,41 @@
+# NAME
+
+git-annex configremote - changes special remote configuration
+
+# SYNOPSIS
+
+git annex configemote `name|uuid|desc [param=value ...]`
+
+# DESCRIPTION
+
+Changes the configuration of a special remote that was set up earlier
+by `git-annex initremote`. The special remote does not need to be enabled
+for use in the current repository, and this command will not enable it.
+
+This command can currently only be used to change the value of the
+`autoenable` parameter, eg "autoenable=false".
+
+To change other parameters, use `git-annex enableremote`
+
+# OPTIONS
+
+Most options are not prefixed by a dash, and set parameters of the remote,
+as shown above. 
+
+Also, the [[git-annex-common-options]](1) can be used.
+
+# SEE ALSO
+
+[[git-annex]](1)
+
+[[git-annex-initremote]](1)
+
+[[git-annex-configremote]](1)
+
+[[git-annex-renameremote]](1)
+
+# AUTHOR
+
+Joey Hess <id@joeyh.name>
+
+Warning: Automatically converted into a man page by mdwn2man. Edit with care.
diff --git a/doc/git-annex-copy.mdwn b/doc/git-annex-copy.mdwn
--- a/doc/git-annex-copy.mdwn
+++ b/doc/git-annex-copy.mdwn
@@ -125,7 +125,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-dead.mdwn b/doc/git-annex-dead.mdwn
--- a/doc/git-annex-dead.mdwn
+++ b/doc/git-annex-dead.mdwn
@@ -4,7 +4,7 @@
 
 # SYNOPSIS
 
-git annex dead `[repository ...] [--key somekey]`
+git annex dead `[repository ...] [--key somekey ...]`
 
 # DESCRIPTION
 
@@ -27,6 +27,16 @@
 * `--key=somekey`
 
   Use to specify a key that is dead.
+
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-describe.mdwn b/doc/git-annex-describe.mdwn
--- a/doc/git-annex-describe.mdwn
+++ b/doc/git-annex-describe.mdwn
@@ -20,7 +20,17 @@
 
 # OPTIONS
 
-* The [[git-annex-common-options]](1) can be used.
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
 
diff --git a/doc/git-annex-drop.mdwn b/doc/git-annex-drop.mdwn
--- a/doc/git-annex-drop.mdwn
+++ b/doc/git-annex-drop.mdwn
@@ -131,7 +131,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-dropkey.mdwn b/doc/git-annex-dropkey.mdwn
--- a/doc/git-annex-dropkey.mdwn
+++ b/doc/git-annex-dropkey.mdwn
@@ -32,7 +32,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-dropunused.mdwn b/doc/git-annex-dropunused.mdwn
--- a/doc/git-annex-dropunused.mdwn
+++ b/doc/git-annex-dropunused.mdwn
@@ -28,6 +28,16 @@
   the last repository that is storing their content. Data loss can
   result from using this option.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
@@ -35,6 +45,10 @@
 [[git-annex]](1)
 
 [[git-annex-unused]](1)
+
+[[git-annex-drop]](1)
+
+[[git-annex-copy]](1)
 
 # AUTHOR
 
diff --git a/doc/git-annex-enableremote.mdwn b/doc/git-annex-enableremote.mdwn
--- a/doc/git-annex-enableremote.mdwn
+++ b/doc/git-annex-enableremote.mdwn
@@ -59,16 +59,27 @@
 
 # OPTIONS
 
-Most options are not prefixed by a dash, and set parameters of the remote,
-as shown above. 
+* `--json`
 
-Also, the [[git-annex-common-options]](1) can be used.
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex.
 
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* Also, the [[git-annex-common-options]](1) can be used.
+
 # SEE ALSO
 
 [[git-annex]](1)
 
 [[git-annex-initremote]](1)
+
+[[git-annex-configremote]](1)
+
+[[git-annex-renameremote]](1)
 
 # AUTHOR
 
diff --git a/doc/git-annex-examinekey.mdwn b/doc/git-annex-examinekey.mdwn
--- a/doc/git-annex-examinekey.mdwn
+++ b/doc/git-annex-examinekey.mdwn
@@ -20,7 +20,8 @@
   The value is a format string, in which '${var}' is expanded to the
   value of a variable. To right-justify a variable with whitespace,
   use '${var;width}' ; to left-justify a variable, use '${var;-width}';
-  to escape unusual characters in a variable, use '${escaped_var}'
+  to escape unusual characters (including control characters)
+  in a variable, use '${escaped_var}'
 
   To generate a path from the top of the repository to the git-annex
   object for a key, use ${objectpath}. To generate the value of a
@@ -32,6 +33,9 @@
   provided to examinekey).
 
   Also, '\\n' is a newline, '\\000' is a NULL, etc.
+  
+  The default output format is the same as `--format='${escapedkey}\\n'`
+  except when outputting to a terminal, control characters will be escaped.
 
 * `--json`
 
@@ -41,7 +45,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * `--migrate-to-backend=backend`
 
diff --git a/doc/git-annex-expire.mdwn b/doc/git-annex-expire.mdwn
--- a/doc/git-annex-expire.mdwn
+++ b/doc/git-annex-expire.mdwn
@@ -48,6 +48,16 @@
   The first version of git-annex that recorded fsck activity was
   5.20150405.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
diff --git a/doc/git-annex-export.mdwn b/doc/git-annex-export.mdwn
--- a/doc/git-annex-export.mdwn
+++ b/doc/git-annex-export.mdwn
@@ -52,15 +52,13 @@
 not supported. See individual special remotes' documentation for
 details of how to enable such versioning.
 
-The `git annex sync --content` command (and the git-annex assistant)
-can also be used to export a branch to a special remote, 
-updating the special remote whenever the branch is changed.
-To do this, you need to configure "remote.<name>.annex-tracking-branch"
-to tell it what branch to track.
-For example:
+Commands like `git-annex push` can also be used to export a branch to a
+special remote, updating the special remote whenever the branch is changed.
+To do this, you need to configure "remote.<name>.annex-tracking-branch" to
+tell it what branch to track. For example:
 
 	git config remote.myremote.annex-tracking-branch master
-	git annex sync --content
+	git annex push myremote
 
 You can combine using `git annex export` to send changes to a special 
 remote with `git annex import` to fetch changes from a special remote.
@@ -89,7 +87,7 @@
 * `--fast`
 
   This sets up an export of a tree, but avoids any expensive file uploads to
-  the remote. You can later run `git annex sync --content` to upload
+  the remote. You can later run `git annex push` to upload
   the files to the export.
 
 * `--jobs=N` `-JN`
@@ -111,7 +109,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
@@ -162,7 +160,7 @@
 
 [[git-annex-import]](1)
 
-[[git-annex-sync]](1)
+[[git-annex-push]](1)
 
 [[git-annex-preferred-content]](1)
 
diff --git a/doc/git-annex-find.mdwn b/doc/git-annex-find.mdwn
--- a/doc/git-annex-find.mdwn
+++ b/doc/git-annex-find.mdwn
@@ -41,7 +41,8 @@
   The value is a format string, in which '${var}' is expanded to the
   value of a variable. To right-justify a variable with whitespace,
   use '${var;width}' ; to left-justify a variable, use '${var;-width}';
-  to escape unusual characters in a variable, use '${escaped_var}'
+  to escape unusual characters (including control characters)
+  in a variable, use '${escaped_var}'
 
   These variables are available for use in formats: file, key, backend,
   bytesize, humansize, keyname, hashdirlower, hashdirmixed, mtime (for
@@ -49,7 +50,8 @@
 
   Also, '\\n' is a newline, '\\000' is a NULL, etc.
 
-  The default output format is the same as `--format='${file}\\n'`
+  The default output format is the same as `--format='${file}\\n'`,
+  except when outputting to a terminal, control characters will be escaped.
 
 * `--json`
 
@@ -61,7 +63,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * `--batch`
 
diff --git a/doc/git-annex-findkeys.mdwn b/doc/git-annex-findkeys.mdwn
--- a/doc/git-annex-findkeys.mdwn
+++ b/doc/git-annex-findkeys.mdwn
@@ -36,7 +36,8 @@
   The value is a format string, in which '${var}' is expanded to the
   value of a variable. To right-justify a variable with whitespace,
   use '${var;width}' ; to left-justify a variable, use '${var;-width}';
-  to escape unusual characters in a variable, use '${escaped_var}'
+  to escape unusual characters (including control characters)
+  in a variable, use '${escaped_var}'
 
   These variables are available for use in formats: key, backend,
   bytesize, humansize, keyname, hashdirlower, hashdirmixed, mtime (for
@@ -44,7 +45,8 @@
 
   Also, '\\n' is a newline, '\\000' is a NULL, etc.
 
-  The default output format is the same as `--format='${key}\\n'`
+  The default output format is the same as `--format='${escapedkey}\\n'`
+  except when outputting to a terminal, control characters will be escaped.
 
 * `--json`
 
@@ -56,7 +58,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-fix.mdwn b/doc/git-annex-fix.mdwn
--- a/doc/git-annex-fix.mdwn
+++ b/doc/git-annex-fix.mdwn
@@ -24,6 +24,16 @@
   The [[git-annex-matching-options]](1)
   can be used to specify files to fix.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
diff --git a/doc/git-annex-fsck.mdwn b/doc/git-annex-fsck.mdwn
--- a/doc/git-annex-fsck.mdwn
+++ b/doc/git-annex-fsck.mdwn
@@ -108,7 +108,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * `--quiet`
 
diff --git a/doc/git-annex-get.mdwn b/doc/git-annex-get.mdwn
--- a/doc/git-annex-get.mdwn
+++ b/doc/git-annex-get.mdwn
@@ -135,7 +135,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-import.mdwn b/doc/git-annex-import.mdwn
--- a/doc/git-annex-import.mdwn
+++ b/doc/git-annex-import.mdwn
@@ -182,7 +182,7 @@
 
 		git annex import /dir --include='*.png'
 
-## COMMON OPTIONS
+# COMMON OPTIONS
 
 * `--jobs=N` `-JN`
 
@@ -211,7 +211,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-importfeed.mdwn b/doc/git-annex-importfeed.mdwn
--- a/doc/git-annex-importfeed.mdwn
+++ b/doc/git-annex-importfeed.mdwn
@@ -13,7 +13,7 @@
 delete, rename, etc the resulting files and repeated runs won't duplicate
 them.
 
-When `youtube-dl` is installed, it can be used to download links in the feed.
+When `yt-dlp` is installed, it can be used to download links in the feed.
 This allows importing e.g., YouTube playlists.
 (However, this is disabled by default as it can be a security risk. 
 See the documentation of annex.security.allowed-ip-addresses
@@ -54,13 +54,13 @@
 
 * `--raw`
 
-  Prevent special handling of urls by youtube-dl, bittorrent, and other
+  Prevent special handling of urls by yt-dlp, bittorrent, and other
   special remotes. This will for example, make importfeed
   download a .torrent file and not the contents it points to.
 
 * `--no-raw`
 
-  Require content pointed to by the url to be downloaded using youtube-dl
+  Require content pointed to by the url to be downloaded using yt-dlp
   or a special remote, rather than the raw content of the url. if that
   cannot be done, the import will fail, and the next import of the feed
   will retry.
@@ -102,9 +102,29 @@
   url to a file that would be ignored. This makes such files be added
   despite any ignores.
 
+* `--jobs=N` `-JN`
+
+  Runs multiple downloads parallel. For example: `-J4`  
+
+  Setting this to "cpus" will run one job per CPU core.
+
 * `--backend`
 
   Specifies which key-value backend to use.
+
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-progress`
+
+  Include progress objects in JSON output.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-info.mdwn b/doc/git-annex-info.mdwn
--- a/doc/git-annex-info.mdwn
+++ b/doc/git-annex-info.mdwn
@@ -29,7 +29,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * `--bytes`
 
diff --git a/doc/git-annex-init.mdwn b/doc/git-annex-init.mdwn
--- a/doc/git-annex-init.mdwn
+++ b/doc/git-annex-init.mdwn
@@ -57,6 +57,16 @@
 
   Do not enable special remotes that were configured with autoenable=true.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
diff --git a/doc/git-annex-initremote.mdwn b/doc/git-annex-initremote.mdwn
--- a/doc/git-annex-initremote.mdwn
+++ b/doc/git-annex-initremote.mdwn
@@ -47,6 +47,8 @@
 
 	git annex initremote mys3 type=S3 --whatelse
 
+  For a machine-readable list of the parameters, use this with --json.
+
 * `--fast`
 
   When initializing a remote that uses encryption, a cryptographic key is
@@ -80,6 +82,16 @@
   branch. The special remote will only be usable from the repository where
   it was created.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # COMMON CONFIGURATION PARAMETERS
@@ -124,6 +136,8 @@
 [[git-annex]](1)
 
 [[git-annex-enableremote]](1)
+
+[[git-annex-configremote]](1)
 
 [[git-annex-renameremote]](1)
 
diff --git a/doc/git-annex-lock.mdwn b/doc/git-annex-lock.mdwn
--- a/doc/git-annex-lock.mdwn
+++ b/doc/git-annex-lock.mdwn
@@ -31,7 +31,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-log.mdwn b/doc/git-annex-log.mdwn
--- a/doc/git-annex-log.mdwn
+++ b/doc/git-annex-log.mdwn
@@ -39,6 +39,16 @@
 
   Generates output suitable for the `gource` visualization program.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * matching options
   
   The [[git-annex-matching-options]](1)
diff --git a/doc/git-annex-merge.mdwn b/doc/git-annex-merge.mdwn
--- a/doc/git-annex-merge.mdwn
+++ b/doc/git-annex-merge.mdwn
@@ -9,11 +9,11 @@
 # DESCRIPTION
 
 When run without any parameters, this performs the same merging (and merge
-conflict resolution) that is done by the sync command, but without pushing
-or pulling any data.
+conflict resolution) that is done by the `git-annex pull` and `git-annex sync`
+commands, but without uploading or downloading any data.
 
 When a branch to merge is specified, this merges it, using the same merge
-conflict resolution as the sync command. This is especially useful on
+conflict resolution as the `git-annex pull` command. This is especially useful on
 an adjusted branch, because it applies the same adjustment to the
 branch before merging it.
 
@@ -27,11 +27,23 @@
   Passed on to `git merge`, to control whether or not to merge
   histories that do not share a common ancestor.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also, the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
 
 [[git-annex]](1)
+
+[[git-annex-pull]](1)
 
 [[git-annex-sync]](1)
 
diff --git a/doc/git-annex-metadata.mdwn b/doc/git-annex-metadata.mdwn
--- a/doc/git-annex-metadata.mdwn
+++ b/doc/git-annex-metadata.mdwn
@@ -122,7 +122,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * `--batch`
 
diff --git a/doc/git-annex-migrate.mdwn b/doc/git-annex-migrate.mdwn
--- a/doc/git-annex-migrate.mdwn
+++ b/doc/git-annex-migrate.mdwn
@@ -51,6 +51,16 @@
 
   	git-annex migrate --remove-size --backend=URL somefile
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 # SEE ALSO
 
 [[git-annex]](1)
diff --git a/doc/git-annex-mirror.mdwn b/doc/git-annex-mirror.mdwn
--- a/doc/git-annex-mirror.mdwn
+++ b/doc/git-annex-mirror.mdwn
@@ -80,7 +80,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-move.mdwn b/doc/git-annex-move.mdwn
--- a/doc/git-annex-move.mdwn
+++ b/doc/git-annex-move.mdwn
@@ -120,7 +120,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-numcopies.mdwn b/doc/git-annex-numcopies.mdwn
--- a/doc/git-annex-numcopies.mdwn
+++ b/doc/git-annex-numcopies.mdwn
@@ -36,6 +36,7 @@
 
 [[git-annex]](1)
 [[git-annex-mincopies]](1)
+[[git-annex-config]](1)
 
 # AUTHOR
 
diff --git a/doc/git-annex-preferred-content.mdwn b/doc/git-annex-preferred-content.mdwn
--- a/doc/git-annex-preferred-content.mdwn
+++ b/doc/git-annex-preferred-content.mdwn
@@ -40,6 +40,13 @@
   files relative to the current directory, preferred content expressions
   match files relative to the top of the git repository.
 
+  A glob is something like `foo.*` or `b?r`. 
+  Globs can also contain character classes, 
+  like `foo[Bb]ar`, as well as additional POSIX character classes like
+  `[[:space:]]`. Which is useful, since a glob in a preferred content
+  expression cannot contain spaces. See the `glob(7)` man page for more
+  about globs.
+
   For example, suppose you put files into `archive` directories
   when you're done with them. Then you could configure your laptop to prefer
   to not retain those files, like this: `exclude=*/archive/*`
@@ -129,6 +136,13 @@
   Matches only files that have a metadata field attached with a value that
   matches the glob. The values of metadata fields are matched case
   insensitively.
+
+  A glob is something like `foo.*` or `b?r`. 
+  Globs can also contain character classes, 
+  like `foo[Bb]ar`, as well as additional POSIX character classes like
+  `[[:space:]]`. Which is useful, since a glob in a preferred content
+  expression cannot contain spaces. See the `glob(7)` man page for more
+  about globs.
 
   To match a tag "done", use `metadata=tag=done`
 
diff --git a/doc/git-annex-pull.mdwn b/doc/git-annex-pull.mdwn
new file mode 100644
--- /dev/null
+++ b/doc/git-annex-pull.mdwn
@@ -0,0 +1,143 @@
+# NAME
+
+git-annex pull - pull content from remotes
+
+# SYNOPSIS
+
+git annex pull `[remote ...]`
+
+# DESCRIPTION
+
+This command pulls content from remotes. It downloads
+both git repository content, and the content of annexed files.
+Like `git pull`, it merges changes into the current branch.
+
+You can use `git pull` and `git-annex get` by hand to do the same thing as
+this command, but this command handles several details, including making
+sure that the git-annex branch is fetched from the remote.
+
+Some special remotes contain a tree of files that can be imported,
+and this command can be used to pull from those remotes as
+well as regular git remotes. See [[git-annex-import]](1) for details
+about how those special remotes work. In order for this command to import
+from a special remote, `remote.<name>.annex-tracking-branch` also must
+be configured, and have the same value as the currently checked out branch.
+
+When [[git-annex-adjust]](1) has been used to check out an adjusted branch,
+this command will also pull changes from the parent branch.
+
+When [[git-annex-view]](1) has been used to check out a view branch,
+this command will update the view branch to reflect any changes 
+to the parent branch or metadata.
+  
+Normally this tries to download the content of each annexed file,
+from any remote that it's pulling from that has a copy. 
+To control which files it downloads, configure the preferred
+content of the local repository.It will also drop files from a
+remote that are not preferred content of the remote.
+See [[git-annex-preferred-content]](1).
+
+# OPTIONS
+
+* `[remote]`
+
+  By default this command pulls from all remotes, except for remotes
+  that have `remote.<name>.annex-pull` (or `remote.<name>.annex-sync`) 
+  set to false. 
+
+  By specifying the names of remotes (or remote groups), you can control
+  which ones to pull from.
+
+* `--fast`
+
+  Only pull with the remotes with the lowest annex-cost value configured.
+
+  When a list of remotes (or remote groups) is provided, it picks from
+  amoung those, otherwise it picks from amoung all remotes.
+
+* `--only-annex` `-a`, `--not-only-annex`
+
+  Only pull the git-annex branch and annexed content from remotes,
+  not other git branches.
+
+  The `annex.synconlyannex` configuration can be set to true to make
+  this be the default behavior. To override such a setting, use
+  `--not-only-annex`.
+
+  When this is combined with --no-content, only the git-annex branch
+  will be pulled.
+
+* `--no-content, `-g`, `--content`
+
+  Use `--no-content` or `-g` to avoid downloading (and dropping)
+  the content of annexed files.
+
+  If you often use `--no-content`, you can set the `annex.synccontent`
+  configuration to false to prevent downloading content by default.
+  The `--content` option overrides that configuration.
+
+* `--content-of=path` `-C path`
+
+  Only download (and drop) annexed files in the given path.
+
+  This option can be repeated multiple times with different paths.
+
+* `--all` `-A`
+
+  Usually this command operates on annexed files in the current branch.
+  This option makes it operate on all available versions of all annexed files
+  (when preferred content settings allow).
+
+  Note that preferred content settings that use `include=` or `exclude=`
+  will only match the version of files currently in the work tree, but not
+  past versions of files.
+
+* `--jobs=N` `-JN`
+
+  Enables parallel pulling with up to the specified number of jobs
+  running at once. For example: `-J10`
+
+  Setting this to "cpus" will run one job per CPU core.
+
+  (Note that git pulls are not done in parallel because that tends to be
+  less efficient.)
+
+* `--allow-unrelated-histories`, `--no-allow-unrelated-histories`
+
+  Passed on to `git merge`, to control whether or not to merge
+  histories that do not share a common ancestor.
+
+* `--resolvemerge`, `--no-resolvemerge`
+
+  By default, merge conflicts are automatically handled by this command. 
+  When two conflicting versions of a file have been committed, both will
+  be added  to the tree, under different filenames. For example, file "foo"
+  would be replaced with "foo.variant-A" and "foo.variant-B". (See
+  [[git-annex-resolvemerge]](1) for details.)
+
+  Use `--no-resolvemerge` to disable this automatic merge conflict
+  resolution. It can also be disabled by setting `annex.resolvemerge`
+  to false.
+
+* `--backend`
+
+  Specifies which key-value backend to use when importing from a
+  special remote. 
+
+* Also the [[git-annex-common-options]](1) can be used.
+  
+# SEE ALSO
+
+[[git-annex]](1)
+
+[[git-annex-push]](1)
+
+[[git-annex-sync]](1)
+
+[[git-annex-preferred-content]](1)
+
+# AUTHOR
+
+Joey Hess <id@joeyh.name>
+
+Warning: Automatically converted into a man page by mdwn2man. Edit with care.
diff --git a/doc/git-annex-push.mdwn b/doc/git-annex-push.mdwn
new file mode 100644
--- /dev/null
+++ b/doc/git-annex-push.mdwn
@@ -0,0 +1,141 @@
+# NAME
+
+git-annex push - push content to remotes
+
+# SYNOPSIS
+
+git annex push `[remote ...]`
+
+# DESCRIPTION
+
+This command pushes content to remotes. It uploads 
+both git repository content, and the content of annexed files.
+
+You can use `git push` and `git-annex copy` by hand to do the same thing as
+this command, but this command handles several details, including making
+sure that the git-annex branch is pushed to the remote.
+
+When using git-annex, often remotes are not bare repositories, because
+it's helpful to add remotes for nearby machines that you want
+to access the same annexed content. Pushing to a non-bare remote will
+not normally update the remote's current branch with changes from the local
+repository. (Unless the remote is configured with
+receive.denyCurrentBranch=updateInstead.)
+
+To make working with such non-bare remotes easier, this command pushes not
+only local `master` to remote `master`, but also to remote `synced/master`
+(and similar with other branches). When `git-annex pull` (or `git-annex
+sync`) is later run on the remote, it will merge the `synced/` branches
+that were pushed to it.
+
+Some special remotes allow exporting a tree of files to them,
+and this command can be used to push to those remotes as well
+as regular git remotes. See [[git-annex-export]](1) for details
+about how those special remotes work. In order for this command to export
+to a special remote, `remote.<name>.annex-tracking-branch` also must
+be configured, and have the same value as the currently checked out branch.
+
+When [[git-annex-adjust]](1) has been used to check out an adjusted branch,
+this command will propagate changes that have been made back to the 
+parent branch, without propagating the adjustments. 
+
+Normally this tries to upload the content of each annexed file that is
+in the working tree, to any remote that it's pushing to that does not have
+a copy. To control which files are uploaded to a remote, configure the preferred
+content of the remote. When a file is not the preferred content of a remote,
+or of the local repository, this command will try to drop the file's content.
+See [[git-annex-preferred-content]](1).
+
+# OPTIONS
+
+* `[remote]`
+
+  By default, this command pushes to all remotes, except for remotes 
+  that have `remote.<name>.annex-push` (or `remote.<name>.annex-sync`) 
+  set to false or `remote.<name>.annex-readonly` set to true.
+
+  By specifying the names of remotes (or remote groups), you can control which
+  ones to push to.
+
+* `--fast`
+
+  Only push to the remotes with the lowest annex-cost value configured.
+
+  When a list of remotes (or remote groups) is provided, it picks from
+  amoung those, otherwise it picks from amoung all remotes.
+
+* `--only-annex` `-a`, `--not-only-annex`
+
+  Only pull the git-annex branch and annexed content from remotes,
+  not other git branches.
+
+  The `annex.synconlyannex` configuration can be set to true to make
+  this be the default behavior. To override such a setting, use
+  `--not-only-annex`.
+
+  When this is combined with --no-content, only the git-annex branch
+  will be pulled.
+
+* `--no-content`, `-g`, `--content`
+
+  Use `--no-content` or `-g` to avoid uploading (and dropping) the content
+  of annexed files.
+
+  If you often use `--no-content`, you can set the `annex.synccontent`
+  configuration to false to prevent uploading content by default.
+  The `--content` option overrides that configuration.
+
+* `--content-of=path` `-C path`
+
+  Only upload (or drop) annexed files in the given path.
+
+  This option can be repeated multiple times with different paths.
+
+* `--all` `-A`
+
+  Usually this command operates on annexed files in the current branch.
+  This option makes it operate on all available versions of all annexed files
+  (when preferred content settings allow).
+
+  Note that preferred content settings that use `include=` or `exclude=`
+  will only match the version of files currently in the work tree, but not
+  past versions of files.
+
+* `--jobs=N` `-JN`
+
+  Enables parallel pushing with up to the specified number of jobs
+  running at once. For example: `-J10`
+
+  Setting this to "cpus" will run one job per CPU core.
+
+* `--cleanup`
+
+  Removes the local and remote `synced/` branches, which were created
+  and pushed by `git-annex push` or `git-annex sync`. This option
+  prevents all other activities.
+
+  This can come in handy when you've pushed a change to remotes and now
+  want to reset your master branch back before that change. So you
+  run `git reset` and force-push the master branch to remotes, only
+  to find that the next `git annex merge` or `git annex pull` brings the
+  changes back. Why? Because the `synced/master` branch is hanging
+  around and still has the change in it. Cleaning up the `synced/` branches
+  prevents that problem.
+
+* Also the [[git-annex-common-options]](1) can be used.
+
+# SEE ALSO
+
+[[git-annex]](1)
+
+[[git-annex-pull]](1)
+
+[[git-annex-sync]](1)
+
+[[git-annex-preferred-content]](1)
+
+# AUTHOR
+
+Joey Hess <id@joeyh.name>
+
+Warning: Automatically converted into a man page by mdwn2man. Edit with care.
diff --git a/doc/git-annex-registerurl.mdwn b/doc/git-annex-registerurl.mdwn
--- a/doc/git-annex-registerurl.mdwn
+++ b/doc/git-annex-registerurl.mdwn
@@ -56,7 +56,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-reinit.mdwn b/doc/git-annex-reinit.mdwn
--- a/doc/git-annex-reinit.mdwn
+++ b/doc/git-annex-reinit.mdwn
@@ -25,7 +25,17 @@
 
 # OPTIONS
 
-* The [[git-annex-common-options]](1) can be used.
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
 
diff --git a/doc/git-annex-reinject.mdwn b/doc/git-annex-reinject.mdwn
--- a/doc/git-annex-reinject.mdwn
+++ b/doc/git-annex-reinject.mdwn
@@ -54,6 +54,16 @@
   Specify the key-value backend to use when checking if a file is known
   with the `--known` option.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
diff --git a/doc/git-annex-rekey.mdwn b/doc/git-annex-rekey.mdwn
--- a/doc/git-annex-rekey.mdwn
+++ b/doc/git-annex-rekey.mdwn
@@ -34,6 +34,16 @@
   Makes the `--batch` input be delimited by nulls instead of the usual
   newlines.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
diff --git a/doc/git-annex-renameremote.mdwn b/doc/git-annex-renameremote.mdwn
--- a/doc/git-annex-renameremote.mdwn
+++ b/doc/git-annex-renameremote.mdwn
@@ -38,6 +38,8 @@
 
 [[git-annex-enableremote]](1)
 
+[[git-annex-configremote]](1)
+
 # AUTHOR
 
 Joey Hess <id@joeyh.name>
diff --git a/doc/git-annex-rmurl.mdwn b/doc/git-annex-rmurl.mdwn
--- a/doc/git-annex-rmurl.mdwn
+++ b/doc/git-annex-rmurl.mdwn
@@ -28,6 +28,16 @@
   Makes the `--batch` input be delimited by nulls instead of the usual
   newlines.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
diff --git a/doc/git-annex-semitrust.mdwn b/doc/git-annex-semitrust.mdwn
--- a/doc/git-annex-semitrust.mdwn
+++ b/doc/git-annex-semitrust.mdwn
@@ -15,7 +15,17 @@
 
 # OPTIONS
 
-* The [[git-annex-common-options]](1) can be used.
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
 
diff --git a/doc/git-annex-setpresentkey.mdwn b/doc/git-annex-setpresentkey.mdwn
--- a/doc/git-annex-setpresentkey.mdwn
+++ b/doc/git-annex-setpresentkey.mdwn
@@ -21,6 +21,16 @@
   Enables batch mode, in which lines are read from stdin.
   The line format is "key uuid [1|0]"
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
diff --git a/doc/git-annex-status.mdwn b/doc/git-annex-status.mdwn
--- a/doc/git-annex-status.mdwn
+++ b/doc/git-annex-status.mdwn
@@ -29,7 +29,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-sync.mdwn b/doc/git-annex-sync.mdwn
--- a/doc/git-annex-sync.mdwn
+++ b/doc/git-annex-sync.mdwn
@@ -10,77 +10,32 @@
 
 This command synchronizes the local repository with its remotes.
 
-The sync process involves first committing any local changes to files
-that have previously been added to the repository,
-then fetching and merging the current branch and the `git-annex` branch
-from the remote repositories, and finally pushing the changes back to
-those branches on the remote repositories. You can use standard git
-commands to do each of those steps by hand, or if you don't want to
-worry about the details, you can use sync.
-
-The content of annexed objects is not synced by default, but the --content
-option (see below) can make that also be synchronized.
-
-When using git-annex, often remotes are not bare repositories, because
-it's helpful to add remotes for nearby machines that you want
-to access the same annexed content. Syncing with a non-bare remote will
-not normally update the remote's current branch with changes from the local
-repository. (Unless the remote is configured with
-receive.denyCurrentBranch=updateInstead.)
-
-To make working with such non-bare remotes easier, sync pushes not only
-local `master` to remote `master`, but also to remote `synced/master` (and
-similar with other branches). When `git-annex sync` is later run on the
-remote, it will merge the `synced/` branches that the repository has
-received.
-
-Some special remotes contain a tree of files that can be imported
-and/or exported, and syncing with these remotes behaves differently.
-See  [[git-annex-import]](1) and [[git-annex-export]](1) for details
-about how importing and exporting work; syncing with such a remote 
-is essentially an import followed by an export. In many cases,
-importing needs to download content from the remote, and so sync will
-only import when the --content option is used. (And exporting only
-ever happens when --content is used.) The remote's 
-`remote.<name>.annex-tracking-branch` also must be configured, and
-have the same value as the currently checked out branch.
+This command first commits any local changes to files that have
+previously been added to the repository. Then it does the equivilant of
+[[git-annex-pull]](1) followed by [[git-annex-push]](1).
 
-When [[git-annex-adjust]](1) has been used to check out an adjusted branch,
-running sync will propagate changes that have been made back to the 
-parent branch, without propagating the adjustments. When
-[[git-annex-view]](1) has been used to check out a view branch,
-running sync will update the view branch to reflect any changes 
-to the parent branch or metadata.
+However, unlike those commands, this command does not transfer annexed
+content by default. That will change in a future version of git-annex,
 
 # OPTIONS
 
-* `[remote]`
-
-  By default, all remotes are synced, except for remotes that have
-  `remote.<name>.annex-sync` set to false. By specifying the names
-  of remotes (or remote groups), you can control which ones to sync with.
-
-* `--fast`
-
-  Only sync with the remotes with the lowest annex-cost value configured.
+* `--content`, `--no-content`, `-g`
 
-  When a list of remotes (or remote groups) is provided, it picks from
-  amoung those, otherwise it picks from amoung all remotes.
+  The --content option causes the content of annexed files
+  to also be pulled and pushed.
 
-* `--only-annex` `-a`, `--not-only-annex`
+  The --no-content and -g options cause the content of annexed files to
+  not be pulled and pushed.
 
-  Only sync the git-annex branch and annexed content with remotes,
-  not other git branches.
+  The `annex.synccontent` configuration can be set to true to make
+  `--content` be enabled by default.
 
-  This avoids pulling and pushing other branches, and it avoids committing
-  any local changes. It's up to you to use regular git commands to do that.
+* `--content-of=path` `-C path`
 
-  The `annex.synconlyannex` configuration can be set to true to make
-  this be the default behavior of `git-annex sync`. To override such
-  a setting, use `--not-only-annex`.
+  This option causes the content of annexed files in the given
+  path to also be pulled and pushed.
 
-  When this is combined with --no-content, only the git-annex branch
-  will be synced.
+  This option can be repeated multiple times with different paths.
 
 * `--commit`, `--no-commit`
 
@@ -88,110 +43,26 @@
   
   Use --no-commit to avoid committing local changes.
 
-* `--message=msg`
+* `--message=msg` `-m msg`
 
   Use this option to specify a commit message.
 
 * `--pull`, `--no-pull`
 
-  By default, syncing pulls from remotes and imports from some special
-  remotes. Use --no-pull to disable all pulling.
+  Use this option to disable pulling.
 
-  When `remote.<name>.annex-pull` or `remote.<name>.annex-sync`
-  are set to false, pulling is disabled for those remotes, and using
-  `--pull` will not enable it.
+  When `remote.<name>.annex-sync` is set to false, pulling is disabled
+  for that remote, and using `--pull` will not enable it.
 
 * `--push`, `--no-push` 
 
-  By default, syncing pushes changes to remotes and exports to some 
-  special remotes. Use --no-push to disable all pushing.
+  Use this option to disable pushing.
   
-  When `remote.<name>.annex-push` or `remote.<name>.annex-sync` are
-  set to false, or `remote.<name>.annex-readonly` is set to true,
-  pushing is disabled for those remotes, and using `--push` will not enable
-  it.
-
-* `--content`, `--no-content`
-
-  Normally, syncing does not transfer the contents of annexed files.
-  The --content option causes the content of annexed files
-  to also be uploaded and downloaded as necessary, to sync the content
-  between the repository and its remotes.
-
-  The `annex.synccontent` configuration can be set to true to make content
-  be synced by default.
-
-  Normally this tries to get each annexed file that is in the working tree
-  and whose content the local repository does not yet have, from any remote
-  that it's syncing with that has a copy, and then copies each file to
-  every remote that it is syncing with. This behavior can be overridden by
-  configuring the preferred content of repositories. See
-  [[git-annex-preferred-content]](1).
-
-* `--content-of=path` `-C path`
-
-  While --content operates on all annexed files,
-  --content-of allows limiting the transferred files to ones in a given
-  location.
-
-  This option can be repeated multiple times with different paths.
-
-* `--all` `-A`
-
-  This option, when combined with `--content`, makes all available versions
-  of all files be synced, when preferred content settings allow.
-
-  Note that preferred content settings that use `include=` or `exclude=`
-  will only match the version of files currently in the work tree, but not
-  past versions of files.
-
-* `--jobs=N` `-JN`
-
-  Enables parallel syncing with up to the specified number of jobs
-  running at once. For example: `-J10`
-
-  Setting this to "cpus" will run one job per CPU core.
-
-  When there are multiple git remotes, pushes will be made to them in
-  parallel. Pulls are not done in parallel because that tends to be
-  less efficient. When --content is synced, the files are processed
-  in parallel as well.
-
-* `--allow-unrelated-histories`, `--no-allow-unrelated-histories`
-
-  Passed on to `git merge`, to control whether or not to merge
-  histories that do not share a common ancestor.
-
-* `--resolvemerge`, `--no-resolvemerge`
-
-  By default, merge conflicts are automatically handled by sync. When two
-  conflicting versions of a file have been committed, both will be added 
-  to the tree, under different filenames. For example, file "foo" 
-  would be replaced with "foo.variant-A" and "foo.variant-B". (See
-  [[git-annex-resolvemerge]](1) for details.)
-
-  Use `--no-resolvemerge` to disable this automatic merge conflict
-  resolution. It can also be disabled by setting `annex.resolvemerge`
-  to false.
-
-* `--backend`
-
-  Specifies which key-value backend to use when adding files, 
-  or when importing from a special remote. 
-
-* `--cleanup`
-
-  Removes the local and remote `synced/` branches, which were created
-  and pushed by `git-annex sync`. This option prevents all other syncing
-  activities.
+  When `remote.<name>.annex-sync` is set to false, pushing is disabled for
+  that remote, and using `--push` will not enable it.
 
-  This can come in handy when you've synced a change to remotes and now
-  want to reset your master branch back before that change. So you
-  run `git reset` and force-push the master branch to remotes, only
-  to find that the next `git annex merge` or `git annex sync` brings the
-  changes back. Why? Because the `synced/master` branch is hanging
-  around and still has the change in it. Cleaning up the `synced/` branches
-  prevents that problem.
+* Also all options supported by [[git-annex-pull]](1) and
+  [[git-annex-push]](1) can be used.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
@@ -199,7 +70,11 @@
 
 [[git-annex]](1)
 
-[[git-annex-preferred-content]](1)
+[[git-annex-pull]](1)
+
+[[git-annex-push]](1)
+
+[[git-annex-assist]](1)
 
 # AUTHOR
 
diff --git a/doc/git-annex-trust.mdwn b/doc/git-annex-trust.mdwn
--- a/doc/git-annex-trust.mdwn
+++ b/doc/git-annex-trust.mdwn
@@ -23,7 +23,17 @@
 
 # OPTIONS
 
-* The [[git-annex-common-options]](1) can be used.
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
 
diff --git a/doc/git-annex-unannex.mdwn b/doc/git-annex-unannex.mdwn
--- a/doc/git-annex-unannex.mdwn
+++ b/doc/git-annex-unannex.mdwn
@@ -28,6 +28,16 @@
   But use --fast mode with caution, because editing the file will
   change the content in the annex.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * file matching options
 
   The [[git-annex-matching-options]](1)
diff --git a/doc/git-annex-undo.mdwn b/doc/git-annex-undo.mdwn
--- a/doc/git-annex-undo.mdwn
+++ b/doc/git-annex-undo.mdwn
@@ -24,7 +24,17 @@
 
 # OPTIONS
 
-* The [[git-annex-common-options]](1) can be used.
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* The [[git-annex-common-options]](1) can also be used.
 
 # SEE ALSO
 
diff --git a/doc/git-annex-uninit.mdwn b/doc/git-annex-uninit.mdwn
--- a/doc/git-annex-uninit.mdwn
+++ b/doc/git-annex-uninit.mdwn
@@ -14,7 +14,17 @@
 
 # OPTIONS
 
-* The [[git-annex-common-options]](1) can be used.
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
 
diff --git a/doc/git-annex-unlock.mdwn b/doc/git-annex-unlock.mdwn
--- a/doc/git-annex-unlock.mdwn
+++ b/doc/git-annex-unlock.mdwn
@@ -58,7 +58,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-unregisterurl.mdwn b/doc/git-annex-unregisterurl.mdwn
--- a/doc/git-annex-unregisterurl.mdwn
+++ b/doc/git-annex-unregisterurl.mdwn
@@ -47,7 +47,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * Also the [[git-annex-common-options]](1) can be used.
 
diff --git a/doc/git-annex-untrust.mdwn b/doc/git-annex-untrust.mdwn
--- a/doc/git-annex-untrust.mdwn
+++ b/doc/git-annex-untrust.mdwn
@@ -16,7 +16,17 @@
 
 # OPTIONS
 
-* The [[git-annex-common-options]](1) can be used.
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex. Each line of output is a JSON object.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
+* Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
 
diff --git a/doc/git-annex-unused.mdwn b/doc/git-annex-unused.mdwn
--- a/doc/git-annex-unused.mdwn
+++ b/doc/git-annex-unused.mdwn
@@ -45,6 +45,16 @@
   The git configuration annex.used-refspec can be used to configure
   this in a more permanent fashion.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # REFSPEC FORMAT
diff --git a/doc/git-annex-upgrade.mdwn b/doc/git-annex-upgrade.mdwn
--- a/doc/git-annex-upgrade.mdwn
+++ b/doc/git-annex-upgrade.mdwn
@@ -31,6 +31,16 @@
   Only do whatever automatic upgrade can be done, don't necessarily
   upgrade to the latest version. This is used internally by git-annex.
 
+* `--json`
+
+  Enable JSON output. This is intended to be parsed by programs that use
+  git-annex.
+
+* `--json-error-messages`
+
+  Messages that would normally be output to standard error are included in
+  the JSON instead.
+
 * Also the [[git-annex-common-options]](1) can be used.
 
 # SEE ALSO
diff --git a/doc/git-annex-watch.mdwn b/doc/git-annex-watch.mdwn
--- a/doc/git-annex-watch.mdwn
+++ b/doc/git-annex-watch.mdwn
@@ -1,6 +1,6 @@
 # NAME
 
-git-annex watch - watch for changes
+git-annex watch - daemon to watch for changes
 
 # SYNOPSIS
 
@@ -14,7 +14,7 @@
 background, you no longer need to manually run git commands when
 manipulating your files.
   
-By default, all files in the directory will be added to the repository.
+By default, all new files in the directory will be added to the repository.
 (Including dotfiles.) To block some files from being added, use
 `.gitignore` files.
   
diff --git a/doc/git-annex-whereis.mdwn b/doc/git-annex-whereis.mdwn
--- a/doc/git-annex-whereis.mdwn
+++ b/doc/git-annex-whereis.mdwn
@@ -76,7 +76,7 @@
 * `--json-error-messages`
 
   Messages that would normally be output to standard error are included in
-  the json instead.
+  the JSON instead.
 
 * `--format=value`
 
@@ -85,7 +85,8 @@
   The value is a format string, in which '${var}' is expanded to the
   value of a variable. To right-justify a variable with whitespace,
   use '${var;width}' ; to left-justify a variable, use '${var;-width}';
-  to escape unusual characters in a variable, use '${escaped_var}'
+  to escape unusual characters (including control characters)
+  in a variable, use '${escaped_var}'
 
   These variables are available for use in formats: file, key, uuid,
   url, backend, bytesize, humansize, keyname, hashdirlower, hashdirmixed,
diff --git a/doc/git-annex.mdwn b/doc/git-annex.mdwn
--- a/doc/git-annex.mdwn
+++ b/doc/git-annex.mdwn
@@ -114,12 +114,30 @@
   
   See [[git-annex-lock]](1) for details.
 
+* `pull [remote ...]`
+
+  Pull content from remotes.
+  
+  See [[git-annex-pull]](1) for details.
+
+* `push [remote ...]`
+
+  Push content to remotes.
+  
+  See [[git-annex-push]](1) for details.
+
 * `sync [remote ...]`
 
   Synchronize local repository with remotes.
   
   See [[git-annex-sync]](1) for details.
 
+* `assist [remote ...]`
+
+  Add files and sync changes with remotes.
+  
+  See [[git-annex-assist]](1) for details.
+
 * `mirror [path ...] [--to=remote|--from=remote]`
 
   Mirror content of files to/from another repository.
@@ -170,13 +188,13 @@
 
 * `watch`
 
-  Watch for changes and autocommit.
+  Daemon to watch for changes and autocommit.
   
   See [[git-annex-watch]](1) for details.
 
 * `assistant`
 
-  Automatically sync folders between devices.
+  Daemon to automatically sync changes.
 
   See [[git-annex-assistant]](1) for details.
 
@@ -222,6 +240,13 @@
   
   See [[git-annex-enableremote]](1) for details.
 
+* `configremote name [param=value ...]`
+
+  Changes configuration of an existing special remote.
+  
+  See [[git-annex-configremote]](1) for details.
+
+
 * `renameremote`
 
   Renames a special remote.
@@ -699,7 +724,8 @@
 
   Resolves a conflicted merge, by adding both conflicting versions of the
   file to the tree, using variants of their filename. This is done
-  automatically when using `git annex sync` or `git annex merge`.
+  automatically when using `git annex sync` or `git-annex pull`
+  or `git annex merge`.
 
   See [[git-annex-resolvemerge]](1) for details.
 
@@ -884,8 +910,8 @@
   annex, and the other files to git.
 
   Other git-annex commands also honor annex.largefiles, including
-  `git annex import`, `git annex addurl`, `git annex importfeed`
-  and the assistant.
+  `git annex import`, `git annex addurl`, `git annex importfeed`,
+  `git-annex assist`, and the `git-annex assistant`.
 
 * `annex.dotfiles`
 
@@ -1130,7 +1156,10 @@
   Set this to `true` to make unlocked files be a hard link to their content
   in the annex, rather than a second copy. This can save considerable
   disk space, but when a modification is made to a file, you will lose the
-  local (and possibly only) copy of the old version. So, enable with care.
+  local (and possibly only) copy of the old version. Any other, locked
+  files in the repository that pointed to that content will get broken
+  as well (`git-annex fsck` will detect and clean up after that). 
+  So, enable this with care.
 
   After setting (or unsetting) this, you should run `git annex fix` to
   fix up the annexed files in the work tree to be hard links (or copies).
@@ -1156,8 +1185,8 @@
 * `annex.resolvemerge`
 
   Set to false to prevent merge conflicts in the checked out branch
-  being automatically resolved by the git-annex assitant,
-  git-annex sync, git-annex merge,
+  being automatically resolved by the `git-annex assitant`,
+  `git-annex assist`, `git-annex sync`, `git-annex pull`, `git-annex merge`,
   and the git-annex post-receive hook.
 
   To configure the behavior in all clones of the repository,
@@ -1165,15 +1194,20 @@
 
 * `annex.synccontent`
 
-  Set to true to make git-annex sync default to syncing annexed content.
+  Set to true to make `git-annex sync` default to transferring
+  annexed content.
 
+  Set to false to prevent `git-annex assist`, `git-annex pull` and
+  `git-annex push` from transferring annexed content.
+
   To configure the behavior in all clones of the repository,
   this can be set in [[git-annex-config]](1).
 
 * `annex.synconlyannex`
 
-  Set to true to make git-annex sync default to only sincing the git-annex
-  branch and annexed content.
+  Set to true to make `git-annex assist`, `git-annex sync`, 
+  `git-annex pull`, and `git-annex push` default to only operating
+  on the git-annex branch and annexed content.
 
   To configure the behavior in all clones of the repository,
   this can be set in [[git-annex-config]](1).
@@ -1345,7 +1379,7 @@
 * `remote.<name>.annex-ignore`
 
   If set to `true`, prevents git-annex
-  from storing file contents on this remote by default.
+  from storing annexed file contents on this remote by default.
   (You can still request it be used by the `--from` and `--to` options.)
 
   This is, for example, useful if the remote is located somewhere
@@ -1353,8 +1387,9 @@
   Or, it could be used if the network connection between two
   repositories is too slow to be used normally.
 
-  This does not prevent git-annex sync (or the git-annex assistant) from
-  syncing the git repository to the remote.
+  This does not prevent `git-annex sync`, `git-annex pull`, `git-annex push`,
+  `git-annex assist` or the `git-annex assistant` from operating on the
+  git repository.
 
 * `remote.<name>.annex-ignore-command`
 
@@ -1364,9 +1399,9 @@
 
 * `remote.<name>.annex-sync`
 
-  If set to `false`, prevents git-annex sync (and the git-annex assistant)
-  from syncing with this remote by default. However, `git annex sync <name>`
-  can still be used to sync with the remote.
+  If set to `false`, prevents `git-annex sync` (and `git-annex pull`, 
+  `git-annex push`, `git-annex assist`, and the `git-annex assistant`)
+  from operating on this remote by default.
 
 * `remote.<name>.annex-sync-command`
 
@@ -1376,19 +1411,22 @@
 
 * `remote.<name>.annex-pull`
 
-  If set to `false`, prevents git-annex sync (and the git-annex assistant
-  etc) from ever pulling (or fetching) from the remote.
+  If set to `false`, prevents `git-annex pull`, `git-annex sync`,
+  `git-annex assist` and the `git-annex assistant` from ever pulling
+  (or fetching) from the remote.
 
 * `remote.<name>.annex-push`
 
-  If set to `false`, prevents git-annex sync (and the git-annex assistant
-  etc) from ever pushing to the remote.
+  If set to `false`, prevents `git-annex push`, `git-annex sync`,
+  `git-annex assist` and the `git-annex assistant` from ever pushing
+  to the remote.
 
 * `remote.<name>.annex-readonly`
 
   If set to `true`, prevents git-annex from making changes to a remote.
-  This both prevents git-annex sync from pushing changes, and prevents
-  storing or removing files from read-only remote.
+  This prevents `git-annex sync` and `git-annex assist` from pushing
+  changes to a git repository. And it prevents storing or removing
+  files from read-only remote.
 
 * `remote.<name>.annex-verify`, `annex.verify`
 
@@ -1410,9 +1448,11 @@
   When set to eg, "master:subdir", the special remote tracks only
   the subdirectory of that branch.
 
-  `git-annex sync --content` will import changes from the remote and 
-  merge them into the annex-tracking-branch. They also export changes
-  made to the branch to the remote.
+  Setting this enables some other command to work with these special
+  remotes: `git-annex pull` will import changes from the remote and merge them into
+  the annex-tracking-branch. And `git-annex push` will export changes to
+  the remote. Higher-level commands `git-annex sync --content`
+  and `git-annex assist` both import and export.
 
 * `remote.<name>.annex-export-tracking`
 
@@ -1737,19 +1777,19 @@
 
 * `annex.youtube-dl-options`
 
-  Options to pass to youtube-dl (or yt-dlp) when using it to find the url
-  to download for a video.
+  Options to pass to yt-dlp (or deprecated youtube-dl) when using it to
+  find the url to download for a video.
 
-  Some options may break git-annex's integration with youtube-dl. For
+  Some options may break git-annex's integration with yt-dlp. For
   example, the --output option could cause it to store files somewhere
-  git-annex won't find them. Avoid setting here or in the youtube-dl config
-  file any options that cause youtube-dl to download more than one file,
+  git-annex won't find them. Avoid setting here or in the yt-dlp config
+  file any options that cause it to download more than one file,
   or to store the file anywhere other than the current working directory.
 
 * `annex.youtube-dl-command`
 
-  Command to run for youtube-dl. Default is to use "youtube-dl" or 
-  if that is not available in the PATH, to use "yt-dlp".
+  Default is to use "yt-dlp" or if that is not available in the PATH, 
+  to use "youtube-dl".
 
 * `annex.aria-torrent-options`
 
@@ -1800,8 +1840,8 @@
   causing it to be downloaded into your repository and transferred to
   other remotes, exposing its content.
 
-  Note that, since the interfaces of curl and youtube-dl do not allow
-  these IP address restrictions to be enforced, curl and youtube-dl will
+  Note that, since the interfaces of curl and yt-dlp do not allow
+  these IP address restrictions to be enforced, curl and yt-dlp will
   never be used unless annex.security.allowed-ip-addresses=all.
   
   To allow accessing local or private IP addresses on only specific ports,
@@ -1904,8 +1944,9 @@
 
 * `annex.autocommit`
 
-  Set to false to prevent the git-annex assistant and git-annex sync
-  from automatically committing changes to files in the repository.
+  Set to false to prevent the `git-annex assistant`, `git-annex assist`, 
+  and `git-annex sync` from automatically committing changes to files in
+  the repository.
 
   To configure the behavior in all clones of the repository,
   this can be set in [[git-annex-config]](1).
diff --git a/git-annex.cabal b/git-annex.cabal
--- a/git-annex.cabal
+++ b/git-annex.cabal
@@ -1,5 +1,5 @@
 Name: git-annex
-Version: 10.20230407
+Version: 10.20230626
 Cabal-Version: 1.12
 License: AGPL-3
 Maintainer: Joey Hess <id@joeyh.name>
@@ -45,11 +45,13 @@
   doc/git-annex-addunused.mdwn
   doc/git-annex-addurl.mdwn
   doc/git-annex-adjust.mdwn
+  doc/git-annex-assist.mdwn
   doc/git-annex-assistant.mdwn
   doc/git-annex-backends.mdwn
   doc/git-annex-calckey.mdwn
   doc/git-annex-checkpresentkey.mdwn
   doc/git-annex-config.mdwn
+  doc/git-annex-configremote.mdwn
   doc/git-annex-contentlocation.mdwn
   doc/git-annex-copy.mdwn
   doc/git-annex-dead.mdwn
@@ -102,6 +104,8 @@
   doc/git-annex-pre-commit.mdwn
   doc/git-annex-preferred-content.mdwn
   doc/git-annex-proxy.mdwn
+  doc/git-annex-pull.mdwn
+  doc/git-annex-push.mdwn
   doc/git-annex-readpresentkey.mdwn
   doc/git-annex-registerurl.mdwn
   doc/git-annex-reinit.mdwn
@@ -307,7 +311,7 @@
   Build-Depends:
    base (>= 4.11.1.0 && < 5.0),
    network-uri (>= 2.6),
-   optparse-applicative (>= 0.14.1),
+   optparse-applicative (>= 0.14.2),
    containers (>= 0.5.8),
    exceptions (>= 0.6),
    stm (>= 2.3),
@@ -706,11 +710,13 @@
     Command.AddUnused
     Command.AddUrl
     Command.Adjust
+    Command.Assist
     Command.Benchmark
     Command.CalcKey
     Command.CheckPresentKey
     Command.Config
     Command.ConfigList
+    Command.ConfigRemote
     Command.ContentLocation
     Command.Copy
     Command.Dead
@@ -768,6 +774,8 @@
     Command.PostReceive
     Command.PreCommit
     Command.Proxy
+    Command.Pull
+    Command.Push
     Command.ReKey
     Command.ReadPresentKey
     Command.RecvKey
@@ -856,7 +864,6 @@
     Git.Env
     Git.FileMode
     Git.FilePath
-    Git.Filename
     Git.FilterProcess
     Git.Fsck
     Git.GCrypt
@@ -871,6 +878,7 @@
     Git.Objects
     Git.PktLine
     Git.Queue
+    Git.Quote
     Git.Ref
     Git.RefLog
     Git.Remote
@@ -890,6 +898,7 @@
     Limit.Wanted
     Logs
     Logs.Activity
+    Logs.AdjustedBranchUpdate
     Logs.Chunk
     Logs.Chunk.Pure
     Logs.Config
@@ -902,6 +911,7 @@
     Logs.File
     Logs.FsckResults
     Logs.Group
+    Logs.Import
     Logs.Line
     Logs.Location
     Logs.MapLog
@@ -1133,6 +1143,7 @@
     Utility.ResourcePool
     Utility.Rsync
     Utility.SafeCommand
+    Utility.SafeOutput
     Utility.Scheduled
     Utility.Scheduled.QuickCheck
     Utility.Shell
@@ -1143,6 +1154,7 @@
     Utility.SshHost
     Utility.Su
     Utility.SystemDirectory
+    Utility.Terminal
     Utility.TimeStamp
     Utility.TList
     Utility.Tense
diff --git a/git-annex.hs b/git-annex.hs
--- a/git-annex.hs
+++ b/git-annex.hs
@@ -16,6 +16,7 @@
 import qualified CmdLine.GitRemoteTorAnnex
 import qualified Test
 import qualified Benchmark
+import Messages
 import Utility.FileSystemEncoding
 
 #ifdef mingw32_HOST_OS
@@ -24,7 +25,7 @@
 #endif
 
 main :: IO ()
-main = withSocketsDo $ do
+main = sanitizeTopLevelExceptionMessages $ withSocketsDo $ do
 	useFileSystemEncoding
 	ps <- getArgs
 #ifdef mingw32_HOST_OS
