refactor: remove enrichMemberBranches method and streamline branch tracking

- Eliminated the enrichMemberBranches method from TeamDataService to simplify member branch enrichment logic.
- Updated TeamDetailView to utilize live branch tracking for both lead and member worktrees, enhancing the accuracy of displayed member branches.
- Adjusted various references to ensure membersWithLiveBranches is used consistently across the component.
This commit is contained in:
iliya 2026-04-01 19:11:51 +03:00
parent 21513bb6f8
commit 1b3bd8752b
2 changed files with 71 additions and 87 deletions

View file

@ -27,8 +27,6 @@ import { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { atomicWriteAsync } from './atomicWrite'; import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
@ -691,10 +689,6 @@ export class TeamDataService {
); );
mark('resolveMembers'); mark('resolveMembers');
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
mark('enrichBranches');
mark('syncComments'); mark('syncComments');
let processes: TeamProcess[] = []; let processes: TeamProcess[] = [];
@ -714,9 +708,9 @@ export class TeamDataService {
'sentMessages' 'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc' 'kanbanGc'
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince( )} resolveMembers=${msSince('resolveMembers')} syncComments=${msSince('syncComments')} processes=${msSince(
'enrichBranches' 'processes'
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}` )}`
); );
} }
@ -804,68 +798,6 @@ export class TeamDataService {
} }
} }
/**
* Enriches members with gitBranch when their cwd differs from the lead's.
* Mutates members in-place for efficiency (called right after resolveMembers).
*/
private async enrichMemberBranches(
members: ResolvedTeamMember[],
config: TeamConfig
): Promise<void> {
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
const leadEntry = config.members?.find((m) => isLeadMember(m));
const leadCwd = leadEntry?.cwd ?? config.projectPath;
if (!leadCwd) return;
const withTimeout = async <T>(p: Promise<T>, ms: number): Promise<T> => {
let timer: NodeJS.Timeout | null = null;
try {
return await Promise.race([
p,
new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('timeout')), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
};
let leadBranch: string | null = null;
try {
// Git can hang on some Windows setups (network drives, locked repos, credential prompts).
// Branch is best-effort; never block team:getData on it.
leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000);
} catch {
// Lead cwd may not be a git repo — skip enrichment entirely
return;
}
const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
if (candidates.length === 0) return;
const concurrency = process.platform === 'win32' ? 4 : 8;
for (let i = 0; i < candidates.length; i += concurrency) {
const batch = candidates.slice(i, i + concurrency);
await Promise.all(
batch.map(async (member) => {
if (!member.cwd) return;
try {
const branch = await withTimeout(
gitIdentityResolver.getBranch(path.normalize(member.cwd)),
2000
);
if (branch && branch !== leadBranch) {
member.gitBranch = branch;
}
} catch {
// Member cwd may not be a git repo — skip silently
}
})
);
}
}
/** /**
* Ensures a member exists in members.meta.json. * Ensures a member exists in members.meta.json.
* Members can appear in the UI from three sources (see TeamMemberResolver): * Members can appear in the UI from three sources (see TeamMemberResolver):

View file

@ -771,17 +771,69 @@ export const TeamDetailView = ({
}; };
}, [projectId]); }, [projectId]);
// Live git branch polling for the team's project path // Live git branch tracking for the lead project and member worktrees
const teamProjectPath = data?.config.projectPath?.trim() ?? null; const teamProjectPath = data?.config.projectPath?.trim() ?? null;
const branchSyncPaths = useMemo( const leadProjectPath = useMemo(() => {
() => (teamProjectPath ? [teamProjectPath] : []), const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
[teamProjectPath] return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
); }, [data?.members, teamProjectPath]);
// Live branch sync now uses main-side background tracking instead of renderer polling. const branchSyncPaths = useMemo(() => {
const uniquePaths = new Map<string, string>();
const addPath = (candidate: string | null | undefined): void => {
const trimmed = candidate?.trim();
if (!trimmed) return;
const key = normalizePath(trimmed);
if (!key || uniquePaths.has(key)) return;
uniquePaths.set(key, trimmed);
};
addPath(leadProjectPath);
for (const member of data?.members ?? []) {
addPath(member.cwd);
}
return Array.from(uniquePaths.values());
}, [data?.members, leadProjectPath]);
useBranchSync(branchSyncPaths, { live: true }); useBranchSync(branchSyncPaths, { live: true });
const leadBranch = useStore((s) => const trackedBranches = useStore(
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null useShallow((s) =>
Object.fromEntries(
branchSyncPaths.map((projectPath) => {
const normalizedPath = normalizePath(projectPath);
return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const;
})
)
)
); );
const leadBranch = leadProjectPath
? (trackedBranches[normalizePath(leadProjectPath)] ?? null)
: null;
const membersWithLiveBranches = useMemo(() => {
if (!data) return [];
return data.members.map((member) => {
const memberPath = member.cwd?.trim();
const nextGitBranch =
memberPath && !isLeadMember(member) && leadBranch !== null
? (() => {
const branch = trackedBranches[normalizePath(memberPath)] ?? null;
return branch && branch !== leadBranch ? branch : undefined;
})()
: undefined;
if (member.gitBranch === nextGitBranch) {
return member;
}
const nextMember: ResolvedTeamMember = { ...member };
if (nextGitBranch) {
nextMember.gitBranch = nextGitBranch;
} else {
delete nextMember.gitBranch;
}
return nextMember;
});
}, [data, leadBranch, trackedBranches]);
// Filter sessions to team-only using sessionHistory + leadSessionId // Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessionIds = useMemo(() => { const teamSessionIds = useMemo(() => {
@ -852,7 +904,7 @@ export const TeamDetailView = ({
return result; return result;
}, [data, timeWindow, kanbanFilter.selectedOwners]); }, [data, timeWindow, kanbanFilter.selectedOwners]);
const activeMembers = useStableActiveMembers(data?.members); const activeMembers = useStableActiveMembers(membersWithLiveBranches);
const kanbanDisplayTasks = useMemo(() => { const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim(); const query = kanbanSearch.trim();
@ -985,12 +1037,12 @@ export const TeamDetailView = ({
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
useEffect(() => { useEffect(() => {
if (!pendingMemberProfile || !data) return; if (!pendingMemberProfile || !data) return;
const member = data.members.find((m) => m.name === pendingMemberProfile); const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
if (member) { if (member) {
setSelectedMember(member); setSelectedMember(member);
} }
useStore.getState().closeMemberProfile(); useStore.getState().closeMemberProfile();
}, [pendingMemberProfile, data]); }, [pendingMemberProfile, membersWithLiveBranches]);
const handleDeleteTask = useCallback( const handleDeleteTask = useCallback(
(taskId: string) => { (taskId: string) => {
@ -1608,7 +1660,7 @@ export const TeamDetailView = ({
} }
> >
<MemberList <MemberList
members={data.members} members={membersWithLiveBranches}
memberTaskCounts={memberTaskCounts} memberTaskCounts={memberTaskCounts}
taskMap={taskMap} taskMap={taskMap}
pendingRepliesByMember={pendingRepliesByMember} pendingRepliesByMember={pendingRepliesByMember}
@ -1975,7 +2027,7 @@ export const TeamDetailView = ({
currentName={data.config.name} currentName={data.config.name}
currentDescription={data.config.description ?? ''} currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''} currentColor={data.config.color ?? ''}
currentMembers={data.members.filter((m) => !isLeadMember(m))} currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
projectPath={data.config.projectPath} projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)} onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)} onSaved={() => void selectTeam(teamName)}
@ -1984,8 +2036,8 @@ export const TeamDetailView = ({
<AddMemberDialog <AddMemberDialog
open={addMemberDialogOpen} open={addMemberDialogOpen}
teamName={teamName} teamName={teamName}
existingNames={data.members.map((m) => m.name)} existingNames={membersWithLiveBranches.map((m) => m.name)}
existingMembers={data.members} existingMembers={membersWithLiveBranches}
projectPath={data.config.projectPath} projectPath={data.config.projectPath}
adding={addingMemberLoading} adding={addingMemberLoading}
onClose={() => setAddMemberDialogOpen(false)} onClose={() => setAddMemberDialogOpen(false)}
@ -2068,7 +2120,7 @@ export const TeamDetailView = ({
mode="launch" mode="launch"
open={launchDialogOpen} open={launchDialogOpen}
teamName={teamName} teamName={teamName}
members={data?.members ?? []} members={membersWithLiveBranches}
defaultProjectPath={data.config.projectPath} defaultProjectPath={data.config.projectPath}
provisioningError={provisioningError} provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError} clearProvisioningError={clearProvisioningError}