Skip to content

Commit 4efb645

Browse files
authored
Merge pull request #543 from lotuszhaol/feat/blame-horizon
Add blame horizon utility
2 parents 835b9ea + ac4228b commit 4efb645

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

blame-horizon/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Blame Horizon
2+
3+
A tiny Bash script that filters `git blame` by time for one file. By default, it prints lines last changed on or after `--since`; with `--older`, it prints lines changed before the cutoff.
4+
5+
## Usage
6+
7+
Run from the repository root:
8+
9+
```bash
10+
bash blame-horizon/blame_horizon.sh --since 2025-01-01 -f README.md
11+
bash blame-horizon/blame_horizon.sh --since "1 week ago" --older --file README.md
12+
bash blame-horizon/blame_horizon.sh --since 2025-01-01 --csv -f README.md > recent.csv
13+
```
14+
15+
Run `bash blame-horizon/blame_horizon.sh --help` for more details.
16+
17+
## Sample Output
18+
19+
```text
20+
80847477 (2026-02-14, Harsh Joshi) 3 ### Note - 100LinesOfCode is now actively maintained by my AI Friday - github.com/fridayjoshi
21+
```
22+
23+
```text
24+
sha,date,author,line_no,line_text
25+
80847477,2026-02-14,"Harsh Joshi",3,"### Note - 100LinesOfCode is now actively maintained by my AI Friday - github.com/fridayjoshi"
26+
c1235362,2026-02-14,"Harsh Joshi",4,""
27+
```
28+
29+
Requires Bash 4+, Git, and GNU `date`.

blame-horizon/blame_horizon.sh

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env bash
2+
# Filter `git blame` by cutoff time; --older flips the comparison.
3+
# Requires: bash 4+, git, GNU date (coreutils).
4+
set -euo pipefail
5+
6+
PROG=${0##*/}
7+
DATE_BIN=${DATE_BIN:-date}
8+
9+
BH_TMPFILE=""
10+
trap '[[ -n $BH_TMPFILE ]] && rm -f -- "$BH_TMPFILE"' EXIT
11+
12+
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
13+
14+
usage() {
15+
cat >&2 <<EOF
16+
Usage: $PROG --since <date|datetime> --file <path> [--older] [--csv]
17+
$PROG -h | --help
18+
19+
Options:
20+
--since <when> Required. YYYY-MM-DD (UTC midnight) or any GNU date string.
21+
--file, -f <p> Required. Path passed to \`git blame\`.
22+
--older Show lines last changed BEFORE --since (default: on/after).
23+
--csv Emit CSV (header + RFC 4180 quoting) instead of text.
24+
-h, --help Show this help and exit.
25+
26+
Requires: bash 4+, git, GNU date.
27+
EOF
28+
}
29+
30+
# Bare YYYY-MM-DD is normalized to 00:00:00 UTC that day (same as README).
31+
since_epoch() {
32+
local s=$1 out
33+
[[ $s =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] && s="$s 00:00:00"
34+
out=$("$DATE_BIN" -u -d "$s" +%s 2>/dev/null) || true
35+
[[ $out =~ ^[0-9]+$ ]] || die "invalid --since: not a valid GNU date string: $1"
36+
printf '%s\n' "$out"
37+
}
38+
39+
# CSV doubles embedded quotes (RFC 4180).
40+
emit_row() {
41+
local mode=$1 ts=$2 author=$3 line=$4 line_no=$5 sha=$6 day qa ql
42+
day=$("$DATE_BIN" -u -d "@$ts" +%F)
43+
if ((mode)); then
44+
qa=${author//\"/\"\"}
45+
ql=${line:1}; ql=${ql//\"/\"\"}
46+
printf '%s,%s,"%s",%d,"%s"\n' "${sha:0:8}" "$day" "$qa" "$line_no" "$ql"
47+
else
48+
printf '%s (%s, %s) %6d %s\n' "${sha:0:8}" "$day" "$author" "$line_no" "${line:1}"
49+
fi
50+
}
51+
52+
main() {
53+
local since="" path="" older=0 csv=0 cutoff sha line_no author="" author_time="" committer_time="" ts n=0 line
54+
while (($#)); do
55+
case $1 in
56+
--since)
57+
(($# >= 2)) || { usage; die "--since requires a value"; }
58+
since=$2
59+
shift 2
60+
;;
61+
--file | -f)
62+
(($# >= 2)) || { usage; die "--file requires a path"; }
63+
path=$2
64+
shift 2
65+
;;
66+
--older) older=1; shift ;;
67+
--csv) csv=1; shift ;;
68+
-h | --help) usage; exit 0 ;;
69+
*) usage; die "unexpected argument: $1 (use --file/-f for the path)" ;;
70+
esac
71+
done
72+
[[ -n $since ]] || { usage; die "--since is required"; }
73+
[[ -n $path ]] || { usage; die "--file is required"; }
74+
75+
command -v git >/dev/null 2>&1 || die "git not found in PATH"
76+
if ! "$DATE_BIN" --version >/dev/null 2>&1; then
77+
if command -v gdate >/dev/null 2>&1 && gdate --version >/dev/null 2>&1; then
78+
DATE_BIN=gdate
79+
else
80+
die "GNU date required; install coreutils or set DATE_BIN=/path/to/gdate"
81+
fi
82+
fi
83+
84+
cutoff=$(since_epoch "$since")
85+
86+
BH_TMPFILE=$(mktemp "${TMPDIR:-/tmp}/${PROG}.XXXXXX") || die "mktemp failed"
87+
git blame --line-porcelain -- "$path" >"$BH_TMPFILE" || die "git blame failed"
88+
((csv)) && echo 'sha,date,author,line_no,line_text'
89+
90+
# Porcelain records end with the TAB-prefixed source line.
91+
while IFS= read -r line || [[ -n $line ]]; do
92+
case $line in
93+
author\ *) author=${line#author } ;;
94+
author-time\ *) author_time=${line#author-time } ;;
95+
committer-time\ *) committer_time=${line#committer-time } ;;
96+
$'\t'*)
97+
ts=${author_time:-${committer_time:-0}}
98+
if (( (ts >= cutoff) ^ older )); then
99+
emit_row "$csv" "$ts" "${author:-?}" "$line" "$line_no" "$sha"
100+
((++n))
101+
fi
102+
;;
103+
*)
104+
# Header: SHA, source line no., final line no.
105+
[[ $line =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})[[:space:]]+[0-9]+[[:space:]]+([0-9]+) ]] && {
106+
sha=${BASH_REMATCH[1],,}
107+
line_no=${BASH_REMATCH[2]}
108+
author="" author_time="" committer_time=""
109+
}
110+
;;
111+
esac
112+
done <"$BH_TMPFILE"
113+
114+
((n)) || echo "No matching lines." >&2
115+
}
116+
117+
main "$@"

0 commit comments

Comments
 (0)