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 path from 'path';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
@ -691,10 +689,6 @@ export class TeamDataService {
);
mark('resolveMembers');
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
mark('enrichBranches');
mark('syncComments');
let processes: TeamProcess[] = [];
@ -714,9 +708,9 @@ export class TeamDataService {
'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc'
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince(
'enrichBranches'
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}`
)} resolveMembers=${msSince('resolveMembers')} 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.
* Members can appear in the UI from three sources (see TeamMemberResolver):

View file

@ -771,17 +771,69 @@ export const TeamDetailView = ({
};
}, [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 branchSyncPaths = useMemo(
() => (teamProjectPath ? [teamProjectPath] : []),
[teamProjectPath]
);
// Live branch sync now uses main-side background tracking instead of renderer polling.
const leadProjectPath = useMemo(() => {
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
}, [data?.members, teamProjectPath]);
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 });
const leadBranch = useStore((s) =>
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
const trackedBranches = useStore(
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
const teamSessionIds = useMemo(() => {
@ -852,7 +904,7 @@ export const TeamDetailView = ({
return result;
}, [data, timeWindow, kanbanFilter.selectedOwners]);
const activeMembers = useStableActiveMembers(data?.members);
const activeMembers = useStableActiveMembers(membersWithLiveBranches);
const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim();
@ -985,12 +1037,12 @@ export const TeamDetailView = ({
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
useEffect(() => {
if (!pendingMemberProfile || !data) return;
const member = data.members.find((m) => m.name === pendingMemberProfile);
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
if (member) {
setSelectedMember(member);
}
useStore.getState().closeMemberProfile();
}, [pendingMemberProfile, data]);
}, [pendingMemberProfile, membersWithLiveBranches]);
const handleDeleteTask = useCallback(
(taskId: string) => {
@ -1608,7 +1660,7 @@ export const TeamDetailView = ({
}
>
<MemberList
members={data.members}
members={membersWithLiveBranches}
memberTaskCounts={memberTaskCounts}
taskMap={taskMap}
pendingRepliesByMember={pendingRepliesByMember}
@ -1975,7 +2027,7 @@ export const TeamDetailView = ({
currentName={data.config.name}
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
currentMembers={data.members.filter((m) => !isLeadMember(m))}
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)}
@ -1984,8 +2036,8 @@ export const TeamDetailView = ({
<AddMemberDialog
open={addMemberDialogOpen}
teamName={teamName}
existingNames={data.members.map((m) => m.name)}
existingMembers={data.members}
existingNames={membersWithLiveBranches.map((m) => m.name)}
existingMembers={membersWithLiveBranches}
projectPath={data.config.projectPath}
adding={addingMemberLoading}
onClose={() => setAddMemberDialogOpen(false)}
@ -2068,7 +2120,7 @@ export const TeamDetailView = ({
mode="launch"
open={launchDialogOpen}
teamName={teamName}
members={data?.members ?? []}
members={membersWithLiveBranches}
defaultProjectPath={data.config.projectPath}
provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}