fix(ui): hide transient dirty state while a worktree bootstraps
A freshly created worktree transiently looks dirty until its setup commands and initial git reset finish. The work status panel showed those files as changes on the branch and the draft's worktree dropdown flashed its dirty warning, and both then froze on that state because nothing refetched after bootstrap. Make the bootstrap state (the existing authority on unfinished creation) subscribable, suppress the dirty probe and the changed-files row while it is pending, and force one status fetch when it settles so the lifted gate shows the reset tree.
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending';
|
||||
import { formatDirectoryName } from '@/lib/utils';
|
||||
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -191,6 +192,13 @@ export function useDraftTarget(enabled: boolean) {
|
||||
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
|
||||
);
|
||||
|
||||
// The draft's own pending flags clear once the directory exists, which is
|
||||
// before setup commands and the initial git reset finish; the bootstrap
|
||||
// state covers that remaining window (and creations the draft never knew
|
||||
// about, such as the New Worktree dialog), so the probe never reads the
|
||||
// transient bootstrap files as the branch being dirty.
|
||||
const selectedDraftDirectoryBootstrapPending = useWorktreeBootstrapPending(selectedDraftDirectory);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!enabled
|
||||
@@ -198,6 +206,7 @@ export function useDraftTarget(enabled: boolean) {
|
||||
|| selectedDraftProject?.kind === 'chat'
|
||||
|| newSessionDraft?.pendingWorktreeRequestId
|
||||
|| newSessionDraft?.bootstrapPendingDirectory
|
||||
|| selectedDraftDirectoryBootstrapPending
|
||||
) {
|
||||
setDirtyDraftDirectory(null);
|
||||
return;
|
||||
@@ -218,7 +227,7 @@ export function useDraftTarget(enabled: boolean) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]);
|
||||
}, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftDirectoryBootstrapPending, selectedDraftProject?.kind]);
|
||||
|
||||
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
|
||||
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
|
||||
|
||||
@@ -179,6 +179,13 @@ including edits the user made by hand and excluding session edits that are
|
||||
already committed. If a session-authored count is ever needed, it has to come
|
||||
from aggregating message summaries, not from `Session.summary`.
|
||||
|
||||
One exception: while the directory is a worktree whose creation has not
|
||||
finished (`useWorktreeBootstrapPending`), the working tree transiently holds
|
||||
bootstrap files that the initial git reset is about to remove. Those are not
|
||||
changes on the branch, so the panel neither fetches status nor renders the
|
||||
changed-files row until the bootstrap settles, then forces one status fetch so
|
||||
the row reflects the reset tree rather than a mid-creation snapshot.
|
||||
|
||||
## Section order
|
||||
|
||||
Ordering is by durability, not category:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
|
||||
import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending';
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
@@ -67,12 +68,34 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
|
||||
),
|
||||
);
|
||||
|
||||
// A worktree that is still being created transiently looks dirty until its
|
||||
// setup commands and initial git reset finish. Those files are not changes
|
||||
// on the branch, so status is neither fetched nor displayed until the
|
||||
// bootstrap settles.
|
||||
const worktreeCreationPending = useWorktreeBootstrapPending(gitDirectory);
|
||||
const awaitingPostBootstrapStatusRef = React.useRef<string | null>(null);
|
||||
|
||||
// Warm the shared git cache through the background-network gate so the panel
|
||||
// never competes with the chat's own bootstrap traffic for sockets.
|
||||
React.useEffect(() => {
|
||||
if (!showRepository || !gitDirectory || !git) return;
|
||||
void runBackgroundNetworkTask(() => ensureStatus(gitDirectory, git));
|
||||
}, [gitDirectory, git, ensureStatus, showRepository]);
|
||||
if (worktreeCreationPending) {
|
||||
awaitingPostBootstrapStatusRef.current = gitDirectory;
|
||||
return;
|
||||
}
|
||||
// Right after bootstrap the cache may still hold a status captured
|
||||
// mid-creation; force one fetch so the lifted gate reveals the real
|
||||
// (reset) working tree instead of the transient one.
|
||||
const finishedBootstrap = awaitingPostBootstrapStatusRef.current === gitDirectory;
|
||||
awaitingPostBootstrapStatusRef.current = null;
|
||||
void runBackgroundNetworkTask(async () => {
|
||||
if (finishedBootstrap) {
|
||||
await fetchStatus(gitDirectory, git, { silent: true });
|
||||
} else {
|
||||
await ensureStatus(gitDirectory, git);
|
||||
}
|
||||
});
|
||||
}, [gitDirectory, git, ensureStatus, fetchStatus, showRepository, worktreeCreationPending]);
|
||||
|
||||
// Own the live invalidation for the repository readout. The desktop
|
||||
// composer's changed-files row no longer renders, so this panel must not
|
||||
@@ -175,6 +198,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
|
||||
// event is reset to an empty array too, and carries real content only on
|
||||
// revert. Git status is the one authoritative, already-cached answer.
|
||||
const changed = React.useMemo(() => {
|
||||
if (worktreeCreationPending) return null;
|
||||
const files = gitStatus?.files ?? [];
|
||||
if (files.length === 0) return null;
|
||||
const stats = gitStatus?.diffStats;
|
||||
@@ -187,7 +211,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
|
||||
}
|
||||
}
|
||||
return { files: files.length, additions, deletions, hasStats: Boolean(stats) };
|
||||
}, [gitStatus?.files, gitStatus?.diffStats]);
|
||||
}, [gitStatus?.files, gitStatus?.diffStats, worktreeCreationPending]);
|
||||
|
||||
const attentionReason = gitStatus?.attentionReason
|
||||
?? (gitStatus?.rebaseInProgress ? 'rebase' : null)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getWorktreeBootstrapState, subscribeWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap';
|
||||
|
||||
/**
|
||||
* Whether `directory` is a worktree whose creation has not finished yet: the
|
||||
* directory exists, but setup commands and the initial git reset are still
|
||||
* running. Until that completes the working tree transiently looks dirty, so
|
||||
* surfaces that report uncommitted changes consult this and show nothing
|
||||
* instead of presenting bootstrap noise as real changes on the branch.
|
||||
*/
|
||||
export const useWorktreeBootstrapPending = (directory: string | null): boolean => {
|
||||
const getSnapshot = React.useCallback(
|
||||
() => (directory ? getWorktreeBootstrapState(directory)?.status === 'pending' : false),
|
||||
[directory],
|
||||
);
|
||||
return React.useSyncExternalStore(subscribeWorktreeBootstrapState, getSnapshot, getSnapshot);
|
||||
};
|
||||
@@ -51,6 +51,7 @@ const {
|
||||
markWorktreeBootstrapPending,
|
||||
setWorktreeBootstrapState,
|
||||
startWorktreeBootstrapWatcher,
|
||||
subscribeWorktreeBootstrapState,
|
||||
waitForWorktreeBootstrap,
|
||||
waitForWorktreeGitReady,
|
||||
} = await import('./worktreeBootstrap');
|
||||
@@ -246,3 +247,39 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktreeBootstrap subscription', () => {
|
||||
beforeEach(() => {
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
});
|
||||
|
||||
test('notifies subscribers as a directory enters and leaves bootstrap', () => {
|
||||
const pendingSnapshots: boolean[] = [];
|
||||
const unsubscribe = subscribeWorktreeBootstrapState(() => {
|
||||
pendingSnapshots.push(getWorktreeBootstrapState('/repo-wt')?.status === 'pending');
|
||||
});
|
||||
|
||||
try {
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
setWorktreeBootstrapState('/repo-wt', { status: 'ready', error: null, updatedAt: 2 });
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(pendingSnapshots).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
test('stops notifying after unsubscribe', () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = subscribeWorktreeBootstrapState(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
unsubscribe();
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
|
||||
expect(notifications).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,23 @@ const watchers = new Map<string, { cancelled: boolean; lifecycleVersion: number
|
||||
const getKey = (directory: string): string => normalizePath(directory);
|
||||
const getWaiterKey = (key: string, target: WorktreeBootstrapTarget): string => `${key}\n${target}`;
|
||||
|
||||
// UI surfaces subscribe to know when a directory enters or leaves bootstrap,
|
||||
// so a half-created worktree's transient files are never shown as changes.
|
||||
const bootstrapListeners = new Set<() => void>();
|
||||
|
||||
const notifyBootstrapListeners = (): void => {
|
||||
for (const listener of bootstrapListeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeWorktreeBootstrapState = (listener: () => void): (() => void) => {
|
||||
bootstrapListeners.add(listener);
|
||||
return () => {
|
||||
bootstrapListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
const startLifecycle = (key: string): void => {
|
||||
const watcher = watchers.get(key);
|
||||
if (watcher) {
|
||||
@@ -72,6 +89,7 @@ const storePolledState = (
|
||||
}
|
||||
|
||||
state.set(key, next);
|
||||
notifyBootstrapListeners();
|
||||
return next;
|
||||
};
|
||||
|
||||
@@ -98,6 +116,7 @@ export const markWorktreeBootstrapPending = (directory: string): void => {
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
notifyBootstrapListeners();
|
||||
};
|
||||
|
||||
export const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
@@ -108,6 +127,7 @@ export const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
startLifecycle(key);
|
||||
state.delete(key);
|
||||
lifecycleVersions.delete(key);
|
||||
notifyBootstrapListeners();
|
||||
};
|
||||
|
||||
export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => {
|
||||
@@ -117,6 +137,7 @@ export const setWorktreeBootstrapState = (directory: string, next: WorktreeBoots
|
||||
}
|
||||
startLifecycle(key);
|
||||
state.set(key, next);
|
||||
notifyBootstrapListeners();
|
||||
};
|
||||
|
||||
export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {
|
||||
|
||||
Reference in New Issue
Block a user