Fix git pull conflicts with pull.ff=only and improve commit UX

- Replace git pull with fetch + merge to avoid conflicts with global
  git config (e.g. pull.ff=only) and background fetch --all
- Disable commit/commit+push buttons when message is empty
- Always show Push/Pull menu items even without remotes configured
- Default remote name to 'origin' when adding a new remote
This commit is contained in:
Gregory Schier
2026-02-12 14:49:30 -08:00
parent 1127d7e3fa
commit d84fec8c7c
5 changed files with 51 additions and 27 deletions

View File

@@ -44,43 +44,65 @@ pub async fn git_pull(dir: &Path) -> Result<PullResult> {
(branch_name, remote_name, remote_url) (branch_name, remote_name, remote_url)
}; };
let out = new_binary_command(dir) // Step 1: fetch the specific branch
// NOTE: We use fetch + merge instead of `git pull` to avoid conflicts with
// global git config (e.g. pull.ff=only) and the background fetch --all.
let fetch_out = new_binary_command(dir)
.await? .await?
.args(["pull", &remote_name, &branch_name]) .args(["fetch", &remote_name, &branch_name])
.env("GIT_TERMINAL_PROMPT", "0") .env("GIT_TERMINAL_PROMPT", "0")
.output() .output()
.await .await
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?; .map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout); let fetch_stdout = String::from_utf8_lossy(&fetch_out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr); let fetch_stderr = String::from_utf8_lossy(&fetch_out.stderr);
let combined = stdout + stderr; let fetch_combined = format!("{fetch_stdout}{fetch_stderr}");
info!("Pulled status={} {combined}", out.status); info!("Fetched status={} {fetch_combined}", fetch_out.status);
if combined.to_lowercase().contains("could not read") { if fetch_combined.to_lowercase().contains("could not read") {
return Ok(PullResult::NeedsCredentials { url: remote_url.to_string(), error: None }); return Ok(PullResult::NeedsCredentials { url: remote_url.to_string(), error: None });
} }
if combined.to_lowercase().contains("unable to access") { if fetch_combined.to_lowercase().contains("unable to access") {
return Ok(PullResult::NeedsCredentials { return Ok(PullResult::NeedsCredentials {
url: remote_url.to_string(), url: remote_url.to_string(),
error: Some(combined.to_string()), error: Some(fetch_combined.to_string()),
}); });
} }
if !out.status.success() { if !fetch_out.status.success() {
let combined_lower = combined.to_lowercase(); return Err(GenericError(format!("Failed to fetch: {fetch_combined}")));
if combined_lower.contains("cannot fast-forward") }
|| combined_lower.contains("not possible to fast-forward")
|| combined_lower.contains("diverged") // Step 2: merge the fetched branch
let ref_name = format!("{}/{}", remote_name, branch_name);
let merge_out = new_binary_command(dir)
.await?
.args(["merge", "--ff-only", &ref_name])
.output()
.await
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
let merge_stdout = String::from_utf8_lossy(&merge_out.stdout);
let merge_stderr = String::from_utf8_lossy(&merge_out.stderr);
let merge_combined = format!("{merge_stdout}{merge_stderr}");
info!("Merged status={} {merge_combined}", merge_out.status);
if !merge_out.status.success() {
let merge_lower = merge_combined.to_lowercase();
if merge_lower.contains("cannot fast-forward")
|| merge_lower.contains("not possible to fast-forward")
|| merge_lower.contains("diverged")
{ {
return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name }); return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name });
} }
return Err(GenericError(format!("Failed to pull {combined}"))); return Err(GenericError(format!("Failed to merge: {merge_combined}")));
} }
if combined.to_lowercase().contains("up to date") { if merge_combined.to_lowercase().contains("up to date") {
return Ok(PullResult::UpToDate); return Ok(PullResult::UpToDate);
} }

View File

@@ -176,7 +176,11 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
} }
if (!hasAnythingToAdd) { if (!hasAnythingToAdd) {
return <EmptyStateText>No changes since last commit</EmptyStateText>; return (
<div className="h-full px-6 pb-4">
<EmptyStateText>No changes since last commit</EmptyStateText>
</div>
);
} }
return ( return (
@@ -230,14 +234,14 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
hideLabel hideLabel
/> />
{commitError && <Banner color="danger">{commitError}</Banner>} {commitError && <Banner color="danger">{commitError}</Banner>}
<HStack alignItems="center"> <HStack alignItems="center" space={2}>
<InlineCode>{status.data?.headRefShorthand}</InlineCode> <InlineCode>{status.data?.headRefShorthand}</InlineCode>
<HStack space={2} className="ml-auto"> <HStack space={2} className="ml-auto">
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={handleCreateCommit} onClick={handleCreateCommit}
disabled={!hasAddedAnything} disabled={!hasAddedAnything || message.trim().length === 0}
isLoading={isPushing} isLoading={isPushing}
> >
Commit Commit
@@ -245,7 +249,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
<Button <Button
color="primary" color="primary"
size="sm" size="sm"
disabled={!hasAddedAnything} disabled={!hasAddedAnything || message.trim().length === 0}
onClick={handleCreateCommitAndPush} onClick={handleCreateCommitAndPush}
isLoading={isPushing} isLoading={isPushing}
> >

View File

@@ -173,7 +173,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Push', label: 'Push',
hidden: !hasRemotes,
leftSlot: <Icon icon="arrow_up_from_line" />, leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true, waitForOnSelect: true,
async onSelect() { async onSelect() {
@@ -192,7 +191,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
}, },
{ {
label: 'Pull', label: 'Pull',
hidden: !hasRemotes,
leftSlot: <Icon icon="arrow_down_to_line" />, leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true, waitForOnSelect: true,
async onSelect() { async onSelect() {

View File

@@ -2,13 +2,13 @@ import type { GitCallbacks } from '@yaakapp-internal/git';
import { sync } from '../../init/sync'; import { sync } from '../../init/sync';
import { promptCredentials } from './credentials'; import { promptCredentials } from './credentials';
import { promptDivergedStrategy } from './diverged'; import { promptDivergedStrategy } from './diverged';
import { promptUncommittedChangesStrategy } from './uncommitted';
import { addGitRemote } from './showAddRemoteDialog'; import { addGitRemote } from './showAddRemoteDialog';
import { promptUncommittedChangesStrategy } from './uncommitted';
export function gitCallbacks(dir: string): GitCallbacks { export function gitCallbacks(dir: string): GitCallbacks {
return { return {
addRemote: async () => { addRemote: async () => {
return addGitRemote(dir); return addGitRemote(dir, 'origin');
}, },
promptCredentials: async ({ url, error }) => { promptCredentials: async ({ url, error }) => {
const creds = await promptCredentials({ url, error }); const creds = await promptCredentials({ url, error });

View File

@@ -3,12 +3,12 @@ import { gitMutations } from '@yaakapp-internal/git';
import { showPromptForm } from '../../lib/prompt-form'; import { showPromptForm } from '../../lib/prompt-form';
import { gitCallbacks } from './callbacks'; import { gitCallbacks } from './callbacks';
export async function addGitRemote(dir: string): Promise<GitRemote> { export async function addGitRemote(dir: string, defaultName?: string): Promise<GitRemote> {
const r = await showPromptForm({ const r = await showPromptForm({
id: 'add-remote', id: 'add-remote',
title: 'Add Remote', title: 'Add Remote',
inputs: [ inputs: [
{ type: 'text', label: 'Name', name: 'name' }, { type: 'text', label: 'Name', name: 'name', defaultValue: defaultName },
{ type: 'text', label: 'URL', name: 'url' }, { type: 'text', label: 'URL', name: 'url' },
], ],
}); });