Skip to content

Commit 7b22382

Browse files
iesahinclaude
andauthored
Read Git's index with gix instead of parsing git ls-files (#318)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent e807704 commit 7b22382

4 files changed

Lines changed: 90 additions & 17 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, unstash_user_staged_files,
86+
stash_user_staged_files, tracked_files, unstash_user_staged_files,
8787
};
8888

8989
pub use util::XvcPathMetadataMap;

core/src/util/git/gix_backend.rs

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
//! Git operations that run in process, via [gix].
22
//!
3-
//! This module is a placeholder. Xvc's Git operations currently all run the `git` binary (see
4-
//! [`super::subprocess`]); they are being moved here one at a time, lowest risk first:
3+
//! Xvc's Git operations are being moved here from [`super::subprocess`] one at a time, lowest
4+
//! risk first:
55
//!
6-
//! 1. `tracked_files` — iterate the index instead of parsing `git ls-files --full-name`.
6+
//! 1. [tracked_files] — iterate the index instead of parsing `git ls-files --full-name`. **Done.**
77
//! 2. `xvc_paths_dirty` — [`gix::Repository::status`] instead of `git status --porcelain`.
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`]
@@ -15,6 +15,87 @@
1515
//! checkout orchestration; reimplementing it risks silently destroying uncommitted user work.
1616
//! It stays on [`super::subprocess::git_checkout_ref`], and so does the stash it needs.
1717
18+
use std::path::Path;
19+
20+
use crate::{Error, Result};
21+
22+
/// List the files Git tracks under `xvc_directory`, as paths relative to `xvc_directory`.
23+
///
24+
/// This replaces `git ls-files --full-name`, which Xvc used to shell out for. Index entries are
25+
/// raw bytes, so reading them directly avoids the quoting `ls-files` applies to its output:
26+
///
27+
/// - Non-ASCII paths. The subprocess version passed `-c core.quotepath=off` to stop Git
28+
/// octal-escaping them (`"\303\274n..."`); there is nothing to escape here, so the workaround
29+
/// is gone.
30+
/// - Paths containing control characters, such as a newline in a filename. Git C-quotes these
31+
/// *regardless* of `core.quotepath`, yielding the literal `"two\nlines.txt"` — quotes, escape
32+
/// and all. That never matches an [`crate::XvcPath`], so those files silently escaped the
33+
/// caller's filter. Index entries are unquoted, so they now match.
34+
/// - Non-UTF-8 paths. With `core.quotepath=off` Git emits the raw bytes, which the subprocess
35+
/// crate lossily replaces with U+FFFD. They are skipped here instead, since the caller compares
36+
/// against `String`s. Either way such a path goes unfiltered; skipping just avoids inventing a
37+
/// path that does not exist.
38+
///
39+
/// # Relative to what
40+
///
41+
/// Index paths are relative to the *repository* root, which is not necessarily `xvc_directory` —
42+
/// `xvc init` can run in a subdirectory of a Git repository. Callers compare these against
43+
/// [`crate::XvcPath`]s, which are relative to the Xvc root, so entries outside `xvc_directory`
44+
/// are dropped and the rest are re-based onto it.
45+
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()?)?;
60+
61+
let index = repo.index_or_empty().map_err(|e| Error::GixIndexError {
62+
cause: e.to_string(),
63+
})?;
64+
65+
let files = index
66+
.entries()
67+
.iter()
68+
// Conflicted paths appear once per stage. `git ls-files` prints them all; we keep only
69+
// stage 0, so a path is reported at most once.
70+
.filter(|entry| entry.stage() == gix::index::entry::Stage::Unconflicted)
71+
.filter_map(|entry| {
72+
let path = std::str::from_utf8(entry.path(&index)).ok()?;
73+
match prefix.as_deref() {
74+
None => Some(path.to_string()),
75+
Some(prefix) => path.strip_prefix(prefix).map(ToString::to_string),
76+
}
77+
})
78+
.collect();
79+
80+
Ok(files)
81+
}
82+
83+
/// The slash-separated path from `root` down to `dir`, with a trailing slash, or `None` when they
84+
/// are the same directory.
85+
///
86+
/// Index paths are slash-separated on every platform, so the components are joined with `/`
87+
/// rather than the host separator.
88+
fn subdirectory_prefix(root: &Path, dir: &Path) -> Result<Option<String>> {
89+
let relative = dir.strip_prefix(root)?;
90+
let joined = relative
91+
.components()
92+
.map(|component| component.as_os_str().to_string_lossy())
93+
.collect::<Vec<_>>()
94+
.join("/");
95+
96+
Ok((!joined.is_empty()).then(|| format!("{joined}/")))
97+
}
98+
1899
/// Compile-time guard for the `tree-editor` feature in `core/Cargo.toml`.
19100
///
20101
/// [`gix::Repository::edit_tree`] is gated behind it and is not enabled by any of `gix`'s default

core/src/util/git/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub mod paths;
2020
pub mod refs;
2121
pub mod subprocess;
2222

23+
pub use gix_backend::tracked_files;
2324
pub use ignore::{GitRoot, build_gitignore, inside_git};
2425
pub use paths::{GITIGNORE_PATHSPEC, XVCIGNORE_PATHSPEC, XvcGitPaths};
2526
pub use refs::{gix_list_branches, gix_list_references};

file/src/common/mod.rs

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ use serde::{Deserialize, Serialize};
2323
use xvc_core::{
2424
AbsolutePath, ContentDigest, DiffStore, Glob, HStore, HashAlgorithm, PathSync, RecheckMethod,
2525
Storable, TextOrBinary, XvcFileType, XvcMetadata, XvcOutputSender, XvcPath, XvcPathMetadataMap,
26-
XvcRoot, XvcStore, all_paths_and_metadata, apply_diff, error, get_absolute_git_command,
27-
get_git_tracked_files, info, persist,
26+
XvcRoot, XvcStore, all_paths_and_metadata, apply_diff, error, info, persist, tracked_files,
2827
types::xvcpath::XvcCachePath,
2928
util::{file::make_symlink, xvcignore::COMMON_IGNORE_PATTERNS},
3029
uwr, warn,
@@ -335,17 +334,9 @@ pub fn targets_from_disk(
335334
// Return false when the path is a git path
336335

337336
let git_files: HashSet<String> = if filter_git_paths {
338-
let git_command_str = xvc_root.config().git.command.clone();
339-
let git_command = get_absolute_git_command(&git_command_str)?;
340-
get_git_tracked_files(
341-
&git_command,
342-
xvc_root
343-
.absolute_path()
344-
.to_str()
345-
.expect("xvc_root must have a path"),
346-
)?
347-
.into_iter()
348-
.collect()
337+
tracked_files(xvc_root.absolute_path())?
338+
.into_iter()
339+
.collect()
349340
} else {
350341
HashSet::new()
351342
};

0 commit comments

Comments
 (0)