Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ the resolved app appearance automatically.
- **Guided bisect** — use a commit’s Find regression menu to choose working
and broken revisions; test and mark working/broken/skip, inspect remaining candidates and the culprit, resume external
sessions, and reset to the original checkout while protecting test edits.
- **Compare branches** — open from the command palette or Branch actions;
choose any two local or remote refs without checkout, with upstream/main/master
defaults and the existing changed-file and full-diff dialog.
- **Commit graph** — SVG lanes with branch/tag chips, revealable inline stash
nodes with non-mutating diff inspection, a
resizable commit detail panel with lazy GPG/SSH/X.509 verification,
Expand Down
7 changes: 7 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -3024,6 +3024,13 @@ and independent Windows signature/hash/metadata checks passing. Publication
started Microsoft Store run `34066047506`; its build and submission remain
pending, followed by Partner Center certification.

**Branch comparison discovery shipped (2026-09-07, DAN-67):** Compare branches
is available from the command palette and Branch actions. Shared defaults choose
current vs upstream, then main/master, then the first pair; sidebar ref menus
also work with detached HEAD. All entry points reuse `CompareRefsDialog` and
`repoDiffBetween` / `repoTreeAt` for a full union file tree, changed-file badges,
and per-file comparison, including an identical-file tip for unchanged paths.

## Cross-cutting tracks (run in parallel with all milestones)

**Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on
Expand Down
3 changes: 3 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,9 @@ community plugins, performance and platform certification from Git feature gaps.
creation/deletion headers, with Computer Use stage/unstage verification.)

### Commits view
- ☑ DAN-67: first-class arbitrary branch comparison (`compareBranchDefaults`,
palette `strand:compare-branches`, Branch actions and HEAD menu defaults;
detached-HEAD context menus reuse `CompareRefsDialog` / `repoDiffBetween`).
- ☑ Table from `repo_log`
- ☑ SVG lane rendering (`ui/src/lib/graph.ts` lane algo + `CommitGraphCell` SVG; multi-color via `--b-1..--b-7`)
- ◐ Branch / tag / HEAD chips inline in the message cell (`indexRefs` in `Commits.tsx` + `.ref-chip` CSS; right-side chip column still open)
Expand Down
1 change: 1 addition & 0 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1788,6 +1788,7 @@ export function App() {
// open, so don't surface them (the network ones would fail confusingly).
if (meta) {
base.push(
{ id: 'compare-branches', label: 'Compare branches…', group: 'Actions', keywords: 'compare refs branches diff', run: () => { window.requestAnimationFrame(() => window.dispatchEvent(new CustomEvent('strand:compare-branches'))); } },
{ id: 'work', label: t('work.paletteShow'), group: 'Actions', shortcut: keyHint('view-work'), keywords: 'files documents embedded terminals shell', run: openWorkbench },
{ id: 'work-new-terminal', label: t('work.newTerminal'), group: 'Actions', keywords: 'work embedded shell prompt console', run: () => { addEmbeddedTerminal(meta.path); showWorkbenchWork(); } },
{ id: 'work-split-right', label: t('work.splitRight'), group: 'Actions', keywords: 'work pane editor group side by side', run: () => {
Expand Down
47 changes: 28 additions & 19 deletions ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { t } from '../lib/i18n';
import { formatBinding } from '../lib/keys';
import { customPanes } from '../lib/customView';
import { pathKey, worktreeName } from '../lib/repoIdentity';
import { compareBranchDefaults } from '../lib/compareBranchDefaults';
import { providerMergedBranchNames } from '../lib/branchIntegration';
import { errMessage, tauri } from '../lib/tauri';
import { defaultRemote, useRepo } from '../stores/repo';
Expand Down Expand Up @@ -255,6 +256,21 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface
[refs],
);
const [refCompare, setRefCompare] = useState<{ from: string; to: string } | null>(null);
const branchCompareDefaults = useMemo(() => compareBranchDefaults({
localNames: refs.branches.map((branch) => branch.name),
remoteNames: refs.remote_branches.map((branch) => branch.name),
currentBranch,
upstream: refs.branches.find((branch) => branch.is_head)?.upstream?.name ?? null,
}), [refs, currentBranch]);
useEffect(() => {
const open = () => {
if (!meta) return;
if (branchCompareDefaults) setRefCompare(branchCompareDefaults);
else onToast('At least two branches are needed to compare branches');
};
window.addEventListener('strand:compare-branches', open);
return () => window.removeEventListener('strand:compare-branches', open);
}, [meta, branchCompareDefaults, onToast]);
// Branches that are HEAD of another worktree — checkout here is guaranteed
// to fail, so their rows badge the fact and open that worktree instead.
const worktreeByBranch = useMemo(
Expand Down Expand Up @@ -498,11 +514,8 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface
};

const compareAgainst = (target: string) => {
if (!currentBranch) {
onToast('Check out a local branch before comparing refs', 'error');
return;
}
setRefCompare({ from: target, to: currentBranch });
const to = currentBranch ?? compareChoices.find((choice) => choice.value !== target)?.value ?? target;
setRefCompare({ from: target, to });
};

const branchMenu = (b: Branch): MenuItem[] => {
Expand Down Expand Up @@ -565,11 +578,8 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface
{
label: 'Compare branch…',
icon: 'compare',
disabled: compareChoices.length < 2,
onSelect: () => {
const other = compareChoices.find((choice) => choice.value !== b.name);
if (other) setRefCompare({ from: other.value, to: b.name });
},
disabled: !branchCompareDefaults,
onSelect: () => setRefCompare(branchCompareDefaults),
},
{ label: 'Copy branch name', icon: 'file', onSelect: () => { void copyToClipboard(b.name); onToast('Branch name copied'); } },
{ label: 'Copy full ref', icon: 'file', onSelect: () => { void copyToClipboard(b.full_name); onToast('Branch ref copied'); } },
Expand All @@ -589,8 +599,8 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface
newWorktreeItem,
renameItem,
];
items.push({ label: currentBranch ? `Compare ${currentBranch} with this…` : 'Compare branch…', icon: 'compare', onSelect: () => compareAgainst(b.name) });
if (currentBranch) {
items.push({ label: `Compare ${currentBranch} with this…`, icon: 'compare', onSelect: () => compareAgainst(b.name) });
items.push({ label: `Review ${currentBranch} vs this`, icon: 'eye', onSelect: () => reviewAgainst(b.name) });
items.push({ label: `Merge into ${currentBranch}`, icon: 'branch', onSelect: () => onMerge(b.name, currentBranch) });
items.push({ label: `Rebase ${currentBranch} onto this`, icon: 'rebase', confirm: true, onSelect: () => runRebase(b.name) });
Expand All @@ -609,12 +619,12 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface
const local = localByUpstream.get(rb.name);
const items: MenuItem[] = [userActionMenu({ path: meta!.path, target: { kind: 'ref', reference: rb.full_name, oid: rb.target } })];
items.push({ label: 'Fetch this branch', icon: 'arrow-down', onSelect: () => onFetchBranch(rb) });
items.push({
label: currentBranch ? `Compare ${currentBranch} with this…` : 'Compare branch…',
icon: 'compare',
onSelect: () => compareAgainst(rb.name),
});
if (currentBranch) {
items.push({
label: `Compare ${currentBranch} with this…`,
icon: 'compare',
onSelect: () => compareAgainst(rb.name),
});
items.push({
label: `Pull into ${currentBranch}`,
icon: 'arrow-down',
Expand Down Expand Up @@ -757,9 +767,7 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface
{ label: 'New branch from here…', icon: 'plus', onSelect: () => onCreateBranch(tg.full_name, tg.name) },
{ label: 'New worktree from here…', icon: 'worktree', onSelect: () => onCreateWorktree({ ref: tg.full_name, label: tg.name }) },
];
if (currentBranch) {
items.push({ label: `Compare ${currentBranch} with this tag…`, icon: 'compare', onSelect: () => compareAgainst(tg.full_name) });
}
items.push({ label: currentBranch ? `Compare ${currentBranch} with this tag…` : 'Compare tag…', icon: 'compare', onSelect: () => compareAgainst(tg.full_name) });
if (tagRemote) {
items.push({ label: `Push to ${tagRemote}`, icon: 'arrow-up', onSelect: () => runTagPush(tg.name) });
// Gray out remote-delete when we know the remote doesn't have this tag.
Expand Down Expand Up @@ -1120,6 +1128,7 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface
const rect = event.currentTarget.getBoundingClientRect();
openMenu(rect.left, rect.bottom, [
{ label: 'New branch…', icon: 'plus', onSelect: () => onCreateBranch(null, 'HEAD') },
{ label: 'Compare branches…', icon: 'compare', disabled: !branchCompareDefaults, onSelect: () => setRefCompare(branchCompareDefaults) },
{ label: 'Git-flow…', onSelect: () => openRepositoryTool({ path: meta.path, tool: 'gitflow' }) },
]);
} }}
Expand Down
48 changes: 48 additions & 0 deletions ui/src/lib/compareBranchDefaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { compareBranchDefaults } from './compareBranchDefaults';

describe('compareBranchDefaults', () => {
const defaults = {
localNames: ['feature', 'master', 'main'],
remoteNames: ['origin/feature'],
currentBranch: 'feature',
upstream: 'origin/feature',
};

it('prefers upstream and keeps current on the To side', () => {
expect(compareBranchDefaults(defaults)).toEqual({ from: 'origin/feature', to: 'feature' });
});

it.each([null, 'origin/deleted', 'feature'])('prefers main when upstream is unusable (%s)', (upstream) => {
expect(compareBranchDefaults({ ...defaults, upstream })).toEqual({ from: 'main', to: 'feature' });
});

it('falls back to master', () => {
expect(compareBranchDefaults({ ...defaults, localNames: ['feature', 'master'], upstream: null }))
.toEqual({ from: 'master', to: 'feature' });
});

it('never compares current with itself when current is main', () => {
expect(compareBranchDefaults({ ...defaults, currentBranch: 'main', upstream: null }))
.toEqual({ from: 'master', to: 'main' });
});

it('uses the first other branch while preserving current direction', () => {
expect(compareBranchDefaults({ ...defaults, localNames: ['other', 'feature'], upstream: null }))
.toEqual({ from: 'other', to: 'feature' });
});

it.each([null, 'missing'])('uses the first pair without an available current branch (%s)', (currentBranch) => {
expect(compareBranchDefaults({ ...defaults, currentBranch }))
.toEqual({ from: 'feature', to: 'master' });
});

it('supports remote-only branches while detached', () => {
expect(compareBranchDefaults({ localNames: [], remoteNames: ['origin/a', 'origin/b'], currentBranch: null, upstream: null }))
.toEqual({ from: 'origin/a', to: 'origin/b' });
});

it.each([[], ['main'], ['main', 'main']].map((localNames) => ({ localNames })))('requires two distinct available branches: $localNames', ({ localNames }) => {
expect(compareBranchDefaults({ localNames, remoteNames: [], currentBranch: null, upstream: null })).toBeNull();
});
});
16 changes: 16 additions & 0 deletions ui/src/lib/compareBranchDefaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Pick existing, distinct refs in stable local-then-remote order. */
export function compareBranchDefaults({ localNames, remoteNames, currentBranch, upstream }: {
localNames: readonly string[];
remoteNames: readonly string[];
currentBranch: string | null;
upstream: string | null;
}): { from: string; to: string } | null {
const names = [...new Set([...localNames, ...remoteNames])];
if (names.length < 2) return null;
if (currentBranch && names.includes(currentBranch)) {
const other = [upstream, 'main', 'master', ...names]
.find((name): name is string => !!name && name !== currentBranch && names.includes(name))!;
return { from: other, to: currentBranch };
}
return { from: names[0], to: names[1] };
}
60 changes: 60 additions & 0 deletions ui/src/lib/compareRefsTree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterAll, describe, expect, it, vi } from 'vitest';
import type { DiffStatus } from './types';

// Pierre reads navigator at import time, including on Node 20.
vi.stubGlobal('navigator', { userAgent: 'node' });
const { compareRefsTree } = await import('./compareRefsTree');
afterAll(() => vi.unstubAllGlobals());

const entries = (...paths: string[]) => paths.map((path) => ({ path }));
const diff = (path: string, status: DiffStatus, old_path: string | null = null) => ({ path, status, old_path });

describe('compareRefsTree', () => {
it('sorts and deduplicates the full union, retaining unchanged files without badges', () => {
expect(compareRefsTree(
entries('src/same.ts', 'deleted.txt', 'README.md'),
entries('added.txt', 'src/same.ts', 'README.md'),
[diff('added.txt', 'added'), diff('deleted.txt', 'deleted')],
)).toEqual({
paths: ['README.md', 'added.txt', 'deleted.txt', 'src/same.ts'],
gitStatus: [{ path: 'added.txt', status: 'added' }, { path: 'deleted.txt', status: 'deleted' }],
});
});

it('keeps identical nonempty trees visible with no status entries', () => {
expect(compareRefsTree(entries('a', 'dir/b'), entries('dir/b', 'a'), []))
.toEqual({ paths: ['a', 'dir/b'], gitStatus: [] });
});

it('supports empty revisions', () => {
expect(compareRefsTree([], [], [])).toEqual({ paths: [], gitStatus: [] });
});

it('includes missing diff paths and both sides of a rename', () => {
expect(compareRefsTree([], entries('new.txt'), [diff('new.txt', 'renamed', 'old.txt'), diff('gone.txt', 'deleted')]))
.toEqual({
paths: ['gone.txt', 'new.txt', 'old.txt'],
gitStatus: [
{ path: 'old.txt', status: 'renamed' },
{ path: 'new.txt', status: 'renamed' },
{ path: 'gone.txt', status: 'deleted' },
],
});
});

it('prefers a path’s own status over a rename alias regardless of diff order', () => {
for (const diffs of [
[diff('old', 'added'), diff('new', 'renamed', 'old')],
[diff('new', 'renamed', 'old'), diff('old', 'added')],
]) {
expect(compareRefsTree([], [], diffs).gitStatus).toContainEqual({ path: 'old', status: 'added' });
}
});

it.each([
['modified', 'modified'], ['typechange', 'modified'], ['copied', 'added'],
] as const)('uses the shared status mapping for %s', (status, expected) => {
const tree = compareRefsTree(entries('source'), entries('target'), [diff('target', status, 'source')]);
expect(tree.gitStatus).toEqual([{ path: 'target', status: expected }]);
});
});
26 changes: 26 additions & 0 deletions ui/src/lib/compareRefsTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { GitStatusEntry } from '@pierre/trees';
import { diffStatusToGit } from '../components/PierreTree';
import type { FileDiff, WorkTreeEntry } from './types';

/** Full revision inventory, with badges only for paths changed by the comparison. */
export function compareRefsTree(
from: readonly Pick<WorkTreeEntry, 'path'>[],
to: readonly Pick<WorkTreeEntry, 'path'>[],
diffs: readonly Pick<FileDiff, 'path' | 'old_path' | 'status'>[],
): { paths: string[]; gitStatus: GitStatusEntry[] } {
const paths = new Set([...from, ...to].map((entry) => entry.path));
const statuses = new Map<string, GitStatusEntry['status']>();
for (const diff of diffs) {
paths.add(diff.path);
if (diff.status === 'renamed' && diff.old_path) {
paths.add(diff.old_path);
statuses.set(diff.old_path, diffStatusToGit(diff.status));
}
}
// A path's own diff takes precedence over a rename's old-path alias.
for (const diff of diffs) statuses.set(diff.path, diffStatusToGit(diff.status));
return {
paths: [...paths].sort(),
gitStatus: [...statuses].map(([path, status]) => ({ path, status })),
};
}
9 changes: 9 additions & 0 deletions ui/src/lib/diffLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { toPierreLayout } from './diffLayout';

describe('toPierreLayout', () => {
it('maps stacked to unified and split to split', () => {
expect(toPierreLayout('stacked')).toBe('unified');
expect(toPierreLayout('split')).toBe('split');
});
});
33 changes: 29 additions & 4 deletions ui/src/styles/features.css
Original file line number Diff line number Diff line change
Expand Up @@ -3184,18 +3184,31 @@ button {

/* ─── Commit / branch comparison ───────────────────────────────────────── */
.compare-refs-dialog {
width: min(1120px, calc(100vw - 48px));
height: min(760px, calc(100vh - 48px));
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
align-self: center;
}
.compare-refs-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 12px 0;
min-height: 28px;
}
.compare-refs-summary {
margin-left: auto;
margin-left: 0;
margin-right: auto;
color: var(--text-dim);
font-size: 11px;
}
.compare-refs-layout {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.compare-refs-message .compare-refs-summary {
margin: 6px 12px 0;
margin: 0;
}
.compare-refs-dialog .cd-close {
margin-left: 4px;
Expand Down Expand Up @@ -3246,6 +3259,12 @@ button {
border-right: 0.5px solid var(--border);
padding: 4px;
}
.compare-refs-dialog .compare-refs-files {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.compare-refs-file {
width: 100%;
min-height: 30px;
Expand Down Expand Up @@ -3481,6 +3500,12 @@ button {
height: min(680px, 88vh);
}
.dialog-wide { width: min(1120px, 96vw); }
.clone-dialog.compare-refs-dialog {
width: calc(100vw - 24px);
max-width: none;
height: calc(100vh - 24px);
align-self: center;
}
.clone-head {
display: flex;
align-items: center;
Expand Down
Loading
Loading