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
113 changes: 96 additions & 17 deletions crates/git/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ pub struct StatusDiffEntry {
pub old_path: Option<String>,
}

/// One entry from `git diff --numstat`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumstatEntry {
pub additions: Option<usize>,
pub deletions: Option<usize>,
pub path: String,
}

/// Parsed worktree entry from `git worktree list --porcelain`
#[derive(Debug, Clone)]
pub struct WorktreeEntry {
Expand Down Expand Up @@ -171,7 +179,52 @@ impl GitCli {
base_commit: &Commit,
opts: StatusDiffOptions,
) -> Result<Vec<StatusDiffEntry>, GitCliError> {
// Create a temp index file
let (_tmp_dir, envs) = self.create_staged_temp_index(worktree_path)?;
// git diff --cached
let mut args: Vec<OsString> = vec![
"-c".into(),
"core.quotepath=false".into(),
"diff".into(),
"--cached".into(),
"-M".into(),
"--name-status".into(),
OsString::from(base_commit.to_string()),
];
args = Self::apply_pathspec_filter(args, opts.path_filter.as_ref());
let out = self.git_with_env(worktree_path, args, &envs)?;
Ok(Self::parse_name_status(&out))
}

/// Diff line/file stats vs a base commit using a temporary index.
///
/// This follows `diff_status` staging semantics so committed, uncommitted,
/// and untracked changes are counted without materializing file contents.
pub fn diff_numstat(
&self,
worktree_path: &Path,
base_commit: &Commit,
opts: StatusDiffOptions,
) -> Result<Vec<NumstatEntry>, GitCliError> {
let (_tmp_dir, envs) = self.create_staged_temp_index(worktree_path)?;

let mut args: Vec<OsString> = vec![
"-c".into(),
"core.quotepath=false".into(),
"diff".into(),
"--cached".into(),
"-M".into(),
"--numstat".into(),
OsString::from(base_commit.to_string()),
];
args = Self::apply_pathspec_filter(args, opts.path_filter.as_ref());
let out = self.git_with_env(worktree_path, args, &envs)?;
Ok(Self::parse_numstat(&out))
}

fn create_staged_temp_index(
&self,
worktree_path: &Path,
) -> Result<(tempfile::TempDir, Vec<(OsString, OsString)>), GitCliError> {
let tmp_dir = tempfile::TempDir::new()
.map_err(|e| GitCliError::CommandFailed(format!("temp dir create failed: {e}")))?;
let tmp_index = tmp_dir.path().join("index");
Expand All @@ -180,11 +233,8 @@ impl GitCli {
tmp_index.as_os_str().to_os_string(),
)];

// Use a temp index from HEAD to accurately track renames in untracked files
let _ = self.git_with_env(worktree_path, ["read-tree", "HEAD"], &envs)?;

// Stage changed and untracked files explicitly, which is faster than `git add -A` for large repos.
// Use raw paths from `get_worktree_status` to avoid lossy UTF-8 conversions for odd filenames.
let status = self.get_worktree_status(worktree_path)?;
let mut paths_to_add: Vec<Vec<u8>> = Vec::new();
for entry in status.entries {
Expand Down Expand Up @@ -212,19 +262,7 @@ impl GitCli {
];
self.git_with_stdin(worktree_path, args, Some(&envs), &input)?;
}
// git diff --cached
let mut args: Vec<OsString> = vec![
"-c".into(),
"core.quotepath=false".into(),
"diff".into(),
"--cached".into(),
"-M".into(),
"--name-status".into(),
OsString::from(base_commit.to_string()),
];
args = Self::apply_pathspec_filter(args, opts.path_filter.as_ref());
let out = self.git_with_env(worktree_path, args, &envs)?;
Ok(Self::parse_name_status(&out))
Ok((tmp_dir, envs))
}

/// Return `git status --porcelain` parsed into a structured summary
Expand Down Expand Up @@ -509,6 +547,29 @@ impl GitCli {
out
}

fn parse_numstat(output: &str) -> Vec<NumstatEntry> {
let mut out = Vec::new();
for line in output.lines() {
let line = line.trim_end();
if line.is_empty() {
continue;
}
let mut parts = line.splitn(3, '\t');
let additions = parts.next().and_then(|v| v.parse::<usize>().ok());
let deletions = parts.next().and_then(|v| v.parse::<usize>().ok());
if let Some(path) = parts.next()
&& !path.is_empty()
{
out.push(NumstatEntry {
additions,
deletions,
path: path.to_string(),
});
}
}
out
}

/// Return the merge base commit sha of two refs in the given worktree.
/// If `git merge-base --fork-point` fails, falls back to regular `merge-base`.
pub fn merge_base(
Expand Down Expand Up @@ -943,3 +1004,21 @@ pub struct WorktreeStatus {
pub untracked: usize,
pub entries: Vec<StatusEntry>,
}

#[cfg(test)]
mod tests {
use super::GitCli;

#[test]
fn parse_numstat_counts_text_and_binary_files() {
let entries = GitCli::parse_numstat("12\t3\tsrc/main.rs\n-\t-\tassets/logo.png\n");

assert_eq!(entries.len(), 2);
assert_eq!(entries[0].additions, Some(12));
assert_eq!(entries[0].deletions, Some(3));
assert_eq!(entries[0].path, "src/main.rs");
assert_eq!(entries[1].additions, None);
assert_eq!(entries[1].deletions, None);
assert_eq!(entries[1].path, "assets/logo.png");
}
}
31 changes: 31 additions & 0 deletions crates/git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ pub struct FileStat {
pub last_time: DateTime<Utc>,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DiffStats {
pub files_changed: usize,
pub lines_added: usize,
pub lines_removed: usize,
}

#[derive(Debug, Error)]
pub enum GitServiceError {
#[error(transparent)]
Expand Down Expand Up @@ -371,6 +378,30 @@ impl GitService {
Ok(entries.into_iter().map(|e| e.path).collect())
}

/// Returns diff counts without loading file contents.
pub fn get_diff_stats(
&self,
worktree_path: &Path,
base_commit: &Commit,
) -> Result<DiffStats, GitServiceError> {
let git = GitCli::new();
let entries = git
.diff_numstat(
worktree_path,
base_commit,
cli::StatusDiffOptions { path_filter: None },
)
.map_err(|e| GitServiceError::InvalidRepository(format!("git diff failed: {e}")))?;

let mut stats = DiffStats::default();
for entry in entries {
stats.files_changed += 1;
stats.lines_added += entry.additions.unwrap_or(0);
stats.lines_removed += entry.deletions.unwrap_or(0);
}
Ok(stats)
}

/// Extract file path from a Diff (for indexing and ConversationPatch)
pub fn diff_path(diff: &Diff) -> String {
diff.new_path
Expand Down
4 changes: 3 additions & 1 deletion crates/local-deployment/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,9 @@ impl Deployment for LocalDeployment {
None => None,
};
let pr_sync_notify = Arc::new(Notify::new());
{
if std::env::var("VK_DISABLE_PR_MONITOR").is_ok() {
tracing::info!("PR monitoring service disabled by VK_DISABLE_PR_MONITOR");
} else {
let db = db.clone();
let analytics = analytics.as_ref().map(|s| AnalyticsContext {
user_id: user_id.clone(),
Expand Down
7 changes: 5 additions & 2 deletions crates/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,11 @@ async fn main() -> Result<(), VibeKanbanError> {

let app_router = routes::router(deployment.clone());

// Production only: open browser
if !cfg!(debug_assertions) {
// Production only: open browser unless disabled for diagnostics.
let disable_auto_open_browser = std::env::var("VK_DISABLE_AUTO_OPEN_BROWSER")
.ok()
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"));
if !cfg!(debug_assertions) && !disable_auto_open_browser {
tracing::info!("Opening browser...");
let browser_port = actual_main_port;
tokio::spawn(async move {
Expand Down
45 changes: 42 additions & 3 deletions crates/server/src/routes/frontend.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use std::borrow::Cow;

use axum::{
body::Body,
body::{Body, Bytes},
http::HeaderValue,
response::{IntoResponse, Response},
};
Expand All @@ -20,28 +22,46 @@ pub(super) async fn serve_frontend_root() -> impl IntoResponse {
}

async fn serve_file(path: &str) -> impl IntoResponse + use<> {
if trace_frontend_assets() {
tracing::info!(path, "Frontend asset request");
}

if path.ends_with(".map") && !serve_frontend_source_maps() {
if trace_frontend_assets() {
tracing::info!(path, "Frontend source map request blocked");
}

return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("404 Not Found"))
.unwrap();
}

let file = Assets::get(path);

match file {
Some(content) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
let body = embedded_asset_body(content.data);

Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref()).unwrap(),
)
.body(Body::from(content.data.into_owned()))
.body(body)
.unwrap()
}
None => {
// For SPA routing, serve index.html for unknown routes
if let Some(index) = Assets::get("index.html") {
let body = embedded_asset_body(index.data);

Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, HeaderValue::from_static("text/html"))
.body(Body::from(index.data.into_owned()))
.body(body)
.unwrap()
} else {
Response::builder()
Expand All @@ -52,3 +72,22 @@ async fn serve_file(path: &str) -> impl IntoResponse + use<> {
}
}
}

fn embedded_asset_body(data: Cow<'static, [u8]>) -> Body {
match data {
Cow::Borrowed(bytes) => Body::from(Bytes::from_static(bytes)),
Cow::Owned(bytes) => Body::from(Bytes::from(bytes)),
}
}

fn serve_frontend_source_maps() -> bool {
std::env::var("VK_SERVE_FRONTEND_SOURCE_MAPS")
.ok()
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
}

fn trace_frontend_assets() -> bool {
std::env::var("VK_TRACE_FRONTEND_ASSETS")
.ok()
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
}
2 changes: 1 addition & 1 deletion crates/server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService<Router> {
.merge(relay_auth::router())
.merge(host_relay::router(&deployment))
.merge(relay_signed_routes)
.layer(CompressionLayer::new())
.layer(ValidateRequestHeaderLayer::custom(
middleware::validate_origin,
))
Expand All @@ -81,6 +82,5 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService<Router> {
.route("/", get(frontend::serve_frontend_root))
.route("/{*path}", get(frontend::serve_frontend))
.nest("/api", api_routes)
.layer(CompressionLayer::new())
.into_make_service()
}
40 changes: 21 additions & 19 deletions crates/server/src/routes/workspaces/workspace_summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use db::models::{
workspace::Workspace,
};
use deployment::Deployment;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use utils::response::ApiResponse;
Expand Down Expand Up @@ -66,6 +67,8 @@ pub struct DiffStats {
pub lines_removed: usize,
}

const DIFF_STATS_CONCURRENCY: usize = 4;

/// Fetch summary information for workspaces filtered by archived status.
/// This endpoint returns data that cannot be efficiently included in the streaming endpoint.
#[axum::debug_handler]
Expand Down Expand Up @@ -112,26 +115,25 @@ pub async fn get_workspace_summaries(
// 6. Get PR status for each workspace
let pr_statuses = PullRequest::get_latest_for_workspaces(pool, archived).await?;

// 7. Compute diff stats for each workspace (in parallel)
let diff_futures: Vec<_> = workspaces
.iter()
.map(|ws| {
let workspace = ws.clone();
let deployment = deployment.clone();
async move {
if workspace.container_ref.is_some() {
compute_workspace_diff_stats(&deployment, &workspace)
.await
.map(|stats| (workspace.id, stats))
} else {
None
}
}
})
.collect();

// 7. Compute diff stats with bounded concurrency. This endpoint is polled,
// so avoid launching Git work for every workspace at once.
let diff_results: Vec<Option<(Uuid, DiffStats)>> =
futures_util::future::join_all(diff_futures).await;
futures_util::stream::iter(workspaces.iter().cloned())
.map(|workspace| {
let deployment = deployment.clone();
async move {
if workspace.container_ref.is_some() {
compute_workspace_diff_stats(&deployment, &workspace)
.await
.map(|stats| (workspace.id, stats))
} else {
None
}
}
})
.buffer_unordered(DIFF_STATS_CONCURRENCY)
.collect()
.await;
let diff_stats: HashMap<Uuid, DiffStats> = diff_results.into_iter().flatten().collect();

// 8. Assemble response
Expand Down
Loading