Skip to content
Closed
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
469 changes: 413 additions & 56 deletions crates/buzz-cli/src/commands/agents.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export default defineConfig({
"**/inbox-edit.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-commit-detail.spec.ts",
"**/project-file-tree.spec.ts",
"**/project-inbox.spec.ts",
"**/project-issue-comments.spec.ts",
"**/project-pr-review.spec.ts",
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ mod project_git;
mod project_git_branches;
mod project_git_diff;
mod project_git_exec;
mod project_git_files;
mod project_git_merge_error;
mod project_git_push;
mod project_git_workflow;
Expand Down
114 changes: 10 additions & 104 deletions desktop/src-tauri/src/commands/project_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ 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_files::{parse_ls_tree, parse_worktree_files, ParsedProjectRepoFiles};
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 {
Expand Down Expand Up @@ -38,6 +38,8 @@ pub struct ProjectRepoSnapshotInfo {
pub latest_commit: Option<ProjectRepoCommitInfo>,
pub commits: Vec<ProjectRepoCommitInfo>,
pub files: Vec<ProjectRepoFileInfo>,
/// Complete file count before the snapshot payload is capped.
pub total_file_count: usize,
pub contributors: Vec<ProjectRepoContributorInfo>,
}
#[derive(Serialize)]
Expand Down Expand Up @@ -134,30 +136,6 @@ 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<u64>,
) -> Option<String> {
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<ProjectRepoCommitInfo> {
output
.lines()
Expand Down Expand Up @@ -235,46 +213,6 @@ fn parse_latest_commit_by_path(
result
}

fn path_modified_at(path: &std::path::Path) -> Option<i64> {
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<String, ProjectRepoCommitInfo>,
) -> Vec<ProjectRepoFileInfo> {
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()
Expand Down Expand Up @@ -309,40 +247,6 @@ fn branch_activity_range(
Some(format!("origin/{base_branch}..HEAD"))
}

fn parse_ls_tree(
repo_dir: &std::path::Path,
output: &str,
latest_commit_by_path: &std::collections::HashMap<String, ProjectRepoCommitInfo>,
) -> Vec<ProjectRepoFileInfo> {
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::<u64>().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,
Expand Down Expand Up @@ -383,7 +287,7 @@ fn snapshot_from_repo(
(Vec::new(), Vec::new())
};

let files = if latest_commit.is_some() {
let parsed_files = if latest_commit.is_some() {
let latest_commit_by_path = run_git(
&[
"log",
Expand All @@ -402,13 +306,14 @@ fn snapshot_from_repo(
.map(|output| parse_ls_tree(repo_dir, &output, &latest_commit_by_path))
.unwrap_or_default()
} else {
Vec::new()
ParsedProjectRepoFiles::default()
};

ProjectRepoSnapshotInfo {
latest_commit,
commits,
files,
files: parsed_files.files,
total_file_count: parsed_files.total_file_count,
contributors,
}
}
Expand Down Expand Up @@ -466,7 +371,7 @@ fn snapshot_from_worktree(
(Vec::new(), Vec::new(), std::collections::HashMap::new())
};

let files = run_git(
let parsed_files = run_git(
&[
"ls-files",
"--cached",
Expand All @@ -483,7 +388,8 @@ fn snapshot_from_worktree(
ProjectRepoSnapshotInfo {
latest_commit,
commits,
files,
files: parsed_files.files,
total_file_count: parsed_files.total_file_count,
contributors,
}
}
Expand Down
152 changes: 152 additions & 0 deletions desktop/src-tauri/src/commands/project_git_files.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
use super::project_git::{ProjectRepoCommitInfo, ProjectRepoFileInfo};
use std::{collections::HashMap, path::Path, time::UNIX_EPOCH};

const MAX_REPOSITORY_FILE_PAYLOAD: usize = 250;

#[derive(Default)]
pub(super) struct ParsedProjectRepoFiles {
pub(super) files: Vec<ProjectRepoFileInfo>,
pub(super) total_file_count: usize,
}

fn read_preview_content(repo_dir: &Path, path: &str, size: Option<u64>) -> Option<String> {
const MAX_PREVIEW_BYTES: u64 = 64 * 1024;
if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) {
return None;
}

let normalized = repo_dir.join(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 path_modified_at(path: &Path) -> Option<i64> {
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())
}

pub(super) fn parse_worktree_files(
repo_dir: &Path,
output: &str,
latest_commit_by_path: &HashMap<String, ProjectRepoCommitInfo>,
) -> ParsedProjectRepoFiles {
let mut parsed = ParsedProjectRepoFiles::default();

for path in output.split('\0').filter(|path| !path.trim().is_empty()) {
let full_path = repo_dir.join(path);
let Ok(metadata) = std::fs::metadata(&full_path) else {
continue;
};
if !metadata.is_file() {
continue;
}

parsed.total_file_count += 1;
if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD {
continue;
}

let size = Some(metadata.len());
let latest_commit = latest_commit_by_path.get(path).cloned();
parsed.files.push(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,
});
}

parsed
}

pub(super) fn parse_ls_tree(
repo_dir: &Path,
output: &str,
latest_commit_by_path: &HashMap<String, ProjectRepoCommitInfo>,
) -> ParsedProjectRepoFiles {
let mut parsed = ParsedProjectRepoFiles::default();

for line in output.lines() {
let Some((meta, path)) = line.split_once('\t') else {
continue;
};
let mut parts = meta.split_whitespace();
let (Some(_mode), Some(kind), Some(_object)) = (parts.next(), parts.next(), parts.next())
else {
continue;
};
let size = parts.next().and_then(|value| value.parse::<u64>().ok());

parsed.total_file_count += 1;
if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD {
continue;
}

let preview_content = (kind == "blob")
.then(|| read_preview_content(repo_dir, path, size))
.flatten();
parsed.files.push(ProjectRepoFileInfo {
path: path.to_string(),
kind: kind.to_string(),
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(),
});
}

parsed
}

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

#[test]
fn ls_tree_reports_the_full_count_when_the_payload_is_capped() {
let output = (0..251)
.map(|index| format!("100644 blob {} 1\tfile-{index:03}.txt", "0".repeat(40)))
.collect::<Vec<_>>()
.join("\n");

let parsed = parse_ls_tree(Path::new("/missing-repository"), &output, &HashMap::new());

assert_eq!(parsed.files.len(), 250);
assert_eq!(parsed.total_file_count, 251);
}

#[test]
fn worktree_reports_the_full_count_when_the_payload_is_capped() {
let repo = tempfile::tempdir().expect("create temporary repository");
let mut paths = Vec::new();
for index in 0..251 {
let path = format!("file-{index:03}.txt");
std::fs::write(repo.path().join(&path), "x").expect("write fixture file");
paths.push(path);
}
let output = format!("{}\0", paths.join("\0"));

let parsed = parse_worktree_files(repo.path(), &output, &HashMap::new());

assert_eq!(parsed.files.len(), 250);
assert_eq!(parsed.total_file_count, 251);
}
}
17 changes: 10 additions & 7 deletions desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,16 @@ with a TypeScript lookup table or an id comparison in a component.
Once the Advanced toggle is visible, its expanded state is exclusively
user-controlled: provider, harness, and required-env changes must never
open it automatically in defaults, create, or edit flows. In Create mode,
the defaults summary follows preferred-harness changes saved while the
dialog is open, and its configured state includes required credentials as
well as provider/model values. If no available harness can resolve, Create
starts in Customize and lets unavailable catalog entries be selected only
to expose their setup guidance; submission remains blocked.
Advanced-only required credentials mark the collapsed Advanced toggle
without opening it in Global Defaults and Edit, and block incomplete saves.
`Run on` belongs in Advanced directly after **Who can send instructions**;
keep it out of the basic create fields. The defaults summary follows
preferred-harness changes saved while the dialog is open, and its configured
state includes required credentials as well as provider/model values. If no
available harness can resolve, Create starts in Customize and lets unavailable
catalog entries be selected only to expose their setup guidance; submission
remains blocked.
Advanced-only required credentials and incomplete remote **Run on** setup
mark the collapsed Advanced toggle without opening it, and block incomplete
saves.
Runtime-file credentials satisfy Global Defaults just as they do Create and
Edit. In Edit,
selecting Custom command keeps its required command field beside the harness
Expand Down
Loading
Loading