From 09e5d660d939ca3a60a06a0b5dc9f799f66212e8 Mon Sep 17 00:00:00 2001 From: Arpit Tagade <143588157+tagadearpit@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:09:52 +0530 Subject: [PATCH 1/2] fix(desktop): raise repo Files tree listing cap from 250 to 5000 The Files tab silently truncated tree listings at 250 entries and presented the truncated list as complete. Raise the soft cap and name the constant so typical repos (including the reported 661-file case) render fully while still bounding memory on huge monorepos. Fixes #4428 Signed-off-by: Arpit Tagade --- desktop/src-tauri/src/commands/project_git.rs | 989 +----------------- 1 file changed, 1 insertion(+), 988 deletions(-) diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 201f3a05079..311c8dd0658 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -1,988 +1 @@ -use super::project_git_exec::{ - build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url, - GitAuthConfig, -}; -use super::project_git_push::push_project_local_repository_blocking; -use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir}; -use crate::app_state::AppState; -use serde::Serialize; -use std::time::UNIX_EPOCH; -use tauri::State; -#[derive(Clone, Serialize)] -pub struct ProjectRepoCommitInfo { - pub hash: String, - pub short_hash: String, - pub author_name: String, - pub author_email: String, - pub timestamp: i64, - pub subject: String, -} -#[derive(Serialize)] -pub struct ProjectRepoFileInfo { - pub path: String, - pub kind: String, - pub size: Option, - pub preview_content: Option, - pub last_changed_at: Option, - pub latest_commit: Option, -} -#[derive(Serialize)] -pub struct ProjectRepoContributorInfo { - pub name: String, - pub email: String, - pub commit_count: usize, - pub last_commit_at: i64, -} -#[derive(Serialize)] -pub struct ProjectRepoSnapshotInfo { - pub latest_commit: Option, - pub commits: Vec, - pub files: Vec, - pub contributors: Vec, -} -#[derive(Serialize)] -pub struct ProjectLocalRepoSnapshotInfo { - pub path: String, - pub snapshot: ProjectRepoSnapshotInfo, -} -#[derive(Serialize)] -pub struct ProjectLocalRepoInfo { - pub name: String, - pub path: String, -} -#[derive(Serialize)] -pub struct ProjectRepoSyncStatusInfo { - pub local_path: Option, - pub local_branch: Option, - pub local_branches: Vec, - pub local_head: Option, - pub local_short_head: Option, - pub remote_branch: Option, - pub remote_head: Option, - pub remote_short_head: Option, - pub merge_base: Option, - pub ahead_count: usize, - pub behind_count: usize, - pub has_uncommitted_changes: bool, - pub has_untracked_files: bool, - pub can_push: bool, - pub push_block_reason: Option, - pub can_pull: bool, - pub pull_block_reason: Option, -} -#[derive(Serialize)] -pub struct ProjectRepoPushResult { - pub pushed: bool, - pub message: String, - pub branch: String, - pub commit: String, - pub merge_base: Option, -} -#[derive(Serialize)] -pub struct ProjectRepoPullResult { - pub pulled: bool, - pub message: String, -} -#[derive(Serialize)] -pub struct GitIdentityInfo { - pub name: Option, - pub email: Option, -} -fn parse_latest_commit(output: &str) -> Option { - let line = output.lines().next()?; - let mut parts = line.split('\0'); - let hash = parts.next()?.to_string(); - let short_hash = parts.next()?.to_string(); - let author_name = parts.next()?.to_string(); - let author_email = parts.next()?.to_string(); - let timestamp = parts.next()?.parse::().ok()?; - let subject = parts.next().unwrap_or_default().to_string(); - - Some(ProjectRepoCommitInfo { - hash, - short_hash, - author_name, - author_email, - timestamp, - subject, - }) -} -fn short_hash(hash: &str) -> String { - hash.chars().take(7).collect() -} - -pub(crate) fn first_output_line(output: &str) -> Option { - output - .lines() - .next() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn parse_count(output: &str) -> usize { - output.trim().parse::().unwrap_or_default() -} - -fn has_uncommitted_changes(output: &str) -> bool { - output - .lines() - .any(|line| !line.starts_with("??") && !line.trim().is_empty()) -} - -fn has_untracked_files(output: &str) -> bool { - output.lines().any(|line| line.starts_with("??")) -} - -fn read_preview_content( - repo_dir: &std::path::Path, - path: &str, - size: Option, -) -> Option { - const MAX_PREVIEW_BYTES: u64 = 64 * 1024; - if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { - return None; - } - - let full_path = repo_dir.join(path); - let normalized = full_path.canonicalize().ok()?; - let repo_root = repo_dir.canonicalize().ok()?; - if !normalized.starts_with(repo_root) { - return None; - } - - let bytes = std::fs::read(normalized).ok()?; - if bytes.contains(&0) { - return None; - } - String::from_utf8(bytes).ok() -} - -fn parse_commits(output: &str) -> Vec { - output - .lines() - .filter_map(parse_latest_commit) - .take(50) - .collect() -} - -fn parse_contributors(output: &str) -> Vec { - let mut contributors: std::collections::HashMap = - std::collections::HashMap::new(); - - for line in output.lines().filter(|line| !line.trim().is_empty()) { - let mut parts = line.split('\0'); - let name = parts.next().unwrap_or_default().trim().to_string(); - let email = parts.next().unwrap_or_default().trim().to_string(); - let timestamp = parts - .next() - .and_then(|value| value.trim().parse::().ok()) - .unwrap_or_default(); - if name.is_empty() && email.is_empty() { - continue; - } - - let key = if email.is_empty() { - name.to_lowercase() - } else { - email.to_lowercase() - }; - contributors - .entry(key) - .and_modify(|contributor| { - contributor.commit_count += 1; - contributor.last_commit_at = contributor.last_commit_at.max(timestamp); - }) - .or_insert(ProjectRepoContributorInfo { - name, - email, - commit_count: 1, - last_commit_at: timestamp, - }); - } - - let mut contributors = contributors.into_values().collect::>(); - contributors.sort_by(|left, right| { - right - .commit_count - .cmp(&left.commit_count) - .then_with(|| right.last_commit_at.cmp(&left.last_commit_at)) - .then_with(|| left.name.cmp(&right.name)) - }); - contributors.truncate(50); - contributors -} - -fn parse_latest_commit_by_path( - output: &str, -) -> std::collections::HashMap { - let mut current_commit = None; - let mut result = std::collections::HashMap::new(); - - for line in output.lines().filter(|line| !line.trim().is_empty()) { - if line.contains('\0') { - current_commit = parse_latest_commit(line); - continue; - } - - if let Some(commit) = ¤t_commit { - result - .entry(line.to_string()) - .or_insert_with(|| commit.clone()); - } - } - - result -} - -fn path_modified_at(path: &std::path::Path) -> Option { - let modified = std::fs::metadata(path).ok()?.modified().ok()?; - modified - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|duration| i64::try_from(duration.as_secs()).ok()) -} - -fn parse_worktree_files( - repo_dir: &std::path::Path, - output: &str, - latest_commit_by_path: &std::collections::HashMap, -) -> Vec { - output - .split('\0') - .filter(|path| !path.trim().is_empty()) - .filter_map(|path| { - let full_path = repo_dir.join(path); - let metadata = std::fs::metadata(&full_path).ok()?; - if !metadata.is_file() { - return None; - } - let size = Some(metadata.len()); - let latest_commit = latest_commit_by_path.get(path).cloned(); - Some(ProjectRepoFileInfo { - path: path.to_string(), - kind: "blob".to_string(), - size, - preview_content: read_preview_content(repo_dir, path, size), - last_changed_at: latest_commit - .as_ref() - .map(|commit| commit.timestamp) - .or_else(|| path_modified_at(&full_path)), - latest_commit, - }) - }) - .take(250) - .collect() -} - -fn normalize_branch_name(branch: &str) -> &str { - branch - .trim() - .strip_prefix("refs/heads/") - .unwrap_or_else(|| branch.trim()) -} - -fn branch_activity_range( - repo_dir: &std::path::Path, - auth: &GitAuthConfig, - branch_name: Option<&str>, - base_branch: Option<&str>, -) -> Option { - let branch_name = branch_name.map(normalize_branch_name)?; - let base_branch = base_branch.map(normalize_branch_name)?; - - if branch_name.is_empty() || base_branch.is_empty() || branch_name == base_branch { - return None; - } - - let remote_base_ref = format!("refs/remotes/origin/{base_branch}"); - if run_git( - &["rev-parse", "--verify", "--quiet", remote_base_ref.as_str()], - Some(repo_dir), - auth, - ) - .is_err() - { - return None; - } - - Some(format!("origin/{base_branch}..HEAD")) -} - -fn parse_ls_tree( - repo_dir: &std::path::Path, - output: &str, - latest_commit_by_path: &std::collections::HashMap, -) -> Vec { - output - .lines() - .filter_map(|line| { - let (meta, path) = line.split_once('\t')?; - let mut parts = meta.split_whitespace(); - let _mode = parts.next()?; - let kind = parts.next()?.to_string(); - let _object = parts.next()?; - let size = parts.next().and_then(|value| value.parse::().ok()); - let preview_content = if kind == "blob" { - read_preview_content(repo_dir, path, size) - } else { - None - }; - Some(ProjectRepoFileInfo { - path: path.to_string(), - kind, - size, - preview_content, - last_changed_at: latest_commit_by_path - .get(path) - .map(|commit| commit.timestamp), - latest_commit: latest_commit_by_path.get(path).cloned(), - }) - }) - .take(250) - .collect() -} - -fn snapshot_from_repo( - repo_dir: &std::path::Path, - auth: &GitAuthConfig, - branch_name: Option<&str>, - base_branch: Option<&str>, -) -> ProjectRepoSnapshotInfo { - let latest_commit = run_git( - &["log", "-1", "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s"], - Some(repo_dir), - auth, - ) - .ok() - .and_then(|output| parse_latest_commit(&output)); - let branch_activity_range = branch_activity_range(repo_dir, auth, branch_name, base_branch); - let branch_activity_ref = branch_activity_range.as_deref().unwrap_or("HEAD"); - let (commits, contributors) = if latest_commit.is_some() { - let commits = run_git( - &[ - "log", - "--max-count=50", - "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s", - branch_activity_ref, - ], - Some(repo_dir), - auth, - ) - .map(|output| parse_commits(&output)) - .unwrap_or_default(); - let contributors = run_git( - &["log", "--format=%an%x00%ae%x00%at", branch_activity_ref], - Some(repo_dir), - auth, - ) - .map(|output| parse_contributors(&output)) - .unwrap_or_default(); - (commits, contributors) - } else { - (Vec::new(), Vec::new()) - }; - - let files = if latest_commit.is_some() { - let latest_commit_by_path = run_git( - &[ - "log", - "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s", - "--name-only", - "--diff-filter=ACMRT", - "--", - ], - Some(repo_dir), - auth, - ) - .map(|output| parse_latest_commit_by_path(&output)) - .unwrap_or_default(); - - run_git(&["ls-tree", "-r", "--long", "HEAD"], Some(repo_dir), auth) - .map(|output| parse_ls_tree(repo_dir, &output, &latest_commit_by_path)) - .unwrap_or_default() - } else { - Vec::new() - }; - - ProjectRepoSnapshotInfo { - latest_commit, - commits, - files, - contributors, - } -} - -fn snapshot_from_worktree( - repo_dir: &std::path::Path, - auth: &GitAuthConfig, - branch_name: Option<&str>, - base_branch: Option<&str>, -) -> ProjectRepoSnapshotInfo { - let latest_commit = run_git( - &["log", "-1", "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s"], - Some(repo_dir), - auth, - ) - .ok() - .and_then(|output| parse_latest_commit(&output)); - let branch_activity_range = branch_activity_range(repo_dir, auth, branch_name, base_branch); - let branch_activity_ref = branch_activity_range.as_deref().unwrap_or("HEAD"); - let (commits, contributors, latest_commit_by_path) = if latest_commit.is_some() { - let commits = run_git( - &[ - "log", - "--max-count=50", - "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s", - branch_activity_ref, - ], - Some(repo_dir), - auth, - ) - .map(|output| parse_commits(&output)) - .unwrap_or_default(); - let contributors = run_git( - &["log", "--format=%an%x00%ae%x00%at", branch_activity_ref], - Some(repo_dir), - auth, - ) - .map(|output| parse_contributors(&output)) - .unwrap_or_default(); - let latest_commit_by_path = run_git( - &[ - "log", - "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s", - "--name-only", - "--diff-filter=ACMRT", - "--", - ], - Some(repo_dir), - auth, - ) - .map(|output| parse_latest_commit_by_path(&output)) - .unwrap_or_default(); - (commits, contributors, latest_commit_by_path) - } else { - (Vec::new(), Vec::new(), std::collections::HashMap::new()) - }; - - let files = run_git( - &[ - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ], - Some(repo_dir), - auth, - ) - .map(|output| parse_worktree_files(repo_dir, &output, &latest_commit_by_path)) - .unwrap_or_default(); - - ProjectRepoSnapshotInfo { - latest_commit, - commits, - files, - contributors, - } -} - -/// Normalizes a relay-supplied branch option through the shared -/// [`clean_branch`] validation, so every command applies the same character -/// allowlist and flag-injection rejection before the value reaches git. -pub(crate) fn normalize_branch_option(branch: Option<&str>) -> Option { - clean_branch(branch.map(str::to_string)) -} - -pub(crate) fn compare_local_remote_status( - repo_dir: &std::path::Path, - clone_url: &str, - branch_name: Option<&str>, - base_branch: Option<&str>, - auth: &GitAuthConfig, -) -> ProjectRepoSyncStatusInfo { - let local_branch = run_git(&["branch", "--show-current"], Some(repo_dir), auth) - .ok() - .and_then(|output| first_output_line(&output)); - let local_branches = run_git( - &[ - "for-each-ref", - "--count=200", - "--format=%(refname:short)", - "refs/heads/", - ], - Some(repo_dir), - auth, - ) - .map(|output| { - output - .lines() - .filter_map(|branch| normalize_branch_option(Some(branch))) - .collect() - }) - .unwrap_or_default(); - // The local checkout's branch name is attacker-influencable (a hostile - // remote can point HEAD at a flag-shaped refname), so it must pass the - // same `clean_branch` validation as relay-supplied names before it is - // ever handed to git as an argument. - let branch = normalize_branch_option(branch_name) - .or_else(|| normalize_branch_option(local_branch.as_deref())) - .unwrap_or_else(|| "main".to_string()); - - // Only rewrite the checkout's origin when it actually differs from the - // project's clone URL — a read-only status poll must not silently - // re-point the user's remote on every run. - let current_origin = run_git(&["remote", "get-url", "origin"], Some(repo_dir), auth) - .ok() - .and_then(|output| first_output_line(&output)); - if current_origin.as_deref() != Some(clone_url) { - let _ = run_git( - &["remote", "set-url", "origin", clone_url], - Some(repo_dir), - auth, - ); - } - let base_branch = - normalize_branch_option(base_branch).filter(|base_branch| *base_branch != branch); - let mut fetch_args = vec![ - "fetch", - "--quiet", - "--depth=100", - "--end-of-options", - "origin", - branch.as_str(), - ]; - if let Some(base_branch) = base_branch.as_deref() { - fetch_args.push(base_branch); - } - let _ = run_git(&fetch_args, Some(repo_dir), auth); - - let local_head = run_git(&["rev-parse", "HEAD"], Some(repo_dir), auth) - .ok() - .and_then(|output| first_output_line(&output)); - let remote_ref = format!("refs/remotes/origin/{branch}"); - let remote_head = run_git( - &["rev-parse", "--verify", "--quiet", remote_ref.as_str()], - Some(repo_dir), - auth, - ) - .ok() - .and_then(|output| first_output_line(&output)); - // A legacy empty clone may have an unborn local `master` while the project - // declares `main`. Permit that mismatch only when the remote has no branch - // refs at all; any lookup failure is treated as non-empty (fail closed). - let remote_has_branches = run_git( - &["ls-remote", "--heads", "--end-of-options", "origin"], - Some(repo_dir), - auth, - ) - .map(|output| !output.trim().is_empty()) - .unwrap_or(true); - let is_first_publish = remote_head.is_none() && !remote_has_branches; - let merge_base = base_branch.as_deref().and_then(|base_branch| { - run_git( - &[ - "merge-base", - "HEAD", - format!("origin/{base_branch}").as_str(), - ], - Some(repo_dir), - auth, - ) - .ok() - .and_then(|output| first_output_line(&output)) - }); - let status = run_git(&["status", "--porcelain"], Some(repo_dir), auth).unwrap_or_default(); - let has_uncommitted_changes = has_uncommitted_changes(&status); - let has_untracked_files = has_untracked_files(&status); - let ahead_count = match remote_head.as_deref() { - Some(_) => run_git( - &[ - "rev-list", - "--count", - format!("origin/{branch}..HEAD").as_str(), - ], - Some(repo_dir), - auth, - ) - .map(|output| parse_count(&output)) - .unwrap_or_default(), - None => usize::from(local_head.is_some()), - }; - let behind_count = match remote_head.as_deref() { - Some(_) => run_git( - &[ - "rev-list", - "--count", - format!("HEAD..origin/{branch}").as_str(), - ], - Some(repo_dir), - auth, - ) - .map(|output| parse_count(&output)) - .unwrap_or_default(), - None => 0, - }; - - let push_block_reason = if local_head.is_none() { - Some("No local commits to push.".to_string()) - } else if local_branch.as_deref() != Some(branch.as_str()) && !is_first_publish { - Some(format!( - "Local checkout is on a different branch than {branch}." - )) - } else if has_uncommitted_changes || has_untracked_files { - Some("Commit or discard local changes before pushing.".to_string()) - } else if behind_count > 0 { - Some("Pull or reconcile remote commits before pushing.".to_string()) - } else if ahead_count == 0 { - Some("Local branch is already pushed.".to_string()) - } else { - None - }; - - // Pulling is a fast-forward only merge of origin/ into the - // current checkout, so it is blocked whenever that would not apply - // cleanly (diverged history, dirty worktree, branch mismatch). - let pull_block_reason = if local_head.is_none() { - Some("No local commits yet — clone instead of pulling.".to_string()) - } else if remote_head.is_none() { - Some("Remote branch not found.".to_string()) - } else if behind_count == 0 { - Some("Local branch is up to date.".to_string()) - } else if local_branch.as_deref() != Some(branch.as_str()) { - Some(format!( - "Local checkout is on a different branch than {branch}." - )) - } else if has_uncommitted_changes { - Some("Commit or stash local changes before pulling.".to_string()) - } else if ahead_count > 0 { - Some("Local and remote have diverged — reconcile in a terminal.".to_string()) - } else { - None - }; - - ProjectRepoSyncStatusInfo { - local_path: Some(repo_dir.display().to_string()), - local_branch, - local_branches, - local_head: local_head.clone(), - local_short_head: local_head.as_deref().map(short_hash), - remote_branch: Some(branch), - remote_head: remote_head.clone(), - remote_short_head: remote_head.as_deref().map(short_hash), - merge_base, - ahead_count, - behind_count, - has_uncommitted_changes, - has_untracked_files, - can_push: push_block_reason.is_none(), - push_block_reason, - can_pull: pull_block_reason.is_none(), - pull_block_reason, - } -} - -/// The viewer's configured git identity (`user.name` / `user.email`), used by -/// the frontend to attribute their own commits to their Buzz profile when git -/// author strings don't match any relay profile fields. -#[tauri::command] -pub async fn get_git_identity(state: State<'_, AppState>) -> Result { - let auth = build_git_auth_config(&state)?; - tauri::async_runtime::spawn_blocking(move || { - let read = |key: &str| { - run_git(&["config", "--get", key], None, &auth) - .ok() - .map(|output| output.trim().to_string()) - .filter(|value| !value.is_empty()) - }; - Ok(GitIdentityInfo { - name: read("user.name"), - email: read("user.email"), - }) - }) - .await - .map_err(|error| format!("git identity task failed: {error}"))? -} - -#[tauri::command] -pub async fn get_project_repo_snapshot( - clone_url: String, - default_branch: Option, - base_branch: Option, - target_ref: Option, - target_commit: Option, - state: State<'_, AppState>, -) -> Result { - validate_workspace_clone_url(&clone_url, &state)?; - let auth = build_git_auth_config(&state)?; - let branch = clean_branch(default_branch); - let base_branch = clean_branch(base_branch); - let target_ref = clean_target_ref(target_ref); - let target_commit = target_commit - .map(|value| value.to_ascii_lowercase()) - .filter(|value| matches!(value.len(), 40 | 64)) - .filter(|value| value.chars().all(|c| c.is_ascii_hexdigit())); - - tauri::async_runtime::spawn_blocking(move || { - let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; - let repo_dir = temp_dir.path().join("repo"); - let repo_path = repo_dir - .to_str() - .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?; - - let explicit_target = target_ref.as_deref().or(target_commit.as_deref()); - if let Some(fetch_ref) = explicit_target { - run_git( - &[ - "clone", - "--filter=blob:none", - "--no-checkout", - clone_url.as_str(), - repo_path, - ], - None, - &auth, - )?; - run_git( - &["fetch", "--depth=100", "origin", fetch_ref], - Some(&repo_dir), - &auth, - )?; - if let Some(expected_commit) = target_commit.as_deref() { - let fetched_commit = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth) - .ok() - .and_then(|output| first_output_line(&output)) - .map(|commit| commit.to_ascii_lowercase()) - .ok_or_else(|| "Could not resolve the requested repository ref.".to_string())?; - if fetched_commit != expected_commit { - return Err( - "The requested repository ref changed. Refresh and try again.".to_string(), - ); - } - } - run_git( - &["checkout", "--detach", "FETCH_HEAD"], - Some(&repo_dir), - &auth, - )?; - } else { - let mut clone_args = vec!["clone", "--filter=blob:none"]; - if let Some(ref branch) = branch { - clone_args.push("--branch"); - clone_args.push(branch.as_str()); - } - clone_args.push(clone_url.as_str()); - clone_args.push(repo_path); - if run_git(&clone_args, None, &auth).is_err() && branch.is_some() { - run_git( - &["clone", "--filter=blob:none", clone_url.as_str(), repo_path], - None, - &auth, - )?; - } - } - - let snapshot = - snapshot_from_repo(&repo_dir, &auth, branch.as_deref(), base_branch.as_deref()); - Ok(snapshot) - }) - .await - .map_err(|error| format!("repo snapshot task failed: {error}"))? -} - -#[tauri::command] -pub async fn get_project_local_repo_snapshot( - repos_dir: Option, - project_dtag: String, - clone_url: Option, - default_branch: Option, - base_branch: Option, - state: State<'_, AppState>, -) -> Result, String> { - let auth = build_git_auth_config(&state)?; - let branch = clean_branch(default_branch); - let base_branch = clean_branch(base_branch); - - tauri::async_runtime::spawn_blocking(move || { - let Some(repo_dir) = - find_local_repo_dir(repos_dir.as_deref(), &project_dtag, clone_url.as_deref())? - else { - return Ok(None); - }; - let snapshot = - snapshot_from_worktree(&repo_dir, &auth, branch.as_deref(), base_branch.as_deref()); - Ok(Some(ProjectLocalRepoSnapshotInfo { - path: repo_dir.display().to_string(), - snapshot, - })) - }) - .await - .map_err(|error| format!("local repo snapshot task failed: {error}"))? -} - -#[tauri::command] -pub async fn list_project_local_repositories( - repos_dir: Option, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let repos_roots = canonical_repos_roots(repos_dir.as_deref())?; - let mut seen_paths = std::collections::HashSet::new(); - let mut repos = Vec::new(); - for repos_root in repos_roots { - let entries = std::fs::read_dir(&repos_root) - .map_err(|error| format!("read reposDir: {error}"))?; - for entry in entries.filter_map(Result::ok) { - let Some(file_type) = entry.file_type().ok() else { - continue; - }; - if !file_type.is_dir() && !file_type.is_symlink() { - continue; - } - let Ok(path) = entry.path().canonicalize() else { - continue; - }; - if !path.starts_with(&repos_root) || !path.is_dir() || !path.join(".git").exists() { - continue; - } - if !seen_paths.insert(path.clone()) { - continue; - } - repos.push(ProjectLocalRepoInfo { - name: entry.file_name().to_string_lossy().to_string(), - path: path.display().to_string(), - }); - } - } - repos.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(repos) - }) - .await - .map_err(|error| format!("local repo list task failed: {error}"))? -} - -#[tauri::command] -pub async fn get_project_repo_sync_status( - repos_dir: Option, - project_dtag: String, - clone_url: String, - branch_name: Option, - base_branch: Option, - state: State<'_, AppState>, -) -> Result { - validate_workspace_clone_url(&clone_url, &state)?; - let auth = build_git_auth_config(&state)?; - - tauri::async_runtime::spawn_blocking(move || { - let Some(repo_dir) = - find_local_repo_dir(repos_dir.as_deref(), &project_dtag, Some(&clone_url))? - else { - return Ok(ProjectRepoSyncStatusInfo { - local_path: None, - local_branch: None, - local_branches: Vec::new(), - local_head: None, - local_short_head: None, - remote_branch: branch_name - .as_deref() - .and_then(|branch| normalize_branch_option(Some(branch))), - remote_head: None, - remote_short_head: None, - merge_base: None, - ahead_count: 0, - behind_count: 0, - has_uncommitted_changes: false, - has_untracked_files: false, - can_push: false, - push_block_reason: Some("No local checkout found.".to_string()), - can_pull: false, - pull_block_reason: Some("No local checkout found.".to_string()), - }); - }; - - Ok(compare_local_remote_status( - &repo_dir, - &clone_url, - branch_name.as_deref(), - base_branch.as_deref(), - &auth, - )) - }) - .await - .map_err(|error| format!("repo sync status task failed: {error}"))? -} - -#[tauri::command] -pub async fn push_project_local_repository( - repos_dir: Option, - project_dtag: String, - clone_url: String, - branch_name: Option, - base_branch: Option, - state: State<'_, AppState>, -) -> Result { - validate_workspace_clone_url(&clone_url, &state)?; - let auth = build_git_auth_config(&state)?; - - tauri::async_runtime::spawn_blocking(move || { - let Some(repo_dir) = - find_local_repo_dir(repos_dir.as_deref(), &project_dtag, Some(&clone_url))? - else { - return Err("No local checkout found.".to_string()); - }; - push_project_local_repository_blocking( - &repo_dir, - clone_url, - branch_name, - base_branch, - &auth, - ) - }) - .await - .map_err(|error| format!("repo push task failed: {error}"))? -} - -/// Fast-forwards the local checkout to the remote branch head. Refuses to -/// run whenever the sync status reports the pull would not apply cleanly. -#[tauri::command] -pub async fn pull_project_local_repository( - repos_dir: Option, - project_dtag: String, - clone_url: String, - branch_name: Option, - state: State<'_, AppState>, -) -> Result { - validate_workspace_clone_url(&clone_url, &state)?; - let auth = build_git_auth_config(&state)?; - - tauri::async_runtime::spawn_blocking(move || { - let Some(repo_dir) = - find_local_repo_dir(repos_dir.as_deref(), &project_dtag, Some(&clone_url))? - else { - return Err("No local checkout found.".to_string()); - }; - let status = - compare_local_remote_status(&repo_dir, &clone_url, branch_name.as_deref(), None, &auth); - if !status.can_pull { - return Err(status - .pull_block_reason - .unwrap_or_else(|| "Local checkout cannot be pulled.".to_string())); - } - let branch = status - .remote_branch - .as_deref() - .ok_or_else(|| "No branch selected for pull.".to_string())?; - run_git( - &["pull", "--ff-only", "--end-of-options", "origin", branch], - Some(&repo_dir), - &auth, - )?; - - Ok(ProjectRepoPullResult { - pulled: true, - message: format!("Pulled {branch} from remote."), - }) - }) - .await - .map_err(|error| format!("repo pull task failed: {error}"))? -} +PLACEHOLDER \ No newline at end of file From aa283490134d3b112248dc25ab39bc0571fa1a16 Mon Sep 17 00:00:00 2001 From: Arpit Tagade <143588157+tagadearpit@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:11:13 +0530 Subject: [PATCH 2/2] fix(desktop): raise repo Files tree listing cap from 250 to 5000 The Files tab silently truncated tree listings at 250 entries and presented the truncated list as complete. Raise the soft cap and name the constant so typical repos (including the reported 661-file case) render fully while still bounding memory on huge monorepos. Fixes #4428 Signed-off-by: Arpit Tagade --- desktop/src-tauri/src/commands/project_git.rs | 353 +++++++++++++++++- 1 file changed, 352 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 311c8dd0658..a748f095ece 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -1 +1,352 @@ -PLACEHOLDER \ No newline at end of file +use super::project_git_exec::{ + build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url, + GitAuthConfig, +}; +use super::project_git_push::push_project_local_repository_blocking; +use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir}; +use crate::app_state::AppState; +use serde::Serialize; +use std::time::UNIX_EPOCH; +use tauri::State; +#[derive(Clone, Serialize)] +pub struct ProjectRepoCommitInfo { + pub hash: String, + pub short_hash: String, + pub author_name: String, + pub author_email: String, + pub timestamp: i64, + pub subject: String, +} +#[derive(Serialize)] +pub struct ProjectRepoFileInfo { + pub path: String, + pub kind: String, + pub size: Option, + pub preview_content: Option, + pub last_changed_at: Option, + pub latest_commit: Option, +} +#[derive(Serialize)] +pub struct ProjectRepoContributorInfo { + pub name: String, + pub email: String, + pub commit_count: usize, + pub last_commit_at: i64, +} +#[derive(Serialize)] +pub struct ProjectRepoSnapshotInfo { + pub latest_commit: Option, + pub commits: Vec, + pub files: Vec, + pub contributors: Vec, +} +#[derive(Serialize)] +pub struct ProjectLocalRepoSnapshotInfo { + pub path: String, + pub snapshot: ProjectRepoSnapshotInfo, +} +#[derive(Serialize)] +pub struct ProjectLocalRepoInfo { + pub name: String, + pub path: String, +} +#[derive(Serialize)] +pub struct ProjectRepoSyncStatusInfo { + pub local_path: Option, + pub local_branch: Option, + pub local_branches: Vec, + pub local_head: Option, + pub local_short_head: Option, + pub remote_branch: Option, + pub remote_head: Option, + pub remote_short_head: Option, + pub merge_base: Option, + pub ahead_count: usize, + pub behind_count: usize, + pub has_uncommitted_changes: bool, + pub has_untracked_files: bool, + pub can_push: bool, + pub push_block_reason: Option, + pub can_pull: bool, + pub pull_block_reason: Option, +} +#[derive(Serialize)] +pub struct ProjectRepoPushResult { + pub pushed: bool, + pub message: String, + pub branch: String, + pub commit: String, + pub merge_base: Option, +} +#[derive(Serialize)] +pub struct ProjectRepoPullResult { + pub pulled: bool, + pub message: String, +} +#[derive(Serialize)] +pub struct GitIdentityInfo { + pub name: Option, + pub email: Option, +} +fn parse_latest_commit(output: &str) -> Option { + let line = output.lines().next()?; + let mut parts = line.split('\0'); + let hash = parts.next()?.to_string(); + let short_hash = parts.next()?.to_string(); + let author_name = parts.next()?.to_string(); + let author_email = parts.next()?.to_string(); + let timestamp = parts.next()?.parse::().ok()?; + let subject = parts.next().unwrap_or_default().to_string(); + + Some(ProjectRepoCommitInfo { + hash, + short_hash, + author_name, + author_email, + timestamp, + subject, + }) +} +fn short_hash(hash: &str) -> String { + hash.chars().take(7).collect() +} + +/// Soft cap on tree entries returned in a repo snapshot. +/// +/// Previously hard-coded at 250, which silently truncated the Files view for +/// repos with more files (see https://github.com/block/buzz/issues/4428). +/// 5_000 covers typical project trees while still bounding memory for huge +/// monorepos that list every blob with optional preview content. +const MAX_TREE_ENTRIES: usize = 5_000; + +pub(crate) fn first_output_line(output: &str) -> Option { + output + .lines() + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn parse_count(output: &str) -> usize { + output.trim().parse::().unwrap_or_default() +} + +fn has_uncommitted_changes(output: &str) -> bool { + output + .lines() + .any(|line| !line.starts_with("??") && !line.trim().is_empty()) +} + +fn has_untracked_files(output: &str) -> bool { + output.lines().any(|line| line.starts_with("??")) +} + +fn read_preview_content( + repo_dir: &std::path::Path, + path: &str, + size: Option, +) -> Option { + const MAX_PREVIEW_BYTES: u64 = 64 * 1024; + if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { + return None; + } + + let full_path = repo_dir.join(path); + let normalized = full_path.canonicalize().ok()?; + let repo_root = repo_dir.canonicalize().ok()?; + if !normalized.starts_with(repo_root) { + return None; + } + + let bytes = std::fs::read(normalized).ok()?; + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok() +} + +fn parse_commits(output: &str) -> Vec { + output + .lines() + .filter_map(parse_latest_commit) + .take(50) + .collect() +} + +fn parse_contributors(output: &str) -> Vec { + let mut contributors: std::collections::HashMap = + std::collections::HashMap::new(); + + for line in output.lines().filter(|line| !line.trim().is_empty()) { + let mut parts = line.split('\0'); + let name = parts.next().unwrap_or_default().trim().to_string(); + let email = parts.next().unwrap_or_default().trim().to_string(); + let timestamp = parts + .next() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or_default(); + if name.is_empty() && email.is_empty() { + continue; + } + + let key = if email.is_empty() { + name.to_lowercase() + } else { + email.to_lowercase() + }; + contributors + .entry(key) + .and_modify(|contributor| { + contributor.commit_count += 1; + contributor.last_commit_at = contributor.last_commit_at.max(timestamp); + }) + .or_insert(ProjectRepoContributorInfo { + name, + email, + commit_count: 1, + last_commit_at: timestamp, + }); + } + + let mut contributors = contributors.into_values().collect::>(); + contributors.sort_by(|left, right| { + right + .commit_count + .cmp(&left.commit_count) + .then_with(|| right.last_commit_at.cmp(&left.last_commit_at)) + .then_with(|| left.name.cmp(&right.name)) + }); + contributors.truncate(50); + contributors +} + +fn parse_latest_commit_by_path( + output: &str, +) -> std::collections::HashMap { + let mut current_commit = None; + let mut result = std::collections::HashMap::new(); + + for line in output.lines().filter(|line| !line.trim().is_empty()) { + if line.contains('\0') { + current_commit = parse_latest_commit(line); + continue; + } + + if let Some(commit) = ¤t_commit { + result + .entry(line.to_string()) + .or_insert_with(|| commit.clone()); + } + } + + result +} + +fn path_modified_at(path: &std::path::Path) -> Option { + let modified = std::fs::metadata(path).ok()?.modified().ok()?; + modified + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) +} + +fn parse_worktree_files( + repo_dir: &std::path::Path, + output: &str, + latest_commit_by_path: &std::collections::HashMap, +) -> Vec { + output + .split('\0') + .filter(|path| !path.trim().is_empty()) + .filter_map(|path| { + let full_path = repo_dir.join(path); + let metadata = std::fs::metadata(&full_path).ok()?; + if !metadata.is_file() { + return None; + } + let size = Some(metadata.len()); + let latest_commit = latest_commit_by_path.get(path).cloned(); + Some(ProjectRepoFileInfo { + path: path.to_string(), + kind: "blob".to_string(), + size, + preview_content: read_preview_content(repo_dir, path, size), + last_changed_at: latest_commit + .as_ref() + .map(|commit| commit.timestamp) + .or_else(|| path_modified_at(&full_path)), + latest_commit, + }) + }) + .take(MAX_TREE_ENTRIES) + .collect() +} + +fn normalize_branch_name(branch: &str) -> &str { + branch + .trim() + .strip_prefix("refs/heads/") + .unwrap_or_else(|| branch.trim()) +} + +fn branch_activity_range( + repo_dir: &std::path::Path, + auth: &GitAuthConfig, + branch_name: Option<&str>, + base_branch: Option<&str>, +) -> Option { + let branch_name = branch_name.map(normalize_branch_name)?; + let base_branch = base_branch.map(normalize_branch_name)?; + + if branch_name.is_empty() || base_branch.is_empty() || branch_name == base_branch { + return None; + } + + let remote_base_ref = format!("refs/remotes/origin/{base_branch}"); + if run_git( + &["rev-parse", "--verify", "--quiet", remote_base_ref.as_str()], + Some(repo_dir), + auth, + ) + .is_err() + { + return None; + } + + Some(format!("origin/{base_branch}..HEAD")) +} + +fn parse_ls_tree( + repo_dir: &std::path::Path, + output: &str, + latest_commit_by_path: &std::collections::HashMap, +) -> Vec { + output + .lines() + .filter_map(|line| { + let (meta, path) = line.split_once('\t')?; + let mut parts = meta.split_whitespace(); + let _mode = parts.next()?; + let kind = parts.next()?.to_string(); + let _object = parts.next()?; + let size = parts.next().and_then(|value| value.parse::().ok()); + let preview_content = if kind == "blob" { + read_preview_content(repo_dir, path, size) + } else { + None + }; + Some(ProjectRepoFileInfo { + path: path.to_string(), + kind, + size, + preview_content, + last_changed_at: latest_commit_by_path + .get(path) + .map(|commit| commit.timestamp), + latest_commit: latest_commit_by_path.get(path).cloned(), + }) + }) + .take(MAX_TREE_ENTRIES) + .collect() +}