Skip to content

Instantly share code, notes, and snippets.

@erikhuizinga
Last active January 29, 2026 08:47
Show Gist options
  • Select an option

  • Save erikhuizinga/7b9e8e6cd16e1bcc39f3a3de96b9ed2e to your computer and use it in GitHub Desktop.

Select an option

Save erikhuizinga/7b9e8e6cd16e1bcc39f3a3de96b9ed2e to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
exec path/to/gradle-live-filter.sh "$@"
# Put this file somewhere on your "$PATH" for easy access.
# Make this executable using 'chmod +x gradle-live-filter'.
# Update the path/to/ the .sh file.
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'USAGE'
Usage:
./gradlew build --console=plain | gradle-live-filter.sh
Filters live Gradle task output while preserving warnings/errors.
USAGE
exit 0
fi
awk '
function flush_group() {
if (group_count == 0) {
return
}
if (group_count == 1) {
print group_line
fflush()
} else {
summary = group_count " lines filtered ("
first = 1
for (i = 1; i <= status_order_count; i++) {
status = status_order[i]
count = status_counts[status]
if (count > 0) {
if (!first) {
summary = summary ", "
}
summary = summary count " " status
first = 0
}
}
summary = summary ")"
print summary
fflush()
}
group_count = 0
group_line = ""
delete status_counts
}
BEGIN {
status_order_count = 5
status_order[1] = "SKIPPED"
status_order[2] = "UP-TO-DATE"
status_order[3] = "FROM-CACHE"
status_order[4] = "NO-SOURCE"
status_order[5] = "DISABLED"
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/[[:space:]]+$/, "", trimmed)
match_line = trimmed
gsub(/\033\[[0-9;]*[[:alpha:]]/, "", match_line)
sub(/^[[:space:]]+/, "", match_line)
if (match_line ~ /^> Task / && match(match_line, /(UP-TO-DATE|FROM-CACHE|SKIPPED|NO-SOURCE|DISABLED)/)) {
status = substr(match_line, RSTART, RLENGTH)
group_count++
status_counts[status]++
if (group_count == 1) {
group_line = line
}
next
}
flush_group()
print line
fflush()
}
END {
flush_group()
}
'
#!/usr/bin/env bash
set -u
set -o pipefail
test_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
default_filter="${test_dir}/gradle-live-filter.sh"
filter_script="${FILTER_SCRIPT:-${default_filter}}"
if [[ ! -f "${filter_script}" ]]; then
echo "error: filter script not found: ${filter_script}" >&2
exit 2
fi
fail() {
echo "error: $*" >&2
return 1
}
assert_eq() {
local expected="$1"
local actual="$2"
local context="${3:-}"
if [[ "${actual}" != "${expected}" ]]; then
if [[ -n "${context}" ]]; then
fail "expected '${expected}' got '${actual}': ${context}"
else
fail "expected '${expected}' got '${actual}'"
fi
fi
}
with_tmp() {
local tmp
tmp="$(mktemp -d)" || return 1
(
trap 'rm -rf -- "${tmp}"' EXIT
"$@" "${tmp}"
)
}
copy_filter_script() {
local tmp="$1"
local script_path="${tmp}/gradle-live-filter.sh"
cp "${filter_script}" "${script_path}"
chmod +x "${script_path}"
echo "${script_path}"
}
run_filter() {
local tmp="$1"
local input="$2"
local script
script="$(copy_filter_script "${tmp}")"
printf '%s' "${input}" | "${script}"
}
failures=0
cases=0
run_case() {
local name="$1"
shift
cases=$((cases + 1))
if ( "$@" ); then
echo "ok - ${name}"
else
echo "not ok - ${name}"
failures=$((failures + 1))
fi
}
test_condenses_status_lines() {
with_tmp test_condenses_status_lines_impl
}
test_condenses_status_lines_impl() {
local tmp="$1"
local input=$'> Task :app:compileJava UP-TO-DATE\n> Task :app:test SKIPPED\n> Task :app:jar UP-TO-DATE'
local expected=$'3 lines filtered (1 SKIPPED, 2 UP-TO-DATE)'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${expected}" "${output}" "status lines should be condensed"
}
test_passes_through_non_task_lines() {
with_tmp test_passes_through_non_task_lines_impl
}
test_passes_through_non_task_lines_impl() {
local tmp="$1"
local input=$'Starting build\n> Configure project :app\nBUILD SUCCESSFUL in 1s'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${input}" "${output}" "non-task lines should pass through"
}
test_single_task_line_not_condensed() {
with_tmp test_single_task_line_not_condensed_impl
}
test_single_task_line_not_condensed_impl() {
local tmp="$1"
local input=$'> Task :app:compileJava UP-TO-DATE'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${input}" "${output}" "single task line should not be condensed"
}
test_condenses_with_reasons_and_spaces() {
with_tmp test_condenses_with_reasons_and_spaces_impl
}
test_condenses_with_reasons_and_spaces_impl() {
local tmp="$1"
local input=$' > Task :app:compileJava UP-TO-DATE (no changes)\n\t> Task :app:test SKIPPED (disabled)'
local expected=$'2 lines filtered (1 SKIPPED, 1 UP-TO-DATE)'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${expected}" "${output}" "reasons and spacing should be handled"
}
test_handles_ansi_codes() {
with_tmp test_handles_ansi_codes_impl
}
test_handles_ansi_codes_impl() {
local tmp="$1"
local input=$'\x1b[32m> Task :app:compileJava\x1b[0m \x1b[33mUP-TO-DATE\x1b[0m\n\x1b[32m> Task :app:test\x1b[0m \x1b[33mSKIPPED\x1b[0m'
local expected=$'2 lines filtered (1 SKIPPED, 1 UP-TO-DATE)'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${expected}" "${output}" "ANSI codes should be ignored"
}
test_preserves_warnings_with_keywords() {
with_tmp test_preserves_warnings_with_keywords_impl
}
test_preserves_warnings_with_keywords_impl() {
local tmp="$1"
local input=$'warning: cache SKIPPED\nerror: build UP-TO-DATE'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${input}" "${output}" "warnings with keywords should pass through"
}
test_flushes_on_non_task_line() {
with_tmp test_flushes_on_non_task_line_impl
}
test_flushes_on_non_task_line_impl() {
local tmp="$1"
local input=$'> Task :app:compileJava UP-TO-DATE\nSome log output\n> Task :app:test SKIPPED'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${input}" "${output}" "non-task line should flush groups"
}
test_blank_line_flushes_groups() {
with_tmp test_blank_line_flushes_groups_impl
}
test_blank_line_flushes_groups_impl() {
local tmp="$1"
local input=$'> Task :app:one UP-TO-DATE\n> Task :app:two SKIPPED\n\n> Task :app:three UP-TO-DATE'
local expected=$'2 lines filtered (1 SKIPPED, 1 UP-TO-DATE)\n\n> Task :app:three UP-TO-DATE'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${expected}" "${output}" "blank line should flush groups"
}
test_flushes_trailing_group() {
with_tmp test_flushes_trailing_group_impl
}
test_flushes_trailing_group_impl() {
local tmp="$1"
local input=$'Some log output\n> Task :app:compileJava UP-TO-DATE\n> Task :app:test UP-TO-DATE'
local expected=$'Some log output\n2 lines filtered (2 UP-TO-DATE)'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${expected}" "${output}" "trailing task group should be condensed"
}
test_summary_orders_statuses() {
with_tmp test_summary_orders_statuses_impl
}
test_summary_orders_statuses_impl() {
local tmp="$1"
local input=$'> Task :app:one FROM-CACHE\n> Task :app:two DISABLED\n> Task :app:three NO-SOURCE\n> Task :app:four SKIPPED'
local expected=$'4 lines filtered (1 SKIPPED, 1 FROM-CACHE, 1 NO-SOURCE, 1 DISABLED)'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${expected}" "${output}" "summary should follow status order"
}
test_preserves_failed_task_lines() {
with_tmp test_preserves_failed_task_lines_impl
}
test_preserves_failed_task_lines_impl() {
local tmp="$1"
local input=$'> Task :app:compileJava UP-TO-DATE\n> Task :app:compileTestJava FAILED\n> Task :app:test SKIPPED'
local output
output="$(run_filter "${tmp}" "${input}")"
assert_eq "${input}" "${output}" "failed task lines must pass through"
}
run_case "condenses status lines" test_condenses_status_lines
run_case "passes through non-task lines" test_passes_through_non_task_lines
run_case "single task line not condensed" test_single_task_line_not_condensed
run_case "condenses reasons and spacing" test_condenses_with_reasons_and_spaces
run_case "handles ANSI codes" test_handles_ansi_codes
run_case "preserves warnings with keywords" test_preserves_warnings_with_keywords
run_case "flushes on non-task line" test_flushes_on_non_task_line
run_case "blank line flushes groups" test_blank_line_flushes_groups
run_case "flushes trailing group" test_flushes_trailing_group
run_case "summary orders statuses" test_summary_orders_statuses
run_case "preserves failed task lines" test_preserves_failed_task_lines
if [[ "${failures}" -ne 0 ]]; then
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment