From 7e5c41b9a977680b1620884383e5e0d97d0bece5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=C3=A1=C5=A1=20Jan=C4=8Da=C5=99=C3=ADk?= Date: Sat, 17 Jan 2026 23:25:57 +0100 Subject: [PATCH 1/3] feat(git): add repo scan and root selection --- src-tauri/src/git.rs | 142 +++++--------- src-tauri/src/git_utils.rs | 188 +++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/types.rs | 2 + src-tauri/src/workspaces.rs | 1 + src/App.tsx | 82 +++++++- src/features/git/components/GitDiffPanel.tsx | 143 ++++++++++++++ src/features/git/hooks/useGitRepoScan.ts | 109 +++++++++++ src/features/layout/hooks/useLayoutNodes.tsx | 22 +++ src/services/tauri.ts | 7 + src/styles/diff.css | 92 +++++++++ src/types.ts | 1 + 12 files changed, 687 insertions(+), 104 deletions(-) create mode 100644 src-tauri/src/git_utils.rs create mode 100644 src/features/git/hooks/useGitRepoScan.ts diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 7877e05a0..b605119b2 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,101 +1,20 @@ -use git2::{BranchType, DiffOptions, Repository, Sort, Status, StatusOptions, Tree}; +use std::path::PathBuf; + +use git2::{BranchType, DiffOptions, Repository, Sort, Status, StatusOptions}; use serde_json::json; use tauri::State; use tokio::process::Command; +use crate::git_utils::{ + checkout_branch, commit_to_entry, diff_patch_to_string, diff_stats_for_path, + list_git_roots as scan_git_roots, parse_github_repo, resolve_git_root, +}; use crate::state::AppState; use crate::types::{ - BranchInfo, GitFileDiff, GitFileStatus, GitHubIssue, GitHubIssuesResponse, GitLogEntry, - GitLogResponse, + BranchInfo, GitFileDiff, GitFileStatus, GitHubIssue, GitHubIssuesResponse, GitLogResponse, }; use crate::utils::normalize_git_path; -fn commit_to_entry(commit: git2::Commit) -> GitLogEntry { - let summary = commit.summary().unwrap_or("").to_string(); - let author = commit.author().name().unwrap_or("").to_string(); - let timestamp = commit.time().seconds(); - GitLogEntry { - sha: commit.id().to_string(), - summary, - author, - timestamp, - } -} - -fn checkout_branch(repo: &Repository, name: &str) -> Result<(), git2::Error> { - let refname = format!("refs/heads/{name}"); - repo.set_head(&refname)?; - let mut options = git2::build::CheckoutBuilder::new(); - options.safe(); - repo.checkout_head(Some(&mut options))?; - Ok(()) -} - -fn diff_stats_for_path( - repo: &Repository, - head_tree: Option<&Tree>, - path: &str, - include_index: bool, - include_workdir: bool, -) -> Result<(i64, i64), git2::Error> { - let mut additions = 0i64; - let mut deletions = 0i64; - - if include_index { - let mut options = DiffOptions::new(); - options.pathspec(path).include_untracked(true); - let diff = repo.diff_tree_to_index(head_tree, None, Some(&mut options))?; - let stats = diff.stats()?; - additions += stats.insertions() as i64; - deletions += stats.deletions() as i64; - } - - if include_workdir { - let mut options = DiffOptions::new(); - options - .pathspec(path) - .include_untracked(true) - .recurse_untracked_dirs(true) - .show_untracked_content(true); - let diff = repo.diff_index_to_workdir(None, Some(&mut options))?; - let stats = diff.stats()?; - additions += stats.insertions() as i64; - deletions += stats.deletions() as i64; - } - - Ok((additions, deletions)) -} - -fn diff_patch_to_string(patch: &mut git2::Patch) -> Result { - let buf = patch.to_buf()?; - Ok(buf - .as_str() - .map(|value| value.to_string()) - .unwrap_or_else(|| String::from_utf8_lossy(&buf).to_string())) -} - -fn parse_github_repo(remote_url: &str) -> Option { - let trimmed = remote_url.trim(); - if trimmed.is_empty() { - return None; - } - let mut path = if trimmed.starts_with("git@github.com:") { - trimmed.trim_start_matches("git@github.com:").to_string() - } else if trimmed.starts_with("ssh://git@github.com/") { - trimmed.trim_start_matches("ssh://git@github.com/").to_string() - } else if let Some(index) = trimmed.find("github.com/") { - trimmed[index + "github.com/".len()..].to_string() - } else { - return None; - }; - path = path.trim_end_matches(".git").trim_end_matches('/').to_string(); - if path.is_empty() { - None - } else { - Some(path) - } -} - #[tauri::command] pub(crate) async fn get_git_status( workspace_id: String, @@ -107,7 +26,8 @@ pub(crate) async fn get_git_status( .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let branch_name = repo .head() @@ -194,6 +114,23 @@ pub(crate) async fn get_git_status( })) } +#[tauri::command] +pub(crate) async fn list_git_roots( + workspace_id: String, + depth: Option, + state: State<'_, AppState>, +) -> Result, String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let root = PathBuf::from(&entry.path); + let depth = depth.unwrap_or(2).clamp(1, 6); + Ok(scan_git_roots(&root, depth, 200)) +} + #[tauri::command] pub(crate) async fn get_git_diffs( workspace_id: String, @@ -205,7 +142,8 @@ pub(crate) async fn get_git_diffs( .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let head_tree = repo .head() .ok() @@ -270,7 +208,8 @@ pub(crate) async fn get_git_log( .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let max_items = limit.unwrap_or(40); let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; revwalk.push_head().map_err(|e| e.to_string())?; @@ -376,7 +315,8 @@ pub(crate) async fn get_git_remote( .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let remotes = repo.remotes().map_err(|e| e.to_string())?; let name = if remotes.iter().any(|remote| remote == Some("origin")) { "origin".to_string() @@ -406,8 +346,9 @@ pub(crate) async fn get_github_issues( .ok_or("workspace not found")? .clone(); + let repo_root = resolve_git_root(&entry)?; let repo_name = { - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let remotes = repo.remotes().map_err(|e| e.to_string())?; let name = if remotes.iter().any(|remote| remote == Some("origin")) { "origin".to_string() @@ -440,7 +381,7 @@ pub(crate) async fn get_github_issues( "--json", "number,title,url,updatedAt", ]) - .current_dir(&entry.path) + .current_dir(&repo_root) .output() .await .map_err(|e| format!("Failed to run gh: {e}"))?; @@ -471,7 +412,7 @@ pub(crate) async fn get_github_issues( "--jq", ".total_count", ]) - .current_dir(&entry.path) + .current_dir(&repo_root) .output() .await { @@ -495,7 +436,8 @@ pub(crate) async fn list_git_branches( .get(&workspace_id) .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let mut branches = Vec::new(); let refs = repo .branches(Some(BranchType::Local)) @@ -529,7 +471,8 @@ pub(crate) async fn checkout_git_branch( .get(&workspace_id) .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; checkout_branch(&repo, &name).map_err(|e| e.to_string()) } @@ -544,7 +487,8 @@ pub(crate) async fn create_git_branch( .get(&workspace_id) .ok_or("workspace not found")? .clone(); - let repo = Repository::open(&entry.path).map_err(|e| e.to_string())?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; let head = repo.head().map_err(|e| e.to_string())?; let target = head.peel_to_commit().map_err(|e| e.to_string())?; repo.branch(&name, &target, false) diff --git a/src-tauri/src/git_utils.rs b/src-tauri/src/git_utils.rs new file mode 100644 index 000000000..0118bb672 --- /dev/null +++ b/src-tauri/src/git_utils.rs @@ -0,0 +1,188 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use git2::{DiffOptions, Repository, Tree}; +use ignore::WalkBuilder; + +use crate::types::{GitLogEntry, WorkspaceEntry}; +use crate::utils::normalize_git_path; + +pub(crate) fn commit_to_entry(commit: git2::Commit) -> GitLogEntry { + let summary = commit.summary().unwrap_or("").to_string(); + let author = commit.author().name().unwrap_or("").to_string(); + let timestamp = commit.time().seconds(); + GitLogEntry { + sha: commit.id().to_string(), + summary, + author, + timestamp, + } +} + +pub(crate) fn checkout_branch(repo: &Repository, name: &str) -> Result<(), git2::Error> { + let refname = format!("refs/heads/{name}"); + repo.set_head(&refname)?; + let mut options = git2::build::CheckoutBuilder::new(); + options.safe(); + repo.checkout_head(Some(&mut options))?; + Ok(()) +} + +pub(crate) fn diff_stats_for_path( + repo: &Repository, + head_tree: Option<&Tree>, + path: &str, + include_index: bool, + include_workdir: bool, +) -> Result<(i64, i64), git2::Error> { + let mut additions = 0i64; + let mut deletions = 0i64; + + if include_index { + let mut options = DiffOptions::new(); + options.pathspec(path).include_untracked(true); + let diff = repo.diff_tree_to_index(head_tree, None, Some(&mut options))?; + let stats = diff.stats()?; + additions += stats.insertions() as i64; + deletions += stats.deletions() as i64; + } + + if include_workdir { + let mut options = DiffOptions::new(); + options + .pathspec(path) + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true); + let diff = repo.diff_index_to_workdir(None, Some(&mut options))?; + let stats = diff.stats()?; + additions += stats.insertions() as i64; + deletions += stats.deletions() as i64; + } + + Ok((additions, deletions)) +} + +pub(crate) fn diff_patch_to_string(patch: &mut git2::Patch) -> Result { + let buf = patch.to_buf()?; + Ok(buf + .as_str() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from_utf8_lossy(&buf).to_string())) +} + +pub(crate) fn parse_github_repo(remote_url: &str) -> Option { + let trimmed = remote_url.trim(); + if trimmed.is_empty() { + return None; + } + let mut path = if trimmed.starts_with("git@github.com:") { + trimmed.trim_start_matches("git@github.com:").to_string() + } else if trimmed.starts_with("ssh://git@github.com/") { + trimmed.trim_start_matches("ssh://git@github.com/").to_string() + } else if let Some(index) = trimmed.find("github.com/") { + trimmed[index + "github.com/".len()..].to_string() + } else { + return None; + }; + path = path.trim_end_matches(".git").trim_end_matches('/').to_string(); + if path.is_empty() { + None + } else { + Some(path) + } +} + +pub(crate) fn resolve_git_root(entry: &WorkspaceEntry) -> Result { + let base = PathBuf::from(&entry.path); + let root = entry + .settings + .git_root + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + let Some(root) = root else { + return Ok(base); + }; + let root_path = if Path::new(root).is_absolute() { + PathBuf::from(root) + } else { + base.join(root) + }; + if root_path.is_dir() { + Ok(root_path) + } else { + Err(format!("Git root not found: {root}")) + } +} + +fn should_skip_dir(name: &str) -> bool { + matches!( + name, + ".git" | "node_modules" | "dist" | "target" | "release-artifacts" + ) +} + +pub(crate) fn list_git_roots( + root: &Path, + max_depth: usize, + max_results: usize, +) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + + let mut results = Vec::new(); + let mut seen = HashSet::new(); + let max_depth = max_depth.max(1); + let walker = WalkBuilder::new(root) + .hidden(false) + .follow_links(false) + .max_depth(Some(max_depth)) + .filter_entry(|entry| { + if entry.depth() == 0 { + return true; + } + if entry.file_type().is_some_and(|ft| ft.is_dir()) { + let name = entry.file_name().to_string_lossy(); + if should_skip_dir(&name) { + return false; + } + } + true + }) + .build(); + + for entry in walker { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + if !entry.file_type().is_some_and(|ft| ft.is_dir()) { + continue; + } + if entry.depth() == 0 { + continue; + } + let candidate = entry.path(); + let git_marker = candidate.join(".git"); + if !git_marker.is_dir() && !git_marker.is_file() { + continue; + } + let rel = match candidate.strip_prefix(root) { + Ok(rel) => rel, + Err(_) => continue, + }; + let normalized = normalize_git_path(&rel.to_string_lossy()); + if normalized.is_empty() || !seen.insert(normalized.clone()) { + continue; + } + results.push(normalized); + if results.len() >= max_results { + break; + } + } + + results.sort(); + results +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 57d8af5d4..5f9dc851e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod codex; mod dictation; mod event_sink; mod git; +mod git_utils; mod prompts; mod settings; mod state; @@ -157,6 +158,7 @@ pub fn run() { codex::archive_thread, workspaces::connect_workspace, git::get_git_status, + git::list_git_roots, git::get_git_diffs, git::get_git_log, git::get_git_remote, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index a9714928e..86cdd1909 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -122,6 +122,8 @@ pub(crate) struct WorkspaceSettings { pub(crate) sidebar_collapsed: bool, #[serde(default, rename = "sortOrder")] pub(crate) sort_order: Option, + #[serde(default, rename = "gitRoot")] + pub(crate) git_root: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 1d4fcad81..340cecb7b 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -586,6 +586,7 @@ mod tests { settings: WorkspaceSettings { sidebar_collapsed: false, sort_order, + git_root: None, }, } } diff --git a/src/App.tsx b/src/App.tsx index be1a1ae59..fa625cabb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ import { useGitDiffs } from "./features/git/hooks/useGitDiffs"; import { useGitLog } from "./features/git/hooks/useGitLog"; import { useGitHubIssues } from "./features/git/hooks/useGitHubIssues"; import { useGitRemote } from "./features/git/hooks/useGitRemote"; +import { useGitRepoScan } from "./features/git/hooks/useGitRepoScan"; import { useModels } from "./features/models/hooks/useModels"; import { useSkills } from "./features/skills/hooks/useSkills"; import { useCustomPrompts } from "./features/prompts/hooks/useCustomPrompts"; @@ -72,6 +73,7 @@ import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { usePanelVisibility } from "./features/layout/hooks/usePanelVisibility"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; import { playNotificationSound } from "./utils/notificationSounds"; +import { pickWorkspacePath } from "./services/tauri"; import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; function useWindowLabel() { @@ -318,6 +320,16 @@ function MainApp() { error: gitIssuesError } = useGitHubIssues(activeWorkspace, gitPanelMode === "issues"); const { remote: gitRemoteUrl } = useGitRemote(activeWorkspace); + const { + repos: gitRootCandidates, + isLoading: gitRootScanLoading, + error: gitRootScanError, + depth: gitRootScanDepth, + hasScanned: gitRootScanHasScanned, + scan: scanGitRoots, + setDepth: setGitRootScanDepth, + clear: clearGitRootCandidates, + } = useGitRepoScan(activeWorkspace); const { models, selectedModel, @@ -347,12 +359,57 @@ function MainApp() { }; const resolvedModel = selectedModel?.model ?? null; + const activeGitRoot = activeWorkspace?.settings.gitRoot ?? null; + const normalizePath = useCallback((value: string) => { + return value.replace(/\\/g, "/").replace(/\/+$/, ""); + }, []); + const handleSetGitRoot = useCallback( + async (path: string | null) => { + if (!activeWorkspace) { + return; + } + await updateWorkspaceSettings(activeWorkspace.id, { + ...activeWorkspace.settings, + gitRoot: path, + }); + clearGitRootCandidates(); + refreshGitStatus(); + }, + [ + activeWorkspace, + clearGitRootCandidates, + refreshGitStatus, + updateWorkspaceSettings, + ], + ); + const handlePickGitRoot = useCallback(async () => { + if (!activeWorkspace) { + return; + } + const selection = await pickWorkspacePath(); + if (!selection) { + return; + } + const workspacePath = normalizePath(activeWorkspace.path); + const selectedPath = normalizePath(selection); + let nextRoot: string | null = null; + if (selectedPath === workspacePath) { + nextRoot = null; + } else if (selectedPath.startsWith(`${workspacePath}/`)) { + nextRoot = selectedPath.slice(workspacePath.length + 1); + } else { + nextRoot = selectedPath; + } + await handleSetGitRoot(nextRoot); + }, [activeWorkspace, handleSetGitRoot, normalizePath]); const fileStatus = - gitStatus.files.length > 0 - ? `${gitStatus.files.length} file${ - gitStatus.files.length === 1 ? "" : "s" - } changed` - : "Working tree clean"; + gitStatus.error + ? "Git status unavailable" + : gitStatus.files.length > 0 + ? `${gitStatus.files.length} file${ + gitStatus.files.length === 1 ? "" : "s" + } changed` + : "Working tree clean"; const { setActiveThreadId, @@ -906,6 +963,21 @@ function MainApp() { gitIssuesLoading, gitIssuesError, gitRemoteUrl, + gitRoot: activeGitRoot, + gitRootCandidates, + gitRootScanDepth, + gitRootScanLoading, + gitRootScanError, + gitRootScanHasScanned, + onGitRootScanDepthChange: setGitRootScanDepth, + onScanGitRoots: scanGitRoots, + onSelectGitRoot: (path) => { + void handleSetGitRoot(path); + }, + onClearGitRoot: () => { + void handleSetGitRoot(null); + }, + onPickGitRoot: handlePickGitRoot, gitDiffs, gitDiffLoading: isDiffLoading, gitDiffError: diffError, diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index d4b18a39b..e048be72c 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -29,6 +29,17 @@ type GitDiffPanelProps = { issuesLoading?: boolean; issuesError?: string | null; gitRemoteUrl?: string | null; + gitRoot?: string | null; + gitRootCandidates?: string[]; + gitRootScanDepth?: number; + gitRootScanLoading?: boolean; + gitRootScanError?: string | null; + gitRootScanHasScanned?: boolean; + onGitRootScanDepthChange?: (depth: number) => void; + onScanGitRoots?: () => void; + onSelectGitRoot?: (path: string) => void; + onClearGitRoot?: () => void; + onPickGitRoot?: () => void | Promise; selectedPath?: string | null; onSelectFile?: (path: string) => void; files: { @@ -93,6 +104,20 @@ function getStatusClass(status: string) { } } +function isMissingRepo(error: string | null | undefined) { + if (!error) { + return false; + } + const normalized = error.toLowerCase(); + return ( + normalized.includes("could not find repository") || + normalized.includes("not a git repository") || + (normalized.includes("repository") && normalized.includes("notfound")) || + normalized.includes("repository not found") || + normalized.includes("git root not found") + ); +} + export function GitDiffPanel({ mode, onModeChange, @@ -119,6 +144,17 @@ export function GitDiffPanel({ issuesTotal = 0, issuesLoading = false, issuesError = null, + gitRoot = null, + gitRootCandidates = [], + gitRootScanDepth = 2, + gitRootScanLoading = false, + gitRootScanError = null, + gitRootScanHasScanned = false, + onGitRootScanDepthChange, + onScanGitRoots, + onSelectGitRoot, + onClearGitRoot, + onPickGitRoot, }: GitDiffPanelProps) { const githubBaseUrl = (() => { if (!gitRemoteUrl) { @@ -187,6 +223,14 @@ export function GitDiffPanel({ : logUpstream ? `${logSyncLabel} ยท ${fileStatus}` : fileStatus; + const hasGitRoot = Boolean(gitRoot && gitRoot.trim()); + const showGitRootPanel = + isMissingRepo(error) || + gitRootScanLoading || + gitRootScanHasScanned || + Boolean(gitRootScanError) || + gitRootCandidates.length > 0; + const depthOptions = [1, 2, 3, 4, 5, 6]; return (