Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ fn build_invoke_handler<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -
tauri_commands::git_diff_file,
tauri_commands::commit_changes,
tauri_commands::git_fetch,
tauri_commands::git_pull,
tauri_commands::git_push,
tauri_commands::get_global_settings,
tauri_commands::set_global_settings,
Expand Down
2 changes: 1 addition & 1 deletion Backend/src/menus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ fn build_view_menu<R: tauri::Runtime>(app: &tauri::App<R>) -> tauri::Result<menu

/// ----- Repository -----
fn build_repository_menu<R: tauri::Runtime>(app: &tauri::App<R>) -> tauri::Result<menu::Submenu<R>> {
let fetch_item = MenuItem::with_id(app, "fetch", "Fetch", true, Some("F5"))?;
let fetch_item = MenuItem::with_id(app, "fetch", "Fetch/Pull", true, Some("F5"))?;
let push_item = MenuItem::with_id(app, "push", "Push", true, Some("Ctrl+P"))?;
let commit_item = MenuItem::with_id(app, "commit", "Commit", true, Some("Ctrl+Enter"))?;
let repo_settings_item = MenuItem::with_id(app, "repo-settings", "Repository Settings", true, None::<&str>)?;
Expand Down
34 changes: 34 additions & 0 deletions Backend/src/tauri_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,40 @@ pub fn git_fetch<R: Runtime>(window: Window<R>, state: State<'_, AppState>) -> R
Ok(())
}

#[tauri::command]
pub fn git_pull<R: Runtime>(window: Window<R>, state: State<'_, AppState>) -> Result<(), String> {
info!("git_pull called");

let repo = state
.current_repo()
.ok_or_else(|| "No repository selected".to_string())?;
let vcs = repo.inner();

let app = window.app_handle().clone();
let on = Some(progress_bridge(app));

let current = vcs
.current_branch()
.map_err(|e| {
error!("Failed to get current branch: {e}");
e.to_string()
})?
.ok_or_else(|| {
warn!("Detached HEAD detected, cannot determine upstream branch for pull");
"Detached HEAD; cannot determine upstream".to_string()
})?;

info!("Fast-forward pulling branch '{current}' from origin");

vcs.pull_ff_only("origin", &current, on).map_err(|e| {
error!("Pull (ff-only) failed for branch '{current}': {e}");
e.to_string()
})?;

info!("Pull (ff-only) completed successfully for branch '{current}'");
Ok(())
}

#[tauri::command]
pub async fn git_push<R: Runtime>(
window: Window<R>,
Expand Down
4 changes: 2 additions & 2 deletions Frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
</button>

<div class="title-actions">
<button class="btn" id="fetch-btn" title="Fetch/Pull (F5)">Fetch</button>
<button class="btn" id="fetch-btn" title="Fetch/Pull (F5)">Fetch/Pull</button>
<button class="btn" id="push-btn" title="Push">Push</button>
<button class="btn icon" id="theme-btn" title="Toggle theme" aria-label="Toggle theme">🌓</button>
</div>
Expand Down Expand Up @@ -86,7 +86,7 @@
Shortcuts: <span class="kbd">Ctrl+R</span> repo ·
<span class="kbd">Ctrl+F</span> filter ·
<span class="kbd">Ctrl+Enter</span> commit ·
<span class="kbd">F5</span> fetch
<span class="kbd">F5</span> fetch/pull
</div>
<div id="status" aria-live="polite">Ready</div>
</footer>
Expand Down
5 changes: 4 additions & 1 deletion Frontend/src/scripts/features/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,12 @@ export async function hydrateBranches() {
export async function hydrateStatus() {
if (!TAURI.has) return;
try {
const result = await TAURI.invoke<{ files: any[] }>('git_status');
const result = await TAURI.invoke<{ files: any[]; ahead?: number; behind?: number }>('git_status');
state.hasRepo = true;
state.files = Array.isArray(result?.files) ? (result.files as any) : [];
// ahead/behind are optional in older backends; default to 0
(state as any).ahead = Number((result as any)?.ahead || 0);
(state as any).behind = Number((result as any)?.behind || 0);
renderList();
window.dispatchEvent(new CustomEvent('app:status-updated'));
} catch (e) {
Expand Down
22 changes: 19 additions & 3 deletions Frontend/src/scripts/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TAURI } from './lib/tauri';
import { qs } from './lib/dom';
import { notify } from './lib/notify';
import { prefs, savePrefs } from './state/state';
import { prefs, savePrefs, state } from './state/state';
import {
bindTabs, initResizer, refreshRepoActions, setRepoHeader, resetRepoHeader, setTab, setTheme, toggleTheme,
bindLayoutActionState
Expand Down Expand Up @@ -40,8 +40,24 @@ function boot() {
// title actions
themeBtn?.addEventListener('click', toggleTheme);
fetchBtn?.addEventListener('click', async () => {
try { if (TAURI.has) await TAURI.invoke('git_fetch', {}); notify('Fetched'); await Promise.allSettled([hydrateStatus(), hydrateCommits()]); }
catch { notify('Fetch failed'); }
try {
if (!TAURI.has) return;
const hasLocalChanges = Array.isArray(state.files) && state.files.length > 0;
const ahead = (state as any).ahead || 0;
const behind = (state as any).behind || 0;
const canFastForward = !hasLocalChanges && ahead === 0;

if (canFastForward) {
await TAURI.invoke('git_pull', {});
notify(behind > 0 ? 'Pulled (fast-forward)' : 'Already up to date');
} else {
await TAURI.invoke('git_fetch', {});
notify('Fetched');
}
await Promise.allSettled([hydrateStatus(), hydrateCommits()]);
} catch {
notify('Fetch/Pull failed');
}
});
pushBtn?.addEventListener('click', async () => {
try { if (TAURI.has) await TAURI.invoke('git_push', {}); notify('Pushed'); }
Expand Down
2 changes: 2 additions & 0 deletions Frontend/src/scripts/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const state = {
branches: [] as Branch[], // list of branches
files: [] as FileStatus[], // working tree status
commits: [] as CommitItem[], // recent commits
ahead: 0 as number, // commits ahead of upstream
behind: 0 as number, // commits behind upstream
// Optional: track the current repo path if you want to show it anywhere
// repoPath: '' as string,
};
Expand Down
4 changes: 4 additions & 0 deletions crates/openvcs-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ pub trait Vcs: Send + Sync {
fn fetch(&self, remote: &str, refspec: &str, on: Option<OnEvent>) -> Result<()>;
fn push(&self, remote: &str, refspec: &str, on: Option<OnEvent>) -> Result<()>;

/// Fast-forward only pull of the current branch from the specified remote/branch.
/// Implementations should fetch as needed and then update the current branch if a fast-forward is possible.
fn pull_ff_only(&self, remote: &str, branch: &str, on: Option<OnEvent>) -> Result<()>;

// content
fn commit(&self, message: &str, name: &str, email: &str, paths: &[PathBuf]) -> Result<String>;
fn status_summary(&self) -> Result<models::StatusSummary>;
Expand Down
7 changes: 7 additions & 0 deletions crates/openvcs-git-libgit2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ impl Vcs for GitLibGit2 {
.map_err(Self::map_err)
}

fn pull_ff_only(&self, remote: &str, branch: &str, _on: Option<OnEvent>) -> Result<()> {
// Use libgit2 path that fetches and performs a fast-forward when possible.
// Progress is logged; we currently do not bridge per-line progress for this path.
let upstream = format!("{}/{}", remote, branch);
self.inner.fast_forward(&upstream).map_err(Self::map_err)
}

fn commit(&self, message: &str, name: &str, email: &str, paths: &[PathBuf]) -> Result<String> {
self.inner.commit(message, name, email, paths)
.map(|oid| oid.to_string())
Expand Down
21 changes: 18 additions & 3 deletions crates/openvcs-git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ impl GitSystem {
if let Some(c) = cwd { cmd.current_dir(c); }
let status = cmd
.args(args.into_iter().map(|s| s.as_ref().to_string()))
.env("GIT_SSH_COMMAND", "ssh")
// Disable interactive terminal prompts; rely on ssh-agent or fail fast
.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes")
.env("GIT_TERMINAL_PROMPT", "0")
.status()
.map_err(VcsError::Io)?;
if status.success() {
Expand All @@ -77,7 +79,8 @@ impl GitSystem {
if let Some(c) = cwd { cmd.current_dir(c); }
let out = cmd
.args(args.into_iter().map(|s| s.as_ref().to_string()))
.env("GIT_SSH_COMMAND", "ssh")
.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes")
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(VcsError::Io)?;
if out.status.success() {
Expand All @@ -94,7 +97,8 @@ impl GitSystem {
let mut cmd = Command::new(GIT_COMMAND_NAME);
cmd.current_dir(cwd)
.args(args)
.env("GIT_SSH_COMMAND", "ssh")
.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes")
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Expand Down Expand Up @@ -249,6 +253,17 @@ impl Vcs for GitSystem {
Self::run_git_streaming(&self.workdir, ["push", "--progress", remote, refspec], on)
}

fn pull_ff_only(&self, remote: &str, branch: &str, on: Option<OnEvent>) -> Result<()> {
// Prefer a single pull with ff-only for simplicity and to surface server messages
// Equivalent to: git fetch <remote> <branch>; git merge --ff-only <remote>/<branch>
// Using streaming to forward progress to the UI when available.
Self::run_git_streaming(
&self.workdir,
["pull", "--ff-only", "--no-rebase", remote, branch],
on,
)
}

fn commit(&self, message: &str, name: &str, email: &str, paths: &[PathBuf]) -> Result<String> {
Self::run_git(Some(&self.workdir), ["config", "user.name", name])?;
Self::run_git(Some(&self.workdir), ["config", "user.email", email])?;
Expand Down
Loading