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