From 4c0edb06dd93647d26fd515fcfab4ee8979f83a6 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 7 May 2026 16:02:27 +1000 Subject: [PATCH 01/21] feat: surface branch git state in timeline with actionable rows Add git state detection and display as timeline rows, showing the relationship between local branches, their remote tracking branches, and base branches. Each state row includes contextual actions: - Dirty worktree: commit or stash changes - Diverged from origin: rebase onto origin and force-push - Behind/ahead of origin: push or pull - Base branch updates: rebase onto latest base - Merge conflicts: show conflicted files Backend adds a git state module that computes branch state by comparing local, remote, and base branch refs. Frontend renders state as styled timeline rows with icons, badges, and action buttons including progress states for async operations. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/diff_commands.rs | 97 +++- apps/staged/src-tauri/src/git/mod.rs | 15 +- apps/staged/src-tauri/src/git/state.rs | 469 ++++++++++++++++++ apps/staged/src-tauri/src/git/state_tests.rs | 243 +++++++++ apps/staged/src-tauri/src/git/worktree.rs | 148 ++++++ apps/staged/src-tauri/src/lib.rs | 4 + apps/staged/src-tauri/src/prs.rs | 38 +- apps/staged/src-tauri/src/timeline.rs | 290 +++++++++++ apps/staged/src/lib/commands.ts | 41 +- .../lib/features/branches/BranchCard.svelte | 209 +++++++- .../branches/BranchCardHeaderInfo.svelte | 39 +- .../src/lib/features/diff/DiffModal.svelte | 29 +- .../features/diff/diffViewerState.svelte.ts | 134 ++++- .../features/timeline/BranchTimeline.svelte | 416 +++++++++++++++- .../lib/features/timeline/TimelineRow.svelte | 242 ++++++++- .../src/lib/shared/ConfirmDialog.svelte | 3 + apps/staged/src/lib/types.ts | 38 ++ 17 files changed, 2379 insertions(+), 76 deletions(-) create mode 100644 apps/staged/src-tauri/src/git/state.rs create mode 100644 apps/staged/src-tauri/src/git/state_tests.rs 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..2d963af0 100644 --- a/apps/staged/src-tauri/src/git/mod.rs +++ b/apps/staged/src-tauri/src/git/mod.rs @@ -5,6 +5,9 @@ mod env; mod files; pub mod github; mod refs; +mod state; +#[cfg(test)] +mod state_tests; mod types; mod worktree; @@ -32,13 +35,19 @@ pub use refs::{ get_repo_root, list_branches, list_refs, merge_base, origin_ref_for_branch, prune_remote, resolve_ref, BranchRef, }; +pub use state::{ + compute_branch_git_state, compute_local_branch_git_state, ensure_fast_forward_pullable, + fast_forward_to_ref, BaseGitState, BranchGitState, 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..6c5080a6 --- /dev/null +++ b/apps/staged/src-tauri/src/git/state.rs @@ -0,0 +1,469 @@ +use super::cli::{self, GitError}; +use super::refs::{branch_name_without_origin, origin_ref_for_branch}; +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, +} + +#[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); + 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, + }; + } + + let mut upstream_known_missing = false; + let branch_refspec = refspec_for(branch_name); + if branch_refspec != base_refspec { + if let Err(error) = run_git(&["fetch", "--prune", "origin", branch_refspec.as_str()]) { + if is_missing_remote_ref(&error) { + upstream_known_missing = true; + } else { + 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() + .and_then(|output| { + let mut parts = output.split_whitespace(); + Some((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 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') + ) +} + +fn compute_worktree_state(run_git: &F) -> WorktreeGitState +where + F: Fn(&[&str]) -> Result, +{ + let mut state = WorktreeGitState { + dirty: false, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + }; + + let Ok(output) = run_git(&["status", "--porcelain=1", "--untracked-files=all"]) else { + return state; + }; + + for line in 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 +} + +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, +{ + let refresh = refresh_refs_if_needed(cache_key, &run_git, branch_name, base_branch, fetch_mode); + let head_sha = run_git(&["rev-parse", "HEAD"]) + .ok() + .and_then(trim_non_empty); + let current_branch = current_branch(&run_git); + let detached_head = head_sha.is_some() && current_branch.is_none(); + let expected_branch = branch_name_without_origin(branch_name); + let expected_branch_matches = current_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); + + BranchGitState { + upstream: compute_upstream_state( + &run_git, + upstream_ref, + head_sha.as_deref(), + refresh.upstream_known_missing, + ), + base: compute_base_state(&run_git, base_ref, head_sha.as_deref()), + worktree: compute_worktree_state(&run_git), + fetch: refresh.fetch, + head_sha, + current_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, + ) +} + +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/worktree.rs b/apps/staged/src-tauri/src/git/worktree.rs index a0fe8a32..54e3ac14 100644 --- a/apps/staged/src-tauri/src/git/worktree.rs +++ b/apps/staged/src-tauri/src/git/worktree.rs @@ -463,6 +463,119 @@ 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() + } +} + +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') + ) +} + +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 { + 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 +824,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..0cc7ea51 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<'a>(branch: &'a 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", @@ -349,6 +360,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 +373,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 +393,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, @@ -1060,12 +1067,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 +1080,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 +1090,7 @@ pub async fn rebase_branch( branch_id, PipelineKind::Rebase, provider, + target, ) .await } @@ -1109,6 +1118,7 @@ pub async fn squash_commits( branch_id, PipelineKind::Squash, provider, + None, ) .await } diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index f995d6b2..fcbd1b0a 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -7,6 +7,7 @@ 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}; @@ -22,10 +23,29 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result, branch_id: &str) -> Result, branch_id: &str) -> Result, + 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()) @@ -254,6 +294,256 @@ pub async fn get_branch_timeline( .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 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( + &cache_key, + |args| { + branches::run_workspace_git(ws_name, repo_subpath.as_deref(), args) + .map_err(|e| e.to_string()) + }, + &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 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( + &cache_key, + |args| { + branches::run_workspace_git(ws_name, repo_subpath.as_deref(), args) + .map_err(|e| e.to_string()) + }, + &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 /// `created_at` timestamp. If a review has an active session, the session is /// cancelled via the registry and then deleted. 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..0e974f6e 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -18,6 +18,7 @@ import { subscribeDragDrop } from './dragDrop'; import type { Branch, + BranchGitState, BranchTimeline as BranchTimelineData, HashtagItem, ProjectRepo, @@ -51,6 +52,7 @@ import { sessionRegistry } from '../../stores/sessionRegistry.svelte'; import { getPreferredAgent } from '../settings/preferences.svelte'; import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; + import type { WorktreeChangesPreview } from '../../commands'; interface Props { branch: Branch; @@ -100,7 +102,10 @@ let loading = $state(true); let revalidating = $state(false); let error = $state(null); + let pullingOrigin = $state(false); + let discardingWorktreeChanges = $state(false); let showBranchDiff = $state(false); + let showWorktreeDiff = $state(false); let loadedTimelineKey = $state(null); type TimelineFullReview = NonNullable>>; type TimelineReviewDetails = { @@ -153,7 +158,13 @@ ); /** Empty timeline used during provisioning so the action buttons render. */ - const emptyTimeline: BranchTimelineData = { commits: [], notes: [], reviews: [], images: [] }; + const emptyTimeline: BranchTimelineData = { + commits: [], + notes: [], + reviews: [], + images: [], + gitState: null, + }; // ========================================================================= // Worktree setup progress (event-driven phases) @@ -251,14 +262,17 @@ let commandPipelinePending = $state(false); let branchSessionBusy = $derived(timeline ? hasActiveSessions(timeline) : false); - async function startBranchCommandPipeline(kind: 'rebase' | 'squash') { + async function startBranchCommandPipeline( + kind: 'rebase' | 'squash', + rebaseTarget?: 'base' | 'origin' + ) { if (commandPipelinePending || branchSessionBusy) return; commandPipelinePending = true; const agents = isRemote ? REMOTE_AGENTS : agentState.providers; const provider = getPreferredAgent(agents) ?? undefined; try { if (kind === 'rebase') { - await commands.rebaseBranch(branch.id, provider); + await commands.rebaseBranch(branch.id, provider, rebaseTarget); } else { await commands.squashCommits(branch.id, provider); } @@ -419,6 +433,7 @@ let confirmDelete = $state<{ title: string; message: string; + confirmLabel?: string; onConfirm: () => void; } | null>(null); @@ -509,6 +524,26 @@ /** Number of finalized commits on this branch. */ let commitCount = $derived(timeline?.commits.filter((c) => c.sha).length ?? 0); + function gitIdentityWarning(state: BranchGitState | null | undefined): string | null { + if (!state) return null; + if (state.detachedHead) return 'Detached HEAD'; + if (!state.expectedBranchMatches) { + return state.currentBranch ? `Checked out ${state.currentBranch}` : 'Wrong branch'; + } + return null; + } + + let branchIdentityWarning = $derived(gitIdentityWarning(timeline?.gitState)); + let gitUnsafeActionsDisabled = $derived(!!branchIdentityWarning); + let branchCommandDisabledReason = $derived( + branchIdentityWarning ?? + (commandPipelinePending + ? 'Command in progress' + : branchSessionBusy + ? 'Session in progress' + : null) + ); + // ========================================================================= // PR button ref // ========================================================================= @@ -753,6 +788,121 @@ } } + async function handlePullOrigin() { + if (pullingOrigin) return; + pullingOrigin = true; + try { + await commands.pullBranchFastForward(branch.id); + await loadTimeline(); + } catch (e) { + notifyError('Pull failed', e); + } finally { + pullingOrigin = false; + } + } + + let pushingOrigin = $state(false); + + async function handlePushOrigin() { + if (pushingOrigin) return; + pushingOrigin = true; + const agents = isRemote ? REMOTE_AGENTS : agentState.providers; + const provider = getPreferredAgent(agents) ?? undefined; + try { + await commands.pushBranch(branch.id, provider, false); + await loadTimeline(); + } catch (e) { + notifyError('Push failed', e); + } finally { + pushingOrigin = false; + } + } + + let showForcePushDialog = $state(false); + + function handleForcePush() { + showForcePushDialog = true; + } + + async function confirmForcePush() { + showForcePushDialog = false; + if (commandPipelinePending || branchSessionBusy) return; + commandPipelinePending = true; + const agents = isRemote ? REMOTE_AGENTS : agentState.providers; + const provider = getPreferredAgent(agents) ?? undefined; + try { + await commands.pushBranch(branch.id, provider, true); + await loadTimeline(); + } catch (e) { + notifyError('Force push failed', e); + } finally { + commandPipelinePending = false; + } + } + + function formatDiscardPreview(preview: WorktreeChangesPreview): string { + const sections: string[] = []; + if (preview.revertPaths.length > 0) { + sections.push( + `Revert tracked changes:\n${preview.revertPaths.map((path) => `- ${path}`).join('\n')}` + ); + } + if (preview.removePaths.length > 0) { + sections.push( + `Remove untracked/new files:\n${preview.removePaths.map((path) => `- ${path}`).join('\n')}` + ); + } + return `This will discard uncommitted worktree changes.\n\n${sections.join('\n\n')}`; + } + + async function handleDiscardWorktreeChanges() { + if (discardingWorktreeChanges) return; + + let preview: WorktreeChangesPreview; + try { + preview = await commands.getWorktreeChangesPreview(branch.id); + } catch (e) { + notifyError('Could not inspect changes', e); + return; + } + + if (preview.conflictedPaths.length > 0) { + alerts.show({ + tone: 'error', + title: 'Conflicts need manual recovery', + message: preview.conflictedPaths.join('\n'), + durationMs: 0, + }); + return; + } + + if (preview.revertPaths.length === 0 && preview.removePaths.length === 0) { + await loadTimeline(); + return; + } + + const doDiscard = async () => { + confirmDelete = null; + discardingWorktreeChanges = true; + try { + await commands.discardWorktreeChanges(branch.id, preview); + commands.invalidateBranchTimeline(branch.id); + await loadTimeline(); + } catch (e) { + notifyError('Discard failed', e); + } finally { + discardingWorktreeChanges = false; + } + }; + + confirmDelete = { + title: 'Discard Changes', + message: formatDiscardPreview(preview), + confirmLabel: 'Discard', + onConfirm: doDiscard, + }; + } + function handleDeleteCommit(sha: string, sessionId?: string, opts?: { altKey: boolean }) { const doDelete = async () => { confirmDelete = null; @@ -1093,6 +1243,7 @@ secondaryLabel={isRemote ? (branch.workspaceName ?? formatBaseBranch(branch.baseBranch)) : formatBaseBranch(branch.baseBranch)} + warning={branchIdentityWarning} />
{#if isRemote && remoteWorkspaceStatus !== 'running' && remoteWorkspaceStatus !== 'starting'} @@ -1112,7 +1263,8 @@ onSquashCommits={() => startBranchCommandPipeline('squash')} newCommitDisabled={sessionMgr.isNewSessionDisabled || commandPipelinePending || - branchSessionBusy} + branchSessionBusy || + gitUnsafeActionsDisabled} {commitCount} />
@@ -1146,6 +1298,7 @@ {prunedSessionIds} {revalidating} {error} + gitActionDisabledReason={branchIdentityWarning} onRetry={() => loadTimeline()} deletingItems={timelineDeletingItems} reviewCommentBreakdown={timelineReviewDetailsById} @@ -1183,8 +1336,21 @@ onNewReview={hasCodeChanges || sessionMgr.hasCommitSessionInProgress ? (e) => sessionMgr.openNewSession('review', e) : undefined} + onPullOrigin={handlePullOrigin} + onPushOrigin={handlePushOrigin} + onRebaseBranch={() => startBranchCommandPipeline('rebase')} + onRebaseBranchOntoOrigin={() => startBranchCommandPipeline('rebase', 'origin')} + onForcePush={handleForcePush} + rebaseBranchDisabledReason={branchCommandDisabledReason} + onViewWorktreeDiff={isLocal ? () => (showWorktreeDiff = true) : undefined} + onCommitWorktreeChanges={() => + sessionMgr.startOrQueueSession('commit', 'Commit uncommitted changes')} + onDiscardWorktreeChanges={handleDiscardWorktreeChanges} onNewSessionReferring={(ref) => sessionMgr.openNewSessionReferring(ref)} - newSessionDisabled={sessionMgr.isNewSessionDisabled} + newSessionDisabled={sessionMgr.isNewSessionDisabled || gitUnsafeActionsDisabled} + {pullingOrigin} + {pushingOrigin} + {discardingWorktreeChanges} {provisioningLabel} {provisioningDetail} > @@ -1249,6 +1415,26 @@ /> {/if} +{#if showWorktreeDiff} + { + showWorktreeDiff = false; + loadTimeline(); + }} + /> +{/if} + {#if commitDiffSha} {/if} +{#if showForcePushDialog} + (showForcePushDialog = false)} + /> +{/if} + {#if confirmDelete} (confirmDelete = null)} diff --git a/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte b/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte index 9f2ec71d..b022c0e2 100644 --- a/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte @@ -1,5 +1,5 @@
@@ -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/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 1602cb4f..752a2472 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 +928,10 @@ secondaryMeta="adding..." isLast={index === pendingDropNotes.length - 1 && pendingItems.length === 0 && + gitFooterItems.length === 0 && !revalidating && !error && - !onNewNote && - !onNewCommit} + !actionFooterVisible} />
{/each} @@ -615,10 +949,54 @@ fallbackHintForPendingType(item.type)) : item.secondaryMeta} isLast={index === pendingItems.length - 1 && + gitFooterItems.length === 0 && !revalidating && !error && - !onNewNote && - !onNewCommit} + !actionFooterVisible} + /> +
+ {/each} + {#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} />
{/each} @@ -627,7 +1005,7 @@
{/if} @@ -637,12 +1015,12 @@ type="load-error" title="Failed to load commits" secondaryMeta={error} - isLast={true} + isLast={!actionFooterVisible} onRetryClick={onRetry} /> {/if} - {#if onNewNote || onNewCommit || onNewReview || footerActions} + {#if actionFooterVisible}