Optimize GitRepositoryMonitor to reduce redundant checks

- Implement repository deduplication in SessionMonitor to check each unique directory only once
- Add smart parent directory detection for common development paths (Projects, Development, etc.)
- Extend cache duration for common parent directories from 30s to 5 minutes
- Add negative caching for non-Git directories (10 minute cache)
- Group sessions by repository before Git checks to avoid duplicate lookups

This reduces Git repository checks from ~100/minute to significantly fewer by:
1. Deduplicating checks across multiple sessions in the same repository
2. Caching parent directory results longer
3. Remembering non-Git paths to avoid repeated filesystem traversals
This commit is contained in:
Peter Steinberger 2025-07-27 14:00:22 +02:00
parent 7674ef07d3
commit 3f86ed365c
7 changed files with 169 additions and 43 deletions

View file

@ -179,19 +179,23 @@ struct FollowModeStatus: Codable {
///
/// ```swift
/// let request = CreateWorktreeRequest(
/// repoPath: "/path/to/repo",
/// branch: "feature/new-feature",
/// createBranch: true,
/// path: "/path/to/worktree",
/// baseBranch: "main"
/// )
/// ```
struct CreateWorktreeRequest: Codable {
/// The repository path where the worktree will be created.
let repoPath: String
/// The branch name for the new worktree.
let branch: String
/// The file system path where the worktree will be created.
let path: String
/// Whether to create the branch if it doesn't exist.
let createBranch: Bool
/// The base branch to create from when `createBranch` is true.
/// The base branch to create from when creating a new branch.
///
/// If nil, uses the repository's default branch.
let baseBranch: String?
@ -202,11 +206,11 @@ struct CreateWorktreeRequest: Codable {
/// This allows changing the checked-out branch without creating
/// a new worktree, useful for quick context switches.
struct SwitchBranchRequest: Codable {
/// The repository path where the branch switch will occur.
let repoPath: String
/// The branch to switch to.
let branch: String
/// Whether to create the branch if it doesn't exist.
let createBranch: Bool
}
/// Request payload for toggling follow mode.
@ -215,19 +219,22 @@ struct SwitchBranchRequest: Codable {
///
/// ```swift
/// // Enable follow mode
/// let enableRequest = FollowModeRequest(enabled: true, targetBranch: "develop")
/// let enableRequest = FollowModeRequest(repoPath: "/path/to/repo", branch: "develop", enable: true)
///
/// // Disable follow mode
/// let disableRequest = FollowModeRequest(enabled: false, targetBranch: nil)
/// let disableRequest = FollowModeRequest(repoPath: "/path/to/repo", branch: nil, enable: false)
/// ```
struct FollowModeRequest: Codable {
/// Whether to enable or disable follow mode.
let enabled: Bool
/// The repository path where follow mode will be configured.
let repoPath: String
/// The branch to follow when enabling.
///
/// Required when `enabled` is true, ignored otherwise.
let targetBranch: String?
/// Required when `enable` is true, ignored otherwise.
let branch: String?
/// Whether to enable or disable follow mode.
let enable: Bool
}
/// Represents a Git branch in the repository.

View file

@ -224,7 +224,7 @@ final class ConfigManager {
private func useDefaults() {
self.quickStartCommands = defaultCommands
self.repositoryBasePath = FilePathConstants.defaultRepositoryBasePath
// Set notification defaults to match TypeScript defaults
self.notificationsEnabled = true
self.notificationSessionStart = true
@ -235,7 +235,7 @@ final class ConfigManager {
self.notificationClaudeTurn = false
self.notificationSoundEnabled = true
self.notificationVibrationEnabled = true
saveConfiguration()
}

View file

@ -218,17 +218,29 @@ public final class GitRepositoryMonitor {
return nil
}
// Check if this path was recently confirmed as non-Git
if let nonGitCheck = nonGitPathCache[filePath],
Date().timeIntervalSince(nonGitCheck) < 600.0
{ // 10 minutes for non-Git paths
logger.debug("⏭️ Skipping known non-Git path: \(filePath)")
return nil
}
// Check cache first
if let cached = getCachedRepository(for: filePath) {
logger.debug("📦 Found cached repository for: \(filePath)")
// Check if this was recently checked (within 30 seconds)
// Use longer cache duration for common parent directories
let cacheThreshold = isCommonParentDirectory(filePath) ? 300.0 :
recentCheckThreshold // 5 minutes vs 30 seconds
// Check if this was recently checked
if let lastCheck = recentRepositoryChecks[filePath],
Date().timeIntervalSince(lastCheck) < recentCheckThreshold
Date().timeIntervalSince(lastCheck) < cacheThreshold
{
logger
.debug(
"⏭️ Skipping redundant check for: \(filePath) (checked \(Int(Date().timeIntervalSince(lastCheck)))s ago)"
"⏭️ Skipping redundant check for: \(filePath) (checked \(Int(Date().timeIntervalSince(lastCheck)))s ago, threshold: \(Int(cacheThreshold))s)"
)
return cached
}
@ -248,8 +260,14 @@ public final class GitRepositoryMonitor {
guard let repoPath = await self.findGitRoot(from: filePath) else {
logger.info("❌ No Git root found for: \(filePath)")
// Mark as recently checked even for non-git paths to avoid repeated checks
// Use longer cache for common parent directories that aren't Git repos
await MainActor.run {
self.recentRepositoryChecks[filePath] = Date()
// Cache the negative result for parent directories longer
if self.isCommonParentDirectory(filePath) {
// Store as a "non-git" marker for 10 minutes
self.nonGitPathCache[filePath] = Date()
}
}
return nil
}
@ -305,6 +323,7 @@ public final class GitRepositoryMonitor {
githubURLFetchesInProgress.removeAll()
pendingRepositoryRequests.removeAll()
recentRepositoryChecks.removeAll()
nonGitPathCache.removeAll()
}
/// Start monitoring and refreshing all cached repositories
@ -346,7 +365,17 @@ public final class GitRepositoryMonitor {
recentRepositoryChecks = recentRepositoryChecks.filter { _, checkDate in
checkDate > cutoffDate
}
logger.debug("🧹 Cleaned up recent checks cache, \(self.recentRepositoryChecks.count) entries remaining")
// Also cleanup non-Git path cache (remove entries older than 10 minutes)
let nonGitCutoff = Date().addingTimeInterval(-600.0)
nonGitPathCache = nonGitPathCache.filter { _, checkDate in
checkDate > nonGitCutoff
}
logger
.debug(
"🧹 Cleaned up caches: \(self.recentRepositoryChecks.count) recent checks, \(self.nonGitPathCache.count) non-Git paths"
)
}
// MARK: - Private Properties
@ -372,11 +401,39 @@ public final class GitRepositoryMonitor {
/// Tracks recent repository checks with timestamps to skip redundant checks
private var recentRepositoryChecks: [String: Date] = [:]
/// Cache for paths that are confirmed NOT to be Git repositories (to avoid repeated filesystem checks)
private var nonGitPathCache: [String: Date] = [:]
/// Duration to consider a repository check as "recent" (30 seconds)
private let recentCheckThreshold: TimeInterval = 30.0
// MARK: - Private Methods
/// Check if a path is a common parent directory that should have longer cache duration
private func isCommonParentDirectory(_ path: String) -> Bool {
let pathComponents = path.split(separator: "/").map(String.init)
// Check if this is a common development parent directory
let commonDevPaths = ["Projects", "Development", "Developer", "Code", "Work", "Source", "Workspace", "Repos"]
// If the path ends with one of these common directory names, it's likely a parent
if let lastComponent = pathComponents.last,
commonDevPaths.contains(lastComponent)
{
return true
}
// Also check for patterns like /Users/username/Projects
if pathComponents.count == 3,
pathComponents[0] == "Users",
commonDevPaths.contains(pathComponents[2])
{
return true
}
return false
}
private func cacheRepository(_ repository: GitRepository, originalFilePath: String? = nil) {
repositoryCache[repository.path] = repository

View file

@ -227,7 +227,7 @@ final class NotificationService: NSObject {
vibrationEnabled: prefs.vibrationEnabled
)
}
/// Get notification sound based on user preferences
private func getNotificationSound(critical: Bool = false) -> UNNotificationSound? {
guard preferences.soundEnabled else { return nil }

View file

@ -195,14 +195,9 @@ final class SessionMonitor {
// Update WindowTracker
WindowTracker.shared.updateFromSessions(sessionsArray)
// Pre-cache Git data for all sessions
// Pre-cache Git data for all sessions (deduplicated by repository)
if let gitMonitor = gitRepositoryMonitor {
for session in sessionsArray where gitMonitor.getCachedRepository(for: session.workingDir) == nil {
Task {
// This will cache the data for immediate access later
_ = await gitMonitor.findRepository(for: session.workingDir)
}
}
await preCacheGitRepositories(for: sessionsArray, using: gitMonitor)
}
} catch {
// Only update error if it's not a simple connection error
@ -215,6 +210,60 @@ final class SessionMonitor {
}
}
/// Pre-cache Git repositories for sessions, deduplicating by repository root
private func preCacheGitRepositories(for sessions: [ServerSessionInfo], using gitMonitor: GitRepositoryMonitor) async {
// Track unique directories we need to check
var uniqueDirectoriesToCheck = Set<String>()
// First, collect all unique directories that don't have cached data
for session in sessions {
// Skip if we already have cached data for this exact path
if gitMonitor.getCachedRepository(for: session.workingDir) != nil {
continue
}
// Add this directory to check
uniqueDirectoriesToCheck.insert(session.workingDir)
// Smart detection: Also check common parent directories
// This helps when multiple sessions are in subdirectories of the same project
let pathComponents = session.workingDir.split(separator: "/").map(String.init)
// Check if this looks like a project directory pattern
// Common patterns: /Users/*/Projects/*, /Users/*/Development/*, etc.
if pathComponents.count >= 4 {
// Check if we're in a common development directory
let commonDevPaths = ["Projects", "Development", "Developer", "Code", "Work", "Source"]
for (index, component) in pathComponents.enumerated() {
if commonDevPaths.contains(component) && index < pathComponents.count - 1 {
// This might be a parent project directory
// Add the immediate child of the development directory
let potentialProjectPath = "/" + pathComponents[0...index + 1].joined(separator: "/")
// Only add if we don't have cached data for it
if gitMonitor.getCachedRepository(for: potentialProjectPath) == nil {
uniqueDirectoriesToCheck.insert(potentialProjectPath)
}
}
}
}
}
// Now check each unique directory only once
for directory in uniqueDirectoriesToCheck {
Task {
// This will cache the data for immediate access later
_ = await gitMonitor.findRepository(for: directory)
}
}
logger
.debug(
"Pre-caching Git data for \(uniqueDirectoriesToCheck.count) unique directories (from \(sessions.count) sessions)"
)
}
/// Start periodic refresh of sessions
private func startPeriodicRefresh() {
// Clean up any existing timer

View file

@ -30,7 +30,7 @@ final class WorktreeService {
let worktreeResponse = try await serverManager.performRequest(
endpoint: "/api/worktrees",
method: "GET",
queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)],
queryItems: [URLQueryItem(name: "repoPath", value: gitRepoPath)],
responseType: WorktreeListResponse.self
)
self.worktrees = worktreeResponse.worktrees
@ -48,17 +48,21 @@ final class WorktreeService {
func createWorktree(
gitRepoPath: String,
branch: String,
createBranch: Bool,
worktreePath: String,
baseBranch: String? = nil
)
async throws
{
let request = CreateWorktreeRequest(branch: branch, createBranch: createBranch, baseBranch: baseBranch)
let request = CreateWorktreeRequest(
repoPath: gitRepoPath,
branch: branch,
path: worktreePath,
baseBranch: baseBranch
)
try await serverManager.performVoidRequest(
endpoint: "/api/worktrees",
method: "POST",
body: request,
queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)]
body: request
)
// Refresh the worktree list
@ -71,7 +75,7 @@ final class WorktreeService {
endpoint: "/api/worktrees/\(branch)",
method: "DELETE",
queryItems: [
URLQueryItem(name: "gitRepoPath", value: gitRepoPath),
URLQueryItem(name: "repoPath", value: gitRepoPath),
URLQueryItem(name: "force", value: String(force))
]
)
@ -81,13 +85,12 @@ final class WorktreeService {
}
/// Switch to a different branch
func switchBranch(gitRepoPath: String, branch: String, createBranch: Bool = false) async throws {
let request = SwitchBranchRequest(branch: branch, createBranch: createBranch)
func switchBranch(gitRepoPath: String, branch: String) async throws {
let request = SwitchBranchRequest(repoPath: gitRepoPath, branch: branch)
try await serverManager.performVoidRequest(
endpoint: "/api/worktrees/switch",
method: "POST",
body: request,
queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)]
body: request
)
// Refresh the worktree list
@ -96,12 +99,11 @@ final class WorktreeService {
/// Toggle follow mode
func toggleFollowMode(gitRepoPath: String, enabled: Bool, targetBranch: String? = nil) async throws {
let request = FollowModeRequest(enabled: enabled, targetBranch: targetBranch)
let request = FollowModeRequest(repoPath: gitRepoPath, branch: targetBranch, enable: enabled)
try await serverManager.performVoidRequest(
endpoint: "/api/worktrees/follow",
method: "POST",
body: request,
queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)]
body: request
)
// Refresh the worktree list

View file

@ -209,11 +209,22 @@ struct NewSessionForm: View {
}
},
onCreateWorktree: { branchName, baseBranch in
// Generate worktree path by slugifying branch name
let slugifiedBranch = branchName
.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: " ", with: "-")
.lowercased()
// Create worktree path in a 'worktrees' subdirectory
let repoURL = URL(fileURLWithPath: repoPath)
let worktreesDir = repoURL.appendingPathComponent("worktrees")
let worktreePath = worktreesDir.appendingPathComponent(slugifiedBranch).path
// Create the worktree
try await service.createWorktree(
gitRepoPath: repoPath,
branch: branchName,
createBranch: true,
worktreePath: worktreePath,
baseBranch: baseBranch
)