Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/diff_load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ impl App {
if path_filter.is_none() {
match vcs.get_change_status() {
Ok(status) => {
if !crate::tuicrignore::has_ignore_rules(repo_root) {
if !crate::tuicrignore::has_tuicrignore(repo_root) {
return Ok(status);
}
return Self::verify_status_against_ignore(
Expand Down
4 changes: 4 additions & 0 deletions src/tuicrignore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub fn has_ignore_rules(repo_root: &Path) -> bool {
repo_root.join(".gitignore").is_file() || repo_root.join(".tuicrignore").is_file()
}

pub fn has_tuicrignore(repo_root: &Path) -> bool {
repo_root.join(".tuicrignore").is_file()
}

fn load_matcher(repo_root: &Path) -> Option<ignore::gitignore::Gitignore> {
let gitignore_file = repo_root.join(".gitignore");
let tuicrignore_file = repo_root.join(".tuicrignore");
Expand Down
81 changes: 78 additions & 3 deletions src/vcs/git/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,12 @@ impl VcsBackend for GitCliBackend {
}

fn get_change_status(&self) -> Result<VcsChangeStatus> {
// Tracked changes have cheap exact probes. Untracked files require a
// working-tree scan, so only pay that cost when tracked unstaged changes
// have not already proven the "unstaged" row should be shown.
if self.repo_mode == GitRepoMode::Standard {
return get_cli_change_status(&self.root_path);
}

// Keep sparse probes pathspec-scoped. Plain `git status` reports
// out-of-cone untracked files and would show an empty Unstaged row.
let staged = has_diff_changes(&self.root_path, &["diff", "--quiet", "--cached", "--"])?;
let tracked_unstaged = has_diff_changes(&self.root_path, &["diff", "--quiet", "--"])?;
let untracked_pathspecs = if tracked_unstaged {
Expand Down Expand Up @@ -455,6 +458,43 @@ fn parse_git_runtime_flags(output: &str) -> (bool, bool) {
(untracked_cache.unwrap_or(many_files), fsmonitor)
}

fn get_cli_change_status(workdir: &Path) -> Result<VcsChangeStatus> {
let output = Command::new("git")
.current_dir(workdir)
.args(["status", "--porcelain=v1", "-z", "--untracked-files=normal"])

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason why we have to force v1? seems like Claude thinks this hsould work with git v2 clients as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v1 is the output format. it is not the git client version. git 2 clients support this format.

we only need the two status columns from v1. the parser reads those first two bytes. v2 has a different and richer record format that we do not need here.

--porcelain defaults to v1 today. writing =v1 makes the parser contract explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, git status 's porcelain flag defaults to v1. See https://git-scm.com/docs/git-status#Documentation/git-status.txt---porcelainversion

.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TuicrError::VcsCommand(format!("Failed to run git: {e}")))?;

if !output.status.success() {
return Err(TuicrError::VcsCommand(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}

Ok(parse_porcelain_status(&output.stdout))
}

/// Parse NUL-delimited porcelain v1 records into staged and unstaged flags.
/// The first two bytes are the index and worktree states. Rename and copy
/// records include a second pathname record, which the parser skips.
fn parse_porcelain_status(output: &[u8]) -> VcsChangeStatus {
let mut status = VcsChangeStatus::default();
let mut entries = output.split(|byte| *byte == 0);
while let Some(entry) = entries.next() {
if entry.len() < 2 {
continue;
}
status.staged |= !matches!(entry[0], b' ' | b'?');
status.unstaged |= entry[1] != b' ';
if matches!(entry[0], b'R' | b'C') || matches!(entry[1], b'R' | b'C') {
entries.next();
}
}
status
}

fn has_diff_changes(workdir: &Path, args: &[&str]) -> Result<bool> {
let output = Command::new("git")
.current_dir(workdir)
Expand Down Expand Up @@ -1185,6 +1225,18 @@ mod tests {
);
}

#[test]
fn parses_porcelain_status_sides() {
assert_eq!(
parse_porcelain_status(b"M staged\0 M unstaged\0?? untracked\0"),
VcsChangeStatus {
staged: true,
unstaged: true,
}
);
assert_eq!(parse_porcelain_status(b""), VcsChangeStatus::default());
}

fn write_file(workdir: &Path, path: &str, content: &str) {
let full_path = workdir.join(path);
fs::create_dir_all(full_path.parent().expect("test path should have parent"))
Expand Down Expand Up @@ -1478,6 +1530,29 @@ mod tests {
);
}

#[test]
fn detects_untracked_files_when_git_status_hides_them() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let workdir = temp_dir.path();
git(workdir, &["init"]);
git(workdir, &["config", "status.showUntrackedFiles", "no"]);
write_file(workdir, "untracked.txt", "untracked\n");
let backend = GitCliBackend::discover_from(workdir, DiffWhitespaceMode::Normal)
.expect("failed to discover cli backend");

let status = backend
.get_change_status()
.expect("failed to get change status");

assert_eq!(
status,
VcsChangeStatus {
staged: false,
unstaged: true,
}
);
}

#[test]
fn ignores_untracked_files_outside_sparse_cone_in_change_status() {
let (temp_dir, backend, _ids) = setup_sparse_index_repo();
Expand Down
Loading