Files
kandev/apps/web/lib/group-by-repo.ts
João Salavisa 5c5e7bf6a7 fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo (#816)
* fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo

Two bugs in the COMMITS panel made it surface commits and file changes
that were already on main:

1. The commit list and the cumulative diff both anchored to a stale base.
   Commit list used `git merge-base HEAD <local-branch>` and the file diff
   used the session's stored base SHA captured once at session start. On
   long-lived worktrees the local main ref lags far behind origin/main, so
   the merge-base lands on an old SHA; once the agent merges main back in,
   the stored base is also stale. Both end up sweeping in dozens of main
   commits as if they were the branch's work.

2. `git log <base>..HEAD` follows both parents of a merge commit by
   default, surfacing main commits brought in via merges from main.

Fixes:
- agentctl computes merge-base against `origin/<target_branch>` (with a
  local-ref fallback for unfetched/local-only branches), and uses that
  for both the commit log and the cumulative diff. Mirrors GitHub's
  three-dot PR diff semantics — anchor to live divergence with the
  upstream, not a session-start snapshot.
- `git log` now passes `--first-parent` so only the branch's own line of
  work plus its merge commits show up; commits brought in via merges from
  main are excluded.

Frontend: drop the per-repo "REPOSITORY" sub-header in the COMMITS panel
for single-repo workspaces. CommitsRepoGroup gains a `showHeader` prop;
when the workspace has one untagged group the section renders flat with
the per-repo Push / PR (and Stage all / Commit / Unstage all) buttons
lifted into the section header. Multi-repo rendering unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review feedback

- Scope --first-parent to the divergence-range path only (Greptile P1).
  The flag is wrong for open-ended history calls — future activity
  widgets etc. need the full graph.
- Log empty merge-base case in cumulative diff (Greptile P2). Silent
  fallback to stored base was invisible during diagnostics.
- Rename TestRunGitCumulativeDiffForRepo_PrefersOriginRef →
  TestComputeMergeBase_CorrectAnchorForCumulativeDiff (Claude). Test
  exercises computeMergeBase, not the runGitCumulativeDiffForRepo
  conditional; new name reflects what it actually proves.
- Add TestGetLog_NoBaseReturnsFullGraph to lock in the --first-parent
  scoping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review round 2

- Drop dead ActionButtonsSection from changes-panel-timeline.tsx (unused
  export, no consumers anywhere). Brings the file from 640 → 516 lines,
  comfortably under the 600-line TS limit Claude flagged.
- Extract isSingleRepoGroup helper into lib/group-by-repo.ts and use it
  in all three sites (CommitsSection, FileListBody, FileListSection) so
  single-repo detection has one source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:28:04 +01:00

38 lines
1.5 KiB
TypeScript

/**
* Buckets a list into per-repository groups, preserving the original item
* order within each bucket and producing groups in first-seen order. Items
* whose `getName` returns undefined or empty land in a single empty-named
* group so callers can decide whether to render a header for it.
*
* One copy of this lived in three places before — review-diff-list-groups,
* changes-panel-timeline (files), and changes-panel-timeline (commits) —
* differing only in element type. The generic shape collapses them all.
*/
export function groupByRepositoryName<T>(
items: T[],
getName: (item: T) => string | undefined,
): Array<{ repositoryName: string; items: T[] }> {
const order: string[] = [];
const buckets = new Map<string, T[]>();
for (const item of items) {
const name = getName(item) ?? "";
if (!buckets.has(name)) {
buckets.set(name, []);
order.push(name);
}
buckets.get(name)!.push(item);
}
return order.map((name) => ({ repositoryName: name, items: buckets.get(name)! }));
}
/**
* True when the grouped output represents a single-repo workspace — exactly
* one bucket with the empty-name key. Callers use this to drop the redundant
* per-repo sub-header in the changes panel and lift action buttons up to the
* section header. Single source of truth so the three call sites
* (CommitsSection, FileListBody, FileListSection) stay in sync.
*/
export function isSingleRepoGroup(groups: Array<{ repositoryName: string }>): boolean {
return groups.length === 1 && groups[0].repositoryName === "";
}