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",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"ts-rs", "ts-rs",
@@ -230,11 +230,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
/> />
{externalEntries.find((e) => e.status !== "current") && ( {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) => ( {externalEntries.map((entry) => (
<ExternalTreeNode <ExternalTreeNode
key={entry.relaPath + entry.status} key={entry.relaPath + entry.status}
entry={entry} entry={entry}
relaDir={status.data?.relaDir ?? ""}
onCheck={checkEntry} onCheck={checkEntry}
/> />
))} ))}
@@ -395,15 +396,22 @@ function TreeNodeChildren({
function ExternalTreeNode({ function ExternalTreeNode({
entry, entry,
relaDir,
onCheck, onCheck,
}: { }: {
entry: GitStatusEntry; entry: GitStatusEntry;
relaDir: string;
onCheck: (entry: GitStatusEntry) => void; onCheck: (entry: GitStatusEntry) => void;
}) { }) {
if (entry.status === "current") { if (entry.status === "current") {
return null; 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 ( return (
<Checkbox <Checkbox
fullWidth fullWidth
@@ -413,7 +421,7 @@ function ExternalTreeNode({
title={ title={
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center"> <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" /> <Icon color="secondary" icon="file_code" />
<div className="truncate">{entry.relaPath}</div> <div className="truncate">{displayPath}</div>
<InlineCode <InlineCode
className={classNames( className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center", "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 repo_dir = dir.to_path_buf();
let workdir = paths.workdir; let workdir = paths.workdir;
let gitdir = paths.gitdir; let gitdir = paths.gitdir;
let commondir = paths.commondir;
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>(); let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = notify::recommended_watcher(tx) let mut watcher = notify::recommended_watcher(tx)
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?; .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 watcher
.watch(&workdir, notify::RecursiveMode::Recursive) .watch(&repo_dir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?; .map_err(|e| Error::GenericError(format!("Failed to watch Git sync directory: {e}")))?;
if gitdir != workdir {
// 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 watcher
.watch(&gitdir, notify::RecursiveMode::Recursive) .watch(&commondir, notify::RecursiveMode::NonRecursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?; .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); 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, &repo_dir,
&workdir, &workdir,
&gitdir, &gitdir,
&commondir,
&channel, &channel,
).await; ).await;
} }
@@ -98,9 +118,10 @@ async fn handle_git_watch_event(
repo_dir: &Path, repo_dir: &Path,
workdir: &Path, workdir: &Path,
gitdir: &Path, gitdir: &Path,
commondir: &Path,
channel: &Channel<GitWorktreeStatus>, 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; return;
} }
@@ -111,7 +132,7 @@ async fn handle_git_watch_event(
loop { loop {
select! { select! {
Some(event_res) = async_rx.recv() => { 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 => { _ = &mut settle_window => {
break; break;
@@ -127,6 +148,7 @@ fn is_relevant_git_watch_event(
repo_dir: &Path, repo_dir: &Path,
workdir: &Path, workdir: &Path,
gitdir: &Path, gitdir: &Path,
commondir: &Path,
) -> bool { ) -> bool {
let event = match event_res { let event = match event_res {
Ok(event) => event, Ok(event) => event,
@@ -137,7 +159,7 @@ fn is_relevant_git_watch_event(
}; };
for path in event.paths { 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; return true;
} }
+4
View File
@@ -18,3 +18,7 @@ url = "2"
yaak-common = { workspace = true } yaak-common = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
yaak-sync = { 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 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>, }; export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, };
+87 -2
View File
@@ -1,11 +1,39 @@
use crate::binary::new_binary_command; use crate::binary::new_binary_command;
use crate::error::Error::GenericError; use crate::error::Error::GenericError;
use crate::repository::open_repo;
use crate::status::repo_relative_dir;
use log::info; use log::info;
use std::path::Path; 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<()> { pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
let out = // Run git from the repo root: command-line pathspecs resolve relative to
new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?; // 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 stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr); 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(()) 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> { fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
let repo = open_repo(dir)?; let repo = open_repo(dir)?;
let mut opts = git2::StatusOptions::new(); // Scoped to the sync directory: uncommitted changes elsewhere in a
opts.include_ignored(false).include_untracked(false); // 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))?; let statuses = repo.statuses(Some(&mut opts))?;
Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT)) 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 struct GitRepositoryPaths {
pub workdir: PathBuf, pub workdir: PathBuf,
pub gitdir: PathBuf, pub gitdir: PathBuf,
pub commondir: PathBuf,
} }
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> { 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() .workdir()
.ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))? .ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))?
.to_path_buf(); .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> { 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")] #[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusSummary { pub struct GitStatusSummary {
pub path: String, 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: Option<String>,
pub head_ref_shorthand: Option<String>, pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>, pub entries: Vec<GitStatusEntry>,
@@ -79,11 +82,8 @@ pub enum GitStatus {
pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> { pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> {
let repo = open_repo(dir)?; let repo = open_repo(dir)?;
let mut opts = git2::StatusOptions::new(); let mut opts = scoped_status_options(&repo, dir);
opts.include_ignored(false) opts.include_unmodified(false);
.include_untracked(true)
.recurse_untracked_dirs(true)
.include_unmodified(false);
let mut entries = Vec::new(); let mut entries = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() { 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 branch_info = git_branch_info_for_repo(&repo, dir)?;
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
let mut opts = git2::StatusOptions::new(); let mut opts = scoped_status_options(&repo, dir);
opts.include_ignored(false) opts.include_unmodified(true); // Include unchanged
.include_untracked(true) // Include untracked
.recurse_untracked_dirs(true) // Show all untracked
.include_unmodified(true); // Include unchanged
// TODO: Support renames // TODO: Support renames
@@ -160,6 +157,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
Ok(GitStatusSummary { Ok(GitStatusSummary {
entries, entries,
rela_dir: repo_relative_dir(&repo, dir).unwrap_or_default(),
path: branch_info.path, path: branch_info.path,
head_ref: branch_info.head_ref, head_ref: branch_info.head_ref,
head_ref_shorthand: branch_info.head_ref_shorthand, 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)) 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> { fn model_id_from_rela_path(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?; let ext = path.extension()?.to_str()?;
if ext != "yaml" && ext != "yml" && ext != "json" { 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) 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);
}
}