From 59ddd5cf3ac1c3ad609647ad4ad0d5cdb0d81de6 Mon Sep 17 00:00:00 2001 From: owa <20108042+owayo@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:06:09 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A4=A7=E3=81=8D=E3=81=AA=E5=B7=AE?= =?UTF-8?q?=E5=88=86=E3=81=A7=E3=82=82=E5=85=A8=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E5=A4=89=E6=9B=B4=E6=A6=82=E8=A6=81=E3=82=92?= =?UTF-8?q?AI=E3=81=AB=E6=B8=A1=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 差分の先頭で文字数の上限を使い切ると、後続ファイルがプロンプトから消えていた。 全ファイルの名前と追加・削除行数を保持し、本文をファイルごとに配分する。 省略・短縮したファイル数を示し、小さな差分の出力と本文の除外設定を維持する。 複数コミットの生成では結合後に一度だけ処理し、生成ログも一覧から件数を取得する。 --- AGENTS.md | 4 +- docs/diff-processing.ja.md | 8 +- docs/diff-processing.md | 6 +- src/ai/service.rs | 18 +- src/app.rs | 19 +-- src/git/service.rs | 330 +++++++++++++++++++++++++++++++++++-- tests/cli_integration.rs | 147 +++++++++++++++++ 7 files changed, 499 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86be7f1..ffa93e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/diff-processing.ja.md b/docs/diff-processing.ja.md index 8c50e95..ba9bb28 100644 --- a/docs/diff-processing.ja.md +++ b/docs/diff-processing.ja.md @@ -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 の権限を付けずに作成し、使用後に自動で削除します。 diff --git a/docs/diff-processing.md b/docs/diff-processing.md index 444bfc6..51cc0e4 100644 --- a/docs/diff-processing.md +++ b/docs/diff-processing.md @@ -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. diff --git a/src/ai/service.rs b/src/ai/service.rs index 534bb84..2a2e605 100644 --- a/src/ai/service.rs +++ b/src/ai/service.rs @@ -787,7 +787,11 @@ impl AiService { /// ログ集計と Apple の縮約で共用する、変更ファイルのヘッダー・要約行。 pub(super) fn diff_file_entries(diff: &str) -> impl Iterator { - 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 ") @@ -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::>().len(), + 2 + ); + } + /// `-p` によるプロバイダー明示指定は ai-usage ゲートの判定を引き継がない。 /// /// ゲートは「設定に書かれたチェーンの各ステップ」に対する判定であり、 diff --git a/src/app.rs b/src/app.rs index 4d21864..b97cd61 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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()?; diff --git a/src/git/service.rs b/src/git/service.rs index dba4f7f..8848fde 100644 --- a/src/git/service.rs +++ b/src/git/service.rs @@ -14,6 +14,12 @@ const MAX_DIFF_CHARS: usize = 10000; /// 除外対象はパスだけを残し、本文を渡していないことを AI に知らせる。 const IGNORED_CONTENT_NOTICE: &str = "[content omitted by .git-sc-ignore]"; +struct DiffFileExcerpt { + overview: String, + content: String, + body_omitted: bool, +} + /// diff の出力形式をユーザーの Git 設定から切り離すための引数 /// /// `.git-sc-ignore` の除外は `diff --git a/... b/...` 行からパスを読んで判定する。 @@ -378,8 +384,7 @@ impl GitService { fn extract_combined_diff_path(header: &str) -> Option { let rest = header .strip_prefix("diff --cc ") - .or_else(|| header.strip_prefix("diff --combined "))? - .trim(); + .or_else(|| header.strip_prefix("diff --combined "))?; if rest.is_empty() { return None; } @@ -501,7 +506,7 @@ impl GitService { return Some((path.clone(), path)); } - let rest = header.strip_prefix("diff --git ")?.trim(); + let rest = header.strip_prefix("diff --git ")?; if !rest.contains('"') { // 同一パスのスペース含み変更を先に中央分割で処理する。 @@ -523,7 +528,7 @@ impl GitService { if !rest.starts_with('"') && let Some(quote_pos) = rest.find('"') { - let before_part = rest[..quote_pos].trim_end(); + let before_part = rest[..quote_pos].strip_suffix(' ')?; let (after_path, _) = Self::decode_quoted_diff_path_with_rest(&rest[quote_pos + 1..])?; return Some(( before_part.strip_prefix("a/")?.to_string(), @@ -533,7 +538,7 @@ impl GitService { // 先頭がクォートの場合はクォート解析、そうでなければ空白分割 let (before_path, rest_after) = Self::take_diff_header_path(rest)?; - let rest_after = rest_after.trim_start(); + let rest_after = rest_after.strip_prefix(' ')?; // 残り側がクォートなら再度クォート解析、そうでなければ末尾までを // そのまま after_path として扱う(`"a/旧.txt" b/new name.txt` のような @@ -697,7 +702,13 @@ impl GitService { } /// diffを最大文字数に切り詰める + #[cfg(test)] pub fn truncate_diff(diff: &str) -> String { + Self::truncate_diff_with_source(diff, diff, None) + } + + /// 上限以内の差分はそのまま。超過した場合は raw diff の全ファイル一覧を残す。 + fn truncate_diff_with_source(diff: &str, source: &str, ignore: Option<&Gitignore>) -> String { // バイト長 <= MAX_DIFF_CHARS なら必ず文字数も収まる (UTF-8 は 1 文字 >= 1 バイト) // 大半の diff はこの早期 return で全文走査を回避する if diff.len() <= MAX_DIFF_CHARS { @@ -711,6 +722,10 @@ impl GitService { None => return diff.to_string(), }; + if let Some(excerpts) = Self::diff_file_excerpts(source, ignore) { + return Self::render_diff_excerpts(excerpts); + } + let truncated = &diff[..cutoff]; // 最後の完全な行まで切り詰める(中途半端な行を避ける) @@ -724,6 +739,133 @@ impl GitService { ) } + /// 行数は本文を除外・要約する前に数える。hunk 外の --- / +++ は数えない。 + fn diff_block_overview(block: &[&str]) -> String { + let paths = Self::ignore_candidate_paths(block); + let path = match paths.as_slice() { + [.., before, after] if before != after => format!( + "{} -> {}", + Self::summary_path(before), + Self::summary_path(after) + ), + [.., path] => Self::summary_path(path), + [] => Self::summary_path(block.first().copied().unwrap_or("unknown")), + }; + if block + .iter() + .any(|line| line.starts_with("Binary files ") || *line == "GIT binary patch") + { + return format!("[File] {path} | binary"); + } + + let mut added = 0usize; + let mut deleted = 0usize; + let mut columns = 0usize; + let mut combined = false; + for line in block { + let at_count = line.bytes().take_while(|byte| *byte == b'@').count(); + if at_count >= 2 && line.as_bytes().get(at_count) == Some(&b' ') { + columns = if at_count == 2 { 1 } else { at_count - 1 }; + combined |= columns > 1; + continue; + } + if columns == 0 || line.len() < columns { + continue; + } + let signs = &line.as_bytes()[..columns]; + // combined diff の親別列は行を重複計数せず、表示された変更行として数える。 + if signs.iter().all(|sign| matches!(sign, b' ' | b'+' | b'-')) { + added += usize::from(signs.contains(&b'+')); + deleted += usize::from(signs.contains(&b'-')); + } + } + let qualifier = if combined { " (combined rows)" } else { "" }; + format!("[File] {path} | +{added} -{deleted}{qualifier}") + } + + fn diff_file_excerpts(diff: &str, ignore: Option<&Gitignore>) -> Option> { + let lines = Self::split_diff_lines(diff); + let mut excerpts = Vec::new(); + let mut index = 0; + while index < lines.len() { + if !Self::is_diff_block_start(lines[index]) { + index += 1; + continue; + } + let start = index; + index += 1; + while index < lines.len() && !Self::is_diff_block_start(lines[index]) { + index += 1; + } + let block = &lines[start..index]; + let raw = block.join("\n"); + let filtered = match ignore { + Some(ignore) => Self::filter_ignored_files(&raw, ignore), + None => raw.clone(), + }; + let content = Self::summarize_diff(&filtered); + let mut overview = Self::diff_block_overview(block); + if filtered != raw { + overview.push(' '); + overview.push_str(IGNORED_CONTENT_NOTICE); + } else if content != filtered { + overview.push_str(" [lockfile/binary content omitted]"); + } + excerpts.push(DiffFileExcerpt { + overview, + body_omitted: content != raw, + content, + }); + } + (!excerpts.is_empty()).then_some(excerpts) + } + + /// 小さいファイルの余りを再配分し、先頭の大きなファイルによる予算の独占を防ぐ。 + fn render_diff_excerpts(excerpts: Vec) -> String { + let mut sizes = excerpts + .iter() + .enumerate() + .map(|(index, excerpt)| (excerpt.content.chars().count(), index)) + .collect::>(); + sizes.sort_unstable(); + let mut allocations = vec![0; excerpts.len()]; + // 本文を連結する改行も本文予算に含める。一覧と通知はこの予算の別枠。 + let mut remaining = MAX_DIFF_CHARS.saturating_sub(excerpts.len().saturating_sub(1)); + for (position, (size, index)) in sizes.iter().enumerate() { + let share = remaining / (sizes.len() - position); + allocations[*index] = (*size).min(share); + remaining -= allocations[*index]; + } + let mut overview = String::from("[Complete change overview]\n"); + let mut bodies = Vec::new(); + let mut omitted = 0usize; + for (excerpt, allocation) in excerpts.iter().zip(allocations) { + let body = match excerpt.content.char_indices().nth(allocation) { + Some((cutoff, _)) => { + let prefix = &excerpt.content[..cutoff]; + prefix.rfind('\n').map_or("", |end| &prefix[..end]) + } + None => &excerpt.content, + }; + overview.push_str(&excerpt.overview); + if body.len() < excerpt.content.len() { + overview.push_str(" [body truncated]"); + } + overview.push('\n'); + omitted += usize::from(excerpt.body_omitted || body.len() < excerpt.content.len()); + if !body.is_empty() { + bodies.push(body); + } + } + format!( + "{overview}\n... (diff truncated: exceeded {MAX_DIFF_CHARS} characters)\n\ + [{omitted} of {} file bodies omitted or truncated; complete file list above; \ + body budget {MAX_DIFF_CHARS} characters]\n\n{}", + excerpts.len(), + bodies.join("\n") + ) + } + /// diffに対して全てのフィルタリングを適用 /// /// `.git-sc-ignore` が存在するのに適用できない場合はエラーを返す。 @@ -734,8 +876,9 @@ impl GitService { // バイナリフィルタより先に実行する必要がある。 // summarize_diff は diff --git ヘッダーをサマリー行に変換するため、 // 先に実行するとignoreパターンがバイナリファイルに適用されなくなる。 - let filtered = if let Some(ignore) = self.load_ignore_patterns()? { - Self::filter_ignored_files(diff, &ignore) + let ignore = self.load_ignore_patterns()?; + let filtered = if let Some(ignore) = &ignore { + Self::filter_ignored_files(diff, ignore) } else { diff.to_string() }; @@ -744,7 +887,11 @@ impl GitService { let filtered = Self::summarize_diff(&filtered); // 3. 文字数制限を適用 - Ok(Self::truncate_diff(&filtered)) + Ok(Self::truncate_diff_with_source( + &filtered, + diff, + ignore.as_ref(), + )) } /// Git のパスはプラットフォームによらず `/` 区切り。 @@ -766,7 +913,7 @@ impl GitService { /// デコード済みパスを一行で表示する。制御文字で要約行が分裂するのを防ぐ。 fn summary_path(path: &str) -> String { - if path.chars().any(char::is_control) || path.contains(['"', '\\']) { + if path.trim() != path || path.chars().any(char::is_control) || path.contains(['"', '\\']) { format!("{path:?}") } else { path.to_string() @@ -1320,6 +1467,25 @@ impl GitService { /// 指定されたコミットハッシュの差分を取得 pub fn get_commit_diff_by_hash(&self, hash: &str) -> Result { + self.apply_all_filters(&self.get_raw_commit_diff_by_hash(hash)?) + } + + /// 各コミットの raw diff を結合してから一度だけ要約・予算配分する。 + pub fn get_commit_diffs_by_hashes(&self, hashes: &[String]) -> Result { + let mut combined = String::new(); + for hash in hashes { + let raw = self.get_raw_commit_diff_by_hash(hash)?; + if !raw.trim().is_empty() { + if !combined.is_empty() && !combined.ends_with('\n') { + combined.push('\n'); + } + combined.push_str(&raw); + } + } + self.apply_all_filters(&combined) + } + + fn get_raw_commit_diff_by_hash(&self, hash: &str) -> Result { // 検証と同時にコミット ID へ解決し、以降は解決済みの ID だけを git に渡す。 // 注釈付きタグ(`--generate-for v1` など)を生のまま `git show` に渡すと、diff の前に // タグオブジェクト自身(`tag v1` / `Tagger: 名前 <メール>` / タグ本文)が出力され、 @@ -1336,8 +1502,7 @@ impl GitService { let mut args = vec!["show", "--no-show-signature", "--format=", "-U0"]; args.extend_from_slice(DIFF_FORMAT_ARGS); args.extend_from_slice(&["--end-of-options", &commit, "--"]); - let raw = self.run_git_raw(&args)?; - self.apply_all_filters(&raw) + self.run_git_raw(&args) } /// 指定されたコミットハッシュのメッセージを取得 @@ -2275,6 +2440,149 @@ index 1234567..abcdefg 100644 assert_eq!(result, diff); } + #[test] + fn test_large_diff_overview_keeps_rename_copy_and_combined_counts() { + let rename = "diff --git a/old.txt b/new b/path.txt\nsimilarity index 90%\nrename from old.txt\nrename to new b/path.txt\n@@ -1 +1,2 @@\n-old\n+new\n+++literal plus"; + let copy = "diff --git a/source.txt b/copy.txt\nsimilarity index 100%\ncopy from source.txt\ncopy to copy.txt"; + let combined = "diff --cc \"caf\\303\\251.txt\"\nindex a,b..c\n@@@ -1 -1 +1 @@@\n--removed\n++added\n context"; + let large = format!( + "diff --git a/a.txt b/a.txt\n@@ -0,0 +1 @@\n+{}", + "長".repeat(MAX_DIFF_CHARS) + ); + let result = GitService::truncate_diff(&format!("{large}\n{rename}\n{copy}\n{combined}")); + for summary in [ + "old.txt -> new b/path.txt | +2 -1", + "source.txt -> copy.txt | +0 -0", + "café.txt | +1 -1 (combined rows)", + ] { + assert!(result.contains(summary), "missing {summary}: {result}"); + } + } + + #[test] + fn test_large_diff_overview_never_truncates_inventory() { + let diff = (0..400) + .map(|index| format!("diff --git a/file-{index:03}.txt b/file-{index:03}.txt\n@@ -1 +1 @@\n-old\n+new\n")) + .collect::(); + let result = GitService::truncate_diff(&diff); + // 一覧自体が本文予算を超える場合も名前と行数を削らない。 + for index in 0..400 { + assert!(result.contains(&format!("file-{index:03}.txt | +1 -1"))); + } + assert!(result.contains("file bodies omitted or truncated")); + let (_, body) = result + .split_once("body budget 10000 characters]\n\n") + .unwrap(); + assert!(body.chars().count() <= MAX_DIFF_CHARS); + } + + #[test] + fn test_large_diff_shares_body_budget_across_large_files() { + let diff = ["first", "second"] + .into_iter() + .map(|name| { + format!( + "diff --git a/{name}.txt b/{name}.txt\n@@ -0,0 +1,1500 @@\n{}", + format!("+{name} body line\n").repeat(1500) + ) + }) + .collect::(); + let result = GitService::truncate_diff(&diff); + let (_, body) = result + .split_once("body budget 10000 characters]\n\n") + .unwrap(); + assert!(body.contains("+first body line\n")); + assert!(body.contains("+second body line\n")); + assert!(body.chars().count() <= MAX_DIFF_CHARS); + assert!(result.contains("2 of 2 file bodies omitted or truncated")); + assert_eq!(result.matches("[body truncated]").count(), 2); + } + + #[test] + fn test_many_diff_files_keep_inventory_without_exceeding_body_budget() { + let count = MAX_DIFF_CHARS + 2; + let diff = (0..count) + .map(|index| { + format!("diff --git a/file-{index}.txt b/file-{index}.txt\n@@ -0,0 +1 @@\n+added\n") + }) + .collect::(); + let result = GitService::truncate_diff(&diff); + let (overview, body) = result + .split_once("body budget 10000 characters]\n\n") + .unwrap(); + assert_eq!( + overview + .lines() + .filter(|line| line.starts_with("[File] ")) + .count(), + count + ); + assert!(body.chars().count() <= MAX_DIFF_CHARS); + } + + #[test] + fn test_large_diff_preserves_spaces_and_escapes_control_characters_in_paths() { + let mut diff = format!( + "diff --git a/large.txt b/large.txt\n@@ -0,0 +1 @@\n+{}\n", + "x".repeat(MAX_DIFF_CHARS) + ); + for name in [ + " leading.txt", + "trailing.txt ", + " both.txt ", + "line\n[File] | \"name\".txt", + ] { + let a = if name.contains(['\n', '"']) { + format!("{:?}", format!("a/{name}")) + } else { + format!("a/{name}") + }; + let b = if name.contains(['\n', '"']) { + format!("{:?}", format!("b/{name}")) + } else { + format!("b/{name}") + }; + diff.push_str(&format!("diff --git {a} {b}\n@@ -0,0 +1 @@\n+change\n")); + } + let result = GitService::truncate_diff(&diff); + for expected in [ + "[File] \" leading.txt\" | +1 -0", + "[File] \"trailing.txt \" | +1 -0", + "[File] \" both.txt \" | +1 -0", + "[File] \"line\\n[File] | \\\"name\\\".txt\" | +1 -0", + ] { + assert!(result.contains(expected), "missing {expected}: {result}"); + } + assert_eq!( + result + .lines() + .filter(|line| line.starts_with("[File] ")) + .count(), + 5 + ); + assert!(!result.contains(" -> ")); + assert_eq!( + GitService::extract_combined_diff_path("diff --cc name "), + Some(" name ".to_string()) + ); + assert_eq!( + GitService::extract_file_paths_from_diff_header("diff --git a/old \"b/new\\tname\""), + Some(("old ".to_string(), "new\tname".to_string())) + ); + } + + #[test] + fn test_large_diff_allocation_keeps_crlf_and_small_file_bodies() { + let diff = format!( + "diff --git a/a.txt b/a.txt\n@@ -0,0 +1 @@\n+{}\ndiff --git a/z.txt b/z.txt\n@@ -1 +1 @@\n-old\n+new\r\n", + "文".repeat(MAX_DIFF_CHARS) + ); + let result = GitService::truncate_diff(&diff); + assert!(result.contains("z.txt | +1 -1")); + assert!(result.contains("+new\r\n") || result.ends_with("+new\r")); + assert!(!result.contains('\u{fffd}')); + } + #[test] fn test_truncate_diff_exactly_at_limit() { // 10000文字ちょうどの場合は切り詰めない diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index f74ae8c..9edfd5c 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -15,6 +15,153 @@ macro_rules! git_sc { }; } +/// 大きな先頭ファイルが後続ファイルを隠さず、実際に送るプロンプトに全体像を残す。 +#[test] +#[cfg(unix)] +fn test_large_diff_prompt_keeps_every_file_and_raw_line_counts() { + let dir = setup_git_repo_with_commit(); + let path = setup_fake_opencode_path(&dir); + let config_dir = dir.path().join(".config/git-sc"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.toml"), + "[dev_log]\nenabled = true\ncontent = \"metadata\"\n", + ) + .unwrap(); + std::fs::write(dir.path().join(".git-sc"), "providers = [\"opencode\"]\n").unwrap(); + std::fs::write(dir.path().join(".git-sc-ignore"), "private.txt\n").unwrap(); + for (name, content) in [ + ("A-large.md", format!("{}\n", "説明".repeat(6000))), + ( + "Cargo.lock", + "hidden-lock-value\nsecond lock line\n".to_string(), + ), + ( + "Z-last.txt", + "last file first line\nlast file second line\n".to_string(), + ), + ("private.txt", "hidden-private-value\n".to_string()), + ("image.bin", "\0hidden-binary-value\n".to_string()), + ] { + std::fs::write(dir.path().join(name), content).unwrap(); + } + let staged = std::process::Command::new("git") + .args([ + "add", + "A-large.md", + "Cargo.lock", + "Z-last.txt", + "private.txt", + "image.bin", + ]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(staged.status.success()); + let output = git_sc!() + .args(["--dry-run", "--debug", "-p", "opencode"]) + .env("PATH", &path) + .env("HOME", dir.path()) + .env("XDG_CONFIG_HOME", dir.path()) + .current_dir(dir.path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + let prompt = String::from_utf8(output).unwrap(); + for summary in [ + "A-large.md | +1 -0", + "Cargo.lock | +2 -0", + "Z-last.txt | +2 -0", + "private.txt | +1 -0", + "image.bin | binary", + ] { + assert!(prompt.contains(summary), "missing {summary}: {prompt}"); + } + assert!(prompt.contains("last file second line")); + assert!(prompt.contains("file bodies omitted or truncated")); + for hidden in [ + "hidden-lock-value", + "hidden-private-value", + "hidden-binary-value", + ] { + assert!(!prompt.contains(hidden)); + } + let records = std::fs::read_dir(config_dir.join("logs")) + .unwrap() + .filter(|date| date.as_ref().unwrap().path().is_dir()) + .flat_map(|date| std::fs::read_dir(date.unwrap().path()).unwrap()) + .filter(|entry| { + entry + .as_ref() + .unwrap() + .path() + .extension() + .is_some_and(|ext| ext == "json") + }) + .map(|entry| { + serde_json::from_slice::( + &std::fs::read(entry.unwrap().path()).unwrap(), + ) + .unwrap() + }) + .collect::>(); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["input"]["files_changed"], 5); +} + +/// 複数コミットの結合時にも各コミットの後方にあるファイル一覧を失わない。 +#[test] +#[cfg(unix)] +fn test_generate_for_large_commits_keeps_all_file_overviews() { + let dir = setup_git_repo_with_commit(); + let path = setup_fake_opencode_path(&dir); + std::fs::write(dir.path().join(".git-sc"), "providers = [\"opencode\"]\n").unwrap(); + let mut hashes = Vec::new(); + for index in 0..2 { + let large = format!("A-large-{index}.txt"); + let last = format!("Z-last-{index}.txt"); + std::fs::write(dir.path().join(&large), "large line\n".repeat(1500)).unwrap(); + std::fs::write(dir.path().join(&last), "tail change\n").unwrap(); + for args in [ + vec!["add", &large, &last], + vec!["commit", "-m", "add sample files"], + ] { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + } + hashes.push(head_hash(&dir)); + } + let output = git_sc!() + .args([ + "--debug", + "-p", + "opencode", + "--generate-for", + &hashes[0], + &hashes[1], + ]) + .env("PATH", &path) + .env("HOME", dir.path()) + .env("XDG_CONFIG_HOME", dir.path()) + .current_dir(dir.path()) + .assert() + .success() + .get_output() + .stderr + .clone(); + let prompt = String::from_utf8(output).unwrap(); + for index in 0..2 { + assert!(prompt.contains(&format!("A-large-{index}.txt | +1500 -0"))); + assert!(prompt.contains(&format!("Z-last-{index}.txt | +1 -0"))); + } +} + /// インデントだけの変更も全モードで生成対象にし、他の差分との混在時も隠さない。 #[test] #[cfg(unix)]