Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/staged/src-tauri/src/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,26 @@ pub(crate) fn run_workspace_git(
blox::ws_exec(workspace_name, &borrowed)
}

/// Execute a shell script inside a Blox workspace via `sh -c`.
///
/// Positional arguments are passed as `$1`, `$2`, etc. This allows batching
/// multiple git commands into a single `ws_exec` round-trip.
pub(crate) fn run_workspace_shell(
workspace_name: &str,
script: &str,
args: &[&str],
) -> Result<String, blox::BloxError> {
let mut owned = Vec::<String>::with_capacity(3 + args.len());
owned.push("sh".to_string());
owned.push("-c".to_string());
owned.push(script.to_string());
// $0 placeholder (conventional for sh -c)
owned.push("_".to_string());
owned.extend(args.iter().map(|arg| (*arg).to_string()));
let borrowed = owned.iter().map(String::as_str).collect::<Vec<_>>();
blox::ws_exec(workspace_name, &borrowed)
}

pub(crate) fn run_workspace_git_bytes(
workspace_name: &str,
repo_subpath: Option<&str>,
Expand Down
97 changes: 96 additions & 1 deletion apps/staged/src-tauri/src/diff_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ use crate::branches;
use crate::git;
use crate::store::Store;
use serde::Serialize;
use std::path::Path;
use std::path::{Component, Path};
use std::process::Command;
use std::sync::{Arc, Mutex};

const WORKTREE_TEXT_PREVIEW_MAX_BYTES: u64 = 1024 * 1024;

/// Context needed to compute diffs for a branch.
pub(crate) struct BranchDiffContext {
pub base_branch: String,
Expand Down Expand Up @@ -108,6 +111,10 @@ fn build_diff_spec(
};
Ok((spec, sha.to_string()))
}
"worktree" => {
let resolved_sha = git::get_head_sha(worktree).map_err(|e| e.to_string())?;
Ok((git::DiffSpec::uncommitted(), resolved_sha))
}
_ => {
let resolved_sha = match commit_sha {
Some(sha) => sha.to_string(),
Expand Down Expand Up @@ -188,6 +195,81 @@ pub(crate) fn file_content_from_bytes(bytes: &[u8], path: &str) -> git::FileCont
}
}

fn file_content_from_bytes_with_text_limit(bytes: &[u8], path: &str) -> git::FileContent {
let check_len = bytes.len().min(8192);
if bytes[..check_len].contains(&0) {
return file_content_binary_or_image(bytes, path);
}
if bytes.len() as u64 > WORKTREE_TEXT_PREVIEW_MAX_BYTES {
return git::FileContent::Binary;
}
let text = String::from_utf8_lossy(bytes);
git::FileContent::Text {
lines: text.lines().map(|line| line.to_string()).collect(),
}
}

fn validate_relative_diff_path(path: &str) -> Result<(), String> {
let path = Path::new(path);
if path.is_absolute() {
return Err("Diff path must be relative".to_string());
}
for component in path.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
_ => return Err("Diff path contains an invalid segment".to_string()),
}
}
Ok(())
}

fn file_exists_at_head(worktree: &Path, path: &str) -> Result<bool, String> {
let spec = format!("HEAD:{path}");
let mut command = Command::new("git");
command
.arg("-C")
.arg(worktree)
.args(["cat-file", "-e", &spec]);
git::strip_git_env(&mut command);
let output = command.output().map_err(|e| e.to_string())?;
Ok(output.status.success())
}

fn large_added_worktree_file_diff(
worktree: &Path,
path: &str,
) -> Result<Option<git::FileDiff>, String> {
validate_relative_diff_path(path)?;
if file_exists_at_head(worktree, path)? {
return Ok(None);
}

let full_path = worktree.join(path);
let Ok(metadata) = std::fs::metadata(&full_path) else {
return Ok(None);
};
if !metadata.is_file() || metadata.len() <= WORKTREE_TEXT_PREVIEW_MAX_BYTES {
return Ok(None);
}

let content = if metadata.len() <= git::IMAGE_PREVIEW_MAX_BYTES as u64 {
let bytes = std::fs::read(&full_path)
.map_err(|e| format!("Cannot read worktree file {path}: {e}"))?;
file_content_from_bytes_with_text_limit(&bytes, path)
} else {
git::FileContent::Binary
};

Ok(Some(git::FileDiff {
before: None,
after: Some(git::File {
path: path.to_string(),
content,
}),
alignments: Vec::new(),
}))
}

/// For binary content in the remote path, try to produce an ImageBase64 variant.
fn file_content_binary_or_image(bytes: &[u8], path: &str) -> git::FileContent {
if bytes.len() > git::IMAGE_PREVIEW_MAX_BYTES {
Expand Down Expand Up @@ -436,6 +518,10 @@ pub async fn get_diff_files(
});
}

if scope == "worktree" {
return Err("Worktree diff is only available for local branches".to_string());
}

// Remote branch — check cache, then collect on miss.
let latest_sha = store
.list_commits_for_branch(&branch_id)
Expand Down Expand Up @@ -515,6 +601,11 @@ pub async fn get_file_diff(
let worktree = Path::new(worktree_path);
let (spec, _) = build_diff_spec(worktree, &ctx.base_branch, Some(&commit_sha), &scope)?;
let file_path = Path::new(&path);
if scope == "worktree" {
if let Some(diff) = large_added_worktree_file_diff(worktree, &path)? {
return Ok(diff);
}
}
let result = git::get_file_diff(worktree, &spec, file_path).map_err(|e| e.to_string())?;
fn file_stats(f: &Option<git::File>) -> (usize, usize) {
match f {
Expand All @@ -538,6 +629,10 @@ pub async fn get_file_diff(
return Ok(result);
}

if scope == "worktree" {
return Err("Worktree diff is only available for local branches".to_string());
}

// Check cache for branch-scope diffs.
if scope == "branch" {
if let Some(file_diff) = crate::diff_cache::load_cached_file_diff(
Expand Down
16 changes: 13 additions & 3 deletions apps/staged/src-tauri/src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ mod env;
mod files;
pub mod github;
mod refs;
mod state;
#[cfg(test)]
mod state_tests;
mod status_parse;
mod types;
mod worktree;

Expand Down Expand Up @@ -32,13 +36,19 @@ pub use refs::{
get_repo_root, list_branches, list_refs, merge_base, origin_ref_for_branch, prune_remote,
resolve_ref, BranchRef,
};
pub use state::{
compute_branch_git_state, compute_branch_git_state_batched, compute_local_branch_git_state,
ensure_fast_forward_pullable, fast_forward_to_ref, BaseGitState, BranchGitState, FetchGitState,
FetchMode, FetchStatus, UpstreamGitState, UpstreamRelation, WorktreeGitState,
};
pub use types::*;
pub use worktree::{
branch_exists, create_worktree, create_worktree_at_path, create_worktree_for_existing_branch,
create_worktree_for_existing_branch_at_path, create_worktree_from_pr,
create_worktree_from_pr_at_path, fetch_pr_head_sha, get_commits_since_base,
get_full_commit_log, get_head_sha, get_parent_commit, has_unpushed_commits, list_worktrees,
create_worktree_from_pr_at_path, discard_worktree_changes, fetch_pr_head_sha,
get_commits_since_base, get_full_commit_log, get_head_sha, get_parent_commit,
has_unpushed_commits, list_worktree_change_paths, list_worktrees, parse_worktree_status_paths,
project_worktree_path_for, project_worktree_root_for, remote_branch_exists, remove_worktree,
reset_to_commit, set_upstream_to_origin, switch_branch, update_branch_from_pr,
worktree_path_for, CommitInfo, UpdateFromPrResult,
worktree_path_for, CommitInfo, UpdateFromPrResult, WorktreeChangePaths,
};
Loading