支持在git推送时选择远程源

This commit is contained in:
xintaofei
2026-03-23 16:39:49 +08:00
parent bf388526d7
commit 017b598649
5 changed files with 217 additions and 38 deletions

View File

@@ -148,6 +148,13 @@ pub struct GitPushResult {
pub upstream_set: bool, pub upstream_set: bool,
} }
#[derive(Debug, Serialize)]
pub struct GitPushInfo {
pub branch: String,
pub remotes: Vec<GitRemote>,
pub tracking_remote: Option<String>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct GitMergeResult { pub struct GitMergeResult {
pub merged_commits: usize, pub merged_commits: usize,
@@ -852,17 +859,68 @@ pub async fn git_fetch(
Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()) Ok(String::from_utf8_lossy(&output.stderr).trim().to_string())
} }
#[tauri::command]
pub async fn git_push_info(path: String) -> Result<GitPushInfo, AppCommandError> {
// Get current branch name
let branch_output = crate::process::tokio_command("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&path)
.output()
.await
.map_err(AppCommandError::io)?;
let branch = String::from_utf8_lossy(&branch_output.stdout)
.trim()
.to_string();
// Get tracking remote for current branch
let remote_key = format!("branch.{}.remote", branch);
let remote_output = crate::process::tokio_command("git")
.args(["config", "--get", &remote_key])
.current_dir(&path)
.output()
.await;
let tracking_remote = remote_output
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|v| !v.is_empty());
// Get all remotes
let remotes = git_list_remotes(path).await?;
Ok(GitPushInfo {
branch,
remotes,
tracking_remote,
})
}
#[tauri::command] #[tauri::command]
pub async fn git_push( pub async fn git_push(
app: tauri::AppHandle, app: tauri::AppHandle,
window: tauri::WebviewWindow, window: tauri::WebviewWindow,
path: String, path: String,
remote: Option<String>,
credentials: Option<GitCredentials>, credentials: Option<GitCredentials>,
db: tauri::State<'_, AppDatabase>, db: tauri::State<'_, AppDatabase>,
) -> Result<GitPushResult, AppCommandError> { ) -> Result<GitPushResult, AppCommandError> {
let pushed_commits = estimate_push_commit_count(&path).await; let pushed_commits = estimate_push_commit_count(&path).await;
// Check if the current branch has an upstream configured // Determine the target remote (use provided or fall back to tracking remote)
let target_remote = remote.unwrap_or_else(|| "origin".to_string());
// Check if the current branch has an upstream configured for this remote
let branch_output = crate::process::tokio_command("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&path)
.output()
.await
.map_err(AppCommandError::io)?;
let branch = String::from_utf8_lossy(&branch_output.stdout)
.trim()
.to_string();
// Check if upstream is set and points to the target remote
let upstream_check = crate::process::tokio_command("git") let upstream_check = crate::process::tokio_command("git")
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
.current_dir(&path) .current_dir(&path)
@@ -870,28 +928,32 @@ pub async fn git_push(
.await .await
.map_err(AppCommandError::io)?; .map_err(AppCommandError::io)?;
let has_upstream = upstream_check.status.success(); let current_upstream = if upstream_check.status.success() {
Some(
String::from_utf8_lossy(&upstream_check.stdout)
.trim()
.to_string(),
)
} else {
None
};
let output = if !has_upstream { // Need to set upstream if: no upstream at all, or upstream points to a different remote
// No upstream: get current branch name and push with --set-upstream let needs_set_upstream = match &current_upstream {
let branch_output = crate::process::tokio_command("git") None => true,
.args(["rev-parse", "--abbrev-ref", "HEAD"]) Some(upstream) => !upstream.starts_with(&format!("{}/", target_remote)),
.current_dir(&path) };
.output()
.await
.map_err(AppCommandError::io)?;
let branch = String::from_utf8_lossy(&branch_output.stdout)
.trim()
.to_string();
let output = if needs_set_upstream {
let mut cmd = crate::process::tokio_command("git"); let mut cmd = crate::process::tokio_command("git");
cmd.args(["push", "--set-upstream", "origin", &branch]) cmd.args(["push", "--set-upstream", &target_remote, &branch])
.current_dir(&path); .current_dir(&path);
prepare_remote_git_cmd(&mut cmd, &path, credentials.as_ref(), &db, &app).await; prepare_remote_git_cmd(&mut cmd, &path, credentials.as_ref(), &db, &app).await;
cmd.output().await.map_err(AppCommandError::io)? cmd.output().await.map_err(AppCommandError::io)?
} else { } else {
let mut cmd = crate::process::tokio_command("git"); let mut cmd = crate::process::tokio_command("git");
cmd.args(["push"]).current_dir(&path); cmd.args(["push", &target_remote, &branch])
.current_dir(&path);
prepare_remote_git_cmd(&mut cmd, &path, credentials.as_ref(), &db, &app).await; prepare_remote_git_cmd(&mut cmd, &path, credentials.as_ref(), &db, &app).await;
cmd.output().await.map_err(AppCommandError::io)? cmd.output().await.map_err(AppCommandError::io)?
}; };
@@ -900,7 +962,7 @@ pub async fn git_push(
return Err(classify_remote_git_error("push", &output.stderr)); return Err(classify_remote_git_error("push", &output.stderr));
} }
let upstream_set = !has_upstream; let upstream_set = needs_set_upstream;
if let Some(folder_id) = window if let Some(folder_id) = window
.label() .label()
@@ -3333,6 +3395,7 @@ pub async fn git_log(
path: String, path: String,
limit: Option<u32>, limit: Option<u32>,
branch: Option<String>, branch: Option<String>,
remote: Option<String>,
) -> Result<GitLogResult, AppCommandError> { ) -> Result<GitLogResult, AppCommandError> {
const COMMIT_META_PREFIX: &str = "__COMMIT__\0"; const COMMIT_META_PREFIX: &str = "__COMMIT__\0";
const MESSAGE_END_MARKER: &str = "__COMMIT_MESSAGE_END__"; const MESSAGE_END_MARKER: &str = "__COMMIT_MESSAGE_END__";
@@ -3431,7 +3494,7 @@ pub async fn git_log(
} }
let log_limit = limit.unwrap_or(100); let log_limit = limit.unwrap_or(100);
let (unpushed_hashes, has_upstream) = get_unpushed_hashes(&path, log_limit) let (unpushed_hashes, has_upstream) = get_unpushed_hashes(&path, log_limit, remote.as_deref())
.await .await
.unwrap_or((None, false)); .unwrap_or((None, false));
for entry in entries.iter_mut() { for entry in entries.iter_mut() {
@@ -3585,6 +3648,7 @@ fn parse_numstat_count(value: &str) -> u32 {
async fn get_unpushed_hashes( async fn get_unpushed_hashes(
path: &str, path: &str,
limit: u32, limit: u32,
remote_override: Option<&str>,
) -> Result<(Option<HashSet<String>>, bool), AppCommandError> { ) -> Result<(Option<HashSet<String>>, bool), AppCommandError> {
let limit_arg = format!("-{}", limit); let limit_arg = format!("-{}", limit);
@@ -3605,7 +3669,16 @@ async fn get_unpushed_hashes(
.trim() .trim()
.is_empty(); .is_empty();
let rev_list_output = if has_upstream { // When remote_override is specified, always compare against that remote
let rev_list_output = if let Some(target_remote) = remote_override {
let remote_arg = format!("--remotes={}", target_remote);
crate::process::tokio_command("git")
.args(["rev-list", &limit_arg, "HEAD", "--not", &remote_arg])
.current_dir(path)
.output()
.await
.map_err(AppCommandError::io)?
} else if has_upstream {
let upstream = String::from_utf8_lossy(&upstream_output.stdout) let upstream = String::from_utf8_lossy(&upstream_output.stdout)
.trim() .trim()
.to_string(); .to_string();

View File

@@ -207,6 +207,7 @@ pub fn run() {
folders::git_start_pull_merge, folders::git_start_pull_merge,
folders::git_has_merge_head, folders::git_has_merge_head,
folders::git_fetch, folders::git_fetch,
folders::git_push_info,
folders::git_push, folders::git_push,
folders::git_new_branch, folders::git_new_branch,
folders::git_worktree_add, folders::git_worktree_add,

View File

@@ -3,9 +3,11 @@
import type { ReactElement } from "react" import type { ReactElement } from "react"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { import {
ArrowRight,
ChevronsDownUp, ChevronsDownUp,
ChevronsUpDown, ChevronsUpDown,
CloudOff, CloudOff,
GitBranch,
Loader2, Loader2,
Upload, Upload,
} from "lucide-react" } from "lucide-react"
@@ -42,10 +44,17 @@ import {
} from "@/components/ai-elements/commit" } from "@/components/ai-elements/commit"
import { DiffViewer } from "@/components/diff/diff-viewer" import { DiffViewer } from "@/components/diff/diff-viewer"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { gitLog, gitPush, gitShowFile } from "@/lib/tauri" import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { gitLog, gitPush, gitPushInfo, gitShowFile } from "@/lib/tauri"
import { toErrorMessage } from "@/lib/app-error" import { toErrorMessage } from "@/lib/app-error"
import { languageFromPath } from "@/lib/language-detect" import { languageFromPath } from "@/lib/language-detect"
import type { GitLogEntry, GitLogFileChange } from "@/lib/types" import type { GitLogEntry, GitLogFileChange, GitPushInfo } from "@/lib/types"
import { useGitCredential } from "@/contexts/git-credential-context" import { useGitCredential } from "@/contexts/git-credential-context"
// --- File tree types & builder (same as aux-panel-git-log-tab) --- // --- File tree types & builder (same as aux-panel-git-log-tab) ---
@@ -278,6 +287,8 @@ export function PushWorkspace({
const tLog = useTranslations("Folder.gitLogTab") const tLog = useTranslations("Folder.gitLogTab")
const { withCredentialRetry } = useGitCredential() const { withCredentialRetry } = useGitCredential()
const [pushInfoData, setPushInfoData] = useState<GitPushInfo | null>(null)
const [selectedRemote, setSelectedRemote] = useState<string | null>(null)
const [commits, setCommits] = useState<GitLogEntry[]>([]) const [commits, setCommits] = useState<GitLogEntry[]>([])
const [hasUpstream, setHasUpstream] = useState(true) const [hasUpstream, setHasUpstream] = useState(true)
const [listLoading, setListLoading] = useState(false) const [listLoading, setListLoading] = useState(false)
@@ -295,22 +306,60 @@ export function PushWorkspace({
[commits] [commits]
) )
const loadCommits = useCallback(async () => { // Load push info (branch, remotes, tracking remote)
setListLoading(true) useEffect(() => {
try { gitPushInfo(folderPath)
const result = await gitLog(folderPath, 100) .then((info) => {
setCommits(result.entries) setPushInfoData(info)
setHasUpstream(result.has_upstream) // Default to tracking remote or first remote
} catch (err) { const defaultRemote =
toast.error(toErrorMessage(err)) info.tracking_remote ??
} finally { (info.remotes.length > 0 ? info.remotes[0].name : null)
setListLoading(false) setSelectedRemote(defaultRemote)
} })
.catch((err) => {
toast.error(toErrorMessage(err))
})
}, [folderPath]) }, [folderPath])
// Deduplicate remotes (git remote -v returns fetch + push entries)
const uniqueRemotes = useMemo(() => {
if (!pushInfoData) return []
const seen = new Set<string>()
return pushInfoData.remotes.filter((r) => {
if (seen.has(r.name)) return false
seen.add(r.name)
return true
})
}, [pushInfoData])
const loadCommits = useCallback(
async (remote?: string) => {
setListLoading(true)
try {
const result = await gitLog(
folderPath,
100,
undefined,
remote ?? undefined
)
setCommits(result.entries)
setHasUpstream(result.has_upstream)
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setListLoading(false)
}
},
[folderPath]
)
// Reload commits when selected remote changes
useEffect(() => { useEffect(() => {
loadCommits() if (selectedRemote !== null) {
}, [loadCommits]) loadCommits(selectedRemote)
}
}, [selectedRemote, loadCommits])
async function handleSelectFile(commitHash: string, file: string) { async function handleSelectFile(commitHash: string, file: string) {
setSelectedFile(file) setSelectedFile(file)
@@ -334,9 +383,10 @@ export function PushWorkspace({
async function handlePush() { async function handlePush() {
setPushing(true) setPushing(true)
try { try {
await withCredentialRetry((creds) => gitPush(folderPath, creds), { await withCredentialRetry(
folderPath, (creds) => gitPush(folderPath, selectedRemote, creds),
}) { folderPath }
)
onPushed?.() onPushed?.()
} catch (err) { } catch (err) {
toast.error(t("toasts.pushFailed"), { toast.error(t("toasts.pushFailed"), {
@@ -349,6 +399,43 @@ export function PushWorkspace({
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Push target header: branch → remote/branch */}
{pushInfoData && (
<div className="flex items-center gap-2 border-b px-3 py-2">
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{pushInfoData.branch}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{uniqueRemotes.length <= 1 ? (
<span className="truncate text-sm text-muted-foreground">
{selectedRemote ?? "origin"}/{pushInfoData.branch}
</span>
) : (
<div className="flex items-center gap-1">
<Select
value={selectedRemote ?? ""}
onValueChange={setSelectedRemote}
>
<SelectTrigger className="h-7 w-auto gap-1 border-none bg-transparent px-1.5 text-sm shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{uniqueRemotes.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
/{pushInfoData.branch}
</span>
</div>
)}
</div>
)}
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1"> <ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
{/* Left panel: commit list */} {/* Left panel: commit list */}
<ResizablePanel defaultSize={35} minSize={25}> <ResizablePanel defaultSize={35} minSize={25}>

View File

@@ -24,6 +24,7 @@ import type {
GitBranchList, GitBranchList,
GitPullResult, GitPullResult,
GitPushResult, GitPushResult,
GitPushInfo,
GitMergeResult, GitMergeResult,
GitRebaseResult, GitRebaseResult,
GitConflictFileVersions, GitConflictFileVersions,
@@ -548,11 +549,20 @@ export async function gitFetch(
return invoke("git_fetch", { path, credentials: credentials ?? null }) return invoke("git_fetch", { path, credentials: credentials ?? null })
} }
export async function gitPushInfo(path: string): Promise<GitPushInfo> {
return invoke("git_push_info", { path })
}
export async function gitPush( export async function gitPush(
path: string, path: string,
remote?: string | null,
credentials?: GitCredentials | null credentials?: GitCredentials | null
): Promise<GitPushResult> { ): Promise<GitPushResult> {
return invoke("git_push", { path, credentials: credentials ?? null }) return invoke("git_push", {
path,
remote: remote ?? null,
credentials: credentials ?? null,
})
} }
export async function gitNewBranch( export async function gitNewBranch(
@@ -1047,12 +1057,14 @@ export async function createFileTreeEntry(
export async function gitLog( export async function gitLog(
path: string, path: string,
limit?: number, limit?: number,
branch?: string branch?: string,
remote?: string
): Promise<GitLogResult> { ): Promise<GitLogResult> {
return invoke("git_log", { return invoke("git_log", {
path, path,
limit: limit ?? null, limit: limit ?? null,
branch: branch ?? null, branch: branch ?? null,
remote: remote ?? null,
}) })
} }

View File

@@ -675,6 +675,12 @@ export interface GitPushResult {
upstream_set: boolean upstream_set: boolean
} }
export interface GitPushInfo {
branch: string
remotes: GitRemote[]
tracking_remote: string | null
}
export interface GitMergeResult { export interface GitMergeResult {
merged_commits: number merged_commits: number
conflict?: GitConflictInfo | null conflict?: GitConflictInfo | null