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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,8 +313,8 @@ Diff truncation note:
- Diff-producing methods use `run_git_raw`, preserving trailing spaces on added lines. The trimmed `run_git` wrapper remains for hashes, counts, and other scalar output. The whitespace regression includes a last added line ending in a space, so reintroducing `trim()` into the diff path fails the test.
- Diff reads preserve whitespace changes instead of passing `-w`: Python/YAML indentation can change behavior, and a whitespace-only staged diff must remain committable. All five operation modes are covered by an integration regression, including mixed whitespace/content changes. `git log` and `git show` also pass `--no-show-signature` to keep verification lines and signer identities out of prompts under `log.showSignature=true`; a real SSH-signed commit exercises all three affected APIs.
- **The `\r` of CRLF lines is kept.** `str::lines()` strips a `\r` before `\n`, and `join("\n")` never put it back, so an LF → CRLF conversion reached the AI as identical `-line` / `+line` pairs — the same "whitespace change made invisible" failure the two bullets above exist to prevent. `filter_ignored_files` and `summarize_diff` split with `split_diff_lines()` instead, which breaks on `\n` only and otherwise reproduces `lines()`'s shape (a single trailing newline does not produce an empty last element), pinned by `test_split_diff_lines_matches_str_lines_without_crlf`. Regression tests: `test_split_diff_lines_keeps_carriage_return_before_newline`, `test_summarize_diff_keeps_carriage_returns_of_crlf_lines`, `test_filter_ignored_files_keeps_carriage_returns_of_crlf_lines`.
- `truncate_diff()` first compares `diff.len()` (byte length) against `MAX_DIFF_CHARS`. Since UTF-8 always uses at least one byte per character, byte length within the limit guarantees character count is also within the limit, so the common ASCII path returns immediately without scanning the entire diff.
- When byte length exceeds the limit, `char_indices().nth(MAX_DIFF_CHARS)` finds the cutoff after scanning at most `MAX_DIFF_CHARS+1` characters, avoiding the previous full-diff `chars().count()` walk on multi-MB inputs.
- `truncate_diff_with_source()` preserves filtered diffs within `MAX_DIFF_CHARS` unchanged, using a byte-length fast path and then a bounded character scan. Larger patches keep a complete `[File]` overview with counts from raw hunks, outside a 10,000-character excerpt budget shared fairly across files. Ignore, lockfile and binary bodies remain omitted. Combined diffs label counts as displayed rows across parent columns. Non-patch input retains the old line-boundary truncation fallback; the test-only `truncate_diff()` wrapper exercises both paths.
- `--generate-for` combines raw commit diffs before filtering and truncating once, so later commits retain their overview. `AiService::diff_file_entries()` reads only overview entries when present, avoiding duplicate counts from excerpt headers in developer logs and Apple's file-list compaction.

## Testing

Expand Down
8 changes: 7 additions & 1 deletion docs/diff-processing.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,18 @@ flowchart LR
- lock ファイルは本文を送らず、`[Lockfile] modified: Cargo.lock` のようにパスと変更種別(追加・変更・削除・rename・copy)だけを送信。rename・copy は変更前後のパスを残し、パスに含まれる改行などの制御文字はエスケープ
- スペースや非 ASCII 文字を含むパスの、クォートされた diff ヘッダーも正しく解析
- `.git-sc-ignore` パターンに一致するファイルは、パスと本文を省略した旨だけを送信
- 10,000 文字で切り詰め
- 10,000 文字以内の差分は従来の形式を維持。それを超える場合は、全ファイルの一覧と追加・削除行数を残し、本文の 10,000 文字をファイルごとに配分。小さいファイルの余りは大きいファイルに再配分

lock ファイルの要約は設定不要で、通常のコミット・amend・squash・reword・`--generate-for` に適用します。対象は任意のディレクトリにある `*.lock`、`*.lockb`、`*.lockfile`、`package-lock.json`、`npm-shrinkwrap.json`、`pnpm-lock.yaml`、`go.sum`、`Package.resolved` です。`.lock`・`.lockb`・`.lockfile` というファイル名自体も対象です。`Cargo.lock`、`yarn.lock`、`Gemfile.lock`、`composer.lock`、`uv.lock`、`bun.lock` などは拡張子で判定されます。

要約は文字数制限の適用前に行うため、大きな lock ファイルの本文で他のファイルの差分が切り詰められるのを防げます。lock ファイルだけの変更でも生成できます。`.git-sc-ignore` に指定した場合は、lock ファイルやバイナリファイルもパスと本文を省略した旨だけを残します。

大きな差分では、全ファイルの一覧と省略通知を本文予算の別枠で保持します。一覧だけで 10,000 文字を超えても削りません。行数は本文の除外前に元の差分から集計し、バイナリは行数の代わりに `binary` と表示します。rename・copy は変更前後のパスを残します。マージの combined diff は、親ごとの列を通じて表示された追加・削除行を一度ずつ数え、`combined rows` と明記します。

プロンプトには、本文を省略または短縮したファイル数も示します。この件数には除外対象・lock ファイル・バイナリも含みます。本文は可能な限り完全な行で区切り、CRLF の内容も残します。`--generate-for` は元の差分を結合してから一度だけ処理するため、後続コミットの全ファイルも一覧に残ります。異なるコミットで同じパスが変わった場合は、それぞれの変更を別の行に表示します。開発ログのファイル数は一覧から集計し、本文のヘッダーを重複して数えません。

本文を短縮したファイルの一覧行には `[body truncated]` を付けます。Apple Intelligence は、端末内モデルの小さいコンテキスト枠に収めるため、さらに一覧と本文を縮めることがあります。この追加処理では、一覧から省いたファイル数を通知します。

## セキュリティメモ

- AI へのプロンプトには、ステージ済みの差分の内容が含まれることがあります。opencode などのプロバイダー向けに一時的なプロンプトファイルが必要な場合や、Codex の最終応答ファイルを使う場合は、Unix/macOS では group/other の権限を付けずに作成し、使用後に自動で削除します。
Expand Down
6 changes: 5 additions & 1 deletion docs/diff-processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ flowchart LR
- Lockfile contents replaced with a path and change kind (added, modified, deleted, renamed, or copied), e.g. `[Lockfile] modified: Cargo.lock`. Renames and copies retain both paths; control characters such as newlines in paths are escaped.
- Quoted diff headers with spaces or non-ASCII file paths are handled correctly
- Files matching `.git-sc-ignore` patterns retain only their paths, with a notice that the contents were omitted
- Truncated at 10,000 characters
- Diffs within 10,000 characters keep their existing format. Larger diffs retain a complete file overview with added/deleted line counts, then share a 10,000-character body budget across files. Small files return their unused share to larger files.

Lockfile summaries need no configuration and apply to normal commits, amend, squash, reword, and `--generate-for`. Matching filenames in any directory are `*.lock`, `*.lockb`, `*.lockfile`, `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `go.sum`, and `Package.resolved`. The literal filenames `.lock`, `.lockb`, and `.lockfile` also match. The suffix rules cover files such as `Cargo.lock`, `yarn.lock`, `Gemfile.lock`, `composer.lock`, `uv.lock`, and `bun.lock`.

Summaries are produced before the character limit is applied, so large lockfile contents do not crowd out other changes. Lockfile-only changes can still generate a message. Files matching `.git-sc-ignore` keep only their paths and the omission notice, including lockfiles and binary files.

For larger diffs, the complete overview and omission notices are outside the body budget: even an overview longer than 10,000 characters is retained. Counts come from the original diff before contents are omitted; binary files show `binary` instead of line counts. Renames and copies show both paths. Combined merge diffs label their counts `combined rows`, counting each displayed added/deleted row once across parent columns. The prompt states how many file bodies were omitted or shortened, including ignored files, lockfiles, and binaries. Excerpts preserve complete lines and CRLF content where possible. `--generate-for` combines the original diffs before processing, so later commits keep their full overview too. Multiple changes to the same path in different commits appear separately. Developer logs count the overview entries without counting excerpt headers again.

Overview entries mark shortened excerpts with `[body truncated]`. Apple Intelligence may further shorten the overview and excerpts to fit its smaller on-device context window; that separate pass reports the number of unlisted files.

## Security Notes

- AI prompts may contain staged diff content. When git-sc needs a temporary prompt file for providers such as opencode, or a final-output file for Codex, it creates the file with no group/other permissions on Unix/macOS and removes it automatically after use.
Expand Down
18 changes: 17 additions & 1 deletion src/ai/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,11 @@ impl AiService {

/// ログ集計と Apple の縮約で共用する、変更ファイルのヘッダー・要約行。
pub(super) fn diff_file_entries(diff: &str) -> impl Iterator<Item = &str> {
diff.lines().filter(|line| {
let has_overview = diff.starts_with("[Complete change overview]\n");
diff.lines().filter(move |line| {
if has_overview {
return line.starts_with("[File] ");
}
line.starts_with("diff --git ")
|| line.starts_with("diff --cc ")
|| line.starts_with("diff --combined ")
Expand Down Expand Up @@ -3916,6 +3920,18 @@ mod tests {
assert_eq!(AiService::count_diff_blocks(""), 0);
}

#[test]
fn test_count_diff_blocks_prefers_complete_overview_to_partial_bodies() {
let diff = "[Complete change overview]\n[File] first.txt | +20 -10\n\
[File] last.txt | +1 -0\n\ndiff --git a/first.txt b/first.txt\n\
@@ -1 +1 @@\n-old\n+new\n+[File] fake.txt | +1 -0";
assert_eq!(AiService::count_diff_blocks(diff), 2);
assert_eq!(
AiService::diff_file_entries(diff).collect::<Vec<_>>().len(),
2
);
}

/// `-p` によるプロバイダー明示指定は ai-usage ゲートの判定を引き継がない。
///
/// ゲートは「設定に書かれたチェーンの各ステップ」に対する判定であり、
Expand Down
19 changes: 2 additions & 17 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1293,28 +1293,13 @@ impl App {
return Err(AppError::InvalidCommitHash("(empty)".to_string()));
}

// 各コミットのdiffを取得して結合
let mut combined_diff = String::new();
for hash in hashes {
let diff = self.git.get_commit_diff_by_hash(hash)?;
if !diff.trim().is_empty() {
if !combined_diff.is_empty() {
combined_diff.push('\n');
}
combined_diff.push_str(&diff);
}
}
// raw diff を結合してから一度だけフィルタ・圧縮し、後続コミットの一覧も残す。
let combined_diff = self.git.get_commit_diffs_by_hashes(hashes)?;

if combined_diff.trim().is_empty() {
return Err(AppError::NoChanges);
}

// 各コミット単位では切り詰め済みだが、複数コミットを結合した総量は
// MAX_DIFF_CHARS を超えうる。他の diff 生成経路(staged/amend/squash)は
// すべて一度の apply_all_filters で上限が効くため、generate-for でも
// 結合後に再度切り詰めて同じ上限契約を守る。
let combined_diff = GitService::truncate_diff(&combined_diff);

// AI CLIがインストールされているか確認
self.ai.verify_installation()?;

Expand Down
Loading
Loading