use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
static GIT_HINT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"(?m)^\s*(\(use "git|hint:|nothing to commit|no changes added)"#).unwrap()
});
static GIT_OBJECT_COUNT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^(Counting|Compressing|remote: Counting|remote: Compressing|Receiving|Resolving|Writing) objects:.*\n?").unwrap()
});
static GIT_DELTA_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^(remote: )?delta compression.*\n?").unwrap());
static GIT_INDEX_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^index [0-9a-f]+\.\.[0-9a-f]+ \d+\n?").unwrap()
});
static GIT_SIMILARITY_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^similarity index \d+%\n?(rename from .*\nrename to .*\n?)?").unwrap()
});
static GIT_COMMIT_SIGNING_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^( +gpg:|-----BEGIN PGP|-----END PGP|gpg: Signature).*\n?").unwrap()
});
static GIT_BLAME_HEADER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^[0-9a-f]{40} \(").unwrap());
pub fn compress_log(raw: &str, max_commits: usize) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.len() <= max_commits {
return cleaned;
}
let head = max_commits / 2;
let tail = max_commits - head;
let skipped = lines.len() - head - tail;
format!(
"{}\n... [{skipped} commits omitted] ...\n{}",
lines[..head].join("\n"),
lines[lines.len() - tail..].join("\n"),
)
}
pub fn compress_log_verbose(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let noise_prefixes = ["Author:", "Date:", "Merge:", " "];
let mut out = Vec::new();
for line in cleaned.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if noise_prefixes.iter().any(|p| line.starts_with(p)) {
continue;
}
if let Some(rest) = line.strip_prefix("commit ") {
out.push(format!("commit {}", &rest[..8.min(rest.len())]));
} else {
out.push(line.to_string());
}
}
out.join("\n")
}
pub fn compress_diff(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s1 = GIT_INDEX_RE.replace_all(&cleaned, "");
let s2 = GIT_SIMILARITY_RE.replace_all(&s1, "");
let file_headers: Vec<&str> = s2.lines().filter(|l| l.starts_with("diff --git")).collect();
if file_headers.len() > 8 {
let truncated = truncate_diff_to_n_files(&s2, 8);
return format!(
"{}\n... [{} more files not shown] ...",
truncated,
file_headers.len() - 8
);
}
s2.into_owned()
}
pub fn compress_diff_stat(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
if let Some(last) = lines.last() {
if lines.len() > 6 {
let files_shown = 5.min(lines.len() - 1);
let shown = lines[..files_shown].to_vec();
return format!(
"{}\n... [{} more files] ...\n{}",
shown.join("\n"),
lines.len() - 1 - files_shown,
last
);
}
}
cleaned
}
fn truncate_diff_to_n_files(diff: &str, n: usize) -> String {
let mut count = 0;
let mut out = String::new();
for line in diff.lines() {
if line.starts_with("diff --git") {
count += 1;
if count > n {
break;
}
}
out.push_str(line);
out.push('\n');
}
out
}
pub fn compress_status(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let stripped = GIT_HINT_RE.replace_all(&cleaned, "");
let mut out: Vec<&str> = Vec::new();
let mut untracked_count = 0usize;
let mut in_untracked = false;
let max_untracked = 8;
for line in stripped.lines() {
if line.contains("Untracked files:") {
in_untracked = true;
}
if line.contains("Changes to be committed:") || line.contains("Changes not staged") {
in_untracked = false;
}
if in_untracked && line.starts_with('\t') {
untracked_count += 1;
if untracked_count <= max_untracked {
out.push(line);
} else if untracked_count == max_untracked + 1 {
out.push(" ... and more untracked files (run git status for full list)");
}
} else {
out.push(line);
}
}
compactor::collapse_blanks(&out.join("\n"))
}
pub fn compress_commit(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s1 = GIT_COMMIT_SIGNING_RE.replace_all(&cleaned, "");
let out: Vec<&str> = s1
.lines()
.filter(|l| {
let t = l.trim();
!t.starts_with("running ") && !t.contains("pre-commit") && !t.contains("post-commit")
})
.collect();
compactor::collapse_blanks(&out.join("\n"))
}
pub fn compress_fetch(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s1 = GIT_OBJECT_COUNT_RE.replace_all(&cleaned, "");
let s2 = GIT_DELTA_RE.replace_all(&s1, "");
let out: Vec<&str> = s2.lines().filter(|l| l.trim() != "remote:").collect();
compactor::collapse_blanks(&out.join("\n"))
}
pub fn compress_blame(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
if !GIT_BLAME_HEADER_RE.is_match(&cleaned) {
return cleaned;
}
let short_sha_re = Regex::new(r"^[0-9a-f]{40}").unwrap();
let lines: Vec<String> = cleaned
.lines()
.map(|l| {
if short_sha_re.is_match(l) {
format!("{}{}", &l[..8], &l[40..])
} else {
l.to_string()
}
})
.collect();
lines.join("\n")
}
pub fn compress_stash_list(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.len() <= 20 {
return lines.join("\n");
}
format!(
"{}\n... [{} more stash entries]",
lines[..20].join("\n"),
lines.len() - 20
)
}
static GIT_BRANCH_TRACKING_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r" \[(?:ahead \d+(?:, )?)?(?:behind \d+)?\]").unwrap());
pub fn compress_branch(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s = GIT_BRANCH_TRACKING_RE.replace_all(&cleaned, "");
let lines: Vec<&str> = s.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.len() <= 40 {
return lines.join("\n");
}
format!(
"{}\n... [{} more branches]",
lines[..40].join("\n"),
lines.len() - 40
)
}
pub fn compress_tag(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.len() <= 30 {
return lines.join("\n");
}
format!(
"{}\n... [{} more tags]",
lines[..30].join("\n"),
lines.len() - 30
)
}
pub fn compress_remote(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().filter(|l| !l.ends_with("(push)")).collect();
lines.join("\n")
}
pub fn compress_rebase(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let rebase_progress_re = Regex::new(r"(?m)^Rebasing \(\d+/\d+\)\n?").unwrap();
let s = rebase_progress_re.replace_all(&cleaned, "");
compactor::collapse_blanks(&s)
}
pub fn compress_cherry_pick(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
if !cleaned.contains("CONFLICT") && !cleaned.contains("conflict") {
return cleaned.lines().next().unwrap_or("").to_string();
}
cleaned
}
pub fn compress_show(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s1 = GIT_INDEX_RE.replace_all(&cleaned, "");
let s2 = GIT_SIMILARITY_RE.replace_all(&s1, "");
compactor::collapse_blanks(&s2)
}
pub fn compress_worktree(raw: &str) -> String {
compactor::normalise(raw)
}
pub fn compress_gc(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s = GIT_OBJECT_COUNT_RE.replace_all(&cleaned, "");
compactor::collapse_blanks(&s)
}
pub fn compress_submodule(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned
.lines()
.filter(|l| {
let t = l.trim();
!t.is_empty()
&& (!t.starts_with("Cloning into") || t.len() <= 60)
&& !t.starts_with("remote: Counting")
&& !t.starts_with("remote: Compressing")
&& !t.starts_with("Receiving objects:")
&& !t.starts_with("Resolving deltas:")
})
.collect();
lines.join("\n")
}
pub fn compress_bisect(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let out: Vec<&str> = cleaned
.lines()
.filter(|l| {
l.starts_with("Bisecting:")
|| l.contains("first bad commit")
|| l.starts_with("commit ")
|| l.starts_with("[")
|| l.starts_with("error")
|| l.starts_with("fatal")
})
.collect();
if out.is_empty() {
cleaned
} else {
out.join("\n")
}
}
pub fn compress_git(subcmd: &str, raw: &str, exit_code: i32) -> String {
if exit_code != 0 {
return compactor::normalise(raw);
}
let sub = subcmd.trim();
if sub.starts_with("log") {
if raw
.lines()
.any(|l| l.starts_with("commit ") && l.len() == 47)
{
return compress_log_verbose(raw);
}
return compress_log(raw, 12);
}
if sub.starts_with("diff") {
if sub.contains("--stat") {
return compress_diff_stat(raw);
}
return compress_diff(raw);
}
if sub.starts_with("status") {
return compress_status(raw);
}
if sub.starts_with("commit") {
return compress_commit(raw);
}
if sub.starts_with("fetch") || sub.starts_with("pull") {
return compress_fetch(raw);
}
if sub.starts_with("blame") {
return compress_blame(raw);
}
if sub.starts_with("stash") {
return compress_stash_list(raw);
}
if sub.starts_with("branch") {
return compress_branch(raw);
}
if sub.starts_with("tag") {
return compress_tag(raw);
}
if sub.starts_with("remote") {
return compress_remote(raw);
}
if sub.starts_with("rebase") {
return compress_rebase(raw);
}
if sub.starts_with("cherry-pick") {
return compress_cherry_pick(raw);
}
if sub.starts_with("show") {
return compress_show(raw);
}
if sub.starts_with("worktree") {
return compress_worktree(raw);
}
if sub.starts_with("bisect") {
return compress_bisect(raw);
}
if sub.starts_with("gc") {
return compress_gc(raw);
}
if sub.starts_with("submodule") {
return compress_submodule(raw);
}
let cleaned = compactor::normalise(raw);
let s = GIT_OBJECT_COUNT_RE.replace_all(&cleaned, "");
compactor::collapse_blanks(&s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_compression_keeps_head_tail() {
let log = (0..20)
.map(|i| format!("abc{i:04} commit message {i}"))
.collect::<Vec<_>>()
.join("\n");
let out = compress_log(&log, 6);
assert!(out.contains("omitted"), "should summarise middle: {out}");
assert!(out.lines().count() < 20);
}
#[test]
fn diff_stat_truncates_long_lists() {
let mut s = String::new();
for i in 0..20 {
s.push_str(&format!(" file{i}.rs | 10 ++--\n"));
}
s.push_str(" 20 files changed, 100 insertions(+), 50 deletions(-)\n");
let out = compress_diff_stat(&s);
assert!(out.contains("more files"));
assert!(out.contains("20 files changed"));
}
#[test]
fn status_strips_hints() {
let raw =
"On branch main\nChanges not staged:\n (use \"git add\" ...)\n\tmodified: foo.rs\n";
let out = compress_status(raw);
assert!(!out.contains("use \"git"), "should strip hints: {out}");
assert!(out.contains("foo.rs"));
}
#[test]
fn commit_strips_signing() {
let raw = "[main abc1234] feat: add thing\n gpg: Signature made Mon\n 1 file changed\n";
let out = compress_commit(raw);
assert!(!out.contains("gpg:"));
assert!(out.contains("feat: add thing"));
}
#[test]
fn fetch_strips_object_count() {
let raw = "remote: Counting objects: 10, done.\nFrom github.com:org/repo\n abc..def main -> origin/main\n";
let out = compress_fetch(raw);
assert!(!out.contains("Counting objects"));
assert!(out.contains("main -> origin/main"));
}
#[test]
fn diff_strips_index_lines() {
let raw = "diff --git a/foo.rs b/foo.rs\nindex abc123..def456 100644\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1,3 +1,4 @@\n+new line\n";
let out = compress_diff(raw);
assert!(
!out.contains("index abc123"),
"index line should be stripped: {out}"
);
assert!(out.contains("+new line"));
}
#[test]
fn diff_truncates_many_files() {
let mut s = String::new();
for i in 0..12 {
s.push_str(&format!("diff --git a/f{i}.rs b/f{i}.rs\n--- a/f{i}.rs\n+++ b/f{i}.rs\n@@ -1 +1 @@\n-old\n+new\n"));
}
let out = compress_diff(&s);
assert!(out.contains("more files not shown"));
}
#[test]
fn stash_list_truncates_at_20() {
let raw = (0..30)
.map(|i| format!("stash@{{{i}}}: On main: WIP change {i}"))
.collect::<Vec<_>>()
.join("\n");
let out = compress_stash_list(&raw);
assert!(out.contains("more stash entries"), "{out}");
}
#[test]
fn tag_list_truncates_at_30() {
let raw = (0..50)
.map(|i| format!("v1.{i}.0"))
.collect::<Vec<_>>()
.join("\n");
let out = compress_tag(&raw);
assert!(out.contains("more tags"), "{out}");
}
#[test]
fn remote_deduplicates_push_lines() {
let raw = "origin\thttps://github.com/org/repo.git (fetch)\norigin\thttps://github.com/org/repo.git (push)\n";
let out = compress_remote(raw);
assert!(!out.contains("(push)"), "{out}");
assert!(out.contains("(fetch)") || out.contains("origin"), "{out}");
}
#[test]
fn rebase_strips_progress() {
let raw = "Rebasing (1/10)\nRebasing (2/10)\nRebasing (10/10)\nSuccessfully rebased and updated refs/heads/main.\n";
let out = compress_rebase(raw);
assert!(!out.contains("Rebasing (1/10)"), "{out}");
assert!(out.contains("Successfully rebased"), "{out}");
}
#[test]
fn cherry_pick_clean_keeps_first_line_only() {
let raw = "[main abc1234] feat: add thing\n Date: Mon Jan 1 12:00:00 2024\n 1 file changed, 5 insertions(+)\n";
let out = compress_cherry_pick(raw);
assert!(out.contains("feat: add thing"), "{out}");
assert!(!out.contains("Date:"), "{out}");
}
}