Skip to content

Commit 85a6280

Browse files
authored
Check whether Xvc's paths are dirty with gix instead of git status (#319)
1 parent 7b22382 commit 85a6280

5 files changed

Lines changed: 191 additions & 78 deletions

File tree

core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ pub use util::file::{all_paths_and_metadata, dir_includes, glob_includes, glob_p
8383
pub use util::git::{
8484
GitRoot, build_gitignore, exec_git, get_absolute_git_command, get_git_tracked_files,
8585
git_auto_commit, git_auto_stage, git_checkout_ref, handle_git_automation, inside_git,
86-
stash_user_staged_files, tracked_files, unstash_user_staged_files,
86+
stash_user_staged_files, tracked_files, unstash_user_staged_files, xvc_paths_dirty,
8787
};
8888

8989
pub use util::XvcPathMetadataMap;

core/src/util/git/gix_backend.rs

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! risk first:
55
//!
66
//! 1. [tracked_files] — iterate the index instead of parsing `git ls-files --full-name`. **Done.**
7-
//! 2. `xvc_paths_dirty` — [`gix::Repository::status`] instead of `git status --porcelain`.
7+
//! 2. [xvc_paths_dirty] — [`gix::Repository::status`] instead of `git status --porcelain`. **Done.**
88
//! 3. `stage_xvc_paths` — write blobs through the filter pipeline and patch the index.
99
//! 4. `commit_xvc_paths` — build the tree from `HEAD^{tree}` with [`gix::Repository::edit_tree`]
1010
//! and commit it, so the user's staging area is never touched and no stash is needed.
@@ -17,6 +17,7 @@
1717
1818
use std::path::Path;
1919

20+
use super::paths::XvcGitPaths;
2021
use crate::{Error, Result};
2122

2223
/// List the files Git tracks under `xvc_directory`, as paths relative to `xvc_directory`.
@@ -43,20 +44,7 @@ use crate::{Error, Result};
4344
/// [`crate::XvcPath`]s, which are relative to the Xvc root, so entries outside `xvc_directory`
4445
/// are dropped and the rest are re-based onto it.
4546
pub fn tracked_files(xvc_directory: &Path) -> Result<Vec<String>> {
46-
let repo = gix::discover(xvc_directory).map_err(|e| Error::GixError {
47-
cause: e.to_string(),
48-
})?;
49-
50-
let workdir = repo.workdir().ok_or_else(|| Error::GixError {
51-
cause: format!(
52-
"{} is inside a bare Git repository, which tracks no worktree files",
53-
xvc_directory.display()
54-
),
55-
})?;
56-
57-
// Both sides are canonicalized before comparison: `gix` reports the worktree as configured,
58-
// which may traverse symlinks differently than the path Xvc was given.
59-
let prefix = subdirectory_prefix(&workdir.canonicalize()?, &xvc_directory.canonicalize()?)?;
47+
let (repo, prefix) = repo_and_prefix(xvc_directory)?;
6048

6149
let index = repo.index_or_empty().map_err(|e| Error::GixIndexError {
6250
cause: e.to_string(),
@@ -80,6 +68,70 @@ pub fn tracked_files(xvc_directory: &Path) -> Result<Vec<String>> {
8068
Ok(files)
8169
}
8270

71+
/// Whether anything under the Xvc-owned path set differs from `HEAD` — staged, unstaged or
72+
/// untracked.
73+
///
74+
/// This replaces the `git status --porcelain <xvc paths>` pre-check that decides whether
75+
/// [`super::handle_git_automation`] has any reason to commit or stage.
76+
///
77+
/// The pathspecs are narrower than the ones the subprocess pre-check used; see
78+
/// [`XvcGitPaths::pathspecs`] for what changed and why.
79+
pub fn xvc_paths_dirty(xvc_directory: &Path) -> Result<bool> {
80+
let (repo, prefix) = repo_and_prefix(xvc_directory)?;
81+
82+
let mut changes = repo
83+
.status(gix::progress::Discard)
84+
.map_err(|e| Error::GixError {
85+
cause: e.to_string(),
86+
})?
87+
// `Collapsed`, the default, would be cheaper — it reports a wholly new `.xvc/store/` as a
88+
// single directory, which is enough for a boolean. `Files` is used anyway so that this
89+
// check and the commit that follows it walk the tree the same way and cannot disagree
90+
// about whether there is anything to do.
91+
.untracked_files(gix::status::UntrackedFiles::Files)
92+
.index_worktree_submodules(None)
93+
.into_iter(XvcGitPaths::pathspecs(
94+
prefix.as_deref().unwrap_or_default(),
95+
))
96+
.map_err(|e| Error::GixError {
97+
cause: e.to_string(),
98+
})?;
99+
100+
// Only the first item matters. Dropping the iterator early interrupts and joins the producer
101+
// threads, so there is no need to drain it.
102+
match changes.next() {
103+
None => Ok(false),
104+
Some(Ok(_)) => Ok(true),
105+
Some(Err(e)) => Err(Error::GixError {
106+
cause: e.to_string(),
107+
}),
108+
}
109+
}
110+
111+
/// Open the repository containing `xvc_directory`, and work out where that directory sits inside
112+
/// it.
113+
///
114+
/// Both paths are canonicalized before comparison: `gix` reports the worktree as configured, which
115+
/// may traverse symlinks differently than the path Xvc was given.
116+
fn repo_and_prefix(xvc_directory: &Path) -> Result<(gix::Repository, Option<String>)> {
117+
let repo = gix::discover(xvc_directory).map_err(|e| Error::GixError {
118+
cause: e.to_string(),
119+
})?;
120+
121+
let workdir = repo
122+
.workdir()
123+
.ok_or_else(|| Error::GixError {
124+
cause: format!(
125+
"{} is inside a bare Git repository, which has no worktree",
126+
xvc_directory.display()
127+
),
128+
})?
129+
.canonicalize()?;
130+
131+
let prefix = subdirectory_prefix(&workdir, &xvc_directory.canonicalize()?)?;
132+
Ok((repo, prefix))
133+
}
134+
83135
/// The slash-separated path from `root` down to `dir`, with a trailing slash, or `None` when they
84136
/// are the same directory.
85137
///

core/src/util/git/mod.rs

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,90 @@
55
//!
66
//! The work is split by *how* it reaches Git:
77
//!
8-
//! - [subprocess] runs the `git` binary. Everything lives here today.
9-
//! - [gix_backend] does the same work in process with [gix]. Operations move here one at a time.
8+
//! - [subprocess] runs the `git` binary.
9+
//! - [gix_backend] does the same work in process with [gix]. Operations move there one at a time.
1010
//! - [paths] holds the path set Xvc owns, shared by both so they cannot drift apart.
1111
//! - [ignore] locates the repository and reads `.gitignore` rules — no Git needed either way.
1212
//! - [refs] lists references and branches for the shell completers, already using [gix].
1313
//!
14-
//! Callers should use the re-exports below rather than reaching into the submodules, so that
15-
//! moving an operation between backends stays invisible to them.
14+
//! [handle_git_automation] is the entry point that decides which of those runs, so it lives here
15+
//! rather than in either backend. Callers should use the re-exports below rather than reaching
16+
//! into the submodules, so that moving an operation between backends stays invisible to them.
1617
1718
pub mod gix_backend;
1819
pub mod ignore;
1920
pub mod paths;
2021
pub mod refs;
2122
pub mod subprocess;
2223

23-
pub use gix_backend::tracked_files;
24+
pub use gix_backend::{tracked_files, xvc_paths_dirty};
2425
pub use ignore::{GitRoot, build_gitignore, inside_git};
2526
pub use paths::{GITIGNORE_PATHSPEC, XVCIGNORE_PATHSPEC, XvcGitPaths};
2627
pub use refs::{gix_list_branches, gix_list_references};
2728
pub use subprocess::{
2829
exec_git, get_absolute_git_command, get_git_tracked_files, git_auto_commit, git_auto_stage,
29-
git_checkout_ref, handle_git_automation, stash_user_staged_files, unstash_user_staged_files,
30+
git_checkout_ref, stash_user_staged_files, unstash_user_staged_files,
3031
};
32+
33+
use xvc_logging::{XvcOutputSender, debug};
34+
35+
use crate::{Result, XvcRoot};
36+
37+
/// Commit or stage Xvc's own files after a command, according to the `git.*` configuration.
38+
///
39+
/// This receives `xvc_root` ownership because as a final operation, it must drop the root to
40+
/// record the last entity counter before commit.
41+
pub fn handle_git_automation(
42+
output_snd: &XvcOutputSender,
43+
xvc_root: &XvcRoot,
44+
to_branch: Option<&str>,
45+
xvc_cmd: &str,
46+
) -> Result<()> {
47+
let xvc_root_dir = xvc_root.as_path().to_path_buf();
48+
let xvc_root_str = xvc_root_dir.to_str().unwrap();
49+
let git_config = xvc_root.config().git.clone();
50+
let use_git = git_config.use_git;
51+
let auto_commit = git_config.auto_commit;
52+
let auto_stage = git_config.auto_stage;
53+
let git_command_str = git_config.command.clone();
54+
let git_command = get_absolute_git_command(&git_command_str)?;
55+
let xvc_dir = xvc_root.xvc_dir().clone();
56+
let xvc_dir_str = xvc_dir.to_str().unwrap();
57+
58+
if use_git {
59+
// Check if there are any changes in the relevant paths before proceeding.
60+
//
61+
// This fails open: if the status check itself errors, carry on and let the commit or stage
62+
// below report the problem. The commit path re-checks anyway, so the cost of a false
63+
// positive here is one wasted `git add`, whereas a false negative would silently drop a
64+
// commit Xvc owed.
65+
match xvc_paths_dirty(&xvc_root_dir) {
66+
Ok(false) => {
67+
debug!(
68+
output_snd,
69+
"No changes detected in Xvc files, skipping Git operations."
70+
);
71+
return Ok(());
72+
}
73+
Ok(true) => {}
74+
Err(e) => {
75+
debug!(output_snd, "Error checking git status: {e}");
76+
}
77+
}
78+
79+
if auto_commit {
80+
git_auto_commit(
81+
output_snd,
82+
&git_command,
83+
xvc_root_str,
84+
xvc_dir_str,
85+
xvc_cmd,
86+
to_branch,
87+
)?;
88+
} else if auto_stage {
89+
git_auto_stage(output_snd, &git_command, xvc_root_str, xvc_dir_str)?;
90+
}
91+
}
92+
93+
Ok(())
94+
}

core/src/util/git/paths.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
//! pre-check and the `git add` that follows it agree by construction, rather than by three
66
//! copies of the same literals staying in sync by hand.
77
8+
use gix::bstr::BString;
9+
10+
use crate::{XVC_DIR, XVCIGNORE_FILENAME};
11+
812
/// Pathspec matching the `.gitignore` files Xvc writes to.
913
///
1014
/// NOTE: This is a bare Git pathspec, so `*` matches `/` too (`fnmatch` without `FNM_PATHNAME`).
@@ -27,6 +31,28 @@ impl XvcGitPaths {
2731
pub fn subprocess_pathspecs(xvc_dir: &str) -> [&str; 3] {
2832
[xvc_dir, GITIGNORE_PATHSPEC, XVCIGNORE_PATHSPEC]
2933
}
34+
35+
/// The same set as Git pathspecs, for the in-process backend.
36+
///
37+
/// `prefix` is where the Xvc root sits inside the repository, slash-terminated — empty when
38+
/// the two are the same directory, `"sub/"` when `xvc init` ran in `sub`. Unlike the
39+
/// subprocess form, these are always relative to the repository root, because
40+
/// [`gix::Repository::status`] has no working directory to be relative to. The `(top)` magic
41+
/// says so explicitly.
42+
///
43+
/// # Narrower than [subprocess_pathspecs][Self::subprocess_pathspecs]
44+
///
45+
/// `*.gitignore` is a bare pathspec, where `*` matches `/` as well, so it also selects
46+
/// `sub/x.gitignore` — a file Git attaches no meaning to and Xvc never writes. The `(glob)`
47+
/// magic stops `*` from crossing directory separators, and `**/` matches any number of
48+
/// leading directories, so `**/.gitignore` selects exactly the ignore files themselves.
49+
pub fn pathspecs(prefix: &str) -> Vec<BString> {
50+
vec![
51+
format!(":(top){prefix}{XVC_DIR}").into(),
52+
format!(":(top,glob){prefix}**/.gitignore").into(),
53+
format!(":(top,glob){prefix}**/{XVCIGNORE_FILENAME}").into(),
54+
]
55+
}
3056
}
3157

3258
#[cfg(test)]
@@ -42,4 +68,31 @@ mod test {
4268
["/repo/.xvc", "*.gitignore", "*.xvcignore"]
4369
);
4470
}
71+
72+
#[test]
73+
fn pathspecs_at_the_repository_root() {
74+
assert_eq!(
75+
XvcGitPaths::pathspecs(""),
76+
vec![
77+
BString::from(":(top).xvc"),
78+
BString::from(":(top,glob)**/.gitignore"),
79+
BString::from(":(top,glob)**/.xvcignore"),
80+
]
81+
);
82+
}
83+
84+
/// When `xvc init` ran in a subdirectory, the pathspecs stay anchored to the repository root
85+
/// but are scoped to that subdirectory — matching what `git -C <xvc root>` did by having the
86+
/// working directory scope them.
87+
#[test]
88+
fn pathspecs_under_a_subdirectory_xvc_root() {
89+
assert_eq!(
90+
XvcGitPaths::pathspecs("sub/"),
91+
vec![
92+
BString::from(":(top)sub/.xvc"),
93+
BString::from(":(top,glob)sub/**/.gitignore"),
94+
BString::from(":(top,glob)sub/**/.xvcignore"),
95+
]
96+
);
97+
}
4598
}

core/src/util/git/subprocess.rs

Lines changed: 0 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -135,62 +135,6 @@ pub fn git_checkout_ref(
135135
Ok(())
136136
}
137137

138-
/// This receives `xvc_root` ownership because as a final operation, it must drop the root to
139-
/// record the last entity counter before commit.
140-
pub fn handle_git_automation(
141-
output_snd: &XvcOutputSender,
142-
xvc_root: &XvcRoot,
143-
to_branch: Option<&str>,
144-
xvc_cmd: &str,
145-
) -> Result<()> {
146-
let xvc_root_dir = xvc_root.as_path().to_path_buf();
147-
let xvc_root_str = xvc_root_dir.to_str().unwrap();
148-
let git_config = xvc_root.config().git.clone();
149-
let use_git = git_config.use_git;
150-
let auto_commit = git_config.auto_commit;
151-
let auto_stage = git_config.auto_stage;
152-
let git_command_str = git_config.command.clone();
153-
let git_command = get_absolute_git_command(&git_command_str)?;
154-
let xvc_dir = xvc_root.xvc_dir().clone();
155-
let xvc_dir_str = xvc_dir.to_str().unwrap();
156-
157-
if use_git {
158-
// Check if there are any changes in the relevant paths before proceeding
159-
let mut status_args = vec!["status", "--porcelain"];
160-
status_args.extend(XvcGitPaths::subprocess_pathspecs(xvc_dir_str));
161-
match exec_git(&git_command, xvc_root_str, &status_args) {
162-
Ok(git_status_out) => {
163-
if git_status_out.trim().is_empty() {
164-
debug!(
165-
output_snd,
166-
"No changes detected in Xvc files, skipping Git operations."
167-
);
168-
return Ok(());
169-
}
170-
}
171-
Err(e) => {
172-
debug!(output_snd, "Error checking git status: {e}");
173-
// Continue and let git_auto_commit/git_auto_stage handle/report the error
174-
}
175-
}
176-
177-
if auto_commit {
178-
git_auto_commit(
179-
output_snd,
180-
&git_command,
181-
xvc_root_str,
182-
xvc_dir_str,
183-
xvc_cmd,
184-
to_branch,
185-
)?;
186-
} else if auto_stage {
187-
git_auto_stage(output_snd, &git_command, xvc_root_str, xvc_dir_str)?;
188-
}
189-
}
190-
191-
Ok(())
192-
}
193-
194138
/// Commit `.xvc` directory after Xvc operations
195139
pub fn git_auto_commit(
196140
output_snd: &XvcOutputSender,

0 commit comments

Comments
 (0)