mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-10 14:52:49 +02:00
Scope Git watching and status to the sync directory (#503)
This commit is contained in:
Generated
+1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
Generated
+6
-1
@@ -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>, };
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user