Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion apps/staged/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ lint:

# Run Rust unit tests
test:
cd src-tauri && cargo test
cd src-tauri && cargo test --lib --bins --tests

# Run frontend unit tests
test-frontend:
Expand Down
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
Loading