diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 3eae6d0..999912f 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -2593,32 +2593,23 @@ pub async fn pull( strategy: Option, remote: Option, branch: Option, + allow_unrelated_histories: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; - let strategy = strategy.as_deref().unwrap_or("merge"); let remote = remote .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); - let mut pull_args = vec![OsString::from("pull")]; - match strategy { - "merge" => { - pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")]) - } - "rebase" => pull_args.push(OsString::from("--rebase")), - "ff-only" => pull_args.push(OsString::from("--ff-only")), - _ => return Err("Unknown pull strategy.".to_string()), - } - if let Some(remote_name) = remote.as_deref() { - validate_remote_name(&repo, remote_name, true)?; - pull_args.push(remote_name.into()); - if let Some(branch) = branch - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - { - pull_args.push(branch.into()); - } - } + let branch = branch + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let pull_args = pull_args_for_repo( + &repo, + strategy.as_deref().unwrap_or("merge"), + remote.as_deref(), + branch.as_deref(), + allow_unrelated_histories.unwrap_or(false), + )?; let output = match (username.as_deref(), password.as_deref()) { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { run_git_authenticated_output(&repo, pull_args.clone(), u, p)? @@ -2657,6 +2648,39 @@ pub async fn pull( .map_err(|err| format!("Could not pull: {err}"))? } +fn pull_args_for_repo( + repo: &Path, + strategy: &str, + remote: Option<&str>, + branch: Option<&str>, + allow_unrelated_histories: bool, +) -> Result, String> { + let mut pull_args = vec![OsString::from("pull")]; + match strategy { + "merge" => { + pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")]); + if allow_unrelated_histories { + pull_args.push(OsString::from("--allow-unrelated-histories")); + } + } + "rebase" => pull_args.push(OsString::from("--rebase")), + "ff-only" => pull_args.push(OsString::from("--ff-only")), + _ => return Err("Unknown pull strategy.".to_string()), + } + if let Some(remote_name) = remote.map(str::trim).filter(|value| !value.is_empty()) { + validate_remote_name(repo, remote_name, true)?; + pull_args.push(remote_name.into()); + let branch = branch + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .map(Ok) + .unwrap_or_else(|| current_branch_name(repo))?; + pull_args.push(branch.into()); + } + Ok(pull_args) +} + #[tauri::command] pub async fn fetch( path: String, @@ -2949,7 +2973,9 @@ fn first_remote_name(repo: &Path) -> Option { } fn current_branch_name(repo: &Path) -> Result { - let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?; + // `symbolic-ref` also works before the first commit, while + // `rev-parse --abbrev-ref HEAD` fails for an unborn HEAD. + let branch = run_git(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"])?; let branch = String::from_utf8_lossy(&branch).trim().to_string(); if branch.is_empty() || branch == "HEAD" { return Err("Could not determine current branch.".to_string()); @@ -8515,6 +8541,108 @@ mod tests { ); } + #[test] + fn pull_with_selected_remote_infers_current_branch() { + let repo = init_temp_repo("pull_selected_remote"); + let branch = git_output_test(&repo.path, ["symbolic-ref", "--short", "HEAD"]); + run_git_test(&repo.path, ["remote", "add", "origin", "."]); + + let args = pull_args_for_repo(&repo.path, "merge", Some("origin"), None, false) + .expect("pull arguments should be created"); + + assert_eq!( + args, + vec![ + OsString::from("pull"), + OsString::from("--no-rebase"), + OsString::from("--no-edit"), + OsString::from("origin"), + OsString::from(branch), + ] + ); + } + + #[test] + fn pull_can_explicitly_allow_unrelated_histories_for_merge() { + let repo = init_temp_repo("pull_unrelated_histories"); + let branch = git_output_test(&repo.path, ["symbolic-ref", "--short", "HEAD"]); + run_git_test(&repo.path, ["remote", "add", "origin", "."]); + + let args = pull_args_for_repo(&repo.path, "merge", Some("origin"), None, true) + .expect("pull arguments should be created"); + + assert_eq!( + args, + vec![ + OsString::from("pull"), + OsString::from("--no-rebase"), + OsString::from("--no-edit"), + OsString::from("--allow-unrelated-histories"), + OsString::from("origin"), + OsString::from(branch), + ] + ); + } + + #[tokio::test] + #[cfg_attr( + windows, + ignore = "Git for Windows can fail local pull tests with a sh signal pipe error" + )] + async fn pull_retries_unrelated_histories_only_after_explicit_opt_in() { + let remote = init_temp_repo("pull_unrelated_remote"); + fs::write(remote.path.join("remote.txt"), "remote history\n") + .expect("remote file should be written"); + run_git_test(&remote.path, ["add", "remote.txt"]); + run_git_test(&remote.path, ["commit", "-q", "-m", "remote init"]); + let remote_branch = git_output_test(&remote.path, ["branch", "--show-current"]); + + let local = init_temp_repo("pull_unrelated_local"); + fs::write(local.path.join("local.txt"), "local history\n") + .expect("local file should be written"); + run_git_test(&local.path, ["add", "local.txt"]); + run_git_test(&local.path, ["commit", "-q", "-m", "local init"]); + run_git_test( + &local.path, + ["remote", "add", "origin", remote.path.to_str().unwrap()], + ); + + let error = pull( + local.path.to_string_lossy().to_string(), + None, + None, + Some("merge".to_string()), + Some("origin".to_string()), + Some(remote_branch.clone()), + Some(false), + ) + .await + .expect_err("unrelated histories should require explicit opt-in"); + assert!(error.contains("refusing to merge unrelated histories")); + + let status = pull( + local.path.to_string_lossy().to_string(), + None, + None, + Some("merge".to_string()), + Some("origin".to_string()), + Some(remote_branch), + Some(true), + ) + .await + .expect("explicitly allowed histories should merge"); + + assert!(status.clean, "{:?}", status.files); + assert!(local.path.join("local.txt").exists()); + assert!(local.path.join("remote.txt").exists()); + let parent_count = + git_output_test(&local.path, ["rev-list", "--parents", "-n", "1", "HEAD"]) + .split_whitespace() + .count() + - 1; + assert_eq!(parent_count, 2); + } + #[tokio::test] #[cfg_attr( windows, @@ -8557,6 +8685,7 @@ mod tests { None, None, None, + None, ) .await .unwrap(); diff --git a/src/App.svelte b/src/App.svelte index 8178a39..5ce729c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2161,6 +2161,10 @@ || value.includes("fetch first"); } + function isUnrelatedHistoriesError(message: string): boolean { + return message.toLowerCase().includes("refusing to merge unrelated histories"); + } + function statusHasConflicts(value: GitStatus | null): boolean { return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted"); } @@ -3650,17 +3654,61 @@ mode: CredentialMode, ) { errorMessage = ""; - await runOperation("Pulling", async () => { - applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined)); - await refreshRepositoryViews(activeRepoPath); + const pulled = await pullWithUnrelatedHistoryConfirmation(username, password, "Pulling"); + if (pulled) { trackEvent("repository_pulled", { from_stored_credential: fromStore ? 1 : 0, changed_files: status?.files.length ?? 0, }); - }); + } handleRemoteResult("pull", key, fromStore, username, mode); } + async function pullWithUnrelatedHistoryConfirmation( + username: string, + password: string, + label: string, + ): Promise { + await runOperation(label, async () => { + applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined)); + await refreshRepositoryViews(activeRepoPath); + }); + + if (!errorMessage || !isUnrelatedHistoriesError(errorMessage)) return !errorMessage; + + if (pullStrategy !== "merge") { + errorMessage = appLanguage === "de" + ? "Lokales und entferntes Repository haben unabhängige Historien. Wähle in den Sync-Einstellungen die Merge-Strategie, um sie zusammenzuführen." + : "The local and remote repositories have unrelated histories. Choose the Merge strategy in Sync settings to combine them."; + return false; + } + + errorMessage = ""; + const confirmed = window.confirm(appLanguage === "de" + ? "Das lokale und das entfernte Repository besitzen getrennte Commit-Historien.\n\nTrotzdem zusammenführen? Dabei können Merge-Konflikte entstehen." + : "The local and remote repositories have separate commit histories.\n\nMerge them anyway? This may produce merge conflicts."); + if (!confirmed) { + errorMessage = appLanguage === "de" + ? "Pull abgebrochen: Die getrennten Historien wurden nicht verändert." + : "Pull cancelled: the separate histories were left unchanged."; + return false; + } + + await runOperation(appLanguage === "de" ? "Historien zusammenführen" : "Merging histories", async () => { + applyStatus(await pull( + activeRepoPath, + username, + password, + pullStrategy, + selectedRemote || undefined, + undefined, + true, + )); + await refreshRepositoryViews(activeRepoPath); + }); + return !errorMessage; + } + async function doActualFetch( username: string, password: string, @@ -3715,10 +3763,7 @@ if (!fromStore) credDialogError = ""; - await runOperation("Pulling before push", async () => { - applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined)); - await refreshRepositoryViews(activeRepoPath); - }); + await pullWithUnrelatedHistoryConfirmation(username, password, "Pulling before push"); if (errorMessage) { handleRemoteResult("pull", key, fromStore, username, mode); diff --git a/src/app.css b/src/app.css index a31ee46..8978d56 100644 --- a/src/app.css +++ b/src/app.css @@ -1486,73 +1486,61 @@ right: 18px; bottom: 18px; z-index: 70; - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 12px; - width: min(430px, calc(100vw - 32px)); - padding: 14px; + width: min(410px, calc(100vw - 28px)); overflow: hidden; - border: 1px solid rgba(100, 108, 255, 0.42); - border-radius: 14px; + border: 1px solid var(--color-border); + border-top: 2px solid var(--color-accent); color: var(--color-ink); - background: - linear-gradient(135deg, rgba(100,108,255,0.22), rgba(189,52,254,0.12) 42%, rgba(65,209,255,0.08)), - rgba(12, 13, 24, 0.96); - box-shadow: 0 24px 72px rgba(0,0,0,0.48), 0 0 0 1px rgba(255,255,255,0.04) inset; - backdrop-filter: blur(18px); + background: var(--app-dialog-bg); + box-shadow: 0 18px 48px rgba(0,0,0,0.42); + animation: update-toast-in 180ms cubic-bezier(.2,.8,.2,1) both; } .update-toast.error { - border-color: rgba(232,96,96,0.45); - background: - linear-gradient(135deg, rgba(232,96,96,0.16), rgba(100,108,255,0.11)), - rgba(12, 13, 24, 0.96); + border-top-color: var(--code-delete-strong); } .update-toast.installed { - border-color: rgba(78,202,118,0.38); - background: - linear-gradient(135deg, rgba(78,202,118,0.15), rgba(65,209,255,0.1), rgba(100,108,255,0.12)), - rgba(12, 13, 24, 0.96); + border-top-color: var(--code-add-strong); } - .update-toast-glow { - position: absolute; - inset: auto 18px -46px auto; - width: 170px; - height: 95px; - border-radius: 999px; - background: rgba(65,209,255,0.18); - filter: blur(34px); - pointer-events: none; + .update-toast-header { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 10px 11px; + border-bottom: 1px solid var(--color-border-subtle); + background: var(--app-dialog-chrome); } .update-toast-icon { display: grid; place-items: center; - width: 42px; - height: 42px; + width: 34px; + height: 34px; flex: 0 0 auto; - border: 1px solid rgba(255,255,255,0.14); - border-radius: 12px; - color: #ffffff; - background: linear-gradient(135deg, rgba(65,209,255,0.28), rgba(100,108,255,0.54), rgba(189,52,254,0.42)); - box-shadow: 0 14px 32px rgba(100,108,255,0.22), inset 0 1px 0 rgba(255,255,255,0.16); + border: 1px solid color-mix(in srgb, var(--color-accent) 32%, var(--color-border)); + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); } - .update-toast-icon.busy { color: #bfefff; } - - .update-toast-content { - position: relative; - z-index: 1; - display: grid; - gap: 9px; - min-width: 0; + .update-toast.error .update-toast-icon { + border-color: color-mix(in srgb, var(--code-delete-strong) 34%, var(--color-border)); + color: var(--code-delete-strong); + background: var(--code-delete-bg); } + .update-toast.installed .update-toast-icon { + border-color: color-mix(in srgb, var(--code-add-strong) 34%, var(--color-border)); + color: var(--code-add-strong); + background: var(--code-add-bg); + } + .update-toast-icon.busy { color: var(--color-accent); } - .update-toast-top { + .update-toast-body { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: 8px; + gap: 10px; min-width: 0; + padding: 12px; + background: var(--app-dialog-bg); } .update-toast-copy { min-width: 0; } @@ -1561,55 +1549,54 @@ overflow: hidden; color: var(--color-accent); font-family: var(--font-mono); - font-size: 11px; + font-size: 10px; font-weight: 800; + letter-spacing: .035em; + text-transform: uppercase; text-overflow: ellipsis; white-space: nowrap; } .update-toast h2 { - margin: 2px 0 0; - color: #ffffff; - font-size: 15px; + margin: 3px 0 0; + color: var(--color-ink); + font-size: 14px; font-weight: 800; line-height: 1.2; } .update-toast p { margin: 0; color: var(--color-ink-muted); - font-size: 12.5px; + font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; } .update-toast-close { - width: 28px; - min-width: 28px; - min-height: 28px; + width: 26px; + min-width: 26px; + min-height: 26px; padding: 0; - border-color: rgba(255,255,255,0.1); - border-radius: 8px; + border-color: var(--color-border-subtle); color: var(--color-ink-dim); - background: rgba(255,255,255,0.04); + background: transparent; } .update-toast-close:hover:not(:disabled) { - border-color: rgba(255,255,255,0.2); - color: #ffffff; - background: rgba(255,255,255,0.1); + border-color: var(--color-border); + color: var(--color-ink); + background: var(--color-surface-hover); } .update-progress { position: relative; - height: 6px; + height: 4px; overflow: hidden; - border-radius: 999px; - background: rgba(255,255,255,0.09); + background: var(--color-surface-hover); } .update-progress span { position: absolute; inset: 0 auto 0 0; min-width: 0; - border-radius: inherit; - background: linear-gradient(90deg, #4db6d6, #6f8cff, #238eb4); + background: var(--color-accent); transition: width 160ms ease; } .update-progress.indeterminate span { @@ -1627,29 +1614,48 @@ display: flex; justify-content: flex-end; flex-wrap: wrap; - gap: 8px; + gap: 7px; + min-height: 49px; + padding: 8px 11px; + border-top: 1px solid var(--color-border-subtle); + background: var(--app-dialog-chrome); } .update-toast-primary, .update-toast-secondary { - min-height: 32px; - border-radius: 8px; - font-size: 12.5px; + min-height: 31px; + font-size: 11.5px; font-weight: 800; } .update-toast-primary { - border-color: rgba(111,140,255,0.72); + border-color: var(--color-primary); color: #ffffff; - background: linear-gradient(135deg, #5f7df2, #238eb4); + background: var(--color-primary); } .update-toast-primary:hover:not(:disabled) { - border-color: rgba(77,182,214,0.74); + border-color: var(--color-primary-dark); color: #ffffff; - background: linear-gradient(135deg, #6f8cff, #2da0c7); + background: var(--color-primary-dark); } .update-toast-secondary { - border-color: rgba(255,255,255,0.1); + border-color: var(--color-border); color: var(--color-ink-muted); - background: rgba(255,255,255,0.05); + background: var(--app-button-bg); + } + + @keyframes update-toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + + @media (max-width: 480px) { + .update-toast { + right: 8px; + bottom: 8px; + width: calc(100vw - 16px); + } + .update-toast-actions > button { + flex: 1 1 0; + } } /* --- Workspace layout --- */ diff --git a/src/lib/components/UpdateToast.svelte b/src/lib/components/UpdateToast.svelte index 61cf5f2..b925863 100644 --- a/src/lib/components/UpdateToast.svelte +++ b/src/lib/components/UpdateToast.svelte @@ -53,7 +53,7 @@ ); const versionLabel = $derived( version && currentVersion - ? `${currentVersion} -> ${version}` + ? `${currentVersion} → ${version}` : version ? `Version ${version}` : "New version", @@ -69,34 +69,32 @@ role={state === "error" ? "alert" : "status"} aria-live={state === "error" ? "assertive" : "polite"} > - - -
- {#if state === "downloading"} -
- -
-
-
- {versionLabel} -

{title}

-
- - {#if !isBusy} - +
+
+ {#if state === "downloading"} +
+
+ Gitty update · {versionLabel} +

{title}

+
+ + {#if !isBusy} + + {/if} +
+ +

{description}

{#if showProgress} @@ -115,33 +113,34 @@
{/if} -
- {#if state === "installed"} - - {:else if state === "error"} - - - {:else} - - - {/if} -
+ +
+ {#if state === "installed"} + + {:else if state === "error"} + + + {:else} + + + {/if} +
diff --git a/src/lib/git.ts b/src/lib/git.ts index ab51aaa..4f462ff 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -405,8 +405,24 @@ export function commitAiSplit(path: string, options: CommitAiGenerateOptions): P }); } -export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise { - return invoke("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null }); +export function pull( + path: string, + username?: string, + password?: string, + strategy: PullStrategy = "merge", + remote?: string, + branch?: string, + allowUnrelatedHistories = false, +): Promise { + return invoke("pull", { + path, + username: username ?? null, + password: password ?? null, + strategy, + remote: remote || null, + branch: branch || null, + allowUnrelatedHistories, + }); } export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise {