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:
Iuliia Ivashko
2026-09-04 02:35:56 +03:00
parent 0a47b306d6
commit acdf0c2e1f
6 changed files with 120 additions and 4 deletions
@@ -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 => {