diff --git a/Annex/Branch.hs b/Annex/Branch.hs
--- a/Annex/Branch.hs
+++ b/Annex/Branch.hs
@@ -20,6 +20,7 @@
 	updateTo,
 	get,
 	getHistorical,
+	getUnmergedRefs,
 	RegardingUUID(..),
 	change,
 	maybeChange,
@@ -27,7 +28,6 @@
 	createMessage,
 	commit,
 	forceCommit,
-	getBranch,
 	files,
 	rememberTreeish,
 	performTransitions,
@@ -80,6 +80,7 @@
 import Logs.Export.Pure
 import Logs.Difference.Pure
 import qualified Annex.Queue
+import Types.Transitions
 import Annex.Branch.Transitions
 import qualified Annex
 import Annex.Hook
@@ -141,17 +142,12 @@
 {- Ensures that the branch and index are up-to-date; should be
  - called before data is read from it. Runs only once per git-annex run. -}
 update :: Annex BranchState
-update = runUpdateOnce $ journalClean <$$> updateTo =<< siblingBranches
+update = runUpdateOnce $ updateTo =<< siblingBranches
 
 {- Forces an update even if one has already been run. -}
 forceUpdate :: Annex UpdateMade
 forceUpdate = updateTo =<< siblingBranches
 
-data UpdateMade = UpdateMade
-	{ refsWereMerged :: Bool
-	, journalClean :: Bool
-	}
-
 {- Merges the specified Refs into the index, if they have any changes not
  - already in it. The Branch names are only used in the commit message;
  - it's even possible that the provided Branches have not been updated to
@@ -167,8 +163,6 @@
  -
  - Also handles performing any Transitions that have not yet been
  - performed, in either the local branch, or the Refs.
- -
- - Returns True if any refs were merged in, False otherwise.
  -}
 updateTo :: [(Git.Sha, Git.Branch)] -> Annex UpdateMade
 updateTo pairs = ifM (annexMergeAnnexBranches <$> Annex.getGitConfig)
@@ -180,7 +174,6 @@
 updateTo' pairs = do
 	-- ensure branch exists, and get its current ref
 	branchref <- getBranch
-	dirty <- journalDirty gitAnnexJournalDir
 	ignoredrefs <- getIgnoredRefs
 	let unignoredrefs = excludeset ignoredrefs pairs
 	tomerge <- if null unignoredrefs
@@ -188,50 +181,60 @@
 		else do
 			mergedrefs <- getMergedRefs
 			filterM isnewer (excludeset mergedrefs unignoredrefs)
-	journalcleaned <- if null tomerge
-		{- Even when no refs need to be merged, the index
-		 - may still be updated if the branch has gotten ahead 
-		 - of the index, or just if the journal is dirty. -}
-		then ifM (needUpdateIndex branchref)
-			( lockJournal $ \jl -> do
-				forceUpdateIndex jl branchref
-				{- When there are journalled changes
-				 - as well as the branch being updated,
-				 - a commit needs to be done. -}
-				when dirty $
-					go branchref dirty [] jl
-				return True
-			, if dirty
-				then ifM (annexAlwaysCommit <$> Annex.getGitConfig)
-					( do
-						lockJournal $ go branchref dirty []
-						return True
-					, return False
-					)
-				else return True
-			)
-		else do
-			lockJournal $ go branchref dirty tomerge
-			return True
-	journalclean <- if journalcleaned
-		then not <$> privateUUIDsKnown
-		else pure False
-	return $ UpdateMade
-		{ refsWereMerged = not (null tomerge)
-		, journalClean = journalclean 
-		}
+	{- In a read-only repository, catching permission denied lets
+	 - query operations still work, although they will need to do
+	 - additional work since the refs are not merged. -}
+	catchPermissionDenied
+		(const (updatefailedperms tomerge))
+		(go branchref tomerge)
   where
 	excludeset s = filter (\(r, _) -> S.notMember r s)
+
 	isnewer (r, _) = inRepo $ Git.Branch.changed fullname r
-	go branchref dirty tomerge jl = stagejournalwhen dirty jl $ do
+
+	go branchref tomerge = do
+		dirty <- journalDirty gitAnnexJournalDir
+		journalcleaned <- if null tomerge
+			{- Even when no refs need to be merged, the index
+			 - may still be updated if the branch has gotten ahead 
+			 - of the index, or just if the journal is dirty. -}
+			then ifM (needUpdateIndex branchref)
+				( lockJournal $ \jl -> do
+					forceUpdateIndex jl branchref
+					{- When there are journalled changes
+					 - as well as the branch being updated,
+					 - a commit needs to be done. -}
+					when dirty $
+						go' branchref dirty [] jl
+					return True
+				, if dirty
+					then ifM (annexAlwaysCommit <$> Annex.getGitConfig)
+						( lockJournal $ \jl -> do
+							go' branchref dirty [] jl
+							return True
+						, return False
+						)
+					else return True
+				)
+			else lockJournal $ \jl -> do
+				go' branchref dirty tomerge jl
+				return True
+		journalclean <- if journalcleaned
+			then not <$> privateUUIDsKnown
+			else pure False
+		return $ UpdateMade
+			{ refsWereMerged = not (null tomerge)
+			, journalClean = journalclean 
+			}
+	
+	go' branchref dirty tomerge jl = stagejournalwhen dirty jl $ do
 		let (refs, branches) = unzip tomerge
 		merge_desc <- if null tomerge
 			then commitMessage
 			else return $ "merging " ++
 				unwords (map Git.Ref.describe branches) ++ 
 				" into " ++ fromRef name
-		localtransitions <- parseTransitionsStrictly "local"
-			<$> getLocal transitionsLog
+		localtransitions <- getLocalTransitions
 		unless (null tomerge) $ do
 			showSideAction merge_desc
 			mapM_ checkBranchDifferences refs
@@ -249,28 +252,86 @@
 			)
 		addMergedRefs tomerge
 		invalidateCache
+	
 	stagejournalwhen dirty jl a
 		| dirty = stageJournal jl a
 		| otherwise = withIndex a
+	
+	-- Preparing for read-only branch access with unmerged remote refs.
+	updatefailedperms tomerge = do
+		let refs = map fst tomerge
+		-- Gather any transitions that are new to either the
+		-- local branch or a remote ref, which will need to be
+		-- applied on the fly.
+		localts <- getLocalTransitions
+		remotets <- mapM getRefTransitions refs
+		ts <- if all (localts ==) remotets
+			then return []
+			else 
+				let tcs = mapMaybe getTransitionCalculator $
+					knownTransitionList $
+						combineTransitions (localts:remotets)
+				in if null tcs
+					then return []
+					else do
+						config <- Annex.getGitConfig
+						trustmap <- calcTrustMap <$> getStaged trustLog
+						remoteconfigmap <- calcRemoteConfigMap <$> getStaged remoteLog
+						return $ map (\c -> c trustmap remoteconfigmap config) tcs
+		return $ UpdateFailedPermissions 
+			{ refsUnmerged = refs
+			, newTransitions = ts
+			}
 
 {- Gets the content of a file, which may be in the journal, or in the index
- - (and committed to the branch).
+ - (and committed to the branch). 
+ -
+ - Returns an empty string if the file doesn't exist yet.
  - 
  - Updates the branch if necessary, to ensure the most up-to-date available
- - content is returned.
+ - content is returned. 
  -
- - Returns an empty string if the file doesn't exist yet. -}
+ - When permissions prevented updating the branch, reads the content from the
+ - journal, plus the branch, plus all unmerged refs. In this case, any
+ - transitions that have not been applied to all refs will be applied on
+ - the fly.
+ -}
 get :: RawFilePath -> Annex L.ByteString
-get file = getCache file >>= \case
-	Just content -> return content
-	Nothing -> do
-		st <- update
-		content <- if journalIgnorable st
-			then getRef fullname file
-			else getLocal file
-		setCache file content
-		return content
+get file = do
+	st <- update
+	case getCache file st of
+		Just content -> return content
+		Nothing -> do
+			content <- if journalIgnorable st
+				then getRef fullname file
+				else if null (unmergedRefs st) 
+					then getLocal file
+					else unmergedbranchfallback st
+			setCache file content
+			return content
+  where
+	unmergedbranchfallback st = do
+		l <- getLocal file
+		bs <- forM (unmergedRefs st) $ \ref -> getRef ref file
+		let content = l <> mconcat bs
+		return $ applytransitions (unhandledTransitions st) content
+	applytransitions [] content = content
+	applytransitions (changer:rest) content = case changer file content of
+		PreserveFile -> applytransitions rest content
+		ChangeFile builder -> do
+			let content' = toLazyByteString builder
+			if L.null content'
+				-- File is deleted, can't run any other
+				-- transitions on it.
+				then content'
+				else applytransitions rest content'
 
+{- When the git-annex branch is unable to be updated due to permissions,
+ - and there are other git-annex branches that have not been merged into
+ - it, this gets the refs of those branches. -}
+getUnmergedRefs :: Annex [Git.Ref]
+getUnmergedRefs = unmergedRefs <$> update
+
 {- Used to cache the value of a file, which has been read from the branch
  - using some optimised method. The journal has to be checked, in case
  - it has a newer version of the file that has not reached the branch yet.
@@ -285,7 +346,7 @@
 			JournalledContent journalcontent -> journalcontent
 			PossiblyStaleJournalledContent journalcontent ->
 				branchcontent <> journalcontent
-	Annex.BranchState.setCache file content
+	setCache file content
 
 {- Like get, but does not merge the branch, so the info returned may not
  - reflect changes in remotes.
@@ -451,16 +512,28 @@
 		commitIndex' jl committedref racemessage basemessage retrynum' [committedref]
 
 {- Lists all files on the branch. including ones in the journal
- - that have not been committed yet. There may be duplicates in the list. -}
-files :: Annex ([RawFilePath], IO Bool)
+ - that have not been committed yet. 
+ -
+ - There may be duplicates in the list, when the journal has files that
+ - have not been written to the branch yet.
+ - 
+ - In a read-only repository that has other git-annex branches that have
+ - not been merged in, returns Nothing, because it's not possible to
+ - efficiently handle that.
+ -}
+files :: Annex (Maybe ([RawFilePath], IO Bool))
 files = do
-	_  <- update
-	(bfs, cleanup) <- branchFiles
-	-- ++ forces the content of the first list to be buffered in
-	-- memory, so use journalledFiles, which should be much smaller
-	-- most of the time. branchFiles will stream as the list is consumed.
-	l <- (++) <$> journalledFiles <*> pure bfs
-	return (l, cleanup)
+	st <- update
+        if not (null (unmergedRefs st))
+		then return Nothing
+		else do
+			(bfs, cleanup) <- branchFiles
+			-- ++ forces the content of the first list to be
+			-- buffered in memory, so use journalledFiles,
+			-- which should be much smaller most of the time.
+			-- branchFiles will stream as the list is consumed.
+			l <- (++) <$> journalledFiles <*> pure bfs
+			return (Just (l, cleanup))
 
 {- Lists all files currently in the journal. There may be duplicates in
  - the list when using a private journal. -}
@@ -605,6 +678,11 @@
 		removeWhenExistsWith (R.removeLink) (toRawFilePath jlogf)
 	openjlog tmpdir = liftIO $ openTmpFileIn tmpdir "jlog"
 
+getLocalTransitions :: Annex Transitions
+getLocalTransitions = 
+	parseTransitionsStrictly "local"
+		<$> getLocal transitionsLog
+
 {- This is run after the refs have been merged into the index,
  - but before the result is committed to the branch.
  - (Which is why it's passed the contents of the local branches's
@@ -623,22 +701,17 @@
  -}
 handleTransitions :: JournalLocked -> Transitions -> [Git.Ref] -> Annex Bool
 handleTransitions jl localts refs = do
-	m <- M.fromList <$> mapM getreftransition refs
-	let remotets = M.elems m
+	remotets <- mapM getRefTransitions refs
 	if all (localts ==) remotets
 		then return False
 		else do
+			let m = M.fromList (zip refs remotets)
 			let allts = combineTransitions (localts:remotets)
 			let (transitionedrefs, untransitionedrefs) =
 				partition (\r -> M.lookup r m == Just allts) refs
 			performTransitionsLocked jl allts (localts /= allts) transitionedrefs
 			ignoreRefs untransitionedrefs
 			return True
-  where
-	getreftransition ref = do
-		ts <- parseTransitionsStrictly "remote"
-			<$> catFile ref transitionsLog
-		return (ref, ts)
 
 {- Performs the specified transitions on the contents of the index file,
  - commits it to the branch, or creates a new branch.
@@ -803,13 +876,32 @@
  - The action is passed a callback that it can repeatedly call to read
  - the next file and its contents. When there are no more files, the
  - callback will return Nothing.
+ -
+ - In some cases the callback may return the same file more than once,
+ - with different content. This happens rarely, only when the journal
+ - contains additional information, and the last version of the
+ - file it returns is the most current one.
+ -
+ - In a read-only repository that has other git-annex branches that have
+ - not been merged in, returns Nothing, because it's not possible to
+ - efficiently handle that.
  -}
 overBranchFileContents
 	:: (RawFilePath -> Maybe v)
 	-> (Annex (Maybe (v, RawFilePath, Maybe L.ByteString)) -> Annex a)
-	-> Annex a
+	-> Annex (Maybe a)
 overBranchFileContents select go = do
 	st <- update
+	if not (null (unmergedRefs st))
+		then return Nothing
+		else Just <$> overBranchFileContents' select go st
+
+overBranchFileContents'
+	:: (RawFilePath -> Maybe v)
+	-> (Annex (Maybe (v, RawFilePath, Maybe L.ByteString)) -> Annex a)
+	-> BranchState
+	-> Annex a
+overBranchFileContents' select go st = do
 	g <- Annex.gitRepo
 	(l, cleanup) <- inRepo $ Git.LsTree.lsTree
 		Git.LsTree.LsTreeRecursive
@@ -819,7 +911,7 @@
 	buf <- liftIO newEmptyMVar
 	let go' reader = go $ liftIO reader >>= \case
 		Just ((v, f), content) -> do
-			content' <- checkjournal st f content
+			content' <- checkjournal f content
 			return (Just (v, f, content'))
 		Nothing
 			| journalIgnorable st -> return Nothing
@@ -836,7 +928,7 @@
 		`finally` liftIO (void cleanup)
   where
 	-- Check the journal, in case it did not get committed to the branch
-	checkjournal st f branchcontent
+	checkjournal f branchcontent
 		| journalIgnorable st = return branchcontent
 		| otherwise = getJournalFileStale (GetPrivate True) f >>= return . \case
 			NoJournalledContent -> branchcontent
diff --git a/Annex/Branch/Transitions.hs b/Annex/Branch/Transitions.hs
--- a/Annex/Branch/Transitions.hs
+++ b/Annex/Branch/Transitions.hs
@@ -6,7 +6,6 @@
  -}
 
 module Annex.Branch.Transitions (
-	FileTransition(..),
 	getTransitionCalculator,
 	filterBranch,
 ) where
@@ -23,23 +22,17 @@
 import Types.UUID
 import Types.MetaData
 import Types.Remote
+import Types.Transitions
 import Types.GitConfig (GitConfig)
 import Types.ProposedAccepted
 import Annex.SpecialRemote.Config
 
 import qualified Data.Map as M
 import qualified Data.Set as S
-import qualified Data.ByteString.Lazy as L
 import qualified Data.Attoparsec.ByteString.Lazy as A
 import Data.ByteString.Builder
 
-data FileTransition
-	= ChangeFile Builder
-	| PreserveFile
-
-type TransitionCalculator = GitConfig -> RawFilePath -> L.ByteString -> FileTransition
-
-getTransitionCalculator :: Transition -> Maybe (TrustMap -> M.Map UUID RemoteConfig -> TransitionCalculator)
+getTransitionCalculator :: Transition -> Maybe (TrustMap -> M.Map UUID RemoteConfig -> GitConfig -> TransitionCalculator)
 getTransitionCalculator ForgetGitHistory = Nothing
 getTransitionCalculator ForgetDeadRemotes = Just dropDead
 
@@ -55,7 +48,7 @@
 -- the latter uuid, that also needs to be removed. The sameas-uuid
 -- is not removed from the remote log, for the same reason the trust log
 -- is not changed.
-dropDead :: TrustMap -> M.Map UUID RemoteConfig -> TransitionCalculator
+dropDead :: TrustMap -> M.Map UUID RemoteConfig -> GitConfig -> TransitionCalculator
 dropDead trustmap remoteconfigmap gc f content
 	| f == trustLog = PreserveFile
 	| f == remoteLog = ChangeFile $
@@ -78,7 +71,7 @@
 		| otherwise = l
 	minimizesameasdead' c = M.restrictKeys c (S.singleton sameasUUIDField)
 
-filterBranch :: (UUID -> Bool) -> TransitionCalculator
+filterBranch :: (UUID -> Bool) -> GitConfig -> TransitionCalculator
 filterBranch wantuuid gc f content = case getLogVariety gc f of
 	Just OldUUIDBasedLog -> ChangeFile $
 		UUIDBased.buildLogOld byteString $
diff --git a/Annex/BranchState.hs b/Annex/BranchState.hs
--- a/Annex/BranchState.hs
+++ b/Annex/BranchState.hs
@@ -2,7 +2,7 @@
  -
  - Runtime state about the git-annex branch, and a small cache.
  -
- - Copyright 2011-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -11,8 +11,10 @@
 
 import Annex.Common
 import Types.BranchState
+import Types.Transitions
 import qualified Annex
 import Logs
+import qualified Git
 
 import qualified Data.ByteString.Lazy as L
 
@@ -30,29 +32,49 @@
 	a
 	changeState $ \s -> s { indexChecked = True }
 
+data UpdateMade 
+	= UpdateMade
+		{ refsWereMerged :: Bool
+		, journalClean :: Bool
+		}
+	| UpdateFailedPermissions
+		{ refsUnmerged :: [Git.Sha]
+		, newTransitions :: [TransitionCalculator]
+		}
+
 {- Runs an action to update the branch, if it's not been updated before
  - in this run of git-annex. 
  -
- - The action should return True if anything that was in the journal
- - before got staged (or if the journal was empty). That lets an opmisation
- - be done: The journal then does not need to be checked going forward,
- - until new information gets written to it.
- -
  - When interactive access is enabled, the journal is always checked when
  - reading values from the branch, and so this does not need to update
  - the branch.
+ -
+ - When the action leaves the journal clean, by staging anything that
+ - was in it, an optimisation is enabled: The journal does not need to
+ - be checked going forward, until new information gets written to it.
+ -
+ - When the action is unable to update the branch due to a permissions
+ - problem, 
  -}
-runUpdateOnce :: Annex Bool -> Annex BranchState
-runUpdateOnce a = do
+runUpdateOnce :: Annex UpdateMade -> Annex BranchState
+runUpdateOnce update = do
 	st <- getState
 	if branchUpdated st || needInteractiveAccess st
 		then return st
 		else do
-			journalstaged <- a
-			let stf = \st' -> st'
-				{ branchUpdated = True
-				, journalIgnorable = journalstaged 
-				}
+			um <- update
+			let stf = case um of
+				UpdateMade {} -> \st' -> st'
+					{ branchUpdated = True
+					, journalIgnorable = journalClean um
+					}
+				UpdateFailedPermissions {} -> \st' -> st'
+					{ branchUpdated = True
+					, journalIgnorable = False
+					, unmergedRefs = refsUnmerged um
+					, unhandledTransitions = newTransitions um
+					, cachedFileContents = []
+					}
 			changeState stf
 			return (stf st)
 
@@ -98,13 +120,13 @@
 		| length l < logFilesToCache = (file, content) : l
 		| otherwise = (file, content) : Prelude.init l
 
-getCache :: RawFilePath -> Annex (Maybe L.ByteString)
-getCache file = (\st -> go (cachedFileContents st) st) <$> getState
+getCache :: RawFilePath -> BranchState -> Maybe L.ByteString
+getCache file state = go (cachedFileContents state)
   where
-	go [] _ = Nothing
-	go ((f,c):rest) state
+	go [] = Nothing
+	go ((f,c):rest)
 		| f == file && not (needInteractiveAccess state) = Just c
-		| otherwise = go rest state
+		| otherwise = go rest
 
 invalidateCache :: Annex ()
 invalidateCache = changeState $ \s -> s { cachedFileContents = [] }
diff --git a/Annex/Concurrent.hs b/Annex/Concurrent.hs
--- a/Annex/Concurrent.hs
+++ b/Annex/Concurrent.hs
@@ -14,7 +14,6 @@
 import Annex.Common
 import Annex.Concurrent.Utility
 import qualified Annex.Queue
-import Annex.Action
 import Types.Concurrency
 import Types.CatFileHandles
 import Annex.CatFile
diff --git a/Annex/Ingest.hs b/Annex/Ingest.hs
--- a/Annex/Ingest.hs
+++ b/Annex/Ingest.hs
@@ -184,18 +184,17 @@
 	ms <- liftIO $ catchMaybeIO $ R.getFileStatus src
 	mcache <- maybe (pure Nothing) (liftIO . toInodeCache delta src) ms
 	case (mcache, inodeCache source) of
-		(_, Nothing) -> go k mcache ms
-		(Just newc, Just c) | compareStrong c newc -> go k mcache ms
+		(_, Nothing) -> go k mcache
+		(Just newc, Just c) | compareStrong c newc -> go k mcache
 		_ -> failure "changed while it was being added"
   where
-	go key mcache (Just s)
-		| lockingFile cfg = golocked key mcache s
-		| otherwise = gounlocked key mcache s
-	go _ _ Nothing = failure "failed to generate a key"
+	go key mcache
+		| lockingFile cfg = golocked key mcache
+		| otherwise = gounlocked key mcache
 
-	golocked key mcache s =
+	golocked key mcache =
 		tryNonAsync (moveAnnex key naf (contentLocation source)) >>= \case
-			Right True -> success key mcache s		
+			Right True -> success key mcache
 			Right False -> giveup "failed to add content to annex"
 			Left e -> restoreFile (keyFilename source) key e
 
@@ -205,7 +204,7 @@
 	-- file, so provide it nothing.
 	naf = AssociatedFile Nothing
 
-	gounlocked key (Just cache) s = do
+	gounlocked key (Just cache) = do
 		-- Remove temp directory hard link first because
 		-- linkToAnnex falls back to copying if a file
 		-- already has a hard link.
@@ -215,11 +214,11 @@
 			LinkAnnexFailed -> failure "failed to link to annex"
 			lar -> do
 				finishIngestUnlocked' key source restage (Just lar)
-				success key (Just cache) s
-	gounlocked _ _ _ = failure "failed statting file"
+				success key (Just cache)
+	gounlocked _ _ = failure "failed statting file"
 
-	success k mcache s = do
-		genMetaData k (keyFilename source) s
+	success k mcache = do
+		genMetaData k (keyFilename source) (fmap inodeCacheToMtime mcache)
 		return (Just k, mcache)
 
 	failure msg = do
diff --git a/Annex/Init.hs b/Annex/Init.hs
--- a/Annex/Init.hs
+++ b/Annex/Init.hs
@@ -200,7 +200,10 @@
   where
 	needsinit = ifM autoInitializeAllowed
 		( do
-			initialize True Nothing Nothing
+			tryNonAsync (initialize True Nothing Nothing) >>= \case
+				Right () -> noop
+				Left e -> giveup $ show e ++ "\n" ++
+					"git-annex: automatic initialization failed due to above problems"
 			autoEnableSpecialRemotes
 		, giveup "First run: git-annex init"
 		)
diff --git a/Annex/LockPool/PosixOrPid.hs b/Annex/LockPool/PosixOrPid.hs
--- a/Annex/LockPool/PosixOrPid.hs
+++ b/Annex/LockPool/PosixOrPid.hs
@@ -1,7 +1,7 @@
 {- Wraps Utility.LockPool, making pid locks be used when git-annex is so
  - configured.
  -
- - Copyright 2015-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2015-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -28,7 +28,7 @@
 import qualified Utility.LockPool.LockHandle as H
 import Utility.LockPool.LockHandle (LockHandle, dropLock)
 import Utility.LockFile.Posix (openLockFile)
-import Utility.LockPool.STM (LockFile)
+import Utility.LockPool.STM (LockFile, LockMode(..))
 import Utility.LockFile.LockStatus
 import Config (pidLockFile)
 import Messages (warning)
@@ -36,16 +36,16 @@
 import System.Posix
 
 lockShared :: Maybe FileMode -> LockFile -> Annex LockHandle
-lockShared m f = pidLock m f $ Posix.lockShared m f
+lockShared m f = pidLock m f LockShared $ Posix.lockShared m f
 
 lockExclusive :: Maybe FileMode -> LockFile -> Annex LockHandle
-lockExclusive m f = pidLock m f $ Posix.lockExclusive m f
+lockExclusive m f = pidLock m f LockExclusive $ Posix.lockExclusive m f
 
 tryLockShared :: Maybe FileMode -> LockFile -> Annex (Maybe LockHandle)
-tryLockShared m f = tryPidLock m f $ Posix.tryLockShared m f
+tryLockShared m f = tryPidLock m f LockShared $ Posix.tryLockShared m f
 
 tryLockExclusive :: Maybe FileMode -> LockFile -> Annex (Maybe LockHandle)
-tryLockExclusive m f = tryPidLock m f $ Posix.tryLockExclusive m f
+tryLockExclusive m f = tryPidLock m f LockExclusive $ Posix.tryLockExclusive m f
 
 checkLocked :: LockFile -> Annex (Maybe Bool)
 checkLocked f = Posix.checkLocked f `pidLockCheck` checkpid
@@ -67,22 +67,22 @@
 pidLockCheck posixcheck pidcheck = debugLocks $
 	liftIO . maybe posixcheck pidcheck =<< pidLockFile
 
-pidLock :: Maybe FileMode -> LockFile -> IO LockHandle -> Annex LockHandle
-pidLock m f posixlock = debugLocks $ go =<< pidLockFile
+pidLock :: Maybe FileMode -> 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 timeout pidlock warning
+		Pid.waitLock f lockmode timeout pidlock warning
 
-tryPidLock :: Maybe FileMode -> LockFile -> IO (Maybe LockHandle) -> Annex (Maybe LockHandle)
-tryPidLock m f posixlock = debugLocks $ liftIO . go =<< pidLockFile
+tryPidLock :: Maybe FileMode -> LockFile -> LockMode -> IO (Maybe LockHandle) -> Annex (Maybe LockHandle)
+tryPidLock m f lockmode posixlock = debugLocks $ liftIO . go =<< pidLockFile
   where
 	go Nothing = posixlock
 	go (Just pidlock) = do
 		dummyPosixLock m f
-		Pid.tryLock pidlock
+		Pid.tryLock f lockmode pidlock
 
 -- 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
diff --git a/Annex/MetaData.hs b/Annex/MetaData.hs
--- a/Annex/MetaData.hs
+++ b/Annex/MetaData.hs
@@ -37,8 +37,8 @@
  -
  - Also, can generate new metadata, if configured to do so.
  -}
-genMetaData :: Key -> RawFilePath -> FileStatus -> Annex ()
-genMetaData key file status = do
+genMetaData :: Key -> RawFilePath -> Maybe POSIXTime -> Annex ()
+genMetaData key file mmtime = do
 	catKeyFileHEAD file >>= \case
 		Nothing -> noop
 		Just oldkey ->
@@ -47,11 +47,14 @@
 			-- preserve any metadata already on key.
 			whenM (copyMetaData oldkey key <&&> (not <$> onlydatemeta oldkey)) $
 				warncopied
-	whenM (annexGenMetaData <$> Annex.getGitConfig) $ do
-		old <- getCurrentMetaData key
-		addMetaData key (dateMetaData mtime old)
+	whenM (annexGenMetaData <$> Annex.getGitConfig) $
+		case mmtime of
+			Just mtime -> do
+				old <- getCurrentMetaData key
+				addMetaData key $
+					dateMetaData (posixSecondsToUTCTime mtime) old
+			Nothing -> noop
   where
-	mtime = posixSecondsToUTCTime $ realToFrac $ modificationTime status
 	warncopied = warning $ 
 		"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
diff --git a/Annex/PidLock.hs b/Annex/PidLock.hs
--- a/Annex/PidLock.hs
+++ b/Annex/PidLock.hs
@@ -1,6 +1,6 @@
 {- Pid locking support.
  -
- - Copyright 2014-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2014-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -53,7 +53,7 @@
 			cleanup
 			(go gonopidlock p pidlock)
   where
-  	setup pidlock = PidP.tryLock pidlock
+  	setup pidlock = fmap fst <$> PidP.tryLock' pidlock
 
 	cleanup (Just h) = dropLock h
 	cleanup Nothing = return ()
@@ -83,7 +83,7 @@
 	Nothing -> a
 	Just pidlock -> bracket (setup pidlock) cleanup (go pidlock)
   where
-	setup pidlock = liftIO $ PidP.tryLock pidlock
+	setup pidlock = liftIO $ fmap fst <$> PidP.tryLock' pidlock
 	
 	cleanup (Just h) = liftIO $ dropLock h
 	cleanup Nothing = return ()
@@ -112,7 +112,7 @@
 	Nothing -> liftIO $ a r
 	Just pidlock -> liftIO $ bracket (setup pidlock) cleanup (go pidlock)
   where
-	setup pidlock = PidP.tryLock pidlock
+	setup pidlock = fmap fst <$> PidP.tryLock' pidlock
 	
 	cleanup (Just h) = dropLock h
 	cleanup Nothing = return ()
diff --git a/Annex/Queue.hs b/Annex/Queue.hs
--- a/Annex/Queue.hs
+++ b/Annex/Queue.hs
@@ -78,7 +78,8 @@
 
 new :: Annex (Git.Queue.Queue Annex)
 new = do
-	q <- Git.Queue.new . annexQueueSize <$> getGitConfig
+	sz <- annexQueueSize <$> getGitConfig
+	q <- liftIO $ Git.Queue.new sz Nothing
 	store q
 	return q
 
diff --git a/Assistant/DeleteRemote.hs b/Assistant/DeleteRemote.hs
--- a/Assistant/DeleteRemote.hs
+++ b/Assistant/DeleteRemote.hs
@@ -53,14 +53,14 @@
  - in keys in the current branch.
  -}
 removableRemote :: UrlRenderer -> UUID -> Assistant ()
-removableRemote urlrenderer uuid = do
-	keys <- getkeys
-	if null keys
-		then finishRemovingRemote urlrenderer uuid
-		else do
+removableRemote urlrenderer uuid = getkeys >>= \case
+	Just keys
+		| null keys -> finishRemovingRemote urlrenderer uuid
+		| otherwise -> do
 			r <- fromMaybe (error "unknown remote")
 				<$> liftAnnex (Remote.remoteFromUUID uuid)
 			mapM_ (queueremaining r) keys
+	Nothing -> noop
   where
 	queueremaining r k = 
 		queueTransferWhenSmall "remaining object in unwanted remote"
diff --git a/Assistant/Sync.hs b/Assistant/Sync.hs
--- a/Assistant/Sync.hs
+++ b/Assistant/Sync.hs
@@ -225,8 +225,9 @@
 				, return $ Just r
 				)
 		else return Nothing
-	haddiverged <- Annex.Branch.refsWereMerged
-		<$> liftAnnex Annex.Branch.forceUpdate
+	haddiverged <- liftAnnex Annex.Branch.forceUpdate >>= return . \case
+		u@(Annex.Branch.UpdateMade {}) -> Annex.Branch.refsWereMerged u
+		(Annex.Branch.UpdateFailedPermissions {}) -> True
 	forM_ remotes $ \r ->
 		liftAnnex $ Command.Sync.mergeRemote r
 			currentbranch mc def
diff --git a/Assistant/Threads/Merger.hs b/Assistant/Threads/Merger.hs
--- a/Assistant/Threads/Merger.hs
+++ b/Assistant/Threads/Merger.hs
@@ -70,8 +70,9 @@
 	| ".lock" `isSuffixOf` file = noop
 	| isAnnexBranch file = do
 		branchChanged
-		diverged <- Annex.Branch.refsWereMerged
-			<$> liftAnnex Annex.Branch.forceUpdate
+		diverged <- liftAnnex Annex.Branch.forceUpdate >>= return . \case
+			u@(Annex.Branch.UpdateMade {}) -> Annex.Branch.refsWereMerged u
+			(Annex.Branch.UpdateFailedPermissions {}) -> True
 		when diverged $ do
 			updateExportTreeFromLogAll
 			queueDeferredDownloads "retrying deferred download" Later
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,29 @@
+git-annex (8.20211231) upstream; urgency=medium
+
+  * Improved support for using git-annex in a read-only repository,
+    git-annex branch information from remotes that cannot be merged into
+    the git-annex branch will now not crash it, but will be merged in
+    memory.
+  * addurl, youtube-dl: When --check-raw prevents downloading an url,
+    still continue with any downloads that come after it, rather than
+    erroring out.
+  * Fix locking problems when annex.pidlock is set and concurrency is
+    enabled eg with -J.
+  * Improve error message display when autoinit fails due to eg, a
+    permissions problem.
+  * export: Avoid unncessarily re-exporting non-annexed files that were
+    already exported.
+  * Improve git command queue flushing so that eg, addurl of several
+    large files that take time to download will update the index for each
+    file, rather than deferring the index updates to the end.
+  * sync: Better error message when unable to export to a remote because
+    remote.name.annex-tracking-branch is configured to a ref that does not
+    exist.
+  * Fix build with ghc 9.0.1
+  * Fix build with old versions of feed library.
+
+ -- Joey Hess <id@joeyh.name>  Fri, 31 Dec 2021 15:03:36 -0400
+
 git-annex (8.20211123) upstream; urgency=medium
 
   * Bugfix: When -J was enabled, getting files could leak an
diff --git a/CmdLine/Seek.hs b/CmdLine/Seek.hs
--- a/CmdLine/Seek.hs
+++ b/CmdLine/Seek.hs
@@ -282,7 +282,9 @@
 				keyaction Nothing (SeekInput [], k, mkActionItem k)
 				go reader
 			Nothing -> return ()
-		Annex.Branch.overBranchFileContents getk go
+		Annex.Branch.overBranchFileContents getk go >>= \case
+			Just r -> return r
+			Nothing -> giveup "This repository is read-only, and there are unmerged git-annex branches, which prevents operating on all keys. (Set annex.merge-annex-branches to false to ignore the unmerged git-annex branches.)"
 
 	runkeyaction getks = do
 		keyaction <- mkkeyaction
@@ -361,13 +363,14 @@
 				(process mi ofeeder mdfeeder mdcloser False l)
 			mdprocessertid <- liftIO . async =<< forkState
 				(mdprocess mi mdreader ofeeder ocloser)
-			if usesLocationLog seeker || matcherNeedsLocationLog mi
-				then catObjectStream g $ \lfeeder lcloser lreader -> do
+			ifM (precachell mi)
+				( catObjectStream g $ \lfeeder lcloser lreader -> do
 					precachertid <- liftIO . async =<< forkState
 						(precacher mi config oreader lfeeder lcloser)
 					precachefinisher mi lreader checktimelimit
 					join (liftIO (wait precachertid))
-				else finisher mi oreader checktimelimit
+				, finisher mi oreader checktimelimit
+				)
 			join (liftIO (wait mdprocessertid))
 			join (liftIO (wait processertid))
 	liftIO $ void cleanup
@@ -466,6 +469,16 @@
 				mdprocess mi mdreader ofeeder ocloser
 		Just _ -> mdprocess mi mdreader ofeeder ocloser
 		Nothing -> liftIO $ void ocloser
+
+	-- Precache location logs if it will speed things up.
+	--
+	-- When there are git-annex branches that are not able to be
+	-- merged, the precaching is disabled, since it only looks at the
+	-- git-annex branch and not at those.
+	precachell mi
+		| usesLocationLog seeker || matcherNeedsLocationLog mi =
+			null <$> Annex.Branch.getUnmergedRefs
+		| otherwise = pure False
 
 seekHelper :: (a -> RawFilePath) -> WarnUnmatchWhen -> ([LsFiles.Options] -> [RawFilePath] -> Git.Repo -> IO ([a], IO Bool)) -> WorkTreeItems -> Annex ([(SeekInput, a)], IO Bool)
 seekHelper c ww a (WorkTreeItems l) = do
diff --git a/Command/AddUrl.hs b/Command/AddUrl.hs
--- a/Command/AddUrl.hs
+++ b/Command/AddUrl.hs
@@ -182,7 +182,7 @@
   where
 	loguri = setDownloader uri OtherDownloader
 	adduri = addUrlChecked o loguri file (Remote.uuid r) checkexistssize
-	checkexistssize key = return $ case sz of
+	checkexistssize key = return $ Just $ case sz of
 		Nothing -> (True, True, loguri)
 		Just n -> (True, n == fromMaybe n (fromKey keySize key), loguri)
 	geturi = next $ isJust <$> downloadRemoteFile addunlockedmatcher r (downloadOptions o) uri file sz
@@ -270,31 +270,31 @@
 	geturl = next $ isJust <$> addUrlFile addunlockedmatcher (downloadOptions o) url urlinfo file
 	addurl = addUrlChecked o url file webUUID $ \k ->
 		ifM (pure (not (rawOption (downloadOptions o))) <&&> youtubeDlSupported url)
-			( return (True, True, setDownloader url YoutubeDownloader)
-			, checkRaw Nothing (downloadOptions o) $
-				return (Url.urlExists urlinfo, Url.urlSize urlinfo == fromKey keySize k, url)
+			( return (Just (True, True, setDownloader url YoutubeDownloader))
+			, checkRaw Nothing (downloadOptions o) Nothing $
+				return (Just (Url.urlExists urlinfo, Url.urlSize urlinfo == fromKey keySize k, url))
 			)
 
 {- Check that the url exists, and has the same size as the key,
  - and add it as an url to the key. -}
-addUrlChecked :: AddUrlOptions -> URLString -> RawFilePath -> UUID -> (Key -> Annex (Bool, Bool, URLString)) -> Key -> CommandPerform
+addUrlChecked :: AddUrlOptions -> URLString -> RawFilePath -> UUID -> (Key -> Annex (Maybe (Bool, Bool, URLString))) -> Key -> CommandPerform
 addUrlChecked o url file u checkexistssize key =
 	ifM ((elem url <$> getUrls key) <&&> (elem u <$> loggedLocations key))
 		( do
 			showDestinationFile (fromRawFilePath file)
 			next $ return True
-		, do
-			(exists, samesize, url') <- checkexistssize key
-			if exists && (samesize || relaxedOption (downloadOptions o))
-				then do
+		, checkexistssize key >>= \case
+			Just (exists, samesize, url')
+				| exists && (samesize || relaxedOption (downloadOptions o)) -> do
 					setUrlPresent key url'
 					logChange key u InfoPresent
 					next $ return True
-				else do
+				| otherwise -> do
 					warning $ "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
+			Nothing -> stop
 		)
 
 {- Downloads an url (except in fast or relaxed mode) and adds it to the
@@ -333,7 +333,7 @@
 			in ifAnnexed f
 				(alreadyannexed (fromRawFilePath f))
 				(dl f)
-		Left err -> checkRaw (Just err) o (normalfinish tmp)
+		Left err -> checkRaw (Just err) o Nothing (normalfinish tmp)
 	  where
 		dl dest = withTmpWorkDir mediakey $ \workdir -> do
 			let cleanuptmp = pruneTmpWorkDirBefore tmp (liftIO . removeWhenExistsWith R.removeLink)
@@ -347,7 +347,7 @@
 								showDestinationFile (fromRawFilePath dest)
 								addWorkTree canadd addunlockedmatcher webUUID mediaurl dest mediakey (Just (toRawFilePath mediafile))
 								return $ Just mediakey
-						Right Nothing -> checkRaw Nothing o (normalfinish tmp)
+						Right Nothing -> checkRaw Nothing o Nothing (normalfinish tmp)
 						Left msg -> do
 							cleanuptmp
 							warning msg
@@ -364,12 +364,14 @@
 					warning $ dest ++ " already exists; not overwriting"
 					return Nothing
 	
-checkRaw :: (Maybe String) -> DownloadOptions -> Annex a -> Annex a
-checkRaw failreason o a
-	| noRawOption o = giveup $ "Unable to use youtube-dl or a special remote and --no-raw was specified" ++
-		case failreason of
-			Just msg -> ": " ++ msg
-			Nothing -> ""
+checkRaw :: (Maybe String) -> DownloadOptions -> 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" ++
+			case failreason of
+				Just msg -> ": " ++ msg
+				Nothing -> ""
+		return f
 	| otherwise = a
 
 {- The destination file is not known at start time unless the user provided
@@ -491,7 +493,7 @@
 		then nomedia
 		else youtubeDlFileName url >>= \case
 			Right mediafile -> usemedia (toRawFilePath mediafile)
-			Left err -> checkRaw (Just err) o nomedia
+			Left err -> checkRaw (Just err) o Nothing nomedia
 	| otherwise = do
 		warning $ "unable to access url: " ++ url
 		return Nothing
diff --git a/Command/Export.hs b/Command/Export.hs
--- a/Command/Export.hs
+++ b/Command/Export.hs
@@ -275,11 +275,20 @@
 	af = AssociatedFile (Just f)
 	ai = ActionItemTreeFile f
 	si = SeekInput []
-	notrecordedpresent ek = (||)
-		<$> liftIO (notElem loc <$> getExportedLocation db ek)
-		-- If content was removed from the remote, the export db
-		-- will still list it, so also check location tracking.
-		<*> (notElem (uuid r) <$> loggedLocations ek)
+	notrecordedpresent ek = 
+		ifM  (liftIO $ notElem loc <$> getExportedLocation db ek)
+			( return True
+			-- When content was lost from the remote and
+			-- a fsck noticed that, the export db will still
+			-- list it as present in the remote. So also check 
+			-- location tracking. 
+			-- However, git sha keys do not have their locations
+			-- tracked, and fsck doesn't check them, so not
+			-- for those.
+			, if isGitShaKey ek
+				then return False
+				else notElem (uuid r) <$> loggedLocations ek
+			)
 
 performExport :: Remote -> ExportHandle -> Key -> AssociatedFile -> Sha -> ExportLocation -> MVar AllFilled -> CommandPerform
 performExport r db ek af contentsha loc allfilledvar = do
diff --git a/Command/FilterBranch.hs b/Command/FilterBranch.hs
--- a/Command/FilterBranch.hs
+++ b/Command/FilterBranch.hs
@@ -12,7 +12,8 @@
 import Command
 import qualified Annex
 import qualified Annex.Branch
-import Annex.Branch.Transitions (filterBranch, FileTransition(..))
+import Annex.Branch.Transitions
+import Types.Transitions
 import Annex.HashObject
 import Annex.Tmp
 import Annex.SpecialRemote.Config
diff --git a/Command/ImportFeed.hs b/Command/ImportFeed.hs
--- a/Command/ImportFeed.hs
+++ b/Command/ImportFeed.hs
@@ -7,6 +7,7 @@
 
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE CPP #-}
 
 module Command.ImportFeed where
 
@@ -88,7 +89,7 @@
   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
+	go tmpf = liftIO (parseFeedFromFile' tmpf) >>= \case
 		Nothing -> debugfeedcontent tmpf "parsing the feed failed"
 		Just f -> case findDownloads url f of
 			[] -> debugfeedcontent tmpf "bad feed content; no enclosures to download"
@@ -109,6 +110,13 @@
 		showEndResult =<< feedProblem url
 			(msg ++ " (use --debug --debugfilter=ImportFeed to see the feed content that was downloaded)")
 
+parseFeedFromFile' :: FilePath -> IO (Maybe Feed)
+#if MIN_VERSION_feed(1,1,0)
+parseFeedFromFile' = parseFeedFromFile
+#else
+parseFeedFromFile' f = catchMaybeIO (parseFeedFromFile f)
+#endif
+
 data ToDownload = ToDownload
 	{ feed :: Feed
 	, feedurl :: URLString
@@ -142,7 +150,9 @@
 {- Scan all url logs and metadata logs in the branch and find urls
  - and ItemIds that are already known. -}
 knownItems :: Annex ([URLString], [ItemId])
-knownItems = Annex.Branch.overBranchFileContents select (go [] [])
+knownItems = Annex.Branch.overBranchFileContents select (go [] []) >>= \case
+		Just r -> return r
+		Nothing -> giveup "This repository is read-only."
   where
 	select f
 		| isUrlLog f = Just ()
@@ -194,7 +204,7 @@
 			let f' = fromRawFilePath f
 			r <- Remote.claimingUrl url
 			if Remote.uuid r == webUUID || rawOption (downloadOptions opts)
-				then checkRaw Nothing (downloadOptions opts) $ do
+				then checkRaw (Just url) (downloadOptions opts) Nothing $ do
 					let dlopts = (downloadOptions opts)
 						-- force using the filename
 						-- chosen here
@@ -333,7 +343,7 @@
 			, downloadlink False
 			)
 	  where
-		downloadlink started' = checkRaw Nothing (downloadOptions opts) $
+		downloadlink started' = checkRaw (Just linkurl) (downloadOptions opts) False $
 			performDownload' started' addunlockedmatcher opts cache todownload
 				{ location = Enclosure linkurl }
 
diff --git a/Command/Info.hs b/Command/Info.hs
--- a/Command/Info.hs
+++ b/Command/Info.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2011-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -370,13 +370,17 @@
 
 -- "remote" is in the name for JSON backwards-compatibility
 repo_annex_keys :: UUID -> Stat
-repo_annex_keys u = stat "remote annex keys" $ json show $
-	countKeys <$> cachedRemoteData u
+repo_annex_keys u = stat "remote annex keys" $ \d ->
+	cachedRemoteData u >>= \case
+		Right rd -> json show (pure (countKeys rd)) d
+		Left n-> json id (pure n) d
 
 -- "remote" is in the name for JSON backwards-compatibility
 repo_annex_size :: UUID -> Stat
 repo_annex_size u = simpleStat "remote annex size" $
-	showSizeKeys =<< cachedRemoteData u
+	cachedRemoteData u >>= \case
+		Right d -> showSizeKeys d
+		Left n -> pure n
 
 known_annex_files :: Bool -> Stat
 known_annex_files isworktree = 
@@ -524,20 +528,22 @@
 			put s { presentData = Just v }
 			return v
 
-cachedRemoteData :: UUID -> StatState KeyInfo
+cachedRemoteData :: UUID -> StatState (Either String KeyInfo)
 cachedRemoteData u = do
 	s <- get
 	case M.lookup u (repoData s) of
-		Just v -> return v
+		Just v -> return (Right v)
 		Nothing -> do
 			let combinedata d uk = finishCheck uk >>= \case
 				Nothing -> return d
 				Just k -> return $ addKey k d
-			(ks, cleanup) <- lift $ loggedKeysFor' u
-			v <- lift $ foldM combinedata emptyKeyInfo ks
-			liftIO $ void cleanup
-			put s { repoData = M.insert u v (repoData s) }
-			return v
+			lift (loggedKeysFor' u) >>= \case
+				Just (ks, cleanup) -> do
+					v <- lift $ foldM combinedata emptyKeyInfo ks
+					liftIO $ void cleanup
+					put s { repoData = M.insert u v (repoData s) }
+					return (Right v)
+				Nothing -> return (Left "not available in this read-only repository with unmerged git-annex branches")
 
 cachedReferencedData :: StatState KeyInfo
 cachedReferencedData = do
diff --git a/Command/Log.hs b/Command/Log.hs
--- a/Command/Log.hs
+++ b/Command/Log.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2012, 2016 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -82,22 +82,25 @@
 	mkpassthru n v = [Param ("--" ++ n), Param v]
 
 seek :: LogOptions -> CommandSeek
-seek o = do
-	m <- Remote.uuidDescriptions
-	zone <- liftIO getCurrentTimeZone
-	let outputter = mkOutputter m zone o
-	let seeker = AnnexedFileSeeker
-		{ startAction = start o outputter
-		, checkContentPresent = Nothing
-		-- the way this uses the location log would not be helped
-		-- by precaching the current value
-		, usesLocationLog = False
-		}
-	case (logFiles o, allOption o) of
-		(fs, False) -> withFilesInGitAnnex ww seeker
-			=<< workTreeItems ww fs
-		([], True) -> commandAction (startAll o outputter)
-		(_, True) -> giveup "Cannot specify both files and --all"
+seek o = ifM (null <$> Annex.Branch.getUnmergedRefs)
+	( do
+		m <- Remote.uuidDescriptions
+		zone <- liftIO getCurrentTimeZone
+		let outputter = mkOutputter m zone o
+		let seeker = AnnexedFileSeeker
+			{ startAction = start o outputter
+			, checkContentPresent = Nothing
+			-- the way this uses the location log would not be
+			-- helped by precaching the current value
+			, usesLocationLog = False
+			}
+		case (logFiles o, allOption o) of
+			(fs, False) -> withFilesInGitAnnex ww seeker
+				=<< workTreeItems ww fs
+			([], True) -> commandAction (startAll o outputter)
+			(_, True) -> giveup "Cannot specify both files and --all"
+	, 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
 
diff --git a/Command/P2P.hs b/Command/P2P.hs
--- a/Command/P2P.hs
+++ b/Command/P2P.hs
@@ -29,7 +29,6 @@
 
 import Control.Concurrent.Async
 import qualified Data.Text as T
-import Data.Time.Clock.POSIX
 
 cmd :: Command
 cmd = command "p2p" SectionSetup
@@ -233,30 +232,11 @@
 		void $ liftIO $ async $ ui observer producer
 		-- Provide an appid to magic wormhole, to avoid using
 		-- the same channels that other wormhole users use.
-		--
-		-- Since a version of git-annex that did not provide an
-		-- appid is shipping in Debian 9, and having one side
-		-- provide an appid while the other does not will make
-		-- wormhole fail, this is deferred until 2021-12-31.
-		-- After that point, all git-annex's should have been
-		-- upgraded to include this code, and they will start
-		-- providing an appid.
-		--
-		-- This assumes reasonably good client clocks. If the clock
-		-- is completely wrong, it won't use the appid at that
-		-- point, and pairing will fail. On 2021-12-31, minor clock
-		-- skew may also cause transient problems.
-		--
-		-- After 2021-12-31, this can be changed to simply
-		-- always provide the appid.
-		now <- liftIO getPOSIXTime
-		let wormholeparams = if now < 1640950000
-			then []
-			else Wormhole.appId "git-annex.branchable.com/p2p-setup"
+		let appid = Wormhole.appId "git-annex.branchable.com/p2p-setup"
 		(sendres, recvres) <- liftIO $
-			Wormhole.sendFile sendf observer wormholeparams
+			Wormhole.sendFile sendf observer appid
 				`concurrently`
-			Wormhole.receiveFile recvf producer wormholeparams
+			Wormhole.receiveFile recvf producer appid
 		liftIO $ removeWhenExistsWith R.removeLink (toRawFilePath sendf)
 		if sendres /= True
 			then return SendFailed
diff --git a/Command/Sync.hs b/Command/Sync.hs
--- a/Command/Sync.hs
+++ b/Command/Sync.hs
@@ -888,7 +888,7 @@
 			Export.closeDb
 			(\db -> Export.writeLockDbWhile db (go' r db))
 	go' r db = case remoteAnnexTrackingBranch (Remote.gitconfig r) of
-		Nothing -> nontracking r db
+		Nothing -> cannotupdateexport r db Nothing
 		Just b -> do
 			mtree <- inRepo $ Git.Ref.tree b
 			mtbcommitsha <- Command.Export.getExportCommit r b
@@ -897,33 +897,41 @@
 					filteredtree <- Command.Export.filterExport r tree
 					Command.Export.changeExport r db filteredtree
 					Command.Export.fillExport r db filteredtree mtbcommitsha
-				_ -> nontracking r db
+				_ -> cannotupdateexport r db (Just b)
 	
-	nontracking r db = do
+	cannotupdateexport r db mtb = do
 		exported <- getExport (Remote.uuid r)
-		maybe noop (warnnontracking r exported) currbranch
-		nontrackingfillexport r db (exportedTreeishes exported) Nothing
+		maybe noop (warncannotupdateexport r mtb exported) currbranch
+		fillexistingexport r db (exportedTreeishes exported) Nothing
 	
-	warnnontracking r exported currb = inRepo (Git.Ref.tree currb) >>= \case
-		Just currt | not (any (== currt) (exportedTreeishes exported)) ->
-			showLongNote $ unwords
-				[ "Not updating export to " ++ Remote.name r
-				, "to reflect changes to the tree, because export"
-				, "tracking is not enabled. "
-				, "(Set " ++ gitconfig ++ " to enable it.)"
-				]
-		_ -> noop
+	warncannotupdateexport r mtb exported currb = case mtb of
+		Nothing -> inRepo (Git.Ref.tree currb) >>= \case
+			Just currt | not (any (== currt) (exportedTreeishes exported)) ->
+				showLongNote $ unwords
+					[ notupdating
+					, "to reflect changes to the tree, because export"
+					, "tracking is not enabled. "
+					, "(Set " ++ gitconfig ++ " to enable it.)"
+					]
+			_ -> noop
+		Just b -> showLongNote $ unwords
+			[ notupdating
+			, "because " ++ Git.fromRef b ++ "does not exist."
+			, "(As configured by " ++ gitconfig ++ ")"
+			]
 	  where
 		gitconfig = show (remoteAnnexConfig r "tracking-branch")
+		notupdating = "Not updating export to " ++ Remote.name r
 
-	nontrackingfillexport _ _ [] _ = return False
-	nontrackingfillexport r db (tree:[]) mtbcommitsha = do
+	fillexistingexport _ _ [] _ = return False
+	fillexistingexport r db (tree:[]) mtbcommitsha = do
 		-- The tree was already filtered when it was exported, so
 		-- does not need be be filtered again now, when we're only
-		-- filling in any files that did not get transferred.
+		-- filling in any files that did not get transferred
+		-- to the existing exported tree.
 		let filteredtree = Command.Export.ExportFiltered tree
 		Command.Export.fillExport r db filteredtree mtbcommitsha
-	nontrackingfillexport r _ _ _ = do
+	fillexistingexport r _ _ _ = do
 		warnExportImportConflict r
 		return False
 
diff --git a/Command/Unused.hs b/Command/Unused.hs
--- a/Command/Unused.hs
+++ b/Command/Unused.hs
@@ -101,7 +101,9 @@
 		r <- Remote.byUUID u
 		_ <- check "" (remoteUnusedMsg r remotename) (remoteunused u) 0
 		next $ return True
-	remoteunused u = excludeReferenced refspec =<< loggedKeysFor u
+	remoteunused u = loggedKeysFor u >>= \case
+		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
diff --git a/Git/Command.hs b/Git/Command.hs
--- a/Git/Command.hs
+++ b/Git/Command.hs
@@ -39,7 +39,7 @@
 run :: [CommandParam] -> Repo -> IO ()
 run params repo = assertLocal repo $
 	unlessM (runBool params repo) $
-		error $ "git " ++ show params ++ " failed"
+		giveup $ "git " ++ show params ++ " failed"
 
 {- Runs git and forces it to be quiet, throwing an error if it fails. -}
 runQuiet :: [CommandParam] -> Repo -> IO ()
diff --git a/Git/LsFiles.hs b/Git/LsFiles.hs
--- a/Git/LsFiles.hs
+++ b/Git/LsFiles.hs
@@ -68,7 +68,7 @@
 guardSafeForLsFiles :: Repo -> IO a -> IO a
 guardSafeForLsFiles r a
 	| safeForLsFiles r = a
-	| otherwise = error $ "git ls-files is unsafe to run on repository " ++ repoDescribe r
+	| otherwise = giveup $ "git ls-files is unsafe to run on repository " ++ repoDescribe r
 
 data Options = ErrorUnmatch
 
diff --git a/Git/Queue.hs b/Git/Queue.hs
--- a/Git/Queue.hs
+++ b/Git/Queue.hs
@@ -10,6 +10,7 @@
 module Git.Queue (
 	Queue,
 	new,
+	defaultTimelimit,
 	addCommand,
 	addUpdateIndex,
 	addInternalAction,
@@ -28,6 +29,8 @@
 
 import qualified Data.Map.Strict as M
 import Control.Monad.IO.Class
+import Data.Time.Clock
+import Data.Time.Clock.POSIX
 
 {- Queable actions that can be performed in a git repository. -}
 data Action m
@@ -76,6 +79,8 @@
 data Queue m = Queue
 	{ size :: Int
 	, _limit :: Int
+	, _timelimit :: NominalDiffTime
+	, _lastchanged :: POSIXTime
 	, items :: M.Map ActionKey (Action m)
 	}
 
@@ -91,9 +96,21 @@
 defaultLimit :: Int
 defaultLimit = 10240
 
+{- How close together in seconds changes to the queue have to be happening
+ - in order for it to keep accumulating actions, rather than running actions
+ - immediately. -}
+defaultTimelimit :: NominalDiffTime
+defaultTimelimit = 60 * 5
+
 {- Constructor for empty queue. -}
-new :: Maybe Int -> Queue m
-new lim = Queue 0 (fromMaybe defaultLimit lim) M.empty
+new :: Maybe Int -> Maybe NominalDiffTime -> IO (Queue m)
+new lim tlim = do
+	now <- getPOSIXTime
+	return $ Queue 0
+		(fromMaybe defaultLimit lim)
+		(fromMaybe defaultTimelimit tlim)
+		now
+		M.empty
 
 {- Adds an git command to the queue.
  -
@@ -139,15 +156,30 @@
 	different (UpdateIndexAction _) = False
 	different _ = True
 
-{- Updates or adds an action in the queue. If the queue already contains a
- - different action, it will be flushed; this is to ensure that conflicting
- - actions, like add and rm, are run in the right order.-}
+{- Updates or adds an action in the queue.
+ -
+ - If the queue already contains a different action, it will be flushed
+ - before adding the action; this is to ensure that conflicting actions,
+ - like add and rm, are run in the right order.
+ -
+ - If the queue's time limit has been exceeded, it will also be flushed,
+ - and the action will be run right away.
+ -}
 updateQueue :: MonadIO m => Action m -> (Action m -> Bool) -> Int -> Queue m -> Repo -> m (Queue m)
-updateQueue !action different sizeincrease q repo
-	| null (filter different (M.elems (items q))) = return $ go q
-	| otherwise = go <$> flush q repo
+updateQueue !action different sizeincrease q repo = do
+	now <- liftIO getPOSIXTime
+	if now - (_lastchanged q) > _timelimit q
+		then if isdifferent
+			then do
+				q' <- flush q repo
+				flush (mk q') repo
+			else flush (mk q) repo
+		else if isdifferent
+			then mk <$> flush q repo
+			else return $ mk (q { _lastchanged = now })
   where
-	go q' = newq
+	isdifferent = not (null (filter different (M.elems (items q))))
+	mk q' = newq
 	  where		
 		!newq = q'
 			{ size = newsize
@@ -175,17 +207,19 @@
 merge origq newq = origq
 	{ size = size origq + size newq
 	, items = M.unionWith combineNewOld (items newq) (items origq)
+	, _lastchanged = max (_lastchanged origq) (_lastchanged newq)
 	}
 
 {- Is a queue large enough that it should be flushed? -}
 full :: Queue m -> Bool
-full (Queue cur lim  _) = cur >= lim
+full (Queue cur lim _ _ _) = cur >= lim
 
 {- Runs a queue on a git repository. -}
 flush :: MonadIO m => Queue m -> Repo -> m (Queue m)
-flush (Queue _ lim m) repo = do
+flush (Queue _ lim tlim _ m) repo = do
 	forM_ (M.elems m) $ runAction repo
-	return $ Queue 0 lim M.empty
+	now <- liftIO getPOSIXTime
+	return $ Queue 0 lim tlim now M.empty
 
 {- Runs an Action on a list of files in a git repository.
  -
diff --git a/Git/Repair.hs b/Git/Repair.hs
--- a/Git/Repair.hs
+++ b/Git/Repair.hs
@@ -114,7 +114,7 @@
 	| not (foundBroken missing) = return missing
 	| otherwise = withTmpDir "tmprepo" $ \tmpdir -> do
 		unlessM (boolSystem "git" [Param "init", File tmpdir]) $
-			error $ "failed to create temp repository in " ++ tmpdir
+			giveup $ "failed to create temp repository in " ++ tmpdir
 		tmpr <- Config.read =<< Construct.fromPath (toRawFilePath tmpdir)
 		let repoconfig r' = fromRawFilePath (localGitDir r' P.</> "config")
 		whenM (doesFileExist (repoconfig r)) $
diff --git a/Logs/Location.hs b/Logs/Location.hs
--- a/Logs/Location.hs
+++ b/Logs/Location.hs
@@ -137,15 +137,17 @@
  -
  - Keys that have been marked as dead are not included.
  -}
-loggedKeys :: Annex ([Unchecked Key], IO Bool)
+loggedKeys :: Annex (Maybe ([Unchecked Key], IO Bool))
 loggedKeys = loggedKeys' (not <$$> checkDead)
 
-loggedKeys' :: (Key -> Annex Bool) -> Annex ([Unchecked Key], IO Bool)
+loggedKeys' :: (Key -> Annex Bool) -> Annex (Maybe ([Unchecked Key], IO Bool))
 loggedKeys' check = do
 	config <- Annex.getGitConfig
-	(bfs, cleanup) <- Annex.Branch.files
-	let l = mapMaybe (defercheck <$$> locationLogFileKey config) bfs
-	return (l, cleanup)
+	Annex.Branch.files >>= \case
+		Nothing -> return Nothing
+		Just (bfs, cleanup) -> do
+			let l = mapMaybe (defercheck <$$> locationLogFileKey config) bfs
+			return (Just (l, cleanup))
   where
 	defercheck k = Unchecked $ ifM (check k)
 		( return (Just k)
@@ -157,14 +159,15 @@
  -
  - This does not stream well; use loggedKeysFor' for lazy streaming.
  -}
-loggedKeysFor :: UUID -> Annex [Key]
-loggedKeysFor u = do
-	(l, cleanup) <- loggedKeysFor' u
-	l' <- catMaybes <$> mapM finishCheck l
-	liftIO $ void cleanup
-	return l'
+loggedKeysFor :: UUID -> Annex (Maybe [Key])
+loggedKeysFor u = loggedKeysFor' u >>= \case
+	Nothing -> return Nothing
+	Just (l, cleanup) -> do
+		l' <- catMaybes <$> mapM finishCheck l
+		liftIO $ void cleanup
+		return (Just l')
 
-loggedKeysFor' :: UUID -> Annex ([Unchecked Key], IO Bool)
+loggedKeysFor' :: UUID -> Annex (Maybe ([Unchecked Key], IO Bool))
 loggedKeysFor' u = loggedKeys' isthere
   where
 	isthere k = do
diff --git a/Logs/Transitions.hs b/Logs/Transitions.hs
--- a/Logs/Transitions.hs
+++ b/Logs/Transitions.hs
@@ -7,7 +7,7 @@
  - done that is listed in the remote branch by checking that the local
  - branch contains the same transition, with the same or newer start time.
  -
- - Copyright 2013-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2013-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -19,6 +19,9 @@
 import Annex.Common
 import Annex.VectorClock
 import Logs.Line
+import qualified Git
+import Git.Types (fromRef)
+import Annex.CatFile
 
 import qualified Data.Set as S
 import Data.Either
@@ -100,3 +103,8 @@
 recordTransitions :: (RawFilePath -> (L.ByteString -> Builder) -> Annex ()) -> Transitions -> Annex ()
 recordTransitions changer t = changer transitionsLog $
 	buildTransitions . S.union t . parseTransitionsStrictly "local"
+
+getRefTransitions :: Git.Ref -> Annex Transitions
+getRefTransitions ref = 
+	parseTransitionsStrictly (fromRef ref) 
+		<$> catFile ref transitionsLog
diff --git a/Remote/Glacier.hs b/Remote/Glacier.hs
--- a/Remote/Glacier.hs
+++ b/Remote/Glacier.hs
@@ -28,6 +28,7 @@
 import Annex.UUID
 import Utility.Env
 import Types.ProposedAccepted
+import Utility.Hash (IncrementalVerifier)
 
 type Vault = String
 type Archive = FilePath
@@ -175,7 +176,7 @@
 		forceSuccessProcess cmd pid
 	go' _ _ _ _ _ = error "internal"
 
-retrieve :: Remote -> Retriever
+retrieve :: forall a. Remote -> Key -> MeterUpdate -> Maybe IncrementalVerifier -> (ContentSource -> Annex a) -> Annex a
 retrieve = byteRetriever . retrieve'
 
 retrieve' :: forall a. Remote -> Key -> (L.ByteString -> Annex a) -> Annex a
diff --git a/Test.hs b/Test.hs
--- a/Test.hs
+++ b/Test.hs
@@ -76,7 +76,6 @@
 import qualified Utility.Format
 import qualified Utility.Verifiable
 import qualified Utility.Process
-import qualified Utility.Process.Transcript
 import qualified Utility.Misc
 import qualified Utility.InodeCache
 import qualified Utility.Env
@@ -312,7 +311,7 @@
 unitTests note = testGroup ("Unit Tests " ++ note)
 	[ testCase "add dup" test_add_dup
 	, testCase "add extras" test_add_extras
-	, testCase "readonly" test_readonly
+	, testCase "readonly remote" test_readonly_remote
 	, testCase "ignore deleted files" test_ignore_deleted_files
 	, testCase "metadata" test_metadata
 	, testCase "export_import" test_export_import
@@ -432,18 +431,15 @@
 	annexed_present wormannexedfile
 	checkbackend wormannexedfile backendWORM
 
-test_readonly :: Assertion
-test_readonly =
+test_readonly_remote :: Assertion
+test_readonly_remote =
 #ifndef mingw32_HOST_OS
 	withtmpclonerepo $ \r1 ->
 		withtmpclonerepo $ \r2 -> do
 			pair r1 r2
 			indir r1 $ do
 				git_annex "get" [annexedfile] "get failed in first repo"
-			{- chmod may fail, or not be available, or the
-			 - filesystem not support permissions. -}
-			void $ Utility.Process.Transcript.processTranscript
-				"chmod" ["-R", "-w", r1] Nothing
+			make_readonly r1
 			indir r2 $ do
 				git_annex "sync" ["r1", "--no-push"] "sync with readonly repo"
 				git_annex "get" [annexedfile, "--from", "r1"] "get from readonly repo"
@@ -469,6 +465,9 @@
 	git_annex "metadata" ["-s", "foo=bar", annexedfile] "set metadata"
 	git_annex_expectoutput "find" ["--metadata", "foo=bar"] [annexedfile]
 	git_annex_expectoutput "find" ["--metadata", "foo=other"] []
+	readonly_query $ do
+		git_annex_expectoutput "find" ["--metadata", "foo=bar"] [annexedfile]
+		git_annex_expectoutput "find" ["--metadata", "foo=other"] []
 	writecontent annexedfiledup $ content annexedfiledup
 	add_annex annexedfiledup "add of second file with same content failed"
 	annexed_present annexedfiledup
@@ -1139,10 +1138,12 @@
 test_find = intmpclonerepo $ do
 	annexed_notpresent annexedfile
 	git_annex_expectoutput "find" [] []
+	readonly_query $ git_annex_expectoutput "find" [] []
 	git_annex "get" [annexedfile] "get"
 	annexed_present annexedfile
 	annexed_notpresent sha1annexedfile
 	git_annex_expectoutput "find" [] [annexedfile]
+	readonly_query $ git_annex_expectoutput "find" [] [annexedfile]
 	git_annex_expectoutput "find" ["--exclude", annexedfile, "--and", "--exclude", sha1annexedfile] []
 	git_annex_expectoutput "find" ["--include", annexedfile] [annexedfile]
 	git_annex_expectoutput "find" ["--not", "--in", "origin"] []
@@ -1164,14 +1165,19 @@
 
 test_info :: Assertion
 test_info = intmpclonerepo $ do
-	json <- BU8.fromString <$> git_annex_output "info" ["--json"]
-	case Utility.Aeson.eitherDecode json :: Either String Utility.Aeson.Value of
-		Right _ -> return ()
-		Left e -> assertFailure e
+	isjson
+	readonly_query isjson
+  where
+	isjson = do
+		json <- BU8.fromString <$> git_annex_output "info" ["--json"]
+		case Utility.Aeson.eitherDecode json :: Either String Utility.Aeson.Value of
+			Right _ -> return ()
+			Left e -> assertFailure e
 
 test_version :: Assertion
-test_version = intmpclonerepo $
+test_version = intmpclonerepo $ do
 	git_annex "version" [] "version"
+	readonly_query $ git_annex "version" [] "readonly version"
 
 test_sync :: Assertion
 test_sync = intmpclonerepo $ do
@@ -1697,11 +1703,15 @@
 test_whereis = intmpclonerepo $ do
 	annexed_notpresent annexedfile
 	git_annex "whereis" [annexedfile] "whereis on non-present file"
+	readonly_query $ 
+		git_annex "whereis" [annexedfile] "readonly whereis on non-present file"
 	git_annex "untrust" ["origin"] "untrust"
 	git_annex_shouldfail "whereis" [annexedfile] "whereis should exit nonzero on non-present file only present in untrusted repo"
 	git_annex "get" [annexedfile] "get"
 	annexed_present annexedfile
 	git_annex "whereis" [annexedfile] "whereis on present file"
+	readonly_query $
+		git_annex "whereis" [annexedfile] "readonly whereis on present file"
 
 test_hook_remote :: Assertion
 test_hook_remote = intmpclonerepo $ do
diff --git a/Test/Framework.hs b/Test/Framework.hs
--- a/Test/Framework.hs
+++ b/Test/Framework.hs
@@ -1,6 +1,6 @@
 {- git-annex test suite framework
  -
- - Copyright 2010-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -640,3 +640,24 @@
 	when (r /= r2) $
 		git "remote" ["add", "r2", "../../" ++ r2] "remote add"
 
+
+{- Runs a query in the current repository, but first makes the repository
+ - read-only. The write bit is added back at the end, so when possible, 
+ - include multiple tests within a single call for efficiency. -}
+readonly_query :: Assertion -> Assertion
+readonly_query = bracket_ (make_readonly ".") (make_writeable ".")
+	
+{- Not guaranteed to do anything: 
+ - chmod may fail, or not be available, or the filesystem not support
+ - permissions. -}
+make_readonly :: FilePath -> IO ()
+make_readonly d = void $
+	Utility.Process.Transcript.processTranscript
+		"chmod" ["-R", "-w", d] Nothing
+
+{- The write bit is added back for the current user, but not for other
+ - users, even though make_readonly removes any other user's write bits. -}
+make_writeable :: FilePath -> IO ()
+make_writeable d = void $
+	Utility.Process.Transcript.processTranscript
+		"chmod" ["-R", "u+w", d] Nothing
diff --git a/Types/BranchState.hs b/Types/BranchState.hs
--- a/Types/BranchState.hs
+++ b/Types/BranchState.hs
@@ -1,6 +1,6 @@
 {- git-annex BranchState data type
  -
- - Copyright 2011-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2021 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -8,17 +8,27 @@
 module Types.BranchState where
 
 import Common
+import qualified Git
+import Types.Transitions
 
 import qualified Data.ByteString.Lazy as L
 
 data BranchState = BranchState
 	{ branchUpdated :: Bool
-	-- ^ has the branch been updated this run?
+	-- ^ has the branch been updated this run? (Or an update tried and
+	-- failed due to permissions.)
 	, indexChecked :: Bool
 	-- ^ has the index file been checked to exist?
 	, journalIgnorable :: Bool
 	-- ^ can reading the journal be skipped, while still getting
 	-- sufficiently up-to-date information from the branch?
+	, unmergedRefs :: [Git.Sha]
+	-- ^ when the branch was not able to be updated due to permissions,
+	-- these other git refs contain unmerged information and need to be
+	-- queried, along with the index and the journal.
+	, unhandledTransitions :: [TransitionCalculator]
+	-- ^ when the branch was not able to be updated due to permissions,
+	-- this is transitions that need to be applied when making queries.
 	, cachedFileContents :: [(RawFilePath, L.ByteString)]
 	-- ^ contents of a few files recently read from the branch
 	, needInteractiveAccess :: Bool
@@ -29,4 +39,4 @@
 	}
 
 startBranchState :: BranchState
-startBranchState = BranchState False False False [] False
+startBranchState = BranchState False False False [] [] [] False
diff --git a/Types/Transitions.hs b/Types/Transitions.hs
new file mode 100644
--- /dev/null
+++ b/Types/Transitions.hs
@@ -0,0 +1,19 @@
+{- git-annex transitions data types
+ -
+ - Copyright 2021 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Types.Transitions where
+
+import Utility.RawFilePath
+
+import qualified Data.ByteString.Lazy as L
+import Data.ByteString.Builder
+
+data FileTransition
+	= ChangeFile Builder
+	| PreserveFile
+
+type TransitionCalculator = RawFilePath -> L.ByteString -> FileTransition
diff --git a/Utility/FileSystemEncoding.hs b/Utility/FileSystemEncoding.hs
--- a/Utility/FileSystemEncoding.hs
+++ b/Utility/FileSystemEncoding.hs
@@ -86,10 +86,10 @@
 
 decodeBS :: S.ByteString -> FilePath
 #ifndef mingw32_HOST_OS
--- This is a copy of code from System.FilePath.Internal.decodeFilePath.
--- However, older versions of that library truncated at NUL, which this
--- must not do, because it may end up used on something other than a unix
--- filepath.
+-- This does the same thing as System.FilePath.ByteString.decodeFilePath,
+-- with an identical implementation. However, older versions of that library
+-- truncated at NUL, which this must not do, because it may end up used on
+-- something other than a unix filepath.
 {-# NOINLINE decodeBS #-}
 decodeBS b = unsafePerformIO $ do
 	enc <- Encoding.getFileSystemEncoding
@@ -100,10 +100,10 @@
 
 encodeBS :: FilePath -> S.ByteString
 #ifndef mingw32_HOST_OS
--- This is a copy of code from System.FilePath.Internal.encodeFilePath.
--- However, older versions of that library truncated at NUL, which this
--- must not do, because it may end up used on something other than a unix
--- filepath.
+-- This does the same thing as System.FilePath.ByteString.encodeFilePath,
+-- with an identical implementation. However, older versions of that library
+-- truncated at NUL, which this must not do, because it may end up used on
+-- something other than a unix filepath.
 {-# NOINLINE encodeBS #-}
 encodeBS f = unsafePerformIO $ do
 	enc <- Encoding.getFileSystemEncoding
diff --git a/Utility/LockFile/PidLock.hs b/Utility/LockFile/PidLock.hs
--- a/Utility/LockFile/PidLock.hs
+++ b/Utility/LockFile/PidLock.hs
@@ -1,6 +1,6 @@
 {- pid-based lock files
  -
- - Copyright 2015-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2015-2021 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -8,9 +8,12 @@
 {-# LANGUAGE OverloadedStrings #-}
 
 module Utility.LockFile.PidLock (
+	PidLockFile,
 	LockHandle,
 	tryLock,
 	waitLock,
+	waitedLock,
+	alreadyLocked,
 	dropLock,
 	LockStatus(..),
 	getLockStatus,
@@ -51,13 +54,13 @@
 import Control.Applicative
 import Prelude
 
-type LockFile = RawFilePath
+type PidLockFile = RawFilePath
 
 data LockHandle
-	= LockHandle LockFile FileStatus SideLockHandle
+	= LockHandle PidLockFile FileStatus SideLockHandle
 	| ParentLocked
 
-type SideLockHandle = Maybe (LockFile, Posix.LockHandle)
+type SideLockHandle = Maybe (RawFilePath, Posix.LockHandle)
 
 data PidLock = PidLock
 	{ lockingPid :: ProcessID
@@ -70,13 +73,13 @@
 	<$> getProcessID
 	<*> getHostName
 
-readPidLock :: LockFile -> IO (Maybe PidLock)
+readPidLock :: PidLockFile -> IO (Maybe PidLock)
 readPidLock lockfile = (readish =<<)
 	<$> catchMaybeIO (readFile (fromRawFilePath lockfile))
 
 -- To avoid races when taking over a stale pid lock, a side lock is used.
 -- This is a regular posix exclusive lock.
-trySideLock :: LockFile -> (SideLockHandle -> IO a) -> IO a
+trySideLock :: PidLockFile -> (SideLockHandle -> IO a) -> IO a
 trySideLock lockfile a = do
 	sidelock <- sideLockFile lockfile
 	mlck <- catchDefaultIO Nothing $ 
@@ -111,7 +114,7 @@
 -- The side lock is put in /dev/shm. This will work on most any
 -- Linux system, even if its whole root filesystem doesn't support posix
 -- locks. /tmp is used as a fallback.
-sideLockFile :: LockFile -> IO LockFile
+sideLockFile :: PidLockFile -> IO RawFilePath
 sideLockFile lockfile = do
 	f <- fromRawFilePath <$> absPath lockfile
 	let base = intercalate "_" (splitDirectories (makeRelative "/" f))
@@ -134,7 +137,7 @@
 --
 -- If a parent process is holding the lock, determined by a
 -- "PIDLOCK_lockfile" environment variable, does not block either.
-tryLock :: LockFile -> IO (Maybe LockHandle)
+tryLock :: PidLockFile -> IO (Maybe LockHandle)
 tryLock lockfile = do
 	abslockfile <- absPath lockfile
 	lockenv <- pidLockEnv abslockfile
@@ -149,19 +152,16 @@
 		setFileMode tmp' (combineModes readModes)
 		hPutStr h . show =<< mkPidLock
 		hClose h
-		let failedlock st = do
-			dropLock $ LockHandle tmp' st sidelock
+		let failedlock = do
+			dropSideLock sidelock
 			removeWhenExistsWith removeLink tmp'
 			return Nothing
 		let tooklock st = return $ Just $ LockHandle abslockfile st sidelock
-		ifM (linkToLock sidelock tmp' abslockfile)
-			( do
+		linkToLock sidelock tmp' abslockfile >>= \case
+			Just lckst -> do
 				removeWhenExistsWith removeLink tmp'
-				-- May not have made a hard link, so stat
-				-- the lockfile
-				lckst <- getFileStatus abslockfile
 				tooklock lckst
-			, do
+			Nothing -> do
 				v <- readPidLock abslockfile
 				hn <- getHostName
 				tmpst <- getFileStatus tmp'
@@ -174,8 +174,7 @@
 						-- stale, and can take it over.
 						rename tmp' abslockfile
 						tooklock tmpst
-					_ -> failedlock tmpst
-			)
+					_ -> failedlock
 
 -- Linux's open(2) man page recommends linking a pid lock into place,
 -- as the most portable atomic operation that will fail if
@@ -187,8 +186,8 @@
 --
 -- However, not all filesystems support hard links. So, first probe
 -- to see if they are supported. If not, use open with O_EXCL.
-linkToLock :: SideLockHandle -> RawFilePath -> RawFilePath -> IO Bool
-linkToLock Nothing _ _ = return False
+linkToLock :: SideLockHandle -> RawFilePath -> RawFilePath -> IO (Maybe FileStatus)
+linkToLock Nothing _ _ = return Nothing
 linkToLock (Just _) src dest = do
 	let probe = src <> ".lnk"
 	v <- tryIO $ createLink src probe
@@ -197,10 +196,13 @@
 		Right _ -> do
 			_ <- tryIO $ createLink src dest
 			ifM (catchBoolIO checklinked)
-				( catchBoolIO $ not <$> checkInsaneLustre dest
-				, return False
+				( ifM (catchBoolIO $ not <$> checkInsaneLustre dest)
+					( catchMaybeIO $ getFileStatus dest
+					, return Nothing
+					)
+				, return Nothing
 				)
-		Left _ -> catchBoolIO $ do
+		Left _ -> catchMaybeIO $ do
 			let setup = do
 				fd <- openFd dest WriteOnly
 					(Just $ combineModes readModes)
@@ -209,7 +211,7 @@
 			let cleanup = hClose
 			let go h = readFile (fromRawFilePath src) >>= hPutStr h
 			bracket setup cleanup go
-			return True
+			getFileStatus dest
   where
 	checklinked = do
 		x <- getSymbolicLinkStatus src
@@ -255,8 +257,8 @@
 --
 -- After the first second waiting, runs the callback to display a message,
 -- so the user knows why it's stalled.
-waitLock :: MonadIO m => Seconds -> LockFile -> (String -> m ()) -> m LockHandle
-waitLock (Seconds timeout) lockfile displaymessage = go timeout
+waitLock :: MonadIO m => Seconds -> PidLockFile -> (String -> m ()) -> (Bool -> IO ()) -> m LockHandle
+waitLock (Seconds timeout) lockfile displaymessage sem = go timeout
   where
 	go n
 		| n > 0 = liftIO (tryLock lockfile) >>= \case
@@ -265,11 +267,26 @@
 					displaymessage $ "waiting for pid lock file " ++ fromRawFilePath lockfile ++ " which is held by another process (or may be stale)"
 				liftIO $ threadDelaySeconds (Seconds 1)
 				go (pred n)
-			Just lckh -> return lckh
+			Just lckh -> do
+				liftIO $ sem True
+				return lckh
 		| otherwise = do
-			displaymessage $ show timeout ++ " second timeout exceeded while waiting for pid lock file " ++ fromRawFilePath lockfile
-			giveup $ "Gave up waiting for pid lock file " ++ fromRawFilePath lockfile
+			liftIO $ sem False
+			waitedLock (Seconds timeout) lockfile displaymessage
 
+waitedLock :: MonadIO m => Seconds -> PidLockFile -> (String -> m ()) -> m a
+waitedLock (Seconds timeout) lockfile displaymessage = do
+	displaymessage $ show timeout ++ " second timeout exceeded while waiting for pid lock file " ++ fromRawFilePath lockfile
+	giveup $ "Gave up waiting for pid lock file " ++ fromRawFilePath lockfile
+
+-- | Use when the pid lock has already been taken by another thread of the
+-- same process.
+alreadyLocked :: MonadIO m => PidLockFile -> m LockHandle
+alreadyLocked lockfile = liftIO $ do
+	abslockfile <- absPath lockfile
+	st <- getFileStatus abslockfile
+	return $ LockHandle abslockfile st Nothing
+
 dropLock :: LockHandle -> IO ()
 dropLock (LockHandle lockfile _ sidelock) = do
 	-- Drop side lock first, at which point the pid lock will be
@@ -278,10 +295,10 @@
 	removeWhenExistsWith removeLink lockfile
 dropLock ParentLocked = return ()
 
-getLockStatus :: LockFile -> IO LockStatus
+getLockStatus :: PidLockFile -> IO LockStatus
 getLockStatus = maybe StatusUnLocked (StatusLockedBy . lockingPid) <$$> readPidLock
 
-checkLocked :: LockFile -> IO (Maybe Bool)
+checkLocked :: PidLockFile -> IO (Maybe Bool)
 checkLocked lockfile = conv <$> getLockStatus lockfile
   where
 	conv (StatusLockedBy _) = Just True
@@ -289,7 +306,7 @@
 
 -- Checks that the lock file still exists, and is the same file that was
 -- locked to get the LockHandle.
-checkSaneLock :: LockFile -> LockHandle -> IO Bool
+checkSaneLock :: PidLockFile -> LockHandle -> IO Bool
 checkSaneLock lockfile (LockHandle _ st _) = 
 	go =<< catchMaybeIO (getFileStatus lockfile)
   where
diff --git a/Utility/LockPool/LockHandle.hs b/Utility/LockPool/LockHandle.hs
--- a/Utility/LockPool/LockHandle.hs
+++ b/Utility/LockPool/LockHandle.hs
@@ -1,6 +1,6 @@
 {- Handles for lock pools.
  -
- - Copyright 2015-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2015-2021 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -8,7 +8,7 @@
 {-# LANGUAGE CPP #-}
 
 module Utility.LockPool.LockHandle (
-	LockHandle,
+	LockHandle(..),
 	FileLockOps(..),
 	dropLock,
 #ifndef mingw32_HOST_OS
@@ -25,7 +25,6 @@
 import Control.Concurrent.STM
 import Control.Monad.Catch
 import Control.Monad.IO.Class (liftIO, MonadIO)
-import Control.Applicative
 import Prelude
 
 data LockHandle = LockHandle P.LockHandle FileLockOps
@@ -52,38 +51,42 @@
 	:: (MonadIO m, MonadMask m)
 	=> P.LockPool
 	-> LockFile
-	-> (P.LockPool -> LockFile -> STM P.LockHandle)
-	-> (LockFile -> m FileLockOps)
-	-> m LockHandle
+	-> (P.LockPool -> LockFile -> STM (P.LockHandle, P.FirstLock))
+	-> (LockFile -> P.FirstLock -> m (FileLockOps, t))
+	-> m (LockHandle, t)
 makeLockHandle pool file pa fa = bracketOnError setup cleanup go
   where
 	setup = debugLocks $ liftIO $ atomically (pa pool file)
-	cleanup ph = debugLocks $ liftIO $ P.releaseLock ph
-	go ph = liftIO . mkLockHandle ph =<< fa file
+	cleanup (ph, _) = debugLocks $ liftIO $ P.releaseLock ph
+	go (ph, firstlock) = do
+		(flo, t) <- fa file firstlock
+		h <- liftIO $ mkLockHandle ph flo
+		return (h, t)
 
 tryMakeLockHandle
 	:: (MonadIO m, MonadMask m)
 	=> P.LockPool
 	-> LockFile
-	-> (P.LockPool -> LockFile -> STM (Maybe P.LockHandle))
-	-> (LockFile -> m (Maybe FileLockOps))
-	-> m (Maybe LockHandle)
+	-> (P.LockPool -> LockFile -> STM (Maybe (P.LockHandle, P.FirstLock)))
+	-> (LockFile -> P.FirstLock -> m (Maybe (FileLockOps, t)))
+	-> m (Maybe (LockHandle, t))
 tryMakeLockHandle pool file pa fa = bracketOnError setup cleanup go
   where
 	setup = liftIO $ atomically (pa pool file)
 	cleanup Nothing = return ()
-	cleanup (Just ph) = liftIO $ P.releaseLock ph
+	cleanup (Just (ph, _)) = liftIO $ P.releaseLock ph
 	go Nothing = return Nothing
-	go (Just ph) = do
-		mfo <- fa file
+	go (Just (ph, firstlock)) = do
+		mfo <- fa file firstlock
 		case mfo of
 			Nothing -> do
-				liftIO $ cleanup (Just ph)
+				liftIO $ cleanup (Just (ph, firstlock))
 				return Nothing
-			Just fo -> liftIO $ Just <$> mkLockHandle ph fo
+			Just (fo, t) -> do
+				h <- liftIO $ mkLockHandle ph fo
+				return (Just (h, t))
 
 mkLockHandle :: P.LockHandle -> FileLockOps -> IO LockHandle
 mkLockHandle ph fo = do
 	atomically $ P.registerCloseLockFile ph (fDropLock fo)
 	return $ LockHandle ph fo
-	
diff --git a/Utility/LockPool/PidLock.hs b/Utility/LockPool/PidLock.hs
--- a/Utility/LockPool/PidLock.hs
+++ b/Utility/LockPool/PidLock.hs
@@ -1,6 +1,6 @@
 {- Pid locks, using lock pools.
  -
- - Copyright 2015-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2015-2021 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -10,6 +10,7 @@
 	LockHandle,
 	waitLock,
 	tryLock,
+	tryLock',
 	checkLocked,
 	getLockStatus,
 	LockStatus(..),
@@ -26,30 +27,114 @@
 
 import System.IO
 import System.Posix
+import Control.Concurrent.STM
 import Data.Maybe
+import Control.Monad
 import Control.Monad.Catch
 import Control.Monad.IO.Class
 import Control.Applicative
 import Prelude
 
--- Takes a pid lock, blocking until the lock is available or the timeout.
+-- Does locking using a pid lock, blocking until the lock is available
+-- or the Seconds timeout if the pid lock is held by another process.
+--
+-- There are two levels of locks. A STM lock is used to handle
+-- fine-grained locking amoung threads, locking a specific lockfile,
+-- but only in memory. The pid lock handles locking between processes.
+--
+-- The pid lock is only taken once, and LockShared is used for it,
+-- so multiple threads can have it locked. Only the first thread
+-- will create the pid lock, and it remains until all threads drop
+-- their locks.
 waitLock
 	:: (MonadIO m, MonadMask m)
-	=> Seconds
-	-> LockFile
+	=> LockFile
+	-> LockMode
+	-> Seconds
+	-> F.PidLockFile
 	-> (String -> m ())
 	-> m LockHandle
-waitLock timeout file displaymessage = makeLockHandle P.lockPool file
-	-- LockShared for STM lock, because a pid lock can be the top-level
-	-- lock with various other STM level locks gated behind it.
-	(\p f -> P.waitTakeLock p f LockShared)
-	(\f -> mk <$> F.waitLock timeout f displaymessage)
+waitLock finelockfile lockmode timeout pidlockfile displaymessage = do
+	fl <- takefinelock
+	pl <- takepidlock
+		`onException` liftIO (dropLock fl)
+	registerPostRelease fl pl
+	return fl
+  where
+	takefinelock = fst <$> makeLockHandle P.lockPool finelockfile
+		(\p f -> P.waitTakeLock p f lockmode)
+		(\_ _ -> pure (stmonlyflo, ()))
+	-- A shared STM lock is taken for each use of the pid lock,
+	-- but only the first thread to take it actually creates the pid
+	-- lock file.
+	takepidlock = makeLockHandle P.lockPool pidlockfile
+		(\p f -> P.waitTakeLock p f LockShared)
+		(\f (P.FirstLock firstlock firstlocksem) -> if firstlock
+			then waitlock f firstlocksem
+			else liftIO (atomically $ readTMVar firstlocksem) >>= \case
+				P.FirstLockSemWaited True -> alreadylocked f
+				P.FirstLockSemTried True -> alreadylocked f
+				P.FirstLockSemWaited False -> F.waitedLock timeout f displaymessage
+				P.FirstLockSemTried False -> waitlock f firstlocksem
+		)
+	waitlock f firstlocksem = do
+		h <- F.waitLock timeout f displaymessage $
+			void . atomically . tryPutTMVar firstlocksem . P.FirstLockSemWaited
+		return (mkflo h, Just h)
+	alreadylocked f = do
+		lh <- F.alreadyLocked f
+		return (mkflo lh, Nothing)
 
+registerPostRelease :: MonadIO m => LockHandle -> (LockHandle, Maybe F.LockHandle) -> m ()
+registerPostRelease (LockHandle flh _) (pl@(LockHandle plh _), mpidlock) = do
+	-- After the fine-grained lock gets dropped (and any shared locks
+	-- of it are also dropped), drop the associated pid lock.
+	liftIO $ atomically $
+		P.registerPostReleaseLock flh (dropLock pl)
+	-- When the last thread to use the pid lock has dropped it,
+	-- close the pid lock file itself.
+	case mpidlock of
+		Just pidlock -> liftIO $ atomically $
+			P.registerPostReleaseLock plh (F.dropLock pidlock)
+		Nothing -> return ()
+
 -- Tries to take a pid lock, but does not block.
-tryLock :: LockFile -> IO (Maybe LockHandle)
-tryLock file = tryMakeLockHandle P.lockPool file
+tryLock :: LockFile -> LockMode -> F.PidLockFile -> IO (Maybe LockHandle)
+tryLock finelockfile lockmode pidlockfile = takefinelock >>= \case
+	Just fl -> tryLock' pidlockfile >>= \case
+		Just pl -> do
+			registerPostRelease fl pl
+			return (Just fl)
+		Nothing -> do
+			dropLock fl
+			return Nothing
+	Nothing -> return Nothing
+  where
+	takefinelock = fmap fst <$> tryMakeLockHandle P.lockPool finelockfile
+		(\p f -> P.tryTakeLock p f lockmode)
+		(\_ _ -> pure (Just (stmonlyflo, ())))
+
+tryLock' :: F.PidLockFile -> IO (Maybe (LockHandle, Maybe F.LockHandle))
+tryLock' pidlockfile = tryMakeLockHandle P.lockPool pidlockfile
 	(\p f -> P.tryTakeLock p f LockShared)
-	(\f -> fmap mk <$> F.tryLock f)
+	(\f (P.FirstLock firstlock firstlocksem) -> if firstlock
+		then do
+			mlh <- F.tryLock f
+			void $ atomically $ tryPutTMVar firstlocksem 
+				(P.FirstLockSemTried (isJust mlh))
+			case mlh of
+				Just lh -> return (Just (mkflo lh, Just lh))
+				Nothing -> return Nothing
+		else liftIO (atomically $ readTMVar firstlocksem) >>= \case
+			P.FirstLockSemWaited True -> alreadylocked f
+			P.FirstLockSemTried True -> alreadylocked f
+			P.FirstLockSemWaited False -> return Nothing
+			P.FirstLockSemTried False -> return Nothing
+	)
+  where
+	alreadylocked f = do
+		lh <- F.alreadyLocked f
+		return (Just (mkflo lh, Nothing))
 
 checkLocked :: LockFile -> IO (Maybe Bool)
 checkLocked file = P.getLockStatus P.lockPool file
@@ -61,8 +146,14 @@
 	(StatusLockedBy <$> getProcessID)
 	(F.getLockStatus file)
 
-mk :: F.LockHandle -> FileLockOps
-mk h = FileLockOps
-	{ fDropLock = F.dropLock h
+mkflo :: F.LockHandle -> FileLockOps
+mkflo h = FileLockOps
+	{ fDropLock = return ()
 	, fCheckSaneLock = \f -> F.checkSaneLock f h
+	}
+		
+stmonlyflo :: FileLockOps
+stmonlyflo = FileLockOps
+	{ fDropLock = return ()
+	, fCheckSaneLock = const (return True)
 	}
diff --git a/Utility/LockPool/Posix.hs b/Utility/LockPool/Posix.hs
--- a/Utility/LockPool/Posix.hs
+++ b/Utility/LockPool/Posix.hs
@@ -33,27 +33,27 @@
 
 -- Takes a shared lock, blocking until the lock is available.
 lockShared :: Maybe FileMode -> LockFile -> IO LockHandle
-lockShared mode file = makeLockHandle P.lockPool file
+lockShared mode file = fst <$> makeLockHandle P.lockPool file
 	(\p f -> P.waitTakeLock p f LockShared)
-	(\f -> mk <$> F.lockShared mode f)
+	(\f _ -> mk <$> F.lockShared mode f)
 
 -- Takes an exclusive lock, blocking until the lock is available.
 lockExclusive :: Maybe FileMode -> LockFile -> IO LockHandle
-lockExclusive mode file = makeLockHandle P.lockPool file
+lockExclusive mode file = fst <$> makeLockHandle P.lockPool file
 	(\p f -> P.waitTakeLock p f LockExclusive)
-	(\f -> mk <$> F.lockExclusive mode f)
+	(\f _ -> mk <$> F.lockExclusive mode f)
 
 -- Tries to take a shared lock, but does not block.
 tryLockShared :: Maybe FileMode -> LockFile -> IO (Maybe LockHandle)
-tryLockShared mode file = tryMakeLockHandle P.lockPool file
+tryLockShared mode file = fmap fst <$> tryMakeLockHandle P.lockPool file
 	(\p f -> P.tryTakeLock p f LockShared)
-	(\f -> fmap mk <$> F.tryLockShared mode f)
+	(\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 mode file = tryMakeLockHandle P.lockPool file
+tryLockExclusive mode file = fmap fst <$> tryMakeLockHandle P.lockPool file
 	(\p f -> P.tryTakeLock p f LockExclusive)
-	(\f -> fmap mk <$> F.tryLockExclusive mode f)
+	(\f _ -> fmap mk <$> F.tryLockExclusive mode f)
 
 -- Returns Nothing when the file doesn't exist, for cases where
 -- that is different from it not being locked.
@@ -67,8 +67,8 @@
 	(StatusLockedBy <$> getProcessID)
 	(F.getLockStatus file)
 
-mk :: F.LockHandle -> FileLockOps
-mk h = FileLockOps
+mk :: F.LockHandle -> (FileLockOps, ())
+mk h = (FileLockOps
 	{ fDropLock = F.dropLock h
 	, fCheckSaneLock = \f -> F.checkSaneLock f h
-	}
+	}, ())
diff --git a/Utility/LockPool/STM.hs b/Utility/LockPool/STM.hs
--- a/Utility/LockPool/STM.hs
+++ b/Utility/LockPool/STM.hs
@@ -1,6 +1,6 @@
 {- STM implementation of lock pools.
  -
- - Copyright 2015-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2015-2021 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -11,12 +11,15 @@
 	LockFile,
 	LockMode(..),
 	LockHandle,
+	FirstLock(..),
+	FirstLockSemVal(..),
 	waitTakeLock,
 	tryTakeLock,
 	getLockStatus,
 	releaseLock,
 	CloseLockFile,
 	registerCloseLockFile,
+	registerPostReleaseLock,
 ) where
 
 import Utility.Monad
@@ -26,9 +29,6 @@
 import qualified Data.Map.Strict as M
 import Control.Concurrent.STM
 import Control.Exception
-import Control.Monad
-import Control.Applicative
-import Prelude
 
 type LockFile = RawFilePath
 
@@ -39,12 +39,29 @@
 -- closed.
 type LockHandle = TMVar (LockPool, LockFile, CloseLockFile)
 
-type LockCount = Integer
+-- When a shared lock is taken, this will only be true for the first
+-- process, not subsequent processes. The first process should
+-- fill the FirstLockSem after doing any IO actions to finish lock setup
+-- and subsequent processes can block on that getting filled to know
+-- when the lock is fully set up.
+data FirstLock = FirstLock Bool FirstLockSem
 
-data LockStatus = LockStatus LockMode LockCount
+type FirstLockSem = TMVar FirstLockSemVal
 
+data FirstLockSemVal = FirstLockSemWaited Bool | FirstLockSemTried Bool
+
+type LockCount = Integer
+
+-- Action that closes the underlying lock file. When this is used
+-- in a LockHandle, it closes a resource that is specific to that
+-- LockHandle (such as eg a file handle), but does not release
+-- any other shared locks. When this is used in a LockStatus,
+-- it closes a resource that should only be closed when there are no
+-- other shared locks.
 type CloseLockFile = IO ()
 
+data LockStatus = LockStatus LockMode LockCount FirstLockSem CloseLockFile
+
 -- This TMVar is normally kept full.
 type LockPool = TMVar (M.Map LockFile LockStatus)
 
@@ -62,32 +79,54 @@
 -- the same shared lock should not be blocked on the exclusive lock.
 -- Keeping the whole Map in a TMVar accomplishes this, at the expense of
 -- sometimes retrying after unrelated changes in the map.
-waitTakeLock :: LockPool -> LockFile -> LockMode -> STM LockHandle
+waitTakeLock :: LockPool -> LockFile -> LockMode -> STM (LockHandle, FirstLock)
 waitTakeLock pool file mode = maybe retry return =<< tryTakeLock pool file mode
 
 -- Avoids blocking if another thread is holding a conflicting lock.
-tryTakeLock :: LockPool -> LockFile -> LockMode -> STM (Maybe LockHandle)
+tryTakeLock :: LockPool -> LockFile -> LockMode -> STM (Maybe (LockHandle, FirstLock))
 tryTakeLock pool file mode = do
 	m <- takeTMVar pool
-	let success v = do
+	let success firstlock v = do
 		putTMVar pool (M.insert file v m)
-		Just <$> newTMVar (pool, file, noop)
+		tmv <- newTMVar (pool, file, noop)
+		return (Just (tmv, firstlock))
 	case M.lookup file m of
-		Just (LockStatus mode' n)
-			| mode == LockShared && mode' == LockShared ->
-				success $ LockStatus mode (succ n)
+		Just (LockStatus mode' n firstlocksem postreleaselock)
+			| mode == LockShared && mode' == LockShared -> do
+				fl@(FirstLock _ firstlocksem') <- if n == 0
+					then FirstLock True <$> newEmptyTMVar
+					else pure (FirstLock False firstlocksem)
+				success fl $ LockStatus mode (succ n) firstlocksem' postreleaselock
 			| n > 0 -> do
 				putTMVar pool m
 				return Nothing
-		_ -> success $ LockStatus mode 1
+		_ -> do
+			firstlocksem <- newEmptyTMVar
+			success (FirstLock True firstlocksem) $
+				LockStatus mode 1 firstlocksem noop
 
 -- Call after waitTakeLock or tryTakeLock, to register a CloseLockFile
--- action to run when releasing the lock.
+-- action to run when releasing the lock. This action should only
+-- close the lock file associated with the LockHandle, while
+-- leaving any other shared locks of the same file open.
 registerCloseLockFile :: LockHandle -> CloseLockFile -> STM ()
 registerCloseLockFile h closelockfile = do
 	(p, f, c) <- takeTMVar h
 	putTMVar h (p, f, c >> closelockfile)
 
+-- Register an action that should be run only once a lock has been
+-- released. When there are multiple shared locks of the same file,
+-- the action will only be run after all are released.
+registerPostReleaseLock :: LockHandle -> CloseLockFile -> STM ()
+registerPostReleaseLock h postreleaselock = do
+	(p, f, _) <- readTMVar h
+	m <- takeTMVar p
+	case M.lookup f m of
+		Nothing -> putTMVar p m
+		Just (LockStatus mode cnt firstlocksem c) -> do
+			let c' = c >> postreleaselock
+			putTMVar p $ M.insert f (LockStatus mode cnt firstlocksem c') m
+
 -- Checks if a lock is being held. If it's held by the current process,
 -- runs the getdefault action; otherwise runs the checker action.
 --
@@ -101,7 +140,7 @@
 	v <- atomically $ do
 		m <- takeTMVar pool
 		let threadlocked = case M.lookup file m of
-			Just (LockStatus _ n) | n > 0 -> True
+			Just (LockStatus _ n _ _) | n > 0 -> True
 			_ -> False
 		if threadlocked
 			then do
@@ -112,25 +151,29 @@
 		Nothing -> getdefault
 		Just restore -> bracket_ (return ()) restore checker
 
--- Only runs action to close underlying lock file when this is the last
--- user of the lock, and when the lock has not already been closed.
+-- Releases the lock. When it is a shared lock, it may remain locked by
+-- other LockHandles.
 --
 -- Note that the lock pool is left empty while the CloseLockFile action
 -- is run, to avoid race with another thread trying to open the same lock
--- file.
+-- file. However, the pool is full again when the PostReleaseLock action
+-- runs.
 releaseLock :: LockHandle -> IO ()
 releaseLock h = go =<< atomically (tryTakeTMVar h)
   where
 	go (Just (pool, file, closelockfile)) = do
-		m <- atomically $ do
+		(m, postreleaselock) <- atomically $ do
 			m <- takeTMVar pool
 			return $ case M.lookup file m of
-				Just (LockStatus mode n)
-					| n == 1 -> (M.delete file m)
+				Just (LockStatus mode n firstlocksem postreleaselock)
+					| n == 1 -> (M.delete file m, postreleaselock)
 					| otherwise ->
-						(M.insert file (LockStatus mode (pred n)) m)
-				Nothing -> m
+						(M.insert file (LockStatus mode (pred n) firstlocksem postreleaselock) m, noop)
+				Nothing -> (m, noop)
 		() <- closelockfile
 		atomically $ putTMVar pool m
+		-- This action may access the pool, so run it only
+		-- after the pool is restored.
+		postreleaselock
 	-- The LockHandle was already closed.
 	go Nothing = return ()
diff --git a/Utility/LockPool/Windows.hs b/Utility/LockPool/Windows.hs
--- a/Utility/LockPool/Windows.hs
+++ b/Utility/LockPool/Windows.hs
@@ -22,9 +22,9 @@
 {- Tries to lock a file with a shared lock, which allows other processes to
  - also lock it shared. Fails if the file is exclusively locked. -}
 lockShared :: LockFile -> IO (Maybe LockHandle)
-lockShared file = tryMakeLockHandle P.lockPool file
+lockShared file = fmap fst <$> tryMakeLockHandle P.lockPool file
 	(\p f -> P.tryTakeLock p f LockShared)
-	(\f -> fmap mk <$> F.lockShared f)
+	(\f _ -> fmap mk <$> F.lockShared f)
 
 {- Tries to take an exclusive lock on a file. Fails if another process has
  - a shared or exclusive lock.
@@ -33,16 +33,16 @@
  - read or write by any other process. So for advisory locking of a file's
  - content, a separate LockFile should be used. -}
 lockExclusive :: LockFile -> IO (Maybe LockHandle)
-lockExclusive file = tryMakeLockHandle P.lockPool file
+lockExclusive file = fmap fst <$> tryMakeLockHandle P.lockPool file
 	(\p f -> P.tryTakeLock p f LockExclusive)
-	(\f -> fmap mk <$> F.lockExclusive f)
+	(\f _ -> fmap mk <$> F.lockExclusive f)
 
 {- If the initial lock fails, this is a BUSY wait, and does not
  - guarentee FIFO order of waiters. In other news, Windows is a POS. -}
 waitToLock :: IO (Maybe lockhandle) -> IO lockhandle
 waitToLock = F.waitToLock
 
-mk :: F.LockHandle -> FileLockOps
-mk h = FileLockOps
+mk :: F.LockHandle -> (FileLockOps, ())
+mk h = (FileLockOps
 	{ fDropLock = F.dropLock h
-	}
+	}, ())
diff --git a/doc/git-annex.mdwn b/doc/git-annex.mdwn
--- a/doc/git-annex.mdwn
+++ b/doc/git-annex.mdwn
@@ -1061,8 +1061,13 @@
   are automatically merged into the local git-annex branch, so that
   git-annex has the most up-to-date possible knowledge.
 
-  To avoid that merging, set this to "false". This can be useful
-  particularly when you don't have write permission to the repository.
+  To avoid that merging, set this to "false". 
+
+  This can be useful particularly when you don't have write permission
+  to the repository. While git-annex is mostly able to work in a read-only
+  repository with unmerged git-annex branches, some things do not work,
+  and when it does work it will be slower due to needing to look at each of
+  the unmerged branches.
 
 * `annex.private`
 
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: 8.20211123
+Version: 8.20211231
 Cabal-Version: >= 1.10
 License: AGPL-3
 Maintainer: Joey Hess <id@joeyh.name>
@@ -1052,6 +1052,7 @@
     Types.Transfer
     Types.Transferrer
     Types.TransferrerPool
+    Types.Transitions
     Types.TrustLevel
     Types.UUID
     Types.UrlContents
