diff --git a/Annex/Balanced.hs b/Annex/Balanced.hs
--- a/Annex/Balanced.hs
+++ b/Annex/Balanced.hs
@@ -9,7 +9,7 @@
 
 import Key
 import Types.UUID
-import Utility.Hash
+import Utility.HMAC
 
 import Data.Maybe
 import qualified Data.List as L
diff --git a/Annex/CopyFile.hs b/Annex/CopyFile.hs
--- a/Annex/CopyFile.hs
+++ b/Annex/CopyFile.hs
@@ -15,7 +15,7 @@
 import Utility.CopyFile
 import Utility.FileMode
 import Utility.Touch
-import Utility.Hash (IncrementalVerifier(..))
+import Utility.Hash.Incremental
 import qualified Utility.FileIO as F
 import qualified Utility.RawFilePath as R
 
diff --git a/Annex/DirHashes.hs b/Annex/DirHashes.hs
--- a/Annex/DirHashes.hs
+++ b/Annex/DirHashes.hs
@@ -21,7 +21,6 @@
 import Data.Bits
 import qualified Data.List.NonEmpty as NE
 import qualified Data.ByteArray as BA
-import qualified Data.ByteArray.Encoding as BA
 import qualified Data.ByteString as S
 
 import Common
@@ -71,11 +70,8 @@
 	(h, t) = S.splitAt sz s
 
 hashDirLower :: HashLevels -> Hasher
-hashDirLower n k = hashDirs n 3 $ S.pack $ take 6 $ conv $
+hashDirLower n k = hashDirs n 3 $ S.take 6 $ hashByteString $ digestToHash $
 	md5s $ serializeKey' $ nonChunkKey k
-  where
-	conv v = BA.unpack $
-		(BA.convertToBase BA.Base16 v :: BA.Bytes)
 
 {- This was originally using Data.Hash.MD5 from MissingH. This new version
 - is faster, but ugly as it has to replicate the 4 Word32's that produced. -}
@@ -83,7 +79,7 @@
 hashDirMixed n k = hashDirs n 2 $ S.pack $ take 4 $
 	concatMap display_32bits_as_dir $
 		encodeWord32 $ map fromIntegral $ BA.unpack $
-			Utility.Hash.md5s $ serializeKey' $ nonChunkKey k
+			md5s $ serializeKey' $ nonChunkKey k
   where
 	encodeWord32 (b1:b2:b3:b4:rest) =
 		(shiftL b4 24 .|. shiftL b3 16 .|. shiftL b2 8 .|. b1)
diff --git a/Annex/Ssh.hs b/Annex/Ssh.hs
--- a/Annex/Ssh.hs
+++ b/Annex/Ssh.hs
@@ -351,7 +351,8 @@
 	fromSshHost host ++ "!" ++ show port
 hostport2socket' :: String -> OsPath
 hostport2socket' s
-	| length s' > lengthofmd5s = toOsPath $ show $ md5 $ encodeBL s'
+	| length s' > lengthofmd5s =
+		toOsPath $ hashByteString $ digestToHash $ md5 $ encodeBL s'
 	| otherwise = toOsPath s'
   where
 	lengthofmd5s = 32
diff --git a/Annex/Url.hs b/Annex/Url.hs
--- a/Annex/Url.hs
+++ b/Annex/Url.hs
@@ -37,7 +37,7 @@
 import qualified Utility.Url as U
 import qualified Utility.Url.Parse as U
 import Annex.Hook
-import Utility.Hash (IncrementalVerifier)
+import Utility.Hash.Incremental
 import Utility.IPAddress
 import Utility.Metered
 import Utility.Env
diff --git a/Annex/VariantFile.hs b/Annex/VariantFile.hs
--- a/Annex/VariantFile.hs
+++ b/Annex/VariantFile.hs
@@ -44,5 +44,5 @@
   where
 	doubleconflict = variantMarker `OS.isInfixOf` file
 
-shortHash :: S.ByteString -> String
-shortHash = take 4 . show . md5s
+shortHash :: S.ByteString -> S.ByteString
+shortHash = S.take 4 . hashByteString . digestToHash . md5s
diff --git a/Annex/Verify.hs b/Annex/Verify.hs
--- a/Annex/Verify.hs
+++ b/Annex/Verify.hs
@@ -34,7 +34,7 @@
 import qualified Types.Backend
 import qualified Backend
 import Types.Remote (unVerified, Verification(..), RetrievalSecurityPolicy(..))
-import Utility.Hash (IncrementalVerifier(..))
+import Utility.Hash.Incremental
 import Utility.Metered
 import Annex.WorkerPool
 import Types.WorkerPool
@@ -102,9 +102,6 @@
 				resumeVerifyKeyContent k f iv
 			_ -> verifyKeyContent k f
 
--- When possible, does an incremental verification, because that can be
--- faster. Eg, the VURL backend can need to try multiple checksums and only
--- with an incremental verification does it avoid reading files twice.
 verifyKeyContent :: Key -> OsPath -> Annex Bool
 verifyKeyContent k f = verifyKeySize k f <&&> verifyKeyContent' k f
 
@@ -115,6 +112,8 @@
 		Nothing -> return True
 		Just b -> case (Types.Backend.verifyKeyContentIncrementally b, Types.Backend.verifyKeyContent b) of
 			(Nothing, Nothing) -> return True
+			(_, Just verifier) | Types.Backend.verifyKeyContentIsFaster b -> 
+				verifier k f
 			(Just mkiv, mverifier) -> do
 				iv <- mkiv k
 				showAction (UnquotedString (descIncrementalVerifier iv))
diff --git a/Assistant/Pairing.hs b/Assistant/Pairing.hs
deleted file mode 100644
--- a/Assistant/Pairing.hs
+++ /dev/null
@@ -1,97 +0,0 @@
-{- git-annex assistant repo pairing, core data types
- -
- - Copyright 2012 Joey Hess <id@joeyh.name>
- -
- - Licensed under the GNU AGPL version 3 or higher.
- -}
-
-module Assistant.Pairing where
-
-import Annex.Common
-import Utility.Verifiable
-import Assistant.Ssh
-
-import Control.Concurrent
-import Network.Socket
-import Data.Char
-import qualified Data.Text as T
-
-data PairStage
-	{- "I'll pair with anybody who shares the secret that can be used
-	 - to verify this request." -}
-	 = PairReq
-	{- "I've verified your request, and you can verify this to see
-	 - that I know the secret. I set up your ssh key already.
-	 - Here's mine for you to set up." -}
-	| PairAck
-	{- "I saw your PairAck; you can stop sending them." -}
-	| PairDone
-	deriving (Eq, Read, Show, Ord, Enum)
-
-newtype PairMsg = PairMsg (Verifiable (PairStage, PairData, SomeAddr))
-	deriving (Eq, Read, Show)
-
-verifiedPairMsg :: PairMsg -> PairingInProgress -> Bool
-verifiedPairMsg (PairMsg m) pip = verify m $ inProgressSecret pip
-
-fromPairMsg :: PairMsg -> Verifiable (PairStage, PairData, SomeAddr)
-fromPairMsg (PairMsg m) = m
-
-pairMsgStage :: PairMsg -> PairStage
-pairMsgStage (PairMsg (Verifiable (s, _, _) _)) = s
-
-pairMsgData :: PairMsg -> PairData
-pairMsgData (PairMsg (Verifiable (_, d, _) _)) = d
-
-pairMsgAddr :: PairMsg -> SomeAddr
-pairMsgAddr (PairMsg (Verifiable (_, _, a) _)) = a
-
-data PairData = PairData
-	-- uname -n output, not a full domain name
-	{ remoteHostName :: Maybe HostName
-	, remoteUserName :: UserName
-	, remoteDirectory :: FilePath
-	, remoteSshPubKey :: SshPubKey
-	, pairUUID :: UUID
-	}
-	deriving (Eq, Read, Show)
-
-checkSane :: PairData -> Bool
-checkSane p = all (not . any isControl)
-	[ fromMaybe "" (remoteHostName p)
-	, remoteUserName p
-	, remoteDirectory p
-	, remoteSshPubKey p
-	, fromUUID (pairUUID p)
-	]
-
-type UserName = String
-
-{- A pairing that is in progress has a secret, a thread that is
- - broadcasting pairing messages, and a SshKeyPair that has not yet been
- - set up on disk. -}
-data PairingInProgress = PairingInProgress
-	{ inProgressSecret :: Secret
-	, inProgressThreadId :: Maybe ThreadId
-	, inProgressSshKeyPair :: SshKeyPair
-	, inProgressPairData :: PairData
-	, inProgressPairStage :: PairStage
-	}
-	deriving (Show)
-
-data AddrClass = IPv4AddrClass | IPv6AddrClass
-
-data SomeAddr = IPv4Addr HostAddress
-	| IPv6Addr HostAddress6
-	deriving (Ord, Eq, Read, Show)
-
-{- This contains the whole secret, just lightly obfuscated to make it not
- - too obvious. It's only displayed in the user's web browser. -}
-newtype SecretReminder = SecretReminder [Int]
-	deriving (Show, Eq, Ord, Read)
-
-toSecretReminder :: T.Text -> SecretReminder
-toSecretReminder = SecretReminder . map ord . T.unpack
-
-fromSecretReminder :: SecretReminder -> T.Text
-fromSecretReminder (SecretReminder s) = T.pack $ map chr s
diff --git a/Assistant/Threads/Merger.hs b/Assistant/Threads/Merger.hs
--- a/Assistant/Threads/Merger.hs
+++ b/Assistant/Threads/Merger.hs
@@ -98,7 +98,7 @@
 						mc
 						def
 						cmode
-						[changedbranch]
+						[(changedbranch, noop)]
 			recordCommit
 		| changedbranch == b =
 			-- Record commit so the pusher pushes it out.
diff --git a/Assistant/Types/DaemonStatus.hs b/Assistant/Types/DaemonStatus.hs
--- a/Assistant/Types/DaemonStatus.hs
+++ b/Assistant/Types/DaemonStatus.hs
@@ -8,7 +8,6 @@
 module Assistant.Types.DaemonStatus where
 
 import Annex.Common
-import Assistant.Pairing
 import Utility.NotificationBroadcaster
 import Types.Transfer
 import Assistant.Types.ThreadName
@@ -57,8 +56,6 @@
 	, syncingToCloudRemote :: Bool
 	-- Set of uuids of remotes that are currently connected.
 	, currentlyConnectedRemotes :: S.Set UUID
-	-- Pairing request that is in progress.
-	, pairingInProgress :: Maybe PairingInProgress
 	-- Broadcasts notifications about all changes to the DaemonStatus.
 	, changeNotifier :: NotificationBroadcaster
 	-- Broadcasts notifications when queued or current transfers change.
@@ -105,7 +102,6 @@
 	<*> pure []
 	<*> pure False
 	<*> pure S.empty
-	<*> pure Nothing
 	<*> newNotificationBroadcaster
 	<*> newNotificationBroadcaster
 	<*> newNotificationBroadcaster
diff --git a/Assistant/WebApp/Types.hs b/Assistant/WebApp/Types.hs
--- a/Assistant/WebApp/Types.hs
+++ b/Assistant/WebApp/Types.hs
@@ -18,7 +18,6 @@
 
 import Assistant.Common
 import Assistant.Ssh
-import Assistant.Pairing
 import Utility.NotificationBroadcaster
 import Utility.AuthToken
 import Utility.WebApp
@@ -153,14 +152,6 @@
 	fromPathPiece = readish . unpack
 
 instance PathPiece Transfer where
-	toPathPiece = pack . show
-	fromPathPiece = readish . unpack
-
-instance PathPiece PairMsg where
-	toPathPiece = pack . show
-	fromPathPiece = readish . unpack
-
-instance PathPiece SecretReminder where
 	toPathPiece = pack . show
 	fromPathPiece = readish . unpack
 
diff --git a/Backend/External.hs b/Backend/External.hs
--- a/Backend/External.hs
+++ b/Backend/External.hs
@@ -69,6 +69,7 @@
 				nullMeterUpdate
 			else Nothing
 		, verifyKeyContentIncrementally = Nothing
+		, verifyKeyContentIsFaster = True
 		, canUpgradeKey = Nothing
 		, fastMigrate = Nothing
 		, isStableKey = const isstable
@@ -84,6 +85,7 @@
 		, genKey = Nothing
 		, verifyKeyContent = Nothing
 		, verifyKeyContentIncrementally = Nothing
+		, verifyKeyContentIsFaster = True
 		, canUpgradeKey = Nothing
 		, fastMigrate = Nothing
 		, isStableKey = const False
diff --git a/Backend/GitRemoteAnnex.hs b/Backend/GitRemoteAnnex.hs
--- a/Backend/GitRemoteAnnex.hs
+++ b/Backend/GitRemoteAnnex.hs
@@ -39,6 +39,7 @@
 	-- git-remote-annex.
 	, verifyKeyContent = Just $ Hash.checkKeyChecksum sameCheckSum hash
 	, verifyKeyContentIncrementally = Just (liftIO . incrementalVerifier)
+	, verifyKeyContentIsFaster = True
 	, canUpgradeKey = Nothing
 	, fastMigrate = Nothing
 	, isStableKey = const True
@@ -53,6 +54,7 @@
 	, genKey = Nothing
 	, verifyKeyContent = Nothing
 	, verifyKeyContentIncrementally = Nothing
+	, verifyKeyContentIsFaster = True
 	, canUpgradeKey = Nothing
 	, fastMigrate = Nothing
 	, isStableKey = const True
@@ -61,26 +63,28 @@
 	}
 
 -- git bundle keys use the sha256 hash.
-hash :: Hash.Hash
+hash :: Hash.HashType
 hash = Hash.SHA2Hash (HashSize 256)
 
 incrementalVerifier :: Key -> IO IncrementalVerifier
 incrementalVerifier = 
-	mkIncrementalVerifier sha2_256_context "checksum" . sameCheckSum
+	mkIncrementalVerifier sha2_256_hasher "checksum" . sameCheckSum
 
-sameCheckSum :: Key -> String -> Bool
-sameCheckSum key s = s == expected
+sameCheckSum :: Key -> Hash -> Bool
+sameCheckSum key h = h == expected
   where
 	-- The checksum comes after a UUID.
-	expected = reverse $ takeWhile (/= '-') $ reverse $
-		decodeBS $ S.fromShort $ fromKey keyName key
+	expected = Hash $ encodeBS $ 
+		reverse $ takeWhile (/= '-') $ reverse $
+			decodeBS $ S.fromShort $ fromKey keyName key
 
 genGitBundleKey :: UUID -> OsPath -> MeterUpdate -> Annex Key
 genGitBundleKey remoteuuid file meterupdate = do
 	filesize <- liftIO $ getFileSize file
-	s <- Hash.hashFile hash file meterupdate
+	h <- Hash.hashFile hash file meterupdate
 	return $ mkKey $ \k -> k
-		{ keyName = S.toShort $ fromUUID remoteuuid <> "-" <> encodeBS s
+		{ keyName = S.toShort $
+			fromUUID remoteuuid <> "-" <> hashByteString h
 		, keyVariety = GitBundleKey
 		, keySize = Just filesize
 		}
diff --git a/Backend/Hash.hs b/Backend/Hash.hs
--- a/Backend/Hash.hs
+++ b/Backend/Hash.hs
@@ -1,17 +1,18 @@
 {- git-annex hashing backends
  -
- - Copyright 2011-2024 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE CPP #-}
 {-# LANGUAGE OverloadedStrings #-}
 
 module Backend.Hash (
 	backends,
 	keyHash,
 	descChecksum,
-	Hash(..),
+	HashType(..),
 	cryptographicallySecure,
 	hashFile,
 	checkKeyChecksum,
@@ -32,10 +33,11 @@
 import qualified Data.ByteString.Short as S (toShort, fromShort)
 import qualified Data.ByteString.Char8 as S8
 import qualified Data.ByteString.Lazy as L
+import Data.Char
 import Control.DeepSeq
 import Control.Exception (evaluate)
 
-data Hash
+data HashType
 	= MD5Hash
 	| SHA1Hash
 	| SHA2Hash HashSize
@@ -45,8 +47,14 @@
 	| Blake2bpHash HashSize
 	| Blake2sHash HashSize
 	| Blake2spHash HashSize
+#ifdef WITH_BLAKE3
+	| Blake3Hash
+#endif
+#ifdef WITH_XXH3
+	| XXH3Hash
+#endif
 
-cryptographicallySecure :: Hash -> Bool
+cryptographicallySecure :: HashType -> Bool
 cryptographicallySecure (SHA2Hash _) = True
 cryptographicallySecure (SHA3Hash _) = True
 cryptographicallySecure (SkeinHash _) = True
@@ -54,6 +62,12 @@
 cryptographicallySecure (Blake2bpHash _) = True
 cryptographicallySecure (Blake2sHash _) = True
 cryptographicallySecure (Blake2spHash _) = True
+#ifdef WITH_BLAKE3
+cryptographicallySecure Blake3Hash = True
+#endif
+#ifdef WITH_XXH3
+cryptographicallySecure XXH3Hash = False
+#endif
 cryptographicallySecure SHA1Hash = False
 cryptographicallySecure MD5Hash = False
 
@@ -61,7 +75,7 @@
  - uses, and must be cryptographically secure. 
  -
  - Also, want more common sizes earlier than uncommon sizes. -}
-hashes :: [Hash]
+hashes :: [HashType]
 hashes = concat 
 	[ map (SHA2Hash . HashSize) [256, 512, 224, 384]
 	, map (SHA3Hash . HashSize) [256, 512, 224, 384]
@@ -70,6 +84,14 @@
 	, map (Blake2bpHash . HashSize) [512]
 	, map (Blake2sHash . HashSize) [256, 160, 224]
 	, map (Blake2spHash . HashSize) [256, 224]
+#ifdef WITH_BLAKE3
+	, [Blake3Hash]
+#endif
+#ifdef WITH_XXH3
+	, if xxh3Supported
+		then [XXH3Hash]
+		else []
+#endif
 	, [SHA1Hash]
 	, [MD5Hash]
 	]
@@ -78,12 +100,13 @@
 backends :: [Backend]
 backends = concatMap (\h -> [genBackendE h, genBackend h]) hashes
 
-genBackend :: Hash -> Backend
+genBackend :: HashType -> Backend
 genBackend hash = Backend
 	{ backendVariety = hashKeyVariety hash (HasExt False)
 	, genKey = Just (keyValue hash)
 	, verifyKeyContent = Just $ checkKeyChecksum sameCheckSum hash
-	, verifyKeyContentIncrementally = Just $ checkKeyChecksumIncremental hash
+	, verifyKeyContentIncrementally = checkKeyChecksumIncremental hash
+	, verifyKeyContentIsFaster = True
 	, canUpgradeKey = Just needsUpgrade
 	, fastMigrate = Just trivialMigrate
 	, isStableKey = const True
@@ -92,13 +115,13 @@
 		cryptographicallySecure hash
 	}
 
-genBackendE :: Hash -> Backend
+genBackendE :: HashType -> Backend
 genBackendE hash = (genBackend hash)
 	{ backendVariety = hashKeyVariety hash (HasExt True)
 	, genKey = Just (keyValueE hash)
 	}
 
-hashKeyVariety :: Hash -> HasExt -> KeyVariety
+hashKeyVariety :: HashType -> HasExt -> KeyVariety
 hashKeyVariety MD5Hash he = MD5Key he
 hashKeyVariety SHA1Hash he = SHA1Key he
 hashKeyVariety (SHA2Hash size) he = SHA2Key size he
@@ -108,26 +131,32 @@
 hashKeyVariety (Blake2bpHash size) he = Blake2bpKey size he
 hashKeyVariety (Blake2sHash size) he = Blake2sKey size he
 hashKeyVariety (Blake2spHash size) he = Blake2spKey size he
+#ifdef WITH_BLAKE3
+hashKeyVariety Blake3Hash he = Blake3Key he
+#endif
+#ifdef WITH_XXH3
+hashKeyVariety XXH3Hash he = XXH3Key he
+#endif
 
 {- A key is a hash of its contents. -}
-keyValue :: Hash -> KeySource -> MeterUpdate -> Annex Key
-keyValue hash source meterupdate = do
+keyValue :: HashType -> KeySource -> MeterUpdate -> Annex Key
+keyValue hashtype source meterupdate = do
 	let file = contentLocation source
 	filesize <- liftIO $ getFileSize file
-	s <- hashFile hash file meterupdate
+	hash <- hashFile hashtype file meterupdate
 	return $ mkKey $ \k -> k
-		{ keyName = S.toShort (encodeBS s)
-		, keyVariety = hashKeyVariety hash (HasExt False)
+		{ keyName = S.toShort (hashByteString hash)
+		, keyVariety = hashKeyVariety hashtype (HasExt False)
 		, keySize = Just filesize
 		}
 
 {- Extension preserving keys. -}
-keyValueE :: Hash -> KeySource -> MeterUpdate -> Annex Key
+keyValueE :: HashType -> KeySource -> MeterUpdate -> Annex Key
 keyValueE hash source meterupdate =
 	keyValue hash source meterupdate
 		>>= addE source (const $ hashKeyVariety hash (HasExt True))
 
-checkKeyChecksum :: (Key -> String -> Bool) -> Hash -> Key -> OsPath -> Annex Bool
+checkKeyChecksum :: (Key -> Hash -> Bool) -> HashType -> Key -> OsPath -> Annex Bool
 checkKeyChecksum issame hash key file = catchIOErrorType HardwareFault hwfault $ do
 	showAction (UnquotedString descChecksum)
 	issame key 
@@ -137,19 +166,23 @@
 		warning $ UnquotedString $ "hardware fault: " ++ show e
 		return False
 
-sameCheckSum :: Key -> String -> Bool
-sameCheckSum key s
-	| s == expected = True
+sameCheckSum :: Key -> Hash -> Bool
+sameCheckSum key hash
+	| hash == Hash expected = True
 	{- A bug caused checksums to be prefixed with \ in some
 	 - cases; still accept these as legal now that the bug
 	 - has been fixed. -}
-	| '\\' : s == expected = True
-	| otherwise = False
+	| otherwise = case S.uncons expected of
+		Just (h, t) | h == backslash -> hash == Hash t
+		_ -> False
   where
-	expected = decodeBS (keyHash key)
+	expected = keyHash key
+	backslash = fromIntegral (ord '\\')
 
-checkKeyChecksumIncremental :: Hash -> Key -> Annex IncrementalVerifier
-checkKeyChecksumIncremental hash key = liftIO $ (snd $ hasher hash) key
+checkKeyChecksumIncremental :: HashType -> Maybe (Key -> Annex IncrementalVerifier)
+checkKeyChecksumIncremental hash = case hashIncremental (hasher hash) of
+	Just iv -> Just (liftIO . iv)
+	Nothing -> Nothing
 
 keyHash :: Key -> S.ByteString
 keyHash = fst . splitKeyNameExtension
@@ -205,18 +238,28 @@
 	oldvariety = fromKey keyVariety oldkey
 	newvariety = backendVariety newbackend
 
-hashFile :: Hash -> OsPath -> MeterUpdate -> Annex String
-hashFile hash file meterupdate = 
-	liftIO $ withMeteredFile file meterupdate $ \b -> do
-		let h = (fst $ hasher hash) b
+hashFile :: HashType -> OsPath -> MeterUpdate -> Annex Hash
+hashFile hashtype file meterupdate = case hashFileFast o of
+	Nothing -> liftIO hashpure
+	Just a -> liftIO $ a file >>= \case
+		Just h -> return h
+		Nothing -> hashpure
+  where
+	o = hasher hashtype
+	hashpure = withMeteredFile file meterupdate $ \b -> do
+		let h = (hashPure o) b
 		-- Force full evaluation of hash so whole file is read
 		-- before returning.
 		evaluate (rnf h)
 		return h
 
-type Hasher = (L.ByteString -> String, Key -> IO IncrementalVerifier)
+data Hasher = Hasher
+	{ hashPure :: L.ByteString -> Hash
+	, hashFileFast :: Maybe (OsPath -> IO (Maybe Hash))
+	, hashIncremental :: Maybe (Key -> IO IncrementalVerifier)
+	}
 
-hasher :: Hash -> Hasher
+hasher :: HashType -> Hasher
 hasher MD5Hash = md5Hasher
 hasher SHA1Hash = sha1Hasher
 hasher (SHA2Hash hashsize) = sha2Hasher hashsize
@@ -226,64 +269,94 @@
 hasher (Blake2bpHash hashsize) = blake2bpHasher hashsize
 hasher (Blake2sHash hashsize) = blake2sHasher hashsize
 hasher (Blake2spHash hashsize) = blake2spHasher hashsize
+#ifdef WITH_BLAKE3
+hasher Blake3Hash = blake3Hasher
+#endif
+#ifdef WITH_XXH3
+hasher XXH3Hash = xxh3Hasher
+#endif
 
-mkHasher :: HashAlgorithm h => (L.ByteString -> Digest h) -> Context h -> Hasher
-mkHasher h c = (show . h, mkIncrementalVerifier c descChecksum . sameCheckSum)
+mkHasher :: (L.ByteString -> HashDigest) -> IO IncrementalHasher -> Hasher
+mkHasher h i = Hasher
+	{ hashPure = digestToHash . h
+	, hashFileFast = Nothing
+	, hashIncremental = Just $
+		mkIncrementalVerifier i descChecksum . sameCheckSum
+	}
 
 sha2Hasher :: HashSize -> Hasher
 sha2Hasher (HashSize hashsize)
-	| hashsize == 256 = mkHasher sha2_256 sha2_256_context
-	| hashsize == 224 = mkHasher sha2_224 sha2_224_context
-	| hashsize == 384 = mkHasher sha2_384 sha2_384_context
-	| hashsize == 512 = mkHasher sha2_512 sha2_512_context
+	| hashsize == 256 = mkHasher sha2_256 sha2_256_hasher
+	| hashsize == 224 = mkHasher sha2_224 sha2_224_hasher
+	| hashsize == 384 = mkHasher sha2_384 sha2_384_hasher
+	| hashsize == 512 = mkHasher sha2_512 sha2_512_hasher
 	| otherwise = giveup $ "unsupported SHA2 size " ++ show hashsize
 
 sha3Hasher :: HashSize -> Hasher
 sha3Hasher (HashSize hashsize)
-	| hashsize == 256 = mkHasher sha3_256 sha3_256_context
-	| hashsize == 224 = mkHasher sha3_224 sha3_224_context
-	| hashsize == 384 = mkHasher sha3_384 sha3_384_context
-	| hashsize == 512 = mkHasher sha3_512 sha3_512_context
+	| hashsize == 256 = mkHasher sha3_256 sha3_256_hasher
+	| hashsize == 224 = mkHasher sha3_224 sha3_224_hasher
+	| hashsize == 384 = mkHasher sha3_384 sha3_384_hasher
+	| hashsize == 512 = mkHasher sha3_512 sha3_512_hasher
 	| otherwise = giveup $ "unsupported SHA3 size " ++ show hashsize
 
 skeinHasher :: HashSize -> Hasher
 skeinHasher (HashSize hashsize)
-	| hashsize == 256 = mkHasher skein256 skein256_context
-	| hashsize == 512 = mkHasher skein512 skein512_context
+	| hashsize == 256 = mkHasher skein256 skein256_hasher
+	| hashsize == 512 = mkHasher skein512 skein512_hasher
 	| otherwise = giveup $ "unsupported SKEIN size " ++ show hashsize
 
 blake2bHasher :: HashSize -> Hasher
 blake2bHasher (HashSize hashsize)
-	| hashsize == 256 = mkHasher blake2b_256 blake2b_256_context
-	| hashsize == 512 = mkHasher blake2b_512 blake2b_512_context
-	| hashsize == 160 = mkHasher blake2b_160 blake2b_160_context
-	| hashsize == 224 = mkHasher blake2b_224 blake2b_224_context
-	| hashsize == 384 = mkHasher blake2b_384 blake2b_384_context
+	| hashsize == 256 = mkHasher blake2b_256 blake2b_256_hasher
+	| hashsize == 512 = mkHasher blake2b_512 blake2b_512_hasher
+	| hashsize == 160 = mkHasher blake2b_160 blake2b_160_hasher
+	| hashsize == 224 = mkHasher blake2b_224 blake2b_224_hasher
+	| hashsize == 384 = mkHasher blake2b_384 blake2b_384_hasher
 	| otherwise = giveup $ "unsupported BLAKE2B size " ++ show hashsize
 
 blake2bpHasher :: HashSize -> Hasher
 blake2bpHasher (HashSize hashsize)
-	| hashsize == 512 = mkHasher blake2bp_512 blake2bp_512_context
+	| hashsize == 512 = mkHasher blake2bp_512 blake2bp_512_hasher
 	| otherwise = giveup $ "unsupported BLAKE2BP size " ++ show hashsize
 
 blake2sHasher :: HashSize -> Hasher
 blake2sHasher (HashSize hashsize)
-	| hashsize == 256 = mkHasher blake2s_256 blake2s_256_context
-	| hashsize == 160 = mkHasher blake2s_160 blake2s_160_context
-	| hashsize == 224 = mkHasher blake2s_224 blake2s_224_context
+	| hashsize == 256 = mkHasher blake2s_256 blake2s_256_hasher
+	| hashsize == 160 = mkHasher blake2s_160 blake2s_160_hasher
+	| hashsize == 224 = mkHasher blake2s_224 blake2s_224_hasher
 	| otherwise = giveup $ "unsupported BLAKE2S size " ++ show hashsize
 
 blake2spHasher :: HashSize -> Hasher
 blake2spHasher (HashSize hashsize)
-	| hashsize == 256 = mkHasher blake2sp_256 blake2sp_256_context
-	| hashsize == 224 = mkHasher blake2sp_224 blake2sp_224_context
+	| hashsize == 256 = mkHasher blake2sp_256 blake2sp_256_hasher
+	| hashsize == 224 = mkHasher blake2sp_224 blake2sp_224_hasher
 	| otherwise = giveup $ "unsupported BLAKE2SP size " ++ show hashsize
 
+#ifdef WITH_BLAKE3
+blake3Hasher :: Hasher
+blake3Hasher = Hasher
+	{ hashPure = blake3
+	, hashFileFast = Just blake3File
+	, hashIncremental = Just $
+		blake3IncrementalVerifier descChecksum . sameCheckSum
+	}
+#endif
+
+#ifdef WITH_XXH3
+xxh3Hasher :: Hasher
+xxh3Hasher = Hasher
+	{ hashPure = digestToHash . xxh3
+	, hashFileFast = Nothing
+	, hashIncremental = xxh3Incremental
+	}
+#endif
+
 sha1Hasher :: Hasher
-sha1Hasher = mkHasher sha1 sha1_context
+sha1Hasher = mkHasher sha1 sha1_hasher
 
 md5Hasher :: Hasher
-md5Hasher = mkHasher md5 md5_context
+md5Hasher = mkHasher md5 md5_hasher
 
 descChecksum :: String
 descChecksum = "checksum"
@@ -310,12 +383,12 @@
   where
 	longext = ".this-is-a-test-key"
 
-testKeyHash :: Hash
+testKeyHash :: HashType
 testKeyHash = SHA2Hash (HashSize 256)
 
 genTestKey :: L.ByteString -> Key
 genTestKey content = addTestE $ mkKey $ \kd -> kd
-	{ keyName = S.toShort $ encodeBS $
-		(fst $ hasher testKeyHash) content
+	{ keyName = S.toShort $ hashByteString $
+		(hashPure $ hasher testKeyHash) content
 	, keyVariety = backendVariety testKeyBackend
 	}
diff --git a/Backend/URL.hs b/Backend/URL.hs
--- a/Backend/URL.hs
+++ b/Backend/URL.hs
@@ -26,6 +26,7 @@
 	, genKey = Nothing
 	, verifyKeyContent = Nothing
 	, verifyKeyContentIncrementally = Nothing
+	, verifyKeyContentIsFaster = True
 	, canUpgradeKey = Nothing
 	, fastMigrate = Just migrateFromURLToVURL
 	-- The content of an url can change at any time, so URL keys are
diff --git a/Backend/Utilities.hs b/Backend/Utilities.hs
--- a/Backend/Utilities.hs
+++ b/Backend/Utilities.hs
@@ -31,7 +31,7 @@
 	-- Avoid making keys longer than the length of a SHA256 checksum.
 	| bytelen > sha256len = S.toShort $
 		truncateFilePath (sha256len - md5len - 1) s' 
-			<> "-" <> encodeBS (show (md5 bl))
+			<> "-" <> hashByteString (digestToHash (md5 bl))
 	| otherwise = S.toShort s'
   where
 	s' = encodeBS $ preSanitizeKeyName s
diff --git a/Backend/VURL.hs b/Backend/VURL.hs
--- a/Backend/VURL.hs
+++ b/Backend/VURL.hs
@@ -16,7 +16,7 @@
 import Logs.EquivilantKeys
 import Backend.Variety
 import Backend.Hash (descChecksum)
-import Utility.Hash
+import Utility.Hash.Incremental
 import Backend.VURL.Utilities
 
 backends :: [Backend]
@@ -72,7 +72,10 @@
 			, positionIncrementalVerifier =
 				getM positionIncrementalVerifier l
 			, descIncrementalVerifier = descChecksum
-			} 
+			}
+	-- Incremental is faster because it avoids reading the file more
+	-- than once when there are multiple equivilant keys.
+	, verifyKeyContentIsFaster = False
 	, canUpgradeKey = Nothing
 	, fastMigrate = Just migrateFromVURLToURL
 	-- Even if a hash is recorded on initial download from the web and
diff --git a/Backend/WORM.hs b/Backend/WORM.hs
--- a/Backend/WORM.hs
+++ b/Backend/WORM.hs
@@ -29,6 +29,7 @@
 	, genKey = Just keyValue
 	, verifyKeyContent = Nothing
 	, verifyKeyContentIncrementally = Nothing
+	, verifyKeyContentIsFaster = True
 	, canUpgradeKey = Just needsUpgrade
 	, fastMigrate = Just removeProblemChars
 	, isStableKey = const True
diff --git a/BuildFlags.hs b/BuildFlags.hs
--- a/BuildFlags.hs
+++ b/BuildFlags.hs
@@ -65,6 +65,21 @@
 #else 
 #warning Building without the OsPath build flag set results in slower filename manipulation and is not recommended.
 #endif
+#ifdef WITH_BOTAN
+	, "Botan"
+#else 
+#warning Consider building with the Botan build flag set, it speeds up checksumming by up 4x to 8x.
+#endif
+#ifdef WITH_BLAKE3
+	, "Blake3"
+#else
+#warning Building without Blake3 support.
+#endif
+#ifdef WITH_XXH3
+	, "XXH3"
+#else
+#warning Building without XXH3 support.
+#endif
 	]
 
 -- Not a complete list, let alone a listing transitive deps, but only
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,31 @@
+git-annex (10.20260624) upstream; urgency=medium
+
+  * Added Botan build flag, which speeds up checksumming significantly
+    for most existing hash backends.
+  * Added Blake3 build flag, which is the fastest available
+    cryptographically secure hash.
+    Thanks to edef for implementing this.
+  * When b3sum is in PATH, use it for faster blake3 hashing.
+  * Added XXH3 build flag, which is the fastest available
+    non-cryptographically secure hash, and is a smaller hash
+    than MD5 or SHA1.
+  * Improve handling of synced/master and similar branches, by
+    only creating such branches when necessary to push to a non-bare
+    remote, and by removing the local synced branch once its changes
+    are merged.
+  * put: New command.
+  * move: Fix bug where an interrupted command other than move could
+    be treated as an interrupted move, resulting in dropping content
+    when not allowed by numcopies.
+  * Fix build with http-types-0.12.5
+  * merge, post-receive: Fix bug that prevented merging any synced/
+    branches into the current branch.
+    (Reversion introduced in 10.20260601)
+  * git-annex.cabal: Remove unused dependency on old-locale.
+  * Remove dependencies on securemem and byteable.
+
+ -- Joey Hess <id@joeyh.name>  Thu, 25 Jun 2026 12:28:43 -0400
+
 git-annex (10.20260601) upstream; urgency=medium
 
   * Avoid an error message displaying a password embedded in a git remote url.
diff --git a/COPYRIGHT b/COPYRIGHT
--- a/COPYRIGHT
+++ b/COPYRIGHT
@@ -51,6 +51,11 @@
 Copyright: © 2010-2023 Joey Hess <id@joeyh.name>
 License: AGPL-3+
 
+Files: Utility.Hash.Blake3
+Copyright 2026 Joey Hess <id@joeyh.name>
+          2022 edef <edef@edef.eu>
+License: AGPL-3+
+
 Files: Utility/*
 Copyright: 2012-2025 Joey Hess <id@joeyh.name>
 License: BSD-2-clause
diff --git a/CmdLine/Batch.hs b/CmdLine/Batch.hs
--- a/CmdLine/Batch.hs
+++ b/CmdLine/Batch.hs
@@ -155,22 +155,24 @@
 --
 -- File matching options are checked, and non-matching files skipped.
 batchFiles :: BatchFormat -> ((SeekInput, OsPath) -> CommandStart) -> Annex ()
-batchFiles fmt a = batchFilesKeys fmt $ \(si, v) -> case v of
-	Right f -> a (si, f)
-	Left _k -> return Nothing
+batchFiles fmt a = batchFilesKeys fmt $ \(si, v) -> 
+	return $ case v of
+		Right f -> [a (si, f)]
+		Left _k -> [return Nothing]
 
-batchFilesKeys :: BatchFormat -> ((SeekInput, Either Key OsPath) -> CommandStart) -> Annex ()
+batchFilesKeys :: BatchFormat -> ((SeekInput, Either Key OsPath) -> Annex [CommandStart]) -> Annex ()
 batchFilesKeys fmt a = do
 	matcher <- getMatcher
 	go $ \si v -> case v of
 		Right f -> 
 			ifM (matcher $ MatchingFile $ FileInfo f f Nothing)
 				( a (si, Right f)
-				, return Nothing
+				, return [return Nothing]
 				)
 		Left k -> a (si, Left k)
   where
-	go a' = batchInput fmt parser (batchCommandAction . uncurry a')
+	go a' = batchInput fmt parser $ \v ->
+		mapM_ batchCommandAction =<< uncurry a' v
 	parser = case fmt of
 		-- Absolute filepaths are converted to relative,
 		-- because in non-batch mode, that is done when
@@ -184,32 +186,37 @@
 				Nothing -> Left "not a valid key"
 
 batchAnnexedFiles :: BatchFormat -> AnnexedFileSeeker -> Annex ()
-batchAnnexedFiles fmt seeker = batchAnnexed fmt seeker (const (return Nothing))
+batchAnnexedFiles fmt seeker = 
+	batchAnnexed' fmt seeker (const (return [return Nothing]))
 
 -- Reads lines of batch input and passes filepaths to the AnnexedFileSeeker
 -- to handle them. Or, with --batch-keys, passes keys to the keyaction.
 --
 -- Matching options are checked, and non-matching items skipped.
 batchAnnexed :: BatchFormat -> AnnexedFileSeeker -> ((SeekInput, Key, ActionItem) -> CommandStart) -> Annex ()
-batchAnnexed fmt seeker keyaction = do
+batchAnnexed fmt seeker keyaction =
+	batchAnnexed' fmt seeker $ \v -> return [keyaction v]
+
+batchAnnexed' :: BatchFormat -> AnnexedFileSeeker -> ((SeekInput, Key, ActionItem) -> Annex [CommandStart]) -> Annex ()
+batchAnnexed' fmt seeker keyaction = do
 	matcher <- getMatcher
 	batchFilesKeys fmt $ \(si, v) ->
 		case v of
 			Right f -> lookupKeyStaged f >>= \case
-				Nothing -> return Nothing
+				Nothing -> return [return Nothing]
 				Just k -> checkpresent k $
 					startAction seeker Nothing si f k
 			Left k -> ifM (matcher (MatchingInfo (mkinfo k)))
 				( checkpresent k $
 					keyaction (si, k, mkActionItem k)
-				, return Nothing)
+				, return [return Nothing])
   where
 	checkpresent k cont = case checkContentPresent seeker of
 		Just v -> do
 			present <- inAnnex k
 			if present == v
 				then cont
-				else return Nothing
+				else return [return Nothing]
 		Nothing -> cont
 	
 	mkinfo k = ProvidedInfo
diff --git a/CmdLine/GitAnnex.hs b/CmdLine/GitAnnex.hs
--- a/CmdLine/GitAnnex.hs
+++ b/CmdLine/GitAnnex.hs
@@ -104,6 +104,7 @@
 import qualified Command.Assist
 import qualified Command.Pull
 import qualified Command.Push
+import qualified Command.Put
 import qualified Command.Satisfy
 import qualified Command.Mirror
 import qualified Command.AddUrl
@@ -161,6 +162,7 @@
 	, Command.Assist.cmd
 	, Command.Pull.cmd
 	, Command.Push.cmd
+	, Command.Put.cmd
 	, Command.Satisfy.cmd
 	, Command.Mirror.cmd
 	, Command.AddUrl.cmd
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-2023 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -58,11 +58,16 @@
 import System.PosixCompat.Files (isDirectory, isSymbolicLink, deviceID, fileID)
 
 data AnnexedFileSeeker = AnnexedFileSeeker
-	{ startAction :: Maybe KeySha -> SeekInput -> OsPath -> Key -> CommandStart
+	{ startAction :: Maybe KeySha -> SeekInput -> OsPath -> Key -> Annex [CommandStart]
 	, checkContentPresent :: Maybe Bool
 	, usesLocationLog :: Bool
 	}
 
+startSingle
+	:: (Maybe KeySha -> SeekInput -> OsPath -> Key -> CommandStart)
+	-> Maybe KeySha -> SeekInput -> OsPath -> Key -> Annex [CommandStart]
+startSingle a ks i p k = return [a ks i p k]
+
 -- The Sha that was read to get the Key.
 newtype KeySha = KeySha Git.Sha
 
@@ -381,8 +386,9 @@
   where
 	finisher mi oreader checktimelimit = liftIO oreader >>= \case
 		Just ((si, f, keysha), content) -> checktimelimit (liftIO discard) $ do
-			keyaction f mi content $
-				commandAction . startAction seeker keysha si f
+			keyaction f mi content $ \k ->
+				commandActions
+					=<< startAction seeker keysha si f k
 			finisher mi oreader checktimelimit
 		Nothing -> return ()
 	  where
@@ -396,7 +402,7 @@
 			checkMatcherWhen mi
 				(matcherNeedsLocationLog mi && not (matcherNeedsFileName mi))
 				(MatchingFile $ FileInfo f f (Just k))
-				(commandAction $ startAction seeker keysha si f k)
+				(commandActions =<< startAction seeker keysha si f k)
 			precachefinisher mi lreader checktimelimit
 		Nothing -> return ()
 	  where
diff --git a/Command/Copy.hs b/Command/Copy.hs
--- a/Command/Copy.hs
+++ b/Command/Copy.hs
@@ -8,6 +8,7 @@
 module Command.Copy where
 
 import Command
+import qualified Annex
 import qualified Command.Move
 import qualified Remote
 import Annex.Wanted
@@ -26,6 +27,7 @@
 	, autoMode :: Bool
 	, wantedMode :: Bool
 	, batchOption :: BatchMode
+	, moveAction :: Command.Move.MoveAction
 	}
 
 optParser :: CmdParamsDesc -> Parser CopyOptions
@@ -36,6 +38,7 @@
 	<*> parseAutoOption
 	<*> parseWantedOption
 	<*> parseBatchOption True
+	<*> pure (Command.Move.Copy False)
 
 instance DeferredParseClass CopyOptions where
 	finishParse v = CopyOptions
@@ -46,10 +49,14 @@
 		<*> pure (autoMode v)
 		<*> pure (wantedMode v)
 		<*> pure (batchOption v)
+		<*> pure (moveAction v)
 
 seek :: CopyOptions -> CommandSeek
 seek o = case fromToOptions o of
-	Just fto -> seek' o fto
+	Just fto -> do
+		fast <- Annex.getRead Annex.fast
+		let o' = o { moveAction = Command.Move.Copy fast }
+		seek' o' fto
 	Nothing -> giveup "Specify --from or --to"
 
 seek' :: CopyOptions -> FromToHereOptions -> CommandSeek
@@ -57,16 +64,16 @@
 	case batchOption o of
 		NoBatch -> withKeyOptions
 			(keyOptions o) (autoMode o || wantedMode o) seeker
-			(commandAction . keyaction)
+			(commandAction . startKey o fto id)
 			(withFilesInGitAnnex ww seeker)
 			=<< workTreeItems ww (copyFiles o)
 		Batch fmt -> batchOnly (keyOptions o) (copyFiles o) $
-			batchAnnexed fmt seeker keyaction
+			batchAnnexed fmt seeker (startKey o fto id)
   where
 	ww = WarnUnmatchLsFiles "copy"
 	
 	seeker = AnnexedFileSeeker
-		{ startAction = const $ start o fto
+		{ startAction = startSingle $ const $ start o fto id
 		, checkContentPresent = case fto of
 			FromOrToRemote (FromRemote _) -> Just False
 			FromOrToRemote (ToRemote _) -> Just True
@@ -75,13 +82,19 @@
 			FromAnywhereToRemote _ -> Nothing
 		, usesLocationLog = True
 		}
-	keyaction = Command.Move.startKey NoLiveUpdate fto Command.Move.RemoveNever
 
 {- A copy is just a move that does not delete the source file.
  - However, auto mode avoids unnecessary copies, and avoids getting or
  - sending non-preferred content. -}
-start :: CopyOptions -> FromToHereOptions -> SeekInput -> OsPath -> Key -> CommandStart
-start o fto si file key = do
+start
+	:: CopyOptions
+	-> FromToHereOptions
+	-> (ActionItem -> ActionItem)
+	-> SeekInput
+	-> OsPath
+	-> Key
+	-> CommandStart
+start o fto fai si file key = do
 	ru <- case fto of
 		FromOrToRemote (ToRemote dest) -> getru dest
 		FromOrToRemote (FromRemote _) -> pure Nothing
@@ -89,13 +102,26 @@
 		FromRemoteToRemote _ dest -> getru dest
 		FromAnywhereToRemote dest -> getru dest
 	lu <- prepareLiveUpdate ru key AddingKey
-	start' lu o fto si file key
+
+	start' lu o fto afile si file key ai
   where
 	getru dest = Just . Remote.uuid <$> getParsed dest
+	
+	afile = AssociatedFile (Just file)
+	ai = fai $ mkActionItem (key, afile)
 
-start' :: LiveUpdate -> CopyOptions -> FromToHereOptions -> SeekInput -> OsPath -> Key -> CommandStart
-start' lu o fto si file key = stopUnless shouldCopy $ 
-	Command.Move.start lu fto Command.Move.RemoveNever si file key
+start'
+	:: LiveUpdate
+	-> CopyOptions
+	-> FromToHereOptions
+	-> AssociatedFile
+	-> SeekInput
+	-> OsPath
+	-> Key
+	-> ActionItem
+	-> CommandStart
+start' lu o fto afile si file key ai = stopUnless shouldCopy $ 
+	Command.Move.start' lu fto (moveAction o) afile si key ai
   where
 	shouldCopy
 		| autoMode o = want <||> numCopiesCheck file key (<)
@@ -112,3 +138,12 @@
 		(Remote.uuid <$> getParsed dest) >>=
 			wantGetBy lu False (Just key) (AssociatedFile (Just file))
 	checkwantget = wantGet lu False (Just key) (AssociatedFile (Just file))
+
+startKey
+	:: CopyOptions
+	-> FromToHereOptions
+	-> (ActionItem -> ActionItem)
+	-> (SeekInput, Key, ActionItem)
+	-> CommandStart
+startKey o fto fai (si, k, ai) = 
+	Command.Move.startKey NoLiveUpdate fto (moveAction o) (si, k, fai ai)
diff --git a/Command/Drop.hs b/Command/Drop.hs
--- a/Command/Drop.hs
+++ b/Command/Drop.hs
@@ -61,7 +61,7 @@
 				then pure Nothing
 				else pure (Just remote)				
 	let seeker = AnnexedFileSeeker
-		{ startAction = const $ start o from
+		{ startAction = startSingle $ 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
@@ -155,7 +155,7 @@
 					=<< Annex.Branch.get f
 			next (return True)
 		let seeker = AnnexedFileSeeker
-			{ startAction = \_ _ _ k -> addkeyinfo k
+			{ startAction = startSingle $ \_ _ _ 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 = const (start o isterminal)
+		{ startAction = startSingle $ const (start o isterminal)
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
diff --git a/Command/FindComputed.hs b/Command/FindComputed.hs
--- a/Command/FindComputed.hs
+++ b/Command/FindComputed.hs
@@ -59,7 +59,8 @@
 	isterminal <- liftIO $ checkIsTerminal stdout
 	computeremotes <- filter isComputeRemote <$> Remote.remoteList
 	let seeker = AnnexedFileSeeker
-		{ startAction = const (start o isterminal computeremotes)
+		{ startAction = startSingle $ 
+			const (start o isterminal computeremotes)
 		, checkContentPresent = Nothing
 		, usesLocationLog = True
 		}
diff --git a/Command/FindKeys.hs b/Command/FindKeys.hs
--- a/Command/FindKeys.hs
+++ b/Command/FindKeys.hs
@@ -33,7 +33,8 @@
 		, usesLocationLog = False
 		-- startAction is not actually used since this
 		-- is not used to seek files
-		, startAction = \_ _ _ key -> start' o isterminal key
+		, startAction = startSingle $ 
+			\_ _ _ 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
@@ -38,7 +38,7 @@
   where
 	ww = WarnUnmatchLsFiles "fix"
 	seeker = AnnexedFileSeeker
-		{ startAction = const $ start FixAll
+		{ startAction = startSingle $ 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
@@ -108,7 +108,7 @@
 		cleanupLinkedWorkTreeBug
 	i <- prepIncremental u (incrementalOpt o)
 	let seeker = AnnexedFileSeeker
-		{ startAction = const $ start from i
+		{ startAction = startSingle $ 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
@@ -18,7 +18,7 @@
 cmd :: Command
 cmd = withAnnexOptions [jobsOption, jsonOptions, jsonProgressOption, annexedMatchingOptions] $ 
 	command "get" SectionCommon 
-		"make content of annexed files available"
+		"get content of annexed files"
 		paramPaths (seek <$$> optParser)
 
 data GetOptions = GetOptions
@@ -43,7 +43,7 @@
 seek o = startConcurrency transferStages $ do
 	from <- maybe (pure Nothing) (Just <$$> getParsed) (getFrom o)
 	let seeker = AnnexedFileSeeker
-		{ startAction = const $ start o from
+		{ startAction = startSingle $ const $ start o from
 		, checkContentPresent = Just False
 		, usesLocationLog = True
 		}
@@ -81,7 +81,7 @@
 			Nothing -> go $ perform lu key afile
 			Just src ->
 				stopUnless (Command.Move.fromOk src key) $
-					go $ Command.Move.fromPerform lu src Command.Move.RemoveNever key afile
+					go $ Command.Move.fromPerform lu src Command.Move.Get key afile
   where
 	go = starting "get" (OnlyActionOn key ai) si
 
diff --git a/Command/ImportFeed.hs b/Command/ImportFeed.hs
--- a/Command/ImportFeed.hs
+++ b/Command/ImportFeed.hs
@@ -228,7 +228,7 @@
 	, do
 		j <- jsonOutputEnabled
 		unless j $
-			showStartMessage (StartMessage "importfeed" (ActionItemOther (Just "gathering known urls")) (SeekInput []))
+			showStartMessage $ StartMessage "importfeed" ai si
 		h <- Db.openDb
 		unless j
 			showEndOk
@@ -237,6 +237,8 @@
   where
 	tmpl = Utility.Format.gen $ fromMaybe defaultTemplate opttemplate
 	ret h = return $ Cache h tmpl
+	ai = ActionItemOther (Just "gathering known urls")
+	si = SeekInput []
 
 findDownloads :: URLString -> Feed -> [ToDownload]
 findDownloads u f = catMaybes $ map mk (feedItems f)
diff --git a/Command/Inprogress.hs b/Command/Inprogress.hs
--- a/Command/Inprogress.hs
+++ b/Command/Inprogress.hs
@@ -42,7 +42,8 @@
 		_ -> do
 			let s = S.fromList ts
 			let seeker = AnnexedFileSeeker
-				{ startAction = const $ start isterminal s
+				{ startAction = startSingle $
+					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
@@ -49,7 +49,7 @@
 	list <- getList o
 	printHeader list
 	let seeker = AnnexedFileSeeker
-		{ startAction = const $ start list
+		{ startAction = startSingle $ 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 = const start
+		{ startAction = startSingle $ const start
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
diff --git a/Command/Log.hs b/Command/Log.hs
--- a/Command/Log.hs
+++ b/Command/Log.hs
@@ -134,7 +134,7 @@
 		zone <- liftIO getCurrentTimeZone
 		outputter <- mkOutputter m zone o <$> jsonOutputEnabled
 		let seeker = AnnexedFileSeeker
-			{ startAction = const $ \si file key ->
+			{ startAction = startSingle $ const $ \si file key ->
 				start o outputter (si, key, mkActionItem (file, key))
 			, checkContentPresent = Nothing
 			-- the way this uses the location log would not be
diff --git a/Command/Merge.hs b/Command/Merge.hs
--- a/Command/Merge.hs
+++ b/Command/Merge.hs
@@ -12,7 +12,7 @@
 import qualified Git
 import qualified Git.Branch
 import Annex.CurrentBranch
-import Command.Sync (prepMerge, mergeLocal, mergeConfig, merge, notOnlyAnnexOption, parseUnrelatedHistoriesOption)
+import Command.Sync (prepMerge, mergeLocalLikePull, mergeConfig, merge, notOnlyAnnexOption, parseUnrelatedHistoriesOption)
 import Git.Types
 
 cmd :: Command
@@ -55,14 +55,14 @@
 mergeSyncedBranch :: MergeOptions -> CommandStart
 mergeSyncedBranch o = do
 	mc <- mergeConfig (allowUnrelatedHistories o)
-	mergeLocal mc def =<< getCurrentBranch
+	mergeLocalLikePull mc def =<< getCurrentBranch
 
 mergeBranch :: MergeOptions -> Git.Ref -> CommandStart
 mergeBranch o r = starting "merge" ai si $ do
 	currbranch <- getCurrentBranch
 	mc <- mergeConfig (allowUnrelatedHistories o)
 	let so = def { notOnlyAnnexOption = True }
-	next $ merge currbranch mc so Git.Branch.ManualCommit [r]
+	next $ merge currbranch mc so Git.Branch.ManualCommit [(r, noop)]
   where
 	ai = ActionItemOther (Just (UnquotedString (Git.fromRef r)))
 	si = SeekInput []
diff --git a/Command/MetaData.hs b/Command/MetaData.hs
--- a/Command/MetaData.hs
+++ b/Command/MetaData.hs
@@ -77,7 +77,7 @@
 		c <- currentVectorClock
 		let ww = WarnUnmatchLsFiles "metadata"
 		let seeker = AnnexedFileSeeker
-			{ startAction = const $ start c o
+			{ startAction = startSingle $ 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
@@ -66,7 +66,7 @@
   where
 	ww = WarnUnmatchLsFiles "migrate"
 	seeker = AnnexedFileSeeker
-		{ startAction = start o
+		{ startAction = startSingle $ start o
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
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 = const $ start o
+		{ startAction = startSingle $ const $ start o
 		, checkContentPresent = Nothing
 		, usesLocationLog = True
 		}
@@ -66,7 +66,7 @@
 startKey :: MirrorOptions -> AssociatedFile -> (SeekInput, Key, ActionItem) -> CommandStart
 startKey o afile (si, key, ai) = case fromToOptions o of
 	ToRemote r -> checkFailedTransferDirection ai Upload $ ifM (inAnnex key)
-		( Command.Move.toStart NoLiveUpdate Command.Move.RemoveNever afile key ai si =<< getParsed r
+		( Command.Move.toStart NoLiveUpdate (Command.Move.Copy False) afile key ai si =<< getParsed r
 		, do
 			(numcopies, mincopies) <- getSafestNumMinCopies afile key
 			Command.Drop.startRemote NoLiveUpdate pcc afile ai si numcopies mincopies key (Command.Drop.DroppingUnused False)
diff --git a/Command/Move.hs b/Command/Move.hs
--- a/Command/Move.hs
+++ b/Command/Move.hs
@@ -35,7 +35,7 @@
 data MoveOptions = MoveOptions
 	{ moveFiles :: CmdParams
 	, fromToOptions :: Maybe FromToHereOptions
-	, removeWhen :: RemoveWhen
+	, moveAction :: MoveAction
 	, keyOptions :: Maybe KeyOptions
 	, batchOption :: BatchMode
 	}
@@ -44,7 +44,7 @@
 optParser desc = MoveOptions
 	<$> cmdParams desc
 	<*> parseFromToHereOptions
-	<*> pure RemoveSafe
+	<*> pure Move
 	<*> optional (parseKeyOptions <|> parseFailedTransfersOption)
 	<*> parseBatchOption True
 
@@ -53,13 +53,23 @@
 		<$> pure (moveFiles v)
 		<*> maybe (pure Nothing) (Just <$$> finishParse)
 			(fromToOptions v)
-		<*> pure (removeWhen v)
+		<*> pure (moveAction v)
 		<*> pure (keyOptions v)
 		<*> pure (batchOption v)
 
-data RemoveWhen = RemoveSafe | RemoveNever
+data MoveAction
+	= Move
+	| Copy { copyFast :: Bool }
+	| Put
+	| Get
 	deriving (Show, Eq)
 
+describeMoveAction :: MoveAction -> String
+describeMoveAction Move = "move"
+describeMoveAction (Copy _) = "copy"
+describeMoveAction Put = "put"
+describeMoveAction Get = "get"
+
 seek :: MoveOptions -> CommandSeek
 seek o = case fromToOptions o of
 	Just fto -> seek' o fto
@@ -76,7 +86,8 @@
 			batchAnnexed fmt seeker keyaction
   where
 	seeker = AnnexedFileSeeker
-		{ startAction = const $ start NoLiveUpdate fto (removeWhen o)
+		{ startAction = startSingle $ 
+			const $ start NoLiveUpdate fto (moveAction o)
 		, checkContentPresent = case fto of
 			FromOrToRemote (FromRemote _) -> Nothing
 			FromOrToRemote (ToRemote _) -> Just True
@@ -85,7 +96,7 @@
 			FromAnywhereToRemote _ -> Nothing
 		, usesLocationLog = True
 		}
-	keyaction = startKey NoLiveUpdate fto (removeWhen o)
+	keyaction = startKey NoLiveUpdate fto (moveAction o)
 	ww = WarnUnmatchLsFiles "move"
 
 stages :: FromToHereOptions -> UsedStages
@@ -95,77 +106,74 @@
 stages (FromRemoteToRemote _ _) = transferStages
 stages (FromAnywhereToRemote _) = transferStages
 
-start :: LiveUpdate -> FromToHereOptions -> RemoveWhen -> SeekInput -> OsPath -> Key -> CommandStart
-start lu fromto removewhen si f k = start' lu fromto removewhen afile si k ai
+start :: LiveUpdate -> FromToHereOptions -> MoveAction -> SeekInput -> OsPath -> Key -> CommandStart
+start lu fromto moveaction si f k = start' lu fromto moveaction afile si k ai
   where
 	afile = AssociatedFile (Just f)
 	ai = mkActionItem (k, afile)
 
-startKey :: LiveUpdate -> FromToHereOptions -> RemoveWhen -> (SeekInput, Key, ActionItem) -> CommandStart
-startKey lu fromto removewhen (si, k, ai) = 
-	start' lu fromto removewhen (AssociatedFile Nothing) si k ai
+startKey :: LiveUpdate -> FromToHereOptions -> MoveAction -> (SeekInput, Key, ActionItem) -> CommandStart
+startKey lu fromto moveaction (si, k, ai) = 
+	start' lu fromto moveaction (AssociatedFile Nothing) si k ai
 
-start' :: LiveUpdate -> FromToHereOptions -> RemoveWhen -> AssociatedFile -> SeekInput -> Key -> ActionItem -> CommandStart
-start' lu fromto removewhen afile si key ai =
+start' :: LiveUpdate -> FromToHereOptions -> MoveAction -> AssociatedFile -> SeekInput -> Key -> ActionItem -> CommandStart
+start' lu fromto moveaction afile si key ai =
 	case fromto of
 		FromOrToRemote (FromRemote src) ->
 			checkFailedTransferDirection ai Download $
-				fromStart lu removewhen afile key ai si =<< getParsed src
+				fromStart lu moveaction afile key ai si =<< getParsed src
 		FromOrToRemote (ToRemote dest) ->
 			checkFailedTransferDirection ai Upload $
-				toStart lu removewhen afile key ai si =<< getParsed dest
+				toStart lu moveaction afile key ai si =<< getParsed dest
 		ToHere ->
 			checkFailedTransferDirection ai Download $
-				toHereStart lu removewhen afile key ai si
+				toHereStart lu moveaction afile key ai si
 		FromRemoteToRemote src dest -> do
 			src' <- getParsed src
 			dest' <- getParsed dest
-			fromToStart lu removewhen afile key ai si src' dest'
+			fromToStart lu moveaction afile key ai si src' dest'
 		FromAnywhereToRemote dest -> do
 			dest' <- getParsed dest
-			fromAnywhereToStart lu removewhen afile key ai si dest'
-
-describeMoveAction :: RemoveWhen -> String
-describeMoveAction RemoveNever = "copy"
-describeMoveAction _ = "move"
+			fromAnywhereToStart lu moveaction afile key ai si dest'
 
-toStart :: LiveUpdate -> RemoveWhen -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> CommandStart
-toStart lu removewhen afile key ai si dest = do
+toStart :: LiveUpdate -> MoveAction -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> CommandStart
+toStart lu moveaction afile key ai si dest = do
 	u <- getUUID
 	if u == Remote.uuid dest
 		then stop
-		else toStart' lu dest removewhen afile key ai si
+		else toStart' lu dest moveaction afile key ai si
 
-toStart' :: LiveUpdate -> Remote -> RemoveWhen -> AssociatedFile -> Key -> ActionItem -> SeekInput -> CommandStart
-toStart' lu dest removewhen afile key ai si = do
-	fast <- Annex.getRead Annex.fast
-	if fast && removewhen == RemoveNever
-		then ifM (expectedPresent dest key)
+toStart' :: LiveUpdate -> Remote -> MoveAction -> AssociatedFile -> Key -> ActionItem -> SeekInput -> CommandStart
+toStart' lu dest moveaction afile key ai si =
+	case moveaction of
+		Move -> checkhaskey
+		Copy { copyFast = False } -> checkhaskey
+		_ -> ifM (expectedPresent dest key)
 			( stop
 			, go True (pure $ Right False)
 			)
-		else go False (Remote.hasKey dest key)
   where
+	checkhaskey = go False (Remote.hasKey dest key)
 	go fastcheck isthere =
-		starting (describeMoveAction removewhen) (OnlyActionOn key ai) si $
-			toPerform lu dest removewhen key afile fastcheck =<< isthere
+		starting (describeMoveAction moveaction) (OnlyActionOn key ai) si $
+			toPerform lu dest moveaction key afile fastcheck =<< isthere
 
 expectedPresent :: Remote -> Key -> Annex Bool
 expectedPresent dest key = do
 	remotes <- Remote.keyPossibilities (Remote.IncludeIgnored True) key
 	return $ dest `elem` remotes
 
-toPerform :: LiveUpdate -> Remote -> RemoveWhen -> Key -> AssociatedFile -> Bool -> Either String Bool -> CommandPerform
+toPerform :: LiveUpdate -> Remote -> MoveAction -> Key -> AssociatedFile -> Bool -> Either String Bool -> CommandPerform
 toPerform = toPerform' Nothing
 
-toPerform' :: Maybe ContentRemovalLock -> LiveUpdate -> Remote -> RemoveWhen -> Key -> AssociatedFile -> Bool -> Either String Bool -> CommandPerform
-toPerform' mcontentlock lu dest removewhen key afile fastcheck isthere = do
+toPerform' :: Maybe ContentRemovalLock -> LiveUpdate -> Remote -> MoveAction -> Key -> AssociatedFile -> Bool -> Either String Bool -> CommandPerform
+toPerform' mcontentlock lu dest moveaction key afile fastcheck isthere = do
 	srcuuid <- getUUID
 	case isthere of
 		Left err -> do
 			showNote (UnquotedString err)
 			stop
-		Right False -> logMove srcuuid destuuid False key $ \deststartedwithcopy -> do
+		Right False -> logMove moveaction srcuuid destuuid False key $ \deststartedwithcopy -> do
 			showAction $ UnquotedString $ "to " ++ Remote.name dest
 			ok <- notifyTransfer Upload afile $
 				upload dest key afile stdRetry
@@ -177,18 +185,14 @@
 					when fastcheck $
 						warning "This could have failed because --fast is enabled."
 					stop
-		Right True -> logMove srcuuid destuuid True key $ \deststartedwithcopy ->
+		Right True -> logMove moveaction srcuuid destuuid True key $ \deststartedwithcopy ->
 			finish deststartedwithcopy $
 				unlessM (expectedPresent dest key) $
 					Remote.logStatus lu dest key InfoPresent
   where
 	destuuid = Remote.uuid dest
-	finish deststartedwithcopy setpresentremote = case removewhen of
-		RemoveNever -> do
-			setpresentremote
-			logMoveCleanup deststartedwithcopy
-			next $ return True
-		RemoveSafe -> lockcontentforremoval $ \contentlock -> do
+	finish deststartedwithcopy setpresentremote = case moveaction of
+		Move -> lockcontentforremoval $ \contentlock -> do
 			srcuuid <- getUUID
 			r <- willDropMakeItWorse lu srcuuid destuuid deststartedwithcopy key afile >>= \case
 				DropAllowed -> drophere setpresentremote contentlock "moved"
@@ -203,6 +207,10 @@
 				DropWorse -> faileddrophere setpresentremote
 			logMoveCleanup deststartedwithcopy
 			return r
+		_ -> do
+			setpresentremote
+			logMoveCleanup deststartedwithcopy
+			next $ return True
 	showproof proof = "proof: " ++ show proof
 	drophere setpresentremote contentlock reason = do
 		fastDebug "Command.Move" $ unwords
@@ -235,11 +243,11 @@
 	lockfailed = next $ Command.Drop.cleanupLocal lu key
 		(Command.Drop.DroppingUnused False)
 
-fromStart :: LiveUpdate -> RemoveWhen -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> CommandStart
-fromStart lu removewhen afile key ai si src = 
+fromStart :: LiveUpdate -> MoveAction -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> CommandStart
+fromStart lu moveaction afile key ai si src = 
 	stopUnless (fromOk src key) $
-		starting (describeMoveAction removewhen) (OnlyActionOn key ai) si $
-			fromPerform lu src removewhen key afile
+		starting (describeMoveAction moveaction) (OnlyActionOn key ai) si $
+			fromPerform lu src moveaction key afile
 
 fromOk :: Remote -> Key -> Annex Bool
 fromOk src key
@@ -259,17 +267,17 @@
 		remotes <- Remote.keyPossibilities (Remote.IncludeIgnored True) key
 		return $ u /= Remote.uuid src && elem src remotes
 
-fromPerform :: LiveUpdate -> Remote -> RemoveWhen -> Key -> AssociatedFile -> CommandPerform
-fromPerform lu src removewhen key afile = do
+fromPerform :: LiveUpdate -> Remote -> MoveAction -> Key -> AssociatedFile -> CommandPerform
+fromPerform lu src moveaction key afile = do
 	present <- inAnnex key
-	finish <- fromPerform' lu present True src key afile
-	finish removewhen
+	finish <- fromPerform' lu present True src moveaction key afile
+	finish moveaction
 
-fromPerform' :: LiveUpdate -> Bool -> Bool -> Remote -> Key -> AssociatedFile -> Annex (RemoveWhen -> CommandPerform)
-fromPerform' lu present updatelocationlog src key afile = do
+fromPerform' :: LiveUpdate -> Bool -> Bool -> Remote -> MoveAction -> Key -> AssociatedFile -> Annex (MoveAction -> CommandPerform)
+fromPerform' lu present updatelocationlog src moveaction key afile = do
 	showAction $ UnquotedString $ "from " ++ Remote.name src
 	destuuid <- getUUID
-	logMove (Remote.uuid src) destuuid present key $ \deststartedwithcopy ->
+	logMove moveaction (Remote.uuid src) destuuid present key $ \deststartedwithcopy ->
 		if present
 			then return $ finish deststartedwithcopy True
 			else do
@@ -287,13 +295,13 @@
 	finish deststartedwithcopy False _ = do
 		logMoveCleanup deststartedwithcopy
 		stop -- copy failed
-	finish deststartedwithcopy True RemoveNever = do
-		logMoveCleanup deststartedwithcopy
-		next $ return True -- copy complete
-	finish deststartedwithcopy True RemoveSafe = do
+	finish deststartedwithcopy True Move = do
 		destuuid <- getUUID
 		lockContentShared key Nothing $ \_lck ->
 			fromDrop lu src destuuid deststartedwithcopy key afile id
+	finish deststartedwithcopy True _ = do
+		logMoveCleanup deststartedwithcopy
+		next $ return True -- copy complete
 
 fromDrop :: LiveUpdate -> Remote -> UUID -> DestStartedWithCopy -> Key -> AssociatedFile -> ([UnVerifiedCopy] -> [UnVerifiedCopy])-> CommandPerform
 fromDrop lu src destuuid deststartedwithcopy key afile adjusttocheck =
@@ -337,59 +345,57 @@
  -
  - When moving, the content is removed from all the reachable remotes that
  - it can safely be removed from. -}
-toHereStart :: LiveUpdate -> RemoveWhen -> AssociatedFile -> Key -> ActionItem -> SeekInput -> CommandStart
-toHereStart lu removewhen afile key ai si = 
+toHereStart ::LiveUpdate -> MoveAction -> AssociatedFile -> Key -> ActionItem -> SeekInput -> CommandStart
+toHereStart lu moveaction afile key ai si = 
 	startingNoMessage (OnlyActionOn key ai) $ do
 		rs <- Remote.keyPossibilities (Remote.IncludeIgnored False) key
 		forM_ rs $ \r ->
 			includeCommandAction $
-				starting (describeMoveAction removewhen) ai si $
-					fromPerform lu r removewhen key afile
+				starting (describeMoveAction moveaction) ai si $
+					fromPerform lu r moveaction key afile
 		next $ return True
 
-fromToStart :: LiveUpdate -> RemoveWhen -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> Remote -> CommandStart
-fromToStart lu removewhen afile key ai si src dest = 
+fromToStart :: LiveUpdate -> MoveAction -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> Remote -> CommandStart
+fromToStart lu moveaction afile key ai si src dest = 
 	stopUnless somethingtodo $ do
 		u <- getUUID
 		if u == Remote.uuid src
-			then toStart lu removewhen afile key ai si dest
+			then toStart lu moveaction afile key ai si dest
 			else if u == Remote.uuid dest
-				then fromStart lu removewhen afile key ai si src
+				then fromStart lu moveaction afile key ai si src
 				else stopUnless (fromOk src key) $
-					starting (describeMoveAction removewhen) (OnlyActionOn key ai) si $
-						fromToPerform lu src dest removewhen key afile
+					starting (describeMoveAction moveaction) (OnlyActionOn key ai) si $
+						fromToPerform lu src dest moveaction key afile
   where
 	somethingtodo
 		| Remote.uuid src == Remote.uuid dest = return False
-		| otherwise = do
-			fast <- Annex.getRead Annex.fast
-			if fast && removewhen == RemoveNever
-				then not <$> expectedPresent dest key
-				else return True
+		| otherwise = case moveaction of
+			Move -> return True
+			Copy { copyFast = False } -> return True
+			_ -> not <$> expectedPresent dest key
 
-fromAnywhereToStart :: LiveUpdate -> RemoveWhen -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> CommandStart
-fromAnywhereToStart lu removewhen afile key ai si dest =
+fromAnywhereToStart :: LiveUpdate -> MoveAction -> AssociatedFile -> Key -> ActionItem -> SeekInput -> Remote -> CommandStart
+fromAnywhereToStart lu moveaction afile key ai si dest =
 	stopUnless somethingtodo $ do
 		u <- getUUID
 		if u == Remote.uuid dest
-			then toHereStart lu removewhen afile key ai si
+			then toHereStart lu moveaction 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 lu r dest removewhen key afile
+						starting (describeMoveAction moveaction) ai si $
+							fromToPerform lu r dest moveaction key afile
 				whenM (inAnnex key) $
 					void $ includeCommandAction $
-						toStart lu removewhen afile key ai si dest 
+						toStart lu moveaction 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
+	somethingtodo = case moveaction of
+		Move -> return True
+		Copy { copyFast = False } -> return True
+		_ -> not <$> expectedPresent dest key
 
 {- When there is a local copy, transfer it to the dest, and drop from the src.
  -
@@ -406,8 +412,8 @@
  - may end up locally present, or not. This is similar to the behavior 
  - when running `git-annex move --to` concurrently with git-annex get.
  -}
-fromToPerform :: LiveUpdate -> Remote -> Remote -> RemoveWhen -> Key -> AssociatedFile -> CommandPerform
-fromToPerform lu src dest removewhen key afile = do
+fromToPerform :: LiveUpdate -> Remote -> Remote -> MoveAction -> Key -> AssociatedFile -> CommandPerform
+fromToPerform lu src dest moveaction key afile = do
 	hereuuid <- getUUID
 	loggedpresent <- any (== hereuuid)
 		<$> loggedLocations key
@@ -429,9 +435,9 @@
 		dropsrc <- fromsrc True
 		combinecleanups 
 			-- Send to dest, preserve local copy.
-			(todest Nothing RemoveNever haskey)
+			(todest Nothing (Copy False) haskey)
 			(\senttodest -> if senttodest
-				then dropsrc removewhen
+				then dropsrc moveaction
 				else stop
 			)
 	go ispresent _loggedpresent = do
@@ -466,26 +472,26 @@
 				lockContentForRemoval key stop $ \contentlock ->
 					combinecleanups
 						-- Send to dest and remove local copy.
-						(todest (Just contentlock) RemoveSafe haskey)
+						(todest (Just contentlock) Move haskey)
 						(\senttodest ->
 							-- Drop from src, checking
 							-- copies including dest.
 							combinecleanups
-								(cleanupfromsrc RemoveNever)
+								(cleanupfromsrc (Copy False))
 								(\_ -> if senttodest
 									then dropfromsrc (\l -> UnVerifiedRemote dest : l)
 									else stop
 								)
 						)
 
-	fromsrc present = fromPerform' lu present False src key afile
+	fromsrc present = fromPerform' lu present False src moveaction key afile
 
-	todest mcontentlock removewhen' = toPerform' mcontentlock lu dest removewhen' key afile False
+	todest mcontentlock moveaction' = toPerform' mcontentlock lu dest moveaction' key afile False
 
-	dropfromsrc adjusttocheck = case removewhen of
-		RemoveSafe -> logMove (Remote.uuid src) (Remote.uuid dest) True key $ \deststartedwithcopy ->
+	dropfromsrc adjusttocheck = case moveaction of
+		Move -> logMove moveaction (Remote.uuid src) (Remote.uuid dest) True key $ \deststartedwithcopy ->
 			fromDrop lu src (Remote.uuid dest) deststartedwithcopy key afile adjusttocheck
-		RemoveNever -> next (return True)
+		_ -> next (return True)
 
 	combinecleanups a b = a >>= \case
 		Just cleanupa -> b True >>= \case
@@ -569,8 +575,8 @@
  - copy, and so could refuse to allow the drop. By providing the logged
  - DestStartedWithCopy, this avoids that annoyance.
  -}
-logMove :: UUID -> UUID -> Bool -> Key -> (DestStartedWithCopy -> Annex a) -> Annex a
-logMove srcuuid destuuid deststartedwithcopy key a = go =<< setup
+logMove :: MoveAction -> UUID -> UUID -> Bool -> Key -> (DestStartedWithCopy -> Annex a) -> Annex a
+logMove Move srcuuid destuuid deststartedwithcopy key a = go =<< setup
   where
 	logline = L.fromStrict $ B8.unwords
 		[ fromUUID srcuuid
@@ -608,3 +614,4 @@
 
 	go' fs deststartedwithcopy' = a $
 		DestStartedWithCopy deststartedwithcopy' (cleanup fs)
+logMove _ _ _ _ _ a = a (DestStartedWithCopy False noop)
diff --git a/Command/Multicast.hs b/Command/Multicast.hs
--- a/Command/Multicast.hs
+++ b/Command/Multicast.hs
@@ -239,7 +239,7 @@
 -- is a 8 digit hex number in the form "0xnnnnnnnn"
 -- Derive it from the UUID.
 uftpUID :: UUID -> String
-uftpUID u = "0x" ++ (take 8 $ show $ sha2_256 $ B8.fromString (fromUUID u))
+uftpUID u = "0x" ++ (take 8 $ show $ digestToHash $ sha2_256 $ B8.fromString (fromUUID u))
 
 withAuthList :: (OsPath -> Annex a) -> Annex a
 withAuthList a = do
diff --git a/Command/PostReceive.hs b/Command/PostReceive.hs
--- a/Command/PostReceive.hs
+++ b/Command/PostReceive.hs
@@ -14,7 +14,7 @@
 import qualified Annex
 import Annex.UpdateInstead
 import Annex.CurrentBranch
-import Command.Sync (mergeLocal, prepMerge, mergeConfig, SyncOptions(..))
+import Command.Sync (mergeLocalLikePull, prepMerge, mergeConfig, SyncOptions(..))
 import Annex.Proxy
 import Remote
 import qualified Types.Remote as Remote
@@ -53,7 +53,7 @@
 	prepMerge
 	let o = def { notOnlyAnnexOption = True }
 	mc <- mergeConfig False
-	mergeLocal mc o =<< getCurrentBranch
+	mergeLocalLikePull mc o =<< getCurrentBranch
 
 proxyExportTree :: CommandSeek
 proxyExportTree = do
diff --git a/Command/Put.hs b/Command/Put.hs
new file mode 100644
--- /dev/null
+++ b/Command/Put.hs
@@ -0,0 +1,83 @@
+{- git-annex command
+ -
+ - Copyright 2026 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Command.Put where
+
+import Command
+import qualified Annex
+import qualified Command.Copy
+import qualified Command.Move (MoveAction(..))
+import qualified Remote
+
+cmd :: Command
+cmd = withAnnexOptions [jobsOption, jsonOptions, jsonProgressOption, annexedMatchingOptions] $
+	command "put" SectionCommon
+		"send content of files to other repositories"
+		paramPaths (seek <$$> optParser)
+
+data PutOptions = PutOptions
+	{ putFiles :: CmdParams
+	, keyOptions :: Maybe KeyOptions
+	, wantedMode :: Bool
+	, autoMode :: Bool
+	, batchOption :: BatchMode
+	}
+
+optParser :: CmdParamsDesc -> Parser PutOptions
+optParser desc = PutOptions
+	<$> cmdParams desc
+	<*> optional (parseKeyOptions <|> parseFailedTransfersOption)
+	<*> parseWantedOption
+	<*> parseAutoOption
+	<*> parseBatchOption True
+
+seek :: PutOptions -> CommandSeek
+seek o = startConcurrency commandStages $ do
+	fast <- Annex.getRead Annex.fast
+	let fastest = fromMaybe [] . headMaybe
+	contentremotes <- filter Remote.canPut
+		. (if fast then fastest else concat)
+		. Remote.byCost 
+		<$> (Remote.contentRemotes =<< Remote.remoteList)
+	let seeker = AnnexedFileSeeker
+		{ startAction = \_ si p k ->
+			return $ flip map contentremotes $ \r ->
+				Command.Copy.start co (to r) (fai r) si p k
+		, checkContentPresent = Just True
+		, usesLocationLog = True
+		}
+	let keyaction v = 
+		return $ flip map contentremotes $ \r ->
+			Command.Copy.startKey co (to r) (fai r) v
+	case batchOption o of
+		NoBatch -> withKeyOptions
+			(keyOptions o) (autoMode o || wantedMode o) seeker
+			(\v -> commandActions =<< keyaction v)
+			(withFilesInGitAnnex ww seeker)
+			=<< workTreeItems ww (putFiles o)
+		Batch fmt -> batchOnly (keyOptions o) (putFiles o) $
+			batchAnnexed' fmt seeker keyaction
+  where
+	ww = WarnUnmatchLsFiles "put"
+
+	co = Command.Copy.CopyOptions
+		{ Command.Copy.copyFiles = putFiles o
+		, Command.Copy.fromToOptions = Nothing
+		, Command.Copy.keyOptions = keyOptions o
+		, Command.Copy.autoMode = autoMode o
+		, Command.Copy.wantedMode = wantedMode o
+		, Command.Copy.batchOption = batchOption o
+		, Command.Copy.moveAction = Command.Move.Put
+		}
+	
+	to = FromOrToRemote . ToRemote . ReadyParse
+
+	-- Since put can send to remotes of its choosing, suppliment the
+	-- ActionItem with the uuid. This makes the json include the remote
+	-- uuid.
+	fai r ai = ActionItemForUUID (Remote.uuid r) ai
+
diff --git a/Command/Recompute.hs b/Command/Recompute.hs
--- a/Command/Recompute.hs
+++ b/Command/Recompute.hs
@@ -59,7 +59,7 @@
 	computeremote <- maybe (pure Nothing) (Just <$$> getParsed)
 		(computeRemote o)
 	let seeker = AnnexedFileSeeker
-		{ startAction = const $ start o computeremote
+		{ startAction = startSingle $ const $ start o computeremote
 		, checkContentPresent = Nothing
 		, usesLocationLog = True
 		}
diff --git a/Command/Sync.hs b/Command/Sync.hs
--- a/Command/Sync.hs
+++ b/Command/Sync.hs
@@ -17,7 +17,7 @@
 	mergeConfig,
 	merge,
 	prepMerge,
-	mergeLocal,
+	mergeLocalLikePull,
 	mergeRemote,
 	commitMsg,
 	pushBranch,
@@ -273,11 +273,8 @@
 			]
 	
 	remotes <- mapM (pushToCreate o) =<< syncRemotes (syncWith o)
-	-- Remotes that git can push to and pull from.
 	let gitremotes = filter Remote.gitSyncableRemote remotes
-	-- Remotes that contain annex object content.
-	contentremotes <- filter (\r -> Remote.uuid r /= NoUUID)
-		<$> filterM (not <$$> liftIO . getDynamicConfig . remoteAnnexIgnore . Remote.gitconfig) remotes
+	contentremotes <- Remote.contentRemotes remotes
 
 	if cleanupOption o
 		then do
@@ -297,9 +294,9 @@
 				whenM (annexSyncMigrations <$> Annex.getGitConfig) $
 					Command.Migrate.seekDistributedMigrations True
 
-			forM_ (filter isImport contentremotes) $
+			forM_ (filter Remote.isImport contentremotes) $
 				withbranch . importRemote changehere o
-			forM_ (filter isThirdPartyPopulated contentremotes) $
+			forM_ (filter Remote.isThirdPartyPopulated contentremotes) $
 				pullThirdPartyPopulated o
 			
 			when content $ do
@@ -314,8 +311,8 @@
 				-- importing only downloads new files not
 				-- old files)
 				let shouldsynccontent r
-					| isExport r && not (isImport r) 
-						&& not (exportHasAnnexObjects r) = False
+					| Remote.isExport r && not (Remote.isImport r) 
+						&& not (annexObjects (Remote.config r)) = False
 					| otherwise = True
 				syncedcontent <- withbranch $
 					seekSyncContent o
@@ -363,17 +360,26 @@
 			else Nothing
 		]
 
-merge :: CurrBranch -> [Git.Merge.MergeConfig] -> SyncOptions -> Git.Branch.CommitMode -> [Git.Branch] -> Annex Bool
+merge :: CurrBranch -> [Git.Merge.MergeConfig] -> SyncOptions -> Git.Branch.CommitMode -> [(Git.Branch, Annex ())] -> Annex Bool
 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 ->
+			(Just b, Just adj) -> forM tomergel $ \(tomerge, postmerge) ->
 				mergeToAdjustedBranch tomerge (b, adj) mergeconfig canresolvemerge commitmode
-			(b, _) -> forM tomergel $ \tomerge ->
+					`andthen` postmerge
+			(b, _) -> forM tomergel $ \(tomerge, postmerge) ->
 				autoMergeFrom tomerge b mergeconfig commitmode canresolvemerge
+					`andthen` postmerge
+  where
+	andthen a b = ifM a
+		( do
+			() <- b
+			return True
+		, return False
+		)
 
 syncBranch :: Git.Branch -> Git.Branch
 syncBranch = Git.Ref.underBase "refs/heads/synced" . origBranch
@@ -439,28 +445,30 @@
 		++ maybe "unknown" fromUUIDDesc (M.lookup u m)
 
 mergeLocal :: [Git.Merge.MergeConfig] -> SyncOptions -> CurrBranch -> CommandStart
-mergeLocal mergeconfig o currbranch = stopUnless shouldmerge $
-	mergeLocal' mergeconfig o currbranch
-  where
-	shouldmerge = pure (pullOption o) <&&> notOnlyAnnex o
+mergeLocal mergeconfig o currbranch = stopUnless (pure (pullOption o)) $
+	mergeLocalLikePull mergeconfig o currbranch
 
-mergeLocal' :: [Git.Merge.MergeConfig] -> SyncOptions -> CurrBranch -> CommandStart
-mergeLocal' mergeconfig o currbranch@(Just branch, _) =
+mergeLocalLikePull :: [Git.Merge.MergeConfig] -> SyncOptions -> CurrBranch -> CommandStart
+mergeLocalLikePull mergeconfig o currbranch = stopUnless (notOnlyAnnex o) $ 
+	mergeLocal'' mergeconfig o currbranch
+
+mergeLocal'' :: [Git.Merge.MergeConfig] -> SyncOptions -> CurrBranch -> CommandStart
+mergeLocal'' mergeconfig o currbranch@(Just branch, _) =
 	needMerge currbranch branch >>= \case
 		[] -> stop
 		tomerge -> do
-			let ai = ActionItemOther (Just $ UnquotedString $ unwords $ map Git.Ref.describe tomerge)
+			let ai = ActionItemOther (Just $ UnquotedString $ unwords $ map Git.Ref.describe $ map fst tomerge)
 			let si = SeekInput []
 			starting "merge" ai si $
 				next $ merge currbranch mergeconfig o Git.Branch.ManualCommit tomerge
-mergeLocal' _ _ currbranch@(Nothing, _) = inRepo Git.Branch.currentUnsafe >>= \case
+mergeLocal'' _ _ currbranch@(Nothing, _) = inRepo Git.Branch.currentUnsafe >>= \case
 	Just branch -> needMerge currbranch branch >>= \case
 		[] -> stop
 		tomerge -> do
-			let ai = ActionItemOther (Just $ UnquotedString $ unwords $ map Git.Ref.describe tomerge)
+			let ai = ActionItemOther (Just $ UnquotedString $ unwords $ map Git.Ref.describe $ map fst tomerge)
 			let si = SeekInput []
 			starting "merge" ai si $ do
-				warning $ UnquotedString $ "There are no commits yet to branch " ++ Git.fromRef branch ++ ", so cannot merge " ++ unwords (map Git.fromRef tomerge) ++ " into it."
+				warning $ UnquotedString $ "There are no commits yet to branch " ++ Git.fromRef branch ++ ", so cannot merge " ++ unwords (map (Git.fromRef . fst) tomerge) ++ " into it."
 				next $ return False
 	Nothing -> stop
 
@@ -468,34 +476,62 @@
 --
 -- Usually this is the sync branch. However, when in an adjusted branch,
 -- it can be either the sync branch or the original branch, or both.
-needMerge :: CurrBranch -> Git.Branch -> Annex [Git.Branch]
+--
+-- Branches are accompanied by an action to run once they have been
+-- successfully merged.
+needMerge :: CurrBranch -> Git.Branch -> Annex [(Git.Branch, Annex ())]
 needMerge currbranch headbranch
 	| is_branchView headbranch = return []
 	| otherwise = ifM isBareRepo
 		( return []
 		, do
-			syncbranchret <- usewhen syncbranch syncbranchchecks
+			syncbranchret <- usewhen (removeaftermerge syncbranch) syncbranchchecks
 			adjbranchret <- case currbranch of
 				(Just origbranch, Just adj) -> 
-					usewhen origbranch $
+					usewhen (pure (origbranch, noop)) $
 						canMergeToAdjustedBranch origbranch (origbranch, adj)
 				_ -> return []
 			return (syncbranchret++adjbranchret)
 		)
   where
-	usewhen v c = ifM c
-		( return [v]
+	usewhen getv c = ifM c
+		( do
+			v <- getv
+			return [v]
 		, return []
 		)
 	syncbranch = syncBranch headbranch
+
 	syncbranchchecks = case currbranch of
 		(Just _, madj) -> syncbranchchanged madj
 		(Nothing, _) -> hassyncbranch
+
 	hassyncbranch = inRepo (Git.Ref.exists syncbranch)
-	syncbranchchanged madj =
+
+	syncbranchchanged madj = do
 		let branch' = maybe headbranch (adjBranch . originalToAdjusted headbranch) madj
-		in hassyncbranch <&&> inRepo (Git.Branch.changed branch' syncbranch)
+		inRepo (Git.Ref.sha syncbranch) >>= \case
+			Nothing -> return False
+			Just sha -> ifM (inRepo $ Git.Branch.changed branch' sha)
+				( return True
+				, do
+					removewhenunchanged (Just sha) syncbranch
+					return False
+				)
 
+	-- Remove the ref after merging, but only if its value does not
+	-- change in the meantime. The sync branch can get changes pushed
+	-- to it at any time, and this avoids losing them.
+	removeaftermerge b = do
+		sorig <- inRepo $ Git.Ref.sha b
+		return (b, removewhenunchanged sorig b)
+	
+	removewhenunchanged sorig b =
+		inRepo (Git.Ref.sha b) >>= \case
+			Just s | Just s == sorig ->
+				inRepo $ Git.Ref.deleteQuiet s b
+			_  -> noop
+
 updateLocal :: SyncOptions -> CurrBranch -> CommandStart
 updateLocal o b = stopUnless (notOnlyAnnex o) $ do
 	updateBranches (pullOption o || syncmode) (pushOption o || syncmode) b
@@ -519,9 +555,6 @@
 				when forpull $
 					updateadjustedbranch adj
 			Nothing -> noop
-	
-	-- Update the sync branch to match the new state of the branch
-	inRepo $ updateBranch (syncBranch branch) (fromViewBranch branch)
   where
 	-- The adjusted branch may need to be updated, if the adjustment
 	-- is not stable, and the usual configuration does not update it.
@@ -636,7 +669,7 @@
 	
 	wantpull = remoteAnnexPull (Remote.gitconfig remote)
 
-{- The remote probably has both a master and a synced/master branch.
+{- The remote often has both a master and a synced/master branch.
  - Which to merge from? Well, the master has whatever latest changes
  - were committed (or pushed changes, if this is a bare remote),
  - while the synced/master may have changes that some
@@ -647,20 +680,22 @@
 	, case currbranch of
 		(Nothing, _) -> do
 			branch <- inRepo Git.Branch.currentUnsafe
-			mergelisted (pure (branchlist branch))
-		(Just branch, _) -> do
-			inRepo $ updateBranch (syncBranch branch) branch
-			mergelisted (tomerge (branchlist (Just branch)))
+			domerge =<< branchlist branch
+		(Just branch, _) ->
+			domerge
+				=<< changedfrom branch
+				=<< branchlist (Just branch)
 	)
   where
-	mergelisted getlist =
-		merge currbranch mergeconfig o Git.Branch.ManualCommit
-			=<< map (remoteBranch remote) <$> getlist
-	tomerge = filterM (changed remote)
-	branchlist Nothing = []
+	domerge = merge currbranch mergeconfig o Git.Branch.ManualCommit . map (\b -> (b, noop))
+	changedfrom branch = filterM (inRepo . Git.Branch.changed branch)
+	branchlist Nothing = pure []
 	branchlist (Just branch)
-		| is_branchView branch = []
-		| otherwise = [origBranch branch, syncBranch branch]
+		| is_branchView branch = pure []
+		| otherwise = filterM (inRepo . Git.Ref.exists)
+			[ remoteBranch remote (origBranch branch)
+			, remoteBranch remote (syncBranch branch)
+			]
 
 pushRemote :: SyncOptions -> Remote -> CurrBranch -> CommandStart
 pushRemote _o _remote (Nothing, _) = stop
@@ -686,9 +721,14 @@
 	needpush mainbranch
 		| remoteAnnexReadOnly gc = return False
 		| not (remoteAnnexPush gc) = return False
-		| otherwise = anyM (newer remote) $ catMaybes
-			[ syncBranch <$> mainbranch
-			, Just (Annex.Branch.name)
+		| otherwise = anyM id $ concat
+			[ case mainbranch of
+				Just b ->
+					[ newer remote b b True
+					, newer remote (syncBranch b) b False
+					]
+				Nothing -> []
+			, [ newer remote Annex.Branch.name Annex.Branch.name True ]
 			]
 	-- Older remotes on crippled filesystems may not have a
 	-- post-receive hook set up, so when updateInstead emulation
@@ -717,7 +757,7 @@
  - branch directly to it, so that cloning/pulling will get it.
  - On the other hand, if it's not bare, pushing to the checked out branch
  - will generally fail (except with receive.denyCurrentBranch=updateInstead),
- - and this is why we push to its syncBranch.
+ - and this is when it's useful to push to its syncBranch.
  -
  - Git offers no way to tell if a remote is bare or not, so both methods
  - are tried.
@@ -726,8 +766,9 @@
  - github may treat the first branch pushed to a new repository as the
  - default branch for that repository.
  -
- - The sync push first sends the synced/master branch,
- - and then forces the update of the remote synced/git-annex branch.
+ - The sync push first sends the synced/master branch
+ - (when the direct push failed), and then forces the update of 
+ - the remote synced/git-annex branch.
  -
  - The forcing is necessary if a transition has rewritten the git-annex branch.
  - Normally any changes to the git-annex branch get pulled and merged before
@@ -756,16 +797,18 @@
 				pushparams True
 					[ Git.fromRef $ Git.Ref.base $ origBranch branch ]
 			let p' = p { std_err = CreatePipe }
-			bracket (createProcess p') cleanupProcess $ \h -> do
+			exitcode <- bracket (createProcess p') cleanupProcess $ \h -> do
 				filterstderr [] (stderrHandle h) (processHandle h)
-				void $ waitForProcess (processHandle h)
-			return True
-		Nothing -> return False
-				
-	syncpush directpushed =  do
+				waitForProcess (processHandle h)
+			return (True, exitcode == ExitSuccess)
+		Nothing -> return (False, False)
+	
+	syncpush (directpushed, directbranchupdated) =  do
 		let p = flip Git.Command.gitCreateProcess g $
 			pushparams (not directpushed) $ catMaybes
-				[ (syncrefspec . origBranch) <$> mbranch
+				[ if not directbranchupdated
+					then (syncrefspec . origBranch) <$> mbranch
+					else Nothing
 				, Just $ Git.Branch.forcePush $ syncrefspec Annex.Branch.name
 				]
 		-- stderr is relayed through a pipe so that the push
@@ -833,20 +876,12 @@
 	void Annex.Branch.forceUpdate
 	stop
 
-changed :: Remote -> Git.Ref -> Annex Bool
-changed remote b = do
-	let r = remoteBranch remote b
-	ifM (inRepo $ Git.Ref.exists r)
-		( inRepo $ Git.Branch.changed b r
-		, return False
-		)
-
-newer :: Remote -> Git.Ref -> Annex Bool
-newer remote b = do
-	let r = remoteBranch remote b
+newer :: Remote -> Git.Ref -> Git.Ref -> Bool -> Annex Bool
+newer remote rb b dne = do
+	let r = remoteBranch remote rb
 	ifM (inRepo $ Git.Ref.exists r)
 		( inRepo $ Git.Branch.changed r b
-		, return True
+		, return dne
 		)
 
 {- Without --all, only looks at files in the work tree.
@@ -894,7 +929,8 @@
   where
 	seekworktree mvar l bloomfeeder = do
 		let seeker = AnnexedFileSeeker
-			{ startAction = const $ gofile bloomfeeder mvar
+			{ startAction = startSingle $ 
+				const $ gofile bloomfeeder mvar
 			, checkContentPresent = Nothing
 			, usesLocationLog = True
 			}
@@ -1008,10 +1044,7 @@
 
 	wantput lu r
 		| pushOption o == False && not satisfymode = return False
-		| Remote.readonly r || remoteAnnexReadOnly (Remote.gitconfig r) = return False
-		| isImport r && not (isExport r) = return False
-		| isExport r && not (exportHasAnnexObjects r) = return False
-		| isThirdPartyPopulated r = return False
+		| not (Remote.canPut r) = return False
 		| otherwise = wantGetBy lu True (Just k) af (Remote.uuid r)
 	handleput lack inhere
 		| inhere = catMaybes <$>
@@ -1023,8 +1056,8 @@
 					)
 			)
 		| otherwise = return []
-	put lu dest = includeCommandAction $ 
-		Command.Move.toStart' lu dest Command.Move.RemoveNever af k ai si
+	put lu dest = includeCommandAction $
+		Command.Move.toStart' lu dest Command.Move.Put af k ai si
 	
 	dropfromhere = changehere && (pullOption o || satisfymode)
 
@@ -1036,7 +1069,7 @@
 
 	ai = mkActionItem (k, af)
 	si = SeekInput []
-
+		
 {- When a remote has an annex-tracking-branch configuration, and that branch
  - is currently checked out, change the export to contain the current content
  - of the branch. (If the branch is not currently checked out, anything
@@ -1053,7 +1086,7 @@
 seekExportContent o rs currbranch =
 	seekExportContent' o (filter canexportcontent rs) currbranch
   where
-	canexportcontent r = isExport r && not (isProxied r)
+	canexportcontent r = Remote.isExport r && not (isProxied r)
 
 seekExportContent' :: Maybe SyncOptions -> [Remote] -> CurrBranch -> Annex Bool
 seekExportContent' o rs (mcurrbranch, madj)
@@ -1219,21 +1252,6 @@
 	| onlyAnnexOption o = pure True
 	| otherwise = getGitConfigVal annexSyncOnlyAnnex
 
-isExport :: Remote -> Bool
-isExport = exportTree . Remote.config
-
-isImport :: Remote -> Bool
-isImport = importTree . Remote.config
-
-isProxied :: Remote -> Bool
-isProxied = isJust . remoteAnnexProxiedBy . Remote.gitconfig
-
-exportHasAnnexObjects :: Remote -> Bool
-exportHasAnnexObjects = annexObjects . Remote.config
-
-isThirdPartyPopulated :: Remote -> Bool
-isThirdPartyPopulated = Remote.thirdPartyPopulated . Remote.remotetype
-
 {- Support for push-to-create of git repositories.
  -
  - When the remote does not exist yet, annex-ignore and
@@ -1273,3 +1291,7 @@
 		case filter (\r' -> Remote.name r' == Remote.name r) rs of
 			(r':_) -> return r'
 			_ -> return r
+
+isProxied :: Remote -> Bool
+isProxied = isJust . remoteAnnexProxiedBy . Remote.gitconfig
+
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 = const $ start fast
+	{ startAction = startSingle $ 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 = const start
+		{ startAction = startSingle $ 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 = startSingle $ \_ _ _ _ -> return Nothing
 		, checkContentPresent = Nothing
 		, usesLocationLog = False
 		}
diff --git a/Command/Whereis.hs b/Command/Whereis.hs
--- a/Command/Whereis.hs
+++ b/Command/Whereis.hs
@@ -52,7 +52,7 @@
 seek o = do
 	m <- remoteMap id
 	let seeker = AnnexedFileSeeker
-		{ startAction = const $ start o m
+		{ startAction = startSingle $ const $ start o m
 		, checkContentPresent = Nothing
 		, usesLocationLog = True
 		}
diff --git a/Git/Ref.hs b/Git/Ref.hs
--- a/Git/Ref.hs
+++ b/Git/Ref.hs
@@ -178,8 +178,12 @@
 		in (Ref r, Ref b)
 
 {- Deletes a ref when it contains the specified sha. 
- - This can delete refs that are not branches, which
- - git branch --delete refuses to delete. -}
+ -
+ - This can delete refs that are not branches, which git branch --delete
+ - refuses to delete.
+ -
+ - Displays a warning on stderr if the ref does not contain the specified sha.
+ -}
 delete :: Sha -> Ref -> Repo -> IO ()
 delete oldvalue ref = run
 	[ Param "update-ref"
@@ -187,6 +191,15 @@
 	, Param $ fromRef ref
 	, Param $ fromRef oldvalue
 	]
+
+{- Like delete, but with no output on stderr. -}
+deleteQuiet :: Sha -> Ref -> Repo -> IO ()
+deleteQuiet oldvalue ref r = void $ tryNonAsync $ runQuiet
+	[ Param "update-ref"
+	, Param "-d"
+	, Param $ fromRef ref
+	, Param $ fromRef oldvalue
+	] r
 
 {- Deletes a ref no matter what it contains. -}
 delete' :: Ref -> Repo -> IO ()
diff --git a/P2P/Protocol.hs b/P2P/Protocol.hs
--- a/P2P/Protocol.hs
+++ b/P2P/Protocol.hs
@@ -21,7 +21,7 @@
 import Types.UUID
 import Types.Transfer
 import Types.Remote (Verification(..))
-import Utility.Hash (IncrementalVerifier(..))
+import Utility.Hash.Incremental
 import Utility.AuthToken
 import Utility.Applicative
 import Utility.PartialPrelude
diff --git a/Remote.hs b/Remote.hs
--- a/Remote.hs
+++ b/Remote.hs
@@ -1,6 +1,6 @@
 {- git-annex remotes
  -
- - Copyright 2011-2024 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -65,6 +65,11 @@
 	isExportSupported,
 	gitSyncableRemote,
 	gitSyncableRemoteType,
+	contentRemotes,
+	canPut,
+	isExport,
+	isImport,
+	isThirdPartyPopulated,
 ) where
 
 import Data.Ord
@@ -75,6 +80,7 @@
 import Annex.Common
 import Types.Remote
 import qualified Annex
+import Annex.SpecialRemote.Config
 import Annex.UUID
 import Annex.Action
 import Logs.UUID
@@ -463,3 +469,29 @@
 	| otherwise = case remoteUrl (gitconfig r) of
 		Just u | "annex::" `isPrefixOf` u -> True
 		_ -> False
+
+{- Filters a list of remotes to those that contain annex object content. -}
+contentRemotes :: [Remote] -> Annex [Remote]
+contentRemotes remotes = 
+	filter (\r -> uuid r /= NoUUID)
+		<$> filterM (not <$$> ignored) remotes
+  where
+	ignored = liftIO . getDynamicConfig . remoteAnnexIgnore . gitconfig
+
+{- Can annex objects be stored on a remote? -}
+canPut :: Remote -> Bool
+canPut r
+	| readonly r || remoteAnnexReadOnly (gitconfig r) = False
+	| isImport r && not (isExport r) = False
+	| isExport r && not (annexObjects (config r)) = False
+	| isThirdPartyPopulated r = False
+	| otherwise = True
+
+isExport :: Remote -> Bool
+isExport = exportTree . config
+
+isImport :: Remote -> Bool
+isImport = importTree . config
+
+isThirdPartyPopulated :: Remote -> Bool
+isThirdPartyPopulated = thirdPartyPopulated . remotetype
diff --git a/Remote/Bup.hs b/Remote/Bup.hs
--- a/Remote/Bup.hs
+++ b/Remote/Bup.hs
@@ -323,7 +323,7 @@
 bupRef :: Key -> String
 bupRef k
 	| Git.Ref.legal True shown = shown
-	| otherwise = "git-annex-" ++ show (sha2_256 (fromString shown))
+	| otherwise = "git-annex-" ++ show (digestToHash (sha2_256 (fromString shown)))
   where
 	shown = serializeKey k
 
diff --git a/Remote/External.hs b/Remote/External.hs
--- a/Remote/External.hs
+++ b/Remote/External.hs
@@ -1073,7 +1073,7 @@
 	delegatename = concat
 		[ fromMaybe "external" (externalRemoteName external)
 		, "-delegate-"
-		, show $ md5s $ encodeBS $ show ps
+		, show $ digestToHash $ md5s $ encodeBS $ show ps
 		]
 	
 	gendelegate = do
diff --git a/Remote/GitLFS.hs b/Remote/GitLFS.hs
--- a/Remote/GitLFS.hs
+++ b/Remote/GitLFS.hs
@@ -422,7 +422,8 @@
 		rememberboth sha256 size
 		ret sha256 size
   where
-	calcsha256 = liftIO $ T.pack . show . sha2_256 <$> F.readFile content
+	calcsha256 = T.pack . show . digestToHash . sha2_256 
+		<$> liftIO (F.readFile content)
 	ret sha256 size = do
 		let obj = LFS.TransferRequestObject
 			{ LFS.req_oid = sha256
diff --git a/Remote/Glacier.hs b/Remote/Glacier.hs
--- a/Remote/Glacier.hs
+++ b/Remote/Glacier.hs
@@ -28,7 +28,7 @@
 import Annex.UUID
 import Utility.Env
 import Types.ProposedAccepted
-import Utility.Hash (IncrementalVerifier)
+import Utility.Hash.Incremental
 
 type Vault = String
 type Archive = FilePath
diff --git a/Remote/Helper/Http.hs b/Remote/Helper/Http.hs
--- a/Remote/Helper/Http.hs
+++ b/Remote/Helper/Http.hs
@@ -13,7 +13,7 @@
 import Types.StoreRetrieve
 import Remote.Helper.Special
 import Utility.Metered
-import Utility.Hash (IncrementalVerifier(..))
+import Utility.Hash.Incremental
 import qualified Utility.FileIO as F
 
 import qualified Data.ByteString.Lazy as L
diff --git a/RemoteDaemon/Transport/P2PGeneric.hs b/RemoteDaemon/Transport/P2PGeneric.hs
--- a/RemoteDaemon/Transport/P2PGeneric.hs
+++ b/RemoteDaemon/Transport/P2PGeneric.hs
@@ -84,7 +84,7 @@
 	createAnnexDirectory d
 	-- Since unix socket path length is limited, use a md5sum of
 	-- the netname and address.
-	let f = d </> toOsPath (show (md5 (encodeBL (netname ++ ":" ++ address))))
+	let f = d </> toOsPath (hashByteString (digestToHash (md5 (encodeBL (netname ++ ":" ++ address)))))
 	-- Use whichever is shorter of the absolute or relative path.
 	relf <- liftIO $ relPathCwdToFile f
 	absf <- liftIO $ absPath f
diff --git a/Test.hs b/Test.hs
--- a/Test.hs
+++ b/Test.hs
@@ -73,12 +73,12 @@
 import qualified Utility.FileMode
 import qualified BuildInfo
 import qualified Utility.Format
-import qualified Utility.Verifiable
 import qualified Utility.Process
 import qualified Utility.Misc
 import qualified Utility.InodeCache
 import qualified Utility.Matcher
 import qualified Utility.Hash
+import qualified Utility.HMAC
 import qualified Utility.Scheduled
 import qualified Utility.Scheduled.QuickCheck
 import qualified Utility.HumanTime
@@ -178,7 +178,6 @@
 	, testProperty "prop_HmacSha1WithCipher_sane" Crypto.prop_HmacSha1WithCipher_sane
 	, testProperty "prop_VectorClock_sane" Annex.VectorClock.prop_VectorClock_sane
 	, testProperty "prop_addMapLog_sane" Logs.MapLog.prop_addMapLog_sane
-	, testProperty "prop_verifiable_sane" Utility.Verifiable.prop_verifiable_sane
 	, testProperty "prop_segment_regressionTest" Utility.Misc.prop_segment_regressionTest
 	, testProperty "prop_read_write_transferinfo" Logs.Transfer.prop_read_write_transferinfo
 	, testProperty "prop_read_show_inodecache" Utility.InodeCache.prop_read_show_inodecache
@@ -201,7 +200,7 @@
   where
 	combos = concat
 		[ Utility.Hash.props_hashes_stable
-		, Utility.Hash.props_macs_stable
+		, Utility.HMAC.props_macs_stable
 		]
 
 testRemotes :: TestTree
diff --git a/Types/ActionItem.hs b/Types/ActionItem.hs
--- a/Types/ActionItem.hs
+++ b/Types/ActionItem.hs
@@ -1,6 +1,6 @@
 {- items that a command can act on
  -
- - Copyright 2016-2023 Joey Hess <id@joeyh.name>
+ - Copyright 2016-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -29,6 +29,7 @@
 	| ActionItemUUID UUID StringContainingQuotedPath
 	-- ^ UUID with a description or name of the repository
 	| ActionItemOther (Maybe StringContainingQuotedPath)
+	| ActionItemForUUID UUID ActionItem
 	| OnlyActionOn Key ActionItem
 	-- ^ Use to avoid more than one thread concurrently processing the
 	-- same Key.
@@ -85,6 +86,7 @@
 actionItemDesc (ActionItemUUID _ desc) = desc
 actionItemDesc (ActionItemOther Nothing) = mempty
 actionItemDesc (ActionItemOther (Just v)) = v
+actionItemDesc (ActionItemForUUID _ ai) = actionItemDesc ai
 actionItemDesc (OnlyActionOn _ ai) = actionItemDesc ai
 
 actionItemKey :: ActionItem -> Maybe Key
@@ -95,21 +97,26 @@
 actionItemKey (ActionItemTreeFile _) = Nothing
 actionItemKey (ActionItemUUID _ _) = Nothing
 actionItemKey (ActionItemOther _) = Nothing
+actionItemKey (ActionItemForUUID _ ai) = actionItemKey ai
 actionItemKey (OnlyActionOn _ ai) = actionItemKey ai
 
 actionItemFile :: ActionItem -> Maybe OsPath
 actionItemFile (ActionItemAssociatedFile (AssociatedFile af) _) = af
 actionItemFile (ActionItemTreeFile f) = Just f
 actionItemFile (ActionItemUUID _ _) = Nothing
+actionItemFile (ActionItemForUUID _ ai) = actionItemFile ai
 actionItemFile (OnlyActionOn _ ai) = actionItemFile ai
 actionItemFile _ = Nothing
 
 actionItemUUID :: ActionItem -> Maybe UUID
 actionItemUUID (ActionItemUUID uuid _) = Just uuid
+actionItemUUID (ActionItemForUUID uuid _) = Just uuid
+actionItemUUID (OnlyActionOn _ ai) = actionItemUUID ai
 actionItemUUID _ = Nothing
 
 actionItemTransferDirection :: ActionItem -> Maybe Direction
 actionItemTransferDirection (ActionItemFailedTransfer t _) = Just $
 	transferDirection t
+actionItemTransferDirection (ActionItemForUUID _ ai) = actionItemTransferDirection ai
 actionItemTransferDirection (OnlyActionOn _ ai) = actionItemTransferDirection ai
 actionItemTransferDirection _ = Nothing
diff --git a/Types/Backend.hs b/Types/Backend.hs
--- a/Types/Backend.hs
+++ b/Types/Backend.hs
@@ -14,7 +14,7 @@
 import Utility.Metered
 import Utility.OsPath
 import Utility.FileSystemEncoding
-import Utility.Hash (IncrementalVerifier)
+import Utility.Hash.Incremental
 
 data BackendA a = Backend
 	{ backendVariety :: KeyVariety
@@ -26,6 +26,12 @@
 	-- hash as verifyKeyContent, but with the content provided
 	-- incrementally a piece at a time, until finalized.
 	, verifyKeyContentIncrementally :: Maybe (Key -> a IncrementalVerifier)
+	-- Is verifyKeyContent sufficiently faster than
+	-- verifyKeyContentIncrementally that it should be used when the
+	-- content of the file is already available? (Even if so, it
+	-- still makes sense to use verifyKeyContentIncrementally when
+	-- downloading a file.)
+	, verifyKeyContentIsFaster :: Bool
 	-- Checks if a key can be upgraded to a better form.
 	, canUpgradeKey :: Maybe (Key -> Bool)
 	-- Checks if there is a fast way to migrate a key to a different
diff --git a/Types/Crypto.hs b/Types/Crypto.hs
--- a/Types/Crypto.hs
+++ b/Types/Crypto.hs
@@ -20,7 +20,7 @@
 	calcMac,
 ) where
 
-import Utility.Hash
+import Utility.HMAC
 import Utility.Gpg (KeyIds(..))
 
 import Data.Typeable
diff --git a/Types/Key.hs b/Types/Key.hs
--- a/Types/Key.hs
+++ b/Types/Key.hs
@@ -5,6 +5,7 @@
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE CPP #-}
 {-# LANGUAGE OverloadedStrings, DeriveGeneric #-}
 
 module Types.Key (
@@ -213,6 +214,12 @@
 	| Blake2bpKey HashSize HasExt
 	| Blake2sKey HashSize HasExt
 	| Blake2spKey HashSize HasExt
+#ifdef WITH_BLAKE3
+	| Blake3Key HasExt
+#endif
+#ifdef WITH_XXH3
+	| XXH3Key HasExt
+#endif
 	| SHA1Key HasExt
 	| MD5Key HasExt
 	| WORMKey
@@ -249,6 +256,12 @@
 hasExt (Blake2bpKey _ (HasExt b)) = b
 hasExt (Blake2sKey _ (HasExt b)) = b
 hasExt (Blake2spKey _ (HasExt b)) = b
+#ifdef WITH_BLAKE3
+hasExt (Blake3Key (HasExt b)) = b
+#endif
+#ifdef WITH_XXH3
+hasExt (XXH3Key (HasExt b)) = b
+#endif
 hasExt (SHA1Key (HasExt b)) = b
 hasExt (MD5Key (HasExt b)) = b
 hasExt WORMKey = False
@@ -267,6 +280,12 @@
 sameExceptExt (Blake2bpKey sz1 _) (Blake2bpKey sz2 _) = sz1 == sz2
 sameExceptExt (Blake2sKey sz1 _) (Blake2sKey sz2 _) = sz1 == sz2
 sameExceptExt (Blake2spKey sz1 _) (Blake2spKey sz2 _) = sz1 == sz2
+#ifdef WITH_BLAKE3
+sameExceptExt (Blake3Key _) (Blake3Key _) = True
+#endif
+#ifdef WITH_XXH3
+sameExceptExt (XXH3Key _) (XXH3Key _) = True
+#endif
 sameExceptExt (SHA1Key _) (SHA1Key _) = True
 sameExceptExt (MD5Key _) (MD5Key _) = True
 sameExceptExt _ _ = False
@@ -280,6 +299,12 @@
 	Blake2bpKey sz e -> adde e (addsz sz "BLAKE2BP")
 	Blake2sKey sz e -> adde e (addsz sz "BLAKE2S")
 	Blake2spKey sz e -> adde e (addsz sz "BLAKE2SP")
+#ifdef WITH_BLAKE3
+	Blake3Key e -> adde e "BLAKE3_256"
+#endif
+#ifdef WITH_XXH3
+	XXH3Key e -> adde e "XXH3"
+#endif
 	SHA1Key e -> adde e "SHA1"
 	MD5Key e -> adde e "MD5"
 	WORMKey -> "WORM"
@@ -345,6 +370,14 @@
 parseKeyVariety "BLAKE2SP224E" = Blake2spKey (HashSize 224) (HasExt True)
 parseKeyVariety "BLAKE2SP256"  = Blake2spKey (HashSize 256) (HasExt False)
 parseKeyVariety "BLAKE2SP256E" = Blake2spKey (HashSize 256) (HasExt True)
+#ifdef WITH_BLAKE3
+parseKeyVariety "BLAKE3_256"   = Blake3Key (HasExt False)
+parseKeyVariety "BLAKE3_256E"  = Blake3Key (HasExt True)
+#endif
+#ifdef WITH_XXH3
+parseKeyVariety "XXH3"         = XXH3Key (HasExt False)
+parseKeyVariety "XXH3E"        = XXH3Key (HasExt True)
+#endif
 parseKeyVariety "SHA1"         = SHA1Key (HasExt False)
 parseKeyVariety "SHA1E"        = SHA1Key (HasExt True)
 parseKeyVariety "MD5"          = MD5Key (HasExt False)
diff --git a/Types/Remote.hs b/Types/Remote.hs
--- a/Types/Remote.hs
+++ b/Types/Remote.hs
@@ -44,7 +44,7 @@
 import Types.Export
 import Types.Import
 import Types.RemoteConfig
-import Utility.Hash (IncrementalVerifier)
+import Utility.Hash.Incremental
 import Config.Cost
 import Utility.Metered
 import Git.Types (RemoteName)
diff --git a/Types/StoreRetrieve.hs b/Types/StoreRetrieve.hs
--- a/Types/StoreRetrieve.hs
+++ b/Types/StoreRetrieve.hs
@@ -12,7 +12,7 @@
 import Annex.Common
 import Types.NumCopies
 import Utility.Metered
-import Utility.Hash (IncrementalVerifier)
+import Utility.Hash.Incremental
 
 import qualified Data.ByteString.Lazy as L
 
diff --git a/Utility/AuthToken.hs b/Utility/AuthToken.hs
--- a/Utility/AuthToken.hs
+++ b/Utility/AuthToken.hs
@@ -23,10 +23,9 @@
 import Utility.Hash
 import Utility.Exception
 
-import Data.SecureMem
 import Data.Maybe
 import Data.Char
-import Data.Byteable
+import qualified Data.ByteArray as BA
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as TE
 import qualified Data.ByteString.Lazy as L
@@ -40,7 +39,7 @@
 -- To avoid decoding issues, and presentation issues, the content
 -- of an AuthToken is limited to ASCII characters a-z, and 0-9.
 -- This is enforced by all exported AuthToken constructors.
-newtype AuthToken = AuthToken SecureMem
+newtype AuthToken = AuthToken BA.ScrubbedBytes
 	deriving (Show, Eq)
 
 allowedChar :: Char -> Bool
@@ -51,7 +50,7 @@
 	deserialize = toAuthToken . T.pack
 
 fromAuthToken :: AuthToken -> T.Text
-fromAuthToken (AuthToken t ) = TE.decodeLatin1 (toBytes t)
+fromAuthToken (AuthToken t ) = TE.decodeLatin1 (BA.convert t)
 
 -- | Upper-case characters are lower-cased to make them fit in the allowed
 -- character set. This allows AuthTokens to be compared effectively
@@ -61,18 +60,18 @@
 toAuthToken :: T.Text -> Maybe AuthToken
 toAuthToken t
 	| all allowedChar s = Just $ AuthToken $ 
-		secureMemFromByteString $ TE.encodeUtf8 $ T.pack s
+		BA.convert $ TE.encodeUtf8 $ T.pack s
 	| otherwise = Nothing
   where
 	s = map toLower $ T.unpack t
 
 -- | The empty AuthToken, for those times when you don't want any security.
 nullAuthToken :: AuthToken
-nullAuthToken = AuthToken $ secureMemFromByteString $ TE.encodeUtf8 T.empty
+nullAuthToken = AuthToken $ BA.convert $ TE.encodeUtf8 T.empty
 
 -- | Display in place of a real AuthToken in protocol dumps.
 displayAuthToken :: AuthToken
-displayAuthToken = AuthToken $ secureMemFromByteString $ TE.encodeUtf8 $ T.pack "<AUTHTOKEN>"
+displayAuthToken = AuthToken $ BA.convert $ TE.encodeUtf8 $ T.pack "<AUTHTOKEN>"
 
 -- | Generates an AuthToken of a specified length. This is done by
 -- generating a random bytestring, hashing it with sha2 512, and truncating
@@ -88,7 +87,8 @@
 			Left e -> giveup $ "failed to generate auth token: " ++ show e
 			Right (s, _) -> fromMaybe (giveup "auth token encoding failed") $
 				toAuthToken $ T.pack $ take len $
-					show $ sha2_512 $ L.fromChunks [s]
+					show $ digestToHash $ 
+						sha2_512 $ L.fromChunks [s]
 
 -- | For when several AuthTokens are allowed to be used.
 newtype AllowedAuthTokens = AllowedAuthTokens [AuthToken]
diff --git a/Utility/HMAC.hs b/Utility/HMAC.hs
new file mode 100644
--- /dev/null
+++ b/Utility/HMAC.hs
@@ -0,0 +1,56 @@
+{- Convenience wrapper around crypton's HMACs
+ -
+ - Copyright 2013-2026 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+{-# LANGUAGE PackageImports #-}
+{-# LANGUAGE RankNTypes #-}
+
+module Utility.HMAC (
+	Mac(..),
+	calcMac,
+	Digest,
+	props_macs_stable,
+) where
+
+import qualified Data.ByteString as S
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import "crypton" Crypto.MAC.HMAC hiding (Context)
+import "crypton" Crypto.Hash
+
+data Mac = HmacSha1 | HmacSha224 | HmacSha256 | HmacSha384 | HmacSha512
+	deriving (Eq)
+
+calcMac
+	:: (forall a. Digest a -> t)    -- ^ applied to MAC'ed message
+	-> Mac          -- ^ MAC
+	-> S.ByteString -- ^ secret key
+	-> S.ByteString -- ^ message
+	-> t
+calcMac f mac = case mac of
+	HmacSha1   -> use SHA1
+	HmacSha224 -> use SHA224
+	HmacSha256 -> use SHA256
+	HmacSha384 -> use SHA384
+	HmacSha512 -> use SHA512
+  where
+	use alg k m = f (hmacGetDigest (hmacWitnessAlg alg k m))
+
+	hmacWitnessAlg :: HashAlgorithm a => a -> S.ByteString -> S.ByteString -> HMAC a
+	hmacWitnessAlg _ = hmac
+
+-- Check that all the MACs continue to produce the same.
+props_macs_stable :: [(String, Bool)]
+props_macs_stable = map (\(desc, mac, result) -> (desc ++ " stable", calcMac show mac key msg == result))
+	[ ("HmacSha1", HmacSha1, "46b4ec586117154dacd49d664e5d63fdc88efb51")
+	, ("HmacSha224", HmacSha224, "4c1f774863acb63b7f6e9daa9b5c543fa0d5eccf61e3ffc3698eacdd")
+	, ("HmacSha256", HmacSha256, "f9320baf0249169e73850cd6156ded0106e2bb6ad8cab01b7bbbebe6d1065317")
+	, ("HmacSha384", HmacSha384, "3d10d391bee2364df2c55cf605759373e1b5a4ca9355d8f3fe42970471eca2e422a79271a0e857a69923839015877fc6")
+	, ("HmacSha512", HmacSha512, "114682914c5d017dfe59fdc804118b56a3a652a0b8870759cf9e792ed7426b08197076bf7d01640b1b0684df79e4b67e37485669e8ce98dbab60445f0db94fce")
+	]
+  where
+	key = T.encodeUtf8 $ T.pack "foo"
+	msg = T.encodeUtf8 $ T.pack "bar"
diff --git a/Utility/Hash.hs b/Utility/Hash.hs
--- a/Utility/Hash.hs
+++ b/Utility/Hash.hs
@@ -1,349 +1,85 @@
-{- Convenience wrapper around crypton's hashing.
+{- Convenience wrapper for hashing libraries.
  -
- - Copyright 2013-2024 Joey Hess <id@joeyh.name>
+ - Copyright 2026 Joey Hess <id@joeyh.name>
  -
  - License: BSD-2-clause
  -}
 
-{-# LANGUAGE BangPatterns, PackageImports #-}
-{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE OverloadedStrings, CPP #-}
 
-module Utility.Hash (
-	sha1,
-	sha1_context,
-	sha1s,
-	sha2_224,
-	sha2_224_context,
-	sha2_256,
-	sha2_256_context,
-	sha2_384,
-	sha2_384_context,
-	sha2_512,
-	sha2_512_context,
-	sha3_224,
-	sha3_224_context,
-	sha3_256,
-	sha3_256_context,
-	sha3_384,
-	sha3_384_context,
-	sha3_512,
-	sha3_512_context,
-	skein256,
-	skein256_context,
-	skein512,
-	skein512_context,
-	blake2s_160,
-	blake2s_160_context,
-	blake2s_224,
-	blake2s_224_context,
-	blake2s_256,
-	blake2s_256_context,
-	blake2sp_224,
-	blake2sp_224_context,
-	blake2sp_256,
-	blake2sp_256_context,
-	blake2b_160,
-	blake2b_160_context,
-	blake2b_224,
-	blake2b_224_context,
-	blake2b_256,
-	blake2b_256_context,
-	blake2b_384,
-	blake2b_384_context,
-	blake2b_512,
-	blake2b_512_context,
-	blake2bp_512,
-	blake2bp_512_context,
-	md5,
-	md5_context,
-	md5s,
-	hashUpdate,
-	hashFinalize,
-	Digest,
-	HashAlgorithm,
-	Context,
-	props_hashes_stable,
-	Mac(..),
-	calcMac,
-	props_macs_stable,
-	IncrementalHasher(..),
-	mkIncrementalHasher,
-	IncrementalVerifier(..),
-	mkIncrementalVerifier,
-) where
+module Utility.Hash (module X, props_hashes_stable) where
 
-import qualified Data.ByteString as S
+import Utility.Hash.Types as X
+import Utility.Hash.Incremental as X
+#ifdef WITH_BOTAN
+import Utility.Hash.Botan as X
+-- Fall back to crypton for hashes not in botan.
+import Utility.Hash.Crypton as X
+	( skein256
+	, skein256_hasher
+	, blake2s_160
+	, blake2s_160_hasher
+	, blake2s_224
+	, blake2s_224_hasher
+	, blake2s_256
+	, blake2s_256_hasher
+	, blake2sp_224
+	, blake2sp_224_hasher
+	, blake2sp_256
+	, blake2sp_256_hasher
+	, blake2bp_512
+	, blake2bp_512_hasher
+	)
+#else
+import Utility.Hash.Crypton as X
+#endif
+#ifdef WITH_BLAKE3
+import Utility.Hash.Blake3 as X
+#endif
+#ifdef WITH_XXH3
+import Utility.Hash.XXH3 as X
+#endif
+
 import qualified Data.ByteString.Lazy as L
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as T
-import Data.IORef
-import "crypton" Crypto.MAC.HMAC hiding (Context)
-import "crypton" Crypto.Hash
 
-sha1 :: L.ByteString -> Digest SHA1
-sha1 = hashlazy
-
-sha1_context :: Context SHA1
-sha1_context = hashInit
-
-sha1s :: S.ByteString -> Digest SHA1
-sha1s = hash
-
-sha2_224 :: L.ByteString -> Digest SHA224
-sha2_224 = hashlazy
-
-sha2_224_context :: Context SHA224
-sha2_224_context = hashInit
-
-sha2_256 :: L.ByteString -> Digest SHA256
-sha2_256 = hashlazy
-
-sha2_256_context :: Context SHA256
-sha2_256_context = hashInit
-
-sha2_384 :: L.ByteString -> Digest SHA384
-sha2_384 = hashlazy
-
-sha2_384_context :: Context SHA384
-sha2_384_context = hashInit
-
-sha2_512 :: L.ByteString -> Digest SHA512
-sha2_512 = hashlazy
-
-sha2_512_context :: Context SHA512
-sha2_512_context = hashInit
-
-sha3_224 :: L.ByteString -> Digest SHA3_224
-sha3_224 = hashlazy
-
-sha3_224_context :: Context SHA3_224
-sha3_224_context = hashInit
-
-sha3_256 :: L.ByteString -> Digest SHA3_256
-sha3_256 = hashlazy
-
-sha3_256_context :: Context SHA3_256
-sha3_256_context = hashInit
-
-sha3_384 :: L.ByteString -> Digest SHA3_384
-sha3_384 = hashlazy
-
-sha3_384_context :: Context SHA3_384
-sha3_384_context = hashInit
-
-sha3_512 :: L.ByteString -> Digest SHA3_512
-sha3_512 = hashlazy
-
-sha3_512_context :: Context SHA3_512
-sha3_512_context = hashInit
-
-skein256 :: L.ByteString -> Digest Skein256_256
-skein256 = hashlazy
-
-skein256_context :: Context Skein256_256
-skein256_context = hashInit
-
-skein512 :: L.ByteString -> Digest Skein512_512
-skein512 = hashlazy
-
-skein512_context :: Context Skein512_512
-skein512_context = hashInit
-
-blake2s_160 :: L.ByteString -> Digest Blake2s_160
-blake2s_160 = hashlazy
-
-blake2s_160_context :: Context Blake2s_160
-blake2s_160_context = hashInit
-
-blake2s_224 :: L.ByteString -> Digest Blake2s_224
-blake2s_224 = hashlazy
-
-blake2s_224_context :: Context Blake2s_224
-blake2s_224_context = hashInit
-
-blake2s_256 :: L.ByteString -> Digest Blake2s_256
-blake2s_256 = hashlazy
-
-blake2s_256_context :: Context Blake2s_256
-blake2s_256_context = hashInit
-
-blake2sp_224 :: L.ByteString -> Digest Blake2sp_224
-blake2sp_224 = hashlazy
-
-blake2sp_224_context :: Context Blake2sp_224
-blake2sp_224_context = hashInit
-
-blake2sp_256 :: L.ByteString -> Digest Blake2sp_256
-blake2sp_256 = hashlazy
-
-blake2sp_256_context :: Context Blake2sp_256
-blake2sp_256_context = hashInit
-
-blake2b_160 :: L.ByteString -> Digest Blake2b_160
-blake2b_160 = hashlazy
-
-blake2b_160_context :: Context Blake2b_160
-blake2b_160_context = hashInit
-
-blake2b_224 :: L.ByteString -> Digest Blake2b_224
-blake2b_224 = hashlazy
-
-blake2b_224_context :: Context Blake2b_224
-blake2b_224_context = hashInit
-
-blake2b_256 :: L.ByteString -> Digest Blake2b_256
-blake2b_256 = hashlazy
-
-blake2b_256_context :: Context Blake2b_256
-blake2b_256_context = hashInit
-
-blake2b_384 :: L.ByteString -> Digest Blake2b_384
-blake2b_384 = hashlazy
-
-blake2b_384_context :: Context Blake2b_384
-blake2b_384_context = hashInit
-
-blake2b_512 :: L.ByteString -> Digest Blake2b_512
-blake2b_512 = hashlazy
-
-blake2b_512_context :: Context Blake2b_512
-blake2b_512_context = hashInit
-
-blake2bp_512 :: L.ByteString -> Digest Blake2bp_512
-blake2bp_512 = hashlazy
-
-blake2bp_512_context :: Context Blake2bp_512
-blake2bp_512_context = hashInit
-
-md5 ::  L.ByteString -> Digest MD5
-md5 = hashlazy
-
-md5_context :: Context MD5
-md5_context = hashInit
-
-md5s ::  S.ByteString -> Digest MD5
-md5s = hash
-
 {- Check that all the hashes continue to hash the same. -}
 props_hashes_stable :: [(String, Bool)]
-props_hashes_stable = map (\(desc, hasher, result) -> (desc ++ " stable", hasher foo == result))
-	[ ("sha1", show . sha1, "0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33")
-	, ("sha2_224", show . sha2_224, "0808f64e60d58979fcb676c96ec938270dea42445aeefcd3a4e6f8db")
-	, ("sha2_256", show . sha2_256, "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae")
-	, ("sha2_384", show . sha2_384, "98c11ffdfdd540676b1a137cb1a22b2a70350c9a44171d6b1180c6be5cbb2ee3f79d532c8a1dd9ef2e8e08e752a3babb")
-	, ("sha2_512", show . sha2_512, "f7fbba6e0636f890e56fbbf3283e524c6fa3204ae298382d624741d0dc6638326e282c41be5e4254d8820772c5518a2c5a8c0c7f7eda19594a7eb539453e1ed7")
-	, ("skein256", show . skein256, "a04efd9a0aeed6ede40fe5ce0d9361ae7b7d88b524aa19917b9315f1ecf00d33")
-	, ("skein512", show . skein512, "fd8956898113510180aa4658e6c0ac85bd74fb47f4a4ba264a6b705d7a8e8526756e75aecda12cff4f1aca1a4c2830fbf57f458012a66b2b15a3dd7d251690a7")
-	, ("sha3_224", show . sha3_224, "f4f6779e153c391bbd29c95e72b0708e39d9166c7cea51d1f10ef58a")
-	, ("sha3_256", show . sha3_256, "76d3bc41c9f588f7fcd0d5bf4718f8f84b1c41b20882703100b9eb9413807c01")
-	, ("sha3_384", show . sha3_384, "665551928d13b7d84ee02734502b018d896a0fb87eed5adb4c87ba91bbd6489410e11b0fbcc06ed7d0ebad559e5d3bb5")
-	, ("sha3_512", show . sha3_512, "4bca2b137edc580fe50a88983ef860ebaca36c857b1f492839d6d7392452a63c82cbebc68e3b70a2a1480b4bb5d437a7cba6ecf9d89f9ff3ccd14cd6146ea7e7")
-	, ("blake2s_160", show . blake2s_160, "52fb63154f958a5c56864597273ea759e52c6f00")
-	, ("blake2s_224", show . blake2s_224, "9466668503ac415d87b8e1dfd7f348ab273ac1d5e4f774fced5fdb55")
-	, ("blake2s_256", show . blake2s_256, "08d6cad88075de8f192db097573d0e829411cd91eb6ec65e8fc16c017edfdb74")
-	, ("blake2sp_224", show . blake2sp_224, "8492d356fbac99f046f55e114301f7596649cb590e5b083d1a19dcdb")
-	, ("blake2sp_256", show . blake2sp_256, "050dc5786037ea72cb9ed9d0324afcab03c97ec02e8c47368fc5dfb4cf49d8c9")
-	, ("blake2b_160", show . blake2b_160, "983ceba2afea8694cc933336b27b907f90c53a88")
-	, ("blake2b_224", show . blake2b_224, "853986b3fe231d795261b4fb530e1a9188db41e460ec4ca59aafef78")
-	, ("blake2b_256", show . blake2b_256, "b8fe9f7f6255a6fa08f668ab632a8d081ad87983c77cd274e48ce450f0b349fd")
-	, ("blake2b_384", show . blake2b_384, "e629ee880953d32c8877e479e3b4cb0a4c9d5805e2b34c675b5a5863c4ad7d64bb2a9b8257fac9d82d289b3d39eb9cc2")
-	, ("blake2b_512", show . blake2b_512, "ca002330e69d3e6b84a46a56a6533fd79d51d97a3bb7cad6c2ff43b354185d6dc1e723fb3db4ae0737e120378424c714bb982d9dc5bbd7a0ab318240ddd18f8d")
-	, ("blake2bp_512", show . blake2bp_512, "8ca9ccee7946afcb686fe7556628b5ba1bf9a691da37ca58cd049354d99f37042c007427e5f219b9ab5063707ec6823872dee413ee014b4d02f2ebb6abb5f643")
-	, ("md5", show . md5, "acbd18db4cc2f85cedef654fccc4a4d8")
+props_hashes_stable = map testhash $
+	[ ("sha1", digestToHash . sha1, "0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33")
+	, ("sha2_224", digestToHash . sha2_224, "0808f64e60d58979fcb676c96ec938270dea42445aeefcd3a4e6f8db")
+	, ("sha2_256", digestToHash . sha2_256, "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae")
+	, ("sha2_384", digestToHash . sha2_384, "98c11ffdfdd540676b1a137cb1a22b2a70350c9a44171d6b1180c6be5cbb2ee3f79d532c8a1dd9ef2e8e08e752a3babb")
+	, ("sha2_512", digestToHash . sha2_512, "f7fbba6e0636f890e56fbbf3283e524c6fa3204ae298382d624741d0dc6638326e282c41be5e4254d8820772c5518a2c5a8c0c7f7eda19594a7eb539453e1ed7")
+	, ("skein256", digestToHash . skein256, "a04efd9a0aeed6ede40fe5ce0d9361ae7b7d88b524aa19917b9315f1ecf00d33")
+	, ("skein512", digestToHash . skein512, "fd8956898113510180aa4658e6c0ac85bd74fb47f4a4ba264a6b705d7a8e8526756e75aecda12cff4f1aca1a4c2830fbf57f458012a66b2b15a3dd7d251690a7")
+	, ("sha3_224", digestToHash . sha3_224, "f4f6779e153c391bbd29c95e72b0708e39d9166c7cea51d1f10ef58a")
+	, ("sha3_256", digestToHash . sha3_256, "76d3bc41c9f588f7fcd0d5bf4718f8f84b1c41b20882703100b9eb9413807c01")
+	, ("sha3_384", digestToHash . sha3_384, "665551928d13b7d84ee02734502b018d896a0fb87eed5adb4c87ba91bbd6489410e11b0fbcc06ed7d0ebad559e5d3bb5")
+	, ("sha3_512", digestToHash . sha3_512, "4bca2b137edc580fe50a88983ef860ebaca36c857b1f492839d6d7392452a63c82cbebc68e3b70a2a1480b4bb5d437a7cba6ecf9d89f9ff3ccd14cd6146ea7e7")
+	, ("blake2s_160", digestToHash . blake2s_160, "52fb63154f958a5c56864597273ea759e52c6f00")
+	, ("blake2s_224", digestToHash . blake2s_224, "9466668503ac415d87b8e1dfd7f348ab273ac1d5e4f774fced5fdb55")
+	, ("blake2s_256", digestToHash . blake2s_256, "08d6cad88075de8f192db097573d0e829411cd91eb6ec65e8fc16c017edfdb74")
+	, ("blake2sp_224", digestToHash . blake2sp_224, "8492d356fbac99f046f55e114301f7596649cb590e5b083d1a19dcdb")
+	, ("blake2sp_256", digestToHash . blake2sp_256, "050dc5786037ea72cb9ed9d0324afcab03c97ec02e8c47368fc5dfb4cf49d8c9")
+	, ("blake2b_160", digestToHash . blake2b_160, "983ceba2afea8694cc933336b27b907f90c53a88")
+	, ("blake2b_224", digestToHash . blake2b_224, "853986b3fe231d795261b4fb530e1a9188db41e460ec4ca59aafef78")
+	, ("blake2b_256", digestToHash . blake2b_256, "b8fe9f7f6255a6fa08f668ab632a8d081ad87983c77cd274e48ce450f0b349fd")
+	, ("blake2b_384", digestToHash . blake2b_384, "e629ee880953d32c8877e479e3b4cb0a4c9d5805e2b34c675b5a5863c4ad7d64bb2a9b8257fac9d82d289b3d39eb9cc2")
+	, ("blake2b_512", digestToHash . blake2b_512, "ca002330e69d3e6b84a46a56a6533fd79d51d97a3bb7cad6c2ff43b354185d6dc1e723fb3db4ae0737e120378424c714bb982d9dc5bbd7a0ab318240ddd18f8d")
+	, ("blake2bp_512", digestToHash . blake2bp_512, "8ca9ccee7946afcb686fe7556628b5ba1bf9a691da37ca58cd049354d99f37042c007427e5f219b9ab5063707ec6823872dee413ee014b4d02f2ebb6abb5f643")
+	, ("md5", digestToHash . md5, "acbd18db4cc2f85cedef654fccc4a4d8")
 	]
+#ifdef WITH_BLAKE3
+	++ [("blake3", blake3, "04e0bb39f30b1a3feb89f536c93be15055482df748674b00d26e5a75777702e9")]
+#endif
+#ifdef WITH_XXH3
+	++ if xxh3Supported
+		then [("xxh3", digestToHash . xxh3, "ab6e5f64077e7d8a")]
+		else []
+#endif
   where
+	testhash (desc, hasher, result) = 
+		(desc ++ " stable", hasher foo == result)
 	foo = L.fromChunks [T.encodeUtf8 $ T.pack "foo"]
-
-data Mac = HmacSha1 | HmacSha224 | HmacSha256 | HmacSha384 | HmacSha512
-	deriving (Eq)
-
-calcMac
-	:: (forall a. Digest a -> t)    -- ^ applied to MAC'ed message
-	-> Mac          -- ^ MAC
-	-> S.ByteString -- ^ secret key
-	-> S.ByteString -- ^ message
-	-> t
-calcMac f mac = case mac of
-	HmacSha1   -> use SHA1
-	HmacSha224 -> use SHA224
-	HmacSha256 -> use SHA256
-	HmacSha384 -> use SHA384
-	HmacSha512 -> use SHA512
-  where
-	use alg k m = f (hmacGetDigest (hmacWitnessAlg alg k m))
-
-	hmacWitnessAlg :: HashAlgorithm a => a -> S.ByteString -> S.ByteString -> HMAC a
-	hmacWitnessAlg _ = hmac
-
--- Check that all the MACs continue to produce the same.
-props_macs_stable :: [(String, Bool)]
-props_macs_stable = map (\(desc, mac, result) -> (desc ++ " stable", calcMac show mac key msg == result))
-	[ ("HmacSha1", HmacSha1, "46b4ec586117154dacd49d664e5d63fdc88efb51")
-	, ("HmacSha224", HmacSha224, "4c1f774863acb63b7f6e9daa9b5c543fa0d5eccf61e3ffc3698eacdd")
-	, ("HmacSha256", HmacSha256, "f9320baf0249169e73850cd6156ded0106e2bb6ad8cab01b7bbbebe6d1065317")
-	, ("HmacSha384", HmacSha384, "3d10d391bee2364df2c55cf605759373e1b5a4ca9355d8f3fe42970471eca2e422a79271a0e857a69923839015877fc6")
-	, ("HmacSha512", HmacSha512, "114682914c5d017dfe59fdc804118b56a3a652a0b8870759cf9e792ed7426b08197076bf7d01640b1b0684df79e4b67e37485669e8ce98dbab60445f0db94fce")
-	]
-  where
-	key = T.encodeUtf8 $ T.pack "foo"
-	msg = T.encodeUtf8 $ T.pack "bar"
-
-data IncrementalHasher = IncrementalHasher
-	{ updateIncrementalHasher :: S.ByteString -> IO ()
-	-- ^ Called repeatedly on each piece of the content.
-	, finalizeIncrementalHasher :: IO (Maybe String)
-	-- ^ Called once the full content has been sent, returns
-	-- the hash. (Nothing if unableIncremental was called.)
-	, unableIncrementalHasher :: IO ()
-	-- ^ Call if the incremental hashing is unable to be done.
-	, positionIncrementalHasher :: IO (Maybe Integer)
-	-- ^ Returns the number of bytes that have been fed to this
-	-- incremental hasher so far. (Nothing if unableIncremental was
-	-- called.)
-	, descIncrementalHasher :: String
-	}
-
-mkIncrementalHasher :: HashAlgorithm h => Context h -> String -> IO IncrementalHasher
-mkIncrementalHasher ctx desc = do
-	v <- newIORef (Just (ctx, 0))
-	return $ IncrementalHasher
-		{ updateIncrementalHasher = \b ->
-			modifyIORef' v $ \case
-				(Just (ctx', n)) -> 
-					let !ctx'' = hashUpdate ctx' b
-					    !n' = n + fromIntegral (S.length b)
-					in (Just (ctx'', n'))
-				Nothing -> Nothing
-		, finalizeIncrementalHasher =
-			readIORef v >>= \case
-				(Just (ctx', _)) -> do
-					let digest = hashFinalize ctx'
-					return $ Just $ show digest
-				Nothing -> return Nothing
-		, unableIncrementalHasher = writeIORef v Nothing
-		, positionIncrementalHasher = readIORef v >>= \case
-			Just (_, n) -> return (Just n)
-			Nothing -> return Nothing
-		, descIncrementalHasher = desc
-		}
-
-data IncrementalVerifier = IncrementalVerifier
-	{ updateIncrementalVerifier :: S.ByteString -> IO ()
-	, finalizeIncrementalVerifier :: IO (Maybe Bool)
-	, unableIncrementalVerifier :: IO ()
-	, positionIncrementalVerifier :: IO (Maybe Integer)
-	, descIncrementalVerifier :: String
-	}
-
-mkIncrementalVerifier :: HashAlgorithm h => Context h -> String -> (String -> Bool) -> IO IncrementalVerifier
-mkIncrementalVerifier ctx desc samechecksum = do
-	hasher <- mkIncrementalHasher ctx desc
-	return $ IncrementalVerifier
-		{ updateIncrementalVerifier = updateIncrementalHasher hasher
-		, finalizeIncrementalVerifier = 
-			maybe Nothing (Just . samechecksum)
-				<$> finalizeIncrementalHasher hasher
-		, unableIncrementalVerifier = unableIncrementalHasher hasher
-		, positionIncrementalVerifier = positionIncrementalHasher hasher
-		, descIncrementalVerifier = descIncrementalHasher hasher
-		}
diff --git a/Utility/Hash/Blake3.hs b/Utility/Hash/Blake3.hs
new file mode 100644
--- /dev/null
+++ b/Utility/Hash/Blake3.hs
@@ -0,0 +1,103 @@
+{- Blake3 convenience wrapper with b3hash support.
+ -
+ - Copyright 2026 Joey Hess <id@joeyh.name>
+ - Copyright 2022 edef <edef@edef.eu>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE BangPatterns #-}
+
+module Utility.Hash.Blake3 (
+	blake3,
+	blake3IncrementalVerifier,
+	blake3File,
+) where
+
+import Utility.Hash.Types
+import Utility.Hash.Incremental
+import Utility.FileSystemEncoding
+import Utility.OsPath
+import Utility.Path
+import Utility.FileSize
+import Utility.Process
+import Utility.Exception
+import Utility.Monad
+
+import qualified BLAKE3
+import qualified Data.ByteString as S
+import qualified Data.ByteString.Lazy as L
+import Data.IORef
+import Control.Concurrent.MVar
+import GHC.IO (unsafePerformIO)
+
+finalize :: BLAKE3.Hasher -> BLAKE3.Digest BLAKE3.DEFAULT_DIGEST_LEN
+finalize = BLAKE3.finalize
+
+blake3 :: L.ByteString -> Hash
+blake3 = Hash . encodeBS . show . finalize . L.foldlChunks ((. pure) . BLAKE3.update) (BLAKE3.init Nothing)
+
+blake3IncrementalVerifier :: String -> (Hash -> Bool) -> IO IncrementalVerifier
+blake3IncrementalVerifier desc samechecksum = do
+	v <- newIORef (Just (BLAKE3.init Nothing, 0))
+	return $ IncrementalVerifier
+		{ updateIncrementalVerifier = \b ->
+			modifyIORef' v $ \case
+				(Just (ctx', n)) ->
+					let !ctx'' = BLAKE3.update ctx' [b]
+					    !n' = n + fromIntegral (S.length b)
+					in (Just (ctx'', n'))
+				Nothing -> Nothing
+		, finalizeIncrementalVerifier =
+			fmap (samechecksum . Hash . encodeBS . show . finalize . fst) <$> readIORef v
+		, unableIncrementalVerifier = writeIORef v Nothing
+		, positionIncrementalVerifier = fmap snd <$> readIORef v
+		, descIncrementalVerifier = desc
+		}
+
+{- When b3sum is in the path, this uses it to hash a file. For large
+ - files, that is much faster due to supporting parallelism.
+ -
+ - This returns Nothing when b3sum is not in the path, or exits nonzero, 
+ - or when the file is too small to be worth running it.
+ -}
+blake3File :: OsPath -> IO (Maybe Hash)
+blake3File file = takeMVar b3sumAvailable >>= \case
+	Just True -> do
+		putMVar b3sumAvailable (Just True)
+		runb3sum
+	Just False -> do
+		putMVar b3sumAvailable (Just False)
+		return Nothing
+	Nothing -> ifM (inSearchPath "b3sum")
+		( do
+			putMVar b3sumAvailable (Just True)
+			runb3sum
+		, do
+			putMVar b3sumAvailable (Just False)
+			return Nothing
+		)
+  where
+	runb3sum = ifM filelargeenough
+		( tryNonAsync runb3sum' >>= return . \case
+			Right output -> case lines output of
+				(hash:[]) | length hash == 64 ->
+					Just $ Hash $ encodeBS hash
+				_ -> Nothing
+			Left _ ->  Nothing
+		, return Nothing
+		)
+	runb3sum' = readProcess "b3sum" ["--no-names", "--", fromOsPath file]
+	
+	-- A file needs to be about 3mb in size before the overhead of
+	-- starting a b3sum process is worthwhile.
+	filelargeenough = tryNonAsync (getFileSize file) >>= return . \case
+		Right sz -> sz > 3000000
+		Left _ -> False
+
+{- Used to avoid needing to check the PATH for b3sum each time
+ - blake3File is called. -}
+{-# NOINLINE b3sumAvailable #-}
+b3sumAvailable :: MVar (Maybe Bool)
+b3sumAvailable = unsafePerformIO $ newMVar Nothing
diff --git a/Utility/Hash/Botan.hs b/Utility/Hash/Botan.hs
new file mode 100644
--- /dev/null
+++ b/Utility/Hash/Botan.hs
@@ -0,0 +1,190 @@
+{- Convenience wrapper around botan's hashing.
+ -
+ - Copyright 2026 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+{-# LANGUAGE BangPatterns #-}
+
+module Utility.Hash.Botan (
+	sha1,
+	sha1_hasher,
+	sha1s,
+	sha2_224,
+	sha2_224_hasher,
+	sha2_256,
+	sha2_256_hasher,
+	sha2_384,
+	sha2_384_hasher,
+	sha2_512,
+	sha2_512_hasher,
+	sha3_224,
+	sha3_224_hasher,
+	sha3_256,
+	sha3_256_hasher,
+	sha3_384,
+	sha3_384_hasher,
+	sha3_512,
+	sha3_512_hasher,
+	skein512,
+	skein512_hasher,
+	blake2b_160,
+	blake2b_160_hasher,
+        blake2b_224,
+        blake2b_224_hasher,
+        blake2b_256,
+        blake2b_256_hasher,
+        blake2b_384,
+        blake2b_384_hasher,
+        blake2b_512,
+        blake2b_512_hasher,
+	md5,
+	md5_hasher,
+	md5s,
+) where
+
+import qualified Botan.Low.Hash as Botan
+import Control.Monad
+import System.IO.Unsafe (unsafePerformIO)
+import qualified Data.ByteString.Lazy as L
+import qualified Data.ByteString as S
+import Data.IORef
+
+import Utility.Hash.Types
+import Utility.Hash.Incremental
+
+sha1 :: L.ByteString -> HashDigest
+sha1 = hashLazy Botan.SHA1
+
+sha1_hasher :: IO IncrementalHasher
+sha1_hasher = mkIncrementalHasher Botan.SHA1
+
+sha1s :: S.ByteString -> HashDigest
+sha1s = hashStrict Botan.SHA1
+
+sha2_224 :: L.ByteString -> HashDigest
+sha2_224 = hashLazy Botan.SHA224
+
+sha2_224_hasher :: IO IncrementalHasher
+sha2_224_hasher = mkIncrementalHasher Botan.SHA224
+
+sha2_256 :: L.ByteString -> HashDigest
+sha2_256 = hashLazy Botan.SHA256
+
+sha2_256_hasher :: IO IncrementalHasher
+sha2_256_hasher = mkIncrementalHasher Botan.SHA256
+
+sha2_384 :: L.ByteString -> HashDigest
+sha2_384 = hashLazy Botan.SHA384
+
+sha2_384_hasher :: IO IncrementalHasher
+sha2_384_hasher = mkIncrementalHasher Botan.SHA384
+
+sha2_512 :: L.ByteString -> HashDigest
+sha2_512 = hashLazy Botan.SHA512
+
+sha2_512_hasher :: IO IncrementalHasher
+sha2_512_hasher = mkIncrementalHasher Botan.SHA512
+
+sha3_224 :: L.ByteString -> HashDigest
+sha3_224 = hashLazy (Botan.sha3 (224 :: Int))
+
+sha3_224_hasher :: IO IncrementalHasher
+sha3_224_hasher = mkIncrementalHasher (Botan.sha3 (224 :: Int))
+
+sha3_256 :: L.ByteString -> HashDigest
+sha3_256 = hashLazy (Botan.sha3 (256 :: Int))
+
+sha3_256_hasher :: IO IncrementalHasher
+sha3_256_hasher = mkIncrementalHasher (Botan.sha3 (256 :: Int))
+
+sha3_384 :: L.ByteString -> HashDigest
+sha3_384 = hashLazy (Botan.sha3 (384 :: Int))
+
+sha3_384_hasher :: IO IncrementalHasher
+sha3_384_hasher = mkIncrementalHasher (Botan.sha3 (384 :: Int))
+
+sha3_512 :: L.ByteString -> HashDigest
+sha3_512 = hashLazy (Botan.sha3 (512 :: Int))
+
+sha3_512_hasher :: IO IncrementalHasher
+sha3_512_hasher = mkIncrementalHasher (Botan.sha3 (512 :: Int))
+
+skein512 :: L.ByteString -> HashDigest
+skein512 = hashLazy Botan.Skein512
+
+skein512_hasher :: IO IncrementalHasher
+skein512_hasher = mkIncrementalHasher Botan.Skein512
+
+blake2b_160 :: L.ByteString -> HashDigest
+blake2b_160 = hashLazy (Botan.blake2b (160 :: Int))
+
+blake2b_160_hasher :: IO IncrementalHasher
+blake2b_160_hasher = mkIncrementalHasher (Botan.blake2b (160 :: Int))
+
+blake2b_224 :: L.ByteString -> HashDigest
+blake2b_224 = hashLazy (Botan.blake2b (224 :: Int))
+
+blake2b_224_hasher :: IO IncrementalHasher
+blake2b_224_hasher = mkIncrementalHasher (Botan.blake2b (224 :: Int))
+
+blake2b_256 :: L.ByteString -> HashDigest
+blake2b_256 = hashLazy (Botan.blake2b (256 :: Int))
+
+blake2b_256_hasher :: IO IncrementalHasher
+blake2b_256_hasher = mkIncrementalHasher (Botan.blake2b (256 :: Int))
+
+blake2b_384 :: L.ByteString -> HashDigest
+blake2b_384 = hashLazy (Botan.blake2b (384 :: Int))
+
+blake2b_384_hasher :: IO IncrementalHasher
+blake2b_384_hasher = mkIncrementalHasher (Botan.blake2b (384 :: Int))
+
+blake2b_512 :: L.ByteString -> HashDigest
+blake2b_512 = hashLazy (Botan.blake2b (512 :: Int))
+
+blake2b_512_hasher :: IO IncrementalHasher
+blake2b_512_hasher = mkIncrementalHasher (Botan.blake2b (512 :: Int))
+
+md5 :: L.ByteString -> HashDigest
+md5 = hashLazy Botan.MD5
+
+md5_hasher :: IO IncrementalHasher
+md5_hasher = mkIncrementalHasher Botan.MD5
+
+md5s :: S.ByteString -> HashDigest
+md5s = hashStrict Botan.MD5
+
+hashLazy :: Botan.HashName -> L.ByteString -> HashDigest
+hashLazy h b = unsafePerformIO $ do
+	hasher <- Botan.hashInit h
+	forM_ (L.toChunks b) (Botan.hashUpdate hasher)
+	HashDigest <$> Botan.hashFinal hasher
+
+hashStrict :: Botan.HashName -> S.ByteString -> HashDigest
+hashStrict h b = unsafePerformIO $ do
+	hasher <- Botan.hashInit h
+	Botan.hashUpdate hasher b
+	HashDigest <$> Botan.hashFinal hasher
+
+mkIncrementalHasher :: Botan.HashName -> IO IncrementalHasher
+mkIncrementalHasher h = do
+	hasher <- Botan.hashInit h
+	v <- newIORef (Just 0)
+	return $ IncrementalHasher
+		{ updateIncrementalHasher = \b -> do
+			readIORef v >>= \case
+				Just n -> do
+					let !n' = n + fromIntegral (S.length b)
+					writeIORef v (Just n')
+					Botan.hashUpdate hasher b
+				Nothing -> return ()
+		, finalizeIncrementalHasher = do
+			readIORef v >>= \case
+				Just _ -> Just . HashDigest
+					<$> Botan.hashFinal hasher
+				Nothing -> return Nothing
+		, unableIncrementalHasher = writeIORef v Nothing
+		, positionIncrementalHasher = readIORef v
+		}
diff --git a/Utility/Hash/Crypton.hs b/Utility/Hash/Crypton.hs
new file mode 100644
--- /dev/null
+++ b/Utility/Hash/Crypton.hs
@@ -0,0 +1,249 @@
+{- Convenience wrapper around crypton's hashing.
+ -
+ - Copyright 2013-2026 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+{-# LANGUAGE BangPatterns, PackageImports #-}
+{-# LANGUAGE RankNTypes #-}
+
+module Utility.Hash.Crypton (
+	sha1,
+	sha1_hasher,
+	sha1s,
+	sha2_224,
+	sha2_224_hasher,
+	sha2_256,
+	sha2_256_hasher,
+	sha2_384,
+	sha2_384_hasher,
+	sha2_512,
+	sha2_512_hasher,
+	sha3_224,
+	sha3_224_hasher,
+	sha3_256,
+	sha3_256_hasher,
+	sha3_384,
+	sha3_384_hasher,
+	sha3_512,
+	sha3_512_hasher,
+	skein256,
+	skein256_hasher,
+	skein512,
+	skein512_hasher,
+	blake2s_160,
+	blake2s_160_hasher,
+	blake2s_224,
+	blake2s_224_hasher,
+	blake2s_256,
+	blake2s_256_hasher,
+	blake2sp_224,
+	blake2sp_224_hasher,
+	blake2sp_256,
+	blake2sp_256_hasher,
+	blake2b_160,
+	blake2b_160_hasher,
+	blake2b_224,
+	blake2b_224_hasher,
+	blake2b_256,
+	blake2b_256_hasher,
+	blake2b_384,
+	blake2b_384_hasher,
+	blake2b_512,
+	blake2b_512_hasher,
+	blake2bp_512,
+	blake2bp_512_hasher,
+	md5,
+	md5_hasher,
+	md5s,
+	hashUpdate,
+	hashFinalize,
+	Digest,
+	HashAlgorithm,
+	Context,
+	mkIncrementalHasher,
+	mkIncrementalVerifier,
+) where
+
+import qualified Data.ByteString as S
+import qualified Data.ByteString.Lazy as L
+import Data.IORef
+import qualified Data.ByteArray as BA
+import "crypton" Crypto.Hash
+
+import Utility.Hash.Types
+import Utility.Hash.Incremental
+
+sha1 :: L.ByteString -> HashDigest
+sha1 = hashDigest . (hashlazy :: HashLazy SHA1)
+
+sha1_hasher :: IO IncrementalHasher
+sha1_hasher = mkIncrementalHasher (hashInit :: Context SHA1)
+
+sha1s :: S.ByteString -> HashDigest
+sha1s = hashDigest . (hash :: HashStrict SHA1)
+
+sha2_224 :: L.ByteString -> HashDigest
+sha2_224 = hashDigest . (hashlazy :: HashLazy SHA224)
+
+sha2_224_hasher :: IO IncrementalHasher
+sha2_224_hasher = mkIncrementalHasher (hashInit :: Context SHA224)
+
+sha2_256 :: L.ByteString -> HashDigest
+sha2_256 = hashDigest . (hashlazy :: HashLazy SHA256)
+
+sha2_256_hasher :: IO IncrementalHasher
+sha2_256_hasher = mkIncrementalHasher (hashInit :: Context SHA256)
+
+sha2_384 :: L.ByteString -> HashDigest
+sha2_384 = hashDigest . (hashlazy :: HashLazy SHA384)
+
+sha2_384_hasher :: IO IncrementalHasher
+sha2_384_hasher = mkIncrementalHasher (hashInit :: Context SHA384)
+
+sha2_512 :: L.ByteString -> HashDigest
+sha2_512 = hashDigest . (hashlazy :: HashLazy SHA512)
+
+sha2_512_hasher :: IO IncrementalHasher
+sha2_512_hasher = mkIncrementalHasher (hashInit :: Context SHA512)
+
+sha3_224 :: L.ByteString -> HashDigest
+sha3_224 = hashDigest . (hashlazy :: HashLazy SHA3_224)
+
+sha3_224_hasher :: IO IncrementalHasher
+sha3_224_hasher = mkIncrementalHasher (hashInit :: Context SHA3_224)
+
+sha3_256 :: L.ByteString -> HashDigest
+sha3_256 = hashDigest . (hashlazy :: HashLazy SHA3_256)
+
+sha3_256_hasher :: IO IncrementalHasher
+sha3_256_hasher = mkIncrementalHasher (hashInit :: Context SHA3_256)
+
+sha3_384 :: L.ByteString -> HashDigest
+sha3_384 = hashDigest . (hashlazy :: HashLazy SHA3_384)
+
+sha3_384_hasher :: IO IncrementalHasher
+sha3_384_hasher = mkIncrementalHasher (hashInit :: Context SHA3_384)
+
+sha3_512 :: L.ByteString -> HashDigest
+sha3_512 = hashDigest . (hashlazy :: HashLazy SHA3_512)
+
+sha3_512_hasher :: IO IncrementalHasher
+sha3_512_hasher = mkIncrementalHasher (hashInit :: Context SHA3_512)
+
+skein256 :: L.ByteString -> HashDigest
+skein256 = hashDigest . (hashlazy :: HashLazy Skein256_256)
+
+skein256_hasher :: IO IncrementalHasher
+skein256_hasher = mkIncrementalHasher (hashInit :: Context Skein256_256)
+
+skein512 :: L.ByteString -> HashDigest
+skein512 = hashDigest . (hashlazy :: HashLazy Skein512_512)
+
+skein512_hasher :: IO IncrementalHasher
+skein512_hasher = mkIncrementalHasher (hashInit :: Context Skein512_512)
+
+blake2s_160 :: L.ByteString -> HashDigest
+blake2s_160 = hashDigest . (hashlazy :: HashLazy Blake2s_160)
+
+blake2s_160_hasher :: IO IncrementalHasher
+blake2s_160_hasher = mkIncrementalHasher (hashInit :: Context Blake2s_160)
+
+blake2s_224 :: L.ByteString -> HashDigest
+blake2s_224 = hashDigest . (hashlazy :: HashLazy Blake2s_224)
+
+blake2s_224_hasher :: IO IncrementalHasher
+blake2s_224_hasher = mkIncrementalHasher (hashInit :: Context Blake2s_224)
+
+blake2s_256 :: L.ByteString -> HashDigest
+blake2s_256 = hashDigest . (hashlazy :: HashLazy Blake2s_256)
+
+blake2s_256_hasher :: IO IncrementalHasher
+blake2s_256_hasher = mkIncrementalHasher (hashInit :: Context Blake2s_256)
+
+blake2sp_224 :: L.ByteString -> HashDigest
+blake2sp_224 = hashDigest . (hashlazy :: HashLazy Blake2sp_224)
+
+blake2sp_224_hasher :: IO IncrementalHasher
+blake2sp_224_hasher = mkIncrementalHasher (hashInit :: Context Blake2sp_224)
+
+blake2sp_256 :: L.ByteString -> HashDigest
+blake2sp_256 = hashDigest . (hashlazy :: HashLazy Blake2sp_256)
+
+blake2sp_256_hasher :: IO IncrementalHasher
+blake2sp_256_hasher = mkIncrementalHasher (hashInit :: Context Blake2sp_256)
+
+blake2b_160 :: L.ByteString -> HashDigest
+blake2b_160 = hashDigest . (hashlazy :: HashLazy Blake2b_160)
+
+blake2b_160_hasher :: IO IncrementalHasher
+blake2b_160_hasher = mkIncrementalHasher (hashInit :: Context Blake2b_160)
+
+blake2b_224 :: L.ByteString -> HashDigest
+blake2b_224 = hashDigest . (hashlazy :: HashLazy Blake2b_224)
+
+blake2b_224_hasher :: IO IncrementalHasher
+blake2b_224_hasher = mkIncrementalHasher (hashInit :: Context Blake2b_224)
+
+blake2b_256 :: L.ByteString -> HashDigest
+blake2b_256 = hashDigest . (hashlazy :: HashLazy Blake2b_256)
+
+blake2b_256_hasher :: IO IncrementalHasher
+blake2b_256_hasher = mkIncrementalHasher (hashInit :: Context Blake2b_256)
+
+blake2b_384 :: L.ByteString -> HashDigest
+blake2b_384 = hashDigest . (hashlazy :: HashLazy Blake2b_384)
+
+blake2b_384_hasher :: IO IncrementalHasher
+blake2b_384_hasher = mkIncrementalHasher (hashInit :: Context Blake2b_384)
+
+blake2b_512 :: L.ByteString -> HashDigest
+blake2b_512 = hashDigest . (hashlazy :: HashLazy Blake2b_512)
+
+blake2b_512_hasher :: IO IncrementalHasher
+blake2b_512_hasher = mkIncrementalHasher (hashInit :: Context Blake2b_512)
+
+blake2bp_512 :: L.ByteString -> HashDigest
+blake2bp_512 = hashDigest . (hashlazy :: HashLazy Blake2bp_512)
+
+blake2bp_512_hasher :: IO IncrementalHasher
+blake2bp_512_hasher = mkIncrementalHasher (hashInit :: Context Blake2bp_512)
+
+md5 ::  L.ByteString -> HashDigest
+md5 = hashDigest . (hashlazy :: HashLazy MD5)
+
+md5_hasher :: IO IncrementalHasher
+md5_hasher = mkIncrementalHasher (hashInit :: Context MD5)
+
+md5s ::  S.ByteString -> HashDigest
+md5s = hashDigest . (hash :: HashStrict MD5)
+
+type HashStrict t = S.ByteString -> Digest t
+
+type HashLazy t = L.ByteString -> Digest t
+
+hashDigest :: Digest a -> HashDigest
+hashDigest = HashDigest . BA.convert
+
+mkIncrementalHasher :: HashAlgorithm h => Context h -> IO IncrementalHasher
+mkIncrementalHasher ctx = do
+	v <- newIORef (Just (ctx, 0))
+	return $ IncrementalHasher
+		{ updateIncrementalHasher = \b ->
+			modifyIORef' v $ \case
+				(Just (ctx', n)) -> 
+					let !ctx'' = hashUpdate ctx' b
+					    !n' = n + fromIntegral (S.length b)
+					in (Just (ctx'', n'))
+				Nothing -> Nothing
+		, finalizeIncrementalHasher =
+			readIORef v >>= \case
+				(Just (ctx', _)) -> return $ Just $
+					hashDigest $ hashFinalize ctx'
+				Nothing -> return Nothing
+		, unableIncrementalHasher = writeIORef v Nothing
+		, positionIncrementalHasher = readIORef v >>= \case
+			Just (_, n) -> return (Just n)
+			Nothing -> return Nothing
+		}
diff --git a/Utility/Hash/Incremental.hs b/Utility/Hash/Incremental.hs
new file mode 100644
--- /dev/null
+++ b/Utility/Hash/Incremental.hs
@@ -0,0 +1,47 @@
+{- Incremental hashing
+ -
+ - Copyright 2013-2026 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+module Utility.Hash.Incremental where
+
+import Utility.Hash.Types
+
+import qualified Data.ByteString as S
+
+data IncrementalHasher = IncrementalHasher
+	{ updateIncrementalHasher :: S.ByteString -> IO ()
+	-- ^ Called repeatedly on each piece of the content.
+	, finalizeIncrementalHasher :: IO (Maybe HashDigest)
+	-- ^ Called once the full content has been sent, returns
+	-- the hash. (Nothing if unableIncremental was called.)
+	, unableIncrementalHasher :: IO ()
+	-- ^ Call if the incremental hashing is unable to be done.
+	, positionIncrementalHasher :: IO (Maybe Integer)
+	-- ^ Returns the number of bytes that have been fed to this
+	-- incremental hasher so far. (Nothing if unableIncrementalHasher
+	-- was called.)
+	}
+
+data IncrementalVerifier = IncrementalVerifier
+	{ updateIncrementalVerifier :: S.ByteString -> IO ()
+	, finalizeIncrementalVerifier :: IO (Maybe Bool)
+	, unableIncrementalVerifier :: IO ()
+	, positionIncrementalVerifier :: IO (Maybe Integer)
+	, descIncrementalVerifier :: String
+	}
+
+mkIncrementalVerifier :: IO IncrementalHasher -> String -> (Hash -> Bool) -> IO IncrementalVerifier
+mkIncrementalVerifier mkhasher desc samechecksum = do
+	hasher <- mkhasher
+	return $ IncrementalVerifier
+		{ updateIncrementalVerifier = updateIncrementalHasher hasher
+		, finalizeIncrementalVerifier = 
+			maybe Nothing (Just . samechecksum . digestToHash)
+				<$> finalizeIncrementalHasher hasher
+		, unableIncrementalVerifier = unableIncrementalHasher hasher
+		, positionIncrementalVerifier = positionIncrementalHasher hasher
+		, descIncrementalVerifier = desc
+		}
diff --git a/Utility/Hash/Types.hs b/Utility/Hash/Types.hs
new file mode 100644
--- /dev/null
+++ b/Utility/Hash/Types.hs
@@ -0,0 +1,45 @@
+{- Hash types
+ -
+ - Copyright 2026 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+{-# LANGUAGE DeriveGeneric #-}
+
+module Utility.Hash.Types where
+
+import qualified Data.ByteString as S
+import Data.ByteArray
+import qualified Data.ByteArray.Encoding as BAE
+import Data.String
+import Control.DeepSeq
+import GHC.Generics
+import Prelude hiding (length)
+
+import Utility.FileSystemEncoding
+
+-- base16 encoded hash
+newtype Hash = Hash { hashByteString :: S.ByteString }	
+	deriving (Eq, Generic)
+
+instance Show Hash where
+	show (Hash v) = decodeBS v
+
+instance IsString Hash where
+	fromString = Hash . encodeBS
+
+instance NFData Hash
+
+-- the raw hash digest
+newtype HashDigest = HashDigest S.ByteString
+	deriving (Eq, Generic)
+
+digestToHash :: HashDigest -> Hash
+digestToHash (HashDigest d) = Hash $ BAE.convertToBase BAE.Base16 d
+
+instance NFData HashDigest
+
+instance ByteArrayAccess HashDigest where
+	length (HashDigest d) = length d
+	withByteArray (HashDigest d) = withByteArray d
diff --git a/Utility/Hash/XXH3.hs b/Utility/Hash/XXH3.hs
new file mode 100644
--- /dev/null
+++ b/Utility/Hash/XXH3.hs
@@ -0,0 +1,43 @@
+{- XXH3 convenience wrapper.
+ -
+ - Copyright 2026 Joey Hess <id@joeyh.name>
+ -
+ - License: BSD-2-clause
+ -}
+
+module Utility.Hash.XXH3 where
+
+import Utility.Hash.Types
+
+import qualified Data.ByteString.Lazy as L
+import qualified Data.Digest.XXHash.FFI as XXH3
+import qualified Data.Hashable as Hashable
+import Data.Word
+import Data.Bits
+import Data.ByteString.Builder
+
+-- Due to use of Int, the xxhash-ffi library is currently only suitable
+-- for use on 64 bit (or higher) systems. Trying to use it on
+-- 32 bit systems will produce non-canonical values.
+-- https://github.com/haskell-haskey/xxhash-ffi/issues/6
+xxh3Supported :: Bool
+xxh3Supported = finiteBitSize (0 :: Int) >= 64
+
+xxh3 :: L.ByteString -> HashDigest
+xxh3 = convcanonical . Hashable.hashWithSalt 0 . XXH3.XXH3
+  where
+	-- Convert to XXH3 canonical representation.
+	-- This is unfortunately not exposed by xxhash-ffi so has to
+	-- be re-implemented here.
+	-- See https://github.com/haskell-haskey/xxhash-ffi/issues/8
+	convcanonical = HashDigest 
+		. L.toStrict . toLazyByteString 
+		. word64BE . fromint
+	fromint :: Int -> Word64
+	fromint = fromIntegral
+	
+-- The haskell library does not support incremental verification
+-- (without going too low-level to be appropriate here).
+-- https://github.com/haskell-haskey/xxhash-ffi/issues/7
+xxh3Incremental :: Maybe a
+xxh3Incremental = Nothing
diff --git a/Utility/LockFile/PidLock.hs b/Utility/LockFile/PidLock.hs
--- a/Utility/LockFile/PidLock.hs
+++ b/Utility/LockFile/PidLock.hs
@@ -124,7 +124,7 @@
 	let shortbase = reverse $ take 32 $ reverse base
 	let md5sum = if base == shortbase
 		then ""
-		else show (md5 (encodeBL base))
+		else show (digestToHash (md5 (encodeBL base)))
 	dir <- ifM (doesDirectoryExist (literalOsPath "/dev/shm"))
 		( return (literalOsPath "/dev/shm")
 		, return (literalOsPath "/tmp")
diff --git a/Utility/Url.hs b/Utility/Url.hs
--- a/Utility/Url.hs
+++ b/Utility/Url.hs
@@ -50,12 +50,13 @@
 import Network.HTTP.Client.Restricted
 import Utility.IPAddress
 import qualified Utility.RawFilePath as R
-import Utility.Hash (IncrementalVerifier(..))
+import Utility.Hash.Incremental
 import Utility.Url.Parse
 import qualified Utility.FileIO as F
 
 import Network.URI
 import Network.HTTP.Types
+import Network.HTTP.Types.Header (hAcceptEncoding, hContentDisposition, hContentRange)
 import qualified System.FilePath.Posix as UrlPath
 import qualified Data.CaseInsensitive as CI
 import qualified Data.ByteString as B
@@ -669,15 +670,6 @@
 		| uriPort ua == ":" -> parseRequest $ show $
 			u { uriAuthority = Just $ ua { uriPort = "" } }
 	_ -> parseRequest (show u)
-
-hAcceptEncoding :: CI.CI B.ByteString
-hAcceptEncoding = "Accept-Encoding"
-
-hContentDisposition :: CI.CI B.ByteString
-hContentDisposition = "Content-Disposition"
-
-hContentRange :: CI.CI B.ByteString
-hContentRange = "Content-Range"
 
 resumeFromHeader :: FileSize -> Header
 resumeFromHeader sz = (hRange, renderByteRanges [ByteRangeFrom sz])
diff --git a/Utility/Verifiable.hs b/Utility/Verifiable.hs
deleted file mode 100644
--- a/Utility/Verifiable.hs
+++ /dev/null
@@ -1,46 +0,0 @@
-{- values verified using a shared secret
- -
- - Copyright 2012 Joey Hess <id@joeyh.name>
- -
- - License: BSD-2-clause
- -}
-
-module Utility.Verifiable (
-	Secret,
-	HMACDigest,
-	Verifiable(..),
-	mkVerifiable,
-	verify,
-	prop_verifiable_sane,
-) where
-
-import Data.ByteString.UTF8 (fromString)
-import qualified Data.ByteString as S
-
-import Utility.Hash
-import Utility.QuickCheck
-
-type Secret = S.ByteString
-type HMACDigest = String
-
-{- A value, verifiable using a HMAC digest and a secret. -}
-data Verifiable a = Verifiable
-	{ verifiableVal :: a
-	, verifiableDigest :: HMACDigest
-	}
-	deriving (Eq, Read, Show)
-
-mkVerifiable :: Show a => a -> Secret -> Verifiable a
-mkVerifiable a secret = Verifiable a (calcDigest (show a) secret)
-
-verify :: (Eq a, Show a) => Verifiable a -> Secret -> Bool
-verify v secret = v == mkVerifiable (verifiableVal v) secret
-
-calcDigest :: String -> Secret -> HMACDigest
-calcDigest v secret = calcMac show HmacSha1 secret (fromString v)
-
-prop_verifiable_sane :: TestableString -> TestableString -> Bool
-prop_verifiable_sane v ts = 
-	verify (mkVerifiable (fromTestableString v) secret) secret
-  where
-	secret = fromString (fromTestableString ts)
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.20260601
+Version: 10.20260624
 Cabal-Version: 1.12
 License: AGPL-3
 Maintainer: Joey Hess <id@joeyh.name>
@@ -35,6 +35,7 @@
 -- Include only files that are needed make cabal install git-annex work.
 Extra-Source-Files:
   stack.yaml
+  stack-botan.yaml
   README
   CHANGELOG
   NEWS
@@ -157,6 +158,10 @@
   Default: False
   Manual: True
 
+Flag Botan
+  Description: Build with the Botan C++ library for faster hashing
+  Default: False
+
 Flag TorrentParser
   Description: Use haskell torrent library to parse torrent files
 
@@ -178,6 +183,14 @@
 Flag Dbus
   Description: Enable dbus support
 
+Flag Blake3
+  Description: Enable BLAKE3 support
+  Default: True
+
+Flag XXH3
+  Description: Enable XXH3 support
+  Default: False
+
 source-repository head
   type: git
   location: https://git.joeyh.name/git/git-annex.git/
@@ -233,12 +246,11 @@
    resourcet,
    http-client (>= 0.5.3),
    http-client-tls (>= 0.3.2),
-   http-types (>= 0.7),
+   http-types (>= 0.9),
    http-conduit (>= 2.3.0),
    http-client-restricted (>= 0.0.2),
    conduit,
    time (>= 1.9.1),
-   old-locale,
    persistent-sqlite (>= 2.13.3),
    persistent (>= 2.13.3),
    persistent-template (>= 2.8.0),
@@ -251,9 +263,7 @@
    feed (>= 1.1.0),
    regex-tdfa,
    socks (>= 0.6.0),
-   byteable,
    stm-chans,
-   securemem,
    crypto-api,
    memory,
    deepseq,
@@ -320,6 +330,29 @@
     Build-Depends:
       filepath-bytestring (>= 1.4.2.1.1)
 
+  if flag(Botan)
+    Build-Depends:
+      botan-low (< 0.3)
+    CPP-Options: -DWITH_BOTAN
+    Other-Modules:
+      Utility.Hash.Botan
+
+  -- Disabled on arm until this issue is resolved:
+  -- https://github.com/k0001/hs-blake3/issues/8
+  if flag(Blake3) && (! arch(aarch64) && ! arch(arm))
+    Build-Depends: blake3 (>= 0.3)
+    CPP-Options: -DWITH_BLAKE3
+    Other-Modules:
+      Utility.Hash.Blake3
+  
+  if flag(XXH3)
+    Build-Depends:
+      xxhash-ffi (>= 0.3),
+      hashable
+    CPP-Options: -DWITH_XXH3
+    Other-Modules:
+      Utility.Hash.XXH3
+
   if (os(windows))
     Build-Depends:
       Win32 (>= 2.13.4.0),
@@ -363,7 +396,6 @@
       Assistant.MakeRepo
       Assistant.Monad
       Assistant.NamedThread
-      Assistant.Pairing
       Assistant.Pushes
       Assistant.RemoteControl
       Assistant.Repair
@@ -700,6 +732,7 @@
     Command.PreCommit
     Command.Pull
     Command.Push
+    Command.Put
     Command.Recompute
     Command.ReKey
     Command.ReadPresentKey
@@ -1061,6 +1094,10 @@
     Utility.Glob
     Utility.Gpg
     Utility.Hash
+    Utility.Hash.Crypton
+    Utility.Hash.Incremental
+    Utility.Hash.Types
+    Utility.HMAC
     Utility.HtmlDetect
     Utility.HumanNumber
     Utility.HumanTime
@@ -1132,7 +1169,6 @@
     Utility.Url
     Utility.Url.Parse
     Utility.UserInfo
-    Utility.Verifiable
 
   if (os(windows))
     Other-Modules:
diff --git a/stack-botan.yaml b/stack-botan.yaml
new file mode 100644
--- /dev/null
+++ b/stack-botan.yaml
@@ -0,0 +1,28 @@
+flags:
+  git-annex:
+    production: true
+    parallelbuild: true
+    assistant: true
+    torrentparser: true
+    magicmime: false
+    dbus: false
+    debuglocks: false
+    benchmark: true
+    ospath: true
+    botan: true
+    blake3: true
+    xxh3: true
+  file-io:
+    os-string: true
+  xxhash-ffi:
+    pkg-config: false
+packages:
+- '.'
+resolver: lts-24.26
+extra-deps:
+- aws-0.25.2
+- file-io-0.2.0
+- botan-low-0.2.0.1
+- botan-bindings-0.3.0.0
+- blake3-0.3
+- xxhash-ffi-0.3.1
diff --git a/stack.yaml b/stack.yaml
--- a/stack.yaml
+++ b/stack.yaml
@@ -9,11 +9,18 @@
     debuglocks: false
     benchmark: true
     ospath: true
+    botan: false
+    blake3: true
+    xxh3: true
   file-io:
     os-string: true
+  xxhash-ffi:
+    pkg-config: false
 packages:
 - '.'
 resolver: lts-24.26
 extra-deps:
 - aws-0.25.2
 - file-io-0.2.0
+- blake3-0.3
+- xxhash-ffi-0.3.1
