diff --git a/Annex/Branch.hs b/Annex/Branch.hs
--- a/Annex/Branch.hs
+++ b/Annex/Branch.hs
@@ -14,6 +14,7 @@
 	hasSibling,
 	siblingBranches,
 	create,
+	getBranch,
 	UpdateMade(..),
 	update,
 	forceUpdate,
@@ -120,7 +121,7 @@
 create :: Annex ()
 create = void getBranch
 
-{- Returns the ref of the branch, creating it first if necessary. -}
+{- Returns the sha of the branch, creating it first if necessary. -}
 getBranch :: Annex Git.Ref
 getBranch = maybe (hasOrigin >>= go >>= use) return =<< branchsha
   where
@@ -920,10 +921,14 @@
 {- Grafts a treeish into the branch at the specified location,
  - and then removes it. This ensures that the treeish won't get garbage
  - collected, and will always be available as long as the git-annex branch
- - is available. -}
-rememberTreeish :: Git.Ref -> TopFilePath -> Annex ()
-rememberTreeish treeish graftpoint = lockJournal $ rememberTreeishLocked treeish graftpoint
-rememberTreeishLocked :: Git.Ref -> TopFilePath -> JournalLocked -> Annex ()
+ - is available.
+ -
+ - Returns the sha of the git commit made to the git-annex branch.
+ -}
+rememberTreeish :: Git.Ref -> TopFilePath -> Annex Git.Sha
+rememberTreeish treeish graftpoint = lockJournal $
+	rememberTreeishLocked treeish graftpoint
+rememberTreeishLocked :: Git.Ref -> TopFilePath -> JournalLocked -> Annex Git.Sha
 rememberTreeishLocked treeish graftpoint jl = do
 	branchref <- getBranch
 	updateIndex jl branchref
@@ -940,6 +945,7 @@
 	-- and the index was updated to that above, so it's safe to
 	-- say that the index contains c'.
 	setIndexSha c'
+	return c'
 
 {- Runs an action on the content of selected files from the branch.
  - This is much faster than reading the content of each file in turn,
diff --git a/Annex/Content.hs b/Annex/Content.hs
--- a/Annex/Content.hs
+++ b/Annex/Content.hs
@@ -1,6 +1,6 @@
 {- git-annex file content managing
  -
- - Copyright 2010-2022 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -66,6 +66,7 @@
 	getKeyStatus,
 	getKeyFileStatus,
 	cleanObjectDirs,
+	contentSize,
 ) where
 
 import System.IO.Unsafe (unsafeInterleaveIO)
@@ -449,7 +450,7 @@
 checkSecureHashes' key = checkSecureHashes key >>= \case
 	Nothing -> return True
 	Just msg -> do
-		warning $ UnquotedString $ msg ++ "to annex objects"
+		warning $ UnquotedString $ msg ++ " to annex objects"
 		return False
 
 data LinkAnnexResult = LinkAnnexOk | LinkAnnexFailed | LinkAnnexNoop
@@ -916,3 +917,25 @@
 				)
 		_ -> return s
 
+{- Gets the size of the content of a key when it is present.
+ - Useful when the key does not have keySize set. 
+ -
+ - When the object file appears possibly modified with annex.thin set, does
+ - not do an expensive verification that the content is good, just returns
+ - Nothing.
+ -}
+contentSize :: Key -> Annex (Maybe FileSize)
+contentSize key = catchDefaultIO Nothing $
+	withObjectLoc key $ \loc ->
+		withTSDelta (liftIO . genInodeCache loc) >>= \case
+			Just ic -> ifM (unmodified ic)
+				( return (Just (inodeCacheFileSize ic))
+				, return Nothing
+				)
+			Nothing -> return Nothing
+  where
+	unmodified ic =
+		ifM (annexThin <$> Annex.getGitConfig)
+			( isUnmodifiedCheap' key ic
+			, return True
+			)
diff --git a/Annex/Content/Presence.hs b/Annex/Content/Presence.hs
--- a/Annex/Content/Presence.hs
+++ b/Annex/Content/Presence.hs
@@ -18,6 +18,7 @@
 	isUnmodified,
 	isUnmodified',
 	isUnmodifiedCheap,
+	isUnmodifiedCheap',
 	withContentLockFile,
 	contentLockFile,
 ) where
@@ -206,7 +207,7 @@
  - within a small time window (eg 1 second).
  -}
 isUnmodifiedCheap :: Key -> RawFilePath -> Annex Bool
-isUnmodifiedCheap key f = maybe (return False) (isUnmodifiedCheap' key) 
+isUnmodifiedCheap key f = maybe (pure False) (isUnmodifiedCheap' key) 
 	=<< withTSDelta (liftIO . genInodeCache f)
 
 isUnmodifiedCheap' :: Key -> InodeCache -> Annex Bool
diff --git a/Annex/FileMatcher.hs b/Annex/FileMatcher.hs
--- a/Annex/FileMatcher.hs
+++ b/Annex/FileMatcher.hs
@@ -16,7 +16,6 @@
 	matchAll,
 	PreferredContentData(..),
 	preferredContentTokens,
-	preferredContentKeylessTokens,
 	preferredContentParser,
 	ParseToken,
 	parsedToMatcher,
@@ -139,19 +138,15 @@
   where
 	splitparens = segmentDelim (`elem` "()")
 
-commonKeylessTokens :: LimitBy -> [ParseToken (MatchFiles Annex)]
-commonKeylessTokens lb =
+commonTokens :: LimitBy -> [ParseToken (MatchFiles Annex)]
+commonTokens lb =
 	[ SimpleToken "anything" (simply limitAnything)
 	, SimpleToken "nothing" (simply limitNothing)
 	, ValueToken "include" (usev limitInclude)
 	, ValueToken "exclude" (usev limitExclude)
 	, ValueToken "largerthan" (usev $ limitSize lb "largerthan" (>))
 	, ValueToken "smallerthan" (usev $ limitSize lb "smallerthan" (<))
-	]
-
-commonKeyedTokens :: [ParseToken (MatchFiles Annex)]
-commonKeyedTokens = 
-	[ SimpleToken "unused" (simply limitUnused)
+	, SimpleToken "unused" (simply limitUnused)
 	]
 
 data PreferredContentData = PCD
@@ -162,25 +157,12 @@
 	, repoUUID :: Maybe UUID
 	}
 
--- Tokens of preferred content expressions that do not need a Key to be
--- known. 
---
--- When importing from a special remote, this is used to match
--- some preferred content expressions before the content is downloaded,
--- so the Key is not known.
-preferredContentKeylessTokens :: PreferredContentData -> [ParseToken (MatchFiles Annex)]
-preferredContentKeylessTokens pcd =
+preferredContentTokens :: PreferredContentData -> [ParseToken (MatchFiles Annex)]
+preferredContentTokens pcd =
 	[ SimpleToken "standard" (call "standard" $ matchStandard pcd)
 	, SimpleToken "groupwanted" (call "groupwanted" $ matchGroupWanted pcd)
 	, SimpleToken "inpreferreddir" (simply $ limitInDir preferreddir "inpreferreddir")
-	] ++ commonKeylessTokens LimitAnnexFiles
-  where
-	preferreddir = maybe "public" fromProposedAccepted $
-		M.lookup preferreddirField =<< (`M.lookup` configMap pcd) =<< repoUUID pcd
-
-preferredContentKeyedTokens :: PreferredContentData -> [ParseToken (MatchFiles Annex)]
-preferredContentKeyedTokens pcd =
-	[ SimpleToken "present" (simply $ limitPresent $ repoUUID pcd)
+	, SimpleToken "present" (simply $ limitPresent $ repoUUID pcd)
 	, SimpleToken "securehash" (simply limitSecureHash)
 	, ValueToken "copies" (usev limitCopies)
 	, ValueToken "lackingcopies" (usev $ limitLackingCopies "lackingcopies" False)
@@ -189,13 +171,10 @@
 	, ValueToken "metadata" (usev limitMetaData)
 	, ValueToken "inallgroup" (usev $ limitInAllGroup $ getGroupMap pcd)
 	, ValueToken "onlyingroup" (usev $ limitOnlyInGroup $ getGroupMap pcd)
-	] ++ commonKeyedTokens
-
-preferredContentTokens :: PreferredContentData -> [ParseToken (MatchFiles Annex)]
-preferredContentTokens pcd = concat
-	[ preferredContentKeylessTokens pcd
-	, preferredContentKeyedTokens pcd
-	]
+	] ++ commonTokens LimitAnnexFiles
+  where
+	preferreddir = maybe "public" fromProposedAccepted $
+		M.lookup preferreddirField =<< (`M.lookup` configMap pcd) =<< repoUUID pcd
 
 preferredContentParser :: [ParseToken (MatchFiles Annex)] -> String -> [ParseResult (MatchFiles Annex)]
 preferredContentParser tokens = map (parseToken tokens) . tokenizeMatcher
@@ -210,8 +189,7 @@
 		const $ Left $ "\""++n++"\" not supported; not built with MagicMime support"
 #endif
 	let parse = parseToken $
-		commonKeyedTokens ++
-		commonKeylessTokens LimitDiskFiles ++
+		commonTokens LimitDiskFiles ++
 #ifdef WITH_MAGICMIME
 		[ mimer "mimetype" $
 			matchMagic "mimetype" getMagicMimeType providedMimeType userProvidedMimeType
diff --git a/Annex/Import.hs b/Annex/Import.hs
--- a/Annex/Import.hs
+++ b/Annex/Import.hs
@@ -990,19 +990,21 @@
  -
  - Only keyless tokens are supported, because the keys are not known
  - until an imported file is downloaded, which is too late to bother
- - excluding it from an import.
+ - excluding it from an import. So prunes any tokens in the preferred
+ - content expression that need keys.
  -}
 makeImportMatcher :: Remote -> Annex (Either String (FileMatcher Annex))
-makeImportMatcher r = load preferredContentKeylessTokens >>= \case
+makeImportMatcher r = load preferredContentTokens >>= \case
 	Nothing -> return $ Right (matchAll, matcherdesc)
 	Just (Right v) -> return $ Right (v, matcherdesc)
-	Just (Left err) -> load preferredContentTokens >>= \case
-		Just (Left err') -> return $ Left err'
-		_ -> return $ Left $
-			"The preferred content expression contains terms that cannot be checked when importing: " ++ err
+	Just (Left err) -> return $ Left err
   where
-	load t = M.lookup (Remote.uuid r) . fst <$> preferredRequiredMapsLoad' t
+	load t = M.lookup (Remote.uuid r) . fst
+		<$> preferredRequiredMapsLoad' pruneImportMatcher t
 	matcherdesc = MatcherDesc "preferred content"
+
+pruneImportMatcher :: Utility.Matcher.Matcher (MatchFiles a) -> Utility.Matcher.Matcher (MatchFiles a)
+pruneImportMatcher = Utility.Matcher.pruneMatcher matchNeedsKey
 
 {- Gets the ImportableContents from the remote.
  -
diff --git a/Annex/Ingest.hs b/Annex/Ingest.hs
--- a/Annex/Ingest.hs
+++ b/Annex/Ingest.hs
@@ -19,6 +19,7 @@
 	finishIngestUnlocked,
 	cleanOldKeys,
 	addSymlink,
+	genSymlink,
 	makeLink,
 	addUnlocked,
 	CheckGitIgnore(..),
@@ -38,6 +39,7 @@
 import Annex.CurrentBranch
 import Annex.CheckIgnore
 import Logs.Location
+import qualified Git
 import qualified Annex
 import qualified Database.Keys
 import Config
@@ -320,9 +322,12 @@
 
 {- Creates the symlink to the annexed content, and stages it in git. -}
 addSymlink :: RawFilePath -> Key -> Maybe InodeCache -> Annex ()
-addSymlink file key mcache = do
+addSymlink file key mcache = stageSymlink file =<< genSymlink file key mcache
+
+genSymlink :: RawFilePath -> Key -> Maybe InodeCache -> Annex Git.Sha
+genSymlink file key mcache = do
 	linktarget <- makeLink file key mcache
-	stageSymlink file =<< hashSymlink linktarget
+	hashSymlink linktarget
 
 {- Parameters to pass to git add, forcing addition of ignored files.
  -
diff --git a/Annex/Locations.hs b/Annex/Locations.hs
--- a/Annex/Locations.hs
+++ b/Annex/Locations.hs
@@ -54,6 +54,10 @@
 	gitAnnexRestageLock,
 	gitAnnexAdjustedBranchUpdateLog,
 	gitAnnexAdjustedBranchUpdateLock,
+	gitAnnexMigrateLog,
+	gitAnnexMigrateLock,
+	gitAnnexMigrationsLog,
+	gitAnnexMigrationsLock,
 	gitAnnexMoveLog,
 	gitAnnexMoveLock,
 	gitAnnexExportDir,
@@ -406,6 +410,20 @@
 
 gitAnnexAdjustedBranchUpdateLock :: Git.Repo -> RawFilePath
 gitAnnexAdjustedBranchUpdateLock r = gitAnnexDir r P.</> "adjust.lck"
+
+{- .git/annex/migrate.log is used to log migrations before committing them. -}
+gitAnnexMigrateLog :: Git.Repo -> RawFilePath
+gitAnnexMigrateLog r = gitAnnexDir r P.</> "migrate.log"
+
+gitAnnexMigrateLock :: Git.Repo -> RawFilePath
+gitAnnexMigrateLock r = gitAnnexDir r P.</> "migrate.lck"
+
+{- .git/annex/migrations.log is used to log committed migrations. -}
+gitAnnexMigrationsLog :: Git.Repo -> RawFilePath
+gitAnnexMigrationsLog r = gitAnnexDir r P.</> "migrations.log"
+
+gitAnnexMigrationsLock :: Git.Repo -> RawFilePath
+gitAnnexMigrationsLock r = gitAnnexDir r P.</> "migrations.lck"
 
 {- .git/annex/move.log is used to log moves that are in progress,
  - to better support resuming an interrupted move. -}
diff --git a/Annex/NumCopies.hs b/Annex/NumCopies.hs
--- a/Annex/NumCopies.hs
+++ b/Annex/NumCopies.hs
@@ -282,9 +282,11 @@
 	showNote "unsafe"
 	if length have < fromNumCopies neednum
 		then showLongNote $ UnquotedString $
-			"Could only verify the existence of " ++
-			show (length have) ++ " out of " ++ show (fromNumCopies neednum) ++ 
-			" necessary " ++ pluralCopies (fromNumCopies neednum)
+			if fromNumCopies neednum == 1
+				then "Could not verify the existence of the 1 necessary copy."
+				else "Could only verify the existence of " ++
+					show (length have) ++ " out of " ++ show (fromNumCopies neednum) ++ 
+					" necessary " ++ pluralCopies (fromNumCopies neednum) ++ "."
 		else do
 			showLongNote $ UnquotedString $ "Unable to lock down " ++ show (fromMinCopies needmin) ++ 
 				" " ++ pluralCopies (fromMinCopies needmin) ++ 
@@ -317,7 +319,7 @@
 verifiableCopies :: Key -> [UUID] -> Annex ([UnVerifiedCopy], [VerifiedCopy])
 verifiableCopies key exclude = do
 	locs <- Remote.keyLocations key
-	(remotes, trusteduuids) <- Remote.remoteLocations locs
+	(remotes, trusteduuids) <- Remote.remoteLocations (Remote.IncludeIgnored False) locs
 		=<< trustGet Trusted
 	untrusteduuids <- trustGet UnTrusted
 	let exclude' = exclude ++ untrusteduuids
diff --git a/Annex/VectorClock/Utility.hs b/Annex/VectorClock/Utility.hs
--- a/Annex/VectorClock/Utility.hs
+++ b/Annex/VectorClock/Utility.hs
@@ -20,4 +20,14 @@
 	go (Just s) = case parsePOSIXTime s of
 		Just t -> return (pure (CandidateVectorClock t))
 		Nothing -> timebased
-	timebased = return (CandidateVectorClock <$> getPOSIXTime)
+	-- Avoid using fractional seconds in the CandidateVectorClock.
+	-- This reduces the size of the packed git-annex branch by up
+	-- to 8%. 
+	--
+	-- Due to the use of vector clocks, high resolution timestamps are
+	-- not necessary to make clear which information is most recent when
+	-- eg, a value is changed repeatedly in the same second. In such a
+	-- case, the vector clock will be advanced to the next second after
+	-- the last modification.
+	timebased = return $
+		CandidateVectorClock . truncateResolution 0 <$> getPOSIXTime
diff --git a/Assistant/TransferQueue.hs b/Assistant/TransferQueue.hs
--- a/Assistant/TransferQueue.hs
+++ b/Assistant/TransferQueue.hs
@@ -93,7 +93,7 @@
 				filter (\r -> not (inset s r || Remote.readonly r))
 					(syncDataRemotes st)
 	  where
-		locs = S.fromList . map Remote.uuid <$> Remote.keyPossibilities k
+		locs = S.fromList . map Remote.uuid <$> Remote.keyPossibilities (Remote.IncludeIgnored False) k
 		inset s r = S.member (Remote.uuid r) s
 	gentransfer r = Transfer
 		{ transferDirection = direction
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,35 @@
+git-annex (10.20231227) upstream; urgency=medium
+
+  * migrate: Support distributed migrations by recording each migration,
+    and adding a --update option that updates the local repository
+    incrementally, hard linking annex objects to their new keys.
+  * pull, sync: When operating on content, automatically handle
+    distributed migrations.
+  * Added annex.syncmigrations config that can be set to false to prevent
+    pull and sync from migrating object content.
+  * migrate: Added --apply option that (re)applies all recorded 
+    distributed migrations to the objects in repository.
+  * migrate: Support adding size to URL keys that were added with
+    --relaxed, by running eg: git-annex migrate --backend=URL foo
+  * When importing from a special remote, support preferred content
+    expressions that use terms that match on keys (eg "present", "copies=1").
+    Such terms are ignored when importing, since the key is not known yet.
+    Before, such expressions caused the import to fail.
+  * Support git-annex copy/move --from-anywhere --to remote.
+  * Make git-annex get/copy/move --from foo override configuration of 
+    remote.foo.annex-ignore, as documented.
+  * Lower precision of timestamps in git-annex branch, which can reduce the
+    size of the branch by up to 8%.
+  * sync: Fix locking problems during merge when annex.pidlock is set.
+  * Avoid a problem with temp file names ending in "." on certian
+    filesystems that have problems with such filenames.
+  * sync, push: Avoid trying to send individual files to special remotes
+    configured with importtree=yes exporttree=no, which would always fail.
+  * Fix a crash opening sqlite databases when run in a non-unicode locale.
+    (Needs persistent-sqlite 2.13.3.)
+
+ -- Joey Hess <id@joeyh.name>  Wed, 27 Dec 2023 19:27:37 -0400
+
 git-annex (10.20231129) upstream; urgency=medium
 
   * Fix bug in git-annex copy --from --to that skipped files that were
diff --git a/COPYRIGHT b/COPYRIGHT
--- a/COPYRIGHT
+++ b/COPYRIGHT
@@ -64,6 +64,12 @@
  The full text of version 2 of the GPL is distributed in
  /usr/share/common-licenses/GPL-2 on Debian systems.
 
+Files: Database/RawFilePath.hs
+Copyright: © 2012 Michael Snoyman, http://www.yesodweb.com/
+           © 2023 Joey Hess <id@joeyh.name>
+License: Expat
+ The text of the Expat license is in the Expat section below.
+
 Files: doc/tips/automatically_adding_metadata/pre-commit-annex 
 Copyright: 2014 Joey Hess <id@joeyh.name>
            2016 Klaus Ethgen <Klaus@Ethgen.ch>
diff --git a/CmdLine/Batch.hs b/CmdLine/Batch.hs
--- a/CmdLine/Batch.hs
+++ b/CmdLine/Batch.hs
@@ -198,7 +198,7 @@
 			Right f -> lookupKeyStaged f >>= \case
 				Nothing -> return Nothing
 				Just k -> checkpresent k $
-					startAction seeker si f k
+					startAction seeker Nothing si f k
 			Left k -> ifM (matcher (MatchingInfo (mkinfo k)))
 				( checkpresent k $
 					keyaction (si, k, mkActionItem k)
diff --git a/CmdLine/GitAnnex/Options.hs b/CmdLine/GitAnnex/Options.hs
--- a/CmdLine/GitAnnex/Options.hs
+++ b/CmdLine/GitAnnex/Options.hs
@@ -162,40 +162,55 @@
 	<> completeRemotes
 	)
 
+parseFromAnywhereOption :: Parser Bool
+parseFromAnywhereOption = switch
+	( long "from-anywhere"
+	<> help "from any remote"
+	)
+
 parseRemoteOption :: Parser RemoteName
 parseRemoteOption = strOption
 	( long "remote" <> metavar paramRemote
 	<> completeRemotes
 	)
 
--- | From or to a remote, or both, or a special --to=here
+-- | --from or --to a remote, or both, or a special --to=here,
+-- or --from-anywhere --to remote.
 data FromToHereOptions 
 	= FromOrToRemote FromToOptions
 	| ToHere
 	| FromRemoteToRemote (DeferredParse Remote) (DeferredParse Remote)
+	| FromAnywhereToRemote (DeferredParse Remote)
 
 parseFromToHereOptions :: Parser (Maybe FromToHereOptions)
 parseFromToHereOptions = go
 	<$> optional parseFromOption
 	<*> optional parseToOption
+	<*> parseFromAnywhereOption
   where
-	go (Just from) (Just to) = Just $ FromRemoteToRemote
+	go _ (Just to) True = Just $ FromAnywhereToRemote
+		(mkParseRemoteOption to)
+	go (Just from) (Just to) _ = Just $ FromRemoteToRemote
 		(mkParseRemoteOption from)
 		(mkParseRemoteOption to)
-	go (Just from) Nothing = Just $ FromOrToRemote
+	go (Just from) Nothing _ = Just $ FromOrToRemote
 		(FromRemote $ mkParseRemoteOption from)
-	go Nothing (Just to) = Just $ case to of
+	go Nothing (Just to) _ = Just $ case to of
 		"here" -> ToHere
 		"." -> ToHere
 		_ -> FromOrToRemote $ ToRemote $ mkParseRemoteOption to
-	go Nothing Nothing = Nothing
+	go Nothing Nothing _ = Nothing
 
 instance DeferredParseClass FromToHereOptions where
-	finishParse (FromOrToRemote v) = FromOrToRemote <$> finishParse v
+	finishParse (FromOrToRemote v) = 
+		FromOrToRemote <$> finishParse v
 	finishParse ToHere = pure ToHere
-	finishParse (FromRemoteToRemote v1 v2) = FromRemoteToRemote
-		<$> finishParse v1
-		<*> finishParse v2
+	finishParse (FromRemoteToRemote v1 v2) = 
+		FromRemoteToRemote
+			<$> finishParse v1
+			<*> finishParse v2
+	finishParse (FromAnywhereToRemote v) = 
+		FromAnywhereToRemote <$> finishParse v
 
 -- Options for acting on keys, rather than work tree files.
 data KeyOptions
diff --git a/CmdLine/Seek.hs b/CmdLine/Seek.hs
--- a/CmdLine/Seek.hs
+++ b/CmdLine/Seek.hs
@@ -4,7 +4,7 @@
  - the values a user passes to a command, and prepare actions operating
  - on them.
  -
- - Copyright 2010-2022 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -58,11 +58,14 @@
 import qualified System.FilePath.ByteString as P
 
 data AnnexedFileSeeker = AnnexedFileSeeker
-	{ startAction :: SeekInput -> RawFilePath -> Key -> CommandStart
+	{ startAction :: Maybe KeySha -> SeekInput -> RawFilePath -> Key -> CommandStart
 	, checkContentPresent :: Maybe Bool
 	, usesLocationLog :: Bool
 	}
 
+-- The Sha that was read to get the Key.
+newtype KeySha = KeySha Git.Sha
+
 withFilesInGitAnnex :: WarnUnmatchWhen -> AnnexedFileSeeker -> WorkTreeItems -> CommandSeek
 withFilesInGitAnnex ww a l = seekFilteredKeys a $
 	seekHelper fst3 ww LsFiles.inRepoDetails l
@@ -375,9 +378,9 @@
 	propagateLsFilesError cleanup
   where
 	finisher mi oreader checktimelimit = liftIO oreader >>= \case
-		Just ((si, f), content) -> checktimelimit (liftIO discard) $ do
-			keyaction f mi content $ 
-				commandAction . startAction seeker si f
+		Just ((si, f, keysha), content) -> checktimelimit (liftIO discard) $ do
+			keyaction f mi content $
+				commandAction . startAction seeker keysha si f
 			finisher mi oreader checktimelimit
 		Nothing -> return ()
 	  where
@@ -386,12 +389,12 @@
 			Just _ -> discard
 
 	precachefinisher mi lreader checktimelimit = liftIO lreader >>= \case
-		Just ((logf, (si, f), k), logcontent) -> checktimelimit (liftIO discard) $ do
+		Just ((logf, (si, f, keysha), k), logcontent) -> checktimelimit (liftIO discard) $ do
 			maybe noop (Annex.Branch.precache logf) logcontent
 			checkMatcherWhen mi
 				(matcherNeedsLocationLog mi && not (matcherNeedsFileName mi))
 				(MatchingFile $ FileInfo f f (Just k))
-				(commandAction $ startAction seeker si f k)
+				(commandAction $ startAction seeker keysha si f k)
 			precachefinisher mi lreader checktimelimit
 		Nothing -> return ()
 	  where
@@ -400,11 +403,11 @@
 			Just _ -> discard
 	
 	precacher mi config oreader lfeeder lcloser = liftIO oreader >>= \case
-		Just ((si, f), content) -> do
+		Just ((si, f, keysha), content) -> do
 			keyaction f mi content $ \k -> 
 				let logf = locationLogFile config k
 				    ref = Git.Ref.branchFileRef Annex.Branch.fullname logf
-				in liftIO $ lfeeder ((logf, (si, f), k), ref)
+				in liftIO $ lfeeder ((logf, (si, f, keysha), k), ref)
 			precacher mi config oreader lfeeder lcloser
 		Nothing -> liftIO $ void lcloser
 	
@@ -415,7 +418,7 @@
 		(not ((matcherNeedsKey mi || matcherNeedsLocationLog mi) 
 			&& not (matcherNeedsFileName mi)))
 		(MatchingFile $ FileInfo f f Nothing)
-		(liftIO $ ofeeder ((si, f), sha))
+		(liftIO $ ofeeder ((si, f, Just (KeySha sha)), sha))
 
 	keyaction f mi content a = 
 		case parseLinkTargetOrPointerLazy =<< content of
diff --git a/Command/CheckPresentKey.hs b/Command/CheckPresentKey.hs
--- a/Command/CheckPresentKey.hs
+++ b/Command/CheckPresentKey.hs
@@ -51,7 +51,7 @@
 check ks mr = case mr of
 	Just r -> go Nothing [r]
 	Nothing -> do
-		mostlikely <- Remote.keyPossibilities k
+		mostlikely <- Remote.keyPossibilities (Remote.IncludeIgnored False) k
 		otherremotes <- flip Remote.remotesWithoutUUID 
 			(map Remote.uuid mostlikely)
 			<$> remoteList
diff --git a/Command/Copy.hs b/Command/Copy.hs
--- a/Command/Copy.hs
+++ b/Command/Copy.hs
@@ -63,12 +63,13 @@
 	ww = WarnUnmatchLsFiles "copy"
 	
 	seeker = AnnexedFileSeeker
-		{ startAction = start o fto
+		{ startAction = const $ start o fto
 		, checkContentPresent = case fto of
 			FromOrToRemote (FromRemote _) -> Just False
 			FromOrToRemote (ToRemote _) -> Just True
 			ToHere -> Just False
 			FromRemoteToRemote _ _ -> Nothing
+			FromAnywhereToRemote _ -> Nothing
 		, usesLocationLog = True
 		}
 	keyaction = Command.Move.startKey fto Command.Move.RemoveNever
@@ -84,12 +85,13 @@
 		| autoMode o = want <||> numCopiesCheck file key (<)
 		| otherwise = return True
 	want = case fto of
-		FromOrToRemote (ToRemote dest) ->
-			(Remote.uuid <$> getParsed dest) >>= checkwantsend
+		FromOrToRemote (ToRemote dest) -> checkwantsend dest
 		FromOrToRemote (FromRemote _) -> checkwantget
 		ToHere -> checkwantget
-		FromRemoteToRemote _ dest ->
-			(Remote.uuid <$> getParsed dest) >>= checkwantsend
-			
-	checkwantsend = wantGetBy False (Just key) (AssociatedFile (Just file))
+		FromRemoteToRemote _ dest -> checkwantsend dest
+		FromAnywhereToRemote dest -> checkwantsend dest
+
+	checkwantsend dest = 
+		(Remote.uuid <$> getParsed dest) >>=
+			wantGetBy False (Just key) (AssociatedFile (Just file))
 	checkwantget = wantGet False (Just key) (AssociatedFile (Just file))
diff --git a/Command/Drop.hs b/Command/Drop.hs
--- a/Command/Drop.hs
+++ b/Command/Drop.hs
@@ -60,7 +60,7 @@
 				then pure Nothing
 				else pure (Just remote)				
 	let seeker = AnnexedFileSeeker
-		{ startAction = start o from
+		{ startAction = const $ start o from
 		, checkContentPresent = case from of
 			Nothing -> Just True
 			Just _ -> Nothing
diff --git a/Command/FilterBranch.hs b/Command/FilterBranch.hs
--- a/Command/FilterBranch.hs
+++ b/Command/FilterBranch.hs
@@ -157,7 +157,7 @@
 					=<< Annex.Branch.get f
 			next (return True)
 		let seeker = AnnexedFileSeeker
-			{ startAction = \_ _ k -> addkeyinfo k
+			{ startAction = \_ _ _ k -> addkeyinfo k
 			, checkContentPresent = Nothing
 			, usesLocationLog = True
 			}
diff --git a/Command/Find.hs b/Command/Find.hs
--- a/Command/Find.hs
+++ b/Command/Find.hs
@@ -63,7 +63,7 @@
 		checkNotBareRepo
 	isterminal <- liftIO $ checkIsTerminal stdout
 	seeker <- contentPresentUnlessLimited $ AnnexedFileSeeker
-		{ startAction = start o isterminal
+		{ startAction = const (start o isterminal)
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
diff --git a/Command/FindKeys.hs b/Command/FindKeys.hs
--- a/Command/FindKeys.hs
+++ b/Command/FindKeys.hs
@@ -33,7 +33,7 @@
 		, usesLocationLog = False
 		-- startAction is not actually used since this
 		-- is not used to seek files
-		, startAction = \_ _ key -> start' o isterminal key
+		, startAction = \_ _ _ key -> start' o isterminal key
 		}
 	withKeyOptions (Just WantAllKeys) False seeker
 		(commandAction . start o isterminal)
diff --git a/Command/Fix.hs b/Command/Fix.hs
--- a/Command/Fix.hs
+++ b/Command/Fix.hs
@@ -37,7 +37,7 @@
   where
 	ww = WarnUnmatchLsFiles "fix"
 	seeker = AnnexedFileSeeker
-		{ startAction = start FixAll
+		{ startAction = const $ start FixAll
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
diff --git a/Command/Fsck.hs b/Command/Fsck.hs
--- a/Command/Fsck.hs
+++ b/Command/Fsck.hs
@@ -102,7 +102,7 @@
 	checkDeadRepo u
 	i <- prepIncremental u (incrementalOpt o)
 	let seeker = AnnexedFileSeeker
-		{ startAction = start from i
+		{ startAction = const $ start from i
 		, checkContentPresent = Nothing
 		, usesLocationLog = True
 		}
diff --git a/Command/Get.hs b/Command/Get.hs
--- a/Command/Get.hs
+++ b/Command/Get.hs
@@ -41,7 +41,7 @@
 seek o = startConcurrency transferStages $ do
 	from <- maybe (pure Nothing) (Just <$$> getParsed) (getFrom o)
 	let seeker = AnnexedFileSeeker
-		{ startAction = start o from
+		{ startAction = const $ start o from
 		, checkContentPresent = Just False
 		, usesLocationLog = True
 		}
@@ -87,7 +87,8 @@
 {- Try to find a copy of the file in one of the remotes,
  - and copy it to here. -}
 getKey :: Key -> AssociatedFile -> Annex Bool
-getKey key afile = getKey' key afile =<< Remote.keyPossibilities key
+getKey key afile = getKey' key afile
+	=<< Remote.keyPossibilities (Remote.IncludeIgnored False) key
 
 getKey' :: Key -> AssociatedFile -> [Remote] -> Annex Bool
 getKey' key afile = dispatch
diff --git a/Command/Inprogress.hs b/Command/Inprogress.hs
--- a/Command/Inprogress.hs
+++ b/Command/Inprogress.hs
@@ -42,7 +42,7 @@
 		_ -> do
 			let s = S.fromList ts
 			let seeker = AnnexedFileSeeker
-				{ startAction = start isterminal s
+				{ startAction = const $ start isterminal s
 				, checkContentPresent = Nothing
 				, usesLocationLog = False
 				}
diff --git a/Command/List.hs b/Command/List.hs
--- a/Command/List.hs
+++ b/Command/List.hs
@@ -50,7 +50,7 @@
 	list <- getList o
 	printHeader list
 	let seeker = AnnexedFileSeeker
-		{ startAction = start list
+		{ startAction = const $ start list
 		, checkContentPresent = Nothing
 		, usesLocationLog = True
 		}
diff --git a/Command/Lock.hs b/Command/Lock.hs
--- a/Command/Lock.hs
+++ b/Command/Lock.hs
@@ -34,7 +34,7 @@
   where
 	ww = WarnUnmatchLsFiles "lock"
 	seeker = AnnexedFileSeeker
-		{ startAction = start
+		{ startAction = const start
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
diff --git a/Command/Log.hs b/Command/Log.hs
--- a/Command/Log.hs
+++ b/Command/Log.hs
@@ -138,7 +138,7 @@
 		zone <- liftIO getCurrentTimeZone
 		outputter <- mkOutputter m zone o <$> jsonOutputEnabled
 		let seeker = AnnexedFileSeeker
-			{ startAction = start o outputter
+			{ startAction = const $ start o outputter
 			, checkContentPresent = Nothing
 			-- the way this uses the location log would not be
 			-- helped by precaching the current value
@@ -165,7 +165,7 @@
 	void $ liftIO cleanup
 	stop
 
-{- Displays changes made. Only works when all the RefChanges are for the
+{- Displays changes made. Only works when all the LoggedFileChanges are for the
  - same key. The method is to compare each value with the value
  - after it in the list, which is the old version of the value.
  -
@@ -179,7 +179,7 @@
  - This also generates subtly better output when the git-annex branch
  - got diverged.
  -}
-showLogIncremental :: Outputter -> [RefChange Key] -> Annex ()
+showLogIncremental :: Outputter -> [LoggedFileChange Key] -> Annex ()
 showLogIncremental outputter ps = do
 	sets <- mapM (getset newref) ps
 	previous <- maybe (return genesis) (getset oldref) (lastMaybe ps)
@@ -196,7 +196,7 @@
 {- Displays changes made. Streams, and can display changes affecting
  - different keys, but does twice as much reading of logged values
  - as showLogIncremental. -}
-showLog :: (ActionItem -> Outputter) -> [RefChange Key] -> Annex ()
+showLog :: (ActionItem -> Outputter) -> [LoggedFileChange Key] -> Annex ()
 showLog outputter cs = forM_ cs $ \c -> do
 	let ai = mkActionItem (changed c)
 	new <- S.fromList <$> loggedLocationsRef (newref c)
@@ -279,7 +279,7 @@
  - once the location log file is gone avoids it checking all the way back
  - to commit 0 to see if it used to exist, so generally speeds things up a
  - *lot* for newish files. -}
-getKeyLog :: Key -> [CommandParam] -> Annex ([RefChange Key], IO Bool)
+getKeyLog :: Key -> [CommandParam] -> Annex ([LoggedFileChange Key], IO Bool)
 getKeyLog key os = do
 	top <- fromRepo Git.repoPath
 	p <- liftIO $ relPathCwdToFile top
@@ -287,11 +287,12 @@
 	let logfile = p P.</> locationLogFile config key
 	getGitLogAnnex [fromRawFilePath logfile] (Param "--remove-empty" : os)
 
-getGitLogAnnex :: [FilePath] -> [CommandParam] -> Annex ([RefChange Key], IO Bool)
+getGitLogAnnex :: [FilePath] -> [CommandParam] -> Annex ([LoggedFileChange Key], IO Bool)
 getGitLogAnnex fs os = do
 	config <- Annex.getGitConfig
-	let fileselector = locationLogFileKey config . toRawFilePath
-	inRepo $ getGitLog Annex.Branch.fullname fs os fileselector
+	let fileselector = \_sha f ->
+		locationLogFileKey config (toRawFilePath f)
+	inRepo $ getGitLog Annex.Branch.fullname Nothing fs os fileselector
 
 showTimeStamp :: TimeZone -> String -> POSIXTime -> String
 showTimeStamp zone format = formatTime defaultTimeLocale format
@@ -321,13 +322,13 @@
 	-- and to the trust log.
 	getlog = do
 		config <- Annex.getGitConfig
-		let fileselector = \f -> let f' = toRawFilePath f in
+		let fileselector = \_sha f -> let f' = toRawFilePath f in
 			case locationLogFileKey config f' of
 				Just k -> Just (Right k)
 				Nothing
 					| f' == trustLog -> Just (Left ())
 					| otherwise -> Nothing
-		inRepo $ getGitLog Annex.Branch.fullname []
+		inRepo $ getGitLog Annex.Branch.fullname Nothing []
 			[ Param "--date-order"
 			, Param "--reverse"
 			]
diff --git a/Command/MetaData.hs b/Command/MetaData.hs
--- a/Command/MetaData.hs
+++ b/Command/MetaData.hs
@@ -77,7 +77,7 @@
 		c <- currentVectorClock
 		let ww = WarnUnmatchLsFiles "metadata"
 		let seeker = AnnexedFileSeeker
-			{ startAction = start c o
+			{ startAction = const $ start c o
 			, checkContentPresent = Nothing
 			, usesLocationLog = False
 			}
diff --git a/Command/Migrate.hs b/Command/Migrate.hs
--- a/Command/Migrate.hs
+++ b/Command/Migrate.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2011 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -15,9 +15,15 @@
 import qualified Command.ReKey
 import qualified Command.Fsck
 import qualified Annex
+import Logs.Migrate
 import Logs.MetaData
 import Logs.Web
+import Logs.Location
 import Utility.Metered
+import qualified Database.Keys
+import Git.FilePath
+import Annex.Link
+import Annex.UUID
 
 cmd :: Command
 cmd = withAnnexOptions [backendOption, annexedMatchingOptions, jsonOptions] $
@@ -27,6 +33,8 @@
 
 data MigrateOptions = MigrateOptions
 	{ migrateThese :: CmdParams
+	, updateOption :: Bool
+	, applyOption :: Bool
 	, removeSize :: Bool
 	}
 
@@ -34,12 +42,27 @@
 optParser desc = MigrateOptions
 	<$> cmdParams desc
 	<*> switch
+		( long "update"
+		<> help "incrementally apply migrations performed elsewhere"
+		)
+	<*> switch
+		( long "apply"
+		<> help "(re)apply migrations performed elsewhere"
+		)
+	<*> switch
 		( long "remove-size"
 		<> help "remove size field from keys"
 		)
 
 seek :: MigrateOptions -> CommandSeek
-seek o = withFilesInGitAnnex ww seeker =<< workTreeItems ww (migrateThese o)
+seek o
+	| updateOption o || applyOption o = do
+		unless (null (migrateThese o)) $
+			error "Cannot combine --update or --apply with files to migrate."
+		seekDistributedMigrations (not (applyOption o))
+	| otherwise = do
+		withFilesInGitAnnex ww seeker =<< workTreeItems ww (migrateThese o)
+		commitMigration
   where
 	ww = WarnUnmatchLsFiles "migrate"
 	seeker = AnnexedFileSeeker
@@ -48,8 +71,16 @@
 		, usesLocationLog = False
 		}
 
-start :: MigrateOptions -> SeekInput -> RawFilePath -> Key -> CommandStart
-start o si file key = do
+seekDistributedMigrations :: Bool -> CommandSeek
+seekDistributedMigrations incremental =
+	streamNewDistributedMigrations incremental $ \oldkey newkey ->
+		-- Not using commandAction because this is not necessarily
+		-- concurrency safe, and also is unlikely to be sped up
+		-- by multiple jobs.
+		void $ includeCommandAction $ update oldkey newkey
+
+start :: MigrateOptions -> Maybe KeySha -> SeekInput -> RawFilePath -> Key -> CommandStart
+start o ksha si file key = do
 	forced <- Annex.getRead Annex.force
 	v <- Backend.getBackend (fromRawFilePath file) key
 	case v of
@@ -57,27 +88,27 @@
 		Just oldbackend -> do
 			exists <- inAnnex key
 			newbackend <- chooseBackend file
-			if (newbackend /= oldbackend || upgradableKey oldbackend key || forced) && exists
+			if (newbackend /= oldbackend || upgradableKey oldbackend || forced) && exists
 				then go False oldbackend newbackend
-				else if removeSize o && exists
-					then go True oldbackend oldbackend
+				else if cantweaksize newbackend oldbackend && exists
+					then go True oldbackend newbackend
 					else stop
   where
-	go onlyremovesize oldbackend newbackend =
+	go onlytweaksize oldbackend newbackend = do
+		keyrec <- case ksha of
+			Just (KeySha s) -> pure (MigrationRecord s)
+			Nothing -> error "internal"
 		starting "migrate" (mkActionItem (key, file)) si $
-			perform onlyremovesize o file key oldbackend newbackend
+			perform onlytweaksize o file key keyrec oldbackend newbackend
 
-{- Checks if a key is upgradable to a newer representation.
- - 
- - Reasons for migration:
- -  - Ideally, all keys have file size metadata. Old keys may not.
- -  - Something has changed in the backend, such as a bug fix.
- -}
-upgradableKey :: Backend -> Key -> Bool
-upgradableKey backend key = isNothing (fromKey keySize key) || backendupgradable
-  where
-	backendupgradable = maybe False (\a -> a key) (canUpgradeKey backend)
+	cantweaksize newbackend oldbackend
+		| removeSize o = isJust (fromKey keySize key)
+		| newbackend /= oldbackend = False
+		| isNothing (fromKey keySize key) = True
+		| otherwise = False
 
+	upgradableKey oldbackend = maybe False (\a -> a key) (canUpgradeKey oldbackend)
+
 {- Store the old backend's key in the new backend
  - The old backend's key is not dropped from it, because there may
  - be other files still pointing at that key.
@@ -87,14 +118,14 @@
  - data cannot get corrupted after the fsck but before the new key is
  - generated.
  -}
-perform :: Bool -> MigrateOptions -> RawFilePath -> Key -> Backend -> Backend -> CommandPerform
-perform onlyremovesize o file oldkey oldbackend newbackend = go =<< genkey (fastMigrate oldbackend)
+perform :: Bool -> MigrateOptions -> RawFilePath -> Key -> MigrationRecord -> Backend -> Backend -> CommandPerform
+perform onlytweaksize o file oldkey oldkeyrec oldbackend newbackend = go =<< genkey (fastMigrate oldbackend)
   where
 	go Nothing = stop
 	go (Just (newkey, knowngoodcontent))
-		| knowngoodcontent = finish (removesize newkey)
+		| knowngoodcontent = finish =<< tweaksize newkey
 		| otherwise = stopUnless checkcontent $
-			finish (removesize newkey)
+			finish =<< tweaksize newkey
 	checkcontent = Command.Fsck.checkBackend oldbackend oldkey KeyPresent afile
 	finish newkey = ifM (Command.ReKey.linkKey file oldkey newkey)
 		( do
@@ -104,10 +135,11 @@
 			urls <- getUrls oldkey
 			forM_ urls $ \url ->
 				setUrlPresent newkey url
-			next $ Command.ReKey.cleanup file newkey
+			next $ Command.ReKey.cleanup file newkey $
+				logMigration oldkeyrec
 		, giveup "failed creating link from old to new key"
 		)
-	genkey _ | onlyremovesize = return $ Just (oldkey, False)
+	genkey _ | onlytweaksize = return $ Just (oldkey, False)
 	genkey Nothing = do
 		content <- calcRepo $ gitAnnexLocation oldkey
 		let source = KeySource
@@ -120,7 +152,56 @@
 	genkey (Just fm) = fm oldkey newbackend afile >>= \case
 		Just newkey -> return (Just (newkey, True))
 		Nothing -> genkey Nothing
-	removesize k
-		| removeSize o = alterKey k $ \kd -> kd { keySize = Nothing } 
-		| otherwise = k
+	tweaksize k
+		| removeSize o = pure (removesize k)
+		| onlytweaksize = addsize k
+		| otherwise = pure k
+	removesize k = alterKey k $ \kd -> kd { keySize = Nothing }
+	addsize k
+		| fromKey keySize k == Nothing = 
+			contentSize k >>= return . \case
+				Just sz -> alterKey k $ \kd -> kd { keySize = Just sz }
+				Nothing -> k
+		| otherwise = return k
 	afile = AssociatedFile (Just file)
+
+update :: Key -> Key -> CommandStart
+update oldkey newkey =
+	stopUnless (allowed <&&> available <&&> wanted) $ do
+		ai <- findworktreefile >>= return . \case
+			Just f -> ActionItemAssociatedFile (AssociatedFile (Just f)) newkey
+			Nothing -> ActionItemKey newkey
+		starting "migrate" ai (SeekInput []) $
+			ifM (Command.ReKey.linkKey' v oldkey newkey)
+				( do
+					logStatus newkey InfoPresent
+					next $ return True
+				, next $ return False
+				)
+  where
+	available = (not <$> inAnnex newkey) <&&> inAnnex oldkey
+
+	-- annex.securehashesonly will block adding keys with insecure
+	-- hashes, this check is only to avoid doing extra work and
+	-- displaying a message when it fails.
+	allowed = isNothing <$> checkSecureHashes newkey
+
+	-- If the new key was previous present in this repository, but got
+	-- dropped, assume the user still doesn't want it there.
+	wanted = loggedPreviousLocations newkey >>= \case
+		[] -> pure True
+		us -> do
+			u <- getUUID
+			pure (u `notElem` us)
+
+	findworktreefile = do
+		fs <- Database.Keys.getAssociatedFiles newkey
+		g <- Annex.gitRepo
+		firstM (\f -> (== Just newkey) <$> isAnnexLink f) $
+			map (\f -> simplifyPath (fromTopFilePath f g)) fs
+	
+	-- Always verify the content agains the newkey, even if
+	-- annex.verify is unset. This is done to prent bad migration
+	-- information maliciously injected into the git-annex branch
+	-- from populating files with the wrong content.
+	v = AlwaysVerify
diff --git a/Command/Mirror.hs b/Command/Mirror.hs
--- a/Command/Mirror.hs
+++ b/Command/Mirror.hs
@@ -52,7 +52,7 @@
 		ToRemote _ -> commandStages
 	ww = WarnUnmatchLsFiles "mirror"
 	seeker = AnnexedFileSeeker
-		{ startAction = start o
+		{ startAction = const $ start o
 		, checkContentPresent = Nothing
 		, usesLocationLog = True
 		}
diff --git a/Command/Move.hs b/Command/Move.hs
--- a/Command/Move.hs
+++ b/Command/Move.hs
@@ -75,12 +75,13 @@
 			batchAnnexed fmt seeker keyaction
   where
 	seeker = AnnexedFileSeeker
-		{ startAction = start fto (removeWhen o)
+		{ startAction = const $ start fto (removeWhen o)
 		, checkContentPresent = case fto of
 			FromOrToRemote (FromRemote _) -> Nothing
 			FromOrToRemote (ToRemote _) -> Just True
 			ToHere -> Nothing
 			FromRemoteToRemote _ _ -> Nothing
+			FromAnywhereToRemote _ -> Nothing
 		, usesLocationLog = True
 		}
 	keyaction = startKey fto (removeWhen o)
@@ -91,6 +92,7 @@
 stages (FromOrToRemote (ToRemote _)) = commandStages
 stages ToHere = transferStages
 stages (FromRemoteToRemote _ _) = transferStages
+stages (FromAnywhereToRemote _) = transferStages
 
 start :: FromToHereOptions -> RemoveWhen -> SeekInput -> RawFilePath -> Key -> CommandStart
 start fromto removewhen si f k = start' fromto removewhen afile si k ai
@@ -118,6 +120,9 @@
 			src' <- getParsed src
 			dest' <- getParsed dest
 			fromToStart removewhen afile key ai si src' dest'
+		FromAnywhereToRemote dest -> do
+			dest' <- getParsed dest
+			fromAnywhereToStart removewhen afile key ai si dest'
 
 describeMoveAction :: RemoveWhen -> String
 describeMoveAction RemoveNever = "copy"
@@ -146,7 +151,7 @@
 
 expectedPresent :: Remote -> Key -> Annex Bool
 expectedPresent dest key = do
-	remotes <- Remote.keyPossibilities key
+	remotes <- Remote.keyPossibilities (Remote.IncludeIgnored True) key
 	return $ dest `elem` remotes
 
 toPerform :: Remote -> RemoveWhen -> Key -> AssociatedFile -> Bool -> Either String Bool -> CommandPerform
@@ -249,7 +254,7 @@
   where
 	checklog = do
 		u <- getUUID
-		remotes <- Remote.keyPossibilities key
+		remotes <- Remote.keyPossibilities (Remote.IncludeIgnored True) key
 		return $ u /= Remote.uuid src && elem src remotes
 
 fromPerform :: Remote -> RemoveWhen -> Key -> AssociatedFile -> CommandPerform
@@ -326,7 +331,7 @@
 toHereStart :: RemoveWhen -> AssociatedFile -> Key -> ActionItem -> SeekInput -> CommandStart
 toHereStart removewhen afile key ai si = 
 	startingNoMessage (OnlyActionOn key ai) $ do
-		rs <- Remote.keyPossibilities key
+		rs <- Remote.keyPossibilities (Remote.IncludeIgnored False) key
 		forM_ rs $ \r ->
 			includeCommandAction $
 				starting (describeMoveAction removewhen) ai si $
@@ -352,6 +357,30 @@
 			if fast && removewhen == RemoveNever
 				then not <$> expectedPresent dest key
 				else return True
+
+fromAnywhereToStart :: RemoveWhen -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> CommandStart
+fromAnywhereToStart removewhen afile key ai si dest =
+	stopUnless somethingtodo $ do
+		u <- getUUID
+		if u == Remote.uuid dest
+			then toHereStart removewhen afile key ai si
+			else startingNoMessage (OnlyActionOn key ai) $ do
+				rs <- filter (/= dest) 
+					<$> Remote.keyPossibilities (Remote.IncludeIgnored False) key
+				forM_ rs $ \r ->
+					includeCommandAction $
+						starting (describeMoveAction removewhen) ai si $
+							fromToPerform r dest removewhen key afile
+				whenM (inAnnex key) $
+					void $ includeCommandAction $
+						toStart removewhen afile key ai si dest 
+				next $ return True
+  where
+	somethingtodo = do
+		fast <- Annex.getRead Annex.fast
+		if fast && removewhen == RemoveNever
+			then not <$> expectedPresent dest key
+			else return True
 
 {- When there is a local copy, transfer it to the dest, and drop from the src.
  -
diff --git a/Command/ReKey.hs b/Command/ReKey.hs
--- a/Command/ReKey.hs
+++ b/Command/ReKey.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2012-2016 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -19,6 +19,7 @@
 import Logs.Location
 import Annex.InodeSentinal
 import Annex.WorkTree
+import Logs.Migrate
 import Utility.InodeCache
 import qualified Utility.RawFilePath as R
 
@@ -88,20 +89,13 @@
 			giveup $ decodeBS $ quote qp $ QuotedPath file
 				<> " is not available (use --force to override)"
 		)
-	next $ cleanup file newkey
+	next $ cleanup file newkey $ const noop
 
 {- Make a hard link to the old key content (when supported),
  - to avoid wasting disk space. -}
 linkKey :: RawFilePath -> Key -> Key -> Annex Bool
 linkKey file oldkey newkey = ifM (isJust <$> isAnnexLink file)
- 	{- If the object file is already hardlinked to elsewhere, a hard
-	 - link won't be made by getViaTmpFromDisk, but a copy instead.
-	 - This avoids hard linking to content linked to an
-	 - unlocked file, which would leave the new key unlocked
-	 - and vulnerable to corruption. -}
-	( getViaTmpFromDisk RetrievalAllKeysSecure DefaultVerify newkey (AssociatedFile Nothing) $ \tmp -> unVerified $ do
-		oldobj <- calcRepo (gitAnnexLocation oldkey)
-		isJust <$> linkOrCopy' (return True) newkey oldobj tmp Nothing
+	( linkKey' DefaultVerify oldkey newkey
 	, do
 	 	{- The file being rekeyed is itself an unlocked file; if
 		 - it's hard linked to the old key, that link must be broken. -}
@@ -127,18 +121,34 @@
 					LinkAnnexNoop -> True
 	)
 
-cleanup :: RawFilePath -> Key -> CommandCleanup
-cleanup file newkey = do
-	ifM (isJust <$> isAnnexLink file)
+ {- If the object file is already hardlinked to elsewhere, a hard
+ - link won't be made by getViaTmpFromDisk, but a copy instead.
+ - This avoids hard linking to content linked to an
+ - unlocked file, which would leave the new key unlocked
+ - and vulnerable to corruption. -}
+linkKey' :: VerifyConfig -> Key -> Key -> Annex Bool
+linkKey' v oldkey newkey =
+	getViaTmpFromDisk RetrievalAllKeysSecure v newkey (AssociatedFile Nothing) $ \tmp -> unVerified $ do
+		oldobj <- calcRepo (gitAnnexLocation oldkey)
+		isJust <$> linkOrCopy' (return True) newkey oldobj tmp Nothing
+
+cleanup :: RawFilePath -> Key -> (MigrationRecord -> Annex ()) -> CommandCleanup
+cleanup file newkey a = do
+	newkeyrec <- ifM (isJust <$> isAnnexLink file)
 		( do
 			-- Update symlink to use the new key.
-			addSymlink file newkey Nothing
+			sha <- genSymlink file newkey Nothing
+			stageSymlink file sha
+			return (MigrationRecord sha)
 		, do
 			mode <- liftIO $ catchMaybeIO $ fileMode <$> R.getFileStatus file
 			liftIO $ whenM (isJust <$> isPointerFile file) $
 				writePointerFile file newkey mode
-			stagePointerFile file mode =<< hashPointerFile newkey
+			sha <- hashPointerFile newkey
+			stagePointerFile file mode sha
+			return (MigrationRecord sha)
 		)
 	whenM (inAnnex newkey) $
 		logStatus newkey InfoPresent
+	a newkeyrec
 	return True
diff --git a/Command/Sync.hs b/Command/Sync.hs
--- a/Command/Sync.hs
+++ b/Command/Sync.hs
@@ -58,6 +58,7 @@
 import qualified Command.Move
 import qualified Command.Export
 import qualified Command.Import
+import qualified Command.Migrate
 import Annex.Drop
 import Annex.UUID
 import Logs.UUID
@@ -76,6 +77,7 @@
 import Annex.CurrentBranch
 import Annex.Import
 import Annex.CheckIgnore
+import Annex.PidLock
 import Types.FileMatcher
 import Types.GitConfig
 import Types.Availability
@@ -293,6 +295,10 @@
 			
 			content <- shouldSyncContent o
 
+			when content $
+				whenM (annexSyncMigrations <$> Annex.getGitConfig) $
+					Command.Migrate.seekDistributedMigrations True
+
 			forM_ (filter isImport contentremotes) $
 				withbranch . importRemote content o
 			forM_ (filter isThirdPartyPopulated contentremotes) $
@@ -352,15 +358,16 @@
 		]
 
 merge :: CurrBranch -> [Git.Merge.MergeConfig] -> SyncOptions -> Git.Branch.CommitMode -> [Git.Branch] -> Annex Bool
-merge currbranch mergeconfig o commitmode tomergel = do
-	canresolvemerge <- if resolveMergeOverride o
-		then getGitConfigVal annexResolveMerge
-		else return False
-	and <$> case currbranch of
-		(Just b, Just adj) -> forM tomergel $ \tomerge ->
-			mergeToAdjustedBranch tomerge (b, adj) mergeconfig canresolvemerge commitmode
-		(b, _) -> forM tomergel $ \tomerge ->
-			autoMergeFrom tomerge b mergeconfig commitmode canresolvemerge
+merge currbranch mergeconfig o commitmode tomergel = 
+	runsGitAnnexChildProcessViaGit $ do
+		canresolvemerge <- if resolveMergeOverride o
+			then getGitConfigVal annexResolveMerge
+			else return False
+		and <$> case currbranch of
+			(Just b, Just adj) -> forM tomergel $ \tomerge ->
+				mergeToAdjustedBranch tomerge (b, adj) mergeconfig canresolvemerge commitmode
+			(b, _) -> forM tomergel $ \tomerge ->
+				autoMergeFrom tomerge b mergeconfig commitmode canresolvemerge
 
 syncBranch :: Git.Branch -> Git.Branch
 syncBranch = Git.Ref.underBase "refs/heads/synced" . origBranch
@@ -840,7 +847,7 @@
   where
 	seekworktree mvar l bloomfeeder = do
 		let seeker = AnnexedFileSeeker
-			{ startAction = gofile bloomfeeder mvar
+			{ startAction = const $ gofile bloomfeeder mvar
 			, checkContentPresent = Nothing
 			, usesLocationLog = True
 			}
@@ -897,7 +904,7 @@
 syncFile :: SyncOptions -> Either (Maybe (Bloom Key)) (Key -> Annex ()) -> [Remote] -> AssociatedFile -> Key -> Annex Bool
 syncFile o ebloom rs af k = do
 	inhere <- inAnnex k
-	locs <- map Remote.uuid <$> Remote.keyPossibilities k
+	locs <- map Remote.uuid <$> Remote.keyPossibilities (Remote.IncludeIgnored False) k
 	let (have, lack) = partition (\r -> Remote.uuid r `elem` locs) rs
 
 	got <- anyM id =<< handleget have inhere
@@ -948,7 +955,7 @@
 	wantput r
 		| pushOption o == False && operationMode o /= SatisfyMode = return False
 		| Remote.readonly r || remoteAnnexReadOnly (Remote.gitconfig r) = return False
-		| isExport r = return False
+		| isExport r || isImport r = return False
 		| isThirdPartyPopulated r = return False
 		| otherwise = wantGetBy True (Just k) af (Remote.uuid r)
 	handleput lack inhere
diff --git a/Command/Unannex.hs b/Command/Unannex.hs
--- a/Command/Unannex.hs
+++ b/Command/Unannex.hs
@@ -34,7 +34,7 @@
 
 seeker :: Bool -> AnnexedFileSeeker
 seeker fast = AnnexedFileSeeker
-	{ startAction = start fast
+	{ startAction = const $ start fast
 	, checkContentPresent = Just True
 	, usesLocationLog = False
 	}
diff --git a/Command/Unlock.hs b/Command/Unlock.hs
--- a/Command/Unlock.hs
+++ b/Command/Unlock.hs
@@ -35,7 +35,7 @@
   where
 	ww = WarnUnmatchLsFiles "unlock"
 	seeker = AnnexedFileSeeker
-		{ startAction = start
+		{ startAction = const start
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
diff --git a/Command/WhereUsed.hs b/Command/WhereUsed.hs
--- a/Command/WhereUsed.hs
+++ b/Command/WhereUsed.hs
@@ -49,7 +49,7 @@
 	(commandAction . start o) dummyfilecommandseek (WorkTreeItems [])
   where
 	dummyfileseeker = AnnexedFileSeeker
-		{ startAction = \_ _ _ -> return Nothing
+		{ startAction = \_ _ _ _ -> return Nothing
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
diff --git a/Command/Whereis.hs b/Command/Whereis.hs
--- a/Command/Whereis.hs
+++ b/Command/Whereis.hs
@@ -51,7 +51,7 @@
 seek o = do
 	m <- remoteMap id
 	let seeker = AnnexedFileSeeker
-		{ startAction = start o m
+		{ startAction = const $ start o m
 		, checkContentPresent = Nothing
 		, usesLocationLog = True
 		}
diff --git a/Database/ContentIdentifier.hs b/Database/ContentIdentifier.hs
--- a/Database/ContentIdentifier.hs
+++ b/Database/ContentIdentifier.hs
@@ -53,9 +53,14 @@
 
 import Database.Persist.Sql hiding (Key)
 import Database.Persist.TH
-import Database.Persist.Sqlite (runSqlite)
 import qualified System.FilePath.ByteString as P
+
+#if MIN_VERSION_persistent_sqlite(2,13,3)
+import Database.RawFilePath
+#else
+import Database.Persist.Sqlite (runSqlite)
 import qualified Data.Text as T
+#endif
 
 data ContentIdentifierHandle = ContentIdentifierHandle H.DbQueue Bool
 
@@ -102,8 +107,13 @@
 			runMigrationSilent migrateContentIdentifier
 		-- Migrate from old versions of database, which had buggy
 		-- and suboptimal uniqueness constraints.
+#if MIN_VERSION_persistent_sqlite(2,13,3)
+		else liftIO $ runSqlite' db $ void $
+			runMigrationSilent migrateContentIdentifier
+#else
 		else liftIO $ runSqlite (T.pack (fromRawFilePath db)) $ void $
 			runMigrationSilent migrateContentIdentifier
+#endif
 	h <- liftIO $ H.openDbQueue db "content_identifiers"
 	return $ ContentIdentifierHandle h isnew
 
diff --git a/Database/Handle.hs b/Database/Handle.hs
--- a/Database/Handle.hs
+++ b/Database/Handle.hs
@@ -193,10 +193,12 @@
 				| otherwise -> rethrow $ errmsg "after successful open" ex
 	
 	opensettle retries ic = do
-		conn <- Sqlite.open tdb
+#if MIN_VERSION_persistent_sqlite(2,13,3)
+		conn <- Sqlite.open' db
+#else
+		conn <- Sqlite.open (T.pack (fromRawFilePath db))
+#endif
 		settle conn retries ic
-
-	tdb = T.pack (fromRawFilePath db)
 
 	settle conn retries ic = do
 		r <- try $ do
diff --git a/Database/Init.hs b/Database/Init.hs
--- a/Database/Init.hs
+++ b/Database/Init.hs
@@ -1,11 +1,11 @@
 {- Persistent sqlite database initialization
  -
- - Copyright 2015-2020 Joey Hess <id@joeyh.name>
+ - Copyright 2015-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
-{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE OverloadedStrings, CPP #-}
 
 module Database.Init where
 
@@ -13,6 +13,9 @@
 import Annex.Perms
 import Utility.FileMode
 import qualified Utility.RawFilePath as R
+#if MIN_VERSION_persistent_sqlite(2,13,3)
+import Database.RawFilePath
+#endif
 
 import Database.Persist.Sqlite
 import Lens.Micro
@@ -32,9 +35,13 @@
 	let dbdir = P.takeDirectory db
 	let tmpdbdir = dbdir <> ".tmp"
 	let tmpdb = tmpdbdir P.</> "db"
-	let tdb = T.pack (fromRawFilePath tmpdb)
+	let tmpdb' = T.pack (fromRawFilePath tmpdb)
 	createAnnexDirectory tmpdbdir
-	liftIO $ runSqliteInfo (enableWAL tdb) migration
+#if MIN_VERSION_persistent_sqlite(2,13,3)
+	liftIO $ runSqliteInfo' tmpdb (enableWAL tmpdb') migration
+#else
+	liftIO $ runSqliteInfo (enableWAL tmpdb') migration
+#endif
 	setAnnexDirPerm tmpdbdir
 	-- Work around sqlite bug that prevents it from honoring
 	-- less restrictive umasks.
diff --git a/Database/RawFilePath.hs b/Database/RawFilePath.hs
new file mode 100644
--- /dev/null
+++ b/Database/RawFilePath.hs
@@ -0,0 +1,95 @@
+{- Persistent sqlite RawFilePath support
+ -
+ - The functions below are copied from persistent-sqlite, but modified to
+ - take a RawFilePath and ignore the sqlConnectionStr from the
+ - SqliteConnectionInfo. This avoids encoding problems using Text
+ - in some situations.
+ -
+ - This module is expected to eventually be supersceded by
+ - persistent-sqlite getting support for OsString.
+ -
+ - Copyright (c) 2012 Michael Snoyman, http://www.yesodweb.com/
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - Permission is hereby granted, free of charge, to any person obtaining
+ - a copy of this software and associated documentation files (the
+ - "Software"), to deal in the Software without restriction, including
+ - without limitation the rights to use, copy, modify, merge, publish,
+ - distribute, sublicense, and/or sell copies of the Software, and to
+ - permit persons to whom the Software is furnished to do so, subject to
+ - the following conditions:
+ - 
+ - The above copyright notice and this permission notice shall be
+ - included in all copies or substantial portions of the Software.
+ - 
+ - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ - LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ - OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+-}
+
+{-# LANGUAGE OverloadedStrings, CPP #-}
+
+module Database.RawFilePath where
+
+#if MIN_VERSION_persistent_sqlite(2,13,3)
+import Database.Persist.Sqlite
+import qualified Database.Sqlite as Sqlite
+import qualified System.FilePath.ByteString as P
+import qualified Control.Exception as E
+import Control.Monad.Logger (MonadLoggerIO)
+import Control.Monad.IO.Unlift (MonadUnliftIO)
+import Control.Monad.Logger (NoLoggingT, runNoLoggingT)
+import Control.Monad.Trans.Reader (ReaderT)
+import UnliftIO.Resource (ResourceT, runResourceT)
+
+openWith'
+	:: P.RawFilePath 
+	-> (SqlBackend -> Sqlite.Connection -> r)
+	-> SqliteConnectionInfo
+	-> LogFunc
+	-> IO r
+openWith' db f connInfo logFunc = do
+	conn <- Sqlite.open' db
+	backend <- wrapConnectionInfo connInfo conn logFunc `E.onException` Sqlite.close conn
+	return $ f backend conn
+
+runSqlite' :: (MonadUnliftIO m)
+	=> P.RawFilePath
+	-> ReaderT SqlBackend (NoLoggingT (ResourceT m)) a
+	-> m a
+runSqlite' connstr = runResourceT
+	. runNoLoggingT
+	. withSqliteConn' connstr
+	. runSqlConn
+
+withSqliteConn'
+	:: (MonadUnliftIO m, MonadLoggerIO m)
+	=> P.RawFilePath
+	-> (SqlBackend -> m a)
+	-> m a
+withSqliteConn' connstr = withSqliteConnInfo' connstr $
+	mkSqliteConnectionInfo mempty
+
+runSqliteInfo'
+	:: (MonadUnliftIO m)
+	=> P.RawFilePath
+	-> SqliteConnectionInfo
+	-> ReaderT SqlBackend (NoLoggingT (ResourceT m)) a
+	-> m a
+runSqliteInfo' db conInfo = runResourceT
+	. runNoLoggingT
+	. withSqliteConnInfo' db conInfo
+	. runSqlConn
+
+withSqliteConnInfo'
+	:: (MonadUnliftIO m, MonadLoggerIO m)
+        => P.RawFilePath
+	-> SqliteConnectionInfo
+	-> (SqlBackend -> m a)
+	-> m a
+withSqliteConnInfo' db = withSqlConn . openWith' db const
+#endif
diff --git a/Git/Log.hs b/Git/Log.hs
--- a/Git/Log.hs
+++ b/Git/Log.hs
@@ -10,12 +10,13 @@
 import Common
 import Git
 import Git.Command
+import Git.Sha
 
 import Data.Time
 import Data.Time.Clock.POSIX
 
 -- A change made to a file.
-data RefChange t = RefChange
+data LoggedFileChange t = LoggedFileChange
 	{ changetime :: POSIXTime
 	, changed :: t
 	, changedfile :: FilePath
@@ -24,18 +25,21 @@
 	}
 	deriving (Show)
 
--- Get the git log. Note that the returned cleanup action should only be
+-- Get the git log of changes to files. 
+-- 
+-- Note that the returned cleanup action should only be
 -- run after processing the returned list.
 getGitLog
 	:: Ref
+	-> Maybe Ref
 	-> [FilePath]
 	-> [CommandParam]
-	-> (FilePath -> Maybe t)
+	-> (Sha -> FilePath -> Maybe t)
 	-> Repo
-	-> IO ([RefChange t], IO Bool)
-getGitLog ref fs os fileselector repo = do
+	-> IO ([LoggedFileChange t], IO Bool)
+getGitLog ref stopref fs os selector repo = do
 	(ls, cleanup) <- pipeNullSplit ps repo
-	return (parseGitRawLog fileselector (map decodeBL ls), cleanup)
+	return (parseGitRawLog selector (map decodeBL ls), cleanup)
   where
 	ps =
 		[ Param "log"
@@ -45,14 +49,16 @@
 		, Param "--no-abbrev"
 		, Param "--no-renames"
 		] ++ os ++
-		[ Param (fromRef ref)
+		[ case stopref of
+			Just stopref' -> Param $
+				fromRef stopref' <> ".." <> fromRef ref
+			Nothing -> Param (fromRef ref)
 		, Param "--"
 		] ++ map Param fs 
 
--- The commitinfo is the timestamp of the commit, followed by
--- the commit hash and then the commit's parents, separated by spaces.
+-- The commitinfo is the commit hash followed by its timestamp.
 commitinfoFormat :: String
-commitinfoFormat = "%ct"
+commitinfoFormat = "%H %ct"
 
 -- Parses chunked git log --raw output generated by getGitLog, 
 -- which looks something like:
@@ -69,22 +75,24 @@
 --
 -- The commitinfo is not included before all changelines, so
 -- keep track of the most recently seen commitinfo.
-parseGitRawLog :: (FilePath -> Maybe t) -> [String] -> [RefChange t]
-parseGitRawLog fileselector = parse epoch
+parseGitRawLog :: (Ref -> FilePath -> Maybe t) -> [String] -> [LoggedFileChange t]
+parseGitRawLog selector = parse (deleteSha, epoch)
   where
 	epoch = toEnum 0 :: POSIXTime
-	parse oldts ([]:rest) = parse oldts rest
-	parse oldts (c1:c2:rest) = case mrc of
-		Just rc -> rc : parse ts rest
-		Nothing -> parse ts (c2:rest)
+	parse old ([]:rest) = parse old rest
+	parse (oldcommitsha, oldts) (c1:c2:rest) = case mrc of
+		Just rc -> rc : parse (commitsha, ts) rest
+		Nothing -> parse (commitsha, ts) (c2:rest)
 	  where
-		(ts, cl) = case separate (== '\n') c1 of
-			(cl', []) -> (oldts, cl')
-			(tss, cl') -> (parseTimeStamp tss, cl')
+		(commitsha, ts, cl) = case separate (== '\n') c1 of
+			(cl', []) -> (oldcommitsha, oldts, cl')
+			(ci, cl') -> case words ci of
+				(css:tss:[]) -> (Ref (encodeBS css), parseTimeStamp tss, cl')
+				_ -> (oldcommitsha, oldts, cl')
 	  	mrc = do
 			(old, new) <- parseRawChangeLine cl
-			v <- fileselector c2
-			return $ RefChange
+			v <- selector commitsha c2
+			return $ LoggedFileChange
 				{ changetime = ts
 				, changed = v
 				, changedfile = c2
diff --git a/Git/Tree.hs b/Git/Tree.hs
--- a/Git/Tree.hs
+++ b/Git/Tree.hs
@@ -23,6 +23,8 @@
 	graftTree',
 	withMkTreeHandle,
 	MkTreeHandle,
+	sendMkTree,
+	finishMkTree,
 	treeMode,
 ) where
 
@@ -101,18 +103,27 @@
 	sha <- mkTree h =<< mapM (recordSubTree h) l
 	return (RecordedSubTree d sha [])
 recordSubTree _ alreadyrecorded = return alreadyrecorded
- 
+
+sendMkTree :: MkTreeHandle -> FileMode -> ObjectType -> Sha -> TopFilePath -> IO ()
+sendMkTree (MkTreeHandle cp) fm ot s f =
+	CoProcess.send cp $ \h -> 
+		hPutStr h (mkTreeOutput fm ot s f)
+
+finishMkTree :: MkTreeHandle -> IO Sha
+finishMkTree (MkTreeHandle cp) = do
+	CoProcess.send cp $ \h ->
+		-- NUL to signal end of tree to --batch
+		hPutStr h "\NUL"
+	getSha "mktree" (CoProcess.receive cp S8.hGetLine)
+
 mkTree :: MkTreeHandle -> [TreeContent] -> IO Sha
-mkTree (MkTreeHandle cp) l = CoProcess.query cp send receive
-  where
-	send h = do
-		forM_ l $ \i ->	hPutStr h $ case i of
-			TreeBlob f fm s -> mkTreeOutput fm BlobObject s f
-			RecordedSubTree f s _ -> mkTreeOutput treeMode TreeObject s f
-			NewSubTree _ _ -> error "recordSubTree internal error; unexpected NewSubTree"
-			TreeCommit f fm s -> mkTreeOutput fm CommitObject s f
-		hPutStr h "\NUL" -- signal end of tree to --batch
-	receive h = getSha "mktree" (S8.hGetLine h)
+mkTree h l = do
+	forM_ l $ \case
+		TreeBlob f fm s -> sendMkTree h fm BlobObject s f
+		RecordedSubTree f s _ -> sendMkTree h treeMode TreeObject s f
+		NewSubTree _ _ -> error "recordSubTree internal error; unexpected NewSubTree"
+		TreeCommit f fm s -> sendMkTree h fm CommitObject s f
+	finishMkTree h
 
 treeMode :: FileMode
 treeMode = 0o040000
diff --git a/Logs.hs b/Logs.hs
--- a/Logs.hs
+++ b/Logs.hs
@@ -1,6 +1,6 @@
 {- git-annex log file names
  -
- - Copyright 2013-2021 Joey Hess <id@joeyh.name>
+ - Copyright 2013-2023 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -155,6 +155,11 @@
  - the git-annex branch. -}
 exportTreeGraftPoint :: RawFilePath
 exportTreeGraftPoint = "export.tree"
+
+{- This is not a log file, it's where migration treeishes get grafted into
+ - the git-annex branch. -}
+migrationTreeGraftPoint :: RawFilePath
+migrationTreeGraftPoint = "migrate.tree"
 
 {- The pathname of the location log file for a given key. -}
 locationLogFile :: GitConfig -> Key -> RawFilePath
diff --git a/Logs/Export.hs b/Logs/Export.hs
--- a/Logs/Export.hs
+++ b/Logs/Export.hs
@@ -78,7 +78,7 @@
 -- repository, the tree has to be kept available, even if it
 -- doesn't end up being merged into the master branch.
 recordExportTreeish :: Git.Ref -> Annex ()
-recordExportTreeish t = 
+recordExportTreeish t = void $
 	Annex.Branch.rememberTreeish t (asTopFilePath exportTreeGraftPoint)
 
 -- | Record that an export to a special remote is under way.
diff --git a/Logs/Location.hs b/Logs/Location.hs
--- a/Logs/Location.hs
+++ b/Logs/Location.hs
@@ -21,6 +21,7 @@
 	logStatusAfter,
 	logChange,
 	loggedLocations,
+	loggedPreviousLocations,
 	loggedLocationsHistorical,
 	loggedLocationsRef,
 	parseLoggedLocations,
@@ -79,7 +80,13 @@
 {- Returns a list of repository UUIDs that, according to the log, have
  - the value of a key. -}
 loggedLocations :: Key -> Annex [UUID]
-loggedLocations = getLoggedLocations currentLogInfo
+loggedLocations = getLoggedLocations presentLogInfo
+
+{- Returns a list of repository UUIDs that the location log indicates
+ - used to have the vale of a key, but no longer do.
+ -}
+loggedPreviousLocations :: Key -> Annex [UUID]
+loggedPreviousLocations = getLoggedLocations notPresentLogInfo
 
 {- Gets the location log on a particular date. -}
 loggedLocationsHistorical :: RefDate -> Key -> Annex [UUID]
diff --git a/Logs/Migrate.hs b/Logs/Migrate.hs
new file mode 100644
--- /dev/null
+++ b/Logs/Migrate.hs
@@ -0,0 +1,203 @@
+{- git-annex migration logs
+ -
+ - To record a migration in the git-annex branch as space efficiently as
+ - possible, it is stored as a tree which contains two subtrees 'old' and 'new'.
+ - The subtrees each contain the same filenames, which point to the old
+ - and new keys respectively.
+ -
+ - When the user commits the migrated files to their HEAD branch, that will
+ - store pointers to the new keys in git. And pointers to the old keys
+ - already exist in git. So recording the migration this way avoids
+ - injecting any new objects into git, besides the two trees. Note that for
+ - this to be the case, care has to be taken to record the migration 
+ - using the same symlink targets or pointer file contents as are used in
+ - the HEAD branch.
+ -
+ - The filenames used in the trees are not the original filenames, to avoid
+ - running migrate in a throwaway branch unexpectedly recording that
+ - branch's contents.
+ -
+ - There are two local log files:
+ - * migrate.log contains pairs of old and new keys, and is used while
+ -   performing a new migration, to build up a migration to commit.
+ -   This allows an interrupted migration to be resumed later.
+ - * migrations.log has as its first line a commit to the git-annex branch
+ -   up to which all migrations have been performed locally (including any
+ -   migrations in parent commits). Or the first line may be a null sha when
+ -   this has not been done yet. The rest of the lines in the file
+ -   are commits that have been made for locally performed migrations,
+ -   but whose parent commits have not necessarily been checked for
+ -   migrations yet.
+ -
+ - Copyright 2023 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+{-# LANGUAGE OverloadedStrings, BangPatterns #-}
+
+module Logs.Migrate (
+	MigrationRecord(..),
+	logMigration,
+	commitMigration,
+	streamNewDistributedMigrations,
+) where
+
+import Annex.Common
+import qualified Git
+import qualified Annex
+import qualified Annex.Branch
+import Git.Types
+import Git.Tree
+import Git.FilePath
+import Git.Ref
+import Git.Sha
+import Git.Log
+import Logs.File
+import Logs
+import Annex.CatFile
+
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Lazy as L
+import Control.Concurrent.STM
+import System.FilePath.ByteString as P
+
+-- | What to use to record a migration. This should be the same Sha that is
+-- used to as the content of the annexed file in the HEAD branch.
+newtype MigrationRecord = MigrationRecord { fromMigrationRecord :: Git.Sha }
+
+-- | Logs a migration from an old to a new key.
+logMigration :: MigrationRecord -> MigrationRecord -> Annex ()
+logMigration old new = do
+	logf <- fromRepo gitAnnexMigrateLog
+	lckf <- fromRepo gitAnnexMigrateLock
+	appendLogFile logf lckf $ L.fromStrict $
+		Git.fromRef' (fromMigrationRecord old)
+			<> " "
+			<> Git.fromRef' (fromMigrationRecord new)
+
+-- | Commits a migration to the git-annex branch.
+commitMigration :: Annex ()
+commitMigration = do
+	logf <- fromRawFilePath <$> fromRepo gitAnnexMigrateLog
+	lckf <- fromRepo gitAnnexMigrateLock
+	nv <- liftIO $ newTVarIO (0 :: Integer)
+	g <- Annex.gitRepo
+	withMkTreeHandle g $ \oldh ->
+		withMkTreeHandle g $ \newh ->
+			streamLogFile logf lckf 
+				(finalizer nv oldh newh g)
+				(processor nv oldh newh)
+  where
+	processor nv oldh newh s = case words s of
+		(old:new:[]) -> do
+			fn <- liftIO $ atomically $ do
+				n <- readTVar nv
+				let !n' = succ n
+				writeTVar nv n'
+				return (asTopFilePath (encodeBS (show n')))
+			let rec h r = liftIO $ sendMkTree h
+				(fromTreeItemType TreeFile)
+				BlobObject
+				(Git.Ref (encodeBS r))
+				fn
+			rec oldh old
+			rec newh new
+		_ -> error "migrate.log parse error"
+	finalizer nv oldh newh g = do
+		oldt <- liftIO $ finishMkTree oldh
+		newt <- liftIO $ finishMkTree newh
+		n <- liftIO $ atomically $ readTVar nv
+		when (n > 0) $ do
+			treesha <- liftIO $ flip recordTree g $ Tree
+				[ RecordedSubTree (asTopFilePath "old") oldt []
+				, RecordedSubTree (asTopFilePath "new") newt []
+				]
+			commitsha <- Annex.Branch.rememberTreeish treesha
+				(asTopFilePath migrationTreeGraftPoint)
+			committedMigration commitsha
+
+-- Streams distributed migrations from the git-annex branch,
+-- and runs the provided action on each old and new key pair.
+--
+-- With the incremental option, only scans as far as the last recorded
+-- migration that this has handled before.
+streamNewDistributedMigrations :: Bool -> (Key -> Key -> Annex ()) -> Annex ()
+streamNewDistributedMigrations incremental a = do
+	void Annex.Branch.update
+	branchsha <- Annex.Branch.getBranch
+	(stoppoint, toskip) <- getPerformedMigrations
+	(l, cleanup) <- inRepo $ getGitLog branchsha
+		(if incremental then stoppoint else Nothing)
+		[fromRawFilePath migrationTreeGraftPoint]
+		-- Need to follow because migrate.tree is grafted in 
+		-- and then deleted, and normally git log stops when a file
+		-- gets deleted.
+		([Param "--reverse", Param "--follow"])
+		(\commit _file -> Just commit)
+	forM_ l (go toskip)
+	liftIO $ void cleanup
+	recordPerformedMigrations branchsha toskip
+  where
+	go toskip c
+		| newref c `elem` nullShas = return ()
+		| changed c `elem` toskip = return ()
+		| not ("/new/" `B.isInfixOf` newfile) = return ()
+		| otherwise = 
+			catKey (newref c) >>= \case
+				Nothing -> return ()
+				Just newkey -> catKey oldfileref >>= \case
+					Nothing -> return ()
+					Just oldkey -> a oldkey newkey
+	  where
+		newfile = toRawFilePath (changedfile c)
+		oldfile = migrationTreeGraftPoint 
+			P.</> "old" 
+			P.</> P.takeBaseName (fromInternalGitPath newfile)
+		oldfileref = branchFileRef (changed c) oldfile
+
+getPerformedMigrations :: Annex (Maybe Sha, [Sha])
+getPerformedMigrations = do
+	logf <- fromRepo gitAnnexMigrationsLog
+	lckf <- fromRepo gitAnnexMigrationsLock
+	ls <- calcLogFile logf lckf [] (:)
+	return $ case reverse ls of
+		[] -> (Nothing, [])
+		(stoppoint:toskip) ->
+			let stoppoint' = conv stoppoint
+			in 
+				( if stoppoint' `elem` nullShas
+					then Nothing
+					else Just stoppoint'
+				, map conv toskip
+				)
+  where
+	conv = Git.Ref . L.toStrict
+
+-- Record locally that migrations have been performed up to the given
+-- commit. The list is additional commits that can be removed from the
+-- log file if present.
+recordPerformedMigrations :: Sha -> [Sha] -> Annex ()
+recordPerformedMigrations commit toremove = do
+	logf <- fromRepo gitAnnexMigrationsLog
+	lckf <- fromRepo gitAnnexMigrationsLock
+	modifyLogFile logf lckf (update . drop 1)
+  where
+	update l = L.fromStrict (fromRef' commit) : filter (`notElem` toremove') l
+	toremove' = map (L.fromStrict . fromRef') toremove
+
+-- Record that a migration was performed locally and committed.
+-- Since committing a migration may result in parent migrations that have
+-- not yet been processed locally, that commit cannot be the first line of
+-- the log file, which is reserved for commits whose parents have also had
+-- their migrations handled. So if the log file does not exist or is empty,
+-- make the first line a null sha.
+committedMigration :: Sha -> Annex ()
+committedMigration commitsha = do
+	logf <- fromRepo gitAnnexMigrationsLog
+	lckf <- fromRepo gitAnnexMigrationsLock
+	modifyLogFile logf lckf update
+  where
+	update [] = [conv deleteSha, conv commitsha]
+	update logged = logged ++ [conv commitsha]
+	conv = L.fromStrict . fromRef'
diff --git a/Logs/PreferredContent.hs b/Logs/PreferredContent.hs
--- a/Logs/PreferredContent.hs
+++ b/Logs/PreferredContent.hs
@@ -82,7 +82,7 @@
 
 preferredRequiredMapsLoad :: (PreferredContentData -> [ParseToken (MatchFiles Annex)]) -> Annex (FileMatcherMap Annex, FileMatcherMap Annex)
 preferredRequiredMapsLoad mktokens = do
-	(pc, rc) <- preferredRequiredMapsLoad' mktokens
+	(pc, rc) <- preferredRequiredMapsLoad' id mktokens
 	let pc' = handleunknown (MatcherDesc "preferred content") pc
 	let rc' = handleunknown (MatcherDesc "required content") rc
 	Annex.changeState $ \s -> s
@@ -94,12 +94,12 @@
 	handleunknown matcherdesc = M.mapWithKey $ \u v ->
 		(either (const $ unknownMatcher u) id v, matcherdesc)
 
-preferredRequiredMapsLoad' :: (PreferredContentData -> [ParseToken (MatchFiles Annex)]) -> Annex (M.Map UUID (Either String (Matcher (MatchFiles Annex))), M.Map UUID (Either String (Matcher (MatchFiles Annex))))
-preferredRequiredMapsLoad' mktokens = do
+preferredRequiredMapsLoad' :: (Matcher (MatchFiles Annex) -> Matcher (MatchFiles Annex)) -> (PreferredContentData -> [ParseToken (MatchFiles Annex)]) -> Annex (M.Map UUID (Either String (Matcher (MatchFiles Annex))), M.Map UUID (Either String (Matcher (MatchFiles Annex))))
+preferredRequiredMapsLoad' matcherf mktokens = do
 	groupmap <- groupMap
 	configmap <- remoteConfigMap
 	let genmap l gm = 
-		let mk u = makeMatcher groupmap configmap gm u mktokens
+		let mk u = makeMatcher groupmap configmap gm u matcherf mktokens
 		in simpleMap
 			. parseLogOldWithUUID (\u -> mk u . decodeBS <$> A.takeByteString)
 			<$> Annex.Branch.get l
@@ -123,13 +123,14 @@
 	-> M.Map UUID RemoteConfig
 	-> M.Map Group PreferredContentExpression
 	-> UUID
+	-> (Matcher (MatchFiles Annex) -> Matcher (MatchFiles Annex))
 	-> (PreferredContentData -> [ParseToken (MatchFiles Annex)])
 	-> PreferredContentExpression
 	-> Either String (Matcher (MatchFiles Annex))
-makeMatcher groupmap configmap groupwantedmap u mktokens = go True True
+makeMatcher groupmap configmap groupwantedmap u matcherf mktokens = go True True
   where
 	go expandstandard expandgroupwanted expr
-		| null (lefts tokens) = Right $ generate $ rights tokens
+		| null (lefts tokens) = Right $ matcherf $ generate $ rights tokens
 		| otherwise = Left $ unwords $ lefts tokens
 	  where
 		tokens = preferredContentParser (mktokens pcd) expr
diff --git a/Logs/Presence.hs b/Logs/Presence.hs
--- a/Logs/Presence.hs
+++ b/Logs/Presence.hs
@@ -17,8 +17,8 @@
 	addLog',
 	maybeAddLog,
 	readLog,
-	currentLog,
-	currentLogInfo,
+	presentLogInfo,
+	notPresentLogInfo,
 	historicalLogInfo,
 ) where
 
@@ -68,12 +68,13 @@
 readLog :: RawFilePath -> Annex [LogLine]
 readLog = parseLog <$$> Annex.Branch.get
 
-{- Reads a log and returns only the info that is still in effect. -}
-currentLogInfo :: RawFilePath -> Annex [LogInfo]
-currentLogInfo file = map info <$> currentLog file
+{- Reads a log and returns only the info that is still present. -}
+presentLogInfo :: RawFilePath -> Annex [LogInfo]
+presentLogInfo file = map info . filterPresent <$> readLog file
 
-currentLog :: RawFilePath -> Annex [LogLine]
-currentLog file = filterPresent <$> readLog file
+{- Reads a log and returns only the info that is no longer present. -}
+notPresentLogInfo :: RawFilePath -> Annex [LogInfo]
+notPresentLogInfo file = map info . filterNotPresent <$> readLog file
 
 {- Reads a historical version of a log and returns the info that was in
  - effect at that time. 
diff --git a/Logs/Presence/Pure.hs b/Logs/Presence/Pure.hs
--- a/Logs/Presence/Pure.hs
+++ b/Logs/Presence/Pure.hs
@@ -70,13 +70,17 @@
 	genstatus InfoMissing = charUtf8 '0'
 	genstatus InfoDead = charUtf8 'X'
 
-{- Given a log, returns only the info that is are still in effect. -}
+{- Given a log, returns only the info that is still present. -}
 getLog :: L.ByteString -> [LogInfo]
 getLog = map info . filterPresent . parseLog
 
-{- Returns the info from LogLines that are in effect. -}
+{- Returns the info from LogLines that is present. -}
 filterPresent :: [LogLine] -> [LogLine]
 filterPresent = filter (\l -> InfoPresent == status l) . compactLog
+
+{- Returns the info from LogLines that is not present. -}
+filterNotPresent :: [LogLine] -> [LogLine]
+filterNotPresent = filter (\l -> InfoPresent /= status l) . compactLog
 
 {- Compacts a set of logs, returning a subset that contains the current
  - status. -}
diff --git a/Logs/Web.hs b/Logs/Web.hs
--- a/Logs/Web.hs
+++ b/Logs/Web.hs
@@ -55,7 +55,7 @@
   where
 	go [] = return []
 	go (l:ls) = do
-		us <- currentLogInfo l
+		us <- presentLogInfo l
 		if null us
 			then go ls
 			else return $ map fromLogInfo us
diff --git a/Remote.hs b/Remote.hs
--- a/Remote.hs
+++ b/Remote.hs
@@ -47,6 +47,7 @@
 	remotesWithUUID,
 	remotesWithoutUUID,
 	keyLocations,
+	IncludeIgnored(..),
 	keyPossibilities,
 	remoteLocations,
 	nameToUUID,
@@ -299,13 +300,16 @@
 keyLocations :: Key -> Annex [UUID]
 keyLocations key = trustExclude DeadTrusted =<< loggedLocations key
 
+{- Whether to include remotes that have annex-ignore set. -}
+newtype IncludeIgnored = IncludeIgnored Bool
+
 {- Cost ordered lists of remotes that the location log indicates
  - may have a key.
  -
  - Also includes remotes with remoteAnnexSpeculatePresent set.
  -}
-keyPossibilities :: Key -> Annex [Remote]
-keyPossibilities key = do
+keyPossibilities :: IncludeIgnored -> Key -> Annex [Remote]
+keyPossibilities ii key = do
 	u <- getUUID
 	-- uuids of all remotes that are recorded to have the key
 	locations <- filter (/= u) <$> keyLocations key
@@ -315,19 +319,21 @@
 	-- there are unlikely to be many speclocations, so building a Set
 	-- is not worth the expense
 	let locations' = speclocations ++ filter (`notElem` speclocations) locations
-	fst <$> remoteLocations locations' []
+	fst <$> remoteLocations ii locations' []
 
 {- Given a list of locations of a key, and a list of all
  - trusted repositories, generates a cost-ordered list of
  - remotes that contain the key, and a list of trusted locations of the key.
  -}
-remoteLocations :: [UUID] -> [UUID] -> Annex ([Remote], [UUID])
-remoteLocations locations trusted = do
+remoteLocations :: IncludeIgnored -> [UUID] -> [UUID] -> Annex ([Remote], [UUID])
+remoteLocations (IncludeIgnored ii) locations trusted = do
 	let validtrustedlocations = nub locations `intersect` trusted
 
 	-- remotes that match uuids that have the key
 	allremotes <- remoteList 
-		>>= filterM (not <$$> liftIO . getDynamicConfig . remoteAnnexIgnore . gitconfig)
+		>>= if not ii
+			then filterM (not <$$> liftIO . getDynamicConfig . remoteAnnexIgnore . gitconfig)
+			else return
 	let validremotes = remotesWithUUID allremotes locations
 
 	return (sortBy (comparing cost) validremotes, validtrustedlocations)
diff --git a/Types/GitConfig.hs b/Types/GitConfig.hs
--- a/Types/GitConfig.hs
+++ b/Types/GitConfig.hs
@@ -94,6 +94,7 @@
 	, annexResolveMerge :: GlobalConfigurable Bool
 	, annexSyncContent :: GlobalConfigurable (Maybe Bool)
 	, annexSyncOnlyAnnex :: GlobalConfigurable Bool
+	, annexSyncMigrations :: Bool
 	, annexDebug :: Bool
 	, annexDebugFilter :: Maybe String
 	, annexWebOptions :: [String]
@@ -184,6 +185,7 @@
 		getmaybebool (annexConfig "synccontent")
 	, annexSyncOnlyAnnex = configurable False $ 
 		getmaybebool (annexConfig "synconlyannex")
+	, annexSyncMigrations = getbool (annexConfig "syncmigrations") True
 	, annexDebug = getbool (annexConfig "debug") False
 	, annexDebugFilter = getmaybe (annexConfig "debugfilter")
 	, annexWebOptions = getwords (annexConfig "web-options")
diff --git a/Utility/CoProcess.hs b/Utility/CoProcess.hs
--- a/Utility/CoProcess.hs
+++ b/Utility/CoProcess.hs
@@ -1,7 +1,7 @@
 {- Interface for running a shell command as a coprocess,
  - sending it queries and getting back results.
  -
- - Copyright 2012-2013 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2023 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
@@ -14,11 +14,14 @@
 	start,
 	stop,
 	query,
+	send,
+	receive,
 ) where
 
 import Common
 
 import Control.Concurrent.MVar
+import Control.Monad.IO.Class (MonadIO)
 
 type CoProcessHandle = MVar CoProcessState
 
@@ -65,12 +68,12 @@
 {- To handle a restartable process, any IO exception thrown by the send and
  - receive actions are assumed to mean communication with the process
  - failed, and the failed action is re-run with a new process. -}
-query :: CoProcessHandle -> (Handle -> IO a) -> (Handle -> IO b) -> IO b
-query ch send receive = do
-	s <- readMVar ch
-	restartable s (send $ coProcessTo s) $ const $
-		restartable s (hFlush $ coProcessTo s) $ const $
-			restartable s (receive $ coProcessFrom s)
+query :: (MonadIO m, MonadCatch m) => CoProcessHandle -> (Handle -> m a) -> (Handle -> m b) -> m b
+query ch sender receiver = do
+	s <- liftIO $ readMVar ch
+	restartable s (sender $ coProcessTo s) $ const $
+		restartable s (liftIO $ hFlush $ coProcessTo s) $ const $
+			restartable s (receiver $ coProcessFrom s)
 				return
   where
 	restartable s a cont
@@ -78,12 +81,23 @@
 			maybe restart cont =<< catchMaybeIO a
 		| otherwise = cont =<< a
 	restart = do
-		s <- takeMVar ch
-		void $ catchMaybeIO $ do
+		s <- liftIO $ takeMVar ch
+		void $ liftIO $ catchMaybeIO $ do
 			hClose $ coProcessTo s
 			hClose $ coProcessFrom s
-		void $ waitForProcess $ coProcessPid s
-		s' <- start' $ (coProcessSpec s)
+		void $ liftIO $ waitForProcess $ coProcessPid s
+		s' <- liftIO $ start' $ (coProcessSpec s)
 			{ coProcessNumRestarts = coProcessNumRestarts (coProcessSpec s) - 1 }
-		putMVar ch s'
-		query ch send receive
+		liftIO $ putMVar ch s'
+		query ch sender receiver
+
+send :: MonadIO m => CoProcessHandle -> (Handle -> m a) -> m a
+send ch a = do
+	s <- liftIO $ readMVar ch
+	a (coProcessTo s)
+
+receive :: MonadIO m => CoProcessHandle -> (Handle -> m a) -> m a
+receive ch a = do
+	s <- liftIO $ readMVar ch
+	liftIO $ hFlush $ coProcessTo s
+	a (coProcessFrom s)
diff --git a/Utility/Matcher.hs b/Utility/Matcher.hs
--- a/Utility/Matcher.hs
+++ b/Utility/Matcher.hs
@@ -24,6 +24,7 @@
 	MatchResult(..),
 	syntaxToken,
 	generate,
+	pruneMatcher,
 	match,
 	match',
 	matchM,
@@ -98,6 +99,26 @@
 	simplify (MOr x y) = MOr (simplify x) (simplify y)
 	simplify (MNot x) = MNot (simplify x)
 	simplify x = x
+
+{- Prunes selected ops from the Matcher. -}
+pruneMatcher :: (op -> Bool) -> Matcher op -> Matcher op
+pruneMatcher f = fst . go
+  where
+	go MAny = (MAny, False)
+	go (MAnd a b) = go2 a b MAnd
+	go (MOr a b) = go2 a b MOr
+	go (MNot a) = case go a of
+		(_, True)  -> (MAny, True)
+		(a', False) -> (MNot a', False)
+	go (MOp op)
+		| f op = (MAny, True)
+		| otherwise = (MOp op, False)
+
+	go2 a b g = case (go a, go b) of
+		((_,  True),  (_,  True))  -> (MAny, True)
+		((a', False), (b', False)) -> (g a' b', False)
+		((_,  True),  (b', False)) -> (b', False)
+		((a', False), (_,  True))  -> (a', False)
 
 data TokenGroup op = One (Token op) | Group [TokenGroup op]
 	deriving (Show, Eq)
diff --git a/Utility/TimeStamp.hs b/Utility/TimeStamp.hs
--- a/Utility/TimeStamp.hs
+++ b/Utility/TimeStamp.hs
@@ -1,14 +1,17 @@
 {- timestamp parsing and formatting
  -
- - Copyright 2015-2019 Joey Hess <id@joeyh.name>
+ - Copyright 2015-2023 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
 
+{-# LANGUAGE CPP #-}
+
 module Utility.TimeStamp (
 	parserPOSIXTime,
 	parsePOSIXTime,
 	formatPOSIXTime,
+	truncateResolution,
 ) where
 
 import Utility.Data
@@ -56,3 +59,14 @@
 
 formatPOSIXTime :: String -> POSIXTime -> String
 formatPOSIXTime fmt t = formatTime defaultTimeLocale fmt (posixSecondsToUTCTime t)
+
+{- Truncate the resolution to the specified number of decimal places. -}
+truncateResolution :: Int -> POSIXTime -> POSIXTime
+#if MIN_VERSION_time(1,9,1)
+truncateResolution n t = secondsToNominalDiffTime $
+	fromIntegral ((truncate (nominalDiffTimeToSeconds t * d)) :: Integer) / d
+  where
+	d = 10 ^ n
+#else
+truncateResolution _ t = t
+#endif
diff --git a/Utility/Tmp.hs b/Utility/Tmp.hs
--- a/Utility/Tmp.hs
+++ b/Utility/Tmp.hs
@@ -105,7 +105,12 @@
  -}
 relatedTemplate :: FilePath -> FilePath
 relatedTemplate f
-	| len > 20 = truncateFilePath (len - 20) f
+	| len > 20 = 
+		{- Some filesystems like FAT have issues with filenames
+		 - ending in ".", so avoid truncating a filename to end
+		 - that way. -}
+		reverse $ dropWhile (== '.') $ reverse $
+			truncateFilePath (len - 20) f
 	| otherwise = f
   where
 	len = length f
diff --git a/git-annex.cabal b/git-annex.cabal
--- a/git-annex.cabal
+++ b/git-annex.cabal
@@ -1,5 +1,5 @@
 Name: git-annex
-Version: 10.20231129
+Version: 10.20231227
 Cabal-Version: 1.12
 License: AGPL-3
 Maintainer: Joey Hess <id@joeyh.name>
@@ -746,6 +746,7 @@
     Database.Keys.Tables
     Database.Keys.SQL
     Database.Queue
+    Database.RawFilePath
     Database.Types
     Database.Utility
     Git
@@ -821,6 +822,7 @@
     Logs.MapLog
     Logs.MetaData
     Logs.MetaData.Pure
+    Logs.Migrate
     Logs.Multicast
     Logs.NumCopies
     Logs.PreferredContent
diff --git a/stack.yaml b/stack.yaml
--- a/stack.yaml
+++ b/stack.yaml
@@ -12,7 +12,7 @@
     crypton: false
 packages:
 - '.'
-resolver: nightly-2023-08-01
+resolver: lts-22.3
 extra-deps:
 - git-lfs-1.2.1
 - http-client-restricted-0.1.0
