diff --git a/Annex.hs b/Annex.hs
--- a/Annex.hs
+++ b/Annex.hs
@@ -197,6 +197,7 @@
 	, checkignorehandle :: Maybe (ResourcePool CheckIgnoreHandle)
 	, globalnumcopies :: Maybe (Maybe NumCopies)
 	, globalmincopies :: Maybe (Maybe MinCopies)
+	, nummincopiesattrcache :: Maybe (OsPath, (Maybe NumCopies, Maybe MinCopies))
 	, limit :: ExpandableMatcher Annex
 	, timelimit :: Maybe (Duration, POSIXTime)
 	, sizelimit :: Maybe (TVar Integer)
@@ -253,6 +254,7 @@
 		, checkignorehandle = Nothing
 		, globalnumcopies = Nothing
 		, globalmincopies = Nothing
+		, nummincopiesattrcache = Nothing
 		, limit = BuildingMatcher []
 		, timelimit = Nothing
 		, sizelimit = Nothing
diff --git a/Annex/NumCopies.hs b/Annex/NumCopies.hs
--- a/Annex/NumCopies.hs
+++ b/Annex/NumCopies.hs
@@ -1,6 +1,6 @@
 {- git-annex numcopies configuration and checking
  -
- - Copyright 2014-2024 Joey Hess <id@joeyh.name>
+ - Copyright 2014-2025 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -181,12 +181,18 @@
 	]
 
 getNumMinCopiesAttr :: OsPath  -> Annex (Maybe NumCopies, Maybe MinCopies)
-getNumMinCopiesAttr file =
-	checkAttrs ["annex.numcopies", "annex.mincopies"] file >>= \case
-		(n:m:[]) -> return
-			( configuredNumCopies <$> readish n
-			, configuredMinCopies <$> readish m
-			)
+getNumMinCopiesAttr file = Annex.getState Annex.nummincopiesattrcache >>= \case
+	Just (cfile, v) | cfile == file -> return v
+	_ -> checkAttrs ["annex.numcopies", "annex.mincopies"] file >>= \case
+		(n:m:[]) -> do
+			let v =
+				( configuredNumCopies <$> readish n
+				, configuredMinCopies <$> readish m
+				)
+			Annex.changeState $ \s -> s 
+				{ Annex.nummincopiesattrcache = Just (file, v)
+				}
+			return v
 		_ -> error "internal"
 
 {- Checks if numcopies are satisfied for a file by running a comparison
diff --git a/CHANGELOG b/CHANGELOG
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,19 @@
+git-annex (10.20250520) upstream; urgency=medium
+
+  * Preferred content now supports "balanced=groupname:lackingcopies"
+    to make files be evenly balanced amoung as many repositories as are
+    needed to satisfy numcopies.
+  * map: Fix buggy handling of remotes that are bare git repositories
+    accessed via ssh.
+  * map: Avoid looping forever with mutually recursive paths between
+    repositories accessed via ssh.
+  * whereused: Fix bug that could find matches from grafts 
+    in remote git-annex branches.
+  * Windows: Fix bug that can cause git status to show annexed files as
+    modified when built with OsPath.
+
+ -- Joey Hess <id@joeyh.name>  Tue, 20 May 2025 10:25:47 -0400
+
 git-annex (10.20250416) upstream; urgency=medium
 
   * Added the mask special remote.
diff --git a/Command/Map.hs b/Command/Map.hs
--- a/Command/Map.hs
+++ b/Command/Map.hs
@@ -1,6 +1,6 @@
 {- git-annex command
  -
- - Copyright 2010 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2025 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -26,9 +26,6 @@
 import qualified Remote.Helper.Ssh as Ssh
 import qualified Utility.Dot as Dot
 
--- a link from the first repository to the second (its remote)
-data Link = Link Git.Repo Git.Repo
-
 -- a repo and its remotes
 type RepoRemotes = (Git.Repo, [Git.Repo])
 
@@ -68,7 +65,7 @@
 	return True
 runViewer file ((c, ps):rest) = ifM (liftIO $ inSearchPath c)
 	( do
-		showLongNote $ UnquotedString $ "running: " ++ c ++ unwords (toCommand ps)
+		showLongNote $ UnquotedString $ "running: " ++ c ++ " " ++ unwords (toCommand ps)
 		showOutput
 		liftIO $ boolSystem c ps
 	, runViewer file rest
@@ -157,7 +154,7 @@
 	Just DeadTrusted -> Dot.fillColor "grey" s
 	Nothing -> Dot.fillColor "white" s
 
-{- Recursively searches out remotes starting with the specified repo. -}
+{- Recursively searches out remotes starting with the specified local repo. -}
 spider :: Git.Repo -> Annex [RepoRemotes]
 spider r = spider' [r] []
 spider' :: [Git.Repo] -> [RepoRemotes] -> Annex [RepoRemotes]
@@ -169,15 +166,18 @@
 
 		-- The remotes will be relative to r', and need to be
 		-- made absolute for later use.
-		remotes <- mapM (absRepo r')
-			=<< (liftIO $ Git.Construct.fromRemotes r')
-	
+		remotes <- mapM (absRepo r') =<<
+			if Git.repoIsUrl r
+				then liftIO $ Git.Construct.fromRemoteUrlRemotes r'
+				else liftIO $ Git.Construct.fromRemotes r'
+
 		spider' (rs ++ remotes) ((r', remotes):known)
 
 {- Converts repos to a common absolute form. -}
 absRepo :: Git.Repo -> Git.Repo -> Annex Git.Repo
 absRepo reference r
-	| Git.repoIsUrl reference = return $ Git.Construct.localToUrl reference r
+	| Git.repoIsUrl reference = return $
+		Git.Construct.localToUrl reference r
 	| Git.repoIsUrl r = return r
 	| otherwise = liftIO $ do
 		r' <- Git.Construct.fromPath =<< absPath (Git.repoPath r)
diff --git a/Command/Smudge.hs b/Command/Smudge.hs
--- a/Command/Smudge.hs
+++ b/Command/Smudge.hs
@@ -112,7 +112,6 @@
 		discardreststdin
 		emitpointer
 	stop
-  where
 
 -- Handles everything except the IO of the file content.
 clean'
@@ -135,7 +134,6 @@
 		, inSmudgeCleanFilter go
 		)
   where
-
 	go = case mk of
 		Right (Just k) -> do
 			addingExistingLink file k $ do
diff --git a/Command/WhereUsed.hs b/Command/WhereUsed.hs
--- a/Command/WhereUsed.hs
+++ b/Command/WhereUsed.hs
@@ -89,7 +89,7 @@
 		[ Param ("--exclude=*/" ++ fromRef (Annex.Branch.name))
 		, Param "--glob=*"
 		-- Also search remote branches
-		, Param ("--exclude=" ++ fromRef (Annex.Branch.name))
+		, Param ("--exclude=*/" ++ fromRef (Annex.Branch.name))
 		, Param "--remotes=*"
 		-- And search tags.
 		, Param "--tags=*"
diff --git a/Git.hs b/Git.hs
--- a/Git.hs
+++ b/Git.hs
@@ -89,8 +89,6 @@
 localGitDir Repo { location = Local { gitdir = d } } = d
 localGitDir _ = giveup "unknown localGitDir"
 
-{- Some code needs to vary between URL and normal repos,
- - or bare and non-bare, these functions help with that. -}
 repoIsUrl :: Repo -> Bool
 repoIsUrl Repo { location = Url _ } = True
 repoIsUrl Repo { location = UnparseableUrl _ } = True
diff --git a/Git/Construct.hs b/Git/Construct.hs
--- a/Git/Construct.hs
+++ b/Git/Construct.hs
@@ -1,6 +1,6 @@
 {- Construction of Git Repo objects
  -
- - Copyright 2010-2023 Joey Hess <id@joeyh.name>
+ - Copyright 2010-2025 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -18,6 +18,7 @@
 	remoteNamed,
 	remoteNamedFromKey,
 	fromRemotes,
+	fromRemoteUrlRemotes,
 	fromRemoteLocation,
 	repoAbsPath,
 	checkForRepo,
@@ -97,14 +98,15 @@
  - or is invalid, because git can also function despite remotes having
  - such urls, only failing if such a remote is used.
  -}
-fromUrl :: String -> IO Repo
-fromUrl url
-	| not (isURI url) = fromUrl' $ escapeURIString isUnescapedInURI url
-	| otherwise = fromUrl' url
+fromUrl :: Bool -> String -> IO Repo
+fromUrl fileurlislocal url
+	| not (isURI url) = fromUrl' fileurlislocal $
+		escapeURIString isUnescapedInURI url
+	| otherwise = fromUrl' fileurlislocal url
 
-fromUrl' :: String -> IO Repo
-fromUrl' url
-	| "file://" `isPrefixOf` url = case parseURIPortable url of
+fromUrl' :: Bool -> String -> IO Repo
+fromUrl' fileurlislocal url
+	| "file://" `isPrefixOf` url && fileurlislocal = case parseURIPortable url of
 		Just u -> fromAbsPath $ toOsPath $ unEscapeString $ uriPath u
 		Nothing -> pure $ newFrom $ UnparseableUrl url
 	| otherwise = case parseURIPortable url of
@@ -123,25 +125,42 @@
 	| repoIsUrl r = r
 	| otherwise = case (Url.authority reference, Url.scheme reference) of
 		(Just auth, Just s) -> 
-			let absurl = concat
+			let referencepath = fromMaybe "" $ Url.path reference
+			    absurl = concat
 				[ s
 				, "//"
 				, auth
-				, fromOsPath (repoPath r)
+				, fromOsPath $ simplifyPath $
+					toOsPath referencepath </> repoPath r
 				]
 			in r { location = Url $ fromJust $ parseURIPortable absurl }
 		_ -> r
 
 {- Calculates a list of a repo's configured remotes, by parsing its config. -}
 fromRemotes :: Repo -> IO [Repo]
-fromRemotes repo = catMaybes <$> mapM construct remotepairs
+fromRemotes = fromRemotes' fromRemoteLocation
+
+fromRemotes' :: (String -> Bool -> Repo -> IO Repo) -> Repo -> IO [Repo]
+fromRemotes' fromremotelocation repo = catMaybes <$> mapM construct remotepairs
   where
 	filterconfig f = filter f $ M.toList $ config repo
 	filterkeys f = filterconfig (\(k,_) -> f k)
 	remotepairs = filterkeys isRemoteUrlKey
 	construct (k,v) = remoteNamedFromKey k $
-		fromRemoteLocation (fromConfigValue v) False repo
+		fromremotelocation (fromConfigValue v) False repo
 
+{- Calculates a list of a remote repo's configured remotes, by parsing its
+ - config. Unlike fromRemotes, this does not do any local path checking. 
+ - The remote repo must have an url path. -}
+fromRemoteUrlRemotes :: Repo -> IO [Repo]
+fromRemoteUrlRemotes = fromRemotes' go
+  where
+	go s knownurl repo = 
+		case parseRemoteLocation s knownurl repo of
+			RemotePath p -> pure $ localToUrl repo $ 
+				newFrom $ LocalUnknown $ toOsPath p
+			RemoteUrl u -> fromUrl False u
+
 {- Sets the name of a remote when constructing the Repo to represent it. -}
 remoteNamed :: String -> IO Repo -> IO Repo
 remoteNamed n constructor = do
@@ -167,7 +186,7 @@
 fromRemoteLocation s knownurl repo = gen $ parseRemoteLocation s knownurl repo
   where
 	gen (RemotePath p) = fromRemotePath p repo
-	gen (RemoteUrl u) = fromUrl u
+	gen (RemoteUrl u) = fromUrl True u
 
 {- Constructs a Repo from the path specified in the git remotes of
  - another Repo. -}
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-2024 Joey Hess <id@joeyh.name>
+ - Copyright 2011-2025 Joey Hess <id@joeyh.name>
  -
  - Licensed under the GNU AGPL version 3 or higher.
  -}
@@ -459,19 +459,26 @@
 		}
 	Nothing -> Left "bad value for number of lacking copies"
   where
-	go mi needed notpresent key = do
-		numcopies <- if approx
-			then approxNumCopies
-			else case mi of
-				MatchingFile fi -> getGlobalFileNumCopies $
-					matchFile fi
-				MatchingInfo {} -> approxNumCopies
-				MatchingUserInfo {} -> approxNumCopies
-		us <- filter (`S.notMember` notpresent)
-			<$> (trustExclude UnTrusted =<< Remote.keyLocations key)
-		let vs nhave numcopies' = numcopies' - nhave >= needed
-		return $ numCopiesCheck'' us vs numcopies
+	go mi needed notpresent key =
+		limitCheckNumCopies approx mi notpresent key vs
+	  where
+		vs nhave numcopies' = numcopies' - nhave >= needed
+
+limitCheckNumCopies :: Bool -> MatchInfo -> AssumeNotPresent -> Key -> (Int -> Int -> v) -> Annex v
+limitCheckNumCopies approx mi notpresent key vs = do
+	numcopies <- if approx
+		then approxNumCopies
+		else case mi of
+			MatchingFile fi -> getGlobalFileNumCopies $
+				matchFile fi
+			MatchingInfo {} -> approxNumCopies
+			MatchingUserInfo {} -> approxNumCopies
+	us <- filter (`S.notMember` notpresent)
+		<$> (trustExclude UnTrusted =<< Remote.keyLocations key)
+	return $ numCopiesCheck'' us vs numcopies
+  where
 	approxNumCopies = fromMaybe defaultNumCopies <$> getGlobalNumCopies
+	
 
 {- Match keys that are unused.
  - 
@@ -597,17 +604,21 @@
 
 limitBalanced' :: String -> MatchFiles Annex -> Maybe UUID -> MkLimit Annex
 limitBalanced' termname fullybalanced mu groupname = do
-	copies <- limitCopies $ if ':' `elem` groupname
-		then groupname
-		else groupname ++ ":1"
+	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
 	let present = limitPresent mu
-	let combo f = f present || f fullybalanced || f copies
+	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 <||>
-					((not <$> matchAction copies lu a i)
+					((checkenoughcopies <$> matchAction enoughcopies lu a i)
 						<&&> matchAction fullybalanced lu a i
 					)
 				)
@@ -667,11 +678,16 @@
 	-> MkLimit Annex
 limitFullyBalanced'' filtercandidates termname mu getgroupmap want =
 	case splitc ':' want of
-		[g] -> go g 1
-		[g, n] -> maybe
-			(Left $ "bad number for " ++ termname)
-			(go g)
-			(readish n)
+		[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
+			| otherwise -> maybe
+				(Left $ "bad number for " ++ termname)
+				(go g . Right)
+				(readish n)
 		_ -> Left $ "bad value for " ++ termname
   where
 	go s n = limitFullyBalanced''' filtercandidates termname mu
@@ -683,13 +699,16 @@
 	-> Maybe UUID
 	-> Annex GroupMap
 	-> Group
-	-> Int
+	-> Either (MatchInfo -> AssumeNotPresent -> Key -> Annex Int) Int
 	-> MkLimit Annex
-limitFullyBalanced''' filtercandidates termname mu getgroupmap g n want = Right $ MatchFiles
-	{ matchAction = \lu -> const $ checkKey $ \key -> do
+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)
+		n <- case getn of
+			Right n -> pure n
+			Left a -> a mi notpresent key
 		candidates <- filtercandidates n key groupmembers
 		let wanted = if S.null candidates
 			then False
diff --git a/Logs/File.hs b/Logs/File.hs
--- a/Logs/File.hs
+++ b/Logs/File.hs
@@ -145,6 +145,7 @@
 	cleanup (Just h) = liftIO $ hClose h
 	go Nothing = finalizer
 	go (Just h) = do
+		liftIO $ fileEncoding h
 		mapM_ processor =<< liftIO (lines <$> hGetContents h)
 		liftIO $ hClose h
 		finalizer
diff --git a/Remote/Bup.hs b/Remote/Bup.hs
--- a/Remote/Bup.hs
+++ b/Remote/Bup.hs
@@ -304,7 +304,7 @@
 		if "/" `isPrefixOf` r
 			then Git.Construct.fromPath (toOsPath r)
 			else giveup "please specify an absolute path"
-	| otherwise = Git.Construct.fromUrl $ "ssh://" ++ host ++ slash dir
+	| otherwise = Git.Construct.fromUrl False $ "ssh://" ++ host ++ slash dir
   where
 	bits = splitc ':' r
 	host = fromMaybe "" $ headMaybe bits
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.20250416
+Version: 10.20250520
 Cabal-Version: 1.12
 License: AGPL-3
 Maintainer: Joey Hess <id@joeyh.name>
