diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 201f3a05079..a748f095ece 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -111,6 +111,14 @@ 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() @@ -271,7 +279,7 @@ fn parse_worktree_files( latest_commit, }) }) - .take(250) + .take(MAX_TREE_ENTRIES) .collect() } @@ -339,650 +347,6 @@ fn parse_ls_tree( latest_commit: latest_commit_by_path.get(path).cloned(), }) }) - .take(250) + .take(MAX_TREE_ENTRIES) .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}"))? -}