支持在git推送时选择远程源
This commit is contained in:
@@ -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,
|
||||||
@@ -853,27 +860,8 @@ pub async fn git_fetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn git_push(
|
pub async fn git_push_info(path: String) -> Result<GitPushInfo, AppCommandError> {
|
||||||
app: tauri::AppHandle,
|
// Get current branch name
|
||||||
window: tauri::WebviewWindow,
|
|
||||||
path: String,
|
|
||||||
credentials: Option<GitCredentials>,
|
|
||||||
db: tauri::State<'_, AppDatabase>,
|
|
||||||
) -> Result<GitPushResult, AppCommandError> {
|
|
||||||
let pushed_commits = estimate_push_commit_count(&path).await;
|
|
||||||
|
|
||||||
// Check if the current branch has an upstream configured
|
|
||||||
let upstream_check = crate::process::tokio_command("git")
|
|
||||||
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
|
|
||||||
.current_dir(&path)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.map_err(AppCommandError::io)?;
|
|
||||||
|
|
||||||
let has_upstream = upstream_check.status.success();
|
|
||||||
|
|
||||||
let output = if !has_upstream {
|
|
||||||
// No upstream: get current branch name and push with --set-upstream
|
|
||||||
let branch_output = crate::process::tokio_command("git")
|
let branch_output = crate::process::tokio_command("git")
|
||||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
.current_dir(&path)
|
.current_dir(&path)
|
||||||
@@ -884,14 +872,88 @@ pub async fn git_push(
|
|||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.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]
|
||||||
|
pub async fn git_push(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
path: String,
|
||||||
|
remote: Option<String>,
|
||||||
|
credentials: Option<GitCredentials>,
|
||||||
|
db: tauri::State<'_, AppDatabase>,
|
||||||
|
) -> Result<GitPushResult, AppCommandError> {
|
||||||
|
let pushed_commits = estimate_push_commit_count(&path).await;
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
|
||||||
|
.current_dir(&path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(AppCommandError::io)?;
|
||||||
|
|
||||||
|
let current_upstream = if upstream_check.status.success() {
|
||||||
|
Some(
|
||||||
|
String::from_utf8_lossy(&upstream_check.stdout)
|
||||||
|
.trim()
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Need to set upstream if: no upstream at all, or upstream points to a different remote
|
||||||
|
let needs_set_upstream = match ¤t_upstream {
|
||||||
|
None => true,
|
||||||
|
Some(upstream) => !upstream.starts_with(&format!("{}/", target_remote)),
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,10 +306,43 @@ export function PushWorkspace({
|
|||||||
[commits]
|
[commits]
|
||||||
)
|
)
|
||||||
|
|
||||||
const loadCommits = useCallback(async () => {
|
// Load push info (branch, remotes, tracking remote)
|
||||||
|
useEffect(() => {
|
||||||
|
gitPushInfo(folderPath)
|
||||||
|
.then((info) => {
|
||||||
|
setPushInfoData(info)
|
||||||
|
// Default to tracking remote or first remote
|
||||||
|
const defaultRemote =
|
||||||
|
info.tracking_remote ??
|
||||||
|
(info.remotes.length > 0 ? info.remotes[0].name : null)
|
||||||
|
setSelectedRemote(defaultRemote)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(toErrorMessage(err))
|
||||||
|
})
|
||||||
|
}, [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)
|
setListLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await gitLog(folderPath, 100)
|
const result = await gitLog(
|
||||||
|
folderPath,
|
||||||
|
100,
|
||||||
|
undefined,
|
||||||
|
remote ?? undefined
|
||||||
|
)
|
||||||
setCommits(result.entries)
|
setCommits(result.entries)
|
||||||
setHasUpstream(result.has_upstream)
|
setHasUpstream(result.has_upstream)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -306,11 +350,16 @@ export function PushWorkspace({
|
|||||||
} finally {
|
} finally {
|
||||||
setListLoading(false)
|
setListLoading(false)
|
||||||
}
|
}
|
||||||
}, [folderPath])
|
},
|
||||||
|
[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}>
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user