diff --git a/Cargo.lock b/Cargo.lock index 6315ae03..0ac99add 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11113,6 +11113,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "thiserror 2.0.17", "tokio", "ts-rs", diff --git a/apps/yaak-client/components/git/GitCommitDialog.tsx b/apps/yaak-client/components/git/GitCommitDialog.tsx index 5f6e2ca4..75752861 100644 --- a/apps/yaak-client/components/git/GitCommitDialog.tsx +++ b/apps/yaak-client/components/git/GitCommitDialog.tsx @@ -230,11 +230,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { /> {externalEntries.find((e) => e.status !== "current") && ( <> - External file changes + Other files {externalEntries.map((entry) => ( ))} @@ -395,15 +396,22 @@ function TreeNodeChildren({ function ExternalTreeNode({ entry, + relaDir, onCheck, }: { entry: GitStatusEntry; + relaDir: string; onCheck: (entry: GitStatusEntry) => void; }) { if (entry.status === "current") { return null; } + // Show paths relative to the sync directory when inside it + const displayPath = entry.relaPath.startsWith(`${relaDir}/`) + ? entry.relaPath.slice(relaDir.length + 1) + : entry.relaPath; + return ( -
{entry.relaPath}
+
{displayPath}
( let repo_dir = dir.to_path_buf(); let workdir = paths.workdir; let gitdir = paths.gitdir; + let commondir = paths.commondir; let (tx, rx) = mpsc::channel::>(); let mut watcher = notify::recommended_watcher(tx) .map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?; + // Watch only the directory Yaak syncs to, not the whole worktree — the + // containing repo may be huge and busy (e.g. a sync dir inside a monorepo), + // and watching it all burns CPU re-checking status for unrelated changes watcher - .watch(&workdir, notify::RecursiveMode::Recursive) - .map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?; - if gitdir != workdir { + .watch(&repo_dir, notify::RecursiveMode::Recursive) + .map_err(|e| Error::GenericError(format!("Failed to watch Git sync directory: {e}")))?; + + // Watch the git metadata that affects branch/status info: the top-level + // gitdir files (HEAD, index) and refs. Not the whole gitdir, since + // .git/objects churns constantly during fetches and gc. Refs and + // packed-refs live in the common dir, which only differs from the gitdir + // for linked worktrees + watcher + .watch(&gitdir, notify::RecursiveMode::NonRecursive) + .map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?; + if commondir != gitdir { watcher - .watch(&gitdir, notify::RecursiveMode::Recursive) - .map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?; + .watch(&commondir, notify::RecursiveMode::NonRecursive) + .map_err(|e| Error::GenericError(format!("Failed to watch Git common dir: {e}")))?; + } + let refs_dir = commondir.join("refs"); + if refs_dir.exists() { + watcher + .watch(&refs_dir, notify::RecursiveMode::Recursive) + .map_err(|e| Error::GenericError(format!("Failed to watch Git refs: {e}")))?; } let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::>(100); @@ -70,6 +89,7 @@ pub(crate) async fn watch_git_worktree_status( &repo_dir, &workdir, &gitdir, + &commondir, &channel, ).await; } @@ -98,9 +118,10 @@ async fn handle_git_watch_event( repo_dir: &Path, workdir: &Path, gitdir: &Path, + commondir: &Path, channel: &Channel, ) { - if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir) { + if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir) { return; } @@ -111,7 +132,7 @@ async fn handle_git_watch_event( loop { select! { Some(event_res) = async_rx.recv() => { - let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir); + let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir); } _ = &mut settle_window => { break; @@ -127,6 +148,7 @@ fn is_relevant_git_watch_event( repo_dir: &Path, workdir: &Path, gitdir: &Path, + commondir: &Path, ) -> bool { let event = match event_res { Ok(event) => event, @@ -137,7 +159,7 @@ fn is_relevant_git_watch_event( }; for path in event.paths { - if path.strip_prefix(gitdir).is_ok() { + if path.strip_prefix(gitdir).is_ok() || path.strip_prefix(commondir).is_ok() { return true; } diff --git a/crates/yaak-git/Cargo.toml b/crates/yaak-git/Cargo.toml index 09079b05..dc6f2389 100644 --- a/crates/yaak-git/Cargo.toml +++ b/crates/yaak-git/Cargo.toml @@ -18,3 +18,7 @@ url = "2" yaak-common = { workspace = true } yaak-models = { workspace = true } yaak-sync = { workspace = true } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/yaak-git/bindings/gen_git.ts b/crates/yaak-git/bindings/gen_git.ts index 3222a770..1df93b14 100644 --- a/crates/yaak-git/bindings/gen_git.ts +++ b/crates/yaak-git/bindings/gen_git.ts @@ -19,7 +19,12 @@ export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "rem export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, }; -export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array, origins: Array, localBranches: Array, remoteBranches: Array, ahead: number, behind: number, }; +export type GitStatusSummary = { path: string, +/** + * The status directory relative to the repo root ("" when it IS the root). + * Useful for displaying entry paths relative to the sync directory + */ +relaDir: string, headRef: string | null, headRefShorthand: string | null, entries: Array, origins: Array, localBranches: Array, remoteBranches: Array, ahead: number, behind: number, }; export type GitWorktreeStatus = { entries: Array, }; diff --git a/crates/yaak-git/src/commit.rs b/crates/yaak-git/src/commit.rs index 1058abba..10a83c83 100644 --- a/crates/yaak-git/src/commit.rs +++ b/crates/yaak-git/src/commit.rs @@ -1,11 +1,39 @@ use crate::binary::new_binary_command; use crate::error::Error::GenericError; +use crate::repository::open_repo; +use crate::status::repo_relative_dir; use log::info; use std::path::Path; +/// Commit the staged changes within `dir` (their current worktree content, +/// matching what the commit dialog displays). Scoping the commit to the sync +/// directory means files staged outside of it (e.g. elsewhere in a containing +/// monorepo) are never swept into a Yaak commit — they stay staged for the +/// user's own next commit. pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> { - let out = - new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?; + // Run git from the repo root: command-line pathspecs resolve relative to + // the working directory, and staged paths are repo-root-relative + let (workdir, rela_dir) = { + let repo = open_repo(dir)?; + let workdir = repo + .workdir() + .ok_or_else(|| GenericError("Repository has no worktree".to_string()))? + .to_path_buf(); + (workdir, repo_relative_dir(&repo, dir)) + }; + + let staged = staged_files(&workdir, rela_dir.as_deref()).await?; + if staged.is_empty() { + return Err(GenericError("No staged changes to commit".to_string())); + } + + let mut cmd = new_binary_command(&workdir).await?; + // --literal-pathspecs: the staged paths are exact files, never patterns + cmd.arg("--literal-pathspecs"); + cmd.args(["commit", "--message", message, "--"]); + cmd.args(staged); + + let out = cmd.output().await?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); @@ -19,3 +47,60 @@ pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> { Ok(()) } + +/// Repo-relative paths of staged changes, limited to `rela_dir` when given. +/// Must be run from the repo root so the pathspec resolves correctly. Uses +/// -z for NUL separation so paths with special characters come through +/// unquoted, --no-renames so staged renames list both sides, and +/// --literal-pathspecs so a scope dir with glob characters isn't a pattern +async fn staged_files(workdir: &Path, rela_dir: Option<&str>) -> crate::error::Result> { + let mut cmd = new_binary_command(workdir).await?; + cmd.arg("--literal-pathspecs"); + cmd.args(["diff", "--cached", "--name-only", "--no-renames", "-z"]); + if let Some(rela_dir) = rela_dir { + cmd.args(["--", rela_dir]); + } + let out = cmd.output().await?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(GenericError(format!("Failed to list staged files: {}", stderr))); + } + + Ok(String::from_utf8_lossy(&out.stdout) + .split('\0') + .filter(|p| !p.is_empty()) + .map(String::from) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_staged_files_scoped() { + let tmp = tempfile::tempdir().unwrap(); + let repo = git2::Repository::init(tmp.path()).unwrap(); + + // Scope dir name doubles as a glob pattern; sync1 is its glob decoy + let sync_dir = tmp.path().join("sync[1]"); + std::fs::create_dir(&sync_dir).unwrap(); + std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap(); + std::fs::create_dir(tmp.path().join("sync1")).unwrap(); + std::fs::write(tmp.path().join("sync1").join("decoy.txt"), "decoy").unwrap(); + std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(Path::new("sync[1]/yaak.req_1.yaml")).unwrap(); + index.add_path(Path::new("sync1/decoy.txt")).unwrap(); + index.add_path(Path::new("outside.txt")).unwrap(); + index.write().unwrap(); + + let scoped = staged_files(tmp.path(), Some("sync[1]")).await.unwrap(); + assert_eq!(scoped, vec!["sync[1]/yaak.req_1.yaml"]); + + let all = staged_files(tmp.path(), None).await.unwrap(); + assert_eq!(all.len(), 3); + } +} diff --git a/crates/yaak-git/src/pull.rs b/crates/yaak-git/src/pull.rs index 4185350e..db61a2f1 100644 --- a/crates/yaak-git/src/pull.rs +++ b/crates/yaak-git/src/pull.rs @@ -21,8 +21,11 @@ pub enum PullResult { fn has_uncommitted_changes(dir: &Path) -> Result { let repo = open_repo(dir)?; - let mut opts = git2::StatusOptions::new(); - opts.include_ignored(false).include_untracked(false); + // Scoped to the sync directory: uncommitted changes elsewhere in a + // containing monorepo shouldn't block pulling. Git itself still refuses + // a merge that would clobber uncommitted files outside the sync dir + let mut opts = crate::status::scoped_status_options(&repo, dir); + opts.include_untracked(false); let statuses = repo.statuses(Some(&mut opts))?; Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT)) } diff --git a/crates/yaak-git/src/repository.rs b/crates/yaak-git/src/repository.rs index 6081abf3..a229d70b 100644 --- a/crates/yaak-git/src/repository.rs +++ b/crates/yaak-git/src/repository.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; pub struct GitRepositoryPaths { pub workdir: PathBuf, pub gitdir: PathBuf, + pub commondir: PathBuf, } pub(crate) fn open_repo(dir: &Path) -> crate::error::Result { @@ -22,7 +23,13 @@ pub fn git_repository_paths(dir: &Path) -> Result { .workdir() .ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))? .to_path_buf(); - Ok(GitRepositoryPaths { workdir, gitdir: repo.path().to_path_buf() }) + Ok(GitRepositoryPaths { + workdir, + gitdir: repo.path().to_path_buf(), + // Same as gitdir except for linked worktrees, where shared refs and + // packed-refs live in the main repository's git directory + commondir: repo.commondir().to_path_buf(), + }) } pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result { diff --git a/crates/yaak-git/src/status.rs b/crates/yaak-git/src/status.rs index c07c218e..6c4152dd 100644 --- a/crates/yaak-git/src/status.rs +++ b/crates/yaak-git/src/status.rs @@ -12,6 +12,9 @@ use yaak_sync::models::SyncModel; #[ts(export, export_to = "gen_git.ts")] pub struct GitStatusSummary { pub path: String, + /// The status directory relative to the repo root ("" when it IS the root). + /// Useful for displaying entry paths relative to the sync directory + pub rela_dir: String, pub head_ref: Option, pub head_ref_shorthand: Option, pub entries: Vec, @@ -79,11 +82,8 @@ pub enum GitStatus { pub fn git_worktree_status(dir: &Path) -> crate::error::Result { let repo = open_repo(dir)?; - let mut opts = git2::StatusOptions::new(); - opts.include_ignored(false) - .include_untracked(true) - .recurse_untracked_dirs(true) - .include_unmodified(false); + let mut opts = scoped_status_options(&repo, dir); + opts.include_unmodified(false); let mut entries = Vec::new(); for entry in repo.statuses(Some(&mut opts))?.into_iter() { @@ -115,11 +115,8 @@ pub fn git_status(dir: &Path) -> crate::error::Result { let branch_info = git_branch_info_for_repo(&repo, dir)?; let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); - let mut opts = git2::StatusOptions::new(); - opts.include_ignored(false) - .include_untracked(true) // Include untracked - .recurse_untracked_dirs(true) // Show all untracked - .include_unmodified(true); // Include unchanged + let mut opts = scoped_status_options(&repo, dir); + opts.include_unmodified(true); // Include unchanged // TODO: Support renames @@ -160,6 +157,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result { Ok(GitStatusSummary { entries, + rela_dir: repo_relative_dir(&repo, dir).unwrap_or_default(), path: branch_info.path, head_ref: branch_info.head_ref, head_ref_shorthand: branch_info.head_ref_shorthand, @@ -266,6 +264,40 @@ fn git_status_from_raw(status: git2::Status) -> Option<(GitStatus, bool)> { Some((status, staged)) } +/// Construct StatusOptions for a walk scoped to `dir`. Yaak only cares about +/// the sync directory, and a full walk is expensive when the containing repo +/// is large (e.g. a sync dir inside a monorepo); scoping is a no-op when +/// `dir` is the repo root. Always build status walks through this so a new +/// call site can't forget the scoping. +pub(crate) fn scoped_status_options(repo: &git2::Repository, dir: &Path) -> git2::StatusOptions { + let mut opts = git2::StatusOptions::new(); + opts.include_ignored(false).include_untracked(true).recurse_untracked_dirs(true); + if let Some(rela) = repo_relative_dir(repo, dir) { + opts.pathspec(rela); + // Match the path literally (exact or directory prefix) instead of as + // a glob — directory names can contain pattern characters like [ or * + opts.disable_pathspec_match(true); + } + opts +} + +/// The path of `dir` relative to the repo root as a forward-slash string +/// (Git pathspecs use forward slashes even on Windows), or None when `dir` +/// is the root itself (or outside the repo). Both sides are canonicalized so +/// symlinked paths compare consistently. +pub(crate) fn repo_relative_dir(repo: &git2::Repository, dir: &Path) -> Option { + let workdir = repo.workdir()?; + let workdir = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf()); + let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + let rela = canonical_dir.strip_prefix(&workdir).ok()?; + if rela.as_os_str().is_empty() { + return None; + } + let parts: Vec = + rela.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect(); + Some(parts.join("/")) +} + fn model_id_from_rela_path(path: &Path) -> Option { let ext = path.extension()?.to_str()?; if ext != "yaml" && ext != "yml" && ext != "json" { @@ -274,3 +306,70 @@ fn model_id_from_rela_path(path: &Path) -> Option { path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_worktree_status_scoped_to_subdir() { + let tmp = tempfile::tempdir().unwrap(); + git2::Repository::init(tmp.path()).unwrap(); + + let sync_dir = tmp.path().join("sync"); + std::fs::create_dir(&sync_dir).unwrap(); + std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap(); + std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap(); + + // Status on a subdirectory only reports that subdirectory + let status = git_worktree_status(&sync_dir).unwrap(); + let paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect(); + assert_eq!(paths, vec!["sync/yaak.req_1.yaml"]); + assert_eq!(status.entries[0].model_id.as_deref(), Some("req_1")); + + // Status on the repo root reports everything + let status = git_worktree_status(tmp.path()).unwrap(); + assert_eq!(status.entries.len(), 2); + } + + #[test] + fn test_worktree_status_scoped_literal_dir_name() { + let tmp = tempfile::tempdir().unwrap(); + git2::Repository::init(tmp.path()).unwrap(); + + // A directory name that is also a valid glob pattern ([1] matches "1") + let sync_dir = tmp.path().join("sync[1]"); + std::fs::create_dir(&sync_dir).unwrap(); + std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap(); + std::fs::create_dir(tmp.path().join("sync1")).unwrap(); + std::fs::write(tmp.path().join("sync1").join("decoy.txt"), "glob match").unwrap(); + + let status = git_worktree_status(&sync_dir).unwrap(); + let paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect(); + assert_eq!(paths, vec!["sync[1]/yaak.req_1.yaml"]); + } + + #[test] + fn test_status_scoped_to_subdir() { + let tmp = tempfile::tempdir().unwrap(); + git2::Repository::init(tmp.path()).unwrap(); + + let sync_dir = tmp.path().join("sync"); + std::fs::create_dir(&sync_dir).unwrap(); + std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap(); + std::fs::write(sync_dir.join("README.md"), "external, but in sync dir").unwrap(); + std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap(); + + // The commit dialog's status only reports the sync directory + let status = git_status(&sync_dir).unwrap(); + assert_eq!(status.rela_dir, "sync"); + let mut paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect(); + paths.sort(); + assert_eq!(paths, vec!["sync/README.md", "sync/yaak.req_1.yaml"]); + + // Status on the repo root reports everything + let status = git_status(tmp.path()).unwrap(); + assert_eq!(status.rela_dir, ""); + assert_eq!(status.entries.len(), 3); + } +}