Scope Git watching and status to the sync directory (#503)

This commit is contained in:
Gregory Schier
2026-07-09 15:26:37 -07:00
committed by GitHub
parent 155260521e
commit e20a184c70
9 changed files with 260 additions and 26 deletions
Generated
+1
View File
@@ -11113,6 +11113,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.17",
"tokio",
"ts-rs",
@@ -230,11 +230,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
/>
{externalEntries.find((e) => e.status !== "current") && (
<>
<Separator className="mt-3 mb-1">External file changes</Separator>
<Separator className="mt-3 mb-1">Other files</Separator>
{externalEntries.map((entry) => (
<ExternalTreeNode
key={entry.relaPath + entry.status}
entry={entry}
relaDir={status.data?.relaDir ?? ""}
onCheck={checkEntry}
/>
))}
@@ -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 (
<Checkbox
fullWidth
@@ -413,7 +421,7 @@ function ExternalTreeNode({
title={
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" />
<div className="truncate">{entry.relaPath}</div>
<div className="truncate">{displayPath}</div>
<InlineCode
className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center",
@@ -32,18 +32,37 @@ pub(crate) async fn watch_git_worktree_status<R: Runtime>(
let repo_dir = dir.to_path_buf();
let workdir = paths.workdir;
let gitdir = paths.gitdir;
let commondir = paths.commondir;
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
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::<notify::Result<notify::Event>>(100);
@@ -70,6 +89,7 @@ pub(crate) async fn watch_git_worktree_status<R: Runtime>(
&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<GitWorktreeStatus>,
) {
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;
}
+4
View File
@@ -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"] }
+6 -1
View File
@@ -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<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, 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<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, };
+87 -2
View File
@@ -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<Vec<String>> {
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);
}
}
+5 -2
View File
@@ -21,8 +21,11 @@ pub enum PullResult {
fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
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))
}
+8 -1
View File
@@ -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<git2::Repository> {
@@ -22,7 +23,13 @@ pub fn git_repository_paths(dir: &Path) -> Result<GitRepositoryPaths> {
.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<bool> {
+109 -10
View File
@@ -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<String>,
pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>,
@@ -79,11 +82,8 @@ pub enum GitStatus {
pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> {
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<GitStatusSummary> {
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<GitStatusSummary> {
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<String> {
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<String> =
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<String> {
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<String> {
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);
}
}