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);
+ }
+}