feat(conflicts): add bash write detection, dismiss/acknowledge, and worktree suppression
This commit is contained in:
parent
05191127ea
commit
38b8447ae6
8 changed files with 354 additions and 24 deletions
|
|
@ -31,8 +31,29 @@ interface FileWriteEntry {
|
|||
// projectId -> filePath -> FileWriteEntry
|
||||
let projectFileWrites = $state<Map<string, Map<string, FileWriteEntry>>>(new Map());
|
||||
|
||||
// projectId -> set of acknowledged file paths (suppresses badge until new conflict on that file)
|
||||
let acknowledgedFiles = $state<Map<string, Set<string>>>(new Map());
|
||||
|
||||
// sessionId -> worktree path (null = main working tree)
|
||||
let sessionWorktrees = $state<Map<string, string | null>>(new Map());
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/** Register the worktree path for a session (null = main working tree) */
|
||||
export function setSessionWorktree(sessionId: string, worktreePath: string | null): void {
|
||||
sessionWorktrees.set(sessionId, worktreePath ?? null);
|
||||
}
|
||||
|
||||
/** Check if two sessions are in different worktrees (conflict suppression) */
|
||||
function areInDifferentWorktrees(sessionIdA: string, sessionIdB: string): boolean {
|
||||
const wtA = sessionWorktrees.get(sessionIdA) ?? null;
|
||||
const wtB = sessionWorktrees.get(sessionIdB) ?? null;
|
||||
// Both null = same main tree, both same string = same worktree → not different
|
||||
if (wtA === wtB) return false;
|
||||
// One or both non-null and different → different worktrees
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Record that a session wrote to a file. Returns true if this creates a new conflict. */
|
||||
export function recordFileWrite(projectId: string, sessionId: string, filePath: string): boolean {
|
||||
let projectMap = projectFileWrites.get(projectId);
|
||||
|
|
@ -42,7 +63,7 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
|
|||
}
|
||||
|
||||
let entry = projectMap.get(filePath);
|
||||
const hadConflict = entry ? entry.sessionIds.size >= 2 : false;
|
||||
const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false;
|
||||
|
||||
if (!entry) {
|
||||
entry = { sessionIds: new Set([sessionId]), lastWriteTs: Date.now() };
|
||||
|
|
@ -50,21 +71,46 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
|
|||
return false;
|
||||
}
|
||||
|
||||
const isNewSession = !entry.sessionIds.has(sessionId);
|
||||
entry.sessionIds.add(sessionId);
|
||||
entry.lastWriteTs = Date.now();
|
||||
|
||||
// New conflict = we just went from 1 session to 2+
|
||||
return !hadConflict && entry.sessionIds.size >= 2;
|
||||
// Check if this is a real conflict (not suppressed by worktrees)
|
||||
const realConflictCount = countRealConflictSessions(entry, sessionId);
|
||||
const isNewConflict = !hadConflict && realConflictCount >= 2;
|
||||
|
||||
// Clear acknowledgement when a new session writes to a previously-acknowledged file
|
||||
if (isNewSession && realConflictCount >= 2) {
|
||||
const ackSet = acknowledgedFiles.get(projectId);
|
||||
if (ackSet) ackSet.delete(filePath);
|
||||
}
|
||||
|
||||
return isNewConflict;
|
||||
}
|
||||
|
||||
/** Get all conflicts for a project */
|
||||
/**
|
||||
* Count sessions that are in a real conflict with the given session
|
||||
* (same worktree or both in main tree). Returns total including the session itself.
|
||||
*/
|
||||
function countRealConflictSessions(entry: FileWriteEntry, forSessionId: string): number {
|
||||
let count = 0;
|
||||
for (const sid of entry.sessionIds) {
|
||||
if (sid === forSessionId || !areInDifferentWorktrees(sid, forSessionId)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Get all conflicts for a project (excludes acknowledged and worktree-suppressed) */
|
||||
export function getProjectConflicts(projectId: string): ProjectConflicts {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return { projectId, conflicts: [], conflictCount: 0 };
|
||||
|
||||
const ackSet = acknowledgedFiles.get(projectId);
|
||||
const conflicts: FileConflict[] = [];
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (entry.sessionIds.size >= 2) {
|
||||
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) {
|
||||
conflicts.push({
|
||||
filePath,
|
||||
shortName: filePath.split('/').pop() ?? filePath,
|
||||
|
|
@ -79,27 +125,56 @@ export function getProjectConflicts(projectId: string): ProjectConflicts {
|
|||
return { projectId, conflicts, conflictCount: conflicts.length };
|
||||
}
|
||||
|
||||
/** Check if a project has any file conflicts */
|
||||
/** Check if a project has any unacknowledged real conflicts */
|
||||
export function hasConflicts(projectId: string): boolean {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return false;
|
||||
for (const entry of projectMap.values()) {
|
||||
if (entry.sessionIds.size >= 2) return true;
|
||||
const ackSet = acknowledgedFiles.get(projectId);
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Get total conflict count across all projects */
|
||||
/** Get total unacknowledged conflict count across all projects */
|
||||
export function getTotalConflictCount(): number {
|
||||
let total = 0;
|
||||
for (const projectMap of projectFileWrites.values()) {
|
||||
for (const entry of projectMap.values()) {
|
||||
if (entry.sessionIds.size >= 2) total++;
|
||||
for (const [projectId, projectMap] of projectFileWrites) {
|
||||
const ackSet = acknowledgedFiles.get(projectId);
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) total++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Check if a file write entry has a real conflict (2+ sessions in same worktree) */
|
||||
function hasRealConflict(entry: FileWriteEntry): boolean {
|
||||
if (entry.sessionIds.size < 2) return false;
|
||||
// Check all pairs for same-worktree conflict
|
||||
const sids = Array.from(entry.sessionIds);
|
||||
for (let i = 0; i < sids.length; i++) {
|
||||
for (let j = i + 1; j < sids.length; j++) {
|
||||
if (!areInDifferentWorktrees(sids[i], sids[j])) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Acknowledge all current conflicts for a project (suppresses badge until new conflict) */
|
||||
export function acknowledgeConflicts(projectId: string): void {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return;
|
||||
|
||||
const ackSet = acknowledgedFiles.get(projectId) ?? new Set();
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (hasRealConflict(entry)) {
|
||||
ackSet.add(filePath);
|
||||
}
|
||||
}
|
||||
acknowledgedFiles.set(projectId, ackSet);
|
||||
}
|
||||
|
||||
/** Remove a session from all file write tracking (call on session end) */
|
||||
export function clearSessionWrites(projectId: string, sessionId: string): void {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
|
|
@ -114,15 +189,22 @@ export function clearSessionWrites(projectId: string, sessionId: string): void {
|
|||
|
||||
if (projectMap.size === 0) {
|
||||
projectFileWrites.delete(projectId);
|
||||
acknowledgedFiles.delete(projectId);
|
||||
}
|
||||
|
||||
// Clean up worktree tracking
|
||||
sessionWorktrees.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Clear all conflict tracking for a project */
|
||||
export function clearProjectConflicts(projectId: string): void {
|
||||
projectFileWrites.delete(projectId);
|
||||
acknowledgedFiles.delete(projectId);
|
||||
}
|
||||
|
||||
/** Clear all conflict state */
|
||||
export function clearAllConflicts(): void {
|
||||
projectFileWrites = new Map();
|
||||
acknowledgedFiles = new Map();
|
||||
sessionWorktrees = new Map();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
clearSessionWrites,
|
||||
clearProjectConflicts,
|
||||
clearAllConflicts,
|
||||
acknowledgeConflicts,
|
||||
setSessionWorktree,
|
||||
} from './conflicts.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -154,4 +156,83 @@ describe('conflicts store', () => {
|
|||
expect(getTotalConflictCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acknowledgeConflicts', () => {
|
||||
it('suppresses conflict from counts after acknowledge', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
acknowledgeConflicts('proj-1');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
expect(getTotalConflictCount()).toBe(0);
|
||||
expect(getProjectConflicts('proj-1').conflictCount).toBe(0);
|
||||
});
|
||||
|
||||
it('resurfaces conflict when new write arrives on acknowledged file', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
acknowledgeConflicts('proj-1');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
// Third session writes same file — should resurface
|
||||
recordFileWrite('proj-1', 'sess-c', '/a.ts');
|
||||
// recordFileWrite returns false for already-conflicted file, but the ack should be cleared
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('no-ops for unknown project', () => {
|
||||
acknowledgeConflicts('nonexistent'); // Should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktree suppression', () => {
|
||||
it('suppresses conflict between sessions in different worktrees', () => {
|
||||
setSessionWorktree('sess-a', null); // main tree
|
||||
setSessionWorktree('sess-b', '/tmp/wt-1'); // worktree
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
expect(getTotalConflictCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('detects conflict between sessions in same worktree', () => {
|
||||
setSessionWorktree('sess-a', '/tmp/wt-1');
|
||||
setSessionWorktree('sess-b', '/tmp/wt-1');
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects conflict between sessions both in main tree', () => {
|
||||
setSessionWorktree('sess-a', null);
|
||||
setSessionWorktree('sess-b', null);
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses conflict when two worktrees differ', () => {
|
||||
setSessionWorktree('sess-a', '/tmp/wt-1');
|
||||
setSessionWorktree('sess-b', '/tmp/wt-2');
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('sessions without worktree info conflict normally (backward compat)', () => {
|
||||
// No setSessionWorktree calls — both default to null (main tree)
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('clearSessionWrites cleans up worktree tracking', () => {
|
||||
setSessionWorktree('sess-a', '/tmp/wt-1');
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
clearSessionWrites('proj-1', 'sess-a');
|
||||
// Subsequent session in main tree should not be compared against stale wt data
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-c', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue