//! Code review diff state management. //! //! Some of the code in this module is adapted from GitHub Desktop, which is licensed under the MIT license, //! Copyright (c) GitHub, Inc. See GITHUB-DESKTOP-LICENSE in this directory. use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader, Read, Seek}; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Result, anyhow}; use instant::Instant; #[cfg(feature = "local_fs")] use warp_util::standardized_path::StandardizedPath; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use std::fs; } } #[cfg(not(target_family = "wasm"))] use warp_core::channel::ChannelState; use warp_core::send_telemetry_from_ctx; #[cfg(feature = "local_fs")] use warp_core::sync_queue::SyncQueue; use warp_util::git::run_git_command; #[cfg(not(target_arch = "wasm32"))] use warpui::AppContext; use warpui::r#async::SpawnedFutureHandle; use warpui::{ModelContext, SingletonEntity}; use crate::code_review::diff_size_limits::{ DiffSize, MAX_DIFF_SIZE, UnrenderableReason, compute_diff_size, }; use crate::code_review::{CodeReviewTelemetryEvent, git_actions, is_file_autogenerated}; use crate::features::FeatureFlag; use crate::server::server_api::ServerApiProvider; #[cfg(feature = "local_tty")] use crate::terminal::local_shell::LocalShellState; #[cfg(feature = "local_fs")] use crate::throttle::throttle; use crate::util::git::{ Commit, FileChangeEntry, detect_current_branch, detect_main_branch, get_unpushed_commits, parse_unified_diff_header, }; #[cfg(feature = "local_fs")] use crate::util::git::{get_all_branches, git_operation_in_progress}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use std::collections::HashSet; use crate::code_review::file_invalidation_queue::FileInvalidationTask; use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource}; use repo_metadata::{ repository::{RepositorySubscriber, SubscriberId}, RepoMetadataError, Repository, RepositoryUpdate, RepositoryWatchMode, }; use async_channel::Sender; use warpui::ModelHandle; } } #[cfg(feature = "local_fs")] use warp_errors::report_error; #[cfg(feature = "local_fs")] use super::DiffOperation; use super::{ BackendOrigin, CommitChainMode, DiffHunk, DiffLine, DiffLineType, DiffMetadata, DiffMetadataAgainstBase, DiffMode, DiffState, DiffStateError, DiffStateModelEvent, DiffStats, FileDiff, FileDiffAndContent, FileStatusInfo, GitDiffData, GitDiffWithBaseContent, GitFileStatus, GitOpResult, }; // Unicode bidirectional characters that should be flagged const BIDI_CHARS: [char; 9] = [ '\u{202A}', // LEFT-TO-RIGHT EMBEDDING '\u{202B}', // RIGHT-TO-LEFT EMBEDDING '\u{202C}', // POP DIRECTIONAL FORMATTING '\u{202D}', // LEFT-TO-RIGHT OVERRIDE '\u{202E}', // RIGHT-TO-LEFT OVERRIDE '\u{2066}', // LEFT-TO-RIGHT ISOLATE '\u{2067}', // RIGHT-TO-LEFT ISOLATE '\u{2068}', // FIRST STRONG ISOLATE '\u{2069}', // POP DIRECTIONAL ISOLATE ]; /// Internal representation of the diffs we've loaded against all bases. /// This could include changes against both the latest commit/HEAD /// and changes against the main branch. #[derive(Clone, Default)] enum InternalDiffState { /// Repo detection has been kicked off but hasn't completed yet. /// We don't yet know whether the path is inside a git repository, so this is /// surfaced as a loading state rather than a premature "not a repository" verdict. Detecting, #[default] NotInRepository, Loading, Loaded(Diffs), } #[derive(Clone)] struct Diffs { changes: Result, } impl From for Diffs { fn from(value: DiffsWithBaseContent) -> Self { Self { changes: value.changes.map(|diff| diff.into()), } } } impl From<&DiffsWithBaseContent> for Diffs { fn from(value: &DiffsWithBaseContent) -> Self { Self { changes: match value.changes.as_ref() { Ok(changes) => Ok(changes.into()), Err(e) => Err(e.clone()), }, } } } /// IMPORTANT: This struct contains expensive data like the full content of diff files /// at base. This should not be cloned at any time. struct DiffsWithBaseContent { changes: Result, } /// Tracks state for in-flight file invalidation tasks and full-reload coordination. #[cfg(feature = "local_fs")] struct FileInvalidationState { /// Whether a full invalidation is in-flight. /// When true, per-file invalidation requests are deferred to `pending_file_updates`. invalidate_all_pending: bool, /// Merge base commit for the current diff mode, computed eagerly during /// full invalidation. merge_base: Option, /// Handle for the in-flight merge base computation spawned during full /// invalidation. Aborted when a new full invalidation starts. merge_base_handle: Option, /// Queue for per-file invalidation tasks queue: SyncQueue, } #[cfg(feature = "local_fs")] impl FileInvalidationState { fn new(queue: SyncQueue) -> Self { Self { invalidate_all_pending: false, merge_base: None, merge_base_handle: None, queue, } } fn cancel_all(&mut self) { self.queue.cancel_all(); self.merge_base = None; if let Some(handle) = self.merge_base_handle.take() { handle.abort(); } } } /// Files that have been invalidated but not yet processed while a full diff /// reload is in-flight. #[cfg(feature = "local_fs")] struct PendingFileUpdate { repo_path: PathBuf, pending_file_edits: HashSet, } #[cfg(feature = "local_fs")] impl PendingFileUpdate { fn update_with_file_invalidation( &mut self, repo_path: PathBuf, invalidated_files: Vec, ) { if self.repo_path != repo_path { self.repo_path = repo_path; self.pending_file_edits = HashSet::from_iter(invalidated_files); } else { self.pending_file_edits.extend(invalidated_files); } } } /// Model that contains all state related to the current pane's open git repository. /// /// This is the local-only implementation. Consumers should use [`DiffStateModel`] (the wrapper) /// rather than this type directly. pub struct LocalDiffStateModel { #[cfg(feature = "local_fs")] repository: Option>, #[cfg(feature = "local_fs")] subscriber_id: Option, state: InternalDiffState, backend_origin: BackendOrigin, mode: DiffMode, metadata: Option, computing_diffs_abort_handle: Option, computing_metadata_abort_handle: Option, /// Start time for the latest caller-tracked full diff load. tracked_diff_load_start_time: Option, /// Controls whether periodic throttled metadata refresh is active. /// Refresh is suppressed when the code review pane is not open. metadata_refresh_enabled: bool, // TODO: Remove pending file invalidations — pause the queue instead. /// Files that have been invalidated but not yet processed when diff is still loading. #[cfg(feature = "local_fs")] pending_file_updates: Option, /// State for tracking in-flight file invalidation tasks. #[cfg(feature = "local_fs")] file_invalidation: FileInvalidationState, } #[derive(Debug, Clone, Copy)] struct GitNumStatMetadata { lines_added: usize, lines_removed: usize, is_binary_file: bool, } impl LocalDiffStateModel { #[cfg(feature = "local_fs")] pub fn new( repo_path: Option, backend_origin: BackendOrigin, ctx: &mut ModelContext, ) -> Self { // Set up file invalidation queue and subscribe to results // so the model can emit SingleFileUpdated events. let queue = SyncQueue::new_streaming(&ctx.background_executor()); let rx = queue.subscribe(); ctx.spawn_stream_local( rx, |me, broadcast_result: Result<_, Arc>, ctx| { if me.file_invalidation.invalidate_all_pending { return; } match broadcast_result { Ok(arc_value) => { let (path, diff): (String, Option>) = match Arc::try_unwrap(arc_value) { Ok((path, diff)) => (path, diff), Err(arc_value) => { let (path, diff) = arc_value.as_ref(); (path.clone(), diff.clone()) } }; ctx.emit(DiffStateModelEvent::SingleFileUpdated { path, diff }); } Err(err) => { err.report_and_log(); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::LoadDiffFailed { backend_origin: me.backend_origin, operation: DiffOperation::FileInvalidation, mode: me.mode.clone(), error: err.to_string(), // Per-file invalidation errors are not tied to a full // tracked load, so `load_duration` is intentionally `None`. load_duration: None, }, ctx ); } } }, |_, _| {}, ); let model = Self { repository: None, state: if repo_path.is_some() { InternalDiffState::Detecting } else { InternalDiffState::NotInRepository }, subscriber_id: None, mode: DiffMode::default(), backend_origin, metadata: None, computing_diffs_abort_handle: None, computing_metadata_abort_handle: None, tracked_diff_load_start_time: None, metadata_refresh_enabled: false, file_invalidation: FileInvalidationState::new(queue), pending_file_updates: None, }; if let Some(repo_path) = &repo_path { let fut = DetectedRepositories::handle(ctx).update(ctx, |model, ctx| { model.detect_possible_local_git_repo( repo_path, RepoDetectionSource::CodeReviewInitialization, ctx, ) }); ctx.spawn(fut, move |me, repo_path, ctx| { if let Some(repo_path) = &repo_path && let Some(repo_handle) = DetectedRepositories::as_ref(ctx) .get_local_watched_repo_for_path(repo_path, ctx) { me.set_active_repository(repo_handle, ctx); return; } // Repo detection completed but found no repository. // Emit so subscribers (e.g. the server model) can drain // pending responses with the NotInRepository state. me.state = InternalDiffState::NotInRepository; me.tracked_diff_load_start_time = None; ctx.emit(DiffStateModelEvent::NewDiffsComputed { diffs: None, load_duration: None, }); }); } model } #[cfg(not(feature = "local_fs"))] pub fn new( _repo_path: Option, backend_origin: BackendOrigin, _ctx: &mut ModelContext, ) -> Self { Self { state: InternalDiffState::default(), backend_origin, mode: DiffMode::default(), metadata: None, computing_diffs_abort_handle: None, computing_metadata_abort_handle: None, tracked_diff_load_start_time: None, metadata_refresh_enabled: false, } } pub fn get(&self) -> DiffState { match &self.state { InternalDiffState::Detecting | InternalDiffState::Loading => DiffState::Loading, InternalDiffState::NotInRepository => DiffState::NotInRepository, InternalDiffState::Loaded(diffs) => match &diffs.changes { Ok(_) => DiffState::Loaded, Err(err) => DiffState::Error(err.clone()), }, } } /// Returns the current diff metadata, if available. pub fn metadata(&self) -> Option<&DiffMetadata> { self.metadata.as_ref() } pub fn diff_mode(&self) -> DiffMode { self.mode.clone() } pub fn get_uncommitted_stats(&self) -> Option { self.metadata .as_ref() .map(|metadata| metadata.against_head.aggregate_stats) } /// Per-file entries for uncommitted-vs-HEAD changes, from cached metadata. pub fn uncommitted_file_entries(&self) -> &[FileChangeEntry] { self.metadata .as_ref() .map(|m| m.against_head.files.as_slice()) .unwrap_or(&[]) } /// Get the name of the main branch being used for comparison pub fn get_main_branch_name(&self) -> Option { self.metadata .as_ref() .map(|metadata| metadata.main_branch_name.clone()) } /// Get the name of the current checked-out branch or commit. pub fn get_current_branch_name(&self) -> Option { self.metadata .as_ref() .map(|metadata| metadata.current_branch_name.clone()) } /// Returns true if the current branch is the main/trunk branch. pub fn is_on_main_branch(&self) -> bool { match (self.get_current_branch_name(), self.get_main_branch_name()) { (Some(current), Some(main)) => { let main_short = main.strip_prefix("origin/").unwrap_or(&main); current == main || current == main_short } _ => false, } } /// The unpushed commits on the current branch (empty if none or no upstream). pub fn unpushed_commits(&self) -> &[Commit] { self.metadata .as_ref() .map(|metadata| metadata.unpushed_commits.as_slice()) .unwrap_or_default() } pub fn upstream_ref(&self) -> Option<&str> { self.metadata .as_ref() .and_then(|m| m.upstream_ref.as_deref()) } /// Returns `true` if the branch's upstream tracking ref differs from the /// detected main branch. `false` when either is unknown, or when the /// upstream points directly at main (e.g. after /// `git checkout -b feature origin/master`). pub fn upstream_differs_from_main(&self) -> bool { match (self.upstream_ref(), self.get_main_branch_name().as_deref()) { (Some(upstream), Some(main)) => upstream != main, _ => false, } } /// Checks if git operations like stash or reset would be blocked due to repository state. /// This performs quick file existence checks to detect if git is in the middle of an operation. #[cfg(feature = "local_fs")] pub fn is_git_operation_blocked(&self, app: &AppContext) -> bool { let Some(repo) = &self.repository else { return false; }; let repo_path = repo.as_ref(app).root_dir().to_local_path_lossy(); git_operation_in_progress(&repo_path) } #[cfg(not(feature = "local_fs"))] pub fn is_git_operation_blocked(&self, _app: &warpui::AppContext) -> bool { false } /// Returns `true` once the repository has at least one commit. pub(super) fn has_head(&self) -> bool { cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { self .metadata .as_ref() .is_some_and(|metadata| metadata.has_head_commit) } else { false } } } #[cfg(feature = "local_fs")] fn active_repository_path(&self, app: &AppContext) -> Option { self.repository .as_ref()? .as_ref(app) .root_dir() .to_local_path() } #[cfg(not(feature = "local_fs"))] fn active_repository_path(&self, _app: &warpui::AppContext) -> Option { None } /// Cancels in-flight per-file invalidation tasks and marks a full /// invalidation pending so that stale queue results are suppressed /// until the reload completes. #[cfg(feature = "local_fs")] fn queue_full_invalidation(&mut self) { self.file_invalidation.cancel_all(); self.file_invalidation.invalidate_all_pending = true; } /// Fetches branches for the active repository and emits /// `DiffStateModelEvent::BranchesReceived` on completion. #[cfg(feature = "local_fs")] pub fn fetch_branches(&self, ctx: &mut ModelContext) { let Some(repo_path) = self.active_repository_path(ctx) else { return; }; ctx.spawn( async move { get_all_branches(&repo_path, None, false /* include_remotes */).await }, |_me, branches_result, ctx| { let branches = match branches_result { Ok(branches) => branches, Err(err) => { log::warn!("LocalDiffStateModel: failed to fetch branches: {err}"); vec![] } }; ctx.emit(DiffStateModelEvent::BranchesReceived(branches)); }, ); } #[cfg(not(feature = "local_fs"))] pub fn fetch_branches(&self, _ctx: &mut ModelContext) { // Noop on WASM builds. } pub fn set_diff_mode( &mut self, mode: DiffMode, should_fetch_base: bool, track_load_duration: bool, ctx: &mut ModelContext, ) { if self.mode != mode { self.mode = mode; self.load_diffs_for_current_repo(should_fetch_base, track_load_duration, ctx); } } /// Like [`Self::set_diff_mode`], but also arranges for the next diff load /// to fetch the base branch from origin if it is not available locally. /// This is intended for the `insert_code_review_comments` flow where the /// requested base branch may never have been checked out. pub fn set_diff_mode_and_fetch_base(&mut self, mode: DiffMode, ctx: &mut ModelContext) { self.set_diff_mode(mode, true, true, ctx); } /// Loads the actual diffs for the current repo. /// See [`DiffStateModel::refresh_diff_metadata_for_current_repo`] for a version that only refreshes diff _metadata. #[cfg(feature = "local_fs")] pub fn load_diffs_for_current_repo( &mut self, should_fetch_base: bool, track_load_duration: bool, ctx: &mut ModelContext, ) { if track_load_duration { self.tracked_diff_load_start_time = Some(Instant::now()); } // Cancels in-flight per-file invalidation tasks so that stale queue results cannot // race with the new full reload. self.queue_full_invalidation(); // Abort any previous diff loading operations before spawning a new one. if let Some(handle) = self.computing_diffs_abort_handle.take() { handle.abort(); } let Some(current_repository) = &self.repository else { return; }; let current_repository_path = current_repository .as_ref(ctx) .root_dir() .to_local_path_lossy(); let mode = self.mode.clone(); self.state = InternalDiffState::Loading; self.computing_diffs_abort_handle = Some(ctx.spawn( async move { Self::load_diffs_for_repo(current_repository_path, mode, should_fetch_base).await }, Self::handle_updated_state_for_repo, )); } #[cfg(not(feature = "local_fs"))] pub fn load_diffs_for_current_repo( &mut self, _should_fetch_base: bool, _track_load_duration: bool, _ctx: &mut ModelContext, ) { // Noop on WASM builds. } /// Stashes uncommitted changes for specific files #[cfg(feature = "local_fs")] async fn stash_uncommitted_changes(repo_path: &Path, relative_paths: &[String]) -> Result<()> { let app_id = ChannelState::app_id(); let app_name = app_id.application_name(); let msg = if relative_paths.len() == 1 { format!("{app_name}: stash {}", relative_paths[0]) } else { format!("{app_name}: stash {} files", relative_paths.len()) }; let mut stash_args = vec!["stash", "push", "-u", "-m", msg.as_str(), "--"]; for path in relative_paths { stash_args.push(path.as_str()); } log::debug!( "[GIT OPERATION] local.rs stash_uncommitted_changes git {}", stash_args.join(" ") ); let stash_res = run_git_command(repo_path, &stash_args).await; match stash_res { Ok(_) => Ok(()), Err(err) => { let err_msg = err.to_string(); // If there are no local changes to stash, git stash will fail // In this case, we can safely ignore the error if err_msg.contains("No local changes to save") { Ok(()) } else { let context = if relative_paths.len() == 1 { relative_paths[0].clone() } else { format!("{} files", relative_paths.len()) }; Err(anyhow!( "Failed to stash changes for {}: {}", context, err_msg )) } } } } /// Runs git restore and git clean for one or more files #[cfg(feature = "local_fs")] async fn git_restore_and_clean( repo_path: &Path, relative_paths: &[String], branch: &str, ) -> Result<()> { let source_arg = format!("--source={branch}"); let mut restore_args = vec![ "restore", "--staged", "--worktree", source_arg.as_str(), "--", ]; for path in relative_paths { restore_args.push(path.as_str()); } log::debug!( "[GIT OPERATION] local.rs git_restore_and_clean git {}", restore_args.join(" ") ); let restore_res = run_git_command(repo_path, &restore_args).await; match restore_res { Ok(_) => { // Clean untracked files for these specific paths let mut clean_args = vec!["clean", "-fd"]; for path in relative_paths { clean_args.push(path.as_str()); } log::debug!( "[GIT OPERATION] local.rs git_restore_and_clean git {}", clean_args.join(" ") ); let clean_res = run_git_command(repo_path, &clean_args).await; match clean_res { Ok(_) => Ok(()), Err(err) => { log::warn!("Failed to clean untracked files: {err}"); Ok(()) } } } Err(err) => { let err_msg = err.to_string(); if branch == "HEAD" && err_msg.contains("could not resolve HEAD") { let mut clean_args = vec!["clean", "-fd"]; for path in relative_paths { clean_args.push(path.as_str()); } log::debug!( "[GIT OPERATION] local.rs git_restore_and_clean git {}", clean_args.join(" ") ); let clean_res = run_git_command(repo_path, &clean_args).await; if let Err(err) = clean_res { log::warn!("Failed to clean untracked files: {err}"); } Ok(()) } else if err_msg.contains("did not match any file(s) known to git") { // If some files don't exist in the branch, we need to remove them for file_path in relative_paths { log::debug!( "[GIT OPERATION] local.rs git_restore_and_clean git rm -f -- {file_path}" ); let rm_res = run_git_command(repo_path, &["rm", "-f", "--", file_path.as_str()]) .await; if let Err(rm_err) = rm_res { let rm_err_msg = rm_err.to_string(); if rm_err_msg.contains("did not match any files") { // if the file was staged but it isn't in the working directory, // e.g. it was locally deleted log::debug!( "[GIT OPERATION] local.rs git_restore_and_clean git reset -- {file_path}" ); if let Err(e) = run_git_command(repo_path, &["reset", "--", file_path.as_str()]) .await { log::warn!("Failed to unstage file '{file_path}': {e}"); } } else { log::warn!("Failed to remove file '{file_path}': {rm_err_msg}"); } } if let Err(e) = fs::remove_file(repo_path.join(file_path)) && e.kind() != std::io::ErrorKind::NotFound { log::warn!("Failed to remove file '{file_path}' from filesystem: {e}"); } } Ok(()) } else { Err(err) } } } } /// Removes files based on the operation type #[cfg(feature = "local_fs")] async fn discard_files_impl( repo_sp: &StandardizedPath, file_infos: Vec, should_stash: bool, branch: &str, ) -> Result<()> { let Some(repo_path) = repo_sp.to_local_path() else { anyhow::bail!("discard_files_impl called with non-local path: {repo_sp}"); }; let mut renamed_file_infos = Vec::new(); let mut other_file_infos = Vec::new(); for info in file_infos { if matches!(info.status, GitFileStatus::Renamed { .. }) { renamed_file_infos.push(info); } else { other_file_infos.push(info); } } // Handle renamed files specially if !renamed_file_infos.is_empty() { if branch == "HEAD" && should_stash { let renamed_paths: Vec = renamed_file_infos .iter() .map(|info| { info.path .strip_prefix(repo_sp) .unwrap_or(info.path.as_str()) .to_string() }) .collect(); Self::stash_uncommitted_changes(&repo_path, &renamed_paths).await?; for info in &renamed_file_infos { if let GitFileStatus::Renamed { old_path } = &info.status { log::debug!( "[GIT OPERATION] local.rs discard_files_impl git restore --staged --worktree -- {old_path}" ); let _ = run_git_command( &repo_path, &["restore", "--staged", "--worktree", "--", old_path], ) .await; } } } else { for info in renamed_file_infos { if let GitFileStatus::Renamed { old_path } = &info.status { let relative_new_path = info .path .strip_prefix(repo_sp) .unwrap_or(info.path.as_str()) .to_string(); // Remove the new file log::debug!( "[GIT OPERATION] local.rs discard_files_impl git rm -f -- {relative_new_path}" ); if let Err(e) = run_git_command(&repo_path, &["rm", "-f", "--", &relative_new_path]) .await { log::warn!("Failed to remove renamed file '{relative_new_path}': {e}"); } // Restore the old file from the branch using git checkout // We use checkout instead of restore because the old path doesn't exist in the // working directory yet, and git restore requires the path to exist log::debug!( "[GIT OPERATION] local.rs discard_files_impl git checkout {branch} -- {old_path}" ); if let Err(e) = run_git_command(&repo_path, &["checkout", branch, "--", old_path]).await { report_error!( e.context("Failed to restore old file from branch"), extra: { "old_path" => %old_path, "branch" => %branch } ); } } } } } // Handle other files normally if !other_file_infos.is_empty() { let relative_paths: Vec = other_file_infos .iter() .map(|info| { info.path .strip_prefix(repo_sp) .unwrap_or(info.path.as_str()) .to_string() }) .collect(); if branch == "HEAD" && should_stash { Self::stash_uncommitted_changes(&repo_path, &relative_paths).await?; } else { Self::git_restore_and_clean(&repo_path, &relative_paths, branch).await?; } } Ok(()) } /// Discard changes for one or more files #[cfg(feature = "local_fs")] pub fn discard_files( &mut self, file_infos: Vec, should_stash: bool, branch_name: Option, ctx: &mut ModelContext, ) { let Some(current_repository) = &self.repository else { return; }; let current_repository_path = current_repository.as_ref(ctx).root_dir().clone(); let branch = branch_name.unwrap_or_else(|| "HEAD".to_string()); ctx.spawn( async move { Self::discard_files_impl( ¤t_repository_path, file_infos, should_stash, &branch, ) .await }, |me, result, ctx| match result { Ok(_) => { me.load_diffs_for_current_repo(false, false, ctx); me.refresh_diff_metadata_for_current_repo(false, ctx); } Err(err) => { report_error!(err.context("Failed to restore files")); } }, ); } #[cfg(not(feature = "local_fs"))] pub fn discard_files( &mut self, _file_infos: Vec, _should_stash: bool, _branch_name: Option, _ctx: &mut ModelContext, ) { // Noop on WASM builds. } /// Sets whether the code review pane needs diff metadata. /// When transitioning from disabled to enabled, triggers an /// immediate refresh to catch up on changes that occurred while disabled. pub fn set_code_review_metadata_refresh_enabled( &mut self, enabled: bool, ctx: &mut ModelContext, ) { let was_enabled = self.metadata_refresh_enabled; self.metadata_refresh_enabled = enabled; if !was_enabled && enabled { self.refresh_diff_metadata_for_current_repo(false, ctx); } } /// Loads diff metadata (aggregate lines added, removed, etc.) for the current repo. #[cfg(feature = "local_fs")] pub fn refresh_diff_metadata_for_current_repo( &mut self, should_reload_diffs: bool, ctx: &mut ModelContext, ) { if !self.metadata_refresh_enabled { return; } let Some(current_repository) = &self.repository else { return; }; let current_repository_path = current_repository .as_ref(ctx) .root_dir() .to_local_path_lossy(); if let Some(handle) = self.computing_metadata_abort_handle.take() { handle.abort(); } // Always include base branch metadata since only code review uses this model now. let include_base_branch = true; let abort_handle = ctx.spawn( async move { Self::load_metadata_for_repo(current_repository_path, include_base_branch).await }, move |me, res, ctx| me.handle_updated_metadata_for_repo(res, should_reload_diffs, ctx), ); self.computing_metadata_abort_handle = Some(abort_handle); } #[cfg(not(feature = "local_fs"))] pub fn refresh_diff_metadata_for_current_repo( &mut self, _should_reload_diffs: bool, _ctx: &mut ModelContext, ) { // Noop on WASM builds. } #[cfg(feature = "local_fs")] fn set_active_repository( &mut self, new_repository: ModelHandle, ctx: &mut ModelContext, ) { use std::time::Duration; // Assign the repository handle before spawning the registration future. // `registration_future` may resolve immediately (e.g. watcher already active), and // `handle_repository_updated` relies on `self.repository` being available for cleanup. self.repository = Some(new_repository.clone()); // Only kick off the expensive metadata + diff loading when the code // review pane is actually open. When the pane opens later, `on_open` // calls `set_code_review_metadata_refresh_enabled(true)` (which // triggers metadata) and `load_diffs_for_current_repo` (which triggers // diffs), so nothing is lost — just deferred. if self.metadata_refresh_enabled { let new_repository_root = new_repository.as_ref(ctx).root_dir().to_local_path_lossy(); // Always include base branch metadata since only code review uses this model now. let include_base_branch = true; let abort_handle = ctx.spawn( async move { Self::load_metadata_for_repo(new_repository_root, include_base_branch).await }, move |me, res, ctx| me.handle_updated_metadata_for_repo(res, true, ctx), ); self.computing_metadata_abort_handle = Some(abort_handle); self.state = InternalDiffState::Loading; } let (repository_update_tx, repository_update_rx) = async_channel::unbounded(); let (throttled_repository_update_tx, throttled_repository_update_rx) = async_channel::unbounded(); let start = new_repository.update(ctx, |new_repository, ctx| { new_repository.start_watching( RepositoryWatchMode::GitRepository, Box::new(LocalDiffStateModelRepositorySubscriber { repository_update_tx, }), ctx, ) }); self.subscriber_id = Some(start.subscriber_id); ctx.spawn(start.registration_future, Self::handle_repository_updated); ctx.spawn_stream_local( repository_update_rx, move |me, item, ctx| match item { DiffStateRepositoryUpdate::Invalidation(update) => { if me.handle_file_update(update, ctx) { log::debug!("[GIT OPERATION] handle_file_update found changes"); let throttled_repository_update_tx_clone = throttled_repository_update_tx.clone(); ctx.background_executor() .spawn(async move { let _ = throttled_repository_update_tx_clone.send(()).await; }) .detach(); } else { log::debug!("[GIT OPERATION] No changes found no metadata update."); } } DiffStateRepositoryUpdate::InvalidationWithLockedIndex => { me.queue_full_invalidation(); } }, |_, _| {}, ); // Only update metadata at most once every 5 seconds. The code review pane // will refresh diffs at a faster rate if it's open (from the stream above). ctx.spawn_stream_local( throttle(Duration::from_secs(5), throttled_repository_update_rx), |me, _, ctx| { me.refresh_diff_metadata_for_current_repo(false, ctx); }, |_, _| {}, ); } #[cfg(feature = "local_fs")] /// Processes a repository file-system update and /// decides whether to trigger a full reload or a per-file invalidation queue. /// /// Returns `true` if a metadata refresh should be triggered. fn handle_file_update( &mut self, update: RepositoryUpdate, ctx: &mut ModelContext, ) -> bool { // Refresh if there are file changes or if commit state has been updated if update.is_empty() { return false; } let RepositoryUpdate { added, modified, deleted, moved, commit_updated, index_lock_detected, remote_ref_updated, } = update; // When the code review pane is closed (metadata_refresh_enabled == // false), skip diff reloads and per-file invalidations. The throttled // metadata path already respects this flag, and `on_open` will trigger // a full reload when the pane becomes visible. if !self.metadata_refresh_enabled { return false; } if commit_updated || remote_ref_updated { self.load_diffs_for_current_repo(false, false, ctx); // Don't emit MetadataRefreshed here — metadata hasn't been // recomputed yet. NewDiffsComputed handles the immediate UI // refresh, and the throttled metadata refresh will emit // MetadataRefreshed with fresh stats/git-operations data. return true; } else if index_lock_detected { if self.file_invalidation.invalidate_all_pending { // Lock was released while a full invalidation was pending — reload now. self.load_diffs_for_current_repo(false, false, ctx); return true; } // Lock just appeared — suppress the per-file queue (data may be stale // while the lock is held) and wait for the lock-release event. self.queue_full_invalidation(); return false; } // If a previous index-lock event set `invalidate_all_pending` but the // lock has since cleared without a commit (e.g. aborted merge), recover // by forcing a full reload. Without this, all subsequent file // invalidations would be silently deferred forever. if self.file_invalidation.invalidate_all_pending { self.load_diffs_for_current_repo(false, false, ctx); return true; } // Filter out gitignored files and extract paths let changed_files = added .into_iter() .chain(modified) .chain(deleted) .chain(moved.into_iter().flat_map(|(k, v)| [k, v])) .filter(|target_file| !target_file.is_ignored) .map(|target_file| target_file.path) .collect::>(); if changed_files.is_empty() { return false; } // Check if .gitignore was modified - if so, do a full reload since // this can fundamentally change which files should appear in the diff let gitignore_modified = changed_files.iter().any(|path| { path.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name == ".gitignore") }); if gitignore_modified { self.load_diffs_for_current_repo(false, false, ctx); } else { self.enqueue_file_invalidations(changed_files, ctx); } // Don't emit MetadataRefreshed here — the metadata struct hasn't been // recomputed yet. The throttled refresh path will emit it with fresh // data once `refresh_diff_metadata_for_current_repo` completes. true } /// Enqueues per-file invalidation tasks into the model's SyncQueue. /// Defers to `pending_file_updates` if a full reload is in-flight. #[cfg(feature = "local_fs")] fn enqueue_file_invalidations(&mut self, files: Vec, ctx: &mut ModelContext) { let Some(repo_path) = self.active_repository_path(ctx) else { return; }; if self.file_invalidation.invalidate_all_pending { // TODO: Remove pending file invalidations — pause the queue instead. // Defer file invalidation if a full reload is in-flight or the diff is still loading. match &mut self.pending_file_updates { Some(pending) => { pending.update_with_file_invalidation(repo_path, files); } None => { self.pending_file_updates = Some(PendingFileUpdate { repo_path, pending_file_edits: HashSet::from_iter(files), }); } } return; } let mode = self.mode.clone(); let merge_base = self.file_invalidation.merge_base.clone(); let queue = self.file_invalidation.queue.clone(); for file in files { let task = FileInvalidationTask { file, repo_path: repo_path.clone(), mode: mode.clone(), merge_base: merge_base.clone(), }; queue.enqueue(task, None, "file-invalidation"); } } /// Flushes deferred file invalidations that accumulated during a full reload. #[cfg(feature = "local_fs")] fn flush_pending_invalidations(&mut self, ctx: &mut ModelContext) { self.file_invalidation.invalidate_all_pending = false; let Some(repo_path) = self.active_repository_path(ctx) else { return; }; let pending = self .pending_file_updates .take() .filter(|p| p.repo_path == repo_path); if let Some(pending) = pending { self.enqueue_file_invalidations(pending.pending_file_edits.into_iter().collect(), ctx); } } /// Recomputes the merge base commit for the current diff mode after a full /// reload, then flushes any file invalidations that were deferred while the /// reload was in-flight. /// /// We cache the merge base on [`FileInvalidationState`] so that individual /// file invalidations (triggered by the file watcher) can diff against the /// correct base without re-running `git merge-base` on every file change. /// The cache is invalidated by [`FileInvalidationState::cancel_all`] at the /// start of every full reload, so it is always fresh by the time /// watcher-driven invalidations resume. /// /// For non-Head diff modes the computation is async (it shells out to git), /// so we keep the `invalidate_all_pending` flag set until it completes. /// This ensures watcher-driven file invalidations are deferred rather than /// enqueued without a merge base. #[cfg(feature = "local_fs")] fn recompute_merge_base_and_flush(&mut self, ctx: &mut ModelContext) { let diff_mode = self.mode.clone(); if !matches!(diff_mode, DiffMode::Head) { let Some(repo_path) = self.active_repository_path(ctx) else { self.flush_pending_invalidations(ctx); return; }; // Abort the previous merge-base computation if still in-flight. if let Some(old_handle) = self.file_invalidation.merge_base_handle.take() { old_handle.abort(); } let handle = ctx.spawn( async move { Self::compute_merge_base(&repo_path, &diff_mode).await }, |me, result, ctx| { me.file_invalidation.merge_base_handle = None; match result { Ok(merge_base) => { me.file_invalidation.merge_base = Some(merge_base); } Err(e) => { report_error!(e.context("Failed to compute merge base")); } } me.flush_pending_invalidations(ctx); }, ); self.file_invalidation.merge_base_handle = Some(handle); } else { self.flush_pending_invalidations(ctx); } } /// Refreshes metadata after a git operation (commit, push, create PR). /// Does NOT reload diffs — for commits the file watcher triggers that via /// `commit_updated`, and for push/create-PR the working directory hasn't /// changed so the loaded diffs are still valid. pub fn refresh_metadata_after_git_operation(&mut self, ctx: &mut ModelContext) { self.refresh_diff_metadata_for_current_repo(false, ctx); } /// Applies a post-git-operation delta (refreshed unpushed commits + /// upstream ref) to the cached metadata and emits `MetadataRefreshed`, /// giving an immediate header update without the full metadata recompute. /// The filesystem watcher remains the eventual-consistency backstop. pub fn apply_git_op_delta( &mut self, unpushed_commits: Vec, upstream_ref: Option, ctx: &mut ModelContext, ) { let metadata = self.metadata.get_or_insert_with(DiffMetadata::default); metadata.unpushed_commits = unpushed_commits; metadata.upstream_ref = upstream_ref; ctx.emit(DiffStateModelEvent::MetadataRefreshed(Box::new( metadata.clone(), ))); } /// Computes the committed branch files (`merge_base(HEAD, main)..HEAD`) /// off-thread and emits `BranchCommittedFilesReceived` for the Create PR /// dialog's Changes box. Committed-only, so uncommitted edits and untracked /// files are excluded — matching what `gh pr create` includes. Emits an /// empty list when there is no active repository path. pub fn fetch_committed_branch_files(&self, ctx: &mut ModelContext) { let Some(repo_path) = self.active_repository_path(ctx) else { ctx.emit(DiffStateModelEvent::BranchCommittedFilesReceived(Vec::new())); return; }; ctx.spawn( async move { crate::util::git::get_committed_branch_file_entries(&repo_path).await }, |_me, result, ctx| { let files = result.unwrap_or_else(|err| { log::warn!("Failed to load committed branch files: {err}"); Vec::new() }); ctx.emit(DiffStateModelEvent::BranchCommittedFilesReceived(files)); }, ); } /// Runs a commit chain (commit, then optionally push, then optionally create-PR) on the local working tree. /// Applies the post-chain delta to metadata and emits `GitOpCompleted`. #[allow(clippy::too_many_arguments)] pub fn git_commit_chain( &self, mode: CommitChainMode, message: String, include_unstaged: bool, branch: String, autogenerate_pr_content: bool, ctx: &mut ModelContext, ) { let Some(repo_path) = self.active_repository_path(ctx) else { ctx.emit(DiffStateModelEvent::GitOpCompleted( GitOpResult::CommitChainCompleted(Err("no active repository".to_string())), )); return; }; // AI title/body generation runs only for the create-PR stage when the // caller asked for it (mirrors the daemon's gating). let ai_client = (matches!(mode, CommitChainMode::CommitAndCreatePr) && autogenerate_pr_content) .then(|| ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client()); let path_future = Self::interactive_path_future(ctx); ctx.spawn( async move { let path_env = path_future.await; git_actions::run_commit_chain( &repo_path, mode, &message, include_unstaged, &branch, ai_client.as_deref(), path_env.as_deref(), ) .await }, |me, result, ctx| { let domain_result = match result { Ok((commits, upstream_ref, pr_info)) => { // Apply the delta for an immediate header refresh. Local // PR info is sourced from `GitRepoStatusModel`, so it is // not written to metadata here. me.apply_git_op_delta(commits, upstream_ref, ctx); Ok(pr_info) } Err(e) => Err(e.to_string()), }; ctx.emit(DiffStateModelEvent::GitOpCompleted( GitOpResult::CommitChainCompleted(domain_result), )); }, ); } /// Pushes `branch` (setting upstream) on the local working tree, applies /// the refreshed delta, and emits `GitOpCompleted`. pub fn git_push(&self, branch: String, ctx: &mut ModelContext) { let Some(repo_path) = self.active_repository_path(ctx) else { ctx.emit(DiffStateModelEvent::GitOpCompleted( GitOpResult::PushCompleted(Err("no active repository".to_string())), )); return; }; let path_future = Self::interactive_path_future(ctx); ctx.spawn( async move { let path_env = path_future.await; git_actions::run_push(&repo_path, &branch, path_env.as_deref()).await }, |me, result, ctx| { let domain_result = match result { Ok((commits, upstream_ref)) => { me.apply_git_op_delta(commits, upstream_ref, ctx); Ok(()) } Err(e) => Err(e.to_string()), }; ctx.emit(DiffStateModelEvent::GitOpCompleted( GitOpResult::PushCompleted(domain_result), )); }, ); } /// Creates a PR for `branch` on the local working tree and emits /// `GitOpCompleted`. Local PR info is sourced from `GitRepoStatusModel`, so /// no metadata is written here. pub fn create_pr( &self, branch: String, autogenerate_content: bool, ctx: &mut ModelContext, ) { let Some(repo_path) = self.active_repository_path(ctx) else { ctx.emit(DiffStateModelEvent::GitOpCompleted(GitOpResult::PrCreated( Err("no active repository".to_string()), ))); return; }; let ai_client = autogenerate_content .then(|| ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client()); let path_future = Self::interactive_path_future(ctx); ctx.spawn( async move { let path_env = path_future.await; git_actions::create_pr( &repo_path, &branch, ai_client.as_deref(), path_env.as_deref(), ) .await }, |_me, result, ctx| { ctx.emit(DiffStateModelEvent::GitOpCompleted(GitOpResult::PrCreated( result.map_err(|e| e.to_string()), ))); }, ); } /// Generates an AI commit message for the working tree and emits `CommitMessageGenerated`. pub fn generate_commit_message( &self, include_unstaged: bool, branch_name: String, ctx: &mut ModelContext, ) { let Some(repo_path) = self.active_repository_path(ctx) else { ctx.emit(DiffStateModelEvent::CommitMessageGenerated(Err( "no active repository".to_string(), ))); return; }; let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client(); ctx.spawn( async move { git_actions::generate_commit_message( &repo_path, &branch_name, include_unstaged, ai_client.as_ref(), ) .await }, |_me, result, ctx| { ctx.emit(DiffStateModelEvent::CommitMessageGenerated( result.map_err(|e| e.to_string()), )); }, ); } /// Future resolving to the user's interactive-shell `PATH` (or `None`), /// forwarded to git/gh so hooks and tooling resolve like an interactive /// shell. Mirrors the daemon's helper. fn interactive_path_future( ctx: &mut ModelContext, ) -> futures::future::BoxFuture<'static, Option> { #[cfg(feature = "local_tty")] { LocalShellState::handle(ctx).update(ctx, |shell_state, ctx| { shell_state.get_interactive_path_env_var(ctx) }) } #[cfg(not(feature = "local_tty"))] { use futures::FutureExt; let _ = ctx; futures::future::ready(None).boxed() } } #[cfg(feature = "local_fs")] fn handle_repository_updated( &mut self, result: Result<(), RepoMetadataError>, ctx: &mut ModelContext, ) { if let Err(err) = result { log::warn!("Could not update repository: {err}"); let Some(repository) = self.repository.as_ref() else { return; }; let Some(subscriber_id) = self.subscriber_id.take() else { return; }; repository.update(ctx, |repo, ctx| { repo.stop_watching(subscriber_id, ctx); }); } } /// Stops watching the active repository without clearing other state. /// Called when this model is about to be dropped to ensure the underlying /// `DirectoryWatcher` can be cleaned up. #[cfg(feature = "local_fs")] pub(crate) fn stop_active_watcher(&mut self, ctx: &mut ModelContext) { if let Some(repository) = &self.repository && let Some(subscriber_id) = self.subscriber_id.take() { repository.update(ctx, |repo, ctx| { repo.stop_watching(subscriber_id, ctx); }); } } /// Gets the merge base between HEAD and the specified branch async fn get_merge_base(repo_path: &Path, branch: &str) -> Result { log::debug!("[GIT OPERATION] local.rs get_merge_base git merge-base HEAD {branch}"); let output = run_git_command(repo_path, &["merge-base", "HEAD", branch]).await?; Ok(output.trim().to_string()) } /// Computes the merge base for the given diff mode. /// /// For [`DiffMode::MainBranch`] the main branch is detected automatically; /// for [`DiffMode::OtherBranch`] the provided branch name is used directly. /// [`DiffMode::Head`] does not have a merge base and returns an error. pub(crate) async fn compute_merge_base(repo_path: &Path, mode: &DiffMode) -> Result { let branch = match mode { DiffMode::MainBranch => detect_main_branch(repo_path).await?, DiffMode::OtherBranch(branch) => branch.clone(), DiffMode::Head => { anyhow::bail!("merge base is not applicable for Head mode") } }; Self::get_merge_base(repo_path, &branch).await } /// Like [`Self::get_merge_base`], but if the branch ref doesn't exist /// locally, attempts to fetch it from the `origin` remote so that /// `git merge-base` can succeed even when the base branch was never /// checked out in this working copy. async fn get_or_fetch_merge_base(repo_path: &Path, branch: &str) -> Result { // Fast path: the ref already exists locally (local branch or remote-tracking ref). if let Ok(merge_base) = Self::get_merge_base(repo_path, branch).await { return Ok(merge_base); } // The ref may not be present locally. Try the remote-tracking ref // before hitting the network. let origin_branch = format!("origin/{branch}"); if let Ok(merge_base) = Self::get_merge_base(repo_path, &origin_branch).await { return Ok(merge_base); } // Fetch the branch from origin. This creates / updates the remote-tracking // ref `origin/` without altering the working tree. log::warn!("Base branch '{branch}' not found locally, fetching from origin"); run_git_command(repo_path, &["fetch", "origin", branch]).await?; // Retry with the now-available remote-tracking ref. Self::get_merge_base(repo_path, &origin_branch).await } async fn load_metadata_for_repo( repo_path: PathBuf, include_base_branch: bool, ) -> Result { // Detect the main branch name first let main_branch_name = detect_main_branch(&repo_path).await?; let current_branch_name = detect_current_branch(&repo_path).await?; log::debug!("[GIT OPERATION] local.rs load_metadata_for_repo git rev-parse HEAD"); let has_head_commit = run_git_command(&repo_path, &["rev-parse", "HEAD"]) .await .is_ok(); let diff_against_head = diff_metadata_against_head(&repo_path).await?; let diff_against_base_branch = if include_base_branch { Some(Self::diff_metadata_against_specific_branch(&repo_path, &main_branch_name).await?) } else { None }; let (unpushed_commits, upstream_ref) = if FeatureFlag::GitOperationsInCodeReview.is_enabled() { let upstream_branch = run_git_command( &repo_path, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], ) .await .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let unpushed = get_unpushed_commits( &repo_path, Some(current_branch_name.as_str()), upstream_branch.as_deref(), ) .await .unwrap_or_default(); (unpushed, upstream_branch) } else { (Vec::new(), None) }; Ok(DiffMetadata { main_branch_name, current_branch_name, against_head: diff_against_head, against_base_branch: diff_against_base_branch, has_head_commit, unpushed_commits, upstream_ref, }) } /// Load diff data for a given mode without requiring an existing model instance. /// Always loads fresh data from disk. #[cfg(feature = "local_fs")] pub async fn load_diff_data_for_mode( mode: DiffMode, repo_path: PathBuf, ) -> Option { let diffs = Self::load_diffs_for_repo(repo_path, mode, false).await; diffs.changes.ok().map(|diff| diff.into()) } /// Load diff data with `content_at_head` for a given mode without /// requiring an existing model instance. Used by the remote server to /// serve late-joining subscribers that need `content_at_head` for editor /// rendering, without disturbing the model's state. #[cfg(feature = "local_fs")] pub async fn load_diffs_with_content_for_mode( mode: DiffMode, repo_path: PathBuf, ) -> Option { let diffs = Self::load_diffs_for_repo(repo_path, mode, false).await; diffs.changes.ok() } async fn load_diffs_for_repo( repo_path: PathBuf, mode: DiffMode, should_fetch_base: bool, ) -> DiffsWithBaseContent { let diffs = match mode { DiffMode::Head => Self::diff_state_against_head(&repo_path).await, DiffMode::MainBranch => { Self::diff_state_against_base_branch(&repo_path, should_fetch_base).await } DiffMode::OtherBranch(branch) => { Self::diff_state_against_specific_branch(&repo_path, branch, should_fetch_base) .await } }; DiffsWithBaseContent { changes: diffs.map_err(|err| err.to_string()), } } fn handle_updated_metadata_for_repo( &mut self, metadata: Result, should_reload_diffs: bool, ctx: &mut ModelContext, ) { let previous_branch = self .metadata .as_ref() .map(|metadata| metadata.current_branch_name.clone()); match metadata { Ok(metadata) => { self.metadata = Some(metadata); } Err(e) => { let err = DiffStateError::from(e); err.report_and_log(); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::LoadMetadataFailed { backend_origin: self.backend_origin, mode: self.mode.clone(), error: err.to_string(), }, ctx ); self.metadata = None; } } let current_branch = self .metadata .as_ref() .map(|metadata| metadata.current_branch_name.clone()); if previous_branch != current_branch { ctx.emit(DiffStateModelEvent::CurrentBranchChanged); } if should_reload_diffs { self.load_diffs_for_current_repo(false, false, ctx); } if let Some(metadata) = self.metadata.clone() { ctx.emit(DiffStateModelEvent::MetadataRefreshed(Box::new(metadata))); } } #[cfg(feature = "local_fs")] fn handle_updated_state_for_repo( &mut self, diffs: DiffsWithBaseContent, ctx: &mut ModelContext, ) { let load_duration = match &diffs.changes { Ok(_) => self .tracked_diff_load_start_time .take() .map(|start| start.elapsed()), Err(e) => { let load_duration = self .tracked_diff_load_start_time .take() .map(|start| start.elapsed()); let err = DiffStateError::from_message(e); err.report_and_log(); send_telemetry_from_ctx!( CodeReviewTelemetryEvent::LoadDiffFailed { backend_origin: self.backend_origin, operation: DiffOperation::DiffLoad, mode: self.mode.clone(), error: err.to_string(), load_duration, }, ctx ); None } }; self.state = InternalDiffState::Loaded((&diffs).into()); // Compute merge base and flush deferred invalidations before emitting. self.recompute_merge_base_and_flush(ctx); ctx.emit(DiffStateModelEvent::NewDiffsComputed { diffs: diffs.changes.ok().map(Arc::new), load_duration, }); } /// Returns the number of lines in a given file. Returns `None` if the file is a binary file /// or if it's larger than 20MB. async fn num_lines_in_file_if_non_binary(file_path: &Path) -> Result> { const MAX_FILE_SIZE_TO_READ_LINES: u64 = 20 * 1000 * 1000; // 20MB // Size of the chunk of file to read to determine whether it's binary. const CHUNK_SIZE: usize = 1024; let mut file = File::open(file_path)?; let mut buffer = vec![0; CHUNK_SIZE]; let n = file.read(&mut buffer)?; buffer.truncate(n); if warp_util::file_type::is_buffer_binary(&buffer) { return Ok(None); } file.rewind()?; if file.metadata()?.len() > MAX_FILE_SIZE_TO_READ_LINES { return Ok(None); } Self::num_lines_in_file(file).await.map(Some) } /// Returns the number of lines in a file. This is optimized to avoid loading the entire file into memory. async fn num_lines_in_file(file: File) -> Result { // Read in chunks of 64 KBs. const CHUNK_SIZE: usize = 1024 * 64; let mut reader = BufReader::with_capacity(CHUNK_SIZE, file); let mut count = 0; loop { let len = { let buf = reader.fill_buf()?; if buf.is_empty() { break; } count += bytecount::count(buf, b'\n'); buf.len() }; reader.consume(len); // Yield so that an attempt to abort this operation is handled. futures_lite::future::yield_now().await; } Ok(count) } async fn file_statuses_against_head(repo_path: &Path) -> Result> { // First, get the list of changed files with their status log::debug!( "[GIT OPERATION] local.rs file_statuses_against_head git --no-optional-locks status --untracked-files=all --branch --porcelain=2 -z" ); let status_output = run_git_command( repo_path, &[ "--no-optional-locks", // Avoid taking locks that might interfere with other git operations "status", "--untracked-files=all", // Get all untracked files "--branch", // Get branch information "--porcelain=2", // Use porcelain=2 to match git desktop implementation "-z", // Split output by null characters ], ) .await?; Self::parse_git_status(&status_output) } async fn diff_state_against_head(repo_path: &Path) -> Result { let changed_files = Self::file_statuses_against_head(repo_path).await?; // Get binary file information using git diff --numstat let binary_files = Self::get_binary_files(repo_path).await?; // Then get the diff for each file let mut files = Vec::new(); let mut total_additions = 0; let mut total_deletions = 0; for (file_path, status) in changed_files { let is_binary = binary_files.contains(&file_path); let mut file_diff = Self::get_file_diff(repo_path, &file_path, &status, is_binary, None).await?; // Never read or ship base content for binary files: it can't be // inline-rendered and, after lossy UTF-8 decoding, can balloon ~3x. let content_at_head = if is_binary { None } else { Self::get_file_content_at_head(repo_path, &file_path, &status).await }; file_diff.is_autogenerated = is_file_autogenerated(&file_path, content_at_head.as_deref()); total_additions += file_diff.additions(); total_deletions += file_diff.deletions(); files.push(FileDiffAndContent { file_diff, content_at_head, }); } Ok(GitDiffWithBaseContent { files_changed: files.len(), files, total_additions, total_deletions, }) } async fn diff_state_against_base_branch( repo_path: &Path, should_fetch_base: bool, ) -> Result { // First detect the main branch let main_branch = detect_main_branch(repo_path).await?; Self::diff_state_against_specific_branch(repo_path, main_branch, should_fetch_base).await } /// Returns the per-file status by running a scoped `git status` (Head mode) /// or `git diff --name-status` (base-branch mode) limited to a single path. async fn file_status_for_path( repo_path: &Path, relative: &str, mode: &DiffMode, merge_base: Option<&str>, ) -> Result> { match (mode, merge_base) { (DiffMode::Head, _) => { log::debug!( "[GIT OPERATION] local.rs file_status_for_path git status -- {relative}" ); let output = run_git_command( repo_path, &[ "--no-optional-locks", "status", "--porcelain=2", "-z", "--", relative, ], ) .await?; let statuses = Self::parse_git_status(&output)?; Ok(statuses.into_iter().find(|(p, _)| *p == relative)) } (_, Some(base)) => { log::debug!( "[GIT OPERATION] local.rs file_status_for_path git diff --name-status -z {base} -- {relative}" ); let diff_output = run_git_command( repo_path, &["diff", "--name-status", "-z", base, "--", relative], ) .await?; if let Some(entry) = Self::parse_git_diff_name_status(&diff_output)? .into_iter() .find(|(p, _)| *p == relative) { return Ok(Some(entry)); } // The file may be untracked (not in the base diff). Fall back to // `git status` scoped to this path to detect untracked files. log::debug!( "[GIT OPERATION] local.rs file_status_for_path git status -- {relative} (untracked fallback)" ); let status_output = run_git_command( repo_path, &["status", "--porcelain=2", "-z", "--", relative], ) .await?; let status_files = Self::parse_git_status(&status_output)?; Ok(status_files .into_iter() .find(|(p, s)| *p == relative && matches!(s, GitFileStatus::Untracked))) } _ => Ok(None), } } /// Checks whether a single file is binary by running a scoped /// `git diff --numstat -- `. async fn is_file_binary(repo_path: &Path, relative: &str, commit: &str) -> Result { log::debug!( "[GIT OPERATION] local.rs is_file_binary git diff --numstat {commit} -- {relative}" ); let output = match run_git_command( repo_path, &["diff", "--numstat", commit, "--", relative], ) .await { Ok(o) => o, Err(_) => return Ok(false), }; // numstat output: "\t\t" — binary files use "-\t-". Ok(output .lines() .next() .is_some_and(|line| line.starts_with("-\t-"))) } /// Retrieves the diff state for a single invalidated file using scoped /// per-file git commands instead of full-repo operations. /// /// Returns `(relative_path, Option>)` — `None` when the /// file is no longer part of the diff (e.g. reverted). pub async fn retrieve_diff_state( repo_path: &Path, file: &Path, mode: &DiffMode, merge_base: Option<&str>, ) -> Result<(String, Option>)> { let relative = file .strip_prefix(repo_path) .unwrap_or(file) .to_str() .ok_or_else(|| anyhow!("non-UTF-8 path"))? .to_owned(); let Some((file_path, status)) = Self::file_status_for_path(repo_path, &relative, mode, merge_base).await? else { // File is no longer part of the diff. return Ok((relative, None)); }; let commit = match mode { DiffMode::Head => "HEAD", _ => merge_base.unwrap_or("HEAD"), }; let is_binary = Self::is_file_binary(repo_path, &relative, commit).await?; let diff = Self::file_diff_for_path(is_binary, repo_path, &file_path, &status, merge_base).await?; Ok((relative, diff.map(Arc::new))) } async fn file_diff_for_path( is_binary: bool, repo_path: &Path, file_path: &str, status: &GitFileStatus, merge_base: Option<&str>, ) -> Result> { let mut file_diff = Self::get_file_diff(repo_path, file_path, status, is_binary, merge_base).await?; // Skip files that have no actual changes (empty hunks and not binary) // Also skip files with no additions or deletions (no real changes) except for renamed or new files. if !is_binary && (file_diff.hunks.is_empty() || file_diff.is_empty()) && !status.is_renamed() && !status.is_new_file() { return Ok(None); } // Never read or ship base content for binary files: it can't be // inline-rendered and, after lossy UTF-8 decoding, can balloon ~3x. let content_at_head = if is_binary { None } else { match &merge_base { Some(base) => { match status { GitFileStatus::New | GitFileStatus::Untracked => { // For new and untracked files that don't exist in the merge base, // provide empty baseline content. The diff hunks will show // all content as additions, which is the correct representation. // Non-file entries (e.g. nested repo/worktree directories) get // no baseline so no editor is constructed for them. repo_path.join(file_path).is_file().then(String::new) } GitFileStatus::Renamed { old_path } => { // The original content is in the old path of the given base commit. Self::get_file_content_at_commit(repo_path, old_path, base).await } _ => Self::get_file_content_at_commit(repo_path, file_path, base).await, } } None => Self::get_file_content_at_head(repo_path, file_path, status).await, } }; file_diff.is_autogenerated = is_file_autogenerated(file_path, content_at_head.as_deref()); Ok(Some(FileDiffAndContent { file_diff, content_at_head, })) } async fn file_statuses_against_base( repo_path: &Path, merge_base: &str, ) -> Result> { log::debug!( "[GIT OPERATION] local.rs file_statuses_against_base git diff --name-status -z {merge_base}" ); let diff_output = run_git_command(repo_path, &["diff", "--name-status", "-z", merge_base]).await?; let mut changed_files = if diff_output.trim().is_empty() { // No tracked changes, but we might have untracked files Vec::new() } else { Self::parse_git_diff_name_status(&diff_output)? }; // Also get untracked files, as they should be included in branch comparisons log::debug!( "[GIT OPERATION] local.rs file_statuses_against_base git status --untracked-files=all --porcelain=2 -z" ); let status_output = run_git_command( repo_path, &["status", "--untracked-files=all", "--porcelain=2", "-z"], ) .await?; let status_files = Self::parse_git_status(&status_output)?; // Add untracked files to the changed files list for (file_path, status) in status_files { if matches!(status, GitFileStatus::Untracked) { changed_files.push((file_path, status)); } } Ok(changed_files) } /// Diff against a specific branch (similar to main branch but with custom branch name) async fn diff_state_against_specific_branch( repo_path: &Path, branch: String, should_fetch_base: bool, ) -> Result { // Get the merge base between HEAD and the specified branch. let merge_base_result = if should_fetch_base { Self::get_or_fetch_merge_base(repo_path, &branch).await } else { Self::get_merge_base(repo_path, &branch).await }; let merge_base = match merge_base_result { Ok(merge_base) => merge_base, Err(err) => { log::warn!("Could not determine merge base against branch {branch}: {err:?}"); return Ok(GitDiffWithBaseContent { files_changed: 0, files: Vec::new(), total_additions: 0, total_deletions: 0, }); } }; let changed_files = Self::file_statuses_against_base(repo_path, &merge_base).await?; // If we have no changes at all (tracked or untracked), return empty result if changed_files.is_empty() { return Ok(GitDiffWithBaseContent { files_changed: 0, files: Vec::new(), total_additions: 0, total_deletions: 0, }); } // Get binary file information using git diff --numstat against merge base let binary_files = Self::get_binary_files_vs_commit(repo_path, &merge_base).await?; // Get the diff for each file let mut files = Vec::new(); let mut total_additions = 0; let mut total_deletions = 0; for (file_path, status) in changed_files { let is_binary = binary_files.contains(&file_path); let file_diff = Self::file_diff_for_path( is_binary, repo_path, &file_path, &status, Some(&merge_base), ) .await?; if let Some(file_diff) = file_diff { total_additions += file_diff.file_diff.additions(); total_deletions += file_diff.file_diff.deletions(); files.push(file_diff); } } Ok(GitDiffWithBaseContent { files_changed: files.len(), files, total_additions, total_deletions, }) } /// Diff against a specific branch (similar to main branch but with custom branch name) async fn diff_metadata_against_specific_branch( repo_path: &Path, branch: &str, ) -> Result { // Get the merge base between HEAD and the main branch let Ok(merge_base) = Self::get_merge_base(repo_path, branch).await else { // If we can't get the merge base, return empty metadata log::warn!("Could not determine merge base against branch {branch}",); return Ok(DiffMetadataAgainstBase::default()); }; // Get the diff between working directory and merge base with full patch output log::debug!( "[GIT OPERATION] local.rs diff_metadata_against_specific_branch git diff --name-status -z {merge_base}" ); let diff_output = run_git_command(repo_path, &["diff", "--name-status", "-z", &merge_base]).await?; let mut changed_files = if diff_output.trim().is_empty() { // No tracked changes, but we might have untracked files Vec::new() } else { Self::parse_git_diff_name_status(&diff_output)? }; // Also get untracked files, as they should be included in branch comparisons log::debug!( "[GIT OPERATION] local.rs diff_metadata_against_specific_branch git status --untracked-files=all --porcelain=2 -z" ); let status_output = run_git_command( repo_path, &["status", "--untracked-files=all", "--porcelain=2", "-z"], ) .await?; let status_files = Self::parse_git_status(&status_output)?; // Add untracked files to the changed files list for (file_path, status) in status_files { if matches!(status, GitFileStatus::Untracked) { changed_files.push((file_path, status)); } } // If we have no changes at all (tracked or untracked), return empty result if changed_files.is_empty() { return Ok(DiffMetadataAgainstBase::default()); } let num_stat_metadata = Self::get_diff_metadata_using_numstat(repo_path, &merge_base).await?; let mut total_additions = 0; let mut total_deletions = 0; let mut files = Vec::with_capacity(changed_files.len()); for (file_path, status) in &changed_files { let (additions, deletions) = if let Some(metadata) = num_stat_metadata.get(file_path) { (metadata.lines_added, metadata.lines_removed) } else if matches!(status, GitFileStatus::Untracked) { // Degrade per entry: an uncountable untracked path (e.g. a nested // repo/worktree directory, or an unreadable file) contributes 0 // lines instead of failing the whole metadata computation. let num_lines = Self::num_lines_in_file_if_non_binary(&repo_path.join(file_path)) .await .unwrap_or_else(|err| { log::debug!("Could not count lines for untracked entry {file_path}: {err}"); None }); (num_lines.unwrap_or(0), 0) } else { (0, 0) }; total_additions += additions; total_deletions += deletions; files.push(FileChangeEntry { path: file_path.clone(), additions, deletions, }); } Ok(DiffMetadataAgainstBase { aggregate_stats: DiffStats { files_changed: changed_files.len(), total_additions, total_deletions, }, files, }) } /// Parses git status output to get changed files and their status /// This handles porcelain=2 format to match git desktop implementation fn parse_git_status(status_output: &str) -> Result> { if status_output.is_empty() { return Ok(Vec::new()); } let mut files = Vec::new(); let tokens: Vec<&str> = status_output.split('\0').collect(); let mut i = 0; while i < tokens.len() { let token = tokens[i]; if token.is_empty() { i += 1; continue; } // Skip header lines that start with '#' if token.starts_with("# ") { i += 1; continue; } let entry_kind = token.chars().next().unwrap_or('?'); match entry_kind { '1' => { // Changed entry: 1 // Use splitn to preserve spaces in filenames (the path is the last field). let parts: Vec<&str> = token.splitn(9, ' ').collect(); if parts.len() >= 9 { let status_code = parts[1]; let path = parts[8]; let status = Self::map_status_code(status_code).map_err(|e| { anyhow!( "Invalid status code '{}' for path '{}': {}", status_code, path, e ) })?; files.push((path.to_string(), status)); } else { log::warn!( "Invalid format for changed entry: '{token}' - expected at least 9 parts, got {}", parts.len() ); } } '2' => { // Renamed/copied entry: 2 // Use splitn to preserve spaces in filenames (the path is the last field). let parts: Vec<&str> = token.splitn(10, ' ').collect(); if parts.len() >= 10 { let status_code = parts[1]; let path = parts[9]; // Get the old path from the next token let old_path = if i + 1 < tokens.len() { tokens[i + 1].to_string() } else { log::warn!("Missing old path for renamed/copied entry: '{token}'"); String::new() }; let status = if status_code.starts_with('R') { GitFileStatus::Renamed { old_path } } else if status_code.starts_with('C') { GitFileStatus::Copied { old_path } } else { Self::map_status_code(status_code).map_err(|e| { anyhow!( "Invalid status code '{}' for renamed/copied path '{}': {}", status_code, path, e ) })? }; files.push((path.to_string(), status)); i += 1; // Skip the old path token } else { log::warn!( "Invalid format for renamed/copied entry: '{token}' - expected at least 10 parts, got {}", parts.len() ); } } 'u' => { // Unmerged entry: u

// Use splitn to preserve spaces in filenames (the path is the last field). let parts: Vec<&str> = token.splitn(11, ' ').collect(); if parts.len() >= 11 { let path = parts[10]; files.push((path.to_string(), GitFileStatus::Conflicted)); } else { log::warn!( "Invalid format for unmerged entry: '{}' - expected at least 11 parts, got {}", token, parts.len() ); } } '?' => { // Untracked entry: ? if token.len() > 2 { let path = &token[2..]; // Skip "? " files.push((path.to_string(), GitFileStatus::Untracked)); } else { log::warn!( "Invalid format for untracked entry: '{token}' - expected path after '? '" ); } } '!' => { // Ignored entry: ! // We skip ignored files } _ => { // Unknown entry type, skip log::debug!("Unknown git status entry type '{entry_kind}' in token: '{token}'"); } } i += 1; } Ok(files) } /// Get binary files using git diff --numstat async fn get_binary_files(repo_path: &Path) -> Result> { Self::get_binary_files_vs_commit(repo_path, "HEAD").await } /// Gets the file content at HEAD commit for diff comparison async fn get_file_content_at_head( repo_path: &Path, file_path: &str, status: &GitFileStatus, ) -> Option { match status { GitFileStatus::Untracked | GitFileStatus::New => { // For new and untracked files, provide empty baseline content // since they don't exist in HEAD. The diff hunks will show // all content as additions, which is the correct representation. // Non-file entries (e.g. nested repo/worktree directories) get // no baseline so no editor is constructed for them. repo_path.join(file_path).is_file().then(String::new) } GitFileStatus::Renamed { old_path } => { // The file only exists under its new path in the working tree; at HEAD it still // lives at the old path. Mirror the merge-base path (see `file_diff_for_path`) // and read the baseline content from `old_path`. log::debug!( "[GIT OPERATION] local.rs get_file_content_at_head git show HEAD:{old_path}" ); run_git_command(repo_path, &["show", &format!("HEAD:{old_path}")]) .await .ok() } _ => { log::debug!( "[GIT OPERATION] local.rs get_file_content_at_head git show HEAD:{file_path}" ); run_git_command(repo_path, &["show", &format!("HEAD:{file_path}")]) .await .ok() } } } /// Gets the diff for a specific file /// This matches Git Desktop's getWorkingDirectoryDiff implementation /// If commit is provided, diffs against that commit; otherwise handles different statuses appropriately async fn get_file_diff( repo_path: &Path, file_path: &str, status: &GitFileStatus, is_binary: bool, commit: Option<&str>, ) -> Result { let mut hunks = Vec::new(); let mut max_line_number = 0; // If it's a binary file, don't fetch or parse the diff content if is_binary { return Ok(FileDiff { file_path: file_path.to_owned(), status: status.clone(), hunks: Arc::new(Vec::new()), is_binary: true, is_autogenerated: false, max_line_number: 0, has_hidden_bidi_chars: false, size: DiffSize::Normal, }); } // `git status` can report a non-file untracked entry (e.g. a nested // repo/worktree directory checked out inside this repo). There is no // content to diff — `git diff --no-index` against a directory fails — // so return an empty non-binary diff up front. if matches!(status, GitFileStatus::Untracked) && repo_path.join(file_path).is_dir() { return Ok(FileDiff { file_path: file_path.to_owned(), status: status.clone(), hunks: Arc::new(Vec::new()), is_binary: false, is_autogenerated: false, max_line_number: 0, has_hidden_bidi_chars: false, size: DiffSize::Normal, }); } // Get the actual diff content for text files only // Use the same diff arguments as Git Desktop or compare against specific commit let diff_args = if let Some(commit) = commit { // When commit is specified, handle special cases that need different treatment match status { GitFileStatus::Untracked => { // Untracked files don't exist in commit history, compare against empty vec![ "diff", "--no-ext-diff", "--patch-with-raw", "-z", "--no-color", "--no-index", "--", "/dev/null", file_path, ] } // For renamed files, we compare the old file path to the new file path. GitFileStatus::Renamed { old_path } => { vec![ "diff", "--no-ext-diff", "--patch-with-raw", "-z", "--no-color", commit, "--", old_path, file_path, ] } _ => { // For all other statuses, diff against the specified commit vec![ "diff", "--no-ext-diff", "--patch-with-raw", "-z", "--no-color", commit, "--", file_path, ] } } } else { // Handle different statuses when no specific commit is provided match status { GitFileStatus::New | GitFileStatus::Untracked => { // For new/untracked files - compare against empty file vec![ "diff", "--no-ext-diff", "--patch-with-raw", "-z", "--no-color", "--no-index", "--", "/dev/null", file_path, ] } GitFileStatus::Renamed { old_path } => { // Compare the working tree directly against HEAD, passing both the // old and new paths (mirroring the merge-base branch above). This // covers every staged/unstaged combination of the rename and any // content edit: comparing against the index alone (as before) produced // an empty diff whenever the rename and edit were both staged, since // the working tree then matched the index exactly. vec![ "diff", "--no-ext-diff", "--patch-with-raw", "-z", "--no-color", "HEAD", "--", old_path, file_path, ] } _ => { // For existing files - compare against HEAD vec![ "diff", "--no-ext-diff", "--patch-with-raw", "-z", "--no-color", "HEAD", "--", file_path, ] } } }; log::debug!( "[GIT OPERATION] local.rs get_file_diff git {}", diff_args.join(" ") ); let diff_output = match run_git_command(repo_path, &diff_args).await { Ok(output) => output, Err(error) => { log::info!( "Failed to get file diff for {file_path}{}: {error}", commit.map(|c| format!(" vs {c}")).unwrap_or_default() ); // If diff fails, treat as binary or empty return Ok(FileDiff { file_path: file_path.to_owned(), status: status.clone(), hunks: Arc::new(hunks), is_binary: true, is_autogenerated: false, max_line_number: 0, has_hidden_bidi_chars: false, size: DiffSize::Normal, }); } }; // Skip parsing oversized diffs; compute_diff_size would mark them Unrenderable anyway. if diff_output.len() > MAX_DIFF_SIZE { return Ok(FileDiff { file_path: file_path.to_owned(), status: status.clone(), hunks: Arc::new(Vec::new()), is_binary: false, is_autogenerated: false, max_line_number: 0, has_hidden_bidi_chars: false, size: DiffSize::Unrenderable(UnrenderableReason::DiffTooLarge), }); } // Double-check for binary files using the old method as fallback // Git outputs "Binary files a/file and b/file differ" for binary files if diff_output .lines() .any(|line| line.starts_with("Binary files ") && line.contains(" differ")) { return Ok(FileDiff { file_path: file_path.to_owned(), status: status.clone(), hunks: Arc::new(Vec::new()), is_binary: true, is_autogenerated: false, max_line_number: 0, has_hidden_bidi_chars: false, size: DiffSize::Normal, }); } // Parse the diff output for text files let parsed_hunks = Self::parse_diff_hunks(&diff_output)?; for hunk in &parsed_hunks { for line in &hunk.lines { if let Some(line_num) = line.old_line_number { max_line_number = max_line_number.max(line_num); } if let Some(line_num) = line.new_line_number { max_line_number = max_line_number.max(line_num); } } } hunks = parsed_hunks; // Check for hidden bidi characters let has_hidden_bidi_chars = Self::check_for_hidden_bidi_chars(&diff_output); let size = compute_diff_size(&hunks, diff_output.len()); Ok(FileDiff { file_path: file_path.to_owned(), status: status.clone(), hunks: Arc::new(hunks), is_binary, is_autogenerated: false, max_line_number, has_hidden_bidi_chars, size, }) } /// Parses diff hunks from git diff output pub(crate) fn parse_diff_hunks(diff_output: &str) -> Result> { let mut hunks = Vec::new(); let lines: Vec<&str> = diff_output.lines().collect(); let mut i = 0; while i < lines.len() { let line = lines[i]; // Look for hunk headers (@@) if line.starts_with("@@") { // Parse hunk header: @@ -old_start,old_count +new_start,new_count @@ let header = parse_unified_diff_header(line)?; let old_start = header.old_start_line; let old_count = header.old_line_count; let new_start = header.new_start_line; let new_count = header.new_line_count; // Collect hunk lines let mut hunk_lines = Vec::new(); i += 1; let mut old_line = old_start; let mut new_line = new_start; while i < lines.len() && !lines[i].starts_with("@@") && !lines[i].starts_with("diff ") { let content_line = lines[i]; if content_line.is_empty() { i += 1; continue; } let (line_type, old_num, new_num) = if content_line.starts_with('+') { let num = new_line; new_line += 1; (DiffLineType::Add, None, Some(num)) } else if content_line.starts_with('-') { let num = old_line; old_line += 1; (DiffLineType::Delete, Some(num), None) } else if content_line.starts_with(' ') { let old_num = old_line; let new_num = new_line; old_line += 1; new_line += 1; (DiffLineType::Context, Some(old_num), Some(new_num)) } else { // Skip lines that don't start with +, -, or space i += 1; continue; }; let text = if content_line.len() > 1 { content_line[1..].to_string() } else { String::new() }; // Check for no trailing newline let no_trailing_newline = content_line.ends_with("\\No newline at end of file"); hunk_lines.push(DiffLine { line_type, old_line_number: old_num, new_line_number: new_num, text, no_trailing_newline, }); i += 1; } hunks.push(DiffHunk { old_start_line: old_start, old_line_count: old_count, new_start_line: new_start, new_line_count: new_count, lines: hunk_lines, unified_diff_start: 0, // Will be calculated later unified_diff_end: 0, // Will be calculated later }); continue; } i += 1; } Ok(hunks) } /// Check for hidden bidirectional characters in diff output fn check_for_hidden_bidi_chars(diff_output: &str) -> bool { diff_output.chars().any(|c| BIDI_CHARS.contains(&c)) } /// Parses git diff --name-status output with -z flag (null-separated) /// Format: statusfilenamestatusfilename... /// For renamed/copied files: statusold_pathnew_path fn parse_git_diff_name_status(diff_output: &str) -> Result> { if diff_output.is_empty() { return Ok(Vec::new()); } let mut files = Vec::new(); let tokens: Vec<&str> = diff_output.split('\0').collect(); let mut i = 0; while i < tokens.len() { let token = tokens[i].trim(); if token.is_empty() { i += 1; continue; } // First token should be the status (M, A, D, R, C, etc.) let status_char = token.chars().next().unwrap_or('M'); // Next token should be the file path if i + 1 >= tokens.len() { // No filename following the status, skip i += 1; continue; } let mut file_path = tokens[i + 1].trim(); if file_path.is_empty() { i += 2; continue; } let status = match status_char { 'A' => GitFileStatus::New, 'M' => GitFileStatus::Modified, 'D' => GitFileStatus::Deleted, 'R' => { // For renamed files: R old_path new_path // We need to get the old path from the next token if i + 2 < tokens.len() { let old_path = file_path; file_path = tokens[i + 2].trim(); i += 1; // Skip the new path token (we'll increment again at the end) GitFileStatus::Renamed { old_path: old_path.to_string(), } } else { GitFileStatus::Modified } } 'C' => { // For copied files: C new_path old_path // We need to get the old path from the next token if i + 2 < tokens.len() { let old_path = tokens[i + 2].trim().to_string(); i += 1; // Skip the old path token (we'll increment again at the end) GitFileStatus::Copied { old_path } } else { GitFileStatus::Modified } } _ => GitFileStatus::Modified, }; files.push((file_path.to_string(), status)); // Move to next status token (skip status + filename, or status + old_path + new_path for R/C) i += 2; } Ok(files) } /// Get binary files using git diff --numstat against a specific commit async fn get_diff_metadata_using_numstat( repo_path: &Path, commit: &str, ) -> Result> { log::debug!( "[GIT OPERATION] local.rs get_diff_metadata_using_numstat git diff --numstat {commit}" ); let numstat_output = match run_git_command(repo_path, &["diff", "--numstat", commit]).await { Ok(output) => output, Err(_) => { // If numstat fails, return empty set return Ok(HashMap::new()); } }; let mut diff_metadata = std::collections::HashMap::new(); for line in numstat_output.lines() { if line.is_empty() { continue; } let parts: Vec<&str> = line.split('\t').collect(); if parts.len() >= 3 { let additions = parts[0]; let deletions = parts[1]; let filename = parts[2]; let metadata = GitNumStatMetadata { lines_added: additions.parse().unwrap_or(0), lines_removed: deletions.parse().unwrap_or(0), is_binary_file: additions == "-" && deletions == "-", }; diff_metadata.insert(filename.to_string(), metadata); } } Ok(diff_metadata) } /// Get binary files using git diff --numstat against a specific commit async fn get_binary_files_vs_commit( repo_path: &Path, commit: &str, ) -> Result> { let diff_metadata = Self::get_diff_metadata_using_numstat(repo_path, commit).await?; let binary_files = diff_metadata .into_iter() .filter_map(|(path, metadata)| metadata.is_binary_file.then_some(path)) .collect(); Ok(binary_files) } /// Gets the file content at a specific commit async fn get_file_content_at_commit( repo_path: &Path, file_path: &str, commit: &str, ) -> Option { log::debug!( "[GIT OPERATION] local.rs get_file_content_at_commit git show {commit}:{file_path}" ); run_git_command(repo_path, &["show", &format!("{commit}:{file_path}")]) .await .ok() } /// Maps git status codes to GitFileStatus fn map_status_code(status_code: &str) -> Result { GitFileStatus::try_from(status_code) } } /// Compute diff stats (file count, additions, deletions) for uncommitted changes /// against HEAD. This is a pure git operation with no model state. pub(crate) async fn diff_metadata_against_head( repo_path: &Path, ) -> Result { // First, get the list of changed files with their status let status_output = run_git_command( repo_path, &[ "--no-optional-locks", // Avoid taking locks that might interfere with other git operations "status", "--untracked-files=all", // Get all untracked files "--branch", // Get branch information "--porcelain=2", // Use porcelain=2 to match git desktop implementation "-z", // Split output by null characters ], ) .await?; let changed_files = LocalDiffStateModel::parse_git_status(&status_output)?; let num_stat_metadata = LocalDiffStateModel::get_diff_metadata_using_numstat(repo_path, "HEAD").await?; let mut total_additions = 0; let mut total_deletions = 0; let mut files = Vec::with_capacity(changed_files.len()); for (file_path, status) in &changed_files { let (additions, deletions) = if let Some(metadata) = num_stat_metadata.get(file_path) { (metadata.lines_added, metadata.lines_removed) } else if matches!(status, GitFileStatus::Untracked) { // Degrade per entry: an uncountable untracked path (e.g. a nested // repo/worktree directory, or an unreadable file) contributes 0 // lines instead of failing the whole metadata computation. let num_lines = LocalDiffStateModel::num_lines_in_file_if_non_binary(&repo_path.join(file_path)) .await .unwrap_or_else(|err| { log::debug!("Could not count lines for untracked entry {file_path}: {err}"); None }); (num_lines.unwrap_or(0), 0) } else { (0, 0) }; total_additions += additions; total_deletions += deletions; files.push(FileChangeEntry { path: file_path.clone(), additions, deletions, }); } Ok(DiffMetadataAgainstBase { aggregate_stats: DiffStats { files_changed: changed_files.len(), total_additions, total_deletions, }, files, }) } impl warpui::Entity for LocalDiffStateModel { type Event = DiffStateModelEvent; } #[cfg(feature = "local_fs")] enum DiffStateRepositoryUpdate { /// Normal file-system update to process. Invalidation(RepositoryUpdate), /// A commit-level update was detected while `.git/index.lock` was held. /// The receiver should mark a full invalidation pending without triggering /// a diff reload (the data would be stale). InvalidationWithLockedIndex, } #[cfg(feature = "local_fs")] struct LocalDiffStateModelRepositorySubscriber { repository_update_tx: Sender, } #[cfg(feature = "local_fs")] impl RepositorySubscriber for LocalDiffStateModelRepositorySubscriber { fn on_scan( &mut self, _repository: &Repository, _ctx: &mut ModelContext, ) -> std::pin::Pin + Send + 'static>> { // The model can safely ignore the initial scan because the model only subscribes // after the repository is already scanned. Box::pin(async {}) } fn on_files_updated( &mut self, repository: &Repository, update: &repo_metadata::RepositoryUpdate, _ctx: &mut ModelContext, ) -> std::pin::Pin + Send + 'static>> { let tx = self.repository_update_tx.clone(); let update = update.clone(); let index_lock_path = repository.git_dir().join("index.lock"); Box::pin(async move { // If commit state changed while the git index is locked (e.g. during // a pull or merge), signal the locked state instead of forwarding stale // data. The lock release atomically renames index.lock → index, which // triggers a fresh commit_updated event that takes the normal path. let msg = if update.commit_updated && async_fs::metadata(&index_lock_path).await.is_ok() { DiffStateRepositoryUpdate::InvalidationWithLockedIndex } else { DiffStateRepositoryUpdate::Invalidation(update) }; let _ = tx.send(msg).await; }) } } #[cfg(test)] impl LocalDiffStateModel { /// Test-only constructor that creates a bare model without a repository. pub fn new_for_test(ctx: &mut ModelContext) -> Self { Self { #[cfg(feature = "local_fs")] repository: None, state: InternalDiffState::default(), #[cfg(feature = "local_fs")] subscriber_id: None, backend_origin: BackendOrigin::ClientLocal, mode: DiffMode::default(), metadata: None, computing_diffs_abort_handle: None, computing_metadata_abort_handle: None, tracked_diff_load_start_time: None, metadata_refresh_enabled: false, #[cfg(feature = "local_fs")] file_invalidation: FileInvalidationState::new(SyncQueue::new_streaming( &ctx.background_executor(), )), #[cfg(feature = "local_fs")] pending_file_updates: None, } } } #[cfg(test)] #[path = "local_tests.rs"] mod tests;