diff --git a/src/git/service.rs b/src/git/service.rs index aef9e95..f161ddd 100644 --- a/src/git/service.rs +++ b/src/git/service.rs @@ -803,9 +803,15 @@ impl GitService { Some(ignore) => Self::filter_ignored_files(&raw, ignore), None => raw.clone(), }; - let content = Self::summarize_diff(&filtered); + let ignored = filtered != raw; + // パスと省略通知は一覧に載せるので、本文の予算を使わない。 + let content = if ignored { + String::new() + } else { + Self::summarize_diff(&filtered) + }; let mut overview = Self::diff_block_overview(block); - if filtered != raw { + if ignored { overview.push(' '); overview.push_str(IGNORED_CONTENT_NOTICE); } else if content != filtered { @@ -825,12 +831,13 @@ impl GitService { let mut sizes = excerpts .iter() .enumerate() + .filter(|(_, excerpt)| !excerpt.content.is_empty()) .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)); + let mut remaining = MAX_DIFF_CHARS.saturating_sub(sizes.len().saturating_sub(1)); for (position, (size, index)) in sizes.iter().enumerate() { let share = remaining / (sizes.len() - position); allocations[*index] = (*size).min(share); @@ -2588,6 +2595,38 @@ index 1234567..abcdefg 100644 assert!(!result.contains('\u{fffd}')); } + #[rstest::rstest] + #[case(false)] + #[case(true)] + fn test_ignored_excerpts_do_not_spend_body_or_separator_budget(#[case] only_ignored: bool) { + let dir = tempfile::tempdir().unwrap(); + let mut builder = GitignoreBuilder::new(dir.path()); + builder.add_line(None, "data/").unwrap(); + let ignore = builder.build().unwrap(); + // 除外ファイルの区切りだけで予算を使い切る件数も扱う。 + let count = MAX_DIFF_CHARS + 2; + let mut diff = (0..count) + .map(|index| { + format!( + "diff --git a/data/{index}.txt b/data/{index}.txt\n@@ -0,0 +1 @@\n+hidden\n" + ) + }) + .collect::(); + let visible = "diff --git a/visible.txt b/visible.txt\n@@ -0,0 +1 @@\n+visible change"; + if !only_ignored { + diff.push_str(visible); + } + let filtered = GitService::filter_ignored_files(&diff, &ignore); + let result = GitService::truncate_diff_with_source(&filtered, &diff, Some(&ignore)); + let (overview, body) = result + .split_once("body budget 10000 characters]\n\n") + .unwrap(); + assert_eq!(overview.matches(IGNORED_CONTENT_NOTICE).count(), count); + assert!(!overview.contains("[body truncated]")); + assert!(!body.contains("hidden")); + assert_eq!(body, if only_ignored { "" } else { visible }); + } + #[test] fn test_truncate_diff_exactly_at_limit() { // 10000文字ちょうどの場合は切り詰めない diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 4a0877d..c994fec 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -15,6 +15,81 @@ macro_rules! git_sc { }; } +/// 除外対象の数にかかわらず、残った本文だけで予算を分ける。 +#[rstest::rstest] +#[case(false)] +#[case(true)] +#[cfg(unix)] +fn test_many_ignored_files_do_not_consume_prompt_body_budget(#[case] large_bodies: bool) { + 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(); + std::fs::write(dir.path().join(".git-sc-ignore"), "data/\n").unwrap(); + std::fs::create_dir(dir.path().join("data")).unwrap(); + for index in 0..254 { + std::fs::write( + dir.path() + .join(format!("data/record-{index:03}-{}.txt", "x".repeat(60))), + "hidden-data-value\n", + ) + .unwrap(); + } + let count = if large_bodies { 1500 } else { 40 }; + for name in ["first", "second"] { + std::fs::write( + dir.path().join(format!("{name}.txt")), + format!("{name} visible body line\n").repeat(count), + ) + .unwrap(); + } + let staged = std::process::Command::new("git") + .args(["add", "data", "first.txt", "second.txt"]) + .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 output = String::from_utf8(output).unwrap(); + let changes = output.split_once("\n").unwrap().1; + let changes = changes.split_once("\n").unwrap().0; + let (_, body) = changes + .split_once("body budget 10000 characters]\n\n") + .unwrap(); + assert_eq!(changes.matches("[File] ").count(), 256); + assert_eq!( + changes + .matches("[content omitted by .git-sc-ignore]") + .count(), + 254 + ); + assert!(!changes.contains("hidden-data-value")); + assert!(!body.contains("data/record-")); + assert!(body.chars().count() <= 10000); + for name in ["first", "second"] { + let line = format!("+{name} visible body line"); + let actual = body.lines().filter(|candidate| *candidate == line).count(); + if large_bodies { + assert!(actual > 0 && actual < count, "{name}: {actual}"); + } else { + assert_eq!(actual, count, "{name}"); + } + } + assert_eq!( + changes.matches("[body truncated]").count(), + if large_bodies { 2 } else { 0 } + ); +} + /// 大きな先頭ファイルが後続ファイルを隠さず、実際に送るプロンプトに全体像を残す。 #[test] #[cfg(unix)]