Handle remote branches

This commit is contained in:
Gregory Schier
2025-02-07 13:21:30 -08:00
parent 2da898d2d4
commit a42bee098b
20 changed files with 272 additions and 32 deletions

File diff suppressed because one or more lines are too long

View File

@@ -5432,6 +5432,11 @@
"type": "string",
"const": "yaak-git:allow-checkout"
},
{
"description": "Enables the checkout_remote command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-checkout-remote"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
@@ -5442,6 +5447,11 @@
"type": "string",
"const": "yaak-git:allow-delete-branch"
},
{
"description": "Enables the fetch_all command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-fetch-all"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",
@@ -5492,6 +5502,11 @@
"type": "string",
"const": "yaak-git:deny-checkout"
},
{
"description": "Denies the checkout_remote command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-checkout-remote"
},
{
"description": "Denies the commit command without any pre-configured scope.",
"type": "string",
@@ -5502,6 +5517,11 @@
"type": "string",
"const": "yaak-git:deny-delete-branch"
},
{
"description": "Denies the fetch_all command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-fetch-all"
},
{
"description": "Denies the initialize command without any pre-configured scope.",
"type": "string",

View File

@@ -5432,6 +5432,11 @@
"type": "string",
"const": "yaak-git:allow-checkout"
},
{
"description": "Enables the checkout_remote command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-checkout-remote"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
@@ -5442,6 +5447,11 @@
"type": "string",
"const": "yaak-git:allow-delete-branch"
},
{
"description": "Enables the fetch_all command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:allow-fetch-all"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",
@@ -5492,6 +5502,11 @@
"type": "string",
"const": "yaak-git:deny-checkout"
},
{
"description": "Denies the checkout_remote command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-checkout-remote"
},
{
"description": "Denies the commit command without any pre-configured scope.",
"type": "string",
@@ -5502,6 +5517,11 @@
"type": "string",
"const": "yaak-git:deny-delete-branch"
},
{
"description": "Denies the fetch_all command without any pre-configured scope.",
"type": "string",
"const": "yaak-git:deny-fetch-all"
},
{
"description": "Denies the initialize command without any pre-configured scope.",
"type": "string",

View File

@@ -9,7 +9,7 @@ export type GitStatus = "added" | "conflict" | "current" | "modified" | "removed
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, branches: Array<string>, };
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, };
export type PullResult = { receivedBytes: number, receivedObjects: number, };

View File

@@ -4,6 +4,7 @@ const COMMANDS: &[&str] = &[
"checkout",
"commit",
"delete_branch",
"fetch_all",
"initialize",
"log",
"merge_branch",

View File

@@ -41,7 +41,7 @@ export function useGit(dir: string) {
mutationFn: (args) => invoke('plugin:yaak-git|delete_branch', { dir, ...args }),
onSuccess,
}),
checkout: useMutation<void, string, { branch: string; force: boolean }>({
checkout: useMutation<string, string, { branch: string; force: boolean }>({
mutationKey: ['git', 'checkout', dir],
mutationFn: (args) => invoke('plugin:yaak-git|checkout', { dir, ...args }),
onSuccess,
@@ -51,6 +51,11 @@ export function useGit(dir: string) {
mutationFn: (args) => invoke('plugin:yaak-git|commit', { dir, ...args }),
onSuccess,
}),
fetchAll: useMutation<string, string, void>({
mutationKey: ['git', 'checkout', dir],
mutationFn: () => invoke('plugin:yaak-git|fetch_all', { dir }),
onSuccess,
}),
push: useMutation<PushResult, string, void>({
mutationKey: ['git', 'push', dir],
mutationFn: () => invoke('plugin:yaak-git|push', { dir }),

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-checkout-remote"
description = "Enables the checkout_remote command without any pre-configured scope."
commands.allow = ["checkout_remote"]
[[permission]]
identifier = "deny-checkout-remote"
description = "Denies the checkout_remote command without any pre-configured scope."
commands.deny = ["checkout_remote"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-fetch-all"
description = "Enables the fetch_all command without any pre-configured scope."
commands.allow = ["fetch_all"]
[[permission]]
identifier = "deny-fetch-all"
description = "Denies the fetch_all command without any pre-configured scope."
commands.deny = ["fetch_all"]

View File

@@ -7,6 +7,7 @@ Default permissions for the plugin
- `allow-checkout`
- `allow-commit`
- `allow-delete-branch`
- `allow-fetch-all`
- `allow-initialize`
- `allow-log`
- `allow-merge-branch`
@@ -105,6 +106,32 @@ Denies the checkout command without any pre-configured scope.
<tr>
<td>
`yaak-git:allow-checkout-remote`
</td>
<td>
Enables the checkout_remote command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-checkout-remote`
</td>
<td>
Denies the checkout_remote command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-commit`
</td>
@@ -157,6 +184,32 @@ Denies the delete_branch command without any pre-configured scope.
<tr>
<td>
`yaak-git:allow-fetch-all`
</td>
<td>
Enables the fetch_all command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:deny-fetch-all`
</td>
<td>
Denies the fetch_all command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`yaak-git:allow-initialize`
</td>

View File

@@ -6,6 +6,7 @@ permissions = [
"allow-checkout",
"allow-commit",
"allow-delete-branch",
"allow-fetch-all",
"allow-initialize",
"allow-log",
"allow-merge-branch",

View File

@@ -324,6 +324,16 @@
"type": "string",
"const": "deny-checkout"
},
{
"description": "Enables the checkout_remote command without any pre-configured scope.",
"type": "string",
"const": "allow-checkout-remote"
},
{
"description": "Denies the checkout_remote command without any pre-configured scope.",
"type": "string",
"const": "deny-checkout-remote"
},
{
"description": "Enables the commit command without any pre-configured scope.",
"type": "string",
@@ -344,6 +354,16 @@
"type": "string",
"const": "deny-delete-branch"
},
{
"description": "Enables the fetch_all command without any pre-configured scope.",
"type": "string",
"const": "allow-fetch-all"
},
{
"description": "Denies the fetch_all command without any pre-configured scope.",
"type": "string",
"const": "deny-fetch-all"
},
{
"description": "Enables the initialize command without any pre-configured scope.",
"type": "string",

View File

@@ -22,9 +22,13 @@ pub(crate) fn branch_set_upstream_after_push(repo: &Repository, branch_name: &st
Ok(())
}
pub(crate) fn git_checkout_branch(dir: &Path, branch: &str, force: bool) -> Result<()> {
pub(crate) fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
if branch_name.starts_with("origin/") {
return git_checkout_remote_branch(dir, branch_name, force);
}
let repo = open_repo(dir)?;
let branch = get_branch_by_name(&repo, branch)?;
let branch = get_branch_by_name(&repo, branch_name)?;
let branch_ref = branch.into_reference();
let branch_tree = branch_ref.peel_to_tree()?;
@@ -36,7 +40,22 @@ pub(crate) fn git_checkout_branch(dir: &Path, branch: &str, force: bool) -> Resu
repo.checkout_tree(branch_tree.as_object(), Some(&mut options))?;
repo.set_head(branch_ref.name().unwrap())?;
Ok(())
Ok(branch_name.to_string())
}
pub(crate) fn git_checkout_remote_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
let branch_name = branch_name.trim_start_matches("origin/");
let repo = open_repo(dir)?;
let refname = format!("refs/remotes/origin/{}", branch_name);
let remote_ref = repo.find_reference(&refname)?;
let commit = remote_ref.peel_to_commit()?;
let mut new_branch = repo.branch(branch_name, &commit, false)?;
let upstream_name = format!("origin/{}", branch_name);
new_branch.set_upstream(Some(&upstream_name))?;
return git_checkout_branch(dir, branch_name, force)
}
pub(crate) fn git_create_branch(dir: &Path, name: &str) -> Result<()> {

View File

@@ -1,5 +1,6 @@
use crate::branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
use crate::error::Result;
use crate::fetch::git_fetch_all;
use crate::git::{
git_add, git_commit, git_init, git_log, git_status, git_unstage, GitCommit, GitStatusSummary,
};
@@ -10,7 +11,7 @@ use tauri::command;
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
#[command]
pub async fn checkout(dir: &Path, branch: &str, force: bool) -> Result<()> {
pub async fn checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {
git_checkout_branch(dir, branch, force)
}
@@ -49,6 +50,11 @@ pub async fn commit(dir: &Path, message: &str) -> Result<()> {
git_commit(dir, message)
}
#[command]
pub async fn fetch_all(dir: &Path) -> Result<()> {
git_fetch_all(dir)
}
#[command]
pub async fn push(dir: &Path) -> Result<PushResult> {
git_push(dir)

View File

@@ -0,0 +1,37 @@
use crate::callbacks::default_callbacks;
use crate::error::Result;
use crate::repository::open_repo;
use git2::{FetchOptions, ProxyOptions, Repository};
use std::path::Path;
pub(crate) fn git_fetch_all(dir: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let remotes = repo.remotes()?.iter().flatten().map(String::from).collect::<Vec<_>>();
for (_idx, remote) in remotes.into_iter().enumerate() {
fetch_from_remote(&repo, &remote)?;
}
Ok(())
}
fn fetch_from_remote(repo: &Repository, remote: &str) -> Result<()> {
let mut remote = repo.find_remote(remote)?;
let mut options = FetchOptions::new();
let callbacks = default_callbacks();
options.prune(git2::FetchPrune::On);
let mut proxy = ProxyOptions::new();
proxy.auto();
options.proxy_options(proxy);
options.download_tags(git2::AutotagOption::All);
options.remote_callbacks(callbacks);
remote.fetch(&[] as &[&str], Some(&mut options), None)?;
// fetch tags (also removing remotely deleted ones)
remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut options), None)?;
Ok(())
}

View File

@@ -1,6 +1,6 @@
use crate::error::Result;
use crate::repository::open_repo;
use crate::util::list_branch_names;
use crate::util::{local_branch_names, remote_branch_names};
use chrono::{DateTime, Utc};
use git2::IndexAddOption;
use log::{info, warn};
@@ -19,7 +19,8 @@ pub struct GitStatusSummary {
pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>,
pub origins: Vec<String>,
pub branches: Vec<String>,
pub local_branches: Vec<String>,
pub remote_branches: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
@@ -288,7 +289,8 @@ pub fn git_status(dir: &Path) -> Result<GitStatusSummary> {
}
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
let branches = list_branch_names(&repo)?;
let local_branches = local_branch_names(&repo)?;
let remote_branches = remote_branch_names(&repo)?;
Ok(GitStatusSummary {
entries,
@@ -296,7 +298,8 @@ pub fn git_status(dir: &Path) -> Result<GitStatusSummary> {
path: dir.to_string_lossy().to_string(),
head_ref,
head_ref_shorthand,
branches,
local_branches,
remote_branches,
})
}

View File

@@ -1,4 +1,4 @@
use crate::commands::{add, branch, checkout, commit, delete_branch, initialize, log, merge_branch, pull, push, status, unstage};
use crate::commands::{add, branch, checkout, commit, delete_branch, fetch_all, initialize, log, merge_branch, pull, push, status, unstage};
use tauri::{
generate_handler,
plugin::{Builder, TauriPlugin},
@@ -9,6 +9,7 @@ mod branch;
mod callbacks;
mod commands;
mod error;
mod fetch;
mod git;
mod merge;
mod pull;
@@ -24,6 +25,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
checkout,
commit,
delete_branch,
fetch_all,
initialize,
log,
merge_branch,

View File

@@ -34,7 +34,7 @@ pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch>> {
Ok(None)
}
pub(crate) fn list_branch_names(repo: &Repository) -> Result<Vec<String>> {
pub(crate) fn local_branch_names(repo: &Repository) -> Result<Vec<String>> {
let mut branches = Vec::new();
for branch in repo.branches(Some(BranchType::Local))? {
let branch = branch?.0;
@@ -45,6 +45,17 @@ pub(crate) fn list_branch_names(repo: &Repository) -> Result<Vec<String>> {
Ok(branches)
}
pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
let mut branches = Vec::new();
for branch in repo.branches(Some(BranchType::Remote))? {
let branch = branch?.0;
let name = branch.name_bytes()?;
let name = bytes_to_string(name)?;
branches.push(name);
}
Ok(branches)
}
pub(crate) fn get_branch_by_name<'s>(repo: &'s Repository, name: &str) -> Result<Branch<'s>> {
Ok(repo.find_branch(name, BranchType::Local)?)
}

View File

@@ -12,6 +12,7 @@ import classNames from 'classnames';
import { useMemo, useState } from 'react';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { showToast } from '../lib/toast';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import type { CheckboxProps } from './core/Checkbox';
@@ -46,8 +47,9 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
};
const handleCreateCommitAndPush = async () => {
await handleCreateCommit();
await commit.mutateAsync({ message });
await push.mutateAsync();
showToast({ id: 'git-push-success', message: 'Pushed changes', color: 'success' });
onDone();
};

View File

@@ -34,9 +34,16 @@ export function GitDropdown() {
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useActiveWorkspace();
const [{ status, log }, { branch, deleteBranch, mergeBranch, push, pull, checkout }] =
const [{ status, log }, { branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout }] =
useGit(syncDir);
const localBranches = status.data?.localBranches ?? [];
const remoteBranches = status.data?.remoteBranches ?? [];
const remoteOnlyBranches = remoteBranches.filter(
(b) => !localBranches.includes(b.replace(/^origin\//, '')),
);
const currentBranch = status.data?.headRefShorthand ?? 'UNKNOWN';
if (workspace == null) {
return null;
}
@@ -69,12 +76,12 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
showErrorToast('git-checkout-error', String(err));
}
},
async onSuccess() {
async onSuccess(branchName) {
showToast({
id: 'git-checkout-success',
message: (
<>
Switched branch <InlineCode>{branch}</InlineCode>
Switched branch <InlineCode>{branchName}</InlineCode>
</>
),
color: 'success',
@@ -124,7 +131,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
{
label: 'Merge Branch',
leftSlot: <Icon icon="merge" />,
hidden: (status.data?.branches ?? []).length <= 1,
hidden: localBranches.length <= 1,
async onSelect() {
showDialog({
id: 'git-merge',
@@ -132,15 +139,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
size: 'sm',
description: (
<>
Select a branch to merge into <InlineCode>{status.data?.headRefShorthand}</InlineCode>
Select a branch to merge into <InlineCode>{currentBranch}</InlineCode>
</>
),
render: ({ hide }) => (
<BranchSelectionDialog
selectText="Merge"
branches={(status.data?.branches ?? []).filter(
(b) => b !== status.data?.headRefShorthand,
)}
branches={localBranches.filter((b) => b !== currentBranch)}
onCancel={hide}
onSelect={async (branch) => {
await mergeBranch.mutateAsync(
@@ -153,7 +158,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{' '}
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
<InlineCode>{currentBranch}</InlineCode>
</>
),
});
@@ -173,10 +178,9 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
{
label: 'Delete Branch',
leftSlot: <Icon icon="trash" />,
hidden: (status.data?.branches ?? []).length <= 1,
hidden: localBranches.length <= 1,
color: 'danger',
async onSelect() {
const currentBranch = status.data?.headRefShorthand;
if (currentBranch == null) return;
const confirmed = await showConfirmDelete({
@@ -256,9 +260,17 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
});
},
},
{ type: 'separator', label: 'Branches', hidden: (status.data?.branches ?? []).length < 1 },
...(status.data?.branches ?? []).map((branch) => {
const isCurrent = status.data?.headRefShorthand === branch;
{ type: 'separator', label: 'Branches', hidden: localBranches.length < 1 },
...localBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
};
}),
...remoteOnlyBranches.map((branch) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
@@ -268,9 +280,9 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
];
return (
<Dropdown fullWidth items={items}>
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
<GitMenuButton>
{noRepo ? 'Configure Git' : <InlineCode>{status.data?.headRefShorthand}</InlineCode>}
<InlineCode>{currentBranch}</InlineCode>
<Icon icon="git_branch" size="sm" />
</GitMenuButton>
</Dropdown>

View File

@@ -71,6 +71,7 @@ export interface DropdownProps {
items: DropdownItem[];
fullWidth?: boolean;
hotKeyAction?: HotkeyAction;
onOpen?: () => void;
}
export interface DropdownRef {
@@ -89,7 +90,7 @@ export interface DropdownRef {
const openAtom = atom<string | null>(null);
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, hotKeyAction, fullWidth }: DropdownProps,
{ children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps,
ref,
) {
const id = useRef(generateId());
@@ -116,13 +117,14 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
// Persist background color of button until we close the dropdown
if (newIsOpen) {
onOpen?.();
buttonRef.current!.style.backgroundColor = window
.getComputedStyle(buttonRef.current!)
.getPropertyValue('background-color');
}
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
});
}, []);
}, [onOpen]);
// Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
// we have of detecting the dropdown closed, to do cleanup.