diff --git a/apps/staged/justfile b/apps/staged/justfile index f825d91d..a7009b6b 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -148,7 +148,7 @@ lint: # Run Rust unit tests test: - cd src-tauri && cargo test + cd src-tauri && cargo test --lib --bins --tests # Run frontend unit tests test-frontend: diff --git a/apps/staged/src-tauri/src/blox.rs b/apps/staged/src-tauri/src/blox.rs index e1af6464..78e7335e 100644 --- a/apps/staged/src-tauri/src/blox.rs +++ b/apps/staged/src-tauri/src/blox.rs @@ -5,7 +5,7 @@ use std::sync::OnceLock; -pub use blox_cli::{BloxError, WorkspaceCommand, WorkspaceInfo, WorkspaceListEntry}; +pub use blox_cli::{BloxError, WorkspaceCommand, WorkspaceInfo, WorkspaceListEntry, WsExecOutput}; static SQ_AVAILABLE: OnceLock = OnceLock::new(); @@ -68,6 +68,12 @@ pub fn ws_exec_bytes(name: &str, args: &[&str]) -> Result, BloxError> { blox_cli::ws_exec_bytes(name, args) } +/// Execute a command inside a Blox workspace, returning full output regardless +/// of exit code. Infrastructure errors still return `Err`. +pub fn ws_exec_output(name: &str, args: &[&str]) -> Result { + blox_cli::ws_exec_output(name, args) +} + /// Quick authentication check — runs `sq blox ws list` and inspects the result. /// /// Returns `Ok(())` if the user appears to be authenticated, or diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 80e52701..2ef9f877 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -213,6 +213,26 @@ pub(crate) fn run_workspace_git( blox::ws_exec(workspace_name, &borrowed) } +/// Execute a shell script inside a Blox workspace via `sh -c`. +/// +/// Positional arguments are passed as `$1`, `$2`, etc. This allows batching +/// multiple git commands into a single `ws_exec` round-trip. +pub(crate) fn run_workspace_shell( + workspace_name: &str, + script: &str, + args: &[&str], +) -> Result { + let mut owned = Vec::::with_capacity(3 + args.len()); + owned.push("sh".to_string()); + owned.push("-c".to_string()); + owned.push(script.to_string()); + // $0 placeholder (conventional for sh -c) + owned.push("_".to_string()); + owned.extend(args.iter().map(|arg| (*arg).to_string())); + let borrowed = owned.iter().map(String::as_str).collect::>(); + blox::ws_exec(workspace_name, &borrowed) +} + pub(crate) fn run_workspace_git_bytes( workspace_name: &str, repo_subpath: Option<&str>, diff --git a/apps/staged/src-tauri/src/diff_commands.rs b/apps/staged/src-tauri/src/diff_commands.rs index 41e20cbe..0a6a2c43 100644 --- a/apps/staged/src-tauri/src/diff_commands.rs +++ b/apps/staged/src-tauri/src/diff_commands.rs @@ -4,9 +4,12 @@ use crate::branches; use crate::git; use crate::store::Store; use serde::Serialize; -use std::path::Path; +use std::path::{Component, Path}; +use std::process::Command; use std::sync::{Arc, Mutex}; +const WORKTREE_TEXT_PREVIEW_MAX_BYTES: u64 = 1024 * 1024; + /// Context needed to compute diffs for a branch. pub(crate) struct BranchDiffContext { pub base_branch: String, @@ -108,6 +111,10 @@ fn build_diff_spec( }; Ok((spec, sha.to_string())) } + "worktree" => { + let resolved_sha = git::get_head_sha(worktree).map_err(|e| e.to_string())?; + Ok((git::DiffSpec::uncommitted(), resolved_sha)) + } _ => { let resolved_sha = match commit_sha { Some(sha) => sha.to_string(), @@ -188,6 +195,81 @@ pub(crate) fn file_content_from_bytes(bytes: &[u8], path: &str) -> git::FileCont } } +fn file_content_from_bytes_with_text_limit(bytes: &[u8], path: &str) -> git::FileContent { + let check_len = bytes.len().min(8192); + if bytes[..check_len].contains(&0) { + return file_content_binary_or_image(bytes, path); + } + if bytes.len() as u64 > WORKTREE_TEXT_PREVIEW_MAX_BYTES { + return git::FileContent::Binary; + } + let text = String::from_utf8_lossy(bytes); + git::FileContent::Text { + lines: text.lines().map(|line| line.to_string()).collect(), + } +} + +fn validate_relative_diff_path(path: &str) -> Result<(), String> { + let path = Path::new(path); + if path.is_absolute() { + return Err("Diff path must be relative".to_string()); + } + for component in path.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + _ => return Err("Diff path contains an invalid segment".to_string()), + } + } + Ok(()) +} + +fn file_exists_at_head(worktree: &Path, path: &str) -> Result { + let spec = format!("HEAD:{path}"); + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree) + .args(["cat-file", "-e", &spec]); + git::strip_git_env(&mut command); + let output = command.output().map_err(|e| e.to_string())?; + Ok(output.status.success()) +} + +fn large_added_worktree_file_diff( + worktree: &Path, + path: &str, +) -> Result, String> { + validate_relative_diff_path(path)?; + if file_exists_at_head(worktree, path)? { + return Ok(None); + } + + let full_path = worktree.join(path); + let Ok(metadata) = std::fs::metadata(&full_path) else { + return Ok(None); + }; + if !metadata.is_file() || metadata.len() <= WORKTREE_TEXT_PREVIEW_MAX_BYTES { + return Ok(None); + } + + let content = if metadata.len() <= git::IMAGE_PREVIEW_MAX_BYTES as u64 { + let bytes = std::fs::read(&full_path) + .map_err(|e| format!("Cannot read worktree file {path}: {e}"))?; + file_content_from_bytes_with_text_limit(&bytes, path) + } else { + git::FileContent::Binary + }; + + Ok(Some(git::FileDiff { + before: None, + after: Some(git::File { + path: path.to_string(), + content, + }), + alignments: Vec::new(), + })) +} + /// For binary content in the remote path, try to produce an ImageBase64 variant. fn file_content_binary_or_image(bytes: &[u8], path: &str) -> git::FileContent { if bytes.len() > git::IMAGE_PREVIEW_MAX_BYTES { @@ -436,6 +518,10 @@ pub async fn get_diff_files( }); } + if scope == "worktree" { + return Err("Worktree diff is only available for local branches".to_string()); + } + // Remote branch — check cache, then collect on miss. let latest_sha = store .list_commits_for_branch(&branch_id) @@ -515,6 +601,11 @@ pub async fn get_file_diff( let worktree = Path::new(worktree_path); let (spec, _) = build_diff_spec(worktree, &ctx.base_branch, Some(&commit_sha), &scope)?; let file_path = Path::new(&path); + if scope == "worktree" { + if let Some(diff) = large_added_worktree_file_diff(worktree, &path)? { + return Ok(diff); + } + } let result = git::get_file_diff(worktree, &spec, file_path).map_err(|e| e.to_string())?; fn file_stats(f: &Option) -> (usize, usize) { match f { @@ -538,6 +629,10 @@ pub async fn get_file_diff( return Ok(result); } + if scope == "worktree" { + return Err("Worktree diff is only available for local branches".to_string()); + } + // Check cache for branch-scope diffs. if scope == "branch" { if let Some(file_diff) = crate::diff_cache::load_cached_file_diff( diff --git a/apps/staged/src-tauri/src/git/mod.rs b/apps/staged/src-tauri/src/git/mod.rs index 549b50e6..0567fd00 100644 --- a/apps/staged/src-tauri/src/git/mod.rs +++ b/apps/staged/src-tauri/src/git/mod.rs @@ -5,6 +5,10 @@ mod env; mod files; pub mod github; mod refs; +mod state; +#[cfg(test)] +mod state_tests; +mod status_parse; mod types; mod worktree; @@ -32,13 +36,21 @@ pub use refs::{ get_repo_root, list_branches, list_refs, merge_base, origin_ref_for_branch, prune_remote, resolve_ref, BranchRef, }; +pub use state::{ + complete_local_git_state, compute_branch_git_state, compute_branch_git_state_batched, + compute_fast_git_state_batched, compute_fast_local_git_state, compute_local_branch_git_state, + ensure_fast_forward_pullable, fast_forward_to_ref, local_git_state_cache_key, needs_fetch, + BaseGitState, BranchGitState, FastGitState, FetchGitState, FetchMode, FetchStatus, + UpstreamGitState, UpstreamRelation, WorktreeGitState, +}; pub use types::*; pub use worktree::{ branch_exists, create_worktree, create_worktree_at_path, create_worktree_for_existing_branch, create_worktree_for_existing_branch_at_path, create_worktree_from_pr, - create_worktree_from_pr_at_path, fetch_pr_head_sha, get_commits_since_base, - get_full_commit_log, get_head_sha, get_parent_commit, has_unpushed_commits, list_worktrees, + create_worktree_from_pr_at_path, discard_worktree_changes, fetch_pr_head_sha, + get_commits_since_base, get_full_commit_log, get_head_sha, get_parent_commit, + has_unpushed_commits, list_worktree_change_paths, list_worktrees, parse_worktree_status_paths, project_worktree_path_for, project_worktree_root_for, remote_branch_exists, remove_worktree, reset_to_commit, set_upstream_to_origin, switch_branch, update_branch_from_pr, - worktree_path_for, CommitInfo, UpdateFromPrResult, + worktree_path_for, CommitInfo, UpdateFromPrResult, WorktreeChangePaths, }; diff --git a/apps/staged/src-tauri/src/git/state.rs b/apps/staged/src-tauri/src/git/state.rs new file mode 100644 index 00000000..46959c2a --- /dev/null +++ b/apps/staged/src-tauri/src/git/state.rs @@ -0,0 +1,1213 @@ +use super::cli::{self, GitError}; +use super::refs::{branch_name_without_origin, origin_ref_for_branch}; +use super::status_parse::is_conflicted_status; +use serde::Serialize; +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const FETCH_TTL_MS: i64 = 30_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FetchMode { + Ttl, + Force, + Never, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BranchGitState { + pub head_sha: Option, + pub current_branch: Option, + pub detached_head: bool, + pub expected_branch_matches: bool, + pub upstream: UpstreamGitState, + pub base: BaseGitState, + pub worktree: WorktreeGitState, + pub fetch: FetchGitState, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UpstreamGitState { + pub r#ref: String, + pub exists: bool, + pub sha: Option, + pub relation: UpstreamRelation, + pub ahead: u32, + pub behind: u32, + pub merge_base_sha: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UpstreamRelation { + Missing, + InSync, + LocalAhead, + OriginAhead, + Diverged, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BaseGitState { + pub r#ref: String, + pub sha: Option, + pub commits_since_fork: u32, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeGitState { + pub dirty: bool, + pub staged: u32, + pub unstaged: u32, + pub untracked: u32, + pub conflicted: u32, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FetchGitState { + pub status: FetchStatus, + pub fetched_at: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum FetchStatus { + Fresh, + Stale, + Failed, +} + +/// Fast (local-only) git state — no fetch, no ref comparisons. +/// Used for the fast stream of the two-stream timeline split. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FastGitState { + pub head_sha: Option, + pub current_branch: Option, + pub detached_head: bool, + pub expected_branch_matches: bool, + pub worktree: WorktreeGitState, +} + +impl FastGitState { + /// Convert to a full BranchGitState with placeholder upstream/base/fetch fields. + /// Used to build the partial timeline before the slow stream (fetch + refs) completes. + pub fn into_placeholder_git_state( + self, + branch_name: &str, + base_branch: &str, + ) -> BranchGitState { + let upstream_ref = origin_ref_for_branch(branch_name); + let base_ref = origin_ref_for_branch(base_branch); + BranchGitState { + head_sha: self.head_sha, + current_branch: self.current_branch, + detached_head: self.detached_head, + expected_branch_matches: self.expected_branch_matches, + worktree: self.worktree, + upstream: UpstreamGitState { + r#ref: upstream_ref, + exists: false, + sha: None, + relation: UpstreamRelation::Missing, + ahead: 0, + behind: 0, + merge_base_sha: None, + }, + base: BaseGitState { + r#ref: base_ref, + sha: None, + commits_since_fork: 0, + }, + fetch: FetchGitState { + status: FetchStatus::Stale, + fetched_at: None, + error: None, + }, + } + } +} + +#[derive(Debug, Clone)] +struct FetchCacheEntry { + fetched_at: i64, + upstream_known_missing: bool, +} + +#[derive(Debug, Clone)] +struct RefreshOutcome { + fetch: FetchGitState, + upstream_known_missing: bool, +} + +fn fetch_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} + +fn trim_non_empty(output: String) -> Option { + let trimmed = output.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn is_missing_remote_ref(error: &str) -> bool { + let lower = error.to_ascii_lowercase(); + lower.contains("couldn't find remote ref") || lower.contains("could not find remote ref") +} + +fn refspec_for(remote_branch: &str) -> String { + let branch = branch_name_without_origin(remote_branch); + format!("+refs/heads/{branch}:refs/remotes/origin/{branch}") +} + +fn refresh_refs_if_needed( + cache_key: &str, + run_git: &F, + branch_name: &str, + base_branch: &str, + fetch_mode: FetchMode, +) -> RefreshOutcome +where + F: Fn(&[&str]) -> Result, +{ + let now = now_ms(); + let previous = fetch_cache() + .lock() + .ok() + .and_then(|cache| cache.get(cache_key).cloned()); + + let should_fetch = match (fetch_mode, &previous) { + (FetchMode::Never, _) => false, + (FetchMode::Force, _) => true, + (FetchMode::Ttl, Some(entry)) => now.saturating_sub(entry.fetched_at) > FETCH_TTL_MS, + (FetchMode::Ttl, None) => true, + }; + + if !should_fetch { + let fetched_at = previous.as_ref().map(|entry| entry.fetched_at); + return RefreshOutcome { + fetch: FetchGitState { + status: if fetched_at.is_some() { + FetchStatus::Fresh + } else { + FetchStatus::Stale + }, + fetched_at, + error: None, + }, + upstream_known_missing: previous + .as_ref() + .map(|entry| entry.upstream_known_missing) + .unwrap_or(false), + }; + } + + let base_refspec = refspec_for(base_branch); + let branch_refspec = refspec_for(branch_name); + let mut upstream_known_missing = false; + + // Fetch both refspecs in a single network call when they differ. + if branch_refspec != base_refspec { + match run_git(&[ + "fetch", + "--prune", + "origin", + base_refspec.as_str(), + branch_refspec.as_str(), + ]) { + Err(error) if is_missing_remote_ref(&error) => { + // The branch refspec is missing on the remote. Re-fetch with + // just the base refspec so we still get base branch updates. + if let Err(base_err) = + run_git(&["fetch", "--prune", "origin", base_refspec.as_str()]) + { + return RefreshOutcome { + fetch: FetchGitState { + status: FetchStatus::Failed, + fetched_at: previous.map(|entry| entry.fetched_at), + error: Some(base_err.trim().to_string()), + }, + upstream_known_missing: false, + }; + } + upstream_known_missing = true; + } + Err(error) => { + return RefreshOutcome { + fetch: FetchGitState { + status: FetchStatus::Failed, + fetched_at: previous.map(|entry| entry.fetched_at), + error: Some(error.trim().to_string()), + }, + upstream_known_missing: false, + }; + } + Ok(_) => {} + } + } else if let Err(error) = run_git(&["fetch", "--prune", "origin", base_refspec.as_str()]) { + return RefreshOutcome { + fetch: FetchGitState { + status: FetchStatus::Failed, + fetched_at: previous.map(|entry| entry.fetched_at), + error: Some(error.trim().to_string()), + }, + upstream_known_missing: false, + }; + } + + if let Ok(mut cache) = fetch_cache().lock() { + cache.insert( + cache_key.to_string(), + FetchCacheEntry { + fetched_at: now, + upstream_known_missing, + }, + ); + } + + RefreshOutcome { + fetch: FetchGitState { + status: FetchStatus::Fresh, + fetched_at: Some(now), + error: None, + }, + upstream_known_missing, + } +} + +fn parse_u32(raw: Option<&str>) -> u32 { + raw.and_then(|s| s.parse::().ok()).unwrap_or(0) +} + +fn rev_count(run_git: &F, range: &str) -> u32 +where + F: Fn(&[&str]) -> Result, +{ + run_git(&["rev-list", "--count", range]) + .ok() + .and_then(trim_non_empty) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) +} + +fn current_branch(run_git: &F) -> Option +where + F: Fn(&[&str]) -> Result, +{ + run_git(&["branch", "--show-current"]) + .ok() + .and_then(trim_non_empty) +} + +fn resolve_ref(run_git: &F, reference: &str) -> Option +where + F: Fn(&[&str]) -> Result, +{ + run_git(&["rev-parse", "--verify", reference]) + .ok() + .and_then(trim_non_empty) +} + +fn merge_base(run_git: &F, left: &str, right: &str) -> Option +where + F: Fn(&[&str]) -> Result, +{ + run_git(&["merge-base", left, right]) + .ok() + .and_then(trim_non_empty) +} + +fn compute_upstream_state( + run_git: &F, + upstream_ref: String, + head_sha: Option<&str>, + upstream_known_missing: bool, +) -> UpstreamGitState +where + F: Fn(&[&str]) -> Result, +{ + let sha = if upstream_known_missing { + None + } else { + resolve_ref(run_git, &upstream_ref) + }; + let exists = sha.is_some(); + + if !exists || head_sha.is_none() { + return UpstreamGitState { + r#ref: upstream_ref, + exists, + sha, + relation: UpstreamRelation::Missing, + ahead: 0, + behind: 0, + merge_base_sha: None, + }; + } + + let counts_range = format!("HEAD...{upstream_ref}"); + let (ahead, behind) = run_git(&["rev-list", "--left-right", "--count", counts_range.as_str()]) + .ok() + .map(|output| { + let mut parts = output.split_whitespace(); + (parse_u32(parts.next()), parse_u32(parts.next())) + }) + .unwrap_or((0, 0)); + let relation = match (ahead, behind) { + (0, 0) => UpstreamRelation::InSync, + (_, 0) => UpstreamRelation::LocalAhead, + (0, _) => UpstreamRelation::OriginAhead, + _ => UpstreamRelation::Diverged, + }; + let merge_base_sha = merge_base(run_git, "HEAD", &upstream_ref); + + UpstreamGitState { + r#ref: upstream_ref, + exists, + sha, + relation, + ahead, + behind, + merge_base_sha, + } +} + +fn compute_base_state(run_git: &F, base_ref: String, head_sha: Option<&str>) -> BaseGitState +where + F: Fn(&[&str]) -> Result, +{ + let sha = resolve_ref(run_git, &base_ref); + let commits_since_fork = if sha.is_some() && head_sha.is_some() { + merge_base(run_git, "HEAD", &base_ref) + .map(|base| rev_count(run_git, &format!("{base}..{base_ref}"))) + .unwrap_or(0) + } else { + 0 + }; + + BaseGitState { + r#ref: base_ref, + sha, + commits_since_fork, + } +} + +fn compute_worktree_state(run_git: &F) -> WorktreeGitState +where + F: Fn(&[&str]) -> Result, +{ + let Ok(output) = run_git(&["status", "--porcelain=1", "--untracked-files=all"]) else { + return WorktreeGitState { + dirty: false, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + }; + }; + + parse_worktree_from_status(&output) +} + +pub fn compute_branch_git_state( + cache_key: &str, + run_git: F, + branch_name: &str, + base_branch: &str, + fetch_mode: FetchMode, +) -> BranchGitState +where + F: Fn(&[&str]) -> Result + Sync, +{ + // Phase 1: Fetch runs in parallel with local-only commands (HEAD, branch + // name, worktree status) since those read local state unaffected by fetch. + let (refresh, head_sha, branch, worktree) = std::thread::scope(|s| { + let fetch_handle = s.spawn(|| { + refresh_refs_if_needed(cache_key, &run_git, branch_name, base_branch, fetch_mode) + }); + let head_handle = s.spawn(|| { + run_git(&["rev-parse", "HEAD"]) + .ok() + .and_then(trim_non_empty) + }); + let branch_handle = s.spawn(|| current_branch(&run_git)); + let worktree_handle = s.spawn(|| compute_worktree_state(&run_git)); + + ( + fetch_handle.join().expect("fetch thread panicked"), + head_handle.join().expect("head thread panicked"), + branch_handle.join().expect("branch thread panicked"), + worktree_handle.join().expect("worktree thread panicked"), + ) + }); + + let detached_head = head_sha.is_some() && branch.is_none(); + let expected_branch = branch_name_without_origin(branch_name); + let expected_branch_matches = branch + .as_deref() + .map(|current| current == expected_branch) + .unwrap_or(false); + let upstream_ref = origin_ref_for_branch(branch_name); + let base_ref = origin_ref_for_branch(base_branch); + + // Phase 2: Upstream and base state computations are independent of each + // other but both need fetch to have completed (for up-to-date remote refs) + // and head_sha. + let (upstream, base) = std::thread::scope(|s| { + let upstream_handle = s.spawn(|| { + compute_upstream_state( + &run_git, + upstream_ref, + head_sha.as_deref(), + refresh.upstream_known_missing, + ) + }); + let base_handle = s.spawn(|| compute_base_state(&run_git, base_ref, head_sha.as_deref())); + + ( + upstream_handle.join().expect("upstream thread panicked"), + base_handle.join().expect("base thread panicked"), + ) + }); + + BranchGitState { + upstream, + base, + worktree, + fetch: refresh.fetch, + head_sha, + current_branch: branch, + detached_head, + expected_branch_matches, + } +} + +pub fn compute_local_branch_git_state( + repo: &Path, + branch_name: &str, + base_branch: &str, + fetch_mode: FetchMode, +) -> BranchGitState { + let cache_key = format!("local:{}:{}:{}", repo.display(), branch_name, base_branch); + compute_branch_git_state( + &cache_key, + |args| cli::run(repo, args).map_err(|e| e.to_string()), + branch_name, + base_branch, + fetch_mode, + ) +} + +/// Check whether a fetch is needed for the given cache key and mode. +/// Used by timeline to decide whether to use the two-stream path. +pub fn needs_fetch(cache_key: &str, fetch_mode: FetchMode) -> bool { + let now = now_ms(); + let previous = fetch_cache() + .lock() + .ok() + .and_then(|cache| cache.get(cache_key).cloned()); + + match (fetch_mode, &previous) { + (FetchMode::Never, _) => false, + (FetchMode::Force, _) => true, + (FetchMode::Ttl, Some(entry)) => now.saturating_sub(entry.fetched_at) > FETCH_TTL_MS, + (FetchMode::Ttl, None) => true, + } +} + +/// Compute fast (local-only) git state for a local branch. +/// Returns HEAD, branch name, and worktree status without any fetch. +pub fn compute_fast_local_git_state(repo: &Path, branch_name: &str) -> FastGitState { + let run_git = |args: &[&str]| -> Result { + cli::run(repo, args).map_err(|e| e.to_string()) + }; + let (head_sha, branch, worktree) = std::thread::scope(|s| { + let h = s.spawn(|| { + run_git(&["rev-parse", "HEAD"]) + .ok() + .and_then(trim_non_empty) + }); + let b = s.spawn(|| current_branch(&run_git)); + let w = s.spawn(|| compute_worktree_state(&run_git)); + ( + h.join().expect("head thread panicked"), + b.join().expect("branch thread panicked"), + w.join().expect("worktree thread panicked"), + ) + }); + let expected = branch_name_without_origin(branch_name); + FastGitState { + detached_head: head_sha.is_some() && branch.is_none(), + expected_branch_matches: branch.as_deref().map(|c| c == expected).unwrap_or(false), + head_sha, + current_branch: branch, + worktree, + } +} + +/// Complete a local branch git state: runs fetch + ref comparisons, combining +/// with a pre-computed `FastGitState`. Used by the slow stream after the +/// partial timeline has been emitted. +pub fn complete_local_git_state( + repo: &Path, + fast: &FastGitState, + branch_name: &str, + base_branch: &str, + fetch_mode: FetchMode, +) -> BranchGitState { + let cache_key = format!("local:{}:{}:{}", repo.display(), branch_name, base_branch); + let run_git = |args: &[&str]| -> Result { + cli::run(repo, args).map_err(|e| e.to_string()) + }; + + let refresh = + refresh_refs_if_needed(&cache_key, &run_git, branch_name, base_branch, fetch_mode); + let upstream_ref = origin_ref_for_branch(branch_name); + let base_ref = origin_ref_for_branch(base_branch); + + let (upstream, base) = std::thread::scope(|s| { + let u = s.spawn(|| { + compute_upstream_state( + &run_git, + upstream_ref, + fast.head_sha.as_deref(), + refresh.upstream_known_missing, + ) + }); + let b = s.spawn(|| compute_base_state(&run_git, base_ref, fast.head_sha.as_deref())); + ( + u.join().expect("upstream thread panicked"), + b.join().expect("base thread panicked"), + ) + }); + + BranchGitState { + head_sha: fast.head_sha.clone(), + current_branch: fast.current_branch.clone(), + detached_head: fast.detached_head, + expected_branch_matches: fast.expected_branch_matches, + worktree: fast.worktree.clone(), + fetch: refresh.fetch, + upstream, + base, + } +} + +pub fn local_git_state_cache_key(repo: &Path, branch_name: &str, base_branch: &str) -> String { + format!("local:{}:{}:{}", repo.display(), branch_name, base_branch) +} + +// --------------------------------------------------------------------------- +// Batched computation for remote projects +// --------------------------------------------------------------------------- +// +// Instead of N separate round-trips (each `run_git` call is a `ws_exec`), +// we batch all git commands into a single shell script that performs fetch +// (when needed), local state collection, and ref comparisons in one call. +// +// The fetch TTL cache controls whether the fetch phase runs within the +// script (via a "skip_fetch" flag argument). + +/// Combined shell script that performs fetch, collects local state, and computes +/// upstream + base ref state in a single round-trip. +/// +/// Arguments: +/// $1 = repo_path +/// $2 = base_refspec +/// $3 = branch_refspec (empty if same as base) +/// $4 = upstream_ref (empty to skip upstream resolution) +/// $5 = base_ref +/// $6 = "skip_fetch" to skip the fetch phase (cache fresh) +const BATCH_GIT_STATE_SCRIPT: &str = concat!( + "cd \"$1\" || exit 1\n", + // --- Fetch phase (conditional) --- + "fetch_err=''\n", + "upstream_missing=''\n", + "if [ \"$6\" != 'skip_fetch' ]; then\n", + " _ferr=$(mktemp) || exit 1\n", + " trap 'rm -f \"$_ferr\"' EXIT\n", + " if [ -n \"$3\" ]; then\n", + " if ! git fetch --prune origin \"$2\" \"$3\" 2>\"$_ferr\"; then\n", + " if grep -qi 'could.not.find.remote.ref\\|couldn.t.find.remote.ref' \"$_ferr\"; then\n", + " if ! git fetch --prune origin \"$2\" 2>\"$_ferr\"; then\n", + " fetch_err=$(cat \"$_ferr\")\n", + " else\n", + " upstream_missing=true\n", + " fi\n", + " else\n", + " fetch_err=$(cat \"$_ferr\")\n", + " fi\n", + " fi\n", + " else\n", + " if ! git fetch --prune origin \"$2\" 2>\"$_ferr\"; then\n", + " fetch_err=$(cat \"$_ferr\")\n", + " fi\n", + " fi\n", + "fi\n", + // --- Local state --- + "head_sha=$(git rev-parse HEAD 2>/dev/null || true)\n", + "printf 'HEAD=%s\\n' \"$head_sha\"\n", + "printf 'BRANCH=%s\\n' \"$(git branch --show-current 2>/dev/null || true)\"\n", + "echo STATUS_START\n", + "git status --porcelain=1 --untracked-files=all 2>/dev/null || true\n", + "echo STATUS_END\n", + "[ -n \"$upstream_missing\" ] && echo 'UPSTREAM_MISSING=true'\n", + "[ -n \"$fetch_err\" ] && printf 'FETCH_ERR=%s\\n' \"$fetch_err\"\n", + // --- Upstream state (skip if $4 is empty) --- + "if [ -n \"$4\" ] && [ -z \"$upstream_missing\" ]; then\n", + " up_sha=$(git rev-parse --verify \"$4\" 2>/dev/null || true)\n", + "else\n", + " up_sha=''\n", + "fi\n", + "printf 'UP_SHA=%s\\n' \"$up_sha\"\n", + "if [ -n \"$up_sha\" ] && [ -n \"$head_sha\" ]; then\n", + " printf 'UP_COUNTS=%s\\n' \"$(git rev-list --left-right --count \"$head_sha\"...\"$4\" 2>/dev/null || echo '0 0')\"\n", + " printf 'UP_MB=%s\\n' \"$(git merge-base \"$head_sha\" \"$4\" 2>/dev/null || true)\"\n", + "fi\n", + // --- Base state --- + "base_sha=$(git rev-parse --verify \"$5\" 2>/dev/null || true)\n", + "printf 'BASE_SHA=%s\\n' \"$base_sha\"\n", + "if [ -n \"$base_sha\" ] && [ -n \"$head_sha\" ]; then\n", + " mb=$(git merge-base \"$head_sha\" \"$5\" 2>/dev/null || true)\n", + " printf 'BASE_MB=%s\\n' \"$mb\"\n", + " if [ -n \"$mb\" ]; then\n", + " printf 'BASE_BEHIND=%s\\n' \"$(git rev-list --count \"$mb\"..\"$5\" 2>/dev/null || echo 0)\"\n", + " fi\n", + "fi\n", + "exit 0\n", +); + +/// Parsed output from the combined git state script. +struct BatchGitStateOutput { + head_sha: Option, + branch: Option, + status_lines: String, + upstream_missing: bool, + fetch_error: Option, + up_sha: Option, + up_ahead: u32, + up_behind: u32, + up_merge_base: Option, + base_sha: Option, + base_behind: u32, +} + +fn parse_batch_git_state_output(raw: &str) -> BatchGitStateOutput { + let mut head_sha = None; + let mut branch = None; + let mut upstream_missing = false; + let mut fetch_error = None; + let mut status_lines = String::new(); + let mut in_status = false; + let mut up_sha = None; + let mut up_ahead = 0u32; + let mut up_behind = 0u32; + let mut up_merge_base = None; + let mut base_sha = None; + let mut base_behind = 0u32; + + for line in raw.lines() { + if line == "STATUS_START" { + in_status = true; + continue; + } + if line == "STATUS_END" { + in_status = false; + continue; + } + if in_status { + if !status_lines.is_empty() { + status_lines.push('\n'); + } + status_lines.push_str(line); + continue; + } + if let Some(val) = line.strip_prefix("HEAD=") { + let v = val.trim(); + if !v.is_empty() { + head_sha = Some(v.to_string()); + } + } else if let Some(val) = line.strip_prefix("BRANCH=") { + let v = val.trim(); + if !v.is_empty() { + branch = Some(v.to_string()); + } + } else if line.starts_with("UPSTREAM_MISSING=") { + upstream_missing = true; + } else if let Some(val) = line.strip_prefix("FETCH_ERR=") { + let v = val.trim(); + if !v.is_empty() { + fetch_error = Some(v.to_string()); + } + } else if let Some(val) = line.strip_prefix("UP_SHA=") { + let v = val.trim(); + if !v.is_empty() { + up_sha = Some(v.to_string()); + } + } else if let Some(val) = line.strip_prefix("UP_COUNTS=") { + let mut parts = val.split_whitespace(); + up_ahead = parse_u32(parts.next()); + up_behind = parse_u32(parts.next()); + } else if let Some(val) = line.strip_prefix("UP_MB=") { + let v = val.trim(); + if !v.is_empty() { + up_merge_base = Some(v.to_string()); + } + } else if let Some(val) = line.strip_prefix("BASE_SHA=") { + let v = val.trim(); + if !v.is_empty() { + base_sha = Some(v.to_string()); + } + } else if line.starts_with("BASE_MB=") { + // Used internally by the script to compute BASE_BEHIND. + } else if let Some(val) = line.strip_prefix("BASE_BEHIND=") { + base_behind = parse_u32(Some(val.trim())); + } + } + + BatchGitStateOutput { + head_sha, + branch, + status_lines, + upstream_missing, + fetch_error, + up_sha, + up_ahead, + up_behind, + up_merge_base, + base_sha, + base_behind, + } +} + +fn parse_worktree_from_status(status_output: &str) -> WorktreeGitState { + let mut state = WorktreeGitState { + dirty: false, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + }; + + for line in status_output.lines() { + let mut chars = line.chars(); + let x = chars.next().unwrap_or(' '); + let y = chars.next().unwrap_or(' '); + + if x == '?' && y == '?' { + state.untracked += 1; + continue; + } + if is_conflicted_status(x, y) { + state.conflicted += 1; + continue; + } + if x != ' ' { + state.staged += 1; + } + if y != ' ' { + state.unstaged += 1; + } + } + + state.dirty = + state.staged > 0 || state.unstaged > 0 || state.untracked > 0 || state.conflicted > 0; + state +} + +// --------------------------------------------------------------------------- +// Fast script for remote two-stream split +// --------------------------------------------------------------------------- +// +// When a fetch is needed, the timeline uses two concurrent round-trips: +// 1. BATCH_FAST_SCRIPT — local state + commits (no fetch, returns immediately) +// 2. BATCH_GIT_STATE_SCRIPT — fetch + full ref comparisons (blocks on fetch) +// +// The fast script's output is used to emit a partial timeline event so +// commits + worktree rows appear before the slow stream completes. + +/// Fast local-only script for remote projects. +/// +/// Arguments: +/// $1 = repo_path +/// $2 = base_ref (e.g., "origin/main") — used for merge-base + git log +const BATCH_FAST_SCRIPT: &str = concat!( + "cd \"$1\" || exit 1\n", + "head_sha=$(git rev-parse HEAD 2>/dev/null || true)\n", + "printf 'HEAD=%s\\n' \"$head_sha\"\n", + "printf 'BRANCH=%s\\n' \"$(git branch --show-current 2>/dev/null || true)\"\n", + "echo STATUS_START\n", + "git status --porcelain=1 --untracked-files=all 2>/dev/null || true\n", + "echo STATUS_END\n", + // Commits using locally-cached refs + "mb=$(git merge-base \"$2\" HEAD 2>/dev/null || true)\n", + "if [ -n \"$mb\" ]; then\n", + " range=\"${mb}..HEAD\"\n", + "else\n", + " range=\"$2..HEAD\"\n", + "fi\n", + "echo COMMITS_START\n", + "git log --format='%H|%h|%s|%an|%ct' \"$range\" 2>/dev/null || true\n", + "echo COMMITS_END\n", + "exit 0\n", +); + +/// Parsed output from the fast local-only script. +pub struct BatchFastOutput { + pub head_sha: Option, + pub branch: Option, + pub status_lines: String, + pub commit_lines: Vec, +} + +pub fn parse_batch_fast_output(raw: &str) -> BatchFastOutput { + let mut head_sha = None; + let mut branch = None; + let mut status_lines = String::new(); + let mut commit_lines = Vec::new(); + let mut in_status = false; + let mut in_commits = false; + + for line in raw.lines() { + if line == "STATUS_START" { + in_status = true; + continue; + } + if line == "STATUS_END" { + in_status = false; + continue; + } + if line == "COMMITS_START" { + in_commits = true; + continue; + } + if line == "COMMITS_END" { + in_commits = false; + continue; + } + if in_status { + if !status_lines.is_empty() { + status_lines.push('\n'); + } + status_lines.push_str(line); + continue; + } + if in_commits { + if !line.is_empty() { + commit_lines.push(line.to_string()); + } + continue; + } + if let Some(val) = line.strip_prefix("HEAD=") { + let v = val.trim(); + if !v.is_empty() { + head_sha = Some(v.to_string()); + } + } else if let Some(val) = line.strip_prefix("BRANCH=") { + let v = val.trim(); + if !v.is_empty() { + branch = Some(v.to_string()); + } + } + } + + BatchFastOutput { + head_sha, + branch, + status_lines, + commit_lines, + } +} + +impl BatchFastOutput { + /// Convert to FastGitState. + pub fn into_fast_git_state(self, branch_name: &str) -> (FastGitState, Vec) { + let worktree = parse_worktree_from_status(&self.status_lines); + let expected = branch_name_without_origin(branch_name); + let fast = FastGitState { + detached_head: self.head_sha.is_some() && self.branch.is_none(), + expected_branch_matches: self + .branch + .as_deref() + .map(|c| c == expected) + .unwrap_or(false), + head_sha: self.head_sha, + current_branch: self.branch, + worktree, + }; + (fast, self.commit_lines) + } +} + +/// Run the fast local-only script on a remote workspace and return parsed output. +pub fn compute_fast_git_state_batched( + run_script: &F, + repo_path: &str, + base_branch: &str, +) -> Result +where + F: Fn(&str, &[&str]) -> Result, +{ + let base_ref = origin_ref_for_branch(base_branch); + let raw = run_script(BATCH_FAST_SCRIPT, &[repo_path, &base_ref])?; + Ok(parse_batch_fast_output(&raw)) +} + +/// Compute branch git state using a single batched shell script. +/// +/// This is the remote-optimised counterpart of `compute_branch_git_state`. +/// Instead of many individual `run_git` calls (each a separate `ws_exec` +/// round-trip), it bundles all commands into one shell script that performs +/// fetch (when needed), local state collection, and ref comparisons — giving +/// **1 round-trip total** regardless of whether fetch is cached or not. +pub fn compute_branch_git_state_batched( + cache_key: &str, + run_script: F, + repo_path: &str, + branch_name: &str, + base_branch: &str, + fetch_mode: FetchMode, +) -> BranchGitState +where + F: Fn(&str, &[&str]) -> Result, +{ + let base_refspec = refspec_for(base_branch); + let branch_refspec = refspec_for(branch_name); + let upstream_ref = origin_ref_for_branch(branch_name); + let base_ref = origin_ref_for_branch(base_branch); + let expected_branch = branch_name_without_origin(branch_name); + + // Check fetch cache to decide whether we need the fetch phase. + let now = now_ms(); + let previous = fetch_cache() + .lock() + .ok() + .and_then(|cache| cache.get(cache_key).cloned()); + + let should_fetch = match (fetch_mode, &previous) { + (FetchMode::Never, _) => false, + (FetchMode::Force, _) => true, + (FetchMode::Ttl, Some(entry)) => now.saturating_sub(entry.fetched_at) > FETCH_TTL_MS, + (FetchMode::Ttl, None) => true, + }; + + let branch_arg = if branch_refspec != base_refspec { + branch_refspec.as_str() + } else { + "" + }; + + // When cache says upstream is missing, pass empty upstream_ref so the + // script skips upstream resolution. + let cached_upstream_missing = previous + .as_ref() + .map(|e| e.upstream_known_missing) + .unwrap_or(false); + let up_ref_arg = if !should_fetch && cached_upstream_missing { + "" + } else { + upstream_ref.as_str() + }; + let skip_fetch_arg = if should_fetch { "" } else { "skip_fetch" }; + + let raw = run_script( + BATCH_GIT_STATE_SCRIPT, + &[ + repo_path, + &base_refspec, + branch_arg, + up_ref_arg, + &base_ref, + skip_fetch_arg, + ], + ); + + match raw { + Ok(output) => { + let parsed = parse_batch_git_state_output(&output); + + let upstream_known_missing = if should_fetch { + parsed.upstream_missing + } else { + cached_upstream_missing + }; + + // Build fetch state + let fetch_state = if should_fetch { + if let Some(ref err) = parsed.fetch_error { + FetchGitState { + status: FetchStatus::Failed, + fetched_at: previous.as_ref().map(|e| e.fetched_at), + error: Some(err.clone()), + } + } else { + if let Ok(mut cache) = fetch_cache().lock() { + cache.insert( + cache_key.to_string(), + FetchCacheEntry { + fetched_at: now, + upstream_known_missing, + }, + ); + } + FetchGitState { + status: FetchStatus::Fresh, + fetched_at: Some(now), + error: None, + } + } + } else { + let fetched_at = previous.as_ref().map(|e| e.fetched_at); + FetchGitState { + status: if fetched_at.is_some() { + FetchStatus::Fresh + } else { + FetchStatus::Stale + }, + fetched_at, + error: None, + } + }; + + let worktree = parse_worktree_from_status(&parsed.status_lines); + let detached_head = parsed.head_sha.is_some() && parsed.branch.is_none(); + let expected_branch_matches = parsed + .branch + .as_deref() + .map(|current| current == expected_branch) + .unwrap_or(false); + + // Build upstream state from parsed output + let up_exists = parsed.up_sha.is_some(); + let relation = if !up_exists || parsed.head_sha.is_none() { + UpstreamRelation::Missing + } else { + match (parsed.up_ahead, parsed.up_behind) { + (0, 0) => UpstreamRelation::InSync, + (_, 0) => UpstreamRelation::LocalAhead, + (0, _) => UpstreamRelation::OriginAhead, + _ => UpstreamRelation::Diverged, + } + }; + + let upstream = UpstreamGitState { + r#ref: upstream_ref, + exists: up_exists, + sha: parsed.up_sha, + relation, + ahead: parsed.up_ahead, + behind: parsed.up_behind, + merge_base_sha: parsed.up_merge_base, + }; + + let base = BaseGitState { + r#ref: base_ref, + sha: parsed.base_sha, + commits_since_fork: parsed.base_behind, + }; + + BranchGitState { + upstream, + base, + worktree, + fetch: fetch_state, + head_sha: parsed.head_sha, + current_branch: parsed.branch, + detached_head, + expected_branch_matches, + } + } + Err(err) => { + // Script execution itself failed + let fetched_at = previous.as_ref().map(|e| e.fetched_at); + BranchGitState { + upstream: UpstreamGitState { + r#ref: upstream_ref, + exists: false, + sha: None, + relation: UpstreamRelation::Missing, + ahead: 0, + behind: 0, + merge_base_sha: None, + }, + base: BaseGitState { + r#ref: base_ref, + sha: None, + commits_since_fork: 0, + }, + worktree: WorktreeGitState { + dirty: false, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + }, + fetch: if should_fetch { + FetchGitState { + status: FetchStatus::Failed, + fetched_at, + error: Some(err), + } + } else { + FetchGitState { + status: if fetched_at.is_some() { + FetchStatus::Fresh + } else { + FetchStatus::Stale + }, + fetched_at, + error: None, + } + }, + head_sha: None, + current_branch: None, + detached_head: false, + expected_branch_matches: false, + } + } + } +} + +pub fn fast_forward_to_ref(repo: &Path, reference: &str) -> Result<(), GitError> { + cli::run(repo, &["merge", "--ff-only", reference])?; + Ok(()) +} + +pub fn ensure_fast_forward_pullable(state: &BranchGitState) -> Result<(), String> { + if state.detached_head { + return Err("Cannot pull while HEAD is detached".to_string()); + } + if !state.expected_branch_matches { + let current = state + .current_branch + .as_deref() + .unwrap_or("an unknown branch"); + return Err(format!("Cannot pull while checked out on {current}")); + } + if state.worktree.dirty { + return Err("Cannot pull with uncommitted changes".to_string()); + } + if state.upstream.relation != UpstreamRelation::OriginAhead { + return Err("Branch is not behind origin, or cannot be fast-forwarded".to_string()); + } + Ok(()) +} diff --git a/apps/staged/src-tauri/src/git/state_tests.rs b/apps/staged/src-tauri/src/git/state_tests.rs new file mode 100644 index 00000000..00b60171 --- /dev/null +++ b/apps/staged/src-tauri/src/git/state_tests.rs @@ -0,0 +1,243 @@ +use super::state::{compute_local_branch_git_state, BranchGitState, FetchMode, UpstreamRelation}; +use super::strip_git_env; +use crate::test_utils::TempGitRepo; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use uuid::Uuid; + +struct TempPath { + path: PathBuf, +} + +impl TempPath { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!("staged-{label}-{}", Uuid::new_v4())); + fs::create_dir_all(&path).unwrap(); + Self { path } + } +} + +impl Drop for TempPath { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn run_git(repo: &Path, args: &[&str]) -> String { + let mut command = Command::new("git"); + command + .arg("-c") + .arg("core.hooksPath=/dev/null") + .arg("-C") + .arg(repo) + .args(args); + strip_git_env(&mut command); + + let output = command.output().unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +fn clone_repo(origin: &TempGitRepo) -> TempPath { + let clone_dir = TempPath::new("clone"); + let mut command = Command::new("git"); + command.arg("clone").arg(origin.path()).arg(&clone_dir.path); + strip_git_env(&mut command); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + run_git( + &clone_dir.path, + &["config", "user.email", "test@example.com"], + ); + run_git(&clone_dir.path, &["config", "user.name", "Test"]); + clone_dir +} + +fn remote_backed_feature() -> (TempGitRepo, TempPath) { + let origin = TempGitRepo::new(); + origin.write_file("file.txt", "base\n"); + origin.commit("base"); + + let clone = clone_repo(&origin); + run_git(&clone.path, &["checkout", "-b", "feature"]); + fs::write(clone.path.join("file.txt"), "base\nfeature\n").unwrap(); + run_git(&clone.path, &["add", "."]); + run_git(&clone.path, &["commit", "-m", "feature"]); + run_git(&clone.path, &["push", "origin", "feature:feature"]); + run_git(&clone.path, &["fetch", "origin", "main", "feature"]); + (origin, clone) +} + +fn state(repo: &Path, fetch_mode: FetchMode) -> BranchGitState { + compute_local_branch_git_state(repo, "feature", "main", fetch_mode) +} + +#[test] +fn detects_in_sync_branch() { + let (_origin, clone) = remote_backed_feature(); + let state = state(&clone.path, FetchMode::Force); + + assert_eq!(state.upstream.relation, UpstreamRelation::InSync); + assert_eq!(state.upstream.ahead, 0); + assert_eq!(state.upstream.behind, 0); + assert!(state.expected_branch_matches); + assert!(!state.worktree.dirty); +} + +#[test] +fn detects_local_ahead_branch() { + let (_origin, clone) = remote_backed_feature(); + fs::write(clone.path.join("local.txt"), "local\n").unwrap(); + run_git(&clone.path, &["add", "."]); + run_git(&clone.path, &["commit", "-m", "local"]); + + let state = state(&clone.path, FetchMode::Force); + + assert_eq!(state.upstream.relation, UpstreamRelation::LocalAhead); + assert_eq!(state.upstream.ahead, 1); + assert_eq!(state.upstream.behind, 0); +} + +#[test] +fn detects_origin_ahead_branch() { + let (origin, clone) = remote_backed_feature(); + origin.run_git(&["checkout", "feature"]); + origin.write_file("origin.txt", "origin\n"); + origin.commit("origin"); + + let state = state(&clone.path, FetchMode::Force); + + assert_eq!(state.upstream.relation, UpstreamRelation::OriginAhead); + assert_eq!(state.upstream.ahead, 0); + assert_eq!(state.upstream.behind, 1); +} + +#[test] +fn detects_diverged_branch() { + let (origin, clone) = remote_backed_feature(); + fs::write(clone.path.join("local.txt"), "local\n").unwrap(); + run_git(&clone.path, &["add", "."]); + run_git(&clone.path, &["commit", "-m", "local"]); + + origin.run_git(&["checkout", "feature"]); + origin.write_file("origin.txt", "origin\n"); + origin.commit("origin"); + + let state = state(&clone.path, FetchMode::Force); + + assert_eq!(state.upstream.relation, UpstreamRelation::Diverged); + assert_eq!(state.upstream.ahead, 1); + assert_eq!(state.upstream.behind, 1); + assert!(state.upstream.merge_base_sha.is_some()); +} + +#[test] +fn detects_missing_upstream_branch() { + let origin = TempGitRepo::new(); + origin.write_file("file.txt", "base\n"); + origin.commit("base"); + let clone = clone_repo(&origin); + run_git(&clone.path, &["checkout", "-b", "feature"]); + fs::write(clone.path.join("file.txt"), "base\nlocal\n").unwrap(); + run_git(&clone.path, &["add", "."]); + run_git(&clone.path, &["commit", "-m", "feature"]); + + let state = state(&clone.path, FetchMode::Force); + + assert_eq!(state.upstream.relation, UpstreamRelation::Missing); + assert!(!state.upstream.exists); +} + +#[test] +fn detects_dirty_worktree_counts() { + let (_origin, clone) = remote_backed_feature(); + fs::write(clone.path.join("staged.txt"), "staged\n").unwrap(); + run_git(&clone.path, &["add", "staged.txt"]); + fs::write(clone.path.join("file.txt"), "base\nfeature\nunstaged\n").unwrap(); + fs::write(clone.path.join("untracked.txt"), "untracked\n").unwrap(); + + let state = state(&clone.path, FetchMode::Never); + + assert!(state.worktree.dirty); + assert_eq!(state.worktree.staged, 1); + assert_eq!(state.worktree.unstaged, 1); + assert_eq!(state.worktree.untracked, 1); +} + +#[test] +fn detects_conflicted_worktree() { + let repo = TempGitRepo::new(); + repo.write_file("file.txt", "base\n"); + repo.commit("base"); + repo.run_git(&["update-ref", "refs/remotes/origin/main", "main"]); + repo.run_git(&["checkout", "-b", "feature"]); + repo.write_file("file.txt", "feature\n"); + repo.commit("feature"); + repo.run_git(&["checkout", "main"]); + repo.write_file("file.txt", "main\n"); + repo.commit("main"); + repo.run_git(&["checkout", "feature"]); + + let mut command = Command::new("git"); + command + .arg("-c") + .arg("core.hooksPath=/dev/null") + .arg("-C") + .arg(repo.path()) + .args(["merge", "main"]); + strip_git_env(&mut command); + let output = command.output().unwrap(); + assert!(!output.status.success()); + + let state = compute_local_branch_git_state(repo.path(), "feature", "main", FetchMode::Never); + + assert!(state.worktree.dirty); + assert_eq!(state.worktree.conflicted, 1); +} + +#[test] +fn detects_detached_head() { + let (_origin, clone) = remote_backed_feature(); + let head = run_git(&clone.path, &["rev-parse", "HEAD"]); + run_git(&clone.path, &["checkout", "--detach", head.trim()]); + + let state = state(&clone.path, FetchMode::Never); + + assert!(state.detached_head); + assert!(state.current_branch.is_none()); + assert!(!state.expected_branch_matches); +} + +#[test] +fn detects_wrong_checked_out_branch() { + let (_origin, clone) = remote_backed_feature(); + run_git(&clone.path, &["checkout", "-b", "other"]); + + let state = state(&clone.path, FetchMode::Never); + + assert_eq!(state.current_branch.as_deref(), Some("other")); + assert!(!state.detached_head); + assert!(!state.expected_branch_matches); +} + +#[test] +fn detects_base_branch_moved() { + let (origin, clone) = remote_backed_feature(); + origin.run_git(&["checkout", "main"]); + origin.write_file("base.txt", "new base\n"); + origin.commit("base moved"); + + let state = state(&clone.path, FetchMode::Force); + + assert_eq!(state.base.commits_since_fork, 1); +} diff --git a/apps/staged/src-tauri/src/git/status_parse.rs b/apps/staged/src-tauri/src/git/status_parse.rs new file mode 100644 index 00000000..d6688146 --- /dev/null +++ b/apps/staged/src-tauri/src/git/status_parse.rs @@ -0,0 +1,8 @@ +/// Returns true if the given porcelain status codes (X, Y) represent a +/// merge conflict state per `git status --porcelain` documentation. +pub fn is_conflicted_status(x: char, y: char) -> bool { + matches!( + (x, y), + ('D', 'D') | ('A', 'U') | ('U', 'D') | ('U', 'A') | ('D', 'U') | ('A', 'A') | ('U', 'U') + ) +} diff --git a/apps/staged/src-tauri/src/git/worktree.rs b/apps/staged/src-tauri/src/git/worktree.rs index a0fe8a32..c95ac252 100644 --- a/apps/staged/src-tauri/src/git/worktree.rs +++ b/apps/staged/src-tauri/src/git/worktree.rs @@ -463,6 +463,116 @@ pub fn reset_to_commit(worktree: &Path, commit_sha: &str) -> Result<(), GitError Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeChangePaths { + pub revert_paths: Vec, + pub remove_paths: Vec, + pub conflicted_paths: Vec, + pub reset_required: bool, +} + +impl WorktreeChangePaths { + pub fn is_empty(&self) -> bool { + self.revert_paths.is_empty() + && self.remove_paths.is_empty() + && self.conflicted_paths.is_empty() + } +} + +use super::status_parse::is_conflicted_status; + +fn literal_pathspec(path: &str) -> String { + format!(":(literal){path}") +} + +/// Parse `git status --porcelain=1 -z --untracked-files=all` into paths that +/// will be reverted by reset and paths that must be removed with clean. +pub fn parse_worktree_status_paths(output: &str) -> WorktreeChangePaths { + let mut revert_paths = Vec::new(); + let mut remove_paths = Vec::new(); + let mut conflicted_paths = Vec::new(); + let mut reset_required = false; + + let mut parts = output.split('\0').filter(|part| !part.is_empty()); + while let Some(record) = parts.next() { + let mut chars = record.chars(); + let x = chars.next().unwrap_or(' '); + let y = chars.next().unwrap_or(' '); + let path = record.get(3..).unwrap_or("").to_string(); + if path.is_empty() { + continue; + } + + let original_path = if matches!(x, 'R' | 'C') || matches!(y, 'R' | 'C') { + parts.next().map(str::to_string) + } else { + None + }; + + if x == '?' && y == '?' { + remove_paths.push(path); + continue; + } + + if is_conflicted_status(x, y) { + conflicted_paths.push(path); + continue; + } + + reset_required = true; + if matches!(x, 'A' | 'C') || matches!(y, 'A' | 'C') { + remove_paths.push(path); + } else if let Some(original) = original_path { + // Display-only: this arrow format is used for the WorktreeChangesPreview + // shown to the user. It is never passed back to git commands as a pathspec. + revert_paths.push(format!("{original} -> {path}")); + } else { + revert_paths.push(path); + } + } + + revert_paths.sort(); + remove_paths.sort(); + conflicted_paths.sort(); + + WorktreeChangePaths { + revert_paths, + remove_paths, + conflicted_paths, + reset_required, + } +} + +pub fn list_worktree_change_paths(worktree: &Path) -> Result { + let output = cli::run( + worktree, + &["status", "--porcelain=1", "-z", "--untracked-files=all"], + )?; + Ok(parse_worktree_status_paths(&output)) +} + +pub fn discard_worktree_changes( + worktree: &Path, + changes: &WorktreeChangePaths, +) -> Result<(), GitError> { + if changes.reset_required { + cli::run(worktree, &["reset", "--hard", "HEAD"])?; + } + + if !changes.remove_paths.is_empty() { + let pathspecs = changes + .remove_paths + .iter() + .map(|path| literal_pathspec(path)) + .collect::>(); + let mut args = vec!["clean", "-fd", "--"]; + args.extend(pathspecs.iter().map(String::as_str)); + cli::run(worktree, &args)?; + } + + Ok(()) +} + /// Get the parent commit SHA of a given commit. /// Returns None if the commit has no parent (initial commit). pub fn get_parent_commit(worktree: &Path, commit_sha: &str) -> Result, GitError> { @@ -711,4 +821,39 @@ mod tests { assert_eq!(projects_root, "projects"); assert_eq!(project_dir, "project-123"); } + + #[test] + fn parses_worktree_status_paths_for_preview_and_discard() { + let status = " M tracked.txt\0A staged-new.txt\0?? untracked.txt\0UU conflicted.txt\0"; + + let paths = parse_worktree_status_paths(status); + + assert_eq!(paths.revert_paths, vec!["tracked.txt"]); + assert_eq!(paths.remove_paths, vec!["staged-new.txt", "untracked.txt"]); + assert_eq!(paths.conflicted_paths, vec!["conflicted.txt"]); + assert!(paths.reset_required); + } + + #[test] + fn discard_worktree_changes_resets_tracked_and_removes_new_files() { + let repo = crate::test_utils::TempGitRepo::new(); + repo.write_file("tracked.txt", "base\n"); + repo.commit("base"); + + repo.write_file("tracked.txt", "modified\n"); + repo.write_file("staged-new.txt", "new\n"); + repo.run_git(&["add", "staged-new.txt"]); + repo.write_file("untracked.txt", "untracked\n"); + + let changes = list_worktree_change_paths(repo.path()).unwrap(); + discard_worktree_changes(repo.path(), &changes).unwrap(); + + assert_eq!( + std::fs::read_to_string(repo.path().join("tracked.txt")).unwrap(), + "base\n" + ); + assert!(!repo.path().join("staged-new.txt").exists()); + assert!(!repo.path().join("untracked.txt").exists()); + assert!(repo.run_git(&["status", "--porcelain"]).trim().is_empty()); + } } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 7d3d3ff3..052619cf 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -190,6 +190,7 @@ pub struct BranchTimeline { pub notes: Vec, pub reviews: Vec, pub images: Vec, + pub git_state: Option, } // ============================================================================= @@ -1802,6 +1803,7 @@ pub fn run() { delete_action_context, // Timeline timeline::get_branch_timeline, + timeline::pull_branch_ff_only, // Notes note_commands::create_note, note_commands::delete_note, @@ -1816,6 +1818,8 @@ pub fn run() { image_commands::list_branch_images, image_commands::create_image_from_data, // Timeline delete commands + timeline::get_worktree_changes_preview, + timeline::discard_worktree_changes, timeline::delete_review, timeline::delete_commit, timeline::delete_pending_commit, diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index 76dfeb8a..0c65e3ff 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -191,6 +191,17 @@ fn base_branch_name(branch: &store::Branch) -> &str { git::branch_name_without_origin(&branch.base_branch) } +/// Determine the remote ref to rebase onto based on the `target` parameter. +/// +/// - `None` or `Some("base")` → base branch name (e.g. `main`) +/// - `Some("origin")` → the branch's own name (e.g. `feature-xyz`) +fn rebase_ref_for_target(branch: &store::Branch, target: Option<&str>) -> String { + match target { + Some("origin") => branch.branch_name.clone(), + _ => base_branch_name(branch).to_string(), + } +} + fn commit_pipeline_prompt(kind: &PipelineKind) -> &'static str { match kind { PipelineKind::Rebase => "Rebase branch", @@ -272,20 +283,6 @@ Here is the context from the prior steps: } } -async fn current_head_for_pipeline_context(ctx: &BranchPipelineContext) -> Result { - if let Some(workspace_name) = ctx.workspace_name.clone() { - tauri::async_runtime::spawn_blocking(move || { - crate::blox::ws_exec(&workspace_name, &["git", "rev-parse", "HEAD"]) - }) - .await - .map_err(|e| format!("HEAD lookup task failed: {e}"))? - .map(|s| s.trim().to_string()) - .map_err(|e| e.to_string()) - } else { - git::get_head_sha(&ctx.working_dir).map_err(|e| format!("Failed to get HEAD SHA: {e}")) - } -} - async fn start_running_commit_pipeline_for_branch( ctx: BranchPipelineContext, kind: PipelineKind, @@ -296,11 +293,6 @@ async fn start_running_commit_pipeline_for_branch( registry: &Arc, ) -> Result { let prompt = commit_pipeline_prompt(&kind); - let pre_head_sha = if kind == PipelineKind::Rebase { - Some(current_head_for_pipeline_context(&ctx).await?) - } else { - None - }; let pipeline = PipelineExecution::from_steps(&steps).with_kind(kind); let mut session = store::Session::new_running(prompt, &ctx.working_dir); @@ -328,7 +320,7 @@ async fn start_running_commit_pipeline_for_branch( steps, pipeline, working_dir: ctx.working_dir, - pre_head_sha, + pre_head_sha: None, provider, workspace_name: ctx.workspace_name, remote_working_dir: ctx.remote_working_dir, @@ -349,6 +341,7 @@ async fn start_or_queue_commit_pipeline_for_branch( branch_id: String, kind: PipelineKind, provider: Option, + target: Option, ) -> Result { let prompt = commit_pipeline_prompt(&kind); @@ -361,15 +354,12 @@ async fn start_or_queue_commit_pipeline_for_branch( .is_empty(); if branch_has_running_session || branch_has_queued_session { - // Only need the branch model (for base_branch) to build pipeline steps. - // Defer the full context resolution (worktree lookup, remote path - // resolution) until the session actually starts running. let branch = store .get_branch(&branch_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("Branch not found: {branch_id}"))?; - let base_branch = base_branch_name(&branch); - let steps = build_commit_pipeline_steps(&kind, base_branch); + let rebase_ref = rebase_ref_for_target(&branch, target.as_deref()); + let steps = build_commit_pipeline_steps(&kind, &rebase_ref); let pipeline = PipelineExecution::from_steps(&steps).with_kind(kind); let mut session = store::Session::new_queued(prompt); if let Some(ref p) = provider { @@ -384,11 +374,9 @@ async fn start_or_queue_commit_pipeline_for_branch( return Ok(session.id); } - // Resolve the full pipeline context (working directory, remote paths) - // only when the session will run immediately. let ctx = resolve_branch_pipeline_context(&store, &branch_id)?; - let base_branch = base_branch_name(&ctx.branch); - let steps = build_commit_pipeline_steps(&kind, base_branch); + let rebase_ref = rebase_ref_for_target(&ctx.branch, target.as_deref()); + let steps = build_commit_pipeline_steps(&kind, &rebase_ref); start_running_commit_pipeline_for_branch( ctx, @@ -420,11 +408,6 @@ pub(crate) async fn start_queued_commit_pipeline_for_branch( let base_branch = base_branch_name(&ctx.branch); let steps = build_commit_pipeline_steps(&kind, base_branch); let prompt = commit_pipeline_prompt(&kind); - let pre_head_sha = if kind == PipelineKind::Rebase { - Some(current_head_for_pipeline_context(&ctx).await?) - } else { - None - }; let pipeline = PipelineExecution::from_steps(&steps).with_kind(kind); let effective_provider = session.provider.clone().or(provider); @@ -461,7 +444,7 @@ pub(crate) async fn start_queued_commit_pipeline_for_branch( steps, pipeline, working_dir: ctx.working_dir, - pre_head_sha, + pre_head_sha: None, provider: effective_provider, workspace_name: ctx.workspace_name, remote_working_dir: ctx.remote_working_dir, @@ -1060,12 +1043,12 @@ pub async fn push_branch( ) } -/// Rebase a branch onto its base branch via a pipeline. +/// Rebase a branch via a pipeline. /// -/// Fetches the latest base branch, then runs `git rebase --signoff origin/{base}`. -/// If the rebase succeeds (no conflicts), the pipeline completes without AI. -/// If the rebase fails, AI is handed off to recover and resolve conflicts when -/// present. +/// When `target` is `None` or `"base"`, rebases onto `origin/{base_branch}` +/// (the default behaviour used by the base-moved row and the `…` menu). +/// When `target` is `"origin"`, rebases onto `origin/{branch_name}` so that +/// the local branch incorporates remote-only commits (used by the diverged row). #[tauri::command(rename_all = "camelCase")] pub async fn rebase_branch( store: tauri::State<'_, Mutex>>>, @@ -1073,6 +1056,7 @@ pub async fn rebase_branch( app_handle: tauri::AppHandle, branch_id: String, provider: Option, + target: Option, ) -> Result { let store = get_store(&store)?; start_or_queue_commit_pipeline_for_branch( @@ -1082,6 +1066,7 @@ pub async fn rebase_branch( branch_id, PipelineKind::Rebase, provider, + target, ) .await } @@ -1109,6 +1094,7 @@ pub async fn squash_commits( branch_id, PipelineKind::Squash, provider, + None, ) .await } diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index f7b77ab0..8f98c5ec 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -643,9 +643,10 @@ pub fn start_pipeline_session( .build() .expect("Failed to create runtime for pipeline session"); + let mut config = config; let local = tokio::task::LocalSet::new(); let outcome = local.block_on(&rt, async { - run_pipeline(&config, &store, &app_handle, &cancel_token).await + run_pipeline(&mut config, &store, &app_handle, &cancel_token).await }); match outcome { @@ -975,11 +976,22 @@ fn drain_queued_after_pipeline_terminal( /// Execute pipeline steps sequentially, emitting events as each step progresses. async fn run_pipeline( - config: &PipelineConfig, + config: &mut PipelineConfig, store: &Arc, app_handle: &AppHandle, cancel_token: &CancellationToken, ) -> PipelineOutcome { + // Capture HEAD before the first step for rebase pipelines. This is deferred + // from session creation so the (potentially slow) remote HEAD lookup doesn't + // block session visibility in the UI. + if config.pre_head_sha.is_none() && config.pipeline.kind.as_ref() == Some(&PipelineKind::Rebase) + { + match current_pipeline_head(config) { + Ok(head) => config.pre_head_sha = Some(head), + Err(e) => log::warn!("Failed to capture pre-rebase HEAD: {e}"), + } + } + // Use the pipeline execution state that was already persisted with the // session, rather than reconstructing from step definitions. This keeps // the data flow clear: callers build + persist the pipeline, and we @@ -1013,8 +1025,21 @@ async fn run_pipeline( let _ = store.update_session_pipeline(&config.session_id, &execution); emit_pipeline_step(app_handle, &config.session_id, idx, &execution.steps[idx]); - // Execute the shell command. - let result = run_pipeline_command(command, &config.working_dir, cancel_token).await; + // Execute the shell command — locally or on a remote workspace. + let result = if let Some(ref ws_name) = config.workspace_name { + run_remote_pipeline_command( + command, + ws_name, + config + .remote_working_dir + .as_deref() + .and_then(|p| p.to_str()), + cancel_token, + ) + .await + } else { + run_pipeline_command(command, &config.working_dir, cancel_token).await + }; match result { Ok(PipelineCommandResult::Completed(output)) => { @@ -1293,6 +1318,80 @@ async fn run_pipeline_command( } } +/// Execute a pipeline command on a remote Blox workspace via `ws_exec`. +/// +/// Wraps the command in `cd && sh -lc ''` so it runs +/// in the correct working directory on the remote, mirroring how the local +/// path runs `sh -lc` with `current_dir`. +async fn run_remote_pipeline_command( + command: &str, + workspace_name: &str, + remote_working_dir: Option<&str>, + cancel_token: &CancellationToken, +) -> io::Result { + // Build the remote shell command. + let shell_command = if let Some(dir) = remote_working_dir { + // Escape single quotes in the directory and command for the outer sh -c. + let escaped_dir = dir.replace('\'', "'\\''"); + let escaped_cmd = command.replace('\'', "'\\''"); + format!("cd '{escaped_dir}' && sh -lc '{escaped_cmd}'") + } else { + let escaped_cmd = command.replace('\'', "'\\''"); + format!("sh -lc '{escaped_cmd}'") + }; + + let ws = workspace_name.to_string(); + let handle = tokio::task::spawn_blocking(move || { + crate::blox::ws_exec_output(&ws, &["sh", "-c", &shell_command]) + }); + + // Check for pre-existing cancellation before waiting. + if cancel_token.is_cancelled() { + handle.abort(); + return Ok(PipelineCommandResult::Cancelled { + stdout: Vec::new(), + stderr: Vec::new(), + }); + } + + tokio::select! { + result = handle => { + match result { + Ok(Ok(output)) => { + use std::os::unix::process::ExitStatusExt; + let status = if output.success { + std::process::ExitStatus::from_raw(0) + } else { + // Encode exit code 1 in wait-status format (exit code << 8). + std::process::ExitStatus::from_raw(1 << 8) + }; + Ok(PipelineCommandResult::Completed(Output { + status, + stdout: output.stdout, + stderr: output.stderr, + })) + } + Ok(Err(e)) => { + // Infrastructure error (CLI not found, timeout, auth failure). + Err(io::Error::other(e.to_string())) + } + Err(e) => { + // spawn_blocking panicked or was cancelled. + Err(io::Error::other(e.to_string())) + } + } + } + _ = cancel_token.cancelled() => { + // ws_exec is blocking and not cancellable — the background thread + // will finish naturally, but we return immediately. + Ok(PipelineCommandResult::Cancelled { + stdout: Vec::new(), + stderr: Vec::new(), + }) + } + } +} + fn combine_normalized_command_output(stdout: &[u8], stderr: &[u8]) -> String { let stdout = crate::terminal_output::normalize_display_bytes(stdout); let stderr = crate::terminal_output::normalize_display_bytes(stderr); diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index f995d6b2..63520858 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -1,4 +1,19 @@ //! Timeline — branch timeline construction and related delete commands. +//! +//! When a fetch is needed (TTL expired), the timeline is built using a +//! **two-stream** approach: +//! +//! - **Fast stream**: local-only git commands (HEAD, branch, status, commits) +//! complete in <1ms (local) or ~2s (one remote round-trip). A partial timeline +//! event is emitted so the frontend can show commits and worktree state +//! immediately. +//! +//! - **Slow stream**: `git fetch` + ref comparisons. Runs concurrently with +//! the fast stream for remote projects. The full `BranchTimeline` returned +//! by the command includes the complete git state from this stream. +//! +//! When the fetch cache is fresh, everything runs as a single fast stream +//! and no partial event is emitted. use crate::git; use crate::session_runner; @@ -7,11 +22,120 @@ use crate::{ blox, branches, BranchTimeline, CommitTimelineItem, ImageTimelineItem, NoteTimelineItem, ReviewTimelineItem, }; +use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::Path; use std::sync::{Arc, Mutex}; +use tauri::Emitter; -fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result { +/// Payload for the `timeline-partial` event emitted by the fast stream. +/// Contains commits and a placeholder git state (worktree populated, +/// upstream/base set to loading defaults). The frontend merges this +/// into the existing timeline while waiting for the full result. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TimelinePartialPayload { + branch_id: String, + commits: Vec, + git_state: git::BranchGitState, +} + +/// Parse `%H|%h|%s|%an|%ct` formatted commit lines into timeline items, +/// looking up DB metadata for session linkage. +fn parse_commit_lines( + store: &Arc, + branch_id: &str, + lines: &[String], +) -> Vec { + let mut commits = Vec::new(); + for line in lines { + let parts: Vec<&str> = line.splitn(5, '|').collect(); + if parts.len() >= 5 { + let sha = parts[0].to_string(); + let our_commit = store.get_commit_by_sha(branch_id, &sha).unwrap_or(None); + let resolved = store + .resolve_session_status(our_commit.as_ref().and_then(|c| c.session_id.as_deref())); + commits.push(CommitTimelineItem { + id: our_commit.as_ref().map(|c| c.id.clone()), + sha, + short_sha: parts[1].to_string(), + subject: parts[2].to_string(), + author: parts[3].to_string(), + timestamp: parts[4].parse().unwrap_or(0), + order: 0, + session_id: resolved.session_id, + session_status: resolved.status, + completion_reason: resolved.completion_reason, + }); + } + } + let len = commits.len() as i64; + for (i, commit) in commits.iter_mut().enumerate() { + commit.order = len - 1 - i as i64; + } + commits +} + +/// Fetch commits from a remote workspace using merge-base + git log. +fn fetch_remote_commits( + ws_name: &str, + repo_subpath: Option<&str>, + store: &Arc, + branch_id: &str, + base_ref: &str, +) -> Result, String> { + let range = if let Ok(mb_output) = + branches::run_workspace_git(ws_name, repo_subpath, &["merge-base", base_ref, "HEAD"]) + { + let mb = mb_output.trim().to_string(); + format!("{mb}..HEAD") + } else { + format!("{base_ref}..HEAD") + }; + let format_arg = "--format=%H|%h|%s|%an|%ct"; + let output = branches::run_workspace_git(ws_name, repo_subpath, &["log", format_arg, &range]) + .map_err(|e| format!("Failed to load commits from workspace: {e}"))?; + let lines: Vec = output + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + Ok(parse_commit_lines(store, branch_id, &lines)) +} + +/// Map local CommitInfo entries to CommitTimelineItems with DB metadata. +fn map_local_commits( + store: &Arc, + branch_id: &str, + git_commits: &[git::CommitInfo], +) -> Vec { + git_commits + .iter() + .map(|gc| { + let our_commit = store.get_commit_by_sha(branch_id, &gc.sha).unwrap_or(None); + let resolved = store + .resolve_session_status(our_commit.as_ref().and_then(|c| c.session_id.as_deref())); + CommitTimelineItem { + id: our_commit.as_ref().map(|c| c.id.clone()), + sha: gc.sha.clone(), + short_sha: gc.short_sha.clone(), + subject: gc.subject.clone(), + author: gc.author.clone(), + timestamp: gc.timestamp, + order: gc.order, + session_id: resolved.session_id, + session_status: resolved.status, + completion_reason: resolved.completion_reason, + } + }) + .collect() +} + +fn build_branch_timeline( + store: &Arc, + branch_id: &str, + app: Option<&tauri::AppHandle>, +) -> Result { // Get the branch and its workdir for git operations let branch = store .get_branch(branch_id) @@ -22,94 +146,152 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result = line.splitn(5, '|').collect(); - if parts.len() >= 5 { - let sha = parts[0].to_string(); - let our_commit = store.get_commit_by_sha(branch_id, &sha).unwrap_or(None); - let resolved = store.resolve_session_status( - our_commit.as_ref().and_then(|c| c.session_id.as_deref()), - ); - - commits.push(CommitTimelineItem { - id: our_commit.as_ref().map(|c| c.id.clone()), - sha, - short_sha: parts[1].to_string(), - subject: parts[2].to_string(), - author: parts[3].to_string(), - timestamp: parts[4].parse().unwrap_or(0), - order: 0, // placeholder, assigned below - session_id: resolved.session_id, - session_status: resolved.status, - completion_reason: resolved.completion_reason, + &branch.branch_name, + &branch.base_branch, + ); + let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; + let base_ref = git::origin_ref_for_branch(&branch.base_branch); + + if app.is_some() && git::needs_fetch(&cache_key, git::FetchMode::Ttl) { + // Two-stream: run fast + slow scripts concurrently. + // The fast script returns local state + commits in one round-trip. + // The slow script performs fetch + ref comparisons in another. + let slow_git_state = std::thread::scope(|s| { + let slow_handle = s.spawn(|| { + git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args) + .map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Ttl, + ) }); + + // Fast stream: local state + commits (no fetch) + if let Ok(fast_output) = git::compute_fast_git_state_batched( + &|script, args| { + branches::run_workspace_shell(ws_name, script, args) + .map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.base_branch, + ) { + let (fast, commit_lines) = fast_output.into_fast_git_state(&branch.branch_name); + commits = parse_commit_lines(store, branch_id, &commit_lines); + let partial_state = + fast.into_placeholder_git_state(&branch.branch_name, &branch.base_branch); + if let Some(app) = app { + let _ = app.emit( + "timeline-partial", + TimelinePartialPayload { + branch_id: branch_id.to_string(), + commits: commits.clone(), + git_state: partial_state, + }, + ); + } + } + + slow_handle.join().expect("slow git state thread panicked") + }); + + // If fast stream failed to get commits, fall back to traditional path + if commits.is_empty() { + commits = fetch_remote_commits( + ws_name, + repo_subpath.as_deref(), + store, + branch_id, + &base_ref, + )?; } - } - let len = commits.len() as i64; - for (i, commit) in commits.iter_mut().enumerate() { - commit.order = len - 1 - i as i64; + git_state = Some(slow_git_state); + } else { + // Single stream: fetch cache is fresh, everything is fast + git_state = Some(git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Ttl, + )); + commits = fetch_remote_commits( + ws_name, + repo_subpath.as_deref(), + store, + branch_id, + &base_ref, + )?; } } else if let Some(ref wd) = workdir { // Local branch: fetch commits from the local worktree let worktree_path = Path::new(&wd.path); if worktree_path.exists() { let base_ref = git::origin_ref_for_branch(&branch.base_branch); - let git_commits = - git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { - format!("Failed to get commits since base for branch {branch_id}: {e:?}") - })?; - - // For each git commit, look up our metadata (session linkage) - for gc in git_commits { - let our_commit = store.get_commit_by_sha(branch_id, &gc.sha).unwrap_or(None); - let resolved = store.resolve_session_status( - our_commit.as_ref().and_then(|c| c.session_id.as_deref()), - ); - - commits.push(CommitTimelineItem { - id: our_commit.as_ref().map(|c| c.id.clone()), - sha: gc.sha, - short_sha: gc.short_sha, - subject: gc.subject, - author: gc.author, - timestamp: gc.timestamp, - order: gc.order, - session_id: resolved.session_id, - session_status: resolved.status, - completion_reason: resolved.completion_reason, - }); + let cache_key = git::local_git_state_cache_key( + worktree_path, + &branch.branch_name, + &branch.base_branch, + ); + + if app.is_some() && git::needs_fetch(&cache_key, git::FetchMode::Ttl) { + // Two-stream: fast state + commits → emit partial → slow state + let fast = git::compute_fast_local_git_state(worktree_path, &branch.branch_name); + let git_commits = + git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { + format!("Failed to get commits since base for branch {branch_id}: {e:?}") + })?; + commits = map_local_commits(store, branch_id, &git_commits); + if let Some(app) = app { + let partial_state = fast + .clone() + .into_placeholder_git_state(&branch.branch_name, &branch.base_branch); + let _ = app.emit( + "timeline-partial", + TimelinePartialPayload { + branch_id: branch_id.to_string(), + commits: commits.clone(), + git_state: partial_state, + }, + ); + } + // Slow stream: fetch + ref comparisons + git_state = Some(git::complete_local_git_state( + worktree_path, + &fast, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Ttl, + )); + } else { + // Single stream: fetch cache is fresh + git_state = Some(git::compute_local_branch_git_state( + worktree_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Ttl, + )); + let git_commits = + git::get_commits_since_base(worktree_path, &base_ref).map_err(|e| { + format!("Failed to get commits since base for branch {branch_id}: {e:?}") + })?; + commits = map_local_commits(store, branch_id, &git_commits); } } } @@ -138,8 +320,8 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result, branch_id: &str) -> Result) -> Result { + match repo_subpath.map(str::trim).filter(|s| !s.is_empty()) { + Some(subpath) => { + branches::resolve_workspace_repo_path(ws_name, subpath).map_err(|e| e.to_string()) + } + None => Ok(".".to_string()), + } +} + +fn remote_git_state_cache_key( + workspace_name: &str, + repo_subpath: Option<&str>, + branch_name: &str, + base_branch: &str, +) -> String { + format!( + "remote:{workspace_name}:{}:{branch_name}:{base_branch}", + repo_subpath.unwrap_or("") + ) +} + fn review_is_visible_in_timeline(review: &Review, visible_shas: &HashSet<&str>) -> bool { review.commit_sha.is_empty() || visible_shas.contains(review.commit_sha.as_str()) @@ -244,14 +448,269 @@ fn review_is_visible_in_timeline(review: &Review, visible_shas: &HashSet<&str>) #[tauri::command] pub async fn get_branch_timeline( + app: tauri::AppHandle, store: tauri::State<'_, Mutex>>>, branch_id: String, ) -> Result { let store = crate::get_store(&store)?; - tauri::async_runtime::spawn_blocking(move || build_branch_timeline(&store, &branch_id)) - .await - .map_err(|e| format!("Timeline task failed: {e}"))? + tauri::async_runtime::spawn_blocking(move || { + build_branch_timeline(&store, &branch_id, Some(&app)) + }) + .await + .map_err(|e| format!("Timeline task failed: {e}"))? +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn pull_branch_ff_only( + store: tauri::State<'_, Mutex>>>, + branch_id: String, +) -> Result<(), String> { + let store = crate::get_store(&store)?; + + tauri::async_runtime::spawn_blocking(move || { + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; + let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; + let cache_key = remote_git_state_cache_key( + ws_name, + repo_subpath.as_deref(), + &branch.branch_name, + &branch.base_branch, + ); + let state = git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Force, + ); + git::ensure_fast_forward_pullable(&state)?; + branches::run_workspace_git( + ws_name, + repo_subpath.as_deref(), + &["merge", "--ff-only", &state.upstream.r#ref], + ) + .map_err(|e| e.to_string())?; + return Ok(()); + } + + let workdir = store + .get_workdir_for_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; + let worktree = Path::new(&workdir.path); + let state = git::compute_local_branch_git_state( + worktree, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Force, + ); + git::ensure_fast_forward_pullable(&state)?; + git::fast_forward_to_ref(worktree, &state.upstream.r#ref).map_err(|e| e.to_string())?; + Ok(()) + }) + .await + .map_err(|e| format!("Pull task failed: {e}"))? +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeChangesPreview { + revert_paths: Vec, + remove_paths: Vec, + conflicted_paths: Vec, +} + +fn preview_from_change_paths(paths: git::WorktreeChangePaths) -> WorktreeChangesPreview { + WorktreeChangesPreview { + revert_paths: paths.revert_paths, + remove_paths: paths.remove_paths, + conflicted_paths: paths.conflicted_paths, + } +} + +fn ensure_preview_matches( + changes: &git::WorktreeChangePaths, + expected: Option<&WorktreeChangesPreview>, +) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + let actual = preview_from_change_paths(changes.clone()); + if &actual == expected { + Ok(()) + } else { + Err("Worktree changes changed; review the discard preview again".to_string()) + } +} + +fn ensure_worktree_discardable(state: &git::BranchGitState) -> Result<(), String> { + if state.detached_head { + return Err("Cannot discard changes while HEAD is detached".to_string()); + } + if !state.expected_branch_matches { + let current = state + .current_branch + .as_deref() + .unwrap_or("an unknown branch"); + return Err(format!( + "Cannot discard changes while checked out on {current}" + )); + } + if state.worktree.conflicted > 0 { + return Err("Resolve merge conflicts before discarding changes".to_string()); + } + Ok(()) +} + +fn remote_worktree_change_paths( + workspace_name: &str, + repo_subpath: Option<&str>, +) -> Result { + let output = branches::run_workspace_git( + workspace_name, + repo_subpath, + &["status", "--porcelain=1", "-z", "--untracked-files=all"], + ) + .map_err(|e| e.to_string())?; + Ok(git::parse_worktree_status_paths(&output)) +} + +fn discard_remote_worktree_changes( + workspace_name: &str, + repo_subpath: Option<&str>, + changes: &git::WorktreeChangePaths, +) -> Result<(), String> { + if changes.reset_required { + branches::run_workspace_git(workspace_name, repo_subpath, &["reset", "--hard", "HEAD"]) + .map_err(|e| e.to_string())?; + } + + if !changes.remove_paths.is_empty() { + let pathspecs = changes + .remove_paths + .iter() + .map(|path| format!(":(literal){path}")) + .collect::>(); + let mut args = vec!["clean", "-fd", "--"]; + args.extend(pathspecs.iter().map(String::as_str)); + branches::run_workspace_git(workspace_name, repo_subpath, &args) + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn get_worktree_changes_preview( + store: tauri::State<'_, Mutex>>>, + branch_id: String, +) -> Result { + let store = crate::get_store(&store)?; + + tauri::async_runtime::spawn_blocking(move || { + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; + return remote_worktree_change_paths(ws_name, repo_subpath.as_deref()) + .map(preview_from_change_paths); + } + + let workdir = store + .get_workdir_for_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; + let paths = + git::list_worktree_change_paths(Path::new(&workdir.path)).map_err(|e| e.to_string())?; + Ok(preview_from_change_paths(paths)) + }) + .await + .map_err(|e| format!("Worktree preview task failed: {e}"))? +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn discard_worktree_changes( + store: tauri::State<'_, Mutex>>>, + branch_id: String, + expected_preview: Option, +) -> Result<(), String> { + let store = crate::get_store(&store)?; + + tauri::async_runtime::spawn_blocking(move || { + let branch = store + .get_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; + let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; + let cache_key = remote_git_state_cache_key( + ws_name, + repo_subpath.as_deref(), + &branch.branch_name, + &branch.base_branch, + ); + let state = git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Never, + ); + ensure_worktree_discardable(&state)?; + let changes = remote_worktree_change_paths(ws_name, repo_subpath.as_deref())?; + ensure_preview_matches(&changes, expected_preview.as_ref())?; + if changes.is_empty() { + return Ok(()); + } + if !changes.conflicted_paths.is_empty() { + return Err("Resolve merge conflicts before discarding changes".to_string()); + } + return discard_remote_worktree_changes(ws_name, repo_subpath.as_deref(), &changes); + } + + let workdir = store + .get_workdir_for_branch(&branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; + let worktree = Path::new(&workdir.path); + let state = git::compute_local_branch_git_state( + worktree, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Never, + ); + ensure_worktree_discardable(&state)?; + let changes = git::list_worktree_change_paths(worktree).map_err(|e| e.to_string())?; + ensure_preview_matches(&changes, expected_preview.as_ref())?; + if changes.is_empty() { + return Ok(()); + } + if !changes.conflicted_paths.is_empty() { + return Err("Resolve merge conflicts before discarding changes".to_string()); + } + git::discard_worktree_changes(worktree, &changes).map_err(|e| e.to_string())?; + Ok(()) + }) + .await + .map_err(|e| format!("Discard task failed: {e}"))? } /// Cancel and delete any reviews (auto or manual) created at or after a commit's @@ -510,7 +969,7 @@ mod tests { store.create_review(&visible_review).unwrap(); store.create_review(&stale_review).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); assert_eq!(timeline.commits.len(), 1); assert_eq!(timeline.commits[0].sha, visible_sha); @@ -529,7 +988,7 @@ mod tests { store.create_review(&stale_review).unwrap(); store.add_comment(&stale_review.id, &agent_comment).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); assert_eq!(timeline.commits.len(), 1); assert!(timeline.reviews.is_empty()); @@ -545,7 +1004,7 @@ mod tests { store.create_review(&stale_review).unwrap(); store.add_comment(&stale_review.id, &user_comment).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); assert_eq!(timeline.commits.len(), 1); assert_eq!(timeline.reviews.len(), 1); @@ -564,7 +1023,7 @@ mod tests { store.add_comment(&stale_review.id, &user_comment).unwrap(); store.delete_comment(&user_comment.id).unwrap(); - let timeline = build_branch_timeline(&store, &branch.id).unwrap(); + let timeline = build_branch_timeline(&store, &branch.id, None).unwrap(); assert_eq!(timeline.commits.len(), 1); assert!(timeline.reviews.is_empty()); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 9d09c3e7..c03e6024 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -33,6 +33,14 @@ import type { SuggestedRepo, } from './types'; +export type DiffScope = 'branch' | 'commit' | 'worktree'; + +export interface WorktreeChangesPreview { + revertPaths: string[]; + removePaths: string[]; + conflictedPaths: string[]; +} + // ============================================================================= // Store status // ============================================================================= @@ -392,6 +400,10 @@ export function invalidateProjectBranchTimelines(branchIds: string[]): void { window.dispatchEvent(new CustomEvent('timeline-invalidated', { detail: { branchIds } })); } +export function pullBranchFastForward(branchId: string): Promise { + return invoke('pull_branch_ff_only', { branchId }); +} + // ============================================================================= // Actions // ============================================================================= @@ -654,6 +666,19 @@ export function deletePendingCommit(commitId: string, deleteSession = true): Pro return invoke('delete_pending_commit', { commitId, deleteSession }); } +/** Preview the exact worktree paths that would be reverted or removed. */ +export function getWorktreeChangesPreview(branchId: string): Promise { + return invoke('get_worktree_changes_preview', { branchId }); +} + +/** Discard all uncommitted worktree changes after backend safety checks. */ +export function discardWorktreeChanges( + branchId: string, + expectedPreview?: WorktreeChangesPreview +): Promise { + return invoke('discard_worktree_changes', { branchId, expectedPreview }); +} + /** Delete a review and all its comments, optionally its linked session. */ export function deleteReview(reviewId: string, deleteSession = true): Promise { return invoke('delete_review', { reviewId, deleteSession }); @@ -675,7 +700,7 @@ export function deleteReview(reviewId: string, deleteSession = true): Promise { return invoke('get_diff_files', { branchId, commitSha, scope }); } @@ -684,7 +709,7 @@ export function getDiffFiles( export function getFileDiff( branchId: string, commitSha: string, - scope: 'branch' | 'commit', + scope: DiffScope, path: string ): Promise { return invoke('get_file_diff', { branchId, commitSha, scope, path }); @@ -913,13 +938,19 @@ export interface GitHubCommentResult { commentType: string; } -/** Rebase a branch onto its base branch via a pipeline. - * Fetches the latest base, then runs git rebase. AI handles conflicts if any. +/** Rebase a branch via a pipeline. + * When target is 'base' (default), rebases onto origin/{base_branch}. + * When target is 'origin', rebases onto origin/{branch_name}. * Returns the session ID so the frontend can track progress. */ -export function rebaseBranch(branchId: string, provider?: string): Promise { +export function rebaseBranch( + branchId: string, + provider?: string, + target?: 'base' | 'origin' +): Promise { return invoke('rebase_branch', { branchId, provider: provider ?? null, + target: target ?? null, }); } diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index a0e0c440..4a10f537 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -12,16 +12,27 @@ -->
@@ -22,6 +23,12 @@ >
{branchName} + {#if warning} + + + {warning} + + {/if} {#if secondaryLabel} @@ -30,9 +37,17 @@
{:else} {branchName} - {#if secondaryLabel} + {#if secondaryLabel || warning}
- {secondaryLabel} + {#if secondaryLabel} + {secondaryLabel} + {/if} + {#if warning} + + + {warning} + + {/if}
{/if} {/if} @@ -81,6 +96,22 @@ white-space: nowrap; } + .branch-warning { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; + max-width: 180px; + color: var(--ui-warning, var(--status-modified)); + overflow: hidden; + white-space: nowrap; + } + + .branch-warning span { + overflow: hidden; + text-overflow: ellipsis; + } + .base-branch-name { color: var(--text-faint); min-width: 0; diff --git a/apps/staged/src/lib/features/diff/DiffModal.svelte b/apps/staged/src/lib/features/diff/DiffModal.svelte index 5d20488f..1a26833f 100644 --- a/apps/staged/src/lib/features/diff/DiffModal.svelte +++ b/apps/staged/src/lib/features/diff/DiffModal.svelte @@ -49,6 +49,7 @@ SmartDiffAnnotation, Span, } from '../../types'; + import type { DiffScope } from '../../commands'; import { findFreshAutoReview, getSession } from '../../commands'; import * as commands from '../../api/commands'; import { @@ -73,7 +74,7 @@ projectId?: string | null; /** Optional — for branch scope, resolved automatically. */ commitSha?: string; - scope?: 'branch' | 'commit'; + scope?: DiffScope; /** When set, opens a specific existing review by ID instead of searching by triple. */ reviewId?: string; /** Label for the before pane header. */ @@ -143,15 +144,15 @@ // be needed to keep the effect firing on context switches. $effect(() => { const sha = diffViewer.state.commitSha; - if (sha && !reviewHandle && !readonly) { + if (sha && !reviewHandle && !readonly && activeScope !== 'worktree') { // svelte-ignore state_referenced_locally - reviewHandle = createReviewState(branchId, sha, activeScope, activeReviewId); + reviewHandle = createReviewState(branchId, sha, reviewableScope(), activeReviewId); } }); // Context switcher state // svelte-ignore state_referenced_locally - let activeScope = $state<'branch' | 'commit'>(scope); + let activeScope = $state(scope); // svelte-ignore state_referenced_locally let activeCommitSha = $state(commitSha); // svelte-ignore state_referenced_locally @@ -166,15 +167,21 @@ /** Tracks the active auto-review reload promise so it can be ignored on stale switches. */ let contextSwitchGeneration = 0; + function reviewableScope(): 'branch' | 'commit' { + return activeScope === 'worktree' ? 'branch' : activeScope; + } + /** Label for the current context shown in the dropdown trigger. */ let contextLabel = $derived( - activeScope === 'branch' - ? 'All changes' - : (commits?.find((c) => c.sha === activeCommitSha)?.subject ?? 'Commit') + activeScope === 'worktree' + ? 'Uncommitted changes' + : activeScope === 'branch' + ? 'All changes' + : (commits?.find((c) => c.sha === activeCommitSha)?.subject ?? 'Commit') ); /** Whether the context switcher should be shown. */ - let showContextSwitcher = $derived((commits?.length ?? 0) > 0); + let showContextSwitcher = $derived(activeScope !== 'worktree' && (commits?.length ?? 0) > 0); async function switchDiffContext(newScope: 'branch' | 'commit', newCommitSha?: string) { showContextDropdown = false; @@ -420,7 +427,7 @@ const prompt = buildCommentPrompt(comment, mode as 'note' | 'commit'); const launchContext = { source: 'diff_viewer' as const, - scope: activeScope, + scope: reviewableScope(), commitSha: diffViewer.state.commitSha ?? '', reviewId: activeReviewId ?? null, }; @@ -500,7 +507,7 @@ showNewSessionModal = false; const launchContext = { source: 'diff_viewer' as const, - scope: activeScope, + scope: reviewableScope(), commitSha: diffViewer.state.commitSha ?? '', reviewId: activeReviewId ?? null, }; @@ -1167,7 +1174,7 @@ {branchId} {projectId} commitSha={diffViewer.state.commitSha} - scope={activeScope} + scope={reviewableScope()} reviewId={activeReviewId} visibleCommentCount={currentComments.length} onStarted={onClose} diff --git a/apps/staged/src/lib/features/diff/diffViewerState.svelte.ts b/apps/staged/src/lib/features/diff/diffViewerState.svelte.ts index ed189ff3..16df8640 100644 --- a/apps/staged/src/lib/features/diff/diffViewerState.svelte.ts +++ b/apps/staged/src/lib/features/diff/diffViewerState.svelte.ts @@ -1,17 +1,131 @@ -// Re-export types and helpers from shared package -export { fileSummaryPath, type DiffViewerState } from '@builderbot/diff-viewer/state'; +// Re-export helper from shared package +export { fileSummaryPath } from '@builderbot/diff-viewer/state'; +export type { DiffViewerState as SharedDiffViewerState } from '@builderbot/diff-viewer/state'; -// Re-export with Staged's Tauri commands pre-bound import * as commands from '../../api/commands'; -import { createDiffViewerState as _create } from '@builderbot/diff-viewer/state'; +import type { DiffScope } from '../../commands'; +import type { FileDiff, FileDiffSummary } from '../../types'; +import { fileSummaryPath as sharedFileSummaryPath } from '@builderbot/diff-viewer/state'; + +export interface DiffViewerState { + branchId: string; + commitSha: string | null; + scope: DiffScope; + files: FileDiffSummary[]; + diffCache: Map; + selectedFile: string | null; + loading: boolean; + loadingFile: string | null; + error: string | null; +} /** * Create a reactive diff viewer state instance, pre-bound to Staged's Tauri commands. */ -export function createDiffViewerState( - branchId: string, - scope: 'branch' | 'commit', - commitSha?: string -) { - return _create(commands, branchId, scope, commitSha); +export function createDiffViewerState(branchId: string, scope: DiffScope, commitSha?: string) { + const state: DiffViewerState = $state({ + branchId, + commitSha: commitSha ?? null, + scope, + files: [], + diffCache: new Map(), + selectedFile: null, + loading: true, + loadingFile: null, + error: null, + }); + + let selectionGeneration = 0; + let contextGeneration = 0; + + async function loadFiles(generation: number): Promise { + state.loading = true; + state.error = null; + + try { + const response = await commands.getDiffFiles( + state.branchId, + state.commitSha ?? undefined, + state.scope + ); + if (generation !== contextGeneration) return; + + state.commitSha = response.commitSha; + state.files = response.files; + + if (state.files.length > 0) { + await selectFile(sharedFileSummaryPath(state.files[0])); + } + } catch (e) { + if (generation !== contextGeneration) return; + state.error = e instanceof Error ? e.message : String(e); + state.files = []; + } finally { + if (generation === contextGeneration) { + state.loading = false; + } + } + } + + async function selectFile(path: string | null): Promise { + const thisGeneration = ++selectionGeneration; + state.selectedFile = path; + + if (path && !state.diffCache.has(path)) { + await loadFileDiff(path); + if (selectionGeneration !== thisGeneration) return; + } + } + + async function loadFileDiff(path: string): Promise { + if (!state.commitSha) return null; + + const cached = state.diffCache.get(path); + if (cached) return cached; + + state.loadingFile = path; + + try { + const diff = await commands.getFileDiff(state.branchId, state.commitSha, state.scope, path); + const newCache = new Map(state.diffCache); + newCache.set(path, diff); + state.diffCache = newCache; + return diff; + } catch (e) { + console.error(`Failed to load diff for ${path}:`, e); + return null; + } finally { + state.loadingFile = null; + } + } + + function getCurrentDiff(): FileDiff | null { + if (!state.selectedFile) return null; + return state.diffCache.get(state.selectedFile) ?? null; + } + + async function switchContext( + newScope: 'branch' | 'commit', + newCommitSha?: string + ): Promise { + const generation = ++contextGeneration; + state.scope = newScope; + state.commitSha = newCommitSha ?? null; + state.diffCache = new Map(); + state.selectedFile = null; + state.loadingFile = null; + state.files = []; + state.error = null; + await loadFiles(generation); + } + + loadFiles(contextGeneration); + + return { + state, + selectFile, + loadFileDiff, + getCurrentDiff, + switchContext, + }; } diff --git a/apps/staged/src/lib/features/sessions/SessionLauncher.svelte b/apps/staged/src/lib/features/sessions/SessionLauncher.svelte index 2c298f16..d7c93c2a 100644 --- a/apps/staged/src/lib/features/sessions/SessionLauncher.svelte +++ b/apps/staged/src/lib/features/sessions/SessionLauncher.svelte @@ -53,7 +53,7 @@ if (s.id === sessionId) { return { ...s, - status: status as SessionStatus, + status, errorMessage: errorMessage ?? s.errorMessage, updatedAt: Date.now(), }; diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 1602cb4f..f2ac70b4 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -2,7 +2,8 @@ BranchTimeline.svelte - Renders the unified timeline for a branch Commits, notes, and reviews are merged by timestamp into a single linear list. - Active pending items (running sessions, generating notes) appear at the bottom. + Active pending items (running sessions, generating notes) appear near the bottom. + Bottom git status rows appear below active and queued work. Failed sessions appear in chronological order with completed items. --> -{#if items.length === 0 && !onNewNote && !onNewCommit && !onNewReview && pendingDropNotes.length === 0 && pendingItems.length === 0} +{#if items.length === 0 && !actionFooterVisible && pendingDropNotes.length === 0 && pendingItems.length === 0}

No commits or notes yet

{:else}
- {#each items as item, index (item.key)} + {#each normalItems as item, index (item.key)}
contextMenuRef?.open(e)} {onSessionClick} onItemClick={() => handleItemClick(item)} - onDeleteClick={item.deleteDisabledReason + onDeleteClick={!isDeletable(item) || item.deleteDisabledReason ? undefined : (opts) => handleDeleteClick(item, opts)} onStartClick={item.type.startsWith('queued-') && !hasActiveSession @@ -594,10 +945,9 @@ secondaryMeta="adding..." isLast={index === pendingDropNotes.length - 1 && pendingItems.length === 0 && - !revalidating && + gitFooterItems.length === 0 && !error && - !onNewNote && - !onNewCommit} + !actionFooterVisible} />
{/each} @@ -615,34 +965,70 @@ fallbackHintForPendingType(item.type)) : item.secondaryMeta} isLast={index === pendingItems.length - 1 && - !revalidating && + gitFooterItems.length === 0 && !error && - !onNewNote && - !onNewCommit} + !actionFooterVisible} />
{/each} - {#if revalidating} + {#each gitFooterItems as item, index (item.key)}
contextMenuRef?.open(e)} + {onSessionClick} + onItemClick={() => handleItemClick(item)} + onDeleteClick={!isDeletable(item) || item.deleteDisabledReason + ? undefined + : (opts) => handleDeleteClick(item, opts)} + onStartClick={item.type.startsWith('queued-') && !hasActiveSession + ? onStartQueued + : undefined} + onResumeClick={isResumable(item) && onResumeClick && item.sessionId && !hasActiveSession + ? () => onResumeClick!(item.sessionId!) + : undefined} />
- {/if} - {#if error && !revalidating} + {/each} + {#if error}
{/if} - {#if onNewNote || onNewCommit || onNewReview || footerActions} + {#if actionFooterVisible}