diff --git a/Annex/FileMatcher.hs b/Annex/FileMatcher.hs
--- a/Annex/FileMatcher.hs
+++ b/Annex/FileMatcher.hs
@@ -67,7 +67,7 @@
 checkFileMatcher' :: LiveUpdate -> GetFileMatcher -> OsPath -> Annex Bool -> Annex Bool
 checkFileMatcher' lu getmatcher file notconfigured = do
 	matcher <- getmatcher file
-	checkMatcher matcher Nothing afile lu S.empty notconfigured d
+	checkMatcher matcher Nothing afile lu mempty notconfigured d
   where
 	afile = AssociatedFile (Just file)
 	-- checkMatcher will never use this, because afile is provided.
@@ -287,7 +287,7 @@
 
 checkAddUnlockedMatcher :: LiveUpdate -> AddUnlockedMatcher -> MatchInfo -> Annex Bool
 checkAddUnlockedMatcher lu (AddUnlockedMatcher matcher) mi = 
-	checkMatcher' matcher mi lu S.empty
+	checkMatcher' matcher mi lu mempty
 
 simply :: MatchFiles Annex -> ParseResult (MatchFiles Annex)
 simply = Right . Operation
@@ -342,7 +342,7 @@
 				(groupwanted mygroups)
 			| otherwise = unknownmatcher
 		mygroups = fromMaybe S.empty (u `M.lookup` groupsByUUID groupmap)
-		groupwanted s = case M.elems $ M.filterWithKey (\k _ -> S.member k s) groupwantedmap of
+		groupwanted s = case filter (not . null) $ M.elems $ M.filterWithKey (\k _ -> S.member k s) groupwantedmap of
 			[pc] -> Just pc
 			_ -> Nothing
 
diff --git a/Annex/Import.hs b/Annex/Import.hs
--- a/Annex/Import.hs
+++ b/Annex/Import.hs
@@ -499,11 +499,11 @@
 buildImportTreesHistory converttree basetree msubdir history hdl = S.fromList
 	<$> mapM (\ic -> buildImportTreesGeneric' converttree basetree msubdir ic hdl) history
 
-canImportKeys :: Remote -> Bool -> Bool
+canImportKeys :: Remote -> Bool -> Annex Bool
 canImportKeys remote importcontent =
-	importcontent || isJust (Remote.importKey ia)
+	pure (importcontent) <||> (isJust <$> Remote.importKey ia)
   where
-	ia = Remote.exportImportActions remote
+	ia = Remote.importActions remote
 
 -- Result of an import. 
 data ImportResult t
@@ -671,7 +671,7 @@
 	-> ImportableContentsChunkable Annex (ContentIdentifier, ByteSize)
 	-> Annex (ImportResult (ImportableContentsChunkable Annex (Either Sha Key)))
 importKeys remote importtreeconfig importcontent thirdpartypopulated importablecontents = do
-	unless (canImportKeys remote importcontent) $
+	unlessM (canImportKeys remote importcontent) $
 		giveup "This remote does not support importing without downloading content."
 	-- This map is used to remember content identifiers that
 	-- were just imported, before they have necessarily been
@@ -807,7 +807,7 @@
 			return (Right job)
 	
 	thirdpartypopulatedimport db (loc, (cid, sz)) = 
-		case Remote.importKey (Remote.exportImportActions remote) of
+		Remote.importKey (Remote.importActions remote) >>= \case
 			Nothing -> return Nothing
 			Just importkey ->
 				tryNonAsync (importkey loc cid sz nullMeterUpdate) >>= \case
@@ -823,20 +823,21 @@
 	importordownload cidmap (loc, (cid, sz)) largematcher = do
 		f <- locworktreefile loc
 		matcher <- largematcher f
+		let usedownload = dodownload cidmap (loc, (cid, sz)) f matcher
+		let useimport = doimport cidmap (loc, (cid, sz)) f matcher
 		-- When importing a key is supported, always use it rather
 		-- than downloading and retrieving a key, to avoid
 		-- generating trees with different keys for the same content.
-		let act = if importcontent
-			then case Remote.importKey (Remote.exportImportActions remote) of
-				Nothing -> dodownload
+		if importcontent
+			then Remote.importKey (Remote.importActions remote) >>= \case
+				Nothing -> usedownload
 				Just _ -> if Utility.Matcher.introspect matchNeedsFileContent (fst matcher)
-					then dodownload
-					else doimport
-			else doimport
-		act cidmap (loc, (cid, sz)) f matcher
+					then usedownload
+					else useimport
+			else useimport
 
 	doimport cidmap (loc, (cid, sz)) f matcher =
-		case Remote.importKey (Remote.exportImportActions remote) of
+		Remote.importKey (Remote.importActions remote) >>= \case
 			Nothing -> error "internal" -- checked earlier
 			Just importkey -> do
 				when (Utility.Matcher.introspect matchNeedsFileContent (fst matcher)) $
diff --git a/Annex/NumCopies.hs b/Annex/NumCopies.hs
--- a/Annex/NumCopies.hs
+++ b/Annex/NumCopies.hs
@@ -213,7 +213,7 @@
 	let nhave = numCopiesCount have
 	explain (ActionItemTreeFile file) $ Just $ UnquotedString $
 		"has " ++ show nhave ++ " " ++ pluralCopies nhave ++ 
-		", and the configured annex.numcopies is " ++ show needed
+		", and the configured annex.numcopies is " ++ show (fromNumCopies needed)
 	return $ numCopiesCheck'' have vs needed
 
 numCopiesCheck'' :: [UUID] -> (Int -> Int -> v) -> NumCopies -> v
diff --git a/Annex/Wanted.hs b/Annex/Wanted.hs
--- a/Annex/Wanted.hs
+++ b/Annex/Wanted.hs
@@ -19,11 +19,11 @@
 
 {- Check if a file is preferred content for the local repository. -}
 wantGet :: LiveUpdate -> Bool -> Maybe Key -> AssociatedFile -> Annex Bool
-wantGet lu d key file = isPreferredContent lu Nothing S.empty key file d
+wantGet lu d key file = isPreferredContent lu Nothing mempty key file d
 
 {- Check if a file is preferred content for a repository. -}
 wantGetBy :: LiveUpdate -> Bool -> Maybe Key -> AssociatedFile -> UUID -> Annex Bool
-wantGetBy lu d key file to = isPreferredContent lu (Just to) S.empty key file d
+wantGetBy lu d key file to = isPreferredContent lu (Just to) mempty key file d
 
 {- Check if a file is not preferred or required content, and can be
  - dropped. When a UUID is provided, checks for that repository.
@@ -46,8 +46,8 @@
 checkDrop :: (LiveUpdate -> Maybe UUID -> AssumeNotPresent -> Maybe Key -> AssociatedFile -> Bool -> Annex Bool) -> LiveUpdate -> Bool -> Maybe UUID -> Maybe Key -> AssociatedFile -> (Maybe [AssociatedFile]) -> Annex (Maybe AssociatedFile)
 checkDrop checker lu d from key file others = do
 	u <- maybe getUUID (pure . id) from
-	let s = S.singleton u
-	let checker' f = checker lu (Just u) s key f d
+	let notpresent = AssumeNotPresent (S.singleton u)
+	let checker' f = checker lu (Just u) notpresent key f d
 	ifM (checker' file)
 		( return (Just file)
 		, do
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,42 @@
+git-annex (10.20260901) upstream; urgency=medium
+
+  * Behavior change: drop --auto --from a remote does not any longer 
+    try to drop content that is not known to be present on the remote.
+    This avoids unncessary work and makes it consistent with the behavior
+    of git-annex sync and push.
+  * External special remote protocol extended to support IMPORTKEY.
+  * git-annex-remote-internetarchive supports --no-content imports.
+  * diffdriver: Avoid crashing when git passes an (undocumented) 8th
+    parameter.
+  * The preferred content groupwanted expression will no longer
+    consider a groupwanted expression of "" to be set, which allows
+    another group's groupwanted expression to be used instead.
+  * Fixed buggy handling of preferred content
+    "balanced=groupname:lackingcopies"
+  * Expand preferred content "lackingcopies" and "approxlackingcopies"
+    expression syntax to support "groupname:number"
+  * Expand preferred content "copies", "lackingcopies", and
+    "approxlackingcopies" expression syntax to support group limits which
+    can include/exclude multiple groups. Eg
+    "copies=archive+backup-offsite=3"
+  * Also expanded --lackingcopies, --approxlackingcopies, and --copies
+    with the same syntax.
+  * Expand preferred content "balanced", "fullybalanced", 
+    "sizebalanced" and "fullysizebalanced" expression syntax to support
+    group limits as well. Eg
+    "balanced=backup:lackingcopies=archive-offsite"
+  * importfeed: Fix reporting and logging of problems with feeds.
+  * importfeed: When adding an url, indicate which feed it is from.
+  * Fix reversion in 8.20200226 that broke git-annex benchmark --databases
+  * Remove the ParallelBuild cabal flag and add cabal.project that
+    enables parallel build by default with ghc 9.8+ and cabal-install 3.12.
+  * NoLLMDependencies: Update for warp and magic.
+  * git-annex.cabal: Pin magic to 1.1 avoiding build failure on Windows
+    with newer version.
+  * stack.yaml: Update to lts-24.52
+
+ -- Joey Hess <id@joeyh.name>  Tue, 01 Sep 2026 11:25:45 -0400
+
 git-annex (10.20260717) upstream; urgency=medium
 
   * External special remote protocol extended to support import.
diff --git a/Command/DiffDriver.hs b/Command/DiffDriver.hs
--- a/Command/DiffDriver.hs
+++ b/Command/DiffDriver.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2014-2023 Joey Hess <id@joeyh.name>
+ - Copyright 2014-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -88,6 +88,10 @@
 			, rNewHex = new_hex
 			, rNewMode = new_mode
 			}
+	-- git documents 7 parameters, but there can be an additional parameter
+	-- containing a similarity index description.
+	mk (path:old_file:old_hex:old_mode:new_file:new_hex:new_mode:_:[]) =
+		mk (path:old_file:old_hex:old_mode:new_file:new_hex:new_mode:[])
 	mk (unmergedpath:[]) = UnmergedReq { rPath = unmergedpath }
 	mk _ = badopts
 
diff --git a/Command/Drop.hs b/Command/Drop.hs
--- a/Command/Drop.hs
+++ b/Command/Drop.hs
@@ -90,7 +90,7 @@
 		stopUnless (wantdrop lu) $
 			case from of
 				Nothing -> startLocal lu pcc afile ai si numcopies mincopies key [] ud
-				Just remote -> startRemote lu pcc afile ai si numcopies mincopies key ud remote
+				Just remote -> startRemote' (autoMode o) lu pcc afile ai si numcopies mincopies key ud remote
   where
 	remoteuuid = Remote.uuid <$> from
 	wantdrop lu
@@ -110,8 +110,11 @@
 		performLocal lu pcc key afile numcopies mincopies preverified ud
 
 startRemote :: LiveUpdate -> PreferredContentChecked -> AssociatedFile -> ActionItem -> SeekInput -> NumCopies -> MinCopies -> Key -> DroppingUnused -> Remote -> CommandStart
-startRemote lu pcc afile ai si numcopies mincopies key ud remote = do
-	fast <- Annex.getRead Annex.fast
+startRemote = startRemote' False
+
+startRemote' :: Bool -> LiveUpdate -> PreferredContentChecked -> AssociatedFile -> ActionItem -> SeekInput -> NumCopies -> MinCopies -> Key -> DroppingUnused -> Remote -> CommandStart
+startRemote' automode lu pcc afile ai si numcopies mincopies key ud remote = do
+	fast <- if automode then pure True else Annex.getRead Annex.fast
 	if fast
 		then do
 			remotes <- Remote.keyPossibilities (Remote.IncludeIgnored True) key
diff --git a/Command/Fsck.hs b/Command/Fsck.hs
--- a/Command/Fsck.hs
+++ b/Command/Fsck.hs
@@ -395,7 +395,7 @@
 	go requiredlocs = do
 		presentlocs <- S.fromList <$> loggedLocations key
 		missinglocs <- filterM
-			(\u -> isRequiredContent NoLiveUpdate (Just u) S.empty (Just key) afile False)
+			(\u -> isRequiredContent NoLiveUpdate (Just u) mempty (Just key) afile False)
 			(S.toList $ S.difference requiredlocs presentlocs)
 		if null missinglocs
 			then return True
diff --git a/Command/ImportFeed.hs b/Command/ImportFeed.hs
--- a/Command/ImportFeed.hs
+++ b/Command/ImportFeed.hs
@@ -172,10 +172,13 @@
 	parse tmpf = liftIO (parseFeedFromFile' tmpf) >>= \case
 		Nothing -> debugfeedcontent tmpf "parsing the feed failed"
 		Just f -> do
-			case decodeBS $ fromFeedText $ getFeedTitle f of
-				"" -> noop
-				t -> showNote (UnquotedString ('"' : t ++ "\""))
-			case findDownloads url f of
+			let feedtitle = '"' : decodeBS (fromFeedText $ getFeedTitle f) ++ "\""
+			unless (null feedtitle) $
+				showNote (UnquotedString feedtitle)
+			let feeddesc = if null feedtitle
+				then url
+				else feedtitle
+			case findDownloads url f feeddesc of
 				[] -> debugfeedcontent tmpf "bad feed content; no enclosures to download"
 				l -> do
 					record (Just (Just l))
@@ -202,6 +205,7 @@
 
 data ToDownload = ToDownload
 	{ feedurl :: URLString
+	, feeddescription :: String
 	, location :: DownloadLocation
 	, itemid :: Maybe B.ByteString
 	-- Either the parsed or unparsed date.
@@ -240,8 +244,8 @@
 	ai = ActionItemOther (Just "gathering known urls")
 	si = SeekInput []
 
-findDownloads :: URLString -> Feed -> [ToDownload]
-findDownloads u f = catMaybes $ map mk (feedItems f)
+findDownloads :: URLString -> Feed -> String -> [ToDownload]
+findDownloads u f feeddesc = catMaybes $ map mk (feedItems f)
   where
 	mk i = case getItemEnclosure i of
 		Just (enclosureurl, _, _) ->
@@ -253,6 +257,7 @@
 			Nothing -> Nothing
 	mk' i l = ToDownload
 		{ feedurl = u
+		, feeddescription = feeddesc
 		, location = l
 		, itemid = case getItemId i of
 			Just (_, iid) -> Just (fromFeedText iid)
@@ -412,7 +417,7 @@
 				| null ks -> do
 					broken <- checkFeedBroken (feedurl todownload)
 					when broken $
-						void $ feedProblem url "download failed"
+						void $ feedProblem (feedurl todownload) "download failed"
 					liftIO $ atomically $ putTMVar cv broken
 					next $ return False
 				| otherwise -> do
@@ -468,9 +473,11 @@
 		(go `onException` recordfailure)
   where
 	recordfailure = do
-		void $ feedProblem url "download failed"
+		void $ feedProblem (feedurl todownload) "download failed"
 		liftIO $ atomically $ tryPutTMVar cv False
 	go = do
+		showNote $ UnquotedString $
+			"from " ++ feeddescription todownload
 		maybeAddJSONField "url" url
 		a
 
@@ -582,6 +589,7 @@
 		iurl <- youtube_url i
 		return $ ToDownload
 			{ feedurl = url
+			, feeddescription = url
 			, location = MediaLink iurl
 			, itemid = Just (encodeBS iurl)
 			, itempubdate = 
diff --git a/Command/MatchExpression.hs b/Command/MatchExpression.hs
--- a/Command/MatchExpression.hs
+++ b/Command/MatchExpression.hs
@@ -14,7 +14,6 @@
 import Logs.Group
 
 import qualified Data.Map as M
-import qualified Data.Set as S
 
 cmd :: Command
 cmd = noCommit $
@@ -90,7 +89,7 @@
 			, liftIO exitFailure
 			)
   where
-	checkmatcher matcher = checkMatcher' matcher (matchinfo o) NoLiveUpdate S.empty
+	checkmatcher matcher = checkMatcher' matcher (matchinfo o) NoLiveUpdate mempty
 
 bail :: String -> IO a
 bail s = do
diff --git a/Command/Sync.hs b/Command/Sync.hs
--- a/Command/Sync.hs
+++ b/Command/Sync.hs
@@ -627,8 +627,8 @@
 		Nothing -> noop
 		Just b -> do
 			let (branch, subdir) = splitRemoteAnnexTrackingBranchSubdir b
-			if canImportKeys remote importcontent
-				then do
+			ifM (canImportKeys remote importcontent)
+				( do
 					addunlockedmatcher <- addUnlockedMatcher
 					Command.Import.seekRemote remote branch subdir importcontent (CheckGitIgnore True) addunlockedmatcher []
 					-- Importing generates a branch
@@ -638,7 +638,8 @@
 					-- mergeing it.
 					mc <- mergeConfig True
 					void $ mergeRemote remote currbranch mc o
-				else warning $ UnquotedString $ "Cannot import from " ++ Remote.name remote ++ " when not syncing content."
+				, warning $ UnquotedString $ "Cannot import from " ++ Remote.name remote ++ " when not syncing content."
+				)
   where
 	wantpull = remoteAnnexPull (Remote.gitconfig remote)
 
@@ -651,10 +652,12 @@
 pullThirdPartyPopulated :: SyncOptions -> Remote -> CommandSeek
 pullThirdPartyPopulated o remote
 	| not (pullOption o) || not wantpull = noop
-	| not (canImportKeys remote False) = noop
-	| otherwise = void $ includeCommandAction $ starting "list" ai si $
-		Command.Import.listContents' remote ImportTree (CheckGitIgnore False) go
+	| otherwise = 
+		whenM (canImportKeys remote False)
+			start
   where
+	start = void $ includeCommandAction $ starting "list" ai si $
+		Command.Import.listContents' remote ImportTree (CheckGitIgnore False) go
 	go (Just importable) = importChanges remote ImportTree False True importable >>= \case
 		ImportFinished postexportlogupdate imported -> do
 			(_t, updatestate) <- recordImportTree remote ImportTree Nothing imported postexportlogupdate
diff --git a/Database/Benchmark.hs b/Database/Benchmark.hs
--- a/Database/Benchmark.hs
+++ b/Database/Benchmark.hs
@@ -11,6 +11,7 @@
 module Database.Benchmark (benchmarkDbs) where
 
 import Annex.Common
+import Annex.Tmp
 import Types.Benchmark
 #ifdef WITH_BENCHMARK
 import qualified Database.Keys.SQL as SQL
@@ -30,18 +31,21 @@
 
 benchmarkDbs :: CriterionMode -> Integer -> Annex ()
 #ifdef WITH_BENCHMARK
-benchmarkDbs mode n = withTmpDirIn (literalOsPath ".") (literalOsPath "benchmark") $ \tmpdir -> do
-	db <- benchDb tmpdir n
-	liftIO $ runMode mode
-		[ bgroup "keys database"
-			[ getAssociatedFilesHitBench db
-			, getAssociatedFilesMissBench db
-			, getAssociatedKeyHitBench db
-			, getAssociatedKeyMissBench db
-			, addAssociatedFileOldBench db
-			, addAssociatedFileNewBench db
+benchmarkDbs mode n = withOtherTmp $ \othertmpdir -> do
+		withTmpDirIn othertmpdir (literalOsPath "benchmark") go
+  where
+	go tmpdir = do
+		db <- benchDb tmpdir n
+		liftIO $ runMode mode
+			[ bgroup "keys database"
+				[ getAssociatedFilesHitBench db
+				, getAssociatedFilesMissBench db
+				, getAssociatedKeyHitBench db
+				, getAssociatedKeyMissBench db
+				, addAssociatedFileOldBench db
+				, addAssociatedFileNewBench db
+				]
 			]
-		]
 #else
 benchmarkDbs _ = giveup "not built with criterion, cannot benchmark"
 #endif
@@ -68,15 +72,17 @@
 
 addAssociatedFileOldBench :: BenchDb -> Benchmark
 addAssociatedFileOldBench (BenchDb h num _) = bench ("addAssociatedFile to (old)") $ nfIO $ do
-	n <- getStdRandom (randomR (1,num))
-	SQL.addAssociatedFile (keyN n) (fileN n) (SQL.WriteHandle h)
+	forM_ [1..num] $ \_ -> do
+		n <- getStdRandom (randomR (1,num))
+		SQL.addAssociatedFile (keyN n) (fileN n) (SQL.WriteHandle h)
 	H.flushDbQueue h
 
 addAssociatedFileNewBench :: BenchDb -> Benchmark
 addAssociatedFileNewBench (BenchDb h num mv) = bench ("addAssociatedFile to (new)") $ nfIO $ do
 	n <- takeMVar mv
-	putMVar mv (n+1)
-	SQL.addAssociatedFile (keyN n) (fileN (num+n)) (SQL.WriteHandle h)
+	putMVar mv (n+num)
+	forM_ [n..n+num] $ \n' ->
+		SQL.addAssociatedFile (keyN n) (fileN (num+n')) (SQL.WriteHandle h)
 	H.flushDbQueue h
 
 populateAssociatedFiles :: H.DbQueue -> Integer -> IO ()
diff --git a/Git/Config/Url.hs b/Git/Config/Url.hs
new file mode 100644
--- /dev/null
+++ b/Git/Config/Url.hs
@@ -0,0 +1,167 @@
+{- git-config http.<url>.* handling
+ -
+ - Copyright 2026 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module Git.Config.Url (
+	getHttpConfig,
+	httpConfigKeys,
+	prop_httpConfigKeys_sane,
+) where
+
+import qualified Data.Map as M
+import Network.URI
+import Data.Function
+
+import Common
+import Git
+import Git.Types
+import Utility.Glob
+
+{- Gets any per-url settings from the git config for a http.foo ConfigKey.
+ -
+ - If there are non, falls back to the non-url-spcecific config, if any,
+ - or the provided fallback value.
+ -
+ - See git-config(1)'s documentation of http.<url>.* for the details.
+ -}
+getHttpConfig :: ConfigKey -> ConfigValue -> URI -> Repo -> ConfigValue
+getHttpConfig key fallback url repo = fromMaybe fallback $
+	case httpConfigKeys key url (config repo) of
+		[] -> Nothing
+		(k:_) -> M.lookup k (config repo)
+
+{- Gets any per-url config keys for a non-url-specific input
+ - http.foo ConfigKey that match the provided url.
+ -
+ - The list is ordered by decreasing precedance and includes the input
+ - ConfigKey at the end when it's part of the RepoConfig.
+ -}
+httpConfigKeys :: ConfigKey -> URI -> RepoConfig -> [ConfigKey]
+httpConfigKeys nonurlspecifickey@(ConfigKey key) urltomatch c =
+	let l = map fst 
+		$ reverse $ sortBy precedence 
+		$ mapMaybe matching (M.keys c)
+	in if M.member nonurlspecifickey c
+		then l ++ [nonurlspecifickey]
+		else l
+  where
+	httpprefix = "http."
+	keysuffix = case decodeBS key of
+		('h':'t':'t':'p':'.':rest) -> '.' : rest
+		v -> v
+	httpprefixlen = length httpprefix
+	keysuffixlen = length keysuffix
+
+	extracturlfromkey = parseURI 
+		. reverse . drop keysuffixlen . reverse 
+		. drop httpprefixlen
+
+	precedence (_k1, u1) (_k2, u2) =
+		(compare `on` (length . uriPath)) u1 u2
+			<> (compare `on` (uriUserInfo <$$> uriAuthority)) u1 u2
+	
+	matching k@(ConfigKey ck) = 
+		let sk = decodeBS ck
+		in if httpprefix `isPrefixOf` sk && keysuffix `isSuffixOf` sk && k /= nonurlspecifickey
+			then do
+				u <- extracturlfromkey sk
+				let same f = f u == f urltomatch
+				if same uriScheme
+					&& (same (uriRegName <$$> uriAuthority)
+						|| subdomainwildcardmatch u)
+					&& same getportordefault
+					&& (same uriPath
+						|| pathslashprefix u)
+					&& (same getusername
+						|| getusername u == Nothing)
+					then Just (k, u)
+					else Nothing
+			else Nothing
+	
+	getportordefault u = do
+		a <- uriAuthority u
+		if null (uriPort a)
+			then case uriScheme u of
+				"http:" -> return ":80"
+				"https:" -> return ":443"
+				_ -> Nothing
+			else return (uriPort a)
+
+	getusername u = do
+		a <- uriAuthority u
+		let (user, _pass) = break (== ':') (uriUserInfo a)
+		let username = fst (break (== '@') user)
+		if null username
+			then Nothing
+			else return username
+
+	pathslashprefix u = 
+		let p = if "/" `isSuffixOf` uriPath u
+			then uriPath u
+			else uriPath u ++ "/"
+		in p `isPrefixOf` uriPath urltomatch
+
+	subdomainwildcardmatch u =
+		subdomainwildcardmatch' (uridomains u) (uridomains urltomatch)
+	
+	subdomainwildcardmatch' [] [] = True
+	subdomainwildcardmatch' [] _ = False
+	subdomainwildcardmatch' _ [] = False
+	subdomainwildcardmatch' (a:as) (b:bs)
+		| a == b = subdomainwildcardmatch' as bs
+		| otherwise =
+			let g = compileGlob a CaseInsensitive (GlobFilePath False)
+			in if matchGlob g b
+				then subdomainwildcardmatch' as bs
+				else False
+
+	uridomains u = case uriRegName <$> uriAuthority u of
+		Nothing -> []
+		Just d -> splitc '.' d
+
+prop_httpConfigKeys_sane :: Bool
+prop_httpConfigKeys_sane = and prop_httpConfigKeys_tests
+
+prop_httpConfigKeys_tests :: [Bool]
+prop_httpConfigKeys_tests =
+	[ httpConfigKeys (ConfigKey "http.foo") u c ==
+		[ "http.http://user@example.com/foo/bar.foo"
+		, "http.http://example.com/foo/bar.foo"
+		, "http.http://example.com/foo.foo"
+		, "http.http://example.com.foo"
+		, "http.foo"
+		]
+	, httpConfigKeys (ConfigKey "http.bar") u c ==
+		[ "http.http://*.com.bar" ]
+	, httpConfigKeys (ConfigKey "http.baz") u c ==
+		[ "http.http://example.com:80.baz" ]
+	, httpConfigKeys (ConfigKey "http.baz") uwithport c ==
+		[ "http.http://example.com:8080.baz" ]
+	]
+  where
+	u = fromMaybe (error "internal") $ 
+		parseURI "http://user:password@example.com/foo/bar/"
+	uwithport = fromMaybe (error "internal") $ 
+		parseURI "http://user:password@example.com:8080/foo/bar/"
+	c = M.fromList $ map (\k -> (ConfigKey k, ConfigValue "dummy value"))
+		[ "http.foo"
+		, "http.http://example.co.foo"
+		, "http.http://example.com.foo"
+		, "https.http://example.com.foo"
+		, "http.http://example.com/fo.foo"
+		, "http.http://example.com/foo.foo"
+		, "http.http://example.com/foo/ba.foo"
+		, "http.http://example.com/foo/bar.foo"
+		, "http.http://user@example.com/foo/bar.foo"
+		, "http.http://nonmatchingexample.com.foo"
+		, "http.http://*.bar"
+		, "http.http://*.com.bar"
+		, "http.http://example.com:80.baz"
+		, "http.http://example.com:443.baz"
+		, "http.http://example.com:8080.baz"
+		]
diff --git a/Git/Credential.hs b/Git/Credential.hs
--- a/Git/Credential.hs
+++ b/Git/Credential.hs
@@ -19,7 +19,6 @@
 
 import qualified Data.Map as M
 import Network.URI
-import Network.HTTP.Types
 import Network.HTTP.Types.Header
 import Control.Concurrent.STM
 
diff --git a/Limit.hs b/Limit.hs
--- a/Limit.hs
+++ b/Limit.hs
@@ -1,6 +1,6 @@
 {- user-specified limits on files to act on
  -
- - Copyright 2011-2025 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -67,7 +67,7 @@
 	run matcher i = do
 		(match, desc) <- runWriterT $
 			Utility.Matcher.matchMrun' matcher $ \o ->
-				matchAction o NoLiveUpdate S.empty i
+				matchAction o NoLiveUpdate mempty i
 		explain (mkActionItem i) $ UnquotedString <$>
 			Utility.Matcher.describeMatchResult
 				(\o -> matchDesc o . Just) desc
@@ -333,14 +333,14 @@
 		, matchNegationUnstable = False
 		, matchDesc = "in" =? s
 		}
-	checkinuuid u notpresent key
+	checkinuuid u (AssumeNotPresent notpresent) key
 		| null date = do
 			us <- Remote.keyLocations key
 			return $ u `elem` us && u `S.notMember` notpresent
 		| otherwise = do
 			us <- loggedLocationsHistorical (RefDate date) key
 			return $ u `elem` us
-	checkinhere notpresent key
+	checkinhere (AssumeNotPresent notpresent) key
 		| S.null notpresent = inAnnex key
 		| otherwise = do
 			u <- getUUID
@@ -415,8 +415,10 @@
 	[v, n] -> case parsetrustspec v of
 		Just checker -> go n $ checktrust checker
 		Nothing -> go n $ checkgroup (toGroup v)
-	[n] -> go n $ const $ return True
-	_ -> Left "bad value for copies"
+	_ -> case splitc '=' want of
+		[gl, n] -> go n $ checkgrouplimit (parseGroupLimit gl)
+		[n] -> go n $ const $ return True
+		_ -> Left "bad value for copies"
   where
 	go num good = case readish num of
 		Nothing -> Left "bad number for copies"
@@ -431,12 +433,15 @@
 			, matchNegationUnstable = False
 			, matchDesc = "copies" =? want
 			}
-	go' n good notpresent key = do
+	go' n good (AssumeNotPresent notpresent) key = do
 		us <- filter (`S.notMember` notpresent)
 			<$> (filterM good =<< Remote.keyLocations key)
 		return $ numCopiesCount us >= n
 	checktrust checker u = checker <$> lookupTrust u
 	checkgroup g u = S.member g <$> lookupGroups u
+	checkgrouplimit gl u = do
+		m <- uuidsByGroup <$> groupMap
+		return (checkGroupLimit gl m u)
 	parsetrustspec s
 		| "+" `isSuffixOf` s = (<=) <$> readTrustLevel (beginning s)
 		| otherwise = (==) <$> readTrustLevel s
@@ -446,7 +451,7 @@
 addLackingCopies desc approx = addLimit . limitLackingCopies desc approx
 
 limitLackingCopies :: String -> Bool -> MkLimit Annex
-limitLackingCopies desc approx want = case readish want of
+limitLackingCopies desc approx want = case readish numwant of
 	Just needed -> Right $ MatchFiles
 		{ matchAction = const $ \notpresent mi -> flip checkKey mi $
 			go mi needed notpresent
@@ -460,13 +465,25 @@
 		}
 	Nothing -> Left "bad value for number of lacking copies"
   where
-	go mi needed notpresent key =
-		limitCheckNumCopies approx mi notpresent key vs
+	go mi needed notpresent key = case (groupwant, grouplimit) of
+		(Nothing, []) -> check (const True)
+		(Just g, _) -> do
+			s <- groupUUIDs g <$> groupMap
+			check (`S.member` s)
+		(Nothing, gl) -> do
+			m <- uuidsByGroup <$> groupMap
+			check (checkGroupLimit gl m)
 	  where
+		check uuidp = limitCheckNumCopies approx mi notpresent uuidp key vs
 		vs nhave numcopies' = numcopies' - nhave >= needed
+	(groupwant, grouplimit, numwant) = case splitc ':' want of
+		(g:n:[]) -> (Just (toGroup g), [], n)
+		_ -> case splitc '=' want of
+			(gl:n:[]) -> (Nothing, parseGroupLimit gl, n)
+			_ -> (Nothing, [], want)
 
-limitCheckNumCopies :: Bool -> MatchInfo -> AssumeNotPresent -> Key -> (Int -> Int -> v) -> Annex v
-limitCheckNumCopies approx mi notpresent key vs = do
+limitCheckNumCopies :: Bool -> MatchInfo -> AssumeNotPresent -> (UUID -> Bool) -> Key -> (Int -> Int -> v) -> Annex v
+limitCheckNumCopies approx mi (AssumeNotPresent notpresent) uuidp key vs = do
 	numcopies <- if approx
 		then approxNumCopies
 		else case mi of
@@ -474,7 +491,7 @@
 				matchFile fi
 			MatchingInfo {} -> approxNumCopies
 			MatchingUserInfo {} -> approxNumCopies
-	us <- filter (`S.notMember` notpresent)
+	us <- filter (\u -> uuidp u && u `S.notMember` notpresent)
 		<$> (trustExclude UnTrusted =<< Remote.keyLocations key)
 	return $ numCopiesCheck'' us vs numcopies
   where
@@ -547,9 +564,8 @@
 
 limitInAllGroup :: Annex GroupMap -> MkLimit Annex
 limitInAllGroup getgroupmap groupname = Right $ MatchFiles
-	{ matchAction = const $ \notpresent mi -> do
-		m <- getgroupmap
-		let want = fromMaybe S.empty $ M.lookup (toGroup groupname) $ uuidsByGroup m
+	{ matchAction = const $ \(AssumeNotPresent notpresent) mi -> do
+		want <- groupUUIDs (toGroup groupname) <$> getgroupmap
 		if S.null want
 			then return True
 			-- optimisation: Check if a wanted uuid is notpresent.
@@ -579,7 +595,7 @@
 limitOnlyInGroup getgroupmap groupname = Right $ MatchFiles
 	{ matchAction = const $ \notpresent mi -> do
 		m <- getgroupmap
-		let want = fromMaybe S.empty $ M.lookup (toGroup groupname) $ uuidsByGroup m
+		let want = groupUUIDs (toGroup groupname) m
 		if S.null want
 			then return False
 			else checkKey (check notpresent want) mi
@@ -592,7 +608,7 @@
 	, matchDesc = "onlyingroup" =? groupname
 	}
   where
-	check notpresent want key = do
+	check (AssumeNotPresent notpresent) want key = do
 		locs <- S.fromList <$> Remote.keyLocations key
 		let present = locs `S.difference` notpresent
 		return $ not (S.null $ present `S.intersection` want)
@@ -604,34 +620,47 @@
 	limitBalanced' "balanced" fullybalanced mu groupname 
 
 limitBalanced' :: String -> MatchFiles Annex -> Maybe UUID -> MkLimit Annex
-limitBalanced' termname fullybalanced mu groupname = do
-	let checknumcopies = ":lackingcopies" `isSuffixOf` groupname
-	enoughcopies <- if checknumcopies
-		then limitLackingCopies termname False "1"
-		else limitCopies $ if ':' `elem` groupname
-			then groupname
-			else groupname ++ ":1"
-	let checkenoughcopies = if checknumcopies then id else not
+limitBalanced' termname fullybalanced mu want = do
+	limitcopies <- if checklackingcopies
+		then limitLackingCopies termname False wantlackingcopies
+		else limitCopies $ if ':' `elem` want
+			then want
+			else want ++ ":1"
 	let present = limitPresent mu
-	let combo f = f present || f fullybalanced || f enoughcopies
-	Right $ MatchFiles
-		{ matchAction = \lu a i ->
-			ifM (Annex.getRead Annex.rebalance)
-				( matchAction fullybalanced lu a i
-				, matchAction present lu a i <||>
-					((checkenoughcopies <$> matchAction enoughcopies lu a i)
-						<&&> matchAction fullybalanced lu a i
+	let combo f = f present || f fullybalanced || f limitcopies
+	let matchaction lu a i =
+		let match f = matchAction f lu a i
+		in ifM (Annex.getRead Annex.rebalance)
+			( match fullybalanced
+			, if checklackingcopies
+				then match present <||>
+					(match limitcopies
+						<&&> match fullybalanced
 					)
-				)
+				else match present <||>
+					((not <$> match limitcopies)
+						<&&> match fullybalanced
+					)
+			)
+	Right $ MatchFiles
+		{ matchAction = matchaction
 		, matchNeedsFileName = combo matchNeedsFileName
 		, matchNeedsFileContent = combo matchNeedsFileContent
 		, matchNeedsKey = combo matchNeedsKey
 		, matchNeedsLocationLog = combo matchNeedsLocationLog
 		, matchNeedsLiveRepoSize = True
 		, matchNegationUnstable = combo matchNegationUnstable
-		, matchDesc = termname =? groupname
+		, matchDesc = termname =? want
 		}
-
+  where
+	(checklackingcopies, wantlackingcopies) = 
+		case splitc ':' want of
+			[g, want']
+				| want' == "lackingcopies" -> (True, "1")
+				| "lackingcopies="` isPrefixOf` want' ->
+					let (_, sgrouplimit) = break (== '=') want'
+					in (True, drop 1 sgrouplimit ++ "+" ++ g ++ "=1")
+			_ -> (False, "")
 
 limitFullyBalanced :: Maybe UUID -> Annex GroupMap -> MkLimit Annex
 limitFullyBalanced = limitFullyBalanced' "fullybalanced"
@@ -682,9 +711,19 @@
 		[g] -> go g (Right 1)
 		[g, n]
 			| n == "lackingcopies" -> go g $ 
-				Left $ \mi notpresent key -> 
-					let vs nhave numcopies = numcopies - nhave
-					in limitCheckNumCopies False mi notpresent key vs
+				Left $ \mi notpresent key -> do
+					s <- getgids g <$> groupMap
+					let others = flip S.notMember s
+					calcnumcopiesneeded mi notpresent key others
+			| "lackingcopies="` isPrefixOf` n -> go g $
+				Left $ \mi notpresent key -> do
+					m <- groupMap
+					let s = getgids g m
+					let (_, sgrouplimit) = break (== '=') n
+					let gl = parseGroupLimit (drop 1 sgrouplimit)
+					let others = \u -> u `S.notMember` s
+						&& checkGroupLimit gl (uuidsByGroup m) u
+					calcnumcopiesneeded mi notpresent key others
 			| otherwise -> maybe
 				(Left $ "bad number for " ++ termname)
 				(go g . Right)
@@ -693,7 +732,12 @@
   where
 	go s n = limitFullyBalanced''' filtercandidates termname mu
 		getgroupmap (toGroup s) n want
+	getgids = groupUUIDs . toGroup
 
+	calcnumcopiesneeded mi notpresent key others =
+		let calc nothers numcopies = numcopies - nothers
+		in limitCheckNumCopies False mi notpresent others key calc
+
 limitFullyBalanced'''
 	:: (Int -> Key -> S.Set UUID -> Annex (S.Set UUID))
 	-> String
@@ -705,8 +749,7 @@
 limitFullyBalanced''' filtercandidates termname mu getgroupmap g getn want = Right $ MatchFiles
 	{ matchAction = \lu notpresent mi -> flip checkKey mi $ \key -> do
 		gm <- getgroupmap
-		let groupmembers = fromMaybe S.empty $
-			M.lookup g (uuidsByGroup gm)
+		let groupmembers = groupUUIDs g gm
 		n <- case getn of
 			Right n -> pure n
 			Left a -> a mi notpresent key
@@ -964,3 +1007,30 @@
 
 (=?) :: String -> String -> (Maybe Bool -> Utility.Matcher.MatchDesc)
 k =? v = matchDescSimple (k ++ "=" ++ v)
+
+data GroupLimit
+	= GroupInclude Group
+	| GroupExclude Group
+	deriving (Show)
+
+parseGroupLimit :: String -> [GroupLimit]
+parseGroupLimit = go GroupInclude
+  where
+	go b s = case break (\c -> c == '+' || c == '-') s of
+		("", "") -> []
+		("", ('+':s')) -> go GroupInclude s'
+		("", ('-':s')) -> go GroupExclude s'
+		(groupname, s') -> b (toGroup groupname) : go GroupInclude s'
+
+checkGroupLimit :: Ord t => [GroupLimit] -> M.Map Group (S.Set t) -> t -> Bool
+checkGroupLimit gl m u = 
+	let (includes, excludes) = partition fst (map decompose gl)
+	in (any member includes || null includes) && not (any member excludes)
+  where
+	decompose (GroupInclude g) = (True, g)
+	decompose (GroupExclude g) = (False, g)
+
+	member (_, g) = 
+		case M.lookup g m of
+			Nothing -> False
+			Just s -> u `S.member` s
diff --git a/Remote/Adb.hs b/Remote/Adb.hs
--- a/Remote/Adb.hs
+++ b/Remote/Adb.hs
@@ -101,7 +101,7 @@
 		, importActions = importUnsupported
 		, exportImportActions = ExportImportActions
 			{ listImportableOrExportedContents = listImportableOrExportedContentsM serial adir c
-			, importKey = Nothing
+			, importKeyWithContentIdentifier = pure Nothing
 			, retrieveExportWithContentIdentifier = retrieveExportWithContentIdentifierM serial adir
 			, storeExportWithContentIdentifier = storeExportWithContentIdentifierM serial adir
 			, removeExportWithContentIdentifier = removeExportWithContentIdentifierM serial adir
diff --git a/Remote/Borg.hs b/Remote/Borg.hs
--- a/Remote/Borg.hs
+++ b/Remote/Borg.hs
@@ -98,7 +98,8 @@
 		, importActions = importUnsupported
 		, exportImportActions = ExportImportActions
 			{ listImportableOrExportedContents = listImportableOrExportedContentsM u borgrepo c
-			, importKey = Just ThirdPartyPopulated.importKey
+			, importKeyWithContentIdentifier = pure $ 
+				Just ThirdPartyPopulated.importKey
 			, retrieveExportWithContentIdentifier = retrieveExportWithContentIdentifierM borgrepo
 			, checkPresentExportWithContentIdentifier = checkPresentExportWithContentIdentifierM borgrepo
 			-- This remote is thirdPartyPopulated, so these
diff --git a/Remote/Directory.hs b/Remote/Directory.hs
--- a/Remote/Directory.hs
+++ b/Remote/Directory.hs
@@ -119,7 +119,8 @@
 			, importActions = importUnsupported
 			, exportImportActions = ExportImportActions
 				{ listImportableOrExportedContents = listImportableOrExportedContentsM ii dir
-				, importKey = Just (importKeyM ii dir)
+				, importKeyWithContentIdentifier = pure $ 
+					Just (importKeyWithContentIdentifierM ii dir)
 				, retrieveExportWithContentIdentifier = retrieveExportWithContentIdentifierM ii dir cow
 				, storeExportWithContentIdentifier = storeExportWithContentIdentifierM ii dir cow fastcopy
 				, removeExportWithContentIdentifier = removeExportWithContentIdentifierM ii dir
@@ -432,8 +433,8 @@
 				let ic' = replaceInode 0 ic
 				in ContentIdentifier (encodeBS (showInodeCache ic'))
 
-importKeyM :: IgnoreInodes -> OsPath -> ExportLocation -> ContentIdentifier -> ByteSize -> MeterUpdate -> Annex (Maybe Key)
-importKeyM ii dir loc cid _sz p = do
+importKeyWithContentIdentifierM :: IgnoreInodes -> OsPath -> ExportLocation -> ContentIdentifier -> ByteSize -> MeterUpdate -> Annex (Maybe Key)
+importKeyWithContentIdentifierM ii dir loc cid _sz p = do
 	backend <- chooseBackend f
 	k <- fst <$> genKey ks p backend
 	currcid <- liftIO $ mkContentIdentifier ii absf
diff --git a/Remote/External.hs b/Remote/External.hs
--- a/Remote/External.hs
+++ b/Remote/External.hs
@@ -129,6 +129,7 @@
 		let importactions = if importsupported
 			then ImportActions
 				{ listImportableContents = listImportableContentsM external
+				, importKey = importKeyM external
 				, retrieveImport = retrieveImportM external gc
 				, checkPresentImport = checkPresentImportM external gc
 				}
@@ -612,6 +613,32 @@
 	go _ _ UNSUPPORTED_REQUEST = result Nothing
 	go _ _ _ = Nothing
 
+importKeyM
+	:: External
+	-> Annex (Maybe (ImportLocation -> ContentIdentifier -> ByteSize -> MeterUpdate -> Annex (Maybe Key)))
+importKeyM external = 
+	withExternalState external $ \st ->
+		return $ if importKeyExtensionEnabled (externalExtensions st)
+			then Just go
+			else Nothing
+  where
+	go loc cid sz p =
+		handleRequestImport' external loc (IMPORTKEY sz cid) Nothing $ \case
+			IMPORTKEY_SUCCESS k ->
+				result (Just k)
+			IMPORTKEY_FAILURE err ->
+				giveup err
+			IMPORTKEY_SKIP ->
+				result Nothing
+			DELEGATE ps -> Just $ do
+				delegate <- getDelegateRemote external ps
+				importKey (importActions delegate) >>= \case
+					Just a -> Result <$> a loc cid sz p
+					Nothing -> giveup "IMPORTKEY delegated to a special remote that does not support it"
+			UNSUPPORTED_REQUEST ->
+				giveup "IMPORTKEY not implemented by external special remote, but it claimed to support it"
+			_ -> Nothing
+
 {- Sends a Request to the external remote, and waits for it to generate
  - a Response. That is fed into the responsehandler, which should return
  - the action to run for it (or Nothing if there's a protocol error).
@@ -990,25 +1017,25 @@
 	liftIO (atomically $ takeTMVar (externalAsync external)) >>= \case
 		UncheckedExternalAsync -> do
 			(st, extensions) <- startExternal' external
-				`onException` store UncheckedExternalAsync
+				`onException` storeasync UncheckedExternalAsync
 			if asyncExtensionEnabled extensions
 				then do
 					annexrunner <- Annex.makeRunner
 					relay <- liftIO $ runRelayToExternalAsync external st annexrunner
 					st' <- liftIO $ asyncRelayExternalState relay
-					store (ExternalAsync relay)
+					storeasync (ExternalAsync relay)
 					return st'
 				else do
-					store NoExternalAsync
+					storeasync NoExternalAsync
 					return st
 		v@NoExternalAsync -> do
-			store v
+			storeasync v
 			fst <$> startExternal' external
 		v@(ExternalAsync relay) -> do
-			store v
+			storeasync v
 			liftIO $ asyncRelayExternalState relay
   where
-	store = liftIO . atomically . putTMVar (externalAsync external)
+	storeasync = liftIO . atomically . putTMVar (externalAsync external)
 
 startExternal' :: External -> Annex (ExternalState, ExtensionList)
 startExternal' external = do
@@ -1040,9 +1067,10 @@
 				, externalPrepared = pv
 				, externalConfig = cv
 				, externalConfigChanges = ccv
+				, externalExtensions = ExtensionList []
 				}
 			extensions <- startproto st
-			return (st, extensions)
+			return (st { externalExtensions = extensions }, extensions)
   where
 	(externalcmd, externalparams) = case externalProgram external of
 		ExternalType t -> ("git-annex-remote-" ++ t, [])
diff --git a/Remote/External/AsyncExtension.hs b/Remote/External/AsyncExtension.hs
--- a/Remote/External/AsyncExtension.hs
+++ b/Remote/External/AsyncExtension.hs
@@ -53,6 +53,7 @@
 			, externalPrepared = externalPrepared st
 			, externalConfig = externalConfig st
 			, externalConfigChanges = externalConfigChanges st
+			, externalExtensions = externalExtensions st
 			}
 
 type ReceiveQueue = TBMChan String
diff --git a/Remote/External/Types.hs b/Remote/External/Types.hs
--- a/Remote/External/Types.hs
+++ b/Remote/External/Types.hs
@@ -18,6 +18,7 @@
 	ExtensionList(..),
 	supportedExtensionList,
 	asyncExtensionEnabled,
+	importKeyExtensionEnabled,
 	ExternalAsync(..),
 	ExternalAsyncRelay(..),
 	Proto.parseMessage,
@@ -109,6 +110,7 @@
 	, externalPrepared :: TMVar PrepareStatus
 	, externalConfig :: TMVar ParsedRemoteConfig
 	, externalConfigChanges :: TMVar (RemoteConfig -> RemoteConfig)
+	, externalExtensions :: ExtensionList
 	}
 
 type PID = Int
@@ -124,6 +126,7 @@
 	, "UNAVAILABLERESPONSE"
 	, "TRANSFER-RETRIEVE-URL"
 	, "CHECKPRESENT-URL"
+	, importKeyExtension
 	, "DELEGATE"
 	, asyncExtension
 	]
@@ -134,6 +137,12 @@
 asyncExtensionEnabled :: ExtensionList -> Bool
 asyncExtensionEnabled l = asyncExtension `elem` fromExtensionList l
 
+importKeyExtension :: String
+importKeyExtension = "IMPORTKEY"
+
+importKeyExtensionEnabled :: ExtensionList -> Bool
+importKeyExtensionEnabled l = importKeyExtension `elem` fromExtensionList l
+
 -- When the async extension is in use, a single external process
 -- is started and used for all requests.
 data ExternalAsync
@@ -198,6 +207,7 @@
 	| IMPORT ImportLocation
 	| RETRIEVEIMPORT FilePath
 	| CHECKPRESENTIMPORT SafeKey
+	| IMPORTKEY Size ContentIdentifier
 	deriving (Show)
 
 -- Does PREPARE need to have been sent before this request?
@@ -269,6 +279,11 @@
 		[ "CHECKPRESENTIMPORT"
 		, Proto.serialize key
 		]
+	formatMessage (IMPORTKEY size cid) = Proto.mkMessage
+		[ "IMPORTKEY"
+		, Proto.serialize size
+		, Proto.serialize cid
+		]
 
 -- Responses the external remote can make to requests.
 data Response
@@ -318,6 +333,9 @@
 	| RETRIEVEIMPORT_SUCCESS
 	| RETRIEVEIMPORT_FAILURE ErrorMsg
 	| RETRIEVEIMPORT_URL URLString
+	| IMPORTKEY_SUCCESS Key
+	| IMPORTKEY_FAILURE ErrorMsg
+	| IMPORTKEY_SKIP
 	| DELEGATE [String]
 	| UNSUPPORTED_REQUEST
 	deriving (Show)
@@ -369,6 +387,9 @@
 	parseCommand "RETRIEVEIMPORT-SUCCESS" = Proto.parse0 RETRIEVEIMPORT_SUCCESS
 	parseCommand "RETRIEVEIMPORT-FAILURE" = Proto.parse1 RETRIEVEIMPORT_FAILURE
 	parseCommand "RETRIEVEIMPORT-URL" = Proto.parse1 RETRIEVEIMPORT_URL
+	parseCommand "IMPORTKEY-SUCCESS" = Proto.parse1 IMPORTKEY_SUCCESS
+	parseCommand "IMPORTKEY-FAILURE" = Proto.parse1 IMPORTKEY_FAILURE
+	parseCommand "IMPORTKEY-SKIP" = Proto.parse0 IMPORTKEY_SKIP
 	parseCommand "DELEGATE" = Proto.parseList DELEGATE
 	parseCommand "UNSUPPORTED-REQUEST" = Proto.parse0 UNSUPPORTED_REQUEST
 	parseCommand _ = Proto.parseFail
diff --git a/Remote/Helper/ExportImport.hs b/Remote/Helper/ExportImport.hs
--- a/Remote/Helper/ExportImport.hs
+++ b/Remote/Helper/ExportImport.hs
@@ -58,6 +58,7 @@
 instance HasImportUnsupported (ImportActions Annex) where
 	importUnsupported = ImportActions
 		{ listImportableContents = nope
+		, importKey = nope
 		, retrieveImport = nope
 		, checkPresentImport = \_ _ -> return False
 		}
@@ -74,7 +75,7 @@
 instance HasExportImportUnsupported (ExportImportActions Annex) where
 	exportImportUnsupported = ExportImportActions
 		{ listImportableOrExportedContents = nope
-		, importKey = Nothing
+		, importKeyWithContentIdentifier = pure Nothing
 		, retrieveExportWithContentIdentifier = nope
 		, storeExportWithContentIdentifier = nope
 		, removeExportWithContentIdentifier = nope
@@ -332,6 +333,7 @@
 	-- more strongly.
 	importActionsForExportImport ciddbv = ImportActions
 		{ listImportableContents = listImportableOrExportedContents (exportImportActions r)
+		, importKey = importKeyWithContentIdentifier (exportImportActions r)
 		, retrieveImport = retrieveExportWithContentIdentifier (exportImportActions r)
 		, checkPresentImport = checkPresentExportImport ciddbv
 		}
diff --git a/Remote/Rsync.hs b/Remote/Rsync.hs
--- a/Remote/Rsync.hs
+++ b/Remote/Rsync.hs
@@ -114,6 +114,7 @@
 				}
 			, importActions = ImportActions
 				{ listImportableContents = listImportableContentsM o
+				, importKey = pure Nothing
 				, retrieveImport = retrieveImportM o
 				, checkPresentImport = checkPresentImportExportM o
 				}
diff --git a/Remote/S3.hs b/Remote/S3.hs
--- a/Remote/S3.hs
+++ b/Remote/S3.hs
@@ -244,7 +244,7 @@
 			, importActions = importUnsupported
 			, exportImportActions = ExportImportActions
                                 { listImportableOrExportedContents = listImportableOrExportedContentsS3 hdl this info c
-				, importKey = Nothing
+				, importKeyWithContentIdentifier = pure Nothing
                                 , retrieveExportWithContentIdentifier = retrieveExportWithContentIdentifierS3 hdl this rs info
                                 , storeExportWithContentIdentifier = storeExportWithContentIdentifierS3 hdl this rs info magic
                                 , removeExportWithContentIdentifier = removeExportWithContentIdentifierS3 hdl this rs info
diff --git a/Remote/WebDAV.hs b/Remote/WebDAV.hs
--- a/Remote/WebDAV.hs
+++ b/Remote/WebDAV.hs
@@ -417,12 +417,12 @@
 
 data DavHandle = DavHandle DAVContext DavUser DavPass URLString
 
-type DavHandleVar = TVar (Either (Annex (Either String DavHandle)) (Either String DavHandle))
+type DavHandleVar = TMVar (Either (Annex (Either String DavHandle)) (Either String DavHandle))
 
 {- Prepares a DavHandle for later use. Does not connect to the server or do
  - anything else expensive. -}
 mkDavHandleVar :: ParsedRemoteConfig -> RemoteGitConfig -> UUID -> Annex DavHandleVar
-mkDavHandleVar c gc u = liftIO $ newTVarIO $ Left $ do
+mkDavHandleVar c gc u = liftIO $ newTMVarIO $ Left $ do
 	mcreds <- getCreds c gc u
 	case (mcreds, configUrl c) of
 		(Just (user, pass), Just baseurl) -> do
@@ -431,12 +431,16 @@
 			return (Right h)
 		_ -> return $ Left "webdav credentials not available"
 
+{- Concurrent actions are allowed to run at the same time with the same
+ - DavHandle, so any use of eg setDepth will affect other actions. -}
 withDavHandle :: DavHandleVar -> (DavHandle -> Annex a) -> Annex a
-withDavHandle hv a = liftIO (readTVarIO hv) >>= \case
-	Right hdl -> either giveup a hdl
+withDavHandle hv a = liftIO (atomically (takeTMVar hv)) >>= \case
+	Right hdl -> do
+		liftIO $ atomically $ putTMVar hv (Right hdl)
+		either giveup a hdl
 	Left mkhdl -> do
 		hdl <- mkhdl
-		liftIO $ atomically $ writeTVar hv (Right hdl)
+		liftIO $ atomically $ putTMVar hv (Right hdl)
 		either giveup a hdl
 
 goDAV :: DavHandle -> DAVT IO a -> IO a
diff --git a/Test.hs b/Test.hs
--- a/Test.hs
+++ b/Test.hs
@@ -38,6 +38,7 @@
 import qualified Git.Ref
 import qualified Git.LsTree
 import qualified Git.FilePath
+import qualified Git.Config.Url
 #ifndef mingw32_HOST_OS
 import qualified Annex.Locations
 import qualified Git.Bundle
@@ -196,6 +197,7 @@
 	, testProperty "prop_viewedFile_rountrips" Annex.View.ViewedFile.prop_viewedFile_roundtrips
 	, testProperty "prop_standardGroups_parse" Logs.PreferredContent.prop_standardGroups_parse
 	, testProperty "prop_balanced_stable" Annex.Balanced.prop_balanced_stable
+	, testProperty "prop_httpConfigKeys_sane" Git.Config.Url.prop_httpConfigKeys_sane
 	] ++ map (uncurry testProperty) combos
   where
 	combos = concat
diff --git a/Types/FileMatcher.hs b/Types/FileMatcher.hs
--- a/Types/FileMatcher.hs
+++ b/Types/FileMatcher.hs
@@ -1,10 +1,12 @@
 {- git-annex file matcher types
  -
- - Copyright 2013-2023 Joey Hess <id@joeyh.name>
+ - Copyright 2013-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
 
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
 module Types.FileMatcher where
 
 import Types.UUID (UUID)
@@ -83,7 +85,8 @@
 
 type MkLimit a = String -> Either String (MatchFiles a)
 
-type AssumeNotPresent = S.Set UUID
+newtype AssumeNotPresent = AssumeNotPresent (S.Set UUID)
+	deriving (Semigroup, Monoid)
 
 data MatchFiles a = MatchFiles 
 	{ matchAction :: LiveUpdate -> AssumeNotPresent -> MatchInfo -> a Bool
diff --git a/Types/Group.hs b/Types/Group.hs
--- a/Types/Group.hs
+++ b/Types/Group.hs
@@ -1,6 +1,6 @@
 {- git-annex repo groups
  -
- - Copyright 2012-2024 Joey Hess <id@joeyh.name>
+ - Copyright 2012-2026 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -10,7 +10,8 @@
 	fromGroup,
 	toGroup,
 	GroupMap(..),
-	emptyGroupMap
+	emptyGroupMap,
+	groupUUIDs
 ) where
 
 import Types.UUID
@@ -19,9 +20,10 @@
 
 import qualified Data.Map as M
 import qualified Data.Set as S
-import qualified Data.ByteString as S
+import qualified Data.ByteString as BS
+import Data.Maybe
 
-newtype Group = Group S.ByteString
+newtype Group = Group BS.ByteString
 	deriving (Eq, Ord, Show, Read)
 
 fromGroup :: Group -> String
@@ -38,3 +40,6 @@
 
 emptyGroupMap :: GroupMap
 emptyGroupMap = GroupMap M.empty M.empty M.empty
+
+groupUUIDs :: Group -> GroupMap -> S.Set UUID
+groupUUIDs g = fromMaybe S.empty . M.lookup g . uuidsByGroup
diff --git a/Types/Remote.hs b/Types/Remote.hs
--- a/Types/Remote.hs
+++ b/Types/Remote.hs
@@ -387,8 +387,11 @@
 	-- file stored on the remote is the content of an annex object,
 	-- and return its Key, or Nothing if it is not.
 	--
-	-- Throws exception on failure to access the remote.
-	, importKey :: Maybe (ImportLocation -> ContentIdentifier -> ByteSize -> MeterUpdate -> a (Maybe Key))
+	-- The outer action should only check if the remote supports
+	-- this, and return the inner action if so.
+	-- The returned action throws exception on failure to access the
+	-- remote.
+	, importKeyWithContentIdentifier :: a (Maybe (ImportLocation -> ContentIdentifier -> ByteSize -> MeterUpdate -> a (Maybe Key)))
 	-- Retrieves a file from the remote. Ensures that the file
 	-- it retrieves has one of the requested ContentIdentifiers.
 	--
@@ -470,6 +473,13 @@
 	-- not need to uniquely identify content.
 	-- Eg, a mtime is sufficient.
 	{ listImportableContents :: a (Maybe (ImportableContentsChunkable a (ContentIdentifier, ByteSize)))
+	-- Like importKeyWithContentIdentifier, but does not need to
+	-- guarantee that the file on the remote still has the same
+	-- content that it did when listImportableContents returned
+	-- the ContentIdentifier. The ContentIdentifier is still
+	-- provided so that information in it can be used to generate the
+	-- key.
+	, importKey :: a (Maybe (ImportLocation -> ContentIdentifier -> ByteSize -> MeterUpdate -> a (Maybe Key)))
 	-- Like retrieveExportWithContentIdentifier, but does not
 	-- need to guarantee that the file it retrieves has one
 	-- of the requested ContentIdentifiers.
diff --git a/Utility/Url.hs b/Utility/Url.hs
--- a/Utility/Url.hs
+++ b/Utility/Url.hs
@@ -55,8 +55,9 @@
 import qualified Utility.FileIO as F
 
 import Network.URI
-import Network.HTTP.Types
-import Network.HTTP.Types.Header (hAcceptEncoding, hContentDisposition, hContentRange)
+import Network.HTTP.Types.Status
+import Network.HTTP.Types.Method
+import Network.HTTP.Types.Header
 import qualified System.FilePath.Posix as UrlPath
 import qualified Data.CaseInsensitive as CI
 import qualified Data.ByteString as B
diff --git a/cabal.project b/cabal.project
new file mode 100644
--- /dev/null
+++ b/cabal.project
@@ -0,0 +1,4 @@
+packages:
+  .
+jobs: $ncpus
+semaphore: True
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.20260717
+Version: 10.20260901
 Cabal-Version: 1.12
 License: AGPL-3
 Maintainer: Joey Hess <id@joeyh.name>
@@ -37,6 +37,7 @@
   stack.yaml
   stack-botan.yaml
   stack-NoLLMDependencies.yaml
+  cabal.project
   README
   CHANGELOG
   NEWS
@@ -160,11 +161,6 @@
 Flag Production
   Description: Enable production build (slower build; faster binary)
 
-Flag ParallelBuild
-  Description: Enable building in parallel
-  Default: False
-  Manual: True
-
 Flag Botan
   Description: Build with the Botan C++ library for faster hashing
   Default: False
@@ -311,7 +307,9 @@
     Build-Depends:
      base (>= 4.18.2.1 && < 4.23),
      ram (< 0.21.0),
-     persistent (>= 2.13.3) && (< 2.15.0.0)
+     persistent (>= 2.13.3) && (< 2.15.0.0),
+     warp (< 3.4.11),
+     magic (<= 1.1)
   else
     Build-Depends:
      base (>= 4.18.2.1 && < 5),
@@ -328,9 +326,6 @@
   else
     GHC-Options: -O0
 
-  if flag(ParallelBuild)
-    GHC-Options: -j
-
   -- Avoid linking with unused dynamic libraries.
   if os(linux) || os(freebsd)
     GHC-Options: -optl-Wl,--as-needed
@@ -539,7 +534,7 @@
     CPP-Options: -DWITH_TORRENTPARSER
 
   if flag(MagicMime)
-    Build-Depends: magic
+    Build-Depends: magic (<= 1.1)
     CPP-Options: -DWITH_MAGICMIME
 
   if flag(Benchmark)
@@ -844,6 +839,7 @@
     Git.Command
     Git.Command.Batch
     Git.Config
+    Git.Config.Url
     Git.ConfigTypes
     Git.Construct
     Git.Credential
diff --git a/stack-NoLLMDependencies.yaml b/stack-NoLLMDependencies.yaml
--- a/stack-NoLLMDependencies.yaml
+++ b/stack-NoLLMDependencies.yaml
@@ -2,7 +2,6 @@
   git-annex:
     NoLLMDependencies: true
     production: true
-    parallelbuild: true
     assistant: true
     torrentparser: true
     magicmime: false
@@ -19,12 +18,14 @@
     pkg-config: false
 packages:
 - '.'
-resolver: lts-24.26
+resolver: lts-24.52
 extra-deps:
 - aws-0.25.2
 - file-io-0.2.0
 - blake3-0.3
-- xxhash-ffi-0.3.1
 - ram-0.20.1
 - persistent-2.14.6.3
 - persistent-sqlite-2.13.3.0
+- yesod-form-1.7.9.2
+- yesod-static-1.6.1.2
+- magic-1.1
diff --git a/stack-botan.yaml b/stack-botan.yaml
--- a/stack-botan.yaml
+++ b/stack-botan.yaml
@@ -2,7 +2,6 @@
   git-annex:
     NoLLMDependencies: false
     production: true
-    parallelbuild: true
     assistant: true
     torrentparser: true
     magicmime: false
@@ -19,11 +18,11 @@
     pkg-config: false
 packages:
 - '.'
-resolver: lts-24.26
+resolver: lts-24.52
 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
+- magic-1.1
diff --git a/stack.yaml b/stack.yaml
--- a/stack.yaml
+++ b/stack.yaml
@@ -2,7 +2,6 @@
   git-annex:
     NoLLMDependencies: false
     production: true
-    parallelbuild: true
     assistant: true
     torrentparser: true
     magicmime: false
@@ -19,9 +18,9 @@
     pkg-config: false
 packages:
 - '.'
-resolver: lts-24.26
+resolver: lts-24.52
 extra-deps:
 - aws-0.25.2
 - file-io-0.2.0
 - blake3-0.3
-- xxhash-ffi-0.3.1
+- magic-1.1
