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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Commit diffs and untracked files follow the `diff` attribute of
`.gitattributes`, as `git diff` does: `-diff` and `binary` show as binary
with no line counts, and `diff` shows as text
([ADR 0033](https://github.com/ShortArrow/gitreant/blob/main/docs/adr/0033-diff-attribute.md)).

## [0.2.3] - 2026-10-09

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/ROADMAP.jp.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@
- **入れ子の worktree をドロワーのフィルタで見つけられるようにする** — 現状はトップレベル行だけが対象
- **切断されたネットワークドライブ上の worktree で一覧が待たされない** — 存在確認をタイムアウト付きにするか、一覧から外して遅延させる

## 差分表示(ADR 0029 の残課題)
## 差分表示(ADR 0029 / 0033 の残課題)

- **`.gitattributes` の `binary` / `-diff` を尊重する** — git はこうしたファイルをバイナリとして扱う(numstat では `- -`)が、gitreant はテキストとして行数を数え、差分を表示している(差分ペインは 0.2.0 からこの状態)
- **ファイル一覧でバイナリを区別する** — 一覧はバイナリのファイルを `+0 −0` と表示し、行数の変わらないテキストと見分けがつかない。git の numstat は `- -` と出す
- **ファイル末尾の改行の有無の変化を見せる** — 末尾の改行を消したり足したりすると、git の「\ No newline at end of file」の印が落ちるため、同じ内容の `-x` と `+x` が並ぶだけになる。さらに作業ツリー側ではこのファイルに「改行コードのみ」のラベルが付き、コミット側では付かない(`--ignore-cr-at-eol` はこの差を無視し、blob の比較は無視しない)。両者をそろえる
- **行末の種類を正確に呼ぶ** — 行末の単独の CR(旧 Mac の改行など、LF を伴わない CR)を現状は CRLF と呼んでいる。また `a\r\r\n` のような入力では、コピーしたテキストに CR が残ることがある
- **向きが混在する変換にも向きを示す** — 一部の行が LF → CRLF、別の行が CRLF → LF に変わったファイルには、ラベルと印は付くが差分ヘッダに向きが出ない
Expand Down
8 changes: 4 additions & 4 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ keyed off the remote URL's host.
list** — check existence with a timeout, or leave it out of the list and
resolve it later

## Diff display (follow-ups to ADR 0029)
## Diff display (follow-ups to ADR 0029 / 0033)

- **Respect `.gitattributes` `binary` / `-diff`** — git shows such files
as binary (`- -` in numstat); gitreant counts and diffs them as text (the
diff pane has done so since 0.2.0)
- **Tell binary files apart in the file list** — the list shows a binary
file as `+0 −0`, the same as text whose line count did not change; git's
numstat prints `- -`
- **Show the end-of-file newline change** — removing or adding the final
newline shows identical `-x` / `+x` lines, because git's
"\ No newline at end of file" marker is dropped. The working-tree side
Expand Down
29 changes: 29 additions & 0 deletions docs/adr/0033-diff-attribute.jp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# ADR 0033: バイナリかどうかは git diff と同じく diff 属性で決める

## Status

Accepted (2026-10-14)

## Context

git は、`.gitattributes` の `diff` 属性でファイルの差分の扱いを決める。`-diff` と、それを含む `binary` マクロはバイナリ、`diff` はテキスト、`diff=<driver>` は `diff.<driver>.binary` の設定に従う。属性が何も言わないときにだけ、先頭 8000 バイトに NUL があるかで判断する([ADR 0006](0006-file-diff-on-demand.jp.md))。

`git diff <commit> <commit>` が属性を読むのは、`$GIT_DIR/info/attributes`、作業ツリーの `.gitattributes`、グローバルとシステムの属性ファイル(`core.attributesFile` など)である。比べるコミットの中の `.gitattributes` も、index も読まない。そのため、作業ツリーから消したディレクトリの `.gitattributes` は、index に残っていても効かない。bare リポジトリでは、`info/attributes` とグローバル・システムのファイルだけになる。

0.2.0 のコミットの行数は gix の差分パイプライン(`diff_resource_cache_for_tree_diff`)を通していたので、`diff` 属性に従っていた。ただし属性は HEAD の index から読むので、作業ツリーで書き換えた `.gitattributes` は効かなかった。また、差分の本文は NUL の有無だけで判断していた。このパイプラインは ADR 0029 の行数の数え方と合わないので、0.2.1 で使うのをやめた。それ以降の 0.2.1〜0.2.3 は、コミットの行数も差分も、未追跡ファイルも、NUL の有無だけで判断していた。そのため、git がバイナリとして扱うファイル(numstat で `- -`)でも行数を数え、テキストの差分を表示していた。未コミットの変更のうち追跡しているファイルは git CLI を通すので、ずっと git と同じ扱いだった。

## Decision

- **差分がバイナリかどうかは、まず `diff` 属性で決め、属性が何も言わないときだけ NUL の有無で決める。** `-diff` と `binary` はバイナリ、`diff` はテキスト、`diff=<driver>` は `diff.<driver>.binary` に従い、その設定が無ければ NUL の有無で決める。
- **属性は `git diff <commit> <commit>` と同じ場所から読む。** index と、比べるコミットの中の `.gitattributes` は読まない。gix には空の index を渡す。
- **判断は `src/git/diff_attributes.rs` の 1 か所にまとめる。** コミットの行数と差分、未追跡ファイルの行数と差分は、どれもここを通す。

## Consequences

- 行数と差分の表示は、下に挙げる場合を除いて `git diff` と一致する。作業ツリーで `.gitattributes` を書き換えると、過去のコミットの表示も変わる。これも git と同じ振る舞いである。
- index を読み込まないので、大きなリポジトリでもリクエストごとの負担が増えない。
- `diff.<driver>.textconv` は実行しない。textconv を設定したドライバのファイルも、`diff.<driver>.binary` と NUL の有無で判断する。
- 未追跡ファイルのうち 4 MiB を超えるものは読まないので、`diff` 属性があってもバイナリとして扱う。
- リネームしたファイルは、新しいパスの属性だけで判断する。git は、元のパスと新しいパスのどちらかがバイナリならバイナリとして扱う。
- ファイル一覧はバイナリを `+0 −0` と表示する。git の `- -` のような区別は、まだ無い。
- 属性を読めないときは、NUL の有無だけで判断する。
63 changes: 63 additions & 0 deletions docs/adr/0033-diff-attribute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ADR 0033: Decide binary diffs by the diff attribute, as git diff does

## Status

Accepted (2026-10-14)

## Context

git decides how a file diffs by the `diff` attribute in `.gitattributes`.
`-diff`, and the `binary` macro that includes it, mean binary; `diff`
means text; `diff=<driver>` follows the `diff.<driver>.binary` setting.
Only when no attribute applies does git look for a NUL in the first 8000
bytes ([ADR 0006](0006-file-diff-on-demand.md)).

`git diff <commit> <commit>` reads attributes from `$GIT_DIR/info/attributes`,
the `.gitattributes` files in the working tree, and the global and system
attribute files (`core.attributesFile` and the like). It reads neither the
`.gitattributes` inside the compared commits nor the index, so a
`.gitattributes` in a directory removed from the working tree has no effect,
even while the index still holds it. A bare repository has only
`info/attributes` and the global and system files.

0.2.0 counted commit lines through gix's diff pipeline
(`diff_resource_cache_for_tree_diff`), so its counts followed the `diff`
attribute. That pipeline reads attributes from the HEAD index, so an edited
`.gitattributes` in the working tree had no effect, and the diff text was
judged by the NUL check alone. The pipeline does not fit the line counting
of ADR 0029, and 0.2.1 stopped using it. From 0.2.1 to 0.2.3, commit counts
and diffs and untracked files were judged by the NUL check alone. gitreant
counted lines and showed a text diff even for files git treats as binary
(`- -` in numstat). Tracked files among the uncommitted changes go through
the git CLI, so they have always matched git.

## Decision

- **Whether a diff is binary is decided by the `diff` attribute first, and
by the NUL check only when no attribute applies.** `-diff` and `binary`
are binary, `diff` is text, and `diff=<driver>` follows
`diff.<driver>.binary`, falling back to the NUL check when that is unset.
- **Attributes are read from the same places as
`git diff <commit> <commit>`.** Neither the index nor the
`.gitattributes` inside the compared commits is read; gix is handed an
empty index.
- **The decision lives in one place, `src/git/diff_attributes.rs`.** Commit
line counts and diffs, and untracked file line counts and diffs, all go
through it.

## Consequences

- Line counts and diffs match `git diff` except in the cases below. Editing
`.gitattributes` in the working tree changes how past commits show, just
as it does in git.
- The index is never loaded, so a large repository adds no cost per
request.
- `diff.<driver>.textconv` is not run. A file whose driver sets textconv is
judged by `diff.<driver>.binary` and the NUL check.
- An untracked file over 4 MiB is not read, so it is binary even with the
`diff` attribute.
- A renamed file is judged by the attributes of its new path only. git
treats it as binary when either the old or the new path is.
- The file list shows a binary file as `+0 −0`; there is no counterpart of
git's `- -` yet.
- When the attributes cannot be read, the NUL check decides alone.
1 change: 1 addition & 0 deletions docs/adr/README.jp.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ gitreant の設計判断の記録。各 ADR は決定時点で凍結され、変
| [0030](0030-release-safeguards.jp.md) | リリースはタグを検査し、CHANGELOGを正とし、公開は承認を経る | Accepted |
| [0031](0031-wording-only-changes.jp.md) | 片方の言語だけの言い回しの修正はコミットの印で日英同時更新の対象から外す | Accepted |
| [0032](0032-static-crt-on-windows.jp.md) | Windows 版は C ランタイムを静的リンクする | Accepted |
| [0033](0033-diff-attribute.jp.md) | バイナリかどうかは git diff と同じく diff 属性で決める | Accepted |
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ are canonical; the files below are their English translations.
| [0030](0030-release-safeguards.md) | Check the tag, take notes from the changelog, and approve publishing | Accepted |
| [0031](0031-wording-only-changes.md) | Exempt a one-language rewording from the bilingual check by a commit mark | Accepted |
| [0032](0032-static-crt-on-windows.md) | Link the C runtime statically on Windows | Accepted |
| [0033](0033-diff-attribute.md) | Decide binary diffs by the diff attribute, as git diff does | Accepted |
37 changes: 21 additions & 16 deletions src/git/detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

use std::path::Path;

use super::diff_attributes::DiffAttributes;

/// One changed file in a commit, with line counts against the first parent.
#[derive(Debug, Clone, PartialEq)]
pub struct FileChange {
Expand Down Expand Up @@ -87,7 +89,8 @@ pub struct FileDiff {
pub path: String,
/// "A" (added), "M" (modified) or "D" (deleted).
pub status: String,
/// True when either side looks binary; `text` is empty then.
/// The file diffs as binary (its `diff` attribute, else a NUL byte on
/// either side); `text` is empty then.
pub binary: bool,
/// Unified diff hunks ("@@ ..." headers with -/+/context lines).
pub text: String,
Expand All @@ -99,7 +102,8 @@ pub fn read_file_diff(path: &Path, id: &str, file: &str) -> Result<FileDiff, Str
let commit = find_commit(&repo, id)?;
let tree = commit.tree().map_err(|e| format!("tree: {e}"))?;
let parent_tree = first_parent_tree(&repo, &commit)?;
diff_blobs(&repo, &parent_tree, &tree, file)?
let mut attributes = DiffAttributes::of(&repo);
diff_blobs(&mut attributes, &repo, &parent_tree, &tree, file)?
.ok_or_else(|| format!("{file} is not part of commit {id}"))
}

Expand All @@ -109,11 +113,12 @@ pub fn read_commit_diff(path: &Path, id: &str) -> Result<Vec<FileDiff>, String>
let commit = find_commit(&repo, id)?;
let tree = commit.tree().map_err(|e| format!("tree: {e}"))?;
let parent_tree = first_parent_tree(&repo, &commit)?;
let mut attributes = DiffAttributes::of(&repo);

changed_files(&repo, &commit)?
.into_iter()
.map(|file| {
diff_blobs(&repo, &parent_tree, &tree, &file.path)?
diff_blobs(&mut attributes, &repo, &parent_tree, &tree, &file.path)?
.ok_or_else(|| format!("{} vanished while diffing", file.path))
})
.collect()
Expand All @@ -132,6 +137,7 @@ fn find_commit<'repo>(

/// Diff the blobs at `file` between two trees; `None` when neither side has it.
fn diff_blobs(
attributes: &mut DiffAttributes,
repo: &gix::Repository,
parent_tree: &gix::Tree<'_>,
tree: &gix::Tree<'_>,
Expand All @@ -147,7 +153,7 @@ fn diff_blobs(
};
let old = old.unwrap_or_default();
let new = new.unwrap_or_default();
let binary = looks_binary(&old) || looks_binary(&new);
let binary = attributes.binary(file, &old, &new);
Ok(Some(FileDiff {
path: file.to_string(),
status: status.to_string(),
Expand Down Expand Up @@ -199,11 +205,6 @@ fn blob_at(
Ok(Some(blob.data.clone()))
}

/// Shared with the uncommitted-changes reader: same rule for both sides.
pub(super) fn looks_binary(data: &[u8]) -> bool {
data.iter().take(8000).any(|&b| b == 0)
}

/// The unified hunks between two blobs, in the `git diff -u` text form.
pub(super) fn unified(old: &[u8], new: &[u8]) -> String {
use gix::diff::blob::{
Expand Down Expand Up @@ -264,13 +265,14 @@ pub(super) fn unified(old: &[u8], new: &[u8]) -> String {
fn changed_files(repo: &gix::Repository, commit: &gix::Commit) -> Result<Vec<FileChange>, String> {
let tree = commit.tree().map_err(|e| format!("tree: {e}"))?;
let parent_tree = first_parent_tree(repo, commit)?;
let mut attributes = DiffAttributes::of(repo);

let mut files = Vec::new();
parent_tree
.changes()
.map_err(|e| format!("diff: {e}"))?
.for_each_to_obtain_tree(&tree, |change| {
if let Some(file) = file_change(repo, change) {
if let Some(file) = file_change(&mut attributes, repo, change) {
files.push(file);
}
Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Continue(()))
Expand All @@ -283,6 +285,7 @@ fn changed_files(repo: &gix::Repository, commit: &gix::Commit) -> Result<Vec<Fil
/// as the diff pane shows them: a line whose ending changed counts as
/// changed, as it does for `git diff`.
fn file_change(
attributes: &mut DiffAttributes,
repo: &gix::Repository,
change: gix::object::tree::diff::Change<'_, '_, '_>,
) -> Option<FileChange> {
Expand All @@ -306,23 +309,25 @@ fn file_change(
("R", blob(source_id.detach()), blob(id.detach()))
}
};
let (additions, deletions) = line_counts(&old, &new);
let path = change.location().to_string();
let (additions, deletions) = if attributes.binary(&path, &old, &new) {
(0, 0)
} else {
line_counts(&old, &new)
};
Some(FileChange {
path: change.location().to_string(),
path,
status: status.to_string(),
additions,
deletions,
eol_only: status == "M" && differ_only_in_line_endings(&old, &new),
})
}

/// Added and removed lines between two blobs; none for binary content.
/// Added and removed lines between two text blobs.
fn line_counts(old: &[u8], new: &[u8]) -> (usize, usize) {
use gix::diff::blob::{Algorithm, Diff, InternedInput};

if looks_binary(old) || looks_binary(new) {
return (0, 0);
}
let old = String::from_utf8_lossy(old);
let new = String::from_utf8_lossy(new);
let input = InternedInput::new(old.as_ref(), new.as_ref());
Expand Down
74 changes: 74 additions & 0 deletions src/git/diff_attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! Whether a file diffs as binary, decided the way `git diff` decides it:
//! the `diff` attribute first (gitattributes(5)), then a NUL byte in the
//! content (ADR 0033).

use gix::attrs::StateRef;
use gix::worktree::stack::state::attributes::Source;

/// The `diff` attribute of the paths in one repository, from the places
/// `git diff <commit> <commit>` reads: `$GIT_DIR/info/attributes`, the
/// `.gitattributes` files in the working tree, and the global and system
/// attribute files. Neither the index nor the commits being diffed are read,
/// and a bare repository has only the first and last.
pub(super) struct DiffAttributes<'repo> {
repo: &'repo gix::Repository,
/// `None` when the attributes cannot be read; every path is then judged
/// by its content alone.
stack: Option<gix::AttributeStack<'repo>>,
outcome: gix::attrs::search::Outcome,
}

impl<'repo> DiffAttributes<'repo> {
/// Hands gix an empty index, so it has no `.gitattributes` to map from
/// the index; this also spares loading an index, which takes hundreds of
/// milliseconds in a large repository.
pub(super) fn of(repo: &'repo gix::Repository) -> Self {
let no_index = gix::index::State::new(repo.object_hash());
let source = if repo.workdir().is_some() {
Source::WorktreeThenIdMapping
} else {
Source::IdMapping
};
let stack = repo.attributes_only(&no_index, source).ok();
let outcome = stack
.as_ref()
.map(|stack| stack.selected_attribute_matches(["diff"]))
.unwrap_or_default();
Self {
repo,
stack,
outcome,
}
}

/// `path` diffs as binary between the contents `old` and `new`.
pub(super) fn binary(&mut self, path: &str, old: &[u8], new: &[u8]) -> bool {
self.declared_binary(path)
.unwrap_or_else(|| looks_binary(old) || looks_binary(new))
}

/// What the `diff` attribute says: `-diff` (and the `binary` macro) is
/// binary, `diff` is text, and `diff=<driver>` is what
/// `diff.<driver>.binary` says. `None` leaves it to the content.
fn declared_binary(&mut self, path: &str) -> Option<bool> {
self.stack
.as_mut()?
.at_entry(path, None)
.ok()?
.matching_attributes(&mut self.outcome);
match self.outcome.iter_selected().next()?.assignment.state {
StateRef::Unset => Some(true),
StateRef::Set => Some(false),
StateRef::Value(driver) => self
.repo
.config_snapshot()
.boolean(format!("diff.{}.binary", driver.as_bstr()).as_str()),
StateRef::Unspecified => None,
}
}
}

/// A NUL byte within the first 8000 bytes, the same rule git uses (ADR 0006).
fn looks_binary(data: &[u8]) -> bool {
data.iter().take(8000).any(|&b| b == 0)
}
1 change: 1 addition & 0 deletions src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
mod avatar;
mod branch;
mod detail;
mod diff_attributes;
mod fetch;
mod prs;
mod repo;
Expand Down
Loading
Loading