diff --git a/Annex.hs b/Annex.hs
--- a/Annex.hs
+++ b/Annex.hs
@@ -154,6 +154,7 @@
 	, gitconfigadjustment :: (GitConfig -> GitConfig)
 	, gitconfigoverride :: [String]
 	, gitremotes :: Maybe [Git.Repo]
+	, gitconfiginodecache :: Maybe InodeCache
 	, backend :: Maybe (BackendA Annex)
 	, remotes :: [Types.Remote.RemoteA Annex]
 	, output :: MessageState
@@ -214,6 +215,7 @@
 		, gitconfigadjustment = id
 		, gitconfigoverride = []
 		, gitremotes = Nothing
+		, gitconfiginodecache = Nothing
 		, backend = Nothing
 		, remotes = []
 		, output = o
diff --git a/Annex/Content.hs b/Annex/Content.hs
--- a/Annex/Content.hs
+++ b/Annex/Content.hs
@@ -1,6 +1,6 @@
 {- git-annex file content managing
  -
- - Copyright 2010-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -19,6 +19,7 @@
 	RetrievalSecurityPolicy(..),
 	getViaTmp,
 	getViaTmpFromDisk,
+	verificationOfContentFailed,
 	checkDiskSpaceToGet,
 	checkSecureHashes,
 	prepTmp,
@@ -58,6 +59,10 @@
 	Verification(..),
 	unVerified,
 	withTmpWorkDir,
+	KeyStatus(..),
+	isKeyUnlockedThin,
+	getKeyStatus,
+	getKeyFileStatus,
 ) where
 
 import System.IO.Unsafe (unsafeInterleaveIO)
@@ -114,8 +119,14 @@
   where
 	notpresent = giveup $ "failed to lock content: not present"
 #ifndef mingw32_HOST_OS
-	lock contentfile Nothing = tryLockShared Nothing contentfile
-	lock _ (Just lockfile) = posixLocker tryLockShared lockfile
+	lock _ (Just lockfile) = 
+		( posixLocker tryLockShared lockfile
+		, Just (posixLocker tryLockExclusive lockfile)
+		)
+	lock contentfile Nothing =
+		( tryLockShared Nothing contentfile
+		, Nothing
+		)
 #else
 	lock = winLocker lockShared
 #endif
@@ -125,28 +136,33 @@
  -
  - If locking fails, throws an exception rather than running the action.
  -
- - But, if locking fails because the the content is not present, runs the
- - fallback action instead.
+ - If locking fails because the the content is not present, runs the
+ - fallback action instead. However, the content is not guaranteed to be
+ - present when this succeeds.
  -}
 lockContentForRemoval :: Key -> Annex a -> (ContentRemovalLock -> Annex a) -> Annex a
 lockContentForRemoval key fallback a = lockContentUsing lock key fallback $ 
 	a (ContentRemovalLock key)
   where
 #ifndef mingw32_HOST_OS
-	{- Since content files are stored with the write bit disabled, have
-	 - to fiddle with permissions to open for an exclusive lock. -}
-	lock contentfile Nothing = bracket_
-		(thawContent contentfile)
-		(freezeContent contentfile)
-		(tryLockExclusive Nothing contentfile)
-	lock _ (Just lockfile) = posixLocker tryLockExclusive lockfile
+	lock _ (Just lockfile) = (posixLocker tryLockExclusive lockfile, Nothing)
+	{- No lock file, so the content file itself is locked. 
+	 - Since content files are stored with the write bit
+	 - disabled, have to fiddle with permissions to open
+	 - for an exclusive lock. -}
+	lock contentfile Nothing =
+		let lck = bracket_
+			(thawContent contentfile)
+			(freezeContent contentfile)
+			(tryLockExclusive Nothing contentfile)
+		in (lck, Nothing)
 #else
 	lock = winLocker lockExclusive
 #endif
 
 {- Passed the object content file, and maybe a separate lock file to use,
  - when the content file itself should not be locked. -}
-type ContentLocker = RawFilePath -> Maybe LockFile -> Annex (Maybe LockHandle)
+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)
@@ -154,37 +170,49 @@
 	mode <- annexFileMode
 	modifyContent lockfile $
 		takelock (Just mode) lockfile
-	
 #else
 winLocker :: (LockFile -> IO (Maybe LockHandle)) -> ContentLocker
-winLocker takelock _ (Just lockfile) = do
-	modifyContent lockfile $
-		void $ liftIO $ tryIO $
-			writeFile (fromRawFilePath lockfile) ""
-	liftIO $ takelock lockfile
+winLocker takelock _ (Just lockfile) = 
+	let lck = do
+		modifyContent lockfile $
+			void $ liftIO $ tryIO $
+				writeFile (fromRawFilePath lockfile) ""
+		liftIO $ takelock lockfile
+	in (lck, Nothing)
 -- never reached; windows always uses a separate lock file
-winLocker _ _ Nothing = return Nothing
+winLocker _ _ Nothing = (return Nothing, Nothing)
 #endif
 
 {- The fallback action is run if the ContentLocker throws an IO exception
  - and the content is not present. It's not guaranteed to always run when
  - the content is not present, because the content file is not always
- - the file that is locked eg on Windows a different file is locked. -}
+ - the file that is locked. -}
 lockContentUsing :: ContentLocker -> Key -> Annex a -> Annex a -> Annex a
-lockContentUsing locker key fallback a = do
+lockContentUsing contentlocker key fallback a = withContentLockFile key $ \mlockfile -> do
 	contentfile <- calcRepo (gitAnnexLocation key)
-	lockfile <- contentLockFile key
+	let (locker, sharedtoexclusive) = contentlocker contentfile mlockfile
 	bracket
-		(lock contentfile lockfile)
-		(either (const noop) (unlock lockfile))
+		(lock locker mlockfile)
+		(either (const noop) (unlock sharedtoexclusive mlockfile))
 		go
   where
 	alreadylocked = giveup "content is locked"
 	failedtolock e = giveup $ "failed to lock content: " ++ show e
 
-	lock contentfile lockfile = tryIO $
-		maybe alreadylocked return 
-			=<< locker contentfile lockfile
+	lock locker mlockfile = tryIO $ locker >>= \case
+		Nothing -> alreadylocked
+		Just h ->
+#ifndef mingw32_HOST_OS
+			case mlockfile of
+				Nothing -> return h
+				Just lockfile ->
+					ifM (checkSaneLock lockfile h)
+						( return h
+						, alreadylocked
+						)
+#else
+			return h
+#endif
 	
 	go (Right _) = a
 	go (Left e) = ifM (inAnnex key)
@@ -193,19 +221,47 @@
 		)
 
 #ifndef mingw32_HOST_OS
-	unlock mlockfile lck = do
-		maybe noop cleanuplockfile mlockfile
-		liftIO $ dropLock lck
+	unlock sharedtoexclusive mlockfile lck = case (sharedtoexclusive, mlockfile) of
+		-- We have a shared lock, so other processes may also
+		-- have shared locks of the same lock file. To avoid
+		-- deleting the lock file when there are other shared
+		-- locks, try to convert to an exclusive lock, and only
+		-- delete it when that succeeds.
+		--
+		-- Since other processes might be doing the same,
+		-- a race is possible where we open the lock file
+		-- and then another process takes the exclusive lock and
+		-- deletes it, leaving us with an invalid lock. To avoid 
+		-- that race, checkSaneLock is used after taking the lock
+		-- here, and above.
+		(Just exclusivelocker, Just lockfile) -> do
+			liftIO $ dropLock lck
+			exclusivelocker >>= \case
+				Nothing -> return ()
+				Just h -> do
+					whenM (checkSaneLock lockfile h) $ do
+						cleanuplockfile lockfile
+					liftIO $ dropLock h
+		-- We have an exclusive lock, so no other process can have
+		-- the lock file locked, and so it's safe to remove it, as 
+		-- long as all lock attempts use checkSaneLock.
+		_ -> do
+			maybe noop cleanuplockfile mlockfile
+			liftIO $ dropLock lck
 #else
-	unlock mlockfile lck = do
-		-- Can't delete a locked file on Windows
+	unlock _ mlockfile lck = do
+		-- Can't delete a locked file on Windows,
+		-- so close our lock first. If there are other shared
+		-- locks, they will prevent the file deletion from
+		-- happening.
 		liftIO $ dropLock lck
 		maybe noop cleanuplockfile mlockfile
 #endif
 
-	cleanuplockfile lockfile = modifyContent lockfile $
-		void $ liftIO $ tryIO $
-			removeWhenExistsWith R.removeLink lockfile
+	cleanuplockfile lockfile = void $ tryNonAsync $ do
+		thawContentDir lockfile
+		liftIO $ removeWhenExistsWith R.removeLink lockfile
+		liftIO $ cleanObjectDirs lockfile
 
 {- Runs an action, passing it the temp file to get,
  - and if the action succeeds, verifies the file matches
@@ -235,16 +291,7 @@
 		then ifM (verifyKeyContentPostRetrieval rsp v verification' key tmpfile)
 			( pruneTmpWorkDirBefore tmpfile (moveAnnex key af)
 			, do
-				warning "verification of content failed"
-				-- The bad content is not retained, because
-				-- a retry should not try to resume from it
-				-- since it's apparently corrupted.
-				-- Also, the bad content could be any data,
-				-- including perhaps the content of another
-				-- file than the one that was requested,
-				-- and so it's best not to keep it on disk.
-				pruneTmpWorkDirBefore tmpfile
-					(liftIO . removeWhenExistsWith R.removeLink)
+				verificationOfContentFailed tmpfile
 				return False
 			)
 		-- On transfer failure, the tmp file is left behind, in case
@@ -263,6 +310,24 @@
 				)
 			)
 
+{- When the content of a file that was successfully transferred from a remote
+ - fails to verify, use this to display a message so the user knows why it
+ - failed, and to clean up the corrupted content.
+ -
+ - The bad content is not retained, because the transfer of it succeeded.
+ - So it's not incomplete and a resume using it will not work. While
+ - some protocols like rsync could recover such a bad content file,
+ - they are assumed to not write out bad data to a file in the first place.
+ - Most protocols, including the P2P protocol, pick up downloads where they
+ - left off, and so if the bad content were not deleted, repeated downloads
+ - would continue to fail.
+ -}
+verificationOfContentFailed :: RawFilePath -> Annex ()
+verificationOfContentFailed tmpfile = do
+	warning "Verification of content failed"
+	pruneTmpWorkDirBefore tmpfile
+		(liftIO . removeWhenExistsWith R.removeLink)
+
 {- Checks if there is enough free disk space to download a key
  - to its temp file.
  -
@@ -533,12 +598,15 @@
 	file <- calcRepo (gitAnnexLocation key)
 	void $ tryIO $ thawContentDir file
 	cleaner
-	liftIO $ removeparents file (3 :: Int)
+	liftIO $ cleanObjectDirs file
+
+cleanObjectDirs :: RawFilePath -> IO ()
+cleanObjectDirs = go (3 :: Int)
   where
-	removeparents _ 0 = noop
-	removeparents file n = do
+	go 0 _ = noop
+	go n file = do
 		let dir = parentDir file
-		maybe noop (const $ removeparents dir (n-1))
+		maybe noop (const $ go (n-1) dir)
 			<=< catchMaybeIO $ removeDirectory (fromRawFilePath dir)
 
 {- Removes a key's file from .git/annex/objects/ -}
@@ -780,3 +848,40 @@
 exclude smaller larger = S.toList $ remove larger $ S.fromList smaller
   where
 	remove a b = foldl (flip S.delete) b a
+
+data KeyStatus
+	= KeyMissing
+	| KeyPresent
+	| KeyUnlockedThin
+	-- ^ An annex.thin worktree file is hard linked to the object.
+	| KeyLockedThin
+	-- ^ The object has hard links, but the file being fscked
+	-- is not the one that hard links to it.
+	deriving (Show)
+
+isKeyUnlockedThin :: KeyStatus -> Bool
+isKeyUnlockedThin KeyUnlockedThin = True
+isKeyUnlockedThin KeyLockedThin = False
+isKeyUnlockedThin KeyPresent = False
+isKeyUnlockedThin KeyMissing = False
+
+getKeyStatus :: Key -> Annex KeyStatus
+getKeyStatus key = catchDefaultIO KeyMissing $ do
+	afs <- not . null <$> Database.Keys.getAssociatedFiles key
+	obj <- calcRepo (gitAnnexLocation key)
+	multilink <- ((> 1) . linkCount <$> liftIO (R.getFileStatus obj))
+	return $ if multilink && afs
+		then KeyUnlockedThin
+		else KeyPresent
+
+getKeyFileStatus :: Key -> FilePath -> Annex KeyStatus
+getKeyFileStatus key file = do
+	s <- getKeyStatus key
+	case s of
+		KeyUnlockedThin -> catchDefaultIO KeyUnlockedThin $
+			ifM (isJust <$> isAnnexLink (toRawFilePath file))
+				( return KeyLockedThin
+				, return KeyUnlockedThin
+				)
+		_ -> return s
+
diff --git a/Annex/Content/Presence.hs b/Annex/Content/Presence.hs
--- a/Annex/Content/Presence.hs
+++ b/Annex/Content/Presence.hs
@@ -1,11 +1,12 @@
 {- git-annex object content presence
  -
- - Copyright 2010-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
 {-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 module Annex.Content.Presence (
 	inAnnex,
@@ -17,22 +18,29 @@
 	isUnmodified,
 	isUnmodified',
 	isUnmodifiedCheap,
-	contentLockFile,
+	withContentLockFile,
 ) where
 
 import Annex.Content.Presence.LowLevel
 import Annex.Common
 import qualified Annex
 import Annex.LockPool
+import Annex.LockFile
+import Annex.Version
+import Types.RepoVersion
 import qualified Database.Keys
 import Annex.InodeSentinal
 import Utility.InodeCache
 import qualified Utility.RawFilePath as R
+import qualified Git
+import Config
 
 #ifdef mingw32_HOST_OS
 import Annex.Perms
 #endif
 
+import qualified System.FilePath.ByteString as P
+
 {- Checks if a given key's content is currently present. -}
 inAnnex :: Key -> Annex Bool
 inAnnex key = inAnnexCheck key $ liftIO . R.doesPathExist
@@ -76,7 +84,7 @@
 	is_unlocked = Just True
 	is_missing = Just False
 
-	go contentfile = flip checklock contentfile =<< contentLockFile key
+	go contentfile = withContentLockFile key $ flip checklock contentfile
 
 #ifndef mingw32_HOST_OS
 	checklock Nothing contentfile = checkOr is_missing contentfile
@@ -115,14 +123,55 @@
 			)
 #endif
 
-{- Windows has to use a separate lock file from the content, since
- - locking the actual content file would interfere with the user's
- - use of it. -}
-contentLockFile :: Key -> Annex (Maybe RawFilePath)
+{- Runs an action with the lock file to use to lock a key's content.
+ - When the content file itself should be locked, runs the action with
+ - Nothing.
+ -
+ - In v9 and below, while the action is running, a shared lock is held of the
+ - gitAnnexContentLockLock. That prevents the v10 upgrade, which changes how
+ - content locking works, from running at the same time as content is locked
+ - using the old method.
+ -}
+withContentLockFile :: Key -> (Maybe RawFilePath -> Annex a) -> Annex a
+withContentLockFile k a = do
+	v <- getVersion
+	if versionNeedsWritableContentFiles v
+		then withSharedLock gitAnnexContentLockLock $ do
+			{- While the lock is held, check to see if the git
+			 - config has changed, and reload it if so. This
+			 - updates the annex.version after the v10 upgrade,
+			 - so that a process that started in a v9 repository
+			 - will switch over to v10 content lock files at the
+			 - right time. -}
+			gitdir <- fromRepo Git.localGitDir
+			let gitconfig = gitdir P.</> "config"
+			ic <- withTSDelta (liftIO . genInodeCache gitconfig)
+			oldic <- Annex.getState Annex.gitconfiginodecache
+			v' <- if fromMaybe False (compareStrong <$> ic <*> oldic)
+				then pure v
+				else do
+					Annex.changeState $ \s -> 
+						s { Annex.gitconfiginodecache = ic }
+					reloadConfig
+					getVersion
+			go (v')
+		else (go v)
+  where
+	go v = contentLockFile k v >>= a
+
+contentLockFile :: Key -> Maybe RepoVersion -> Annex (Maybe RawFilePath)
 #ifndef mingw32_HOST_OS
-contentLockFile _ = pure Nothing
+{- Older versions of git-annex locked content files themselves, but newer
+ - versions use a separate lock file, to better support repos shared
+ - amoung users in eg a group. -}
+contentLockFile key v
+	| versionNeedsWritableContentFiles v = pure Nothing
+	| otherwise = Just <$> calcRepo (gitAnnexContentLock key)
 #else
-contentLockFile key = Just <$> calcRepo (gitAnnexContentLock key)
+{- Windows always has to use a separate lock file from the content, since
+ - locking the actual content file would interfere with the user's
+ - use of it. -}
+contentLockFile key _ = Just <$> calcRepo (gitAnnexContentLock key)
 #endif
 
 {- Performs an action, passing it the location to use for a key's content. -}
diff --git a/Annex/Init.hs b/Annex/Init.hs
--- a/Annex/Init.hs
+++ b/Annex/Init.hs
@@ -282,7 +282,7 @@
 		-- running as root). But some crippled
 		-- filesystems ignore write bit removals or ignore
 		-- permissions entirely.
-		ifM ((== Just False) <$> liftIO (checkContentWritePerm' UnShared (toRawFilePath f)))
+		ifM ((== Just False) <$> liftIO (checkContentWritePerm' UnShared (toRawFilePath f) Nothing))
 			( return (True, ["Filesystem does not allow removing write bit from files."])
 			, liftIO $ ifM ((== 0) <$> getRealUserID)
 				( return (False, [])
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-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -20,6 +20,7 @@
 	gitAnnexLink,
 	gitAnnexLinkCanonical,
 	gitAnnexContentLock,
+	gitAnnexContentLockLock,
 	gitAnnexInodeSentinal,
 	gitAnnexInodeSentinalCache,
 	annexLocationsBare,
@@ -44,6 +45,8 @@
 	gitAnnexFsckDbDirOld,
 	gitAnnexFsckDbLock,
 	gitAnnexFsckResultsLog,
+	gitAnnexUpgradeLog,
+	gitAnnexUpgradeLock,
 	gitAnnexSmudgeLog,
 	gitAnnexSmudgeLock,
 	gitAnnexMoveLog,
@@ -237,6 +240,17 @@
 	loc <- gitAnnexLocation key r config
 	return $ loc <> ".lck"
 
+{- Lock that is held when taking the gitAnnexContentLock to support the v10
+ - upgrade.
+ -
+ - This uses the gitAnnexInodeSentinal file, because it needs to be a file
+ - that exists in the repository, even when it's an old v8 repository that
+ - is mounted read-only. The gitAnnexInodeSentinal is created by git-annex
+ - init, so should already exist.
+ -}
+gitAnnexContentLockLock :: Git.Repo -> RawFilePath
+gitAnnexContentLockLock = gitAnnexInodeSentinal
+
 gitAnnexInodeSentinal :: Git.Repo -> RawFilePath
 gitAnnexInodeSentinal r = gitAnnexDir r P.</> "sentinal"
 
@@ -344,6 +358,13 @@
 gitAnnexFsckResultsLog :: UUID -> Git.Repo -> RawFilePath
 gitAnnexFsckResultsLog u r = 
 	gitAnnexDir r P.</> "fsckresults" P.</> fromUUID u
+
+{- .git/annex/upgrade.log is used to record repository version upgrades. -}
+gitAnnexUpgradeLog :: Git.Repo -> RawFilePath
+gitAnnexUpgradeLog r = gitAnnexDir r P.</> "upgrade.log"
+
+gitAnnexUpgradeLock :: Git.Repo -> RawFilePath
+gitAnnexUpgradeLock r = gitAnnexDir r P.</> "upgrade.lck"
 
 {- .git/annex/smudge.log is used to log smudges worktree files that need to
  - be updated. -}
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-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -16,6 +16,7 @@
 	noUmask,
 	freezeContent,
 	freezeContent',
+	freezeContent'',
 	checkContentWritePerm,
 	checkContentWritePerm',
 	thawContent,
@@ -32,6 +33,8 @@
 import Git
 import Git.ConfigTypes
 import qualified Annex
+import Annex.Version
+import Types.RepoVersion
 import Config
 import Utility.Directory.Create
 import qualified Utility.RawFilePath as R
@@ -127,12 +130,15 @@
 {- Normally, blocks writing to an annexed file, and modifies file
  - permissions to allow reading it.
  -
- - When core.sharedRepository is set, the write bits are not removed from
- - the file, but instead the appropriate group write bits are set. This is
- - necessary to let other users in the group lock the file. But, in a
- - shared repository, the current user may not be able to change a file
- - owned by another user, so failure to set this mode is ignored.
+ - Before v9, when core.sharedRepository is set, the write bits are not
+ - removed from the file, but instead the appropriate group write bits
+ - are set. This is necessary to let other users in the group lock the file.
+ - v9 improved this by using separate lock files, so the content file does
+ - not need to be writable when using it.
  -
+ - In a shared repository, the current user may not be able to change
+ - a file owned by another user, so failure to change modes is ignored.
+ -
  - Note that, on Linux, xattrs can sometimes prevent removing
  - certain permissions from a file with chmod. (Maybe some ACLs too?) 
  - In such a case, this will return with the file still having some mode
@@ -144,17 +150,32 @@
 	withShared $ \sr -> freezeContent' sr file
 
 freezeContent' :: SharedRepository -> RawFilePath -> Annex ()
-freezeContent' sr file = do
+freezeContent' sr file = freezeContent'' sr file =<< getVersion
+
+freezeContent'' :: SharedRepository -> RawFilePath -> Maybe RepoVersion -> Annex ()
+freezeContent'' sr file rv = do
 	go sr
 	freezeHook file
   where
-	go GroupShared = liftIO $ void $ tryIO $ modifyFileMode file $
-		addModes [ownerReadMode, groupReadMode, ownerWriteMode, groupWriteMode]
-	go AllShared = liftIO $ void $ tryIO $ modifyFileMode file $
-		addModes (readModes ++ writeModes)
-	go _ = liftIO $ modifyFileMode file $
+	go GroupShared = if versionNeedsWritableContentFiles rv
+		then liftIO $ ignoresharederr $ modmode $ addModes
+			[ownerReadMode, groupReadMode, ownerWriteMode, groupWriteMode]
+		else liftIO $ ignoresharederr $
+			nowriteadd [ownerReadMode, groupReadMode]
+	go AllShared = if versionNeedsWritableContentFiles rv
+		then liftIO $ ignoresharederr $ modmode $ addModes
+			(readModes ++ writeModes)
+		else liftIO $ ignoresharederr $ 
+			nowriteadd readModes
+	go _ = liftIO $ nowriteadd [ownerReadMode]
+
+	ignoresharederr = void . tryIO
+
+	modmode = modifyFileMode file
+
+	nowriteadd readmodes = modmode $ 
 		removeModes writeModes .
-		addModes [ownerReadMode]
+		addModes readmodes
 
 {- Checks if the write permissions are as freezeContent should set them.
  -
@@ -166,14 +187,21 @@
 checkContentWritePerm :: RawFilePath -> Annex (Maybe Bool)
 checkContentWritePerm file = ifM crippledFileSystem
 	( return (Just True)
-	, withShared (\sr -> liftIO (checkContentWritePerm' sr file))
+	, do
+		rv <- getVersion
+		withShared (\sr -> liftIO (checkContentWritePerm' sr file rv))
 	)
 
-checkContentWritePerm' :: SharedRepository -> RawFilePath -> IO (Maybe Bool)
-checkContentWritePerm' sr file = case sr of
-	GroupShared -> want sharedret
-		(includemodes [ownerWriteMode, groupWriteMode])
-	AllShared -> want sharedret (includemodes writeModes)
+checkContentWritePerm' :: SharedRepository -> RawFilePath -> Maybe RepoVersion -> IO (Maybe Bool)
+checkContentWritePerm' sr file rv = case sr of
+	GroupShared
+		| versionNeedsWritableContentFiles rv -> want sharedret
+			(includemodes [ownerWriteMode, groupWriteMode])
+		| otherwise -> want sharedret (excludemodes writeModes)
+	AllShared
+		| versionNeedsWritableContentFiles rv -> 
+			want sharedret (includemodes writeModes)
+		| otherwise -> want sharedret (excludemodes writeModes)
 	_ -> want Just (excludemodes writeModes)
   where
 	want mk f = catchMaybeIO (fileMode <$> R.getFileStatus file)
diff --git a/Annex/SpecialRemote.hs b/Annex/SpecialRemote.hs
--- a/Annex/SpecialRemote.hs
+++ b/Annex/SpecialRemote.hs
@@ -1,6 +1,6 @@
 {- git-annex special remote configuration
  -
- - Copyright 2011-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -25,16 +25,22 @@
 import Git.Types (RemoteName)
 
 import qualified Data.Map as M
-import Data.Ord
 
 {- See if there's an existing special remote with this name.
  -
- - Prefer remotes that are not dead when a name appears multiple times. -}
-findExisting :: RemoteName -> Annex (Maybe (UUID, RemoteConfig, Maybe (ConfigFrom UUID)))
+ - Remotes that are not dead come first in the list
+ - when a name appears multiple times. -}
+findExisting :: RemoteName -> Annex [(UUID, RemoteConfig, Maybe (ConfigFrom UUID))]
 findExisting name = do
+	(a, b) <- findExisting' name
+	return (a++b)
+
+{- Dead remotes with the name are in the second list, all others in the
+ - first list. -}
+findExisting' :: RemoteName -> Annex ([(UUID, RemoteConfig, Maybe (ConfigFrom UUID))], [(UUID, RemoteConfig, Maybe (ConfigFrom UUID))])
+findExisting' name = do
 	t <- trustMap
-	headMaybe
-		. sortBy (comparing $ \(u, _, _) -> Down $ M.lookup u t)
+	partition (\(u, _, _) -> M.lookup u t /= Just DeadTrusted)
 		. findByRemoteConfig (\c -> lookupName c == Just name)
 		<$> Logs.Remote.remoteConfigMap
 
diff --git a/Annex/Version.hs b/Annex/Version.hs
--- a/Annex/Version.hs
+++ b/Annex/Version.hs
@@ -1,6 +1,6 @@
 {- git-annex repository versioning
  -
- - Copyright 2010-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -22,25 +22,27 @@
 defaultVersion = RepoVersion 8
 
 latestVersion :: RepoVersion
-latestVersion = RepoVersion 8
+latestVersion = RepoVersion 10
 
 supportedVersions :: [RepoVersion]
-supportedVersions = map RepoVersion [8]
+supportedVersions = map RepoVersion [8, 9, 10]
 
-upgradableVersions :: [RepoVersion]
+upgradeableVersions :: [RepoVersion]
 #ifndef mingw32_HOST_OS
-upgradableVersions = map RepoVersion [0..7]
+upgradeableVersions = map RepoVersion [0..10]
 #else
-upgradableVersions = map RepoVersion [2..7]
+upgradeableVersions = map RepoVersion [2..10]
 #endif
 
 autoUpgradeableVersions :: M.Map RepoVersion RepoVersion
 autoUpgradeableVersions = M.fromList
-	[ (RepoVersion 3, latestVersion)
-	, (RepoVersion 4, latestVersion)
-	, (RepoVersion 5, latestVersion)
-	, (RepoVersion 6, latestVersion)
-	, (RepoVersion 7, latestVersion)
+	[ (RepoVersion 3, defaultVersion)
+	, (RepoVersion 4, defaultVersion)
+	, (RepoVersion 5, defaultVersion)
+	, (RepoVersion 6, defaultVersion)
+	, (RepoVersion 7, defaultVersion)
+	, (RepoVersion 8, defaultVersion)
+	, (RepoVersion 9, latestVersion)
 	]
 
 versionField :: ConfigKey
@@ -54,3 +56,13 @@
 
 removeVersion :: Annex ()
 removeVersion = unsetConfig versionField
+
+versionSupportsFilterProcess :: Maybe RepoVersion -> Bool
+versionSupportsFilterProcess (Just v) 
+	| v >= RepoVersion 9 = True
+versionSupportsFilterProcess _ = False
+
+versionNeedsWritableContentFiles :: Maybe RepoVersion -> Bool
+versionNeedsWritableContentFiles (Just v) 
+	| v >= RepoVersion 10 = False
+versionNeedsWritableContentFiles _ = True
diff --git a/Assistant/MakeRemote.hs b/Assistant/MakeRemote.hs
--- a/Assistant/MakeRemote.hs
+++ b/Assistant/MakeRemote.hs
@@ -55,9 +55,9 @@
 makeRsyncRemote name location = makeRemote name location $ const $ void $
 	go =<< Annex.SpecialRemote.findExisting name
   where
-	go Nothing = setupSpecialRemote name Rsync.remote config Nothing
+	go [] = setupSpecialRemote name Rsync.remote config Nothing
 		(Nothing, R.Init, Annex.SpecialRemote.newConfig name Nothing mempty mempty) Nothing
-	go (Just (u, c, mcu)) = setupSpecialRemote name Rsync.remote config Nothing
+	go ((u, c, mcu):_) = setupSpecialRemote name Rsync.remote config Nothing
 		(Just u, R.Enable c, c) mcu
 	config = M.fromList
 		[ (encryptionField, Proposed "shared")
@@ -86,16 +86,16 @@
 	go n = do
 		let fullname = if n == 0  then name else name ++ show n
 		Annex.SpecialRemote.findExisting fullname >>= \case
-			Nothing -> setupSpecialRemote fullname remotetype config mcreds
+			[] -> setupSpecialRemote fullname remotetype config mcreds
 				(Nothing, R.Init, Annex.SpecialRemote.newConfig fullname Nothing mempty mempty) Nothing
-			Just _ -> go (n + 1)
+			_ -> go (n + 1)
 
 {- Enables an existing special remote. -}
 enableSpecialRemote :: SpecialRemoteMaker
 enableSpecialRemote name remotetype mcreds config =
 	Annex.SpecialRemote.findExisting name >>= \case
-		Nothing -> error $ "Cannot find a special remote named " ++ name
-		Just (u, c, mcu) -> setupSpecialRemote' False name remotetype config mcreds (Just u, R.Enable c, c) mcu
+		[] -> error $ "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
 setupSpecialRemote = setupSpecialRemote' True
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,36 @@
+git-annex (10.20220127) upstream; urgency=medium
+
+  * New v10 repository version (with v9 as a stepping-stone to it).
+    v8 remains the default version for now.
+  * In v10, object files are locked using separate lock files. This allows
+    the object files to be kept non-writable even in repositories where
+    core.sharedRepository is set.
+  * The v10 upgrade will happen automatically, one year after the v9
+    upgrade, in order to allow time for any old git-annex processes that
+    are not aware of the locking change to finish. Or git-annex upgrade
+    can be used to upgrade to v10 immediately.
+  * In v9 upgrade, set filter.annex.process. This makes git add/checkout faster
+    when there are a lot of unlocked annexed files or non-annexed files, but can
+    also make git add of large files to the annex somewhat slower.
+    If this tradeoff does not work for your use case, you can still unset
+    filter.annex.process.
+  * export: When a non-annexed symlink is in the tree to be exported, skip it.
+  * import: When the previously exported tree contained a non-annexed symlink,
+    preserve it in the imported tree so it does not get deleted.
+  * enableremote, renameremote: Better handling of the unusual case where
+    multiple special remotes have been initialized with the same name.
+  * Recover from corrupted content being received from a git remote,
+    by deleting the temporary file when it fails to verify. This prevents
+    a retry from failing again. 
+    (reversion introduced in version 8.20210903)
+  * adb: Added ignorefinderror configuration parameter.
+  * Avoid crashing when run in a bare git repo that somehow contains an
+    index file.
+  * Reject combinations of --batch (or --batch-keys) with options like
+    --all or --key or with filenames.
+
+ -- Joey Hess <id@joeyh.name>  Thu, 27 Jan 2022 13:25:19 -0400
+
 git-annex (8.20211231) upstream; urgency=medium
 
   * Improved support for using git-annex in a read-only repository,
diff --git a/COPYRIGHT b/COPYRIGHT
--- a/COPYRIGHT
+++ b/COPYRIGHT
@@ -2,7 +2,7 @@
 Source: native package
 
 Files: *
-Copyright: © 2010-2021 Joey Hess <id@joeyh.name>
+Copyright: © 2010-2022 Joey Hess <id@joeyh.name>
 License: AGPL-3+
 
 Files: Assistant/WebApp.hs Assistant/WebApp/* templates/* static/*
diff --git a/CmdLine/Batch.hs b/CmdLine/Batch.hs
--- a/CmdLine/Batch.hs
+++ b/CmdLine/Batch.hs
@@ -77,8 +77,9 @@
 	
 	batchseeker (opts, NoBatch, params) =
 		mapM_ (\p -> go NoBatch opts (SeekInput [p], p)) params
-	batchseeker (opts, batchmode@(Batch fmt), _) = 
-		batchInput fmt (pure . Right) (go batchmode opts)
+	batchseeker (opts, batchmode@(Batch fmt), params) = 
+		batchOnly Nothing params $
+			batchInput fmt (pure . Right) (go batchmode opts)
 
 	go batchmode opts (si, p) =
 		unlessM (handler opts si p) $
@@ -209,3 +210,8 @@
 		, providedMimeEncoding = Nothing
 		, providedLinkType = Nothing
 		}
+
+batchOnly :: Maybe KeyOptions -> CmdParams -> Annex () -> Annex ()
+batchOnly Nothing [] a = a
+batchOnly _ _ _ = giveup "Cannot combine batch option with file or key options"
+
diff --git a/Command/Add.hs b/Command/Add.hs
--- a/Command/Add.hs
+++ b/Command/Add.hs
@@ -95,7 +95,8 @@
 		Batch fmt
 			| updateOnly o ->
 				giveup "--update --batch is not supported"
-			| otherwise -> batchFiles fmt gofile
+			| otherwise -> batchOnly Nothing (addThese o) $
+				batchFiles fmt gofile
 		NoBatch -> do
 			-- Avoid git ls-files complaining about files that
 			-- are not known to git yet, since this will add
diff --git a/Command/AddUrl.hs b/Command/AddUrl.hs
--- a/Command/AddUrl.hs
+++ b/Command/AddUrl.hs
@@ -119,10 +119,10 @@
 			then void $ commandAction $
 				startWeb addunlockedmatcher o' si u
 			else checkUrl addunlockedmatcher r o' si u
-	forM_ (addUrls o) (\u -> go (SeekInput [u], (o, u)))
 	case batchOption o of
-		Batch fmt -> batchInput fmt (pure . parseBatchInput o) go
-		NoBatch -> noop
+		Batch fmt -> batchOnly Nothing (addUrls o) $
+			batchInput fmt (pure . parseBatchInput o) go
+		NoBatch -> forM_ (addUrls o) (\u -> go (SeekInput [u], (o, u)))
 
 parseBatchInput :: AddUrlOptions -> String -> Either String (AddUrlOptions, URLString)
 parseBatchInput o s
diff --git a/Command/Copy.hs b/Command/Copy.hs
--- a/Command/Copy.hs
+++ b/Command/Copy.hs
@@ -51,7 +51,8 @@
 			(commandAction . keyaction)
 			(withFilesInGitAnnex ww seeker)
 			=<< workTreeItems ww (copyFiles o)
-		Batch fmt -> batchAnnexed fmt seeker keyaction
+		Batch fmt -> batchOnly (keyOptions o) (copyFiles o) $
+			batchAnnexed fmt seeker keyaction
   where
 	ww = WarnUnmatchLsFiles
 	
diff --git a/Command/Drop.hs b/Command/Drop.hs
--- a/Command/Drop.hs
+++ b/Command/Drop.hs
@@ -71,7 +71,8 @@
 			(commandAction . startKeys o from)
 			(withFilesInGitAnnex ww seeker)
 			=<< workTreeItems ww (dropFiles o)
-		Batch fmt -> batchAnnexed fmt seeker (startKeys o from)
+		Batch fmt -> batchOnly (keyOptions o) (dropFiles o) $
+			batchAnnexed fmt seeker (startKeys o from)
   where
 	ww = WarnUnmatchLsFiles
 
diff --git a/Command/DropKey.hs b/Command/DropKey.hs
--- a/Command/DropKey.hs
+++ b/Command/DropKey.hs
@@ -33,11 +33,11 @@
 seek o = do
 	unlessM (Annex.getState Annex.force) $
 		giveup "dropkey can cause data loss; use --force if you're sure you want to do this"
-	withKeys (commandAction . start) (toDrop o)
 	case batchOption o of
-		Batch fmt -> batchInput fmt (pure . parsekey) $
-			batchCommandAction . start
-		NoBatch -> noop
+		NoBatch -> withKeys (commandAction . start) (toDrop o)
+		Batch fmt -> batchOnly Nothing (toDrop o) $
+			batchInput fmt (pure . parsekey) $
+				batchCommandAction . start
   where
 	parsekey = maybe (Left "bad key") Right . deserializeKey
 
diff --git a/Command/EnableRemote.hs b/Command/EnableRemote.hs
--- a/Command/EnableRemote.hs
+++ b/Command/EnableRemote.hs
@@ -43,8 +43,13 @@
 start (name:rest) = go =<< filter matchingname <$> Annex.getGitRemotes
   where
 	matchingname r = Git.remoteName r == Just name
-	go [] = startSpecialRemote name (Logs.Remote.keyValToConfig Proposed rest)
-		=<< SpecialRemote.findExisting 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).
@@ -69,16 +74,16 @@
 	ai = ActionItemOther (Just name)
 	si = SeekInput [name]
 
-startSpecialRemote :: Git.RemoteName -> Remote.RemoteConfig -> Maybe (UUID, Remote.RemoteConfig, Maybe (SpecialRemote.ConfigFrom UUID)) -> CommandStart
-startSpecialRemote name config Nothing = do
+startSpecialRemote :: Git.RemoteName -> Remote.RemoteConfig -> [(UUID, Remote.RemoteConfig, Maybe (SpecialRemote.ConfigFrom UUID))] -> CommandStart
+startSpecialRemote name config [] = do
 	m <- SpecialRemote.specialRemoteMap
 	confm <- Logs.Remote.remoteConfigMap
 	Remote.nameToUUID' name >>= \case
 		Right u | u `M.member` m ->
 			startSpecialRemote name config $
-				Just (u, fromMaybe M.empty (M.lookup u confm), Nothing)
+				[(u, fromMaybe M.empty (M.lookup u confm), Nothing)]
 		_ -> unknownNameError "Unknown remote name."
-startSpecialRemote name config (Just (u, c, mcu)) =
+startSpecialRemote name config ((u, c, mcu):[]) =
 	starting "enableremote" ai si $ do
 		let fullconfig = config `M.union` c	
 		t <- either giveup return (SpecialRemote.findType fullconfig)
@@ -89,6 +94,8 @@
   where
 	ai = ActionItemOther (Just 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."
 
 performSpecialRemote :: RemoteType -> UUID -> R.RemoteConfig -> R.RemoteConfig -> RemoteGitConfig -> Maybe (SpecialRemote.ConfigFrom UUID) -> CommandPerform
 performSpecialRemote t u oldc c gc mcu = do
diff --git a/Command/Export.hs b/Command/Export.hs
--- a/Command/Export.hs
+++ b/Command/Export.hs
@@ -479,7 +479,8 @@
 newtype ExportFiltered t = ExportFiltered t
 
 -- | Filters the tree to annexed files that are preferred content of the
--- remote, and also including non-annexed files, but not submodules.
+-- remote, and also including non-annexed files, but not submodules or
+-- non-annexed symlinks.
 --
 -- A log is written with tree items that were filtered out, so they can
 -- be added back in when importing from the remote.
@@ -505,16 +506,27 @@
 		case toTreeItemType mode of
 			-- Don't export submodule entries.
 			Just TreeSubmodule -> excluded
-			_ -> case mmatcher of
-				Nothing -> return (Just ti)
-				Just matcher -> catKey sha >>= \case
-					Just k -> checkmatcher matcher k
-					-- Always export non-annexed files.
-					Nothing -> return (Just ti)
+			Just TreeSymlink -> checkkey True
+			_ -> checkkey False
 	  where
 		excluded = do
 			() <- liftIO $ logwriter ti
 			return Nothing
+
+		checkkey issymlink =
+			case mmatcher of
+				Nothing
+					| issymlink -> catKey sha >>= \case
+						Just _ -> return (Just ti)
+						Nothing
+							| issymlink -> excluded
+							| otherwise -> return (Just ti)
+					| otherwise -> return (Just ti)
+				Just matcher -> catKey sha >>= \case
+					Just k -> checkmatcher matcher k
+					Nothing
+						| issymlink -> excluded
+						| otherwise -> return (Just ti)
 
 		checkmatcher matcher k = do
 			let mi = MatchingInfo $ ProvidedInfo
diff --git a/Command/Find.hs b/Command/Find.hs
--- a/Command/Find.hs
+++ b/Command/Find.hs
@@ -70,7 +70,8 @@
 			(commandAction . startKeys o)
 			(withFilesInGitAnnex ww seeker)
 			=<< workTreeItems ww (findThese o)
-		Batch fmt -> batchAnnexedFiles fmt seeker
+		Batch fmt -> batchOnly (keyOptions o) (findThese o) $
+			batchAnnexedFiles fmt seeker
   where
 	ww = WarnUnmatchLsFiles
 
diff --git a/Command/FromKey.hs b/Command/FromKey.hs
--- a/Command/FromKey.hs
+++ b/Command/FromKey.hs
@@ -41,7 +41,8 @@
 seek o = do
 	matcher <- addUnlockedMatcher
 	case (batchOption o, keyFilePairs o) of
-		(Batch fmt, _) -> seekBatch matcher fmt
+		(Batch fmt, _) -> batchOnly Nothing (keyFilePairs o) $
+			seekBatch matcher fmt
 		-- older way of enabling batch input, does not support BatchNull
 		(NoBatch, []) -> seekBatch matcher (BatchFormat BatchLine (BatchKeys False))
 		(NoBatch, ps) -> do
diff --git a/Command/Fsck.hs b/Command/Fsck.hs
--- a/Command/Fsck.hs
+++ b/Command/Fsck.hs
@@ -705,39 +705,3 @@
 withFsckDb NonIncremental _ = noop
 withFsckDb (ScheduleIncremental _ _ i) a = withFsckDb i a
 
-data KeyStatus
-	= KeyMissing
-	| KeyPresent
-	| KeyUnlockedThin
-	-- ^ An annex.thin worktree file is hard linked to the object.
-	| KeyLockedThin
-	-- ^ The object has hard links, but the file being fscked
-	-- is not the one that hard links to it.
-	deriving (Show)
-
-isKeyUnlockedThin :: KeyStatus -> Bool
-isKeyUnlockedThin KeyUnlockedThin = True
-isKeyUnlockedThin KeyLockedThin = False
-isKeyUnlockedThin KeyPresent = False
-isKeyUnlockedThin KeyMissing = False
-
-getKeyStatus :: Key -> Annex KeyStatus
-getKeyStatus key = catchDefaultIO KeyMissing $ do
-	afs <- not . null <$> Database.Keys.getAssociatedFiles key
-	obj <- calcRepo (gitAnnexLocation key)
-	multilink <- ((> 1) . linkCount <$> liftIO (R.getFileStatus obj))
-	return $ if multilink && afs
-		then KeyUnlockedThin
-		else KeyPresent
-
-getKeyFileStatus :: Key -> FilePath -> Annex KeyStatus
-getKeyFileStatus key file = do
-	s <- getKeyStatus key
-	case s of
-		KeyUnlockedThin -> catchDefaultIO KeyUnlockedThin $
-			ifM (isJust <$> isAnnexLink (toRawFilePath file))
-				( return KeyLockedThin
-				, return KeyUnlockedThin
-				)
-		_ -> return s
-
diff --git a/Command/Get.hs b/Command/Get.hs
--- a/Command/Get.hs
+++ b/Command/Get.hs
@@ -49,7 +49,8 @@
 			(commandAction . startKeys from)
 			(withFilesInGitAnnex ww seeker)
 			=<< workTreeItems ww (getFiles o)
-		Batch fmt -> batchAnnexed fmt seeker (startKeys from)
+		Batch fmt -> batchOnly (keyOptions o) (getFiles o) $
+			batchAnnexed fmt seeker (startKeys from)
   where
 	ww = WarnUnmatchLsFiles
 
diff --git a/Command/Info.hs b/Command/Info.hs
--- a/Command/Info.hs
+++ b/Command/Info.hs
@@ -119,7 +119,8 @@
 seek :: InfoOptions -> CommandSeek
 seek o = case batchOption o of
 	NoBatch -> withWords (commandAction . start o) (infoFor o)
-	Batch fmt -> batchInput fmt (pure . Right) (itemInfo o)
+	Batch fmt -> batchOnly Nothing (infoFor o) $
+		batchInput fmt (pure . Right) (itemInfo o)
 
 start :: InfoOptions -> [String] -> CommandStart
 start o [] = do
diff --git a/Command/InitRemote.hs b/Command/InitRemote.hs
--- a/Command/InitRemote.hs
+++ b/Command/InitRemote.hs
@@ -64,7 +64,7 @@
 
 start :: InitRemoteOptions -> [String] -> CommandStart
 start _ [] = giveup "Specify a name for the remote."
-start o (name:ws) = ifM (isJust <$> findExisting name)
+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)
diff --git a/Command/MetaData.hs b/Command/MetaData.hs
--- a/Command/MetaData.hs
+++ b/Command/MetaData.hs
@@ -93,8 +93,9 @@
 	Batch fmt -> withMessageState $ \s -> case outputType s of
 		JSONOutput _ -> ifM limited
 			( giveup "combining --batch with file matching options is not currently supported"
-			, batchInput fmt parseJSONInput 
-				(commandAction . batchCommandStart . startBatch)
+			, batchOnly (keyOptions o) (forFiles o) $
+				batchInput fmt parseJSONInput 
+					(commandAction . batchCommandStart . startBatch)
 			)
 		_ -> giveup "--batch is currently only supported in --json mode"
 
diff --git a/Command/Migrate.hs b/Command/Migrate.hs
--- a/Command/Migrate.hs
+++ b/Command/Migrate.hs
@@ -96,7 +96,7 @@
 		| knowngoodcontent = finish (removesize newkey)
 		| otherwise = stopUnless checkcontent $
 			finish (removesize newkey)
-	checkcontent = Command.Fsck.checkBackend oldbackend oldkey Command.Fsck.KeyPresent afile
+	checkcontent = Command.Fsck.checkBackend oldbackend oldkey KeyPresent afile
 	finish newkey = ifM (Command.ReKey.linkKey file oldkey newkey)
 		( do
 			_ <- copyMetaData oldkey newkey
diff --git a/Command/Move.hs b/Command/Move.hs
--- a/Command/Move.hs
+++ b/Command/Move.hs
@@ -64,7 +64,8 @@
 			(commandAction . keyaction)
 			(withFilesInGitAnnex ww seeker)
 			=<< workTreeItems ww (moveFiles o)
-		Batch fmt -> batchAnnexed fmt seeker keyaction
+		Batch fmt -> batchOnly (keyOptions o) (moveFiles o) $
+			batchAnnexed fmt seeker keyaction
   where
 	seeker = AnnexedFileSeeker
 		{ startAction = start (fromToOptions o) (removeWhen o)
diff --git a/Command/ReKey.hs b/Command/ReKey.hs
--- a/Command/ReKey.hs
+++ b/Command/ReKey.hs
@@ -50,8 +50,9 @@
 
 seek :: ReKeyOptions -> CommandSeek
 seek o = case batchOption o of
-	Batch fmt -> batchInput fmt batchParser
-		(batchCommandAction . uncurry start)
+	Batch fmt -> batchOnly Nothing (reKeyThese o) $
+		batchInput fmt batchParser
+			(batchCommandAction . uncurry start)
 	NoBatch -> withPairs 
 		(\(si, p) -> commandAction (start si (parsekey p))) 
 		(reKeyThese o)
diff --git a/Command/RegisterUrl.hs b/Command/RegisterUrl.hs
--- a/Command/RegisterUrl.hs
+++ b/Command/RegisterUrl.hs
@@ -32,7 +32,7 @@
 
 seek :: RegisterUrlOptions -> CommandSeek
 seek o = case (batchOption o, keyUrlPairs o) of
-	(Batch (BatchFormat sep _), _) ->
+	(Batch (BatchFormat sep _), _) -> batchOnly Nothing (keyUrlPairs o) $
 		commandAction $ startMass setUrlPresent sep
 	-- older way of enabling batch input, does not support BatchNull
 	(NoBatch, []) -> commandAction $ startMass setUrlPresent BatchLine
diff --git a/Command/RenameRemote.hs b/Command/RenameRemote.hs
--- a/Command/RenameRemote.hs
+++ b/Command/RenameRemote.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2019 Joey Hess <id@joeyh.name>
+ - Copyright 2019-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -28,19 +28,20 @@
 
 start :: [String] -> CommandStart
 start ps@(oldname:newname:[]) = Annex.SpecialRemote.findExisting oldname >>= \case
-	Just (u, cfg, mcu) -> Annex.SpecialRemote.findExisting newname >>= \case
-		Just _ -> giveup $ "The name " ++ newname ++ " is already used by a special remote."
-		Nothing -> go u cfg mcu
+	((u, cfg, mcu):[]) -> Annex.SpecialRemote.findExisting newname >>= \case
+		[] -> go u cfg mcu
+		_ -> giveup $ "The name " ++ newname ++ " is already used by a special remote."
 	-- Support lookup by uuid or description as well as remote name,
 	-- as a fallback when there is nothing with the name in the
 	-- special remote log.
-	Nothing -> Remote.nameToUUID' oldname >>= \case
+	[] -> Remote.nameToUUID' oldname >>= \case
 		Left e -> giveup e
 		Right u -> do
 			m <- Logs.Remote.remoteConfigMap
 			case M.lookup u m of
 				Nothing -> giveup "That is not a special remote."
 				Just cfg -> go u cfg Nothing
+	_ -> giveup $ "There are multiple special remotes named " ++ oldname ++ ". Provide instead the uuid or description of the remote to rename."
   where
 	ai = ActionItemOther Nothing
 	si = SeekInput ps
diff --git a/Command/RmUrl.hs b/Command/RmUrl.hs
--- a/Command/RmUrl.hs
+++ b/Command/RmUrl.hs
@@ -29,7 +29,8 @@
 
 seek :: RmUrlOptions -> CommandSeek
 seek o = case batchOption o of
-	Batch fmt -> batchInput fmt batchParser (batchCommandAction . start)
+	Batch fmt -> batchOnly Nothing (rmThese o) $
+		batchInput fmt batchParser (batchCommandAction . start)
 	NoBatch -> withPairs (commandAction . start) (rmThese o)
 
 -- Split on the last space, since a FilePath can contain whitespace,
diff --git a/Command/SetPresentKey.hs b/Command/SetPresentKey.hs
--- a/Command/SetPresentKey.hs
+++ b/Command/SetPresentKey.hs
@@ -30,9 +30,10 @@
 
 seek :: SetPresentKeyOptions -> CommandSeek
 seek o = case batchOption o of
-	Batch fmt -> batchInput fmt
-		(pure . parseKeyStatus . words)
-		(batchCommandAction . uncurry start)
+	Batch fmt -> batchOnly Nothing (params o) $
+		batchInput fmt
+			(pure . parseKeyStatus . words)
+			(batchCommandAction . uncurry start)
 	NoBatch -> either giveup (commandAction . start (SeekInput (params o)))
 		(parseKeyStatus $ params o)
 
diff --git a/Command/Version.hs b/Command/Version.hs
--- a/Command/Version.hs
+++ b/Command/Version.hs
@@ -66,7 +66,7 @@
 	vinfo "supported repository versions" $
 		verlist supportedVersions
 	vinfo "upgrade supported from repository versions" $
-		verlist upgradableVersions
+		verlist upgradeableVersions
   where
 	verlist = unwords . map showRepoVersion
 
diff --git a/Command/Whereis.hs b/Command/Whereis.hs
--- a/Command/Whereis.hs
+++ b/Command/Whereis.hs
@@ -62,7 +62,8 @@
 				(commandAction . startKeys o m)
 				(withFilesInGitAnnex ww seeker)
 				=<< workTreeItems ww (whereisFiles o)
-		Batch fmt -> batchAnnexed fmt seeker (startKeys o m)
+		Batch fmt -> batchOnly (keyOptions o) (whereisFiles o) $
+			batchAnnexed fmt seeker (startKeys o m)
   where
 	ww = WarnUnmatchLsFiles
 
diff --git a/Config/Smudge.hs b/Config/Smudge.hs
--- a/Config/Smudge.hs
+++ b/Config/Smudge.hs
@@ -1,6 +1,6 @@
 {- Git smudge filter configuration
  -
- - Copyright 2011-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -16,6 +16,7 @@
 import Git.Types
 import Config
 import Utility.Directory.Create
+import Annex.Version
 
 import qualified System.FilePath.ByteString as P
 
@@ -32,6 +33,8 @@
 
 	setConfig (ConfigKey "filter.annex.smudge") "git-annex smudge -- %f"
 	setConfig (ConfigKey "filter.annex.clean") "git-annex smudge --clean -- %f"
+	whenM (versionSupportsFilterProcess <$> getVersion)
+		configureSmudgeFilterProcess
 	lf <- Annex.fromRepo Git.attributesLocal
 	gf <- Annex.fromRepo Git.attributes
 	lfs <- readattr lf
@@ -42,6 +45,10 @@
 		writeFile (fromRawFilePath lf) (lfs ++ "\n" ++ unlines stdattr)
   where
 	readattr = liftIO . catchDefaultIO "" . readFileStrict . fromRawFilePath
+
+configureSmudgeFilterProcess :: Annex ()
+configureSmudgeFilterProcess =
+	setConfig (ConfigKey "filter.annex.process") "git-annex filter-process"
 
 stdattr :: [String]
 stdattr =
diff --git a/Database/Keys.hs b/Database/Keys.hs
--- a/Database/Keys.hs
+++ b/Database/Keys.hs
@@ -49,6 +49,7 @@
 import Git.CatFile
 import Git.Branch (writeTreeQuiet, update')
 import qualified Git.Ref
+import qualified Git.Config
 import Config.Smudge
 import qualified Utility.RawFilePath as R
 
@@ -154,11 +155,23 @@
 {- Note that the files returned were once associated with the key, but
  - some of them may not be any longer. -}
 getAssociatedFiles :: Key -> Annex [TopFilePath]
-getAssociatedFiles = runReaderIO . SQL.getAssociatedFiles
+getAssociatedFiles k = emptyWhenBare $ runReaderIO $ SQL.getAssociatedFiles k
 
+{- Queries for associated files never return anything when in a bare
+ - repository, since without a work tree there can be no associated files. 
+ -
+ - Normally the keys database is not even populated with associated files 
+ - in a bare repository, but it might happen if a non-bare repo got
+ - converted to bare. -}
+emptyWhenBare :: Annex [a] -> Annex [a]
+emptyWhenBare a = ifM (Git.Config.isBare <$> gitRepo)
+	( return []
+	, a
+	)
+
 {- Include a known associated file along with any recorded in the database. -}
 getAssociatedFilesIncluding :: AssociatedFile -> Key -> Annex [RawFilePath]
-getAssociatedFilesIncluding afile k = do
+getAssociatedFilesIncluding afile k = emptyWhenBare $ do
 	g <- Annex.gitRepo
 	l <- map (`fromTopFilePath` g) <$> getAssociatedFiles k
 	return $ case afile of
@@ -168,7 +181,7 @@
 {- Gets any keys that are on record as having a particular associated file.
  - (Should be one or none but the database doesn't enforce that.) -}
 getAssociatedKey :: TopFilePath -> Annex [Key]
-getAssociatedKey = runReaderIO . SQL.getAssociatedKey
+getAssociatedKey f = emptyWhenBare $ runReaderIO $ SQL.getAssociatedKey f
 
 removeAssociatedFile :: Key -> TopFilePath -> Annex ()
 removeAssociatedFile k = runWriterIO . SQL.removeAssociatedFile k
@@ -233,7 +246,7 @@
  - is an associated file.
  -}
 reconcileStaged :: H.DbQueue -> Annex ()
-reconcileStaged qh = do
+reconcileStaged qh = unlessM (Git.Config.isBare <$> gitRepo) $ do
 	gitindex <- inRepo currentIndexFile
 	indexcache <- fromRawFilePath <$> fromRepo gitAnnexKeysDbIndexCache
 	withTSDelta (liftIO . genInodeCache gitindex) >>= \case
diff --git a/Logs/Upgrade.hs b/Logs/Upgrade.hs
new file mode 100644
--- /dev/null
+++ b/Logs/Upgrade.hs
@@ -0,0 +1,52 @@
+{- git-annex upgrade log
+ -
+ - This file is stored locally in .git/annex/, not in the git-annex branch.
+ -
+ - The format: "version timestamp"
+ -
+ - Copyright 2022 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Logs.Upgrade (
+	writeUpgradeLog,
+	readUpgradeLog,
+	timeOfUpgrade,
+) where
+
+import Annex.Common
+import Utility.TimeStamp
+import Logs.File
+import Types.RepoVersion
+
+import Data.Time.Clock.POSIX
+
+writeUpgradeLog :: RepoVersion -> POSIXTime-> Annex ()
+writeUpgradeLog v t = do
+	logfile <- fromRepo gitAnnexUpgradeLog
+	appendLogFile logfile gitAnnexUpgradeLock $ encodeBL $
+		show (fromRepoVersion v) ++ " " ++ show t
+
+readUpgradeLog :: Annex [(RepoVersion, POSIXTime)]
+readUpgradeLog = do
+	logfile <- fromRawFilePath <$> fromRepo gitAnnexUpgradeLog
+	ifM (liftIO $ doesFileExist logfile)
+		( mapMaybe parse . lines
+			<$> liftIO (readFileStrict logfile)
+		, return []
+		)
+  where
+	parse line = case (readish sint, parsePOSIXTime ts) of
+		(Just v, Just t) -> Just (RepoVersion v, t)
+		_ -> Nothing
+	  where
+		(sint, ts) = separate (== ' ') line
+
+timeOfUpgrade :: RepoVersion -> Annex (Maybe POSIXTime)
+timeOfUpgrade want = do
+	l <- readUpgradeLog
+	return $ case filter (\(v, _) -> v == want) l of
+		[] -> Nothing
+		l' -> Just (minimum (map snd l'))
+
diff --git a/NEWS b/NEWS
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,10 @@
+git-annex (8.20211028) upstream; urgency=medium
+
+  This version of git-annex removes support for communicating with git-annex
+  remotes that have version 6.20180312 or older installed.
+
+ -- Joey Hess <id@joeyh.name>  Wed, 05 Jan 2022 10:09:11 -0400
+
 git-annex (8.20200523) upstream; urgency=medium
 
   Transition notice: Commands like `git-annex get foo*` silently skip over
diff --git a/P2P/Annex.hs b/P2P/Annex.hs
--- a/P2P/Annex.hs
+++ b/P2P/Annex.hs
@@ -1,6 +1,6 @@
 {- P2P protocol, Annex implementation
  -
- - Copyright 2016-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2016-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -206,7 +206,9 @@
 							| rightsize -> liftIO (finalizeIncrementalVerifier iv) >>= \case
 								Nothing -> return (True, UnVerified)
 								Just True -> return (True, Verified)
-								Just False -> return (False, UnVerified)
+								Just False -> do
+									verificationOfContentFailed (toRawFilePath dest)
+									return (False, UnVerified)
 							| otherwise -> return (False, UnVerified)
 						Nothing -> return (rightsize, UnVerified)
 					Right (Just Invalid) | l == 0 ->
diff --git a/README b/README
--- a/README
+++ b/README
@@ -1,6 +1,7 @@
-git-annex allows managing files with git, without checking the file
-contents into git. While that may seem paradoxical, it is useful when
-dealing with files larger than git can currently easily handle, whether due
-to limitations in memory, checksumming time, or disk space.
+git-annex allows managing large files with git, without storing the file
+contents in git. It can sync, backup, and archive your data, offline
+and online. Checksums and encryption keep your data safe and secure. Bring
+the power and distributed nature of git to bear on your large files with
+git-annex.
 
 For documentation, see doc/ or <https://git-annex.branchable.com/>
diff --git a/Remote/Adb.hs b/Remote/Adb.hs
--- a/Remote/Adb.hs
+++ b/Remote/Adb.hs
@@ -43,6 +43,8 @@
 			(FieldDesc "location on the Android device where the files are stored")
 		, optionalStringParser androidserialField
 			(FieldDesc "sometimes needed to specify which Android device to use")
+		, yesNoParser ignorefinderrorField (Just False)
+			(FieldDesc "ignore adb find errors")
 		]
 	, setup = adbSetup
 	, exportSupported = exportIsSupported
@@ -56,6 +58,10 @@
 androidserialField :: RemoteConfigField
 androidserialField = Accepted "androidserial"
 
+ignorefinderrorField :: RemoteConfigField
+ignorefinderrorField = Accepted "ignorefinderror"
+
+
 gen :: Git.Repo -> UUID -> RemoteConfig -> RemoteGitConfig -> RemoteStateHandle -> Annex (Maybe Remote)
 gen r u rc gc rs = do
 	c <- parsedRemoteConfig remote rc
@@ -83,7 +89,7 @@
 			, renameExport = renameExportM serial adir
 			}
 		, importActions = ImportActions
-			{ listImportableContents = listImportableContentsM serial adir
+			{ listImportableContents = listImportableContentsM serial adir c
 			, importKey = Nothing
 			, retrieveExportWithContentIdentifier = retrieveExportWithContentIdentifierM serial adir
 			, storeExportWithContentIdentifier = storeExportWithContentIdentifierM serial adir
@@ -202,6 +208,7 @@
 		showOutput -- make way for adb pull output
 		liftIO $ boolSystem "adb" $ mkAdbCommand serial
 			[ Param "pull"
+			, Param "-a"
 			, File $ fromAndroidPath src
 			, File dest
 			]
@@ -288,13 +295,13 @@
 		, File newloc
 		]
 
-listImportableContentsM :: AndroidSerial -> AndroidPath -> Annex (Maybe (ImportableContentsChunkable Annex (ContentIdentifier, ByteSize)))
-listImportableContentsM serial adir = adbfind >>= \case
+listImportableContentsM :: AndroidSerial -> AndroidPath -> ParsedRemoteConfig -> Annex (Maybe (ImportableContentsChunkable Annex (ContentIdentifier, ByteSize)))
+listImportableContentsM serial adir c = adbfind >>= \case
 	Just ls -> return $ Just $ ImportableContentsComplete $ 
 		ImportableContents (mapMaybe mk ls) []
 	Nothing -> giveup "adb find failed"
   where
-	adbfind = adbShell serial
+	adbfind = adbShell' serial
 		[ Param "find"
 		-- trailing slash is needed, or android's find command
 		-- won't recurse into the directory
@@ -303,8 +310,11 @@
 		, Param "-exec", Param "stat"
 		, Param "-c", Param statformat
 		, Param "{}", Param "+"
-		]
+		] 
+		(if ignorefinderror then "|| true" else "")
 
+	ignorefinderror = fromMaybe False (getRemoteConfigValue ignorefinderrorField c)
+
 	statformat = adbStatFormat ++ "\t%n"
 
 	mk ('S':'T':'\t':l) =
@@ -389,8 +399,11 @@
 --
 -- Any stdout from the command is returned, separated into lines.
 adbShell :: AndroidSerial -> [CommandParam] -> Annex (Maybe [String])
-adbShell serial cmd = adbShellRaw serial $
-	unwords $ map shellEscape (toCommand cmd)
+adbShell serial cmd = adbShell' serial cmd ""
+
+adbShell' :: AndroidSerial -> [CommandParam] -> String -> Annex (Maybe [String])
+adbShell' serial cmd extra = adbShellRaw serial $
+	(unwords $ map shellEscape (toCommand cmd)) ++ extra
 
 adbShellBool :: AndroidSerial -> [CommandParam] -> Annex Bool
 adbShellBool serial cmd =
diff --git a/Remote/Git.hs b/Remote/Git.hs
--- a/Remote/Git.hs
+++ b/Remote/Git.hs
@@ -30,6 +30,7 @@
 import Annex.Transfer
 import Annex.CopyFile
 import Annex.Verify
+import Annex.Content (verificationOfContentFailed)
 import Annex.UUID
 import qualified Annex.Content
 import qualified Annex.BranchState
@@ -706,7 +707,9 @@
 			ifM (liftIO (catchBoolIO (linker src dest)))
 				( ifM check
 					( return (True, Verified)
-					, return (False, UnVerified)
+					, do
+						verificationOfContentFailed (toRawFilePath dest)
+						return (False, UnVerified)
 					)
 				, copier src dest k p check verifyconfig
 				)
@@ -717,7 +720,9 @@
 		fileCopier copycowtried src dest p iv >>= \case
 			Copied -> ifM check
 				( finishVerifyKeyContentIncrementally iv
-				, return (False, UnVerified)
+				, do
+					verificationOfContentFailed (toRawFilePath dest)
+					return (False, UnVerified)
 				)
 			CopiedCoW -> unVerified check
 
diff --git a/Remote/Helper/Ssh.hs b/Remote/Helper/Ssh.hs
--- a/Remote/Helper/Ssh.hs
+++ b/Remote/Helper/Ssh.hs
@@ -1,6 +1,6 @@
 {- git-annex remote access with ssh and git-annex-shell
  -
- - Copyright 2011-2018 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -27,7 +27,6 @@
 import qualified P2P.Annex as P2P
 
 import Control.Concurrent.STM
-import Control.Concurrent.Async
 
 toRepo :: ConsumeStdin -> Git.Repo -> RemoteGitConfig -> SshCommand -> Annex (FilePath, [CommandParam])
 toRepo cs r gc remotecmd = do
@@ -178,18 +177,15 @@
 	gc = gitconfig r
 
 -- A connection over ssh to git-annex shell speaking the P2P protocol.
-type P2PSshConnection = P2P.ClosableConnection
-	(P2P.RunState, P2P.P2PConnection, ProcessHandle, TVar StderrHandlerState)
-
-data StderrHandlerState = DiscardStderr | DisplayStderr | EndStderrHandler
+type P2PSshConnection = P2P.ClosableConnection 
+	(P2P.RunState, P2P.P2PConnection, ProcessHandle)
 
 closeP2PSshConnection :: P2PSshConnection -> IO (P2PSshConnection, Maybe ExitCode)
 closeP2PSshConnection P2P.ClosedConnection = return (P2P.ClosedConnection, Nothing)
-closeP2PSshConnection (P2P.OpenConnection (_st, conn, pid, stderrhandlerst)) =
+closeP2PSshConnection (P2P.OpenConnection (_st, conn, pid)) =
 	-- mask async exceptions, avoid cleanup being interrupted
 	uninterruptibleMask_ $ do
 		P2P.closeConnection conn
-		atomically $ writeTVar stderrhandlerst EndStderrHandler
 		exitcode <- waitForProcess pid
 		return (P2P.ClosedConnection, Just exitcode)
 
@@ -244,11 +240,10 @@
 		Just (cmd, params) -> start cmd params =<< getRepo r
   where
 	start cmd params repo = liftIO $ do
-		(Just from, Just to, Just err, pid) <- createProcess $
+		(Just from, Just to, Nothing, pid) <- createProcess $
 			(proc cmd (toCommand params))
 				{ std_in = CreatePipe
 				, std_out = CreatePipe
-				, std_err = CreatePipe
 				}
 		pidnum <- getPid pid
 		let conn = P2P.P2PConnection
@@ -259,26 +254,19 @@
 			, P2P.connIdent = P2P.ConnIdent $
 				Just $ "ssh connection " ++ show pidnum
 			}
-		stderrhandlerst <- newStderrHandler err pid
 		runst <- P2P.mkRunState P2P.Client
-		let c = P2P.OpenConnection (runst, conn, pid, stderrhandlerst)
+		let c = P2P.OpenConnection (runst, conn, pid)
 		-- When the connection is successful, the remote
 		-- will send an AUTH_SUCCESS with its uuid.
 		let proto = P2P.postAuth $
 			P2P.negotiateProtocolVersion P2P.maxProtocolVersion
 		tryNonAsync (P2P.runNetProto runst conn proto) >>= \case
-			Right (Right (Just theiruuid)) | theiruuid == uuid r -> do
-				atomically $ 
-					writeTVar stderrhandlerst DisplayStderr
+			Right (Right (Just theiruuid)) | theiruuid == uuid r ->
 				return $ Just c
 			_ -> do
 				(cclosed, exitcode) <- closeP2PSshConnection c
 				-- ssh exits 255 when unable to connect to
-				-- server. Return a closed connection in
-				-- this case, to avoid the fallback action
-				-- being run instead, which would mean a
-				-- second connection attempt to this server
-				-- that is down.
+				-- server.
 				if exitcode == Just (ExitFailure 255)
 					then return (Just cclosed)
 					else do
@@ -288,25 +276,6 @@
 		modifyTVar' connpool $
 			maybe (Just P2PSshUnsupported) Just
 
-newStderrHandler :: Handle -> ProcessHandle -> IO (TVar StderrHandlerState)
-newStderrHandler errh ph = do
-	-- stderr from git-annex-shell p2pstdio is initially discarded
-	-- because old versions don't support the command. Once it's known
-	-- to be running, this is changed to DisplayStderr.
-	v <- newTVarIO DiscardStderr
-	void $ async $ go v
-	return v
-  where
-	go v = do
-		hGetLineUntilExitOrEOF ph errh >>= \case
-			Nothing -> hClose errh
-			Just l -> atomically (readTVar v) >>= \case
-				DiscardStderr -> go v
-				DisplayStderr -> do
-					hPutStrLn stderr l
-					go v
-				EndStderrHandler -> hClose errh
-
 -- Runs a P2P Proto action on a remote when it supports that,
 -- otherwise the fallback action.
 runProto :: Remote -> P2PSshConnectionPool -> Annex a -> P2P.Proto a -> Annex (Maybe a)
@@ -323,7 +292,7 @@
 
 runProtoConn :: P2P.Proto a -> P2PSshConnection -> Annex (P2PSshConnection, Maybe a)
 runProtoConn _ P2P.ClosedConnection = return (P2P.ClosedConnection, Nothing)
-runProtoConn a conn@(P2P.OpenConnection (runst, c, _, _)) = do
+runProtoConn a conn@(P2P.OpenConnection (runst, c, _)) = do
 	P2P.runFullProto runst c a >>= \case
 		Right r -> return (conn, Just r)
 		-- When runFullProto fails, the connection is no longer
diff --git a/Types/Upgrade.hs b/Types/Upgrade.hs
new file mode 100644
--- /dev/null
+++ b/Types/Upgrade.hs
@@ -0,0 +1,10 @@
+{- git-annex upgrade types
+ -
+ - Copyright 2022 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Types.Upgrade where
+
+data UpgradeResult = UpgradeSuccess | UpgradeFailed | UpgradeDeferred
diff --git a/Upgrade.hs b/Upgrade.hs
--- a/Upgrade.hs
+++ b/Upgrade.hs
@@ -1,6 +1,6 @@
 {- git-annex upgrade support
  -
- - Copyright 2010-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2022 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -10,12 +10,14 @@
 module Upgrade where
 
 import Annex.Common
+import Types.Upgrade
 import qualified Annex
 import qualified Git
 import Config
 import Annex.Path
 import Annex.Version
 import Types.RepoVersion
+import Logs.Upgrade
 #ifndef mingw32_HOST_OS
 import qualified Upgrade.V0
 import qualified Upgrade.V1
@@ -26,8 +28,11 @@
 import qualified Upgrade.V5
 import qualified Upgrade.V6
 import qualified Upgrade.V7
+import qualified Upgrade.V8
+import qualified Upgrade.V9
 
 import qualified Data.Map as M
+import Data.Time.Clock.POSIX
 
 checkUpgrade :: RepoVersion -> Annex ()
 checkUpgrade = maybe noop giveup <=< needsUpgrade
@@ -37,7 +42,7 @@
 	| v `elem` supportedVersions = ok
 	| otherwise = case M.lookup v autoUpgradeableVersions of
 		Nothing
-			| v `elem` upgradableVersions ->
+			| v `elem` upgradeableVersions ->
 				err "Upgrade this repository: git-annex upgrade"
 			| otherwise ->
 				err "Upgrade git-annex."
@@ -62,25 +67,27 @@
 
 upgrade :: Bool -> RepoVersion -> Annex Bool
 upgrade automatic destversion = do
-	upgraded <- go =<< getVersion
-	when upgraded
-		postupgrade
+	(upgraded, newversion) <- go =<< getVersion
+	when upgraded $
+		postupgrade newversion
 	return upgraded
   where
 	go (Just v)
-		| v >= destversion = return True
+		| v >= destversion = return (True, Just v)
 		| otherwise = ifM upgradingRemote
 			( upgraderemote
-			, ifM (up v)
-				( go (Just (RepoVersion (fromRepoVersion v + 1)))
-				, return False
-				)
+			, up v >>= \case
+				UpgradeSuccess -> go (Just (incrversion v) )
+				UpgradeFailed -> return (False, Just v)
+				UpgradeDeferred -> return (True, Just v)
 			)
-	go _ = return True
+	go Nothing = return (True, Nothing)
 
-	postupgrade = ifM upgradingRemote
+	incrversion v = RepoVersion (fromRepoVersion v + 1)
+
+	postupgrade newversion = ifM upgradingRemote
 		( reloadConfig
-		, setVersion destversion
+		, maybe noop upgradedto newversion
 		)
 
 #ifndef mingw32_HOST_OS
@@ -96,7 +103,9 @@
 	up (RepoVersion 5) = Upgrade.V5.upgrade automatic
 	up (RepoVersion 6) = Upgrade.V6.upgrade automatic
 	up (RepoVersion 7) = Upgrade.V7.upgrade automatic
-	up _ = return True
+	up (RepoVersion 8) = Upgrade.V8.upgrade automatic
+	up (RepoVersion 9) = Upgrade.V9.upgrade automatic
+	up _ = return UpgradeDeferred
 
 	-- Upgrade local remotes by running git-annex upgrade in them.
 	-- This avoids complicating the upgrade code by needing to handle
@@ -109,9 +118,13 @@
 			]
 			(\p -> p { cwd = Just rp })
 			(\_ _ _ pid -> waitForProcess pid >>= return . \case
-				ExitSuccess -> True
-				_ -> False
+				ExitSuccess -> (True, Nothing)
+				_ -> (False, Nothing)
 			)
+
+	upgradedto v = do
+		setVersion v
+		writeUpgradeLog v =<< liftIO getPOSIXTime
 
 upgradingRemote :: Annex Bool
 upgradingRemote = isJust <$> fromRepo Git.remoteName
diff --git a/Upgrade/V0.hs b/Upgrade/V0.hs
--- a/Upgrade/V0.hs
+++ b/Upgrade/V0.hs
@@ -8,10 +8,11 @@
 module Upgrade.V0 where
 
 import Annex.Common
+import Types.Upgrade
 import Annex.Content
 import qualified Upgrade.V1
 
-upgrade :: Annex Bool
+upgrade :: Annex UpgradeResult
 upgrade = do
 	showAction "v0 to v1"
 
diff --git a/Upgrade/V1.hs b/Upgrade/V1.hs
--- a/Upgrade/V1.hs
+++ b/Upgrade/V1.hs
@@ -17,6 +17,7 @@
 import qualified System.FilePath.ByteString as P
 
 import Annex.Common
+import Types.Upgrade
 import Annex.Content
 import Annex.Link
 import Annex.Perms
@@ -53,7 +54,7 @@
 -- Something similar to the migrate subcommand could be used,
 -- and users could then run that at their leisure.
 
-upgrade :: Annex Bool
+upgrade :: Annex UpgradeResult
 upgrade = do
 	showAction "v1 to v2"
 	
diff --git a/Upgrade/V2.hs b/Upgrade/V2.hs
--- a/Upgrade/V2.hs
+++ b/Upgrade/V2.hs
@@ -8,6 +8,7 @@
 module Upgrade.V2 where
 
 import Annex.Common
+import Types.Upgrade
 import qualified Git
 import qualified Git.Command
 import qualified Git.Ref
@@ -38,7 +39,7 @@
  - * Remove stuff that used to be needed in .gitattributes.
  - * Commit changes.
  -}
-upgrade :: Annex Bool
+upgrade :: Annex UpgradeResult
 upgrade = do
 	showAction "v2 to v3"
 	bare <- fromRepo Git.repoIsLocalBare
@@ -63,7 +64,7 @@
 
 	unless bare push
 
-	return True
+	return UpgradeSuccess
 
 locationLogs :: Annex [(Key, FilePath)]
 locationLogs = do
diff --git a/Upgrade/V4.hs b/Upgrade/V4.hs
--- a/Upgrade/V4.hs
+++ b/Upgrade/V4.hs
@@ -8,8 +8,9 @@
 module Upgrade.V4 where
 
 import Annex.Common
+import Types.Upgrade
 
 {- Was only used for direct mode upgrade. v4 to v5 indirect update is a no-op,
  - and direct mode is no longer supported, so nothing needs to be done. -}
-upgrade :: Bool -> Annex Bool
-upgrade _automatic = return True
+upgrade :: Bool -> Annex UpgradeResult
+upgrade _automatic = return UpgradeSuccess
diff --git a/Upgrade/V5.hs b/Upgrade/V5.hs
--- a/Upgrade/V5.hs
+++ b/Upgrade/V5.hs
@@ -10,6 +10,7 @@
 module Upgrade.V5 where
 
 import Annex.Common
+import Types.Upgrade
 import Config
 import Config.Smudge
 import Annex.InodeSentinal
@@ -36,7 +37,7 @@
 
 import qualified Data.ByteString as S
 
-upgrade :: Bool -> Annex Bool
+upgrade :: Bool -> Annex UpgradeResult
 upgrade automatic = flip catchNonAsync onexception $ do
 	unless automatic $
 		showAction "v5 to v6"
@@ -55,11 +56,11 @@
 	-- use direct mode may not have created it.
 	unlessM isDirect $
 		createInodeSentinalFile True
-	return True
+	return UpgradeSuccess
   where
 	onexception e = do
 		warning $ "caught exception: " ++ show e
-		return False
+		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
@@ -8,14 +8,15 @@
 module Upgrade.V6 where
 
 import Annex.Common
+import Types.Upgrade
 import Config
 import Annex.Hook
 
-upgrade :: Bool -> Annex Bool
+upgrade :: Bool -> Annex UpgradeResult
 upgrade automatic = do
 	unless automatic $
 		showAction "v6 to v7"
 	unlessM isBareRepo $ do
 		hookWrite postCheckoutHook
 		hookWrite postMergeHook
-	return True
+	return UpgradeSuccess
diff --git a/Upgrade/V7.hs b/Upgrade/V7.hs
--- a/Upgrade/V7.hs
+++ b/Upgrade/V7.hs
@@ -12,6 +12,7 @@
 
 import qualified Annex
 import Annex.Common
+import Types.Upgrade
 import Annex.CatFile
 import qualified Database.Keys
 import qualified Database.Keys.SQL
@@ -23,7 +24,7 @@
 
 import qualified System.FilePath.ByteString as P
 
-upgrade :: Bool -> Annex Bool
+upgrade :: Bool -> Annex UpgradeResult
 upgrade automatic = do
 	unless automatic $
 		showAction "v7 to v8"
@@ -54,7 +55,7 @@
 	
 	updateSmudgeFilter
 
-	return True
+	return UpgradeSuccess
 
 gitAnnexKeysDbOld :: Git.Repo -> RawFilePath
 gitAnnexKeysDbOld r = gitAnnexDir r P.</> "keys"
diff --git a/Upgrade/V8.hs b/Upgrade/V8.hs
new file mode 100644
--- /dev/null
+++ b/Upgrade/V8.hs
@@ -0,0 +1,21 @@
+{- git-annex v8 -> v9 upgrade support
+ -
+ - Copyright 2022 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Upgrade.V8 where
+
+import Annex.Common
+import Types.Upgrade
+import Config.Smudge
+
+upgrade :: Bool -> Annex UpgradeResult
+upgrade automatic = do
+	unless automatic $
+		showAction "v8 to v9"
+
+	configureSmudgeFilterProcess
+
+	return UpgradeSuccess
diff --git a/Upgrade/V9.hs b/Upgrade/V9.hs
new file mode 100644
--- /dev/null
+++ b/Upgrade/V9.hs
@@ -0,0 +1,103 @@
+{- git-annex v9 -> v10 upgrade support
+ -
+ - Copyright 2022 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Upgrade.V9 where
+
+import Annex.Common
+import qualified Annex
+import Types.Upgrade
+import Annex.Content
+import Annex.Perms
+import Annex.LockFile
+import Annex.Version
+import Git.ConfigTypes
+import Types.RepoVersion
+import Logs.Upgrade
+import Utility.Daemon
+
+import Data.Time.Clock.POSIX
+
+upgrade :: Bool -> Annex UpgradeResult
+upgrade automatic
+	| automatic = ifM oldprocessesdanger
+		( return UpgradeDeferred
+		, performUpgrade automatic
+		)
+	| otherwise = ifM (oldprocessesdanger <&&> (not <$> Annex.getState Annex.force))
+		( do
+			warning $ unlines unsafeupgrade
+			return UpgradeDeferred
+		, performUpgrade automatic
+		)
+  where
+	{- Wait until a year after the v9 upgrade, to give time for
+	 - any old processes that were running before the v9 upgrade
+	 - to finish. Such old processes lock content using the old method,
+	 - and it is not safe for such to still be running after
+	 - this upgrade. -}
+	oldprocessesdanger = timeOfUpgrade (RepoVersion 9) >>= \case
+		Nothing -> pure True
+		Just t -> do
+			now <- liftIO getPOSIXTime
+			if now - 365*24*60*60 > t
+				then return True
+				else not <$> assistantrunning
+
+	{- Skip upgrade when git-annex assistant (or watch) is running,
+	 - because these are long-running daemons that could conceivably
+	 - run for an entire year and so predate the v9 upgrade. -}
+	assistantrunning = do
+		pidfile <- fromRepo gitAnnexPidFile
+		isJust <$> liftIO (checkDaemon (fromRawFilePath pidfile))
+	
+	unsafeupgrade =
+		[ "Not upgrading from v9 to v10, because there may be git-annex"
+		, "processes running that predate the v9 upgrade. Upgrading with"
+		, "such processes running could lead to data loss. This upgrade"
+		, "will be deferred until one year after the v9 upgrade to make"
+		, "sure there are no such old processes running."
+		, "(Use --force to upgrade immediately.)"
+		]
+
+performUpgrade :: Bool -> Annex UpgradeResult
+performUpgrade automatic = do
+	unless automatic $
+		showAction "v9 to v10"
+	
+	{- Take a lock to ensure that there are no other git-annex
+	 - processes running that are using the old content locking method. -}
+	withExclusiveLock gitAnnexContentLockLock $ do
+		{- When core.sharedRepository is set, object files
+		 - used to have their write bits set. That can now be
+		 - removed, if the user the upgrade is running as has
+		 - permission to remove it.
+		 - (Otherwise, a later fsck will fix up the permissions.) -}
+		withShared $ \sr -> case sr of
+			GroupShared -> removewrite sr
+			AllShared -> removewrite sr
+			_ -> return ()
+
+		{- Set the new version while still holding the lock,
+		 - so that any other process waiting for the lock will
+		 - be able to detect that the upgrade happened. -}
+		setVersion newver
+
+		return UpgradeSuccess
+  where
+	newver = RepoVersion 10
+
+	removewrite sr = do
+		ks <- listKeys InAnnex
+		forM_ ks $ \k -> do
+			obj <- calcRepo (gitAnnexLocation k)
+			keystatus <- getKeyStatus k
+			case keystatus of
+				KeyPresent -> void $ tryIO $
+					freezeContent'' sr obj (Just newver)
+				KeyUnlockedThin -> return ()
+				KeyLockedThin -> return ()
+				KeyMissing -> return ()
diff --git a/doc/git-annex-enable-tor.mdwn b/doc/git-annex-enable-tor.mdwn
--- a/doc/git-annex-enable-tor.mdwn
+++ b/doc/git-annex-enable-tor.mdwn
@@ -18,7 +18,7 @@
 as output by `id -u`
 
 After this command is run, `git annex remotedaemon` can be run to serve the
-tor hidden service, and then `git-annex p2p --gen-address` can be run to
+tor hidden service, and then `git-annex p2p --gen-addresses` can be run to
 give other users access to your repository via the tor hidden service.
 
 # OPTIONS
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
@@ -45,8 +45,8 @@
   Force the repository to be initialized using a different annex.version
   than the current default.
 
-  When the version given is one that automatically upgrades to a newer
-  version, it will automatically use the newer version instead.
+  When the version given is not supported, but can be automatically
+  upgraded to a newer version, it will use the newer version instead.
 
 * --autoenable
 
diff --git a/git-annex.cabal b/git-annex.cabal
--- a/git-annex.cabal
+++ b/git-annex.cabal
@@ -1,11 +1,11 @@
 Name: git-annex
-Version: 8.20211231
+Version: 10.20220127
 Cabal-Version: >= 1.10
 License: AGPL-3
 Maintainer: Joey Hess <id@joeyh.name>
 Author: Joey Hess
 Stability: Stable
-Copyright: 2010-2021 Joey Hess
+Copyright: 2010-2022 Joey Hess
 License-File: COPYRIGHT
 Homepage: http://git-annex.branchable.com/
 Build-type: Custom
@@ -943,6 +943,7 @@
     Logs.UUID
     Logs.UUIDBased
     Logs.Unused
+    Logs.Upgrade
     Logs.View
     Logs.Web
     Messages
@@ -1055,6 +1056,7 @@
     Types.Transitions
     Types.TrustLevel
     Types.UUID
+    Types.Upgrade
     Types.UrlContents
     Types.VectorClock
     Types.View
@@ -1069,6 +1071,8 @@
     Upgrade.V5.Direct
     Upgrade.V6
     Upgrade.V7
+    Upgrade.V8
+    Upgrade.V9
     Utility.Aeson
     Utility.Android
     Utility.Applicative
