Git branch flow improvements (#370)

This commit is contained in:
Gregory Schier
2026-01-26 14:45:51 -08:00
committed by GitHub
parent eb10910d20
commit 47a3d44888
20 changed files with 862 additions and 423 deletions

View File

@@ -17,7 +17,6 @@ import type { DropdownItem } from '../core/Dropdown';
import { Dropdown } from '../core/Dropdown';
import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode';
import { BranchSelectionDialog } from './BranchSelectionDialog';
import { gitCallbacks } from './callbacks';
import { GitCommitDialog } from './GitCommitDialog';
import { GitRemotesDialog } from './GitRemotesDialog';
@@ -39,7 +38,18 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
const [
{ status, log },
{ branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init },
{
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
fetchAll,
mergeBranch,
push,
pull,
checkout,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir));
const localBranches = status.data?.localBranches ?? [];
@@ -47,8 +57,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const remoteOnlyBranches = remoteBranches.filter(
(b) => !localBranches.includes(b.replace(/^origin\//, '')),
);
const currentBranch = status.data?.headRefShorthand ?? 'UNKNOWN';
if (workspace == null) {
return null;
}
@@ -58,6 +66,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
}
// Still loading
if (status.data == null) {
return null;
}
const currentBranch = status.data.headRefShorthand;
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
{ branch, force },
@@ -104,7 +119,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const items: DropdownItem[] = [
{
label: 'View History',
label: 'View History...',
hidden: (log.data ?? []).length === 0,
leftSlot: <Icon icon="history" />,
onSelect: async () => {
@@ -118,13 +133,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
},
{
label: 'Manage Remotes',
label: 'Manage Remotes...',
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: () => GitRemotesDialog.show(syncDir),
},
{ type: 'separator' },
{
label: 'New Branch',
label: 'New Branch...',
leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() {
const name = await showPrompt({
@@ -134,7 +149,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
});
if (!name) return;
await branch.mutateAsync(
await createBranch.mutateAsync(
{ branch: name },
{
disableToastError: true,
@@ -150,95 +165,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
tryCheckout(name, false);
},
},
{
label: 'Merge Branch',
leftSlot: <Icon icon="merge" />,
hidden: localBranches.length <= 1,
async onSelect() {
showDialog({
id: 'git-merge',
title: 'Merge Branch',
size: 'sm',
description: (
<>
Select a branch to merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
render: ({ hide }) => (
<BranchSelectionDialog
selectText="Merge"
branches={localBranches.filter((b) => b !== currentBranch)}
onCancel={hide}
onSelect={async (branch) => {
await mergeBranch.mutateAsync(
{ branch, force: false },
{
disableToastError: true,
onSettled: hide,
onSuccess() {
showToast({
id: 'git-merged-branch',
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{' '}
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
sync({ force: true });
},
onError(err) {
showErrorToast({
id: 'git-merged-branch-error',
title: 'Error merging branch',
message: String(err),
});
},
},
);
}}
/>
),
});
},
},
{
label: 'Delete Branch',
leftSlot: <Icon icon="trash" />,
hidden: localBranches.length <= 1,
color: 'danger',
async onSelect() {
if (currentBranch == null) return;
const confirmed = await showConfirmDelete({
id: 'git-delete-branch',
title: 'Delete Branch',
description: (
<>
Permanently delete <InlineCode>{currentBranch}</InlineCode>?
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch: currentBranch },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: 'git-delete-branch-error',
title: 'Error deleting branch',
message: String(err),
});
},
async onSuccess() {
await sync({ force: true });
},
},
);
}
},
},
{ type: 'separator' },
{
label: 'Push',
@@ -278,7 +204,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
},
{
label: 'Commit',
label: 'Commit...',
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
@@ -298,16 +224,239 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
};
submenuOpenOnClick: true,
submenu: [
{
label: 'Checkout',
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: (
<>
Merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
hidden: isCurrent,
async onSelect() {
await mergeBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: 'git-merged-branch',
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{' '}
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
sync({ force: true });
},
onError(err) {
showErrorToast({
id: 'git-merged-branch-error',
title: 'Error merging branch',
message: String(err),
});
},
},
);
},
},
{
label: 'New Branch...',
async onSelect() {
const name = await showPrompt({
id: 'git-new-branch-from',
title: 'New Branch',
description: (
<>
Create a new branch from <InlineCode>{branch}</InlineCode>
</>
),
label: 'Branch Name',
});
if (!name) return;
await createBranch.mutateAsync(
{ branch: name, base: branch },
{
disableToastError: true,
onError: (err) => {
showErrorToast({
id: 'git-branch-error',
title: 'Error creating branch',
message: String(err),
});
},
},
);
tryCheckout(name, false);
},
},
{
label: 'Rename...',
async onSelect() {
const newName = await showPrompt({
id: 'git-rename-branch',
title: 'Rename Branch',
label: 'New Branch Name',
defaultValue: branch,
});
if (!newName || newName === branch) return;
await renameBranch.mutateAsync(
{ oldName: branch, newName },
{
disableToastError: true,
onSuccess() {
showToast({
id: 'git-rename-branch-success',
message: (
<>
Renamed <InlineCode>{branch}</InlineCode> to{' '}
<InlineCode>{newName}</InlineCode>
</>
),
color: 'success',
});
},
onError(err) {
showErrorToast({
id: 'git-rename-branch-error',
title: 'Error renaming branch',
message: String(err),
});
},
},
);
},
},
{ type: 'separator', hidden: isCurrent },
{
label: 'Delete',
color: 'danger',
hidden: isCurrent,
onSelect: async () => {
const confirmed = await showConfirmDelete({
id: 'git-delete-branch',
title: 'Delete Branch',
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode>?
</>
),
});
if (!confirmed) {
return;
}
const result = await deleteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: 'git-delete-branch-error',
title: 'Error deleting branch',
message: String(err),
});
},
},
);
if (result.type === 'not_fully_merged') {
const confirmed = await showConfirm({
id: 'force-branch-delete',
title: 'Branch not fully merged',
description: (
<>
<p>
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
</p>
<p>Do you want to delete it anyway?</p>
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch, force: true },
{
disableToastError: true,
onError(err) {
showErrorToast({
id: 'git-force-delete-branch-error',
title: 'Error force deleting branch',
message: String(err),
});
},
},
);
}
}
},
},
],
} satisfies DropdownItem;
}),
...remoteOnlyBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
};
submenuOpenOnClick: true,
submenu: [
{
label: 'Checkout',
hidden: isCurrent,
onSelect: () => tryCheckout(branch, false),
},
{
label: 'Delete',
color: 'danger',
async onSelect() {
const confirmed = await showConfirmDelete({
id: 'git-delete-remote-branch',
title: 'Delete Remote Branch',
description: (
<>
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
</>
),
});
if (!confirmed) return;
await deleteRemoteBranch.mutateAsync(
{ branch },
{
disableToastError: true,
onSuccess() {
showToast({
id: 'git-delete-remote-branch-success',
message: (
<>
Deleted remote branch <InlineCode>{branch}</InlineCode>
</>
),
color: 'success',
});
},
onError(err) {
showErrorToast({
id: 'git-delete-remote-branch-error',
title: 'Error deleting remote branch',
message: String(err),
});
},
},
);
},
},
],
} satisfies DropdownItem;
}),
];

View File

@@ -1,7 +1,5 @@
import type { GitCallbacks } from '@yaakapp-internal/git';
import { showPromptForm } from '../../lib/prompt-form';
import { Banner } from '../core/Banner';
import { InlineCode } from '../core/InlineCode';
import { promptCredentials } from './credentials';
import { addGitRemote } from './showAddRemoteDialog';
export function gitCallbacks(dir: string): GitCallbacks {
@@ -9,40 +7,10 @@ export function gitCallbacks(dir: string): GitCallbacks {
addRemote: async () => {
return addGitRemote(dir);
},
promptCredentials: async ({ url: remoteUrl, error }) => {
const isGitHub = /github\.com/i.test(remoteUrl);
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
const passDescription = isGitHub
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
: 'Enter your password or access token for this Git server.';
const r = await showPromptForm({
id: 'git-credentials',
title: 'Credentials Required',
description: error ? (
<Banner color="danger">{error}</Banner>
) : (
<>
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
</>
),
inputs: [
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
{
type: 'text',
name: 'password',
label: passLabel,
description: passDescription,
password: true,
},
],
});
if (r == null) throw new Error('Cancelled credentials prompt');
const username = String(r.username || '');
const password = String(r.password || '');
return { username, password };
promptCredentials: async ({ url, error }) => {
const creds = await promptCredentials({ url, error });
if (creds == null) throw new Error('Cancelled credentials prompt');
return creds;
},
};
}

View File

@@ -0,0 +1,50 @@
import { showPromptForm } from '../../lib/prompt-form';
import { Banner } from '../core/Banner';
import { InlineCode } from '../core/InlineCode';
export interface GitCredentials {
username: string;
password: string;
}
export async function promptCredentials({
url: remoteUrl,
error,
}: {
url: string;
error: string | null;
}): Promise<GitCredentials | null> {
const isGitHub = /github\.com/i.test(remoteUrl);
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
const passDescription = isGitHub
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
: 'Enter your password or access token for this Git server.';
const r = await showPromptForm({
id: 'git-credentials',
title: 'Credentials Required',
description: error ? (
<Banner color="danger">{error}</Banner>
) : (
<>
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
</>
),
inputs: [
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
{
type: 'text',
name: 'password',
label: passLabel,
description: passDescription,
password: true,
},
],
});
if (r == null) return null;
const username = String(r.username || '');
const password = String(r.password || '');
return { username, password };
}