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 @@ -104,6 +104,7 @@ fn build_invoke_handler<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -
tauri_commands::git_list_branches,
tauri_commands::git_status,
tauri_commands::git_log,
tauri_commands::git_head_status,
tauri_commands::git_checkout_branch,
tauri_commands::git_create_branch,
tauri_commands::git_current_branch,
Expand Down
26 changes: 26 additions & 0 deletions Backend/src/tauri_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::utilities::utilities;
use crate::validate;

use openvcs_core::{OnEvent, models::{BranchItem, StatusPayload, CommitItem}, Repo, BackendId, backend_id};
use serde::Serialize;
use openvcs_core::backend_descriptor::{get_backend, list_backends};
use openvcs_core::models::{VcsEvent};
use crate::settings::AppConfig;
Expand Down Expand Up @@ -373,6 +374,31 @@ pub fn git_log(
vcs.log_commits(&q).map_err(|e| e.to_string())
}

/* ---------- git_head_status ---------- */
#[derive(Serialize)]
pub struct HeadStatus {
pub detached: bool,
pub branch: Option<String>,
pub commit: Option<String>,
}

#[tauri::command]
pub fn git_head_status(state: State<'_, AppState>) -> Result<HeadStatus, String> {
use openvcs_core::models::LogQuery;

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

let branch = vcs.current_branch().map_err(|e| e.to_string())?;
let q = LogQuery { rev: Some("HEAD".into()), limit: 1, ..Default::default() };
let head = vcs.log_commits(&q).map_err(|e| e.to_string())?;
let commit = head.get(0).map(|c| c.id.clone());

Ok(HeadStatus { detached: branch.is_none(), branch, commit })
}

/* ---------- optional: branch ops used by your JS ---------- */
#[tauri::command]
pub fn git_checkout_branch(state: State<'_, AppState>, name: String) -> Result<(), String> {
Expand Down
11 changes: 6 additions & 5 deletions Frontend/src/scripts/features/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ async function loadBranches() {
const branches = await TAURI.invoke<Branch[]>('git_list_branches');
state.branches = Array.isArray(branches) ? branches : [];

const cur = await TAURI.invoke<string>('git_current_branch').catch(() => state.branch || '');
state.branch = cur || state.branch || '';

if (branchName) branchName.textContent = state.branch || '—';
if (repoBranchEl) repoBranchEl.textContent = state.branch || '—';
const head = await TAURI.invoke<{ detached: boolean; branch?: string; commit?: string }>('git_head_status');
if (head?.branch) state.branch = head.branch;
const short = (head?.commit || '').slice(0, 7);
const label = head?.detached ? `Detached HEAD ${short ? '(' + short + ')' : ''}` : (state.branch || '—');
if (branchName) branchName.textContent = label;
if (repoBranchEl) repoBranchEl.textContent = label;

renderBranches();
setBranchUIEnabled(!!state.branch);
Expand Down
4 changes: 2 additions & 2 deletions Frontend/src/scripts/features/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,14 +355,14 @@ export async function hydrateBranches() {
if (!TAURI.has) return;
try {
const list = await TAURI.invoke<any[]>('git_list_branches');
const current = await TAURI.invoke<string>('git_current_branch').catch(() => '');
const head = await TAURI.invoke<{ detached: boolean; branch?: string; commit?: string }>('git_head_status').catch(() => ({ detached: false } as any));

const has = Array.isArray(list) && list.length > 0;
state.hasRepo = state.hasRepo || has; // don’t flip to false if another hydrate confirms true

if (has) {
state.branches = list as any;
state.branch = current || (list.find((b: any) => b.current)?.name) || state.branch || 'main';
state.branch = (head as any)?.branch || (list.find((b: any) => b.current)?.name) || state.branch || 'main';
window.dispatchEvent(new CustomEvent('app:branches-updated'));
}
} catch (e) {
Expand Down
83 changes: 61 additions & 22 deletions crates/openvcs-git-libgit2/src/lowlevel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,35 +226,74 @@ impl Git {
}

pub fn checkout_branch(&self, name: &str) -> Result<()> {
use git2 as g;
info!("checking out branch '{name}'");

self.with_repo(|repo| {
let (obj, reference) = repo
.revparse_ext(&format!("refs/heads/{name}"))
.map_err(|_| {
error!("branch '{name}' not found");
GitError::NoSuchBranch(name.into())
})?;
// Helper: checkout by full ref if present
let checkout_ref = |repo: &g::Repository, full_ref: &str| -> Result<()> {
let (obj, reference) = repo.revparse_ext(full_ref)?;
repo.checkout_tree(&obj, None)?;
if let Some(r) = reference {
repo.set_head(r.name().unwrap())?;
} else {
repo.set_head_detached(obj.id())?;
}
Ok(())
};

repo.checkout_tree(&obj, None).map_err(|e| {
error!("failed to checkout tree for branch '{name}': {e}");
e
})?;
// 1) Local branch exists?
if repo.find_branch(name, g::BranchType::Local).is_ok() {
checkout_ref(repo, &format!("refs/heads/{name}"))?;
info!("checked out existing local branch '{name}'");
return Ok(());
}

if let Some(r) = reference {
repo.set_head(r.name().unwrap()).map_err(|e| {
error!("failed to set HEAD for branch '{name}': {e}");
e
})?;
} else {
repo.set_head_detached(obj.id()).map_err(|e| {
error!("failed to set detached HEAD for branch '{name}': {e}");
e
})?;
// 2) Remote branch name like "origin/feature"
let mut tried_remote = false;
if name.contains('/') {
if repo.find_branch(name, g::BranchType::Remote).is_ok() {
tried_remote = true;
let local = name.split('/').last().unwrap_or(name);
if repo.find_branch(local, g::BranchType::Local).is_err() {
// Create local branch at the remote target
let rb = repo.find_branch(name, g::BranchType::Remote)?;
let target = rb.get().target().ok_or_else(|| g::Error::from_str("remote branch has no target"))?;
let commit = repo.find_commit(target)?;
repo.branch(local, &commit, false)?;
// Set upstream to remote
let mut lb = repo.find_branch(local, g::BranchType::Local)?;
lb.set_upstream(Some(name))?;
}
checkout_ref(repo, &format!("refs/heads/{}", local))?;
info!("created and checked out tracking branch '{}' for remote '{}'", local, name);
return Ok(());
}
}

info!("successfully checked out branch '{name}'");
Ok(())
// 3) Try default remote "origin/<name>"
if !tried_remote {
let remote_short = format!("origin/{name}");
if repo.find_branch(&remote_short, g::BranchType::Remote).is_ok() {
let local = name;
if repo.find_branch(local, g::BranchType::Local).is_err() {
let rb = repo.find_branch(&remote_short, g::BranchType::Remote)?;
let target = rb.get().target().ok_or_else(|| g::Error::from_str("remote branch has no target"))?;
let commit = repo.find_commit(target)?;
repo.branch(local, &commit, false)?;
let mut lb = repo.find_branch(local, g::BranchType::Local)?;
lb.set_upstream(Some(&remote_short))?;
}
checkout_ref(repo, &format!("refs/heads/{local}"))?;
info!("created and checked out tracking branch '{}' for remote '{}'", local, remote_short);
return Ok(());
}
}

// 4) Fallback to local ref (may detach if it's a commit)
checkout_ref(repo, &format!("refs/heads/{name}"))
.or_else(|_| checkout_ref(repo, name))
.map_err(|_| GitError::NoSuchBranch(name.into()))
})
}

Expand Down
43 changes: 43 additions & 0 deletions crates/openvcs-git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,49 @@ impl Vcs for GitSystem {
}

fn checkout_branch(&self, name: &str) -> Result<()> {
// 1) If local branch exists, just checkout
if Self::run_git_capture(
Some(&self.workdir),
["rev-parse", "--verify", "--quiet", &format!("refs/heads/{name}")],
).is_ok() {
return Self::run_git(Some(&self.workdir), ["checkout", name]);
}

// 2) If a matching remote branch exists, create a local tracking branch and checkout
let try_remote = |remote_ref: &str, local_name: &str| -> Result<bool> {
if Self::run_git_capture(
Some(&self.workdir),
["rev-parse", "--verify", "--quiet", remote_ref],
).is_ok() {
// If local already exists under the derived name, just checkout it.
if Self::run_git_capture(
Some(&self.workdir),
["rev-parse", "--verify", "--quiet", &format!("refs/heads/{local_name}")],
).is_ok() {
Self::run_git(Some(&self.workdir), ["checkout", local_name])?;
} else {
// Create a local tracking branch from the remote
// Equivalent to: git checkout -b <local_name> --track <remote_ref_short>
let short = if let Some((_, s)) = remote_ref.split_once("refs/remotes/") { s } else { remote_ref };
Self::run_git(Some(&self.workdir), ["checkout", "-b", local_name, "--track", short])?;
}
return Ok(true);
}
Ok(false)
};

// name may be like "origin/feature" (remote) or just "feature"
if let Some((_remote, rest)) = name.split_once('/') {
// refs/remotes/<name>
let remote_ref = format!("refs/remotes/{name}");
if try_remote(&remote_ref, rest)? { return Ok(()); }
} else {
// Try origin/<name> by default
let remote_ref = format!("refs/remotes/origin/{name}");
if try_remote(&remote_ref, name)? { return Ok(()); }
}

// 3) Fallback to a direct checkout (may detach if it's a commit)
Self::run_git(Some(&self.workdir), ["checkout", name])
}

Expand Down
Loading