diff --git a/Cargo.lock b/Cargo.lock
index fec4afca..ad5b8037 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1448,6 +1448,15 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "funty"
version = "2.0.0"
@@ -2354,6 +2363,26 @@ dependencies = [
"cfb",
]
+[[package]]
+name = "inotify"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
+dependencies = [
+ "bitflags 2.11.0",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "ipc"
version = "0.1.0"
@@ -2361,10 +2390,12 @@ dependencies = [
"git",
"log",
"logger",
+ "notify",
"serde",
"serde_json",
"tauri",
"tauri-plugin-store",
+ "tempfile",
"tokio",
"uuid",
]
@@ -2567,6 +2598,26 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "kqueue"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags 2.11.0",
+ "libc",
+]
+
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -2823,6 +2874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
+ "log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@@ -2884,6 +2936,33 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags 2.11.0",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags 2.11.0",
+]
+
[[package]]
name = "num-conv"
version = "0.2.0"
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index 9f37cdc4..07893b2f 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -2,6 +2,7 @@ use git::AppState;
use ipc::{
self,
repo_manager::{RepoManager, STORE_FILE},
+ repository_watcher::RepoContextRuntime,
session_manager::SessionManager,
};
use log::LevelFilter;
@@ -37,6 +38,7 @@ pub fn run() {
.manage(AppState {
services: RwLock::new(HashMap::new()),
})
+ .manage(RepoContextRuntime::default())
.manage(Arc::new(SessionManager::new()));
#[cfg(target_os = "macos")]
@@ -63,6 +65,8 @@ pub fn run() {
ipc::commands::init_repository,
ipc::commands::create_repo_context,
ipc::commands::dispose_repo_context,
+ ipc::commands::dispose_repo_context_owner,
+ ipc::commands::invalidate_repo_context_caches,
ipc::commands::open_with_app,
ipc::repo_manager::list_repositories,
ipc::repo_manager::add_repository,
diff --git a/apps/desktop/src/bootstrap/app-root.tsx b/apps/desktop/src/bootstrap/app-root.tsx
index 75a1cdaa..d9976435 100644
--- a/apps/desktop/src/bootstrap/app-root.tsx
+++ b/apps/desktop/src/bootstrap/app-root.tsx
@@ -30,6 +30,7 @@ import {
isEmbeddedRuntime,
isGitRoutePath,
normalizeWorkspaceRoutePath,
+ resolveRepositoryContextScope,
SNAPSHOT_EMIT_DEBOUNCE_MS,
sanitizeTabWebviewLabel,
stripEmbeddedQueryFromRoutePath,
@@ -496,10 +497,16 @@ const AppRouter = () => {
};
}, [syncTabMetadata]);
- const tabScopeId =
- embeddedRuntime && embeddedTabId
- ? embeddedTabId
- : (activeRuntimeId ?? activeTabId ?? "tab-main");
+ const tabScopeId = resolveRepositoryContextScope(
+ embeddedRuntime,
+ embeddedTabId,
+ activeRuntimeId,
+ activeTabId,
+ );
+
+ if (!tabScopeId) {
+ return ;
+ }
return (
diff --git a/apps/desktop/src/bootstrap/query-bridge.ts b/apps/desktop/src/bootstrap/query-bridge.ts
index aa0b26ad..9f691bdd 100644
--- a/apps/desktop/src/bootstrap/query-bridge.ts
+++ b/apps/desktop/src/bootstrap/query-bridge.ts
@@ -1,5 +1,7 @@
+import { initializeRepositoryChangeBridge } from "../state/core/repository-change-bridge";
import { initializeQueryFocusBridge } from "../state/core/state-manager";
export function initializeQueryBridge() {
initializeQueryFocusBridge();
+ initializeRepositoryChangeBridge();
}
diff --git a/apps/desktop/src/bootstrap/runtime-utils.ts b/apps/desktop/src/bootstrap/runtime-utils.ts
index 6cbe5b4a..94d843a6 100644
--- a/apps/desktop/src/bootstrap/runtime-utils.ts
+++ b/apps/desktop/src/bootstrap/runtime-utils.ts
@@ -45,6 +45,19 @@ export const isEmbeddedRuntime = () => {
export const isDesktopHostRuntime = () => !isEmbeddedRuntime();
+export const resolveRepositoryContextScope = (
+ embeddedRuntime: boolean,
+ embeddedTabId: string | null,
+ activeRuntimeId: string | null,
+ activeTabId: string | null,
+) => {
+ if (!embeddedRuntime) {
+ return null;
+ }
+
+ return embeddedTabId ?? activeRuntimeId ?? activeTabId ?? "tab-main";
+};
+
export type TabRuntimeStatePayload = {
tabId: string;
routePath?: string;
diff --git a/apps/desktop/src/components/webview-tab-host.tsx b/apps/desktop/src/components/webview-tab-host.tsx
index bca46a1b..b6c1a6bd 100644
--- a/apps/desktop/src/components/webview-tab-host.tsx
+++ b/apps/desktop/src/components/webview-tab-host.tsx
@@ -1,7 +1,13 @@
+import { disposeRepoContextOwner } from "@gitru/commands";
import { LogicalPosition, LogicalSize } from "@tauri-apps/api/dpi";
import { Webview } from "@tauri-apps/api/webview";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ sanitizeTabWebviewLabel,
+ TAB_WEBVIEW_LABEL_PREFIX,
+} from "@/bootstrap/runtime-utils";
+import { createRepoContextOwnerId } from "@/state/core/repo-context-registry";
import { useAppStore } from "@/store/use-app-store";
import type { WorkspaceTab } from "@/types/store";
@@ -14,12 +20,12 @@ type HostBounds = {
type ManagedWebview = {
tabId: string;
+ ownerId: string;
webview: Webview;
ready: Promise;
bounds: HostBounds;
};
-const WEBVIEW_LABEL_PREFIX = "tab-webview:";
const CREATE_TIMEOUT_MS = 1200;
const managedWebviews = new Map();
@@ -29,9 +35,6 @@ let visibleTabId: string | null = null;
let liveTabIds = new Set();
let pendingCleanupTimer: number | null = null;
-const sanitizeWebviewLabel = (tabId: string) =>
- `${WEBVIEW_LABEL_PREFIX}${tabId.replace(/[^a-zA-Z0-9\-/:_]/g, "_")}`;
-
const getRoutePathname = (routePath: string) => {
try {
return new URL(routePath, window.location.origin).pathname;
@@ -79,7 +82,13 @@ const updateManagedBounds = async (
};
const closeManagedWebview = async (entry: ManagedWebview) => {
- await Promise.allSettled([entry.webview.close()]);
+ try {
+ await entry.webview.close();
+ } finally {
+ await Promise.allSettled([
+ disposeRepoContextOwner({ ownerId: entry.ownerId }),
+ ]);
+ }
};
const hideUnlessActive = async (entry: ManagedWebview) => {
@@ -103,12 +112,15 @@ const ensureTabWebview = async (
const task = (async (): Promise => {
const normalized = normalizeBounds(bounds);
- const label = sanitizeWebviewLabel(tab.id);
+ const label = sanitizeTabWebviewLabel(tab.id);
+ const childScopeId = label.slice(TAB_WEBVIEW_LABEL_PREFIX.length);
+ const ownerId = createRepoContextOwnerId(label, childScopeId);
const existingByLabel = await Webview.getByLabel(label);
if (existingByLabel) {
const reused: ManagedWebview = {
tabId: tab.id,
+ ownerId,
webview: existingByLabel,
ready: Promise.resolve(),
// Force one geometry sync because the native view can outlive a host
@@ -168,6 +180,7 @@ const ensureTabWebview = async (
void webview.once("tauri://created", () => {
void hideUnlessActive({
tabId: tab.id,
+ ownerId,
webview,
ready: Promise.resolve(),
bounds: normalized,
@@ -184,6 +197,7 @@ const ensureTabWebview = async (
const created: ManagedWebview = {
tabId: tab.id,
+ ownerId,
webview,
ready,
bounds: normalized,
@@ -196,6 +210,7 @@ const ensureTabWebview = async (
if (recovered) {
const entry: ManagedWebview = {
tabId: tab.id,
+ ownerId,
webview: recovered,
ready: Promise.resolve(),
bounds: normalized,
@@ -299,7 +314,7 @@ const cleanupAllWebviews = async () => {
ensureInFlightByTabId.clear();
const entries = Array.from(managedWebviews.values());
managedWebviews.clear();
- await Promise.all(entries.map(closeManagedWebview));
+ await Promise.all(entries.map((entry) => closeManagedWebview(entry)));
};
const readHostBounds = (element: HTMLDivElement | null): HostBounds | null => {
diff --git a/apps/desktop/src/context/tab-context-provider.tsx b/apps/desktop/src/context/tab-context-provider.tsx
index 71a2640a..f3083523 100644
--- a/apps/desktop/src/context/tab-context-provider.tsx
+++ b/apps/desktop/src/context/tab-context-provider.tsx
@@ -1,3 +1,4 @@
+import { getCurrentWebview } from "@tauri-apps/api/webview";
import {
createContext,
type PropsWithChildren,
@@ -6,7 +7,10 @@ import {
useMemo,
useState,
} from "react";
-import { repoContextRegistry } from "@/state/core/repo-context-registry";
+import {
+ createRepoContextOwnerId,
+ repoContextRegistry,
+} from "@/state/core/repo-context-registry";
import { useAppStore } from "@/store/use-app-store";
type TabContextValue = {
@@ -31,6 +35,17 @@ export function TabContextProvider({
children,
scopeId = "main",
}: TabContextProviderProps) {
+ const ownerId = useMemo(() => {
+ let webviewLabel = "browser";
+
+ try {
+ webviewLabel = getCurrentWebview().label;
+ } catch {
+ // Plain browser runtimes do not expose a native webview label.
+ }
+
+ return createRepoContextOwnerId(webviewLabel, scopeId);
+ }, [scopeId]);
const activeSessionRepositoryId = useAppStore((state) => {
const runtimeId = state.activeSessionId ?? state.activeTabId;
if (!runtimeId) {
@@ -72,7 +87,7 @@ export function TabContextProvider({
setIsInitializing(true);
try {
- await repoContextRegistry.ensureScopeContext(scopeId, repoId);
+ await repoContextRegistry.ensureScopeContext(scopeId, repoId, ownerId);
} finally {
if (!cancelled) {
setIsInitializing(false);
@@ -85,7 +100,7 @@ export function TabContextProvider({
return () => {
cancelled = true;
};
- }, [scopeId, activeSessionRepositoryId]);
+ }, [scopeId, ownerId, activeSessionRepositoryId]);
useEffect(() => {
return () => {
diff --git a/apps/desktop/src/state/core/repo-context-registry.ts b/apps/desktop/src/state/core/repo-context-registry.ts
index 5928635b..23eb7319 100644
--- a/apps/desktop/src/state/core/repo-context-registry.ts
+++ b/apps/desktop/src/state/core/repo-context-registry.ts
@@ -1,17 +1,47 @@
import { createRepoContext, disposeRepoContext } from "@gitru/commands";
+import { repositories } from "../domains/repository-manager";
type ScopeId = string;
type ScopeContextEntry = {
repoId: string;
contextId: string;
+ ownerId: string;
};
-class RepoContextRegistry {
+export const createRepoContextOwnerId = (
+ webviewLabel: string,
+ scopeId: ScopeId,
+) => JSON.stringify([webviewLabel, scopeId]);
+
+type RepoContextRegistryDependencies = {
+ createContext: (input: {
+ repoId: string;
+ ownerId: string;
+ }) => Promise;
+ disposeContext: (contextId: string) => Promise;
+ disposeRepositoryState: (contextId: string) => Promise;
+};
+
+const defaultDependencies: RepoContextRegistryDependencies = {
+ createContext: ({ repoId, ownerId }) =>
+ createRepoContext({ repoId, ownerId }),
+ disposeContext: async (contextId) => {
+ await disposeRepoContext({ contextId });
+ },
+ disposeRepositoryState: (contextId) => repositories.disposeContext(contextId),
+};
+
+export class RepoContextRegistry {
private readonly contextsByScope = new Map();
+ private readonly operationsByScope = new Map>();
private readonly listeners = new Set<(contextId: string | null) => void>();
private activeScopeId: ScopeId | null = null;
+ constructor(
+ private readonly dependencies: RepoContextRegistryDependencies = defaultDependencies,
+ ) {}
+
getScopeContext(scopeId: ScopeId): ScopeContextEntry | null {
const context = this.contextsByScope.get(scopeId);
return context ? { ...context } : null;
@@ -28,48 +58,38 @@ class RepoContextRegistry {
this.notify();
}
- async ensureScopeContext(scopeId: ScopeId, repoId: string): Promise {
- const existing = this.contextsByScope.get(scopeId);
-
- if (existing?.repoId === repoId) {
- this.notify();
- return existing.contextId;
- }
-
- if (existing?.contextId) {
- await this.disposeContext(existing.contextId);
- }
-
- const contextId = await createRepoContext({ repoId });
- this.contextsByScope.set(scopeId, { repoId, contextId });
- this.notify();
-
- return contextId;
+ async ensureScopeContext(
+ scopeId: ScopeId,
+ repoId: string,
+ ownerId: string,
+ ): Promise {
+ return this.runSerialized(scopeId, async () => {
+ return this.ensureScopeContextNow(scopeId, repoId, ownerId);
+ });
}
async disposeScope(scopeId: ScopeId) {
- const existing = this.contextsByScope.get(scopeId);
+ await this.runSerialized(scopeId, async () => {
+ const existing = this.contextsByScope.get(scopeId);
- this.contextsByScope.delete(scopeId);
-
- if (existing?.contextId) {
- await this.disposeContext(existing.contextId);
- }
+ this.contextsByScope.delete(scopeId);
+ this.notify();
- this.notify();
+ if (existing?.contextId) {
+ await this.disposeContext(existing.contextId);
+ }
+ });
}
async disposeAll() {
- const contextIds = Array.from(this.contextsByScope.values()).map(
- (entry) => entry.contextId,
- );
-
- this.contextsByScope.clear();
- this.notify();
+ const scopeIds = new Set([
+ ...this.contextsByScope.keys(),
+ ...this.operationsByScope.keys(),
+ ]);
await Promise.all(
- contextIds.map(async (contextId) => {
- await this.disposeContext(contextId);
+ Array.from(scopeIds, async (scopeId) => {
+ await this.disposeScope(scopeId);
}),
);
}
@@ -91,6 +111,34 @@ class RepoContextRegistry {
};
}
+ private async ensureScopeContextNow(
+ scopeId: ScopeId,
+ repoId: string,
+ ownerId: string,
+ ): Promise {
+ const existing = this.contextsByScope.get(scopeId);
+
+ if (existing?.repoId === repoId && existing.ownerId === ownerId) {
+ this.notify();
+ return existing.contextId;
+ }
+
+ if (existing?.contextId) {
+ this.contextsByScope.delete(scopeId);
+ this.notify();
+ await this.disposeContext(existing.contextId);
+ }
+
+ const contextId = await this.dependencies.createContext({
+ repoId,
+ ownerId,
+ });
+ this.contextsByScope.set(scopeId, { repoId, contextId, ownerId });
+ this.notify();
+
+ return contextId;
+ }
+
private notify() {
const activeContextId = this.getActiveContextId();
for (const listener of this.listeners) {
@@ -100,11 +148,34 @@ class RepoContextRegistry {
private async disposeContext(contextId: string) {
try {
- await disposeRepoContext({ contextId });
+ await this.dependencies.disposeContext(contextId);
} catch {
// Ignore cleanup errors when context is already disposed server-side.
+ } finally {
+ await this.dependencies.disposeRepositoryState(contextId);
}
}
+
+ private runSerialized(
+ scopeId: ScopeId,
+ operation: () => Promise,
+ ): Promise {
+ const previous = this.operationsByScope.get(scopeId) ?? Promise.resolve();
+ const result = previous.catch(() => undefined).then(operation);
+ const tail = result.then(
+ () => undefined,
+ () => undefined,
+ );
+
+ this.operationsByScope.set(scopeId, tail);
+ void tail.then(() => {
+ if (this.operationsByScope.get(scopeId) === tail) {
+ this.operationsByScope.delete(scopeId);
+ }
+ });
+
+ return result;
+ }
}
export const repoContextRegistry = new RepoContextRegistry();
diff --git a/apps/desktop/src/state/core/repository-change-bridge.ts b/apps/desktop/src/state/core/repository-change-bridge.ts
new file mode 100644
index 00000000..45efa796
--- /dev/null
+++ b/apps/desktop/src/state/core/repository-change-bridge.ts
@@ -0,0 +1,186 @@
+import type { QueryClient, QueryKey } from "@tanstack/react-query";
+import { queryClient } from "./state-manager";
+
+export const REPOSITORY_CHANGED_EVENT = "gitru://repository-changed";
+
+export type RepositoryChangeKind =
+ | "worktree"
+ | "index"
+ | "head"
+ | "refs"
+ | "stash"
+ | "operation"
+ | "config";
+
+export type RepositoryChangedPayload = {
+ contextId: string;
+ changes: RepositoryChangeKind[];
+};
+
+const isRepositoryQueryForContext = (queryKey: QueryKey, contextId: string) =>
+ queryKey[0] === "repository" && queryKey[1] === contextId;
+
+const isWorktreeFileQueryForContext = (queryKey: QueryKey, contextId: string) =>
+ queryKey[0] === "worktree-file" && queryKey[1] === contextId;
+
+const hasBranchKey = (queryKey: QueryKey, keys: readonly string[]) =>
+ queryKey[3] === "branches" &&
+ typeof queryKey[4] === "string" &&
+ keys.includes(queryKey[4]);
+
+const isWorktreeDiff = (queryKey: QueryKey) =>
+ queryKey[3] === "diff" && queryKey[4] === "worktree";
+
+const isStashDiff = (queryKey: QueryKey) =>
+ queryKey[3] === "diff" &&
+ typeof queryKey[4] === "string" &&
+ queryKey[4].startsWith("stash:");
+
+const isCommitTimeline = (queryKey: QueryKey) =>
+ queryKey[3] === "commit" &&
+ ["last", "history", "historyGraph", "commitActivity"].includes(
+ String(queryKey[4]),
+ );
+
+const includesStash = (value: unknown) =>
+ typeof value === "object" &&
+ value !== null &&
+ "include_stash" in value &&
+ value.include_stash === true;
+
+const isStashTimeline = (queryKey: QueryKey) =>
+ queryKey[3] === "commit" &&
+ ["historyGraph", "commitActivity"].includes(String(queryKey[4])) &&
+ queryKey.some(includesStash);
+
+const isAffectedByChange = (
+ queryKey: QueryKey,
+ change: RepositoryChangeKind,
+) => {
+ const domain = queryKey[3];
+
+ switch (change) {
+ case "worktree":
+ return (
+ domain === "status" ||
+ isWorktreeDiff(queryKey) ||
+ hasBranchKey(queryKey, ["hasUncommittedChanges"])
+ );
+ case "index":
+ return (
+ domain === "status" ||
+ domain === "operation" ||
+ isWorktreeDiff(queryKey) ||
+ hasBranchKey(queryKey, ["hasUncommittedChanges"])
+ );
+ case "head":
+ return (
+ domain === "status" ||
+ isWorktreeDiff(queryKey) ||
+ isCommitTimeline(queryKey) ||
+ hasBranchKey(queryKey, [
+ "list",
+ "current",
+ "statusAheadBehind",
+ "hasUncommittedChanges",
+ "currentBranchStash",
+ ])
+ );
+ case "refs":
+ return (
+ domain === "status" ||
+ isWorktreeDiff(queryKey) ||
+ isCommitTimeline(queryKey) ||
+ hasBranchKey(queryKey, [
+ "list",
+ "statusAheadBehind",
+ "hasUncommittedChanges",
+ ])
+ );
+ case "stash":
+ return (
+ domain === "stash" ||
+ isStashDiff(queryKey) ||
+ isStashTimeline(queryKey) ||
+ hasBranchKey(queryKey, ["currentBranchStash"])
+ );
+ case "operation":
+ return (
+ domain === "operation" ||
+ domain === "status" ||
+ isWorktreeDiff(queryKey) ||
+ hasBranchKey(queryKey, ["current", "hasUncommittedChanges"])
+ );
+ case "config":
+ return (
+ domain === "status" ||
+ domain === "diff" ||
+ domain === "origin" ||
+ hasBranchKey(queryKey, [
+ "list",
+ "statusAheadBehind",
+ "hasUncommittedChanges",
+ ])
+ );
+ }
+};
+
+export const shouldInvalidateRepositoryQuery = (
+ queryKey: QueryKey,
+ payload: RepositoryChangedPayload,
+) => {
+ if (isWorktreeFileQueryForContext(queryKey, payload.contextId)) {
+ return payload.changes.includes("worktree");
+ }
+
+ return (
+ isRepositoryQueryForContext(queryKey, payload.contextId) &&
+ payload.changes.some((change) => isAffectedByChange(queryKey, change))
+ );
+};
+
+export const invalidateRepositoryQueries = async (
+ client: QueryClient,
+ payload: RepositoryChangedPayload,
+) => {
+ const predicate = (query: { queryKey: QueryKey }) =>
+ shouldInvalidateRepositoryQuery(query.queryKey, payload);
+
+ // An invalidation alone does not restart an active initial fetch with no
+ // cached data. Cancel first so a pre-change result cannot clear the
+ // invalidation and leave the query stale indefinitely.
+ await client.cancelQueries({ predicate });
+ await client.invalidateQueries({
+ predicate,
+ refetchType: "active",
+ });
+};
+
+let repositoryChangeBridgeInitialized = false;
+
+/**
+ * Installs one native change listener for this webview runtime. The context ID
+ * in each event keeps unrelated repository tabs from refetching.
+ */
+export function initializeRepositoryChangeBridge() {
+ if (repositoryChangeBridgeInitialized || typeof window === "undefined") {
+ return;
+ }
+
+ repositoryChangeBridgeInitialized = true;
+
+ void import("@tauri-apps/api/event")
+ .then(async ({ listen }) => {
+ await listen(
+ REPOSITORY_CHANGED_EVENT,
+ ({ payload }) => {
+ if (!payload?.contextId || !Array.isArray(payload.changes)) return;
+ void invalidateRepositoryQueries(queryClient, payload);
+ },
+ );
+ })
+ .catch(() => {
+ // The bridge is optional in a plain browser runtime. Native focus
+ // refresh remains the recovery path if listener setup fails in Tauri.
+ });
+}
diff --git a/apps/desktop/src/state/core/state-manager.ts b/apps/desktop/src/state/core/state-manager.ts
index caeabcb1..97f31d97 100644
--- a/apps/desktop/src/state/core/state-manager.ts
+++ b/apps/desktop/src/state/core/state-manager.ts
@@ -12,13 +12,28 @@ export const queryClient = new QueryClient({
gcTime: 1000 * 60 * 30,
retry: 2,
refetchOnWindowFocus: true,
- refetchInterval: 30000,
},
},
});
let focusBridgeInitialized = false;
+export async function refreshActiveQueriesAfterNativeFocus(
+ client: QueryClient,
+ invalidateBackendCaches: () => Promise,
+) {
+ await client.cancelQueries({ type: "active" });
+
+ try {
+ await invalidateBackendCaches();
+ } catch {
+ // A frontend refetch is still useful if the native cache bridge is
+ // unavailable; the watcher remains the primary freshness path.
+ }
+
+ await client.invalidateQueries({ refetchType: "active" });
+}
+
/**
* React Query relies on browser focus events by default. In Tauri, those can be
* inconsistent, so we bridge Tauri's native focus change signal into Query's
@@ -41,6 +56,7 @@ export function initializeQueryFocusBridge() {
let disposed = false;
let tauriUnlisten: (() => void) | undefined;
+ let nativeFocusGeneration = 0;
// Use dynamic import so this remains safe in non-Tauri runtime contexts.
void import("@tauri-apps/api/window")
@@ -48,10 +64,28 @@ export function initializeQueryFocusBridge() {
if (disposed) return;
tauriUnlisten = await getCurrentWindow().onFocusChanged(
({ payload: focused }) => {
- handleFocus(focused);
- if (focused) {
- void queryClient.invalidateQueries({ refetchType: "active" });
+ nativeFocusGeneration += 1;
+ const generation = nativeFocusGeneration;
+
+ if (!focused) {
+ handleFocus(false);
+ return;
}
+
+ // Pause focus-triggered refetches until the Rust cache is cleared.
+ // This keeps the fallback correct even when filesystem watching
+ // could not be established for a repository.
+ handleFocus(false);
+ void refreshActiveQueriesAfterNativeFocus(queryClient, async () => {
+ const { invalidateRepoContextCaches } = await import(
+ "@gitru/commands"
+ );
+ await invalidateRepoContextCaches();
+ }).finally(() => {
+ if (!disposed && generation === nativeFocusGeneration) {
+ handleFocus(true);
+ }
+ });
},
);
})
diff --git a/apps/desktop/src/state/domains/repository-manager.ts b/apps/desktop/src/state/domains/repository-manager.ts
index 12ee9d24..c5b68fe6 100644
--- a/apps/desktop/src/state/domains/repository-manager.ts
+++ b/apps/desktop/src/state/domains/repository-manager.ts
@@ -1,9 +1,12 @@
+import type { QueryClient } from "@tanstack/react-query";
import { queryClient } from "../core/state-manager";
import { RepositoryState } from "./repository-state";
-class RepositoryManager {
+export class RepositoryManager {
private instances = new Map();
+ constructor(private readonly client: QueryClient = queryClient) {}
+
for(repoPath: string, contextId: string): RepositoryState {
const normalizedPath = this.normalizePath(repoPath);
const key = this.getKey(normalizedPath, contextId);
@@ -11,7 +14,7 @@ class RepositoryManager {
if (!this.instances.has(key)) {
this.instances.set(
key,
- new RepositoryState(queryClient, normalizedPath, contextId),
+ new RepositoryState(this.client, normalizedPath, contextId),
);
}
@@ -24,22 +27,52 @@ class RepositoryManager {
async dispose(repoPath: string, contextId: string): Promise {
const normalizedPath = this.normalizePath(repoPath);
const key = this.getKey(normalizedPath, contextId);
- const instance = this.instances.get(key);
+ const queryKey = ["repository", contextId, normalizedPath];
+ const worktreeFileKey = ["worktree-file", contextId];
+
+ this.instances.delete(key);
+ await Promise.all([
+ this.client.cancelQueries({ queryKey }),
+ this.client.cancelQueries({ queryKey: worktreeFileKey }),
+ ]);
+ this.client.removeQueries({ queryKey });
+ this.client.removeQueries({ queryKey: worktreeFileKey });
+ }
- if (instance) {
- await instance.invalidateAll();
- this.instances.delete(key);
+ /**
+ * Remove every cached query and state facade owned by a native context.
+ * Cancelling before removal prevents a disposing tab from refetching.
+ */
+ async disposeContext(contextId: string): Promise {
+ for (const [key, instance] of this.instances) {
+ if (instance.contextId === contextId) {
+ this.instances.delete(key);
+ }
}
+
+ const queryKey = ["repository", contextId];
+ const worktreeFileKey = ["worktree-file", contextId];
+ await Promise.all([
+ this.client.cancelQueries({ queryKey }),
+ this.client.cancelQueries({ queryKey: worktreeFileKey }),
+ ]);
+ this.client.removeQueries({ queryKey });
+ this.client.removeQueries({ queryKey: worktreeFileKey });
}
/**
* Clear all repository state instances
*/
async disposeAll(): Promise {
- for (const instance of this.instances.values()) {
- await instance.invalidateAll();
- }
this.instances.clear();
+ const queryKey = ["repository"];
+ const worktreeFileKey = ["worktree-file"];
+ await Promise.all([
+ this.client.cancelQueries({ queryKey }),
+ this.client.cancelQueries({ queryKey: worktreeFileKey }),
+ ]);
+ this.client.removeQueries({ queryKey });
+ this.client.removeQueries({ queryKey: worktreeFileKey });
}
private getKey(repoPath: string, contextId: string): string {
diff --git a/apps/desktop/src/state/domains/repository-state.ts b/apps/desktop/src/state/domains/repository-state.ts
index b90f7638..b8259be1 100644
--- a/apps/desktop/src/state/domains/repository-state.ts
+++ b/apps/desktop/src/state/domains/repository-state.ts
@@ -86,22 +86,8 @@ class DiffState extends StateDomain {
diffScope?: DiffScope;
},
) {
- const sourceScope = options?.stashReference
- ? `stash:${options.stashReference}`
- : options?.commitHash
- ? `commit:${options.commitHash}:p${options.parentIndex ?? 1}`
- : "worktree";
const diffScope = options?.diffScope ?? "Worktree";
- const queryKey = [
- ...this.baseKey,
- sourceScope,
- diffScope,
- filePath,
- options?.fileNewPath ?? "",
- options?.status?.join(",") ?? "",
- ];
-
- const data = await getPatchByFilePath({
+ return await getPatchByFilePath({
contextId: this.contextId,
filePath: filePath,
fileNewPath: options?.fileNewPath,
@@ -111,10 +97,6 @@ class DiffState extends StateDomain {
parentIndex: options?.parentIndex,
diffScope,
});
-
- this.queryClient.setQueryData(queryKey, data);
-
- return data;
}
getDiffQueryKey(
@@ -169,15 +151,9 @@ class StatusState extends StateDomain {
}
async get() {
- await this.queryClient.cancelQueries({ queryKey: [...this.baseKey] });
-
- const data = await getStatus({
+ return await getStatus({
contextId: this.contextId,
});
-
- this.queryClient.setQueryData([...this.baseKey], data);
-
- return data;
}
// For React hooks
@@ -210,43 +186,22 @@ class BranchState extends StateDomain {
}
async list(kind: BranchKind) {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "list", kind],
- });
-
- const data = await listBranches({
+ return await listBranches({
contextId: this.contextId,
kind,
});
-
- this.queryClient.setQueryData([...this.baseKey, "list", kind], data);
- return data;
}
async current() {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "current"],
- });
-
- const data = await currentBranch({
+ return await currentBranch({
contextId: this.contextId,
});
-
- this.queryClient.setQueryData([...this.baseKey, "current"], data);
- return data;
}
async statusAheadBehind() {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "statusAheadBehind"],
- });
-
- const data = await statusAheadBehind({
+ return await statusAheadBehind({
contextId: this.contextId,
});
-
- this.queryClient.setQueryData([...this.baseKey, "statusAheadBehind"], data);
- return data;
}
getQueryKey(
@@ -461,35 +416,16 @@ class Commit extends StateDomain {
this.contextId = contextId;
}
async last() {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "last"],
- });
-
- const data = await lastCommit({
+ return await lastCommit({
contextId: this.contextId,
});
-
- this.queryClient.setQueryData([...this.baseKey, "last"], data);
-
- return data;
}
async getCommitById(hash: string) {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "getCommitById", hash],
- });
-
- const data = await commitById({
+ return await commitById({
contextId: this.contextId,
hash,
});
-
- this.queryClient.setQueryData(
- [...this.baseKey, "getCommitById", hash],
- data,
- );
-
- return data;
}
async createCommit(payload: CreateCommitPayload) {
@@ -502,19 +438,11 @@ class Commit extends StateDomain {
}
async history() {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "history"],
- });
-
- const data = await history({
+ return await history({
contextId: this.contextId,
limit: 100,
skip: 0,
});
-
- this.queryClient.setQueryData([...this.baseKey, "history"], data);
-
- return data;
}
async historyGraph(params: HistoryGraphParams["query"]) {
@@ -565,10 +493,7 @@ class OperationState extends StateDomain {
}
async get(): Promise {
- await this.queryClient.cancelQueries({ queryKey: [...this.baseKey] });
- const data = await getRepoOperation({ contextId: this.contextId });
- this.queryClient.setQueryData([...this.baseKey], data);
- return data;
+ return await getRepoOperation({ contextId: this.contextId });
}
get queryKey() {
@@ -701,18 +626,9 @@ class RepositoryState extends StateDomain {
}
async getRepositoryOrigin() {
- // repositoryOrigin()
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "origin"],
- });
-
- const data = await repositoryOrigin({
+ return await repositoryOrigin({
contextId: this.contextId,
});
-
- this.queryClient.setQueryData([...this.baseKey, "origin"], data);
-
- return data;
}
getQueryKey(key: "origin") {
diff --git a/apps/desktop/src/state/domains/stash-state.ts b/apps/desktop/src/state/domains/stash-state.ts
index ae7bb15b..31ff7032 100644
--- a/apps/desktop/src/state/domains/stash-state.ts
+++ b/apps/desktop/src/state/domains/stash-state.ts
@@ -42,47 +42,23 @@ class StashState extends StateDomain {
}
async list(): Promise {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "list"],
- });
-
- const data = await stashList({
+ return await stashList({
contextId: this.contextId,
});
-
- this.queryClient.setQueryData([...this.baseKey, "list"], data);
- return data;
}
async quickStat(reference: string): Promise {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "quickStat", reference],
- });
-
- const data = await stashQuickStat({
+ return await stashQuickStat({
contextId: this.contextId,
reference,
});
-
- this.queryClient.setQueryData(
- [...this.baseKey, "quickStat", reference],
- data,
- );
- return data;
}
async show(reference: string): Promise {
- await this.queryClient.cancelQueries({
- queryKey: [...this.baseKey, "show", reference],
- });
-
- const data = await stashShow({
+ return await stashShow({
contextId: this.contextId,
reference,
});
-
- this.queryClient.setQueryData([...this.baseKey, "show", reference], data);
- return data;
}
async push(params?: StashPushInput): Promise {
diff --git a/apps/desktop/tests/repo-context-registry.test.ts b/apps/desktop/tests/repo-context-registry.test.ts
new file mode 100644
index 00000000..05624e40
--- /dev/null
+++ b/apps/desktop/tests/repo-context-registry.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, test, vi } from "vitest";
+import {
+ createRepoContextOwnerId,
+ RepoContextRegistry,
+} from "../src/state/core/repo-context-registry";
+
+const deferred = () => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+};
+
+const createDependencies = () => ({
+ createContext: vi.fn(
+ async ({ repoId }: { repoId: string; ownerId: string }) =>
+ `context-${repoId}`,
+ ),
+ disposeContext: vi.fn(async (_contextId: string) => {}),
+ disposeRepositoryState: vi.fn(async (_contextId: string) => {}),
+});
+
+describe("RepoContextRegistry lifecycle", () => {
+ test("builds collision-safe owners from the webview and scope", () => {
+ expect(createRepoContextOwnerId("main", "tab-a")).toBe('["main","tab-a"]');
+ expect(createRepoContextOwnerId("main:tab", "a")).not.toBe(
+ createRepoContextOwnerId("main", "tab:a"),
+ );
+ });
+
+ test("passes the runtime-specific owner to the backend", async () => {
+ const dependencies = createDependencies();
+ const registry = new RepoContextRegistry(dependencies);
+ const ownerId = createRepoContextOwnerId("main", "tab-a");
+
+ await expect(
+ registry.ensureScopeContext("tab-a", "repo-a", ownerId),
+ ).resolves.toBe("context-repo-a");
+
+ expect(dependencies.createContext).toHaveBeenCalledWith({
+ repoId: "repo-a",
+ ownerId,
+ });
+ });
+
+ test("replaces a context when the same scope moves to another runtime", async () => {
+ const dependencies = createDependencies();
+ dependencies.createContext
+ .mockResolvedValueOnce("context-root")
+ .mockResolvedValueOnce("context-child");
+ const registry = new RepoContextRegistry(dependencies);
+ const rootOwner = createRepoContextOwnerId("main", "tab-a");
+ const childOwner = createRepoContextOwnerId("tab-webview:tab-a", "tab-a");
+
+ await registry.ensureScopeContext("tab-a", "repo-a", rootOwner);
+ await registry.ensureScopeContext("tab-a", "repo-a", childOwner);
+
+ expect(dependencies.disposeContext).toHaveBeenCalledWith("context-root");
+ expect(registry.getScopeContext("tab-a")).toEqual({
+ repoId: "repo-a",
+ contextId: "context-child",
+ ownerId: childOwner,
+ });
+ });
+
+ test("serializes rapid repository switches without leaking the late context", async () => {
+ const firstContext = deferred();
+ const dependencies = createDependencies();
+ dependencies.createContext.mockImplementation(
+ async ({ repoId }: { repoId: string; ownerId: string }) => {
+ if (repoId === "repo-a") return firstContext.promise;
+ return "context-repo-b";
+ },
+ );
+ const registry = new RepoContextRegistry(dependencies);
+
+ const ownerId = createRepoContextOwnerId("tab-webview:tab-a", "tab-a");
+ const first = registry.ensureScopeContext("tab-a", "repo-a", ownerId);
+ const second = registry.ensureScopeContext("tab-a", "repo-b", ownerId);
+
+ await vi.waitFor(() => {
+ expect(dependencies.createContext).toHaveBeenCalledTimes(1);
+ });
+ firstContext.resolve("context-repo-a");
+
+ await expect(first).resolves.toBe("context-repo-a");
+ await expect(second).resolves.toBe("context-repo-b");
+
+ expect(dependencies.disposeContext).toHaveBeenCalledWith("context-repo-a");
+ expect(dependencies.disposeRepositoryState).toHaveBeenCalledWith(
+ "context-repo-a",
+ );
+ expect(registry.getScopeContext("tab-a")).toEqual({
+ repoId: "repo-b",
+ contextId: "context-repo-b",
+ ownerId,
+ });
+ });
+
+ test("queues scope disposal behind an in-flight context creation", async () => {
+ const createdContext = deferred();
+ const dependencies = createDependencies();
+ dependencies.createContext.mockImplementation(async () => {
+ return createdContext.promise;
+ });
+ const registry = new RepoContextRegistry(dependencies);
+
+ const ensure = registry.ensureScopeContext(
+ "tab-a",
+ "repo-a",
+ createRepoContextOwnerId("main", "tab-a"),
+ );
+ const dispose = registry.disposeScope("tab-a");
+
+ createdContext.resolve("context-repo-a");
+ await ensure;
+ await dispose;
+
+ expect(registry.getScopeContext("tab-a")).toBeNull();
+ expect(dependencies.disposeContext).toHaveBeenCalledWith("context-repo-a");
+ expect(dependencies.disposeRepositoryState).toHaveBeenCalledWith(
+ "context-repo-a",
+ );
+ });
+
+ test("disposeAll includes scopes whose context creation is still pending", async () => {
+ const createdContext = deferred();
+ const dependencies = createDependencies();
+ dependencies.createContext.mockImplementation(async () => {
+ return createdContext.promise;
+ });
+ const registry = new RepoContextRegistry(dependencies);
+
+ const ensure = registry.ensureScopeContext(
+ "tab-a",
+ "repo-a",
+ createRepoContextOwnerId("main", "tab-a"),
+ );
+ const disposeAll = registry.disposeAll();
+
+ createdContext.resolve("context-repo-a");
+ await ensure;
+ await disposeAll;
+
+ expect(registry.getScopeContext("tab-a")).toBeNull();
+ expect(dependencies.disposeContext).toHaveBeenCalledWith("context-repo-a");
+ });
+});
diff --git a/apps/desktop/tests/repository-change-bridge.test.ts b/apps/desktop/tests/repository-change-bridge.test.ts
new file mode 100644
index 00000000..4f79f968
--- /dev/null
+++ b/apps/desktop/tests/repository-change-bridge.test.ts
@@ -0,0 +1,234 @@
+import { QueryClient, QueryObserver } from "@tanstack/react-query";
+import { describe, expect, test, vi } from "vitest";
+import {
+ invalidateRepositoryQueries,
+ type RepositoryChangeKind,
+ shouldInvalidateRepositoryQuery,
+} from "../src/state/core/repository-change-bridge";
+import {
+ queryClient,
+ refreshActiveQueriesAfterNativeFocus,
+} from "../src/state/core/state-manager";
+
+const repoKey = (...suffix: unknown[]) => [
+ "repository",
+ "context-a",
+ "/repos/alpha",
+ ...suffix,
+];
+
+const affected = (change: RepositoryChangeKind, ...suffix: unknown[]) =>
+ shouldInvalidateRepositoryQuery(repoKey(...suffix), {
+ contextId: "context-a",
+ changes: [change],
+ });
+
+describe("repository filesystem change query mapping", () => {
+ test("worktree changes refresh only worktree state", () => {
+ expect(affected("worktree", "status")).toBe(true);
+ expect(affected("worktree", "diff", "worktree", "Worktree")).toBe(true);
+ expect(affected("worktree", "branches", "hasUncommittedChanges")).toBe(
+ true,
+ );
+
+ expect(affected("worktree", "diff", "commit:abc:p1")).toBe(false);
+ expect(affected("worktree", "commit", "history")).toBe(false);
+ expect(
+ shouldInvalidateRepositoryQuery(
+ ["worktree-file", "context-a", "src/conflicted.ts", 0],
+ { contextId: "context-a", changes: ["worktree"] },
+ ),
+ ).toBe(true);
+ expect(
+ shouldInvalidateRepositoryQuery(
+ ["worktree-file", "context-b", "src/conflicted.ts", 0],
+ { contextId: "context-a", changes: ["worktree"] },
+ ),
+ ).toBe(false);
+ expect(
+ shouldInvalidateRepositoryQuery(
+ ["worktree-file", "context-a", "src/conflicted.ts", 0],
+ { contextId: "context-a", changes: ["index"] },
+ ),
+ ).toBe(false);
+ });
+
+ test("index changes also refresh operation state", () => {
+ expect(affected("index", "status")).toBe(true);
+ expect(affected("index", "diff", "worktree", "Staged")).toBe(true);
+ expect(affected("index", "operation")).toBe(true);
+ expect(affected("index", "branches", "list")).toBe(false);
+ });
+
+ test("HEAD changes refresh branch, timeline, status, and worktree data", () => {
+ for (const suffix of [
+ ["branches", "list"],
+ ["branches", "current"],
+ ["branches", "statusAheadBehind"],
+ ["branches", "hasUncommittedChanges"],
+ ["branches", "currentBranchStash"],
+ ["commit", "last"],
+ ["commit", "historyGraph"],
+ ["status"],
+ ["diff", "worktree"],
+ ]) {
+ expect(affected("head", ...suffix)).toBe(true);
+ }
+
+ expect(affected("head", "stash", "list")).toBe(false);
+ expect(affected("head", "commit", "getCommitById", "abc")).toBe(false);
+ });
+
+ test("ref changes refresh branch and commit timeline queries", () => {
+ expect(affected("refs", "branches", "list")).toBe(true);
+ expect(affected("refs", "branches", "statusAheadBehind")).toBe(true);
+ expect(affected("refs", "branches", "hasUncommittedChanges")).toBe(true);
+ expect(affected("refs", "commit", "history")).toBe(true);
+ expect(affected("refs", "status")).toBe(true);
+ expect(affected("refs", "diff", "worktree")).toBe(true);
+
+ expect(affected("refs", "branches", "current")).toBe(false);
+ expect(affected("refs", "diff", "commit:abc:p1")).toBe(false);
+ });
+
+ test("stash changes refresh stash identities and stash-aware timelines", () => {
+ expect(affected("stash", "stash", "list")).toBe(true);
+ expect(affected("stash", "diff", "stash:stash@{0}")).toBe(true);
+ expect(affected("stash", "branches", "currentBranchStash")).toBe(true);
+ expect(
+ affected("stash", "commit", "historyGraph", {
+ include_stash: true,
+ }),
+ ).toBe(true);
+
+ expect(
+ affected("stash", "commit", "historyGraph", {
+ include_stash: false,
+ }),
+ ).toBe(false);
+ expect(affected("stash", "diff", "worktree")).toBe(false);
+ });
+
+ test("operation and config changes stay within their affected domains", () => {
+ expect(affected("operation", "operation")).toBe(true);
+ expect(affected("operation", "status")).toBe(true);
+ expect(affected("operation", "diff", "worktree")).toBe(true);
+ expect(affected("operation", "branches", "current")).toBe(true);
+ expect(affected("operation", "branches", "hasUncommittedChanges")).toBe(
+ true,
+ );
+ expect(affected("operation", "branches", "list")).toBe(false);
+
+ expect(affected("config", "origin")).toBe(true);
+ expect(affected("config", "branches", "list")).toBe(true);
+ expect(affected("config", "branches", "statusAheadBehind")).toBe(true);
+ expect(affected("config", "branches", "hasUncommittedChanges")).toBe(true);
+ expect(affected("config", "status")).toBe(true);
+ expect(affected("config", "diff", "worktree")).toBe(true);
+ expect(affected("config", "diff", "commit:abc:p1")).toBe(true);
+ expect(affected("config", "diff", "stash:stash@{0}")).toBe(true);
+ });
+
+ test("never invalidates another repository context", () => {
+ expect(
+ shouldInvalidateRepositoryQuery(
+ ["repository", "context-b", "/repos/beta", "status"],
+ { contextId: "context-a", changes: ["worktree", "head"] },
+ ),
+ ).toBe(false);
+ });
+
+ test("marks only matching cached queries stale", async () => {
+ const client = new QueryClient();
+ const matching = repoKey("status");
+ const unrelated = repoKey("commit", "getCommitById", "abc");
+ const otherContext = ["repository", "context-b", "/repos/beta", "status"];
+
+ client.setQueryData(matching, "status");
+ client.setQueryData(unrelated, "commit");
+ client.setQueryData(otherContext, "other");
+
+ await invalidateRepositoryQueries(client, {
+ contextId: "context-a",
+ changes: ["worktree"],
+ });
+
+ expect(client.getQueryState(matching)?.isInvalidated).toBe(true);
+ expect(client.getQueryState(unrelated)?.isInvalidated).toBe(false);
+ expect(client.getQueryState(otherContext)?.isInvalidated).toBe(false);
+ });
+
+ test("restarts an active initial fetch so an old result cannot win", async () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const key = repoKey("status");
+ let resolveFirst!: (value: string) => void;
+ const firstResult = new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ let calls = 0;
+ const observer = new QueryObserver(client, {
+ queryKey: key,
+ queryFn: () => {
+ calls += 1;
+ return calls === 1 ? firstResult : Promise.resolve("fresh");
+ },
+ });
+ const unsubscribe = observer.subscribe(() => {});
+
+ await vi.waitFor(() => expect(calls).toBe(1));
+ const refresh = invalidateRepositoryQueries(client, {
+ contextId: "context-a",
+ changes: ["worktree"],
+ });
+
+ await vi.waitFor(() => expect(calls).toBe(2));
+ resolveFirst("stale");
+ await refresh;
+
+ expect(client.getQueryData(key)).toBe("fresh");
+ expect(client.getQueryState(key)?.isInvalidated).toBe(false);
+ unsubscribe();
+ });
+});
+
+test("global queries keep focus recovery without interval polling", () => {
+ const options = queryClient.getDefaultOptions().queries;
+
+ expect(options?.refetchOnWindowFocus).toBe(true);
+ expect(options?.refetchInterval).toBeUndefined();
+});
+
+test("focus recovery clears native caches before refetching", async () => {
+ const calls: string[] = [];
+ const client = {
+ cancelQueries: vi.fn(async () => {
+ calls.push("cancel");
+ }),
+ invalidateQueries: vi.fn(async () => {
+ calls.push("refetch");
+ }),
+ } as unknown as QueryClient;
+
+ await refreshActiveQueriesAfterNativeFocus(client, async () => {
+ calls.push("backend");
+ });
+
+ expect(calls).toEqual(["cancel", "backend", "refetch"]);
+});
+
+test("focus recovery still refetches if native cache clearing fails", async () => {
+ const client = {
+ cancelQueries: vi.fn(async () => {}),
+ invalidateQueries: vi.fn(async () => {}),
+ } as unknown as QueryClient;
+
+ await refreshActiveQueriesAfterNativeFocus(client, async () => {
+ throw new Error("native bridge unavailable");
+ });
+
+ expect(client.invalidateQueries).toHaveBeenCalledWith({
+ refetchType: "active",
+ });
+});
diff --git a/apps/desktop/tests/repository-manager.test.ts b/apps/desktop/tests/repository-manager.test.ts
new file mode 100644
index 00000000..169b6e01
--- /dev/null
+++ b/apps/desktop/tests/repository-manager.test.ts
@@ -0,0 +1,61 @@
+import { QueryClient } from "@tanstack/react-query";
+import { describe, expect, test, vi } from "vitest";
+import { RepositoryManager } from "../src/state/domains/repository-manager";
+
+describe("RepositoryManager disposal", () => {
+ test("cancels and removes every query for a disposed context", async () => {
+ const client = new QueryClient();
+ const manager = new RepositoryManager(client);
+ const alphaStatus = ["repository", "context-a", "/repos/alpha", "status"];
+ const alphaDiff = [
+ "repository",
+ "context-a",
+ "/repos/alpha",
+ "diff",
+ "worktree",
+ ];
+ const betaStatus = ["repository", "context-b", "/repos/beta", "status"];
+ const alphaWorktreeFile = [
+ "worktree-file",
+ "context-a",
+ "src/conflicted.ts",
+ 0,
+ ];
+
+ manager.for("/repos/alpha", "context-a");
+ manager.for("/repos/beta", "context-b");
+ client.setQueryData(alphaStatus, "alpha-status");
+ client.setQueryData(alphaDiff, "alpha-diff");
+ client.setQueryData(alphaWorktreeFile, "file contents");
+ client.setQueryData(betaStatus, "beta-status");
+ const cancel = vi.spyOn(client, "cancelQueries");
+
+ await manager.disposeContext("context-a");
+
+ expect(cancel).toHaveBeenCalledWith({
+ queryKey: ["repository", "context-a"],
+ });
+ expect(cancel).toHaveBeenCalledWith({
+ queryKey: ["worktree-file", "context-a"],
+ });
+ expect(client.getQueryData(alphaStatus)).toBeUndefined();
+ expect(client.getQueryData(alphaDiff)).toBeUndefined();
+ expect(client.getQueryData(alphaWorktreeFile)).toBeUndefined();
+ expect(client.getQueryData(betaStatus)).toBe("beta-status");
+ });
+
+ test("repository disposal removes cache instead of invalidating it", async () => {
+ const client = new QueryClient();
+ const manager = new RepositoryManager(client);
+ const status = ["repository", "context-a", "/repos/alpha", "status"];
+ const invalidate = vi.spyOn(client, "invalidateQueries");
+
+ manager.for("/repos/alpha/", "context-a");
+ client.setQueryData(status, "status");
+
+ await manager.dispose("/repos/alpha", "context-a");
+
+ expect(invalidate).not.toHaveBeenCalled();
+ expect(client.getQueryData(status)).toBeUndefined();
+ });
+});
diff --git a/apps/desktop/tests/runtime-utils.test.ts b/apps/desktop/tests/runtime-utils.test.ts
new file mode 100644
index 00000000..ab3a49aa
--- /dev/null
+++ b/apps/desktop/tests/runtime-utils.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, test } from "vitest";
+import { resolveRepositoryContextScope } from "../src/bootstrap/runtime-utils";
+
+describe("repository context runtime scope", () => {
+ test("does not create a repository context for the native host shell", () => {
+ expect(
+ resolveRepositoryContextScope(
+ false,
+ null,
+ "active-runtime",
+ "active-tab",
+ ),
+ ).toBeNull();
+ });
+
+ test("uses the embedded tab identity for child runtimes", () => {
+ expect(
+ resolveRepositoryContextScope(
+ true,
+ "embedded-tab",
+ "active-runtime",
+ "active-tab",
+ ),
+ ).toBe("embedded-tab");
+ });
+
+ test("keeps the active-tab fallback for browser embedded mode", () => {
+ expect(resolveRepositoryContextScope(true, null, null, "active-tab")).toBe(
+ "active-tab",
+ );
+ });
+});
diff --git a/crates/git/cache.rs b/crates/git/cache.rs
index 8bd8e92e..514f15b0 100644
--- a/crates/git/cache.rs
+++ b/crates/git/cache.rs
@@ -63,6 +63,7 @@ struct RepoCacheState {
pub struct RepoCache {
generation: AtomicU64,
+ namespace_generations: Mutex>,
state: Mutex,
}
@@ -76,6 +77,7 @@ impl RepoCache {
pub fn new() -> Self {
Self {
generation: AtomicU64::new(0),
+ namespace_generations: Mutex::new(HashMap::new()),
state: Mutex::new(RepoCacheState::default()),
}
}
@@ -88,6 +90,28 @@ impl RepoCache {
}
}
+ pub fn invalidate_namespaces(&self, namespaces: &[&str]) {
+ if namespaces.is_empty() {
+ return;
+ }
+
+ if let Ok(mut generations) = self.namespace_generations.lock() {
+ for namespace in namespaces {
+ let generation = generations.entry((*namespace).to_string()).or_default();
+ *generation = generation.wrapping_add(1);
+ }
+ }
+
+ if let Ok(mut state) = self.state.lock() {
+ state
+ .entries
+ .retain(|key, _| !storage_key_matches_namespace(key, namespaces));
+ state
+ .inflight
+ .retain(|key, _| !storage_key_matches_namespace(key, namespaces));
+ }
+ }
+
pub async fn get_or_refresh(
&self,
policy: CachePolicy,
@@ -243,10 +267,22 @@ impl RepoCache {
fn build_storage_key(&self, namespace: &str, key: &str) -> String {
let generation = self.generation.load(Ordering::SeqCst);
- format!("{generation}:{namespace}:{key}")
+ let namespace_generation = self
+ .namespace_generations
+ .lock()
+ .map(|generations| generations.get(namespace).copied().unwrap_or_default())
+ .unwrap_or_default();
+ format!("{generation}:{namespace_generation}:{namespace}:{key}")
}
}
+fn storage_key_matches_namespace(storage_key: &str, namespaces: &[&str]) -> bool {
+ let Some(namespace) = storage_key.split(':').nth(2) else {
+ return false;
+ };
+ namespaces.contains(&namespace)
+}
+
#[cfg(test)]
mod tests {
use super::{CachePolicy, RepoCache};
@@ -254,6 +290,7 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::runtime::Builder;
+ use tokio::sync::Notify;
fn run_async(f: F)
where
@@ -463,4 +500,85 @@ mod tests {
assert_eq!(calls.load(Ordering::SeqCst), 2);
});
}
+
+ #[test]
+ fn cache_invalidate_namespaces_preserves_unaffected_entries() {
+ run_async(async {
+ let cache = RepoCache::new();
+ let affected = CachePolicy {
+ namespace: "affected",
+ ttl: Duration::from_secs(5),
+ };
+ let unaffected = CachePolicy {
+ namespace: "unaffected",
+ ttl: Duration::from_secs(5),
+ };
+
+ cache
+ .get_or_refresh(affected, "k".into(), || async { Ok::<_, String>(1) })
+ .await
+ .unwrap();
+ cache
+ .get_or_refresh(unaffected, "k".into(), || async { Ok::<_, String>(10) })
+ .await
+ .unwrap();
+ cache.invalidate_namespaces(&["affected"]);
+
+ let affected_value = cache
+ .get_or_refresh(affected, "k".into(), || async { Ok::<_, String>(2) })
+ .await
+ .unwrap();
+ let unaffected_value = cache
+ .get_or_refresh(unaffected, "k".into(), || async { Ok::<_, String>(20) })
+ .await
+ .unwrap();
+
+ assert_eq!(affected_value, 2);
+ assert_eq!(unaffected_value, 10);
+ });
+ }
+
+ #[test]
+ fn namespace_epoch_prevents_an_old_inflight_result_from_repopulating() {
+ run_async(async {
+ let cache = Arc::new(RepoCache::new());
+ let started = Arc::new(Notify::new());
+ let release = Arc::new(Notify::new());
+ let policy = CachePolicy {
+ namespace: "racy",
+ ttl: Duration::from_secs(5),
+ };
+
+ let old_request = {
+ let cache = cache.clone();
+ let started = started.clone();
+ let release = release.clone();
+ tokio::spawn(async move {
+ cache
+ .get_or_refresh(policy, "k".into(), move || async move {
+ started.notify_one();
+ release.notified().await;
+ Ok::<_, String>(1)
+ })
+ .await
+ })
+ };
+
+ started.notified().await;
+ cache.invalidate_namespaces(&["racy"]);
+ let fresh = cache
+ .get_or_refresh(policy, "k".into(), || async { Ok::<_, String>(2) })
+ .await
+ .unwrap();
+ release.notify_one();
+ assert_eq!(old_request.await.unwrap().unwrap(), 1);
+
+ let cached = cache
+ .get_or_refresh(policy, "k".into(), || async { Ok::<_, String>(3) })
+ .await
+ .unwrap();
+ assert_eq!(fresh, 2);
+ assert_eq!(cached, 2);
+ });
+ }
}
diff --git a/crates/git/context.rs b/crates/git/context.rs
index c0be3e44..125b90ca 100644
--- a/crates/git/context.rs
+++ b/crates/git/context.rs
@@ -1,7 +1,48 @@
+use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::{cache::RepoCache, runner::GitCommandRunner};
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RepositoryWatchPaths {
+ pub worktree: PathBuf,
+ pub git_dir: PathBuf,
+ pub common_dir: PathBuf,
+}
+
+impl RepositoryWatchPaths {
+ pub(crate) fn discover(repo_path: &str) -> Result {
+ let repository = open_worktree_repository(repo_path)?;
+ let worktree = repository
+ .workdir()
+ .expect("worktree repository validation must reject bare repositories");
+
+ Ok(Self {
+ worktree: canonicalize_watch_path(worktree)?,
+ git_dir: canonicalize_watch_path(repository.path())?,
+ common_dir: canonicalize_watch_path(repository.commondir())?,
+ })
+ }
+}
+
+fn open_worktree_repository(repo_path: &str) -> Result {
+ let repository = git2::Repository::open(repo_path)
+ .map_err(|error| format!("Failed to open Git repository: {error}"))?;
+ if repository.workdir().is_none() {
+ return Err("Bare Git repositories are not supported".to_string());
+ }
+ Ok(repository)
+}
+
+fn canonicalize_watch_path(path: &Path) -> Result {
+ path.canonicalize().map_err(|error| {
+ format!(
+ "Failed to resolve repository path {}: {error}",
+ path.display()
+ )
+ })
+}
+
pub struct RepoContext {
pub repo_path: String,
pub runner: GitCommandRunner,
@@ -20,3 +61,91 @@ impl RepoContext {
})
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{RepoContext, RepositoryWatchPaths};
+ use tempfile::tempdir;
+
+ #[test]
+ fn discovers_normal_repository_watch_paths() {
+ let directory = tempdir().expect("temp dir");
+ git2::Repository::init(directory.path()).expect("init repository");
+
+ let paths = RepositoryWatchPaths::discover(
+ directory
+ .path()
+ .to_str()
+ .expect("temporary path should be UTF-8"),
+ )
+ .expect("discover watch paths");
+
+ assert_eq!(paths.worktree, directory.path().canonicalize().unwrap());
+ assert_eq!(
+ paths.git_dir,
+ directory.path().join(".git").canonicalize().unwrap()
+ );
+ assert_eq!(paths.common_dir, paths.git_dir);
+ }
+
+ #[test]
+ fn discovers_linked_worktree_private_and_common_git_dirs() {
+ let directory = tempdir().expect("temp dir");
+ let main_path = directory.path().join("main");
+ let linked_path = directory.path().join("linked");
+ let repository = git2::Repository::init(&main_path).expect("init repository");
+ let tree_id = repository
+ .index()
+ .and_then(|mut index| index.write_tree())
+ .expect("write empty tree");
+ {
+ let tree = repository.find_tree(tree_id).expect("find empty tree");
+ let signature = git2::Signature::now("Gitru", "gitru@example.com").unwrap();
+ repository
+ .commit(
+ Some("HEAD"),
+ &signature,
+ &signature,
+ "Initial commit",
+ &tree,
+ &[],
+ )
+ .expect("create initial commit");
+ }
+ repository
+ .worktree("linked", &linked_path, None)
+ .expect("create linked worktree");
+
+ let paths = RepositoryWatchPaths::discover(linked_path.to_str().unwrap())
+ .expect("discover linked worktree paths");
+
+ assert_eq!(paths.worktree, linked_path.canonicalize().unwrap());
+ assert_ne!(paths.git_dir, paths.common_dir);
+ assert_eq!(
+ paths.common_dir,
+ main_path.join(".git").canonicalize().unwrap()
+ );
+ assert!(
+ paths
+ .git_dir
+ .starts_with(paths.common_dir.join("worktrees"))
+ );
+ assert!(linked_path.join(".git").is_file());
+ }
+
+ #[test]
+ fn watcher_path_failure_does_not_prevent_context_construction() {
+ let directory = tempdir().expect("temp dir");
+ git2::Repository::init(directory.path()).expect("init repository");
+ let repo_path = directory.path().to_str().unwrap();
+
+ std::fs::rename(
+ directory.path().join(".git"),
+ directory.path().join(".git-away"),
+ )
+ .expect("make watcher path unavailable");
+
+ let _context = RepoContext::new(repo_path).expect("construct repository context");
+ assert!(RepositoryWatchPaths::discover(repo_path).is_err());
+ }
+}
diff --git a/crates/git/core.rs b/crates/git/core.rs
index ab667bb9..7880d9f5 100644
--- a/crates/git/core.rs
+++ b/crates/git/core.rs
@@ -1,8 +1,10 @@
+use std::path::Path;
use std::sync::Arc;
use crate::{
AppState,
- context::RepoContext,
+ context::{RepoContext, RepositoryWatchPaths},
+ runner::GitRunOptions,
service::{
actions::ActionService, branch::BranchService, commit::CommitService, diff::DiffService,
history::HistoryService, operation::OperationService, origin::OriginService,
@@ -64,6 +66,61 @@ impl RepoServices {
pub fn rebase(&self) -> RebaseService {
RebaseService::new(self.ctx.clone())
}
+
+ pub fn watch_paths(&self) -> Result {
+ RepositoryWatchPaths::discover(&self.ctx.repo_path)
+ }
+
+ pub async fn validate_worktree(&self) -> Result<(), String> {
+ let top_level = self
+ .ctx
+ .runner
+ .run_with_options(
+ &["rev-parse", "--show-toplevel"],
+ GitRunOptions::default_read(),
+ )
+ .await?;
+ let selected_path = Path::new(&self.ctx.repo_path)
+ .canonicalize()
+ .map_err(|error| format!("Failed to resolve selected repository path: {error}"))?;
+ let top_level_path = Path::new(top_level.trim())
+ .canonicalize()
+ .map_err(|error| format!("Failed to resolve Git worktree root: {error}"))?;
+
+ if canonical_paths_equal(&selected_path, &top_level_path) {
+ Ok(())
+ } else {
+ Err(format!(
+ "Select the Git worktree root: {}",
+ top_level_path.display()
+ ))
+ }
+ }
+
+ pub fn invalidate_cache_namespaces(&self, namespaces: &[&str]) {
+ self.ctx.cache.invalidate_namespaces(namespaces);
+ }
+
+ pub fn invalidate_cache(&self) {
+ self.ctx.cache.invalidate_all();
+ }
+}
+
+#[cfg(not(windows))]
+fn canonical_paths_equal(left: &Path, right: &Path) -> bool {
+ left == right
+}
+
+#[cfg(windows)]
+fn canonical_paths_equal(left: &Path, right: &Path) -> bool {
+ fn normalized(path: &Path) -> String {
+ path.to_string_lossy()
+ .trim_start_matches(r"\\?\")
+ .replace('/', r"\")
+ .to_lowercase()
+ }
+
+ normalized(left) == normalized(right)
}
pub async fn get_services(
@@ -89,3 +146,59 @@ pub async fn remove_services(state: tauri::State<'_, AppState>, context_id: &str
let mut lock = state.services.write().await;
lock.remove(context_id).is_some()
}
+
+#[cfg(test)]
+mod tests {
+ use super::RepoServices;
+ use std::process::Command;
+ use tempfile::tempdir;
+
+ #[tokio::test]
+ async fn cli_supported_repository_formats_do_not_require_libgit2() {
+ let directory = tempdir().expect("temp dir");
+ let output = Command::new("git")
+ .args(["init", "--object-format=sha256"])
+ .arg(directory.path())
+ .output()
+ .expect("run git init");
+
+ // Older Git versions cannot create SHA-256 repositories. On newer
+ // versions this guards against making libgit2 watcher discovery a
+ // prerequisite for otherwise CLI-compatible repository services.
+ if !output.status.success() {
+ return;
+ }
+
+ let services = RepoServices::new(directory.path().to_str().unwrap())
+ .expect("construct CLI-backed services");
+ services
+ .validate_worktree()
+ .await
+ .expect("validate SHA-256 worktree with Git CLI");
+ }
+
+ #[tokio::test]
+ async fn worktree_validation_rejects_a_plain_directory() {
+ let directory = tempdir().expect("temp dir");
+ let services = RepoServices::new(directory.path().to_str().unwrap())
+ .expect("construct path-backed services");
+
+ assert!(services.validate_worktree().await.is_err());
+ }
+
+ #[tokio::test]
+ async fn worktree_validation_rejects_a_nested_directory() {
+ let directory = tempdir().expect("temp dir");
+ git2::Repository::init(directory.path()).expect("init repository");
+ let nested = directory.path().join("nested");
+ std::fs::create_dir(&nested).expect("create nested directory");
+ let services =
+ RepoServices::new(nested.to_str().unwrap()).expect("construct path-backed services");
+
+ let error = services
+ .validate_worktree()
+ .await
+ .expect_err("nested directory must not be stored as repository root");
+ assert!(error.contains("Select the Git worktree root"));
+ }
+}
diff --git a/crates/git/service/actions.rs b/crates/git/service/actions.rs
index 2133adc8..a8351f68 100644
--- a/crates/git/service/actions.rs
+++ b/crates/git/service/actions.rs
@@ -42,9 +42,17 @@ impl ActionService {
},
"porcelain_v2".to_string(),
move || async move {
+ // An optional index refresh would feed this read back into
+ // the repository watcher and trigger another status read.
let output = runner
.run_with_options(
- &["status", "--porcelain=v2", "--untracked-files=all", "-z"],
+ &[
+ "--no-optional-locks",
+ "status",
+ "--porcelain=v2",
+ "--untracked-files=all",
+ "-z",
+ ],
GitRunOptions::default_read(),
)
.await?;
diff --git a/crates/git/service/query.rs b/crates/git/service/query.rs
index f1763253..bdde0f0c 100644
--- a/crates/git/service/query.rs
+++ b/crates/git/service/query.rs
@@ -121,9 +121,17 @@ impl QueryService {
},
"porcelain".to_string(),
move || async move {
+ // An optional index refresh would feed this read back into
+ // the repository watcher and trigger another status read.
let output = runner
.run_with_options(
- &["status", "--porcelain", "-z", "--untracked-files=all"],
+ &[
+ "--no-optional-locks",
+ "status",
+ "--porcelain",
+ "-z",
+ "--untracked-files=all",
+ ],
GitRunOptions::default_read(),
)
.await?;
diff --git a/crates/git/tests/actions_service.rs b/crates/git/tests/actions_service.rs
index e1a4b8dd..0a7db6cc 100644
--- a/crates/git/tests/actions_service.rs
+++ b/crates/git/tests/actions_service.rs
@@ -10,6 +10,7 @@ use git::models::diff::{DiffScope, PatchAction, PatchRange};
use git::service::actions::ActionService;
use serial_test::serial;
use std::sync::Arc;
+use std::time::Duration;
fn setup_action_service(repo: &TestRepo) -> ActionService {
let ctx = Arc::new(RepoContext::new(repo.path_str()).expect("failed to create repo context"));
@@ -305,6 +306,30 @@ fn get_status_clean() {
});
}
+#[test]
+#[serial]
+fn get_status_does_not_refresh_the_index() {
+ run_async(async {
+ let repo = TestRepo::new();
+ repo.commit_file("README.md", "# Test", "Initial commit");
+ let index_path = repo.path().join(".git/index");
+ let index_before = std::fs::read(&index_path).unwrap();
+
+ // Rewriting identical contents after the filesystem timestamp advances
+ // makes the index stat cache stale without creating a worktree change.
+ // Background status queries must not rewrite the index, otherwise a
+ // repository watcher can invalidate and refetch status forever.
+ std::thread::sleep(Duration::from_millis(1_100));
+ repo.create_file("README.md", "# Test");
+
+ let service = setup_action_service(&repo);
+ let status = service.get_status().await.unwrap();
+
+ assert!(status.files.is_empty());
+ assert_eq!(std::fs::read(index_path).unwrap(), index_before);
+ });
+}
+
#[test]
#[serial]
fn get_status_untracked() {
diff --git a/crates/git/tests/branch_service.rs b/crates/git/tests/branch_service.rs
index 040ad8ae..1a03ec8c 100644
--- a/crates/git/tests/branch_service.rs
+++ b/crates/git/tests/branch_service.rs
@@ -11,6 +11,7 @@ use git::service::branch::BranchService;
use serial_test::serial;
use std::process::Command;
use std::sync::Arc;
+use std::time::Duration;
fn setup_branch_service(repo: &TestRepo) -> BranchService {
let ctx = Arc::new(RepoContext::new(repo.path_str()).expect("failed to create repo context"));
@@ -848,6 +849,29 @@ fn has_uncommitted_changes_false_when_clean() {
});
}
+#[test]
+#[serial]
+fn has_uncommitted_changes_does_not_refresh_the_index() {
+ run_async(async {
+ let repo = TestRepo::new();
+ repo.commit_file("README.md", "# Test", "Initial commit");
+ let index_path = repo.path().join(".git/index");
+ let index_before = std::fs::read(&index_path).unwrap();
+
+ // Keep contents clean while making the index's cached stat data stale.
+ // A background query that refreshes the index would feed its own write
+ // back into the repository watcher and trigger another query.
+ std::thread::sleep(Duration::from_millis(1_100));
+ repo.create_file("README.md", "# Test");
+
+ let service = setup_branch_service(&repo);
+ let has_changes = service.has_uncommitted_changes().await.unwrap();
+
+ assert!(!has_changes);
+ assert_eq!(std::fs::read(index_path).unwrap(), index_before);
+ });
+}
+
#[test]
#[serial]
fn has_uncommitted_changes_true_with_modified() {
diff --git a/crates/ipc/Cargo.toml b/crates/ipc/Cargo.toml
index ecbd3609..8f879d0a 100644
--- a/crates/ipc/Cargo.toml
+++ b/crates/ipc/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
-serde = "1.0.225"
+serde = { version = "1.0.225", features = ["derive"] }
serde_json = "1.0.145"
tauri = { version = "2", features = [] }
tauri-plugin-store = "2"
@@ -12,4 +12,9 @@ uuid = { version = "1", features = ["v4"] }
logger = { path = "../logger" }
log = "0.4"
git = { path = "../git" }
-tokio = { version = "1", features = ["sync"] }
+notify = "8.2.0"
+tokio = { version = "1", features = ["macros", "rt", "sync", "time"] }
+
+[dev-dependencies]
+tempfile = "3"
+tokio = { version = "1", features = ["macros", "rt", "sync", "test-util", "time"] }
diff --git a/crates/ipc/src/commands.rs b/crates/ipc/src/commands.rs
index 583e007d..b55310c5 100644
--- a/crates/ipc/src/commands.rs
+++ b/crates/ipc/src/commands.rs
@@ -14,6 +14,10 @@ use tauri::Emitter;
use uuid::Uuid;
use crate::repo_manager::{RepoManager, RepositoryInfo, SELECTED_REPO_KEY};
+use crate::repository_watcher::{
+ REPOSITORY_CHANGED_EVENT, RepoContextRuntime, RepositoryChangedEvent, RepositoryWatcher,
+ cache_namespaces_for_changes,
+};
use crate::session_manager::{SessionManager, SessionNavigationInfo};
#[derive(Serialize)]
@@ -41,49 +45,38 @@ pub async fn add_local_git_repo(repo_path: String) -> Result