├── .gitconfig.example ├── .gitignore ├── LICENSE ├── Makefile ├── README.rst ├── git-follow ├── man └── git-follow.1.gz ├── src └── GitFollow │ ├── Blame.pm │ ├── Cli │ └── OptionsNormalizer.pm │ ├── Config.pm │ ├── Environment.pm │ ├── Log.pm │ ├── Metadata.pm │ ├── Repository │ └── ObjectUtils.pm │ └── Stdlib │ └── NumberUtils.pm └── tools ├── install.sh ├── test.sh └── uninstall.sh /.gitconfig.example: -------------------------------------------------------------------------------- 1 | [follow "diff"] 2 | mode = colorsxs 3 | [follow "log"] 4 | format = "%C(bold cyan)%h%Creset (%C(bold magenta)%t%Creset) - %s - %C(bold blue)%an%Creset <%C(bold yellow)%ae%Creset> [%C(bold green)%cr%Creset]" 5 | [follow "pager"] 6 | disable = false 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw[po] 2 | *DS_Store 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2017 Nickolas Burr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ### 2 | ### Makefile 3 | ### 4 | 5 | PREFIX ?= /usr/local 6 | TOOLS = tools 7 | 8 | .PHONY: all install uninstall 9 | 10 | all: test 11 | 12 | install: 13 | @unset CDPATH; cd $(TOOLS) && ./install.sh $(PREFIX) 14 | 15 | test: 16 | @unset CDPATH; cd $(TOOLS) && ./test.sh 17 | 18 | uninstall: 19 | @unset CDPATH; cd $(TOOLS) && ./uninstall.sh $(PREFIX) 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | git-follow(1) 2 | ============= 3 | 4 | ``git-follow`` follows lifetime changes of a pathspec in Git, providing a simplified log and diff. 5 | 6 | .. contents:: 7 | :local: 8 | 9 | Installation 10 | ------------ 11 | 12 | You can install ``git-follow`` via Homebrew or manually. 13 | 14 | Homebrew 15 | ^^^^^^^^ 16 | 17 | .. code-block:: sh 18 | 19 | brew tap nickolasburr/pfa 20 | brew install git-follow 21 | 22 | Manual 23 | ^^^^^^ 24 | 25 | .. code-block:: sh 26 | 27 | git clone https://github.com/nickolasburr/git-follow.git 28 | cd git-follow 29 | make 30 | make install 31 | 32 | By default, files are installed to ``/usr/local``. You can install elsewhere by passing ``PREFIX`` to ``make install``. 33 | 34 | .. code-block:: sh 35 | 36 | make install PREFIX="$HOME/.usr/local" 37 | 38 | Configuration 39 | ------------- 40 | 41 | git-config(1) settings can be used to customize the behavior of git-follow. 42 | 43 | .. raw:: html 44 | 45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 62 | 70 | 81 | 86 | 87 | 88 | 93 | 101 | 106 | 111 | 112 | 113 | 118 | 126 | 134 | 139 | 140 | 141 |
ConfigurationDescriptionSettingsDefault
58 | 59 | follow.diff.mode 60 | 61 | 63 |
64 | Diff mode to use with git-diff(1), git-log(1), git-show(1), etc. 65 |
66 |
67 | See --word-diff of git-log(1). 68 |
69 |
71 |
72 | inline 73 |
74 |
75 | sxs 76 |
77 |
78 | colorsxs 79 |
80 |
82 |
83 | inline 84 |
85 |
89 | 90 | follow.log.format 91 | 92 | 94 |
95 | Log format to use with git-log(1). 96 |
97 |
98 | See --format of git-log(1) for syntax. 99 |
100 |
102 |
103 | - 104 |
105 |
107 |
108 | - 109 |
110 |
114 | 115 | follow.pager.disable 116 | 117 | 119 |
120 | Disable pager used with git-diff(1), git-log(1), git-show(1), etc. 121 |
122 |
123 | See --no-pager of git(1). 124 |
125 |
127 |
128 | true 129 |
130 |
131 | false 132 |
133 |
135 |
136 | false 137 |
138 |
142 |
143 | 144 | Options 145 | ------- 146 | 147 | Options can be specified to provide more refined information. If no options are given, all applicable commits will be shown. 148 | 149 | .. raw:: html 150 | 151 |
152 | 153 | 154 | 155 | 160 | 165 | 166 | 167 | 172 | 177 | 178 | 179 | 184 | 192 | 193 | 194 | 199 | 207 | 208 | 209 | 214 | 222 | 223 | 224 | 229 | 237 | 238 | 239 | 244 | 252 | 253 | 254 | 259 | 267 | 268 | 269 | 274 | 282 | 283 | 284 | 289 | 297 | 298 | 299 | 304 | 312 | 313 | 314 | 319 | 327 | 328 | 329 | 334 | 339 | 340 | 341 | 346 | 351 | 352 | 353 | 358 | 363 | 364 | 365 | 370 | 375 | 376 | 377 |
156 | 157 | -b, --branch BRANCH 158 | 159 | 161 |
162 | Show commits for BRANCH 163 |
164 |
168 | 169 | -f, --first 170 | 171 | 173 |
174 | Show first commit where Git initiated tracking of pathspec. 175 |
176 |
180 | 181 | -F, --func FUNCNAME 182 | 183 | 185 |
186 | Show commits for function FUNCNAME. 187 |
188 |
189 | See -L of git-log(1). 190 |
191 |
195 | 196 | -l, --last COUNT 197 | 198 | 200 |
201 | Show last COUNT commits for pathspec. 202 |
203 |
204 | Omit COUNT defaults to last commit. 205 |
206 |
210 | 211 | -L, --lines X[,Y] 212 | 213 | 215 |
216 | Show commits for lines X through Y. 217 |
218 |
219 | Omit Y defaults to EOF. 220 |
221 |
225 | 226 | -M, --no-merges 227 | 228 | 230 |
231 | Show commits which have a maximum of one parent. 232 |
233 |
234 | See --no-merges of git-log(1). 235 |
236 |
240 | 241 | -N, --no-patch 242 | 243 | 245 |
246 | Suppress diff output. 247 |
248 |
249 | See --no-patch of git-log(1). 250 |
251 |
255 | 256 | -O, --no-renames 257 | 258 | 260 |
261 | Disable rename detection. 262 |
263 |
264 | See --no-renames of git-log(1). 265 |
266 |
270 | 271 | -p, --pager 272 | 273 | 275 |
276 | Force pager when invoking git-log(1). 277 |
278 |
279 | Overrides follow.pager.disable config value. 280 |
281 |
285 | 286 | -P, --pickaxe STRING 287 | 288 | 290 |
291 | Show commits which change the frequency of STRING in revision history. 292 |
293 |
294 | See -S of git-log(1). 295 |
296 |
300 | 301 | -r, --range X[,Y] 302 | 303 | 305 |
306 | Show commits in range X through Y. 307 |
308 |
309 | Omit Y defaults to HEAD. 310 |
311 |
315 | 316 | -R, --reverse 317 | 318 | 320 |
321 | Show commits in reverse chronological order. 322 |
323 |
324 | See --walk-reflogs of git-log(1). 325 |
326 |
330 | 331 | -t, --tag TAG 332 | 333 | 335 |
336 | Show commits specific to tag TAG. 337 |
338 |
342 | 343 | -T, --total 344 | 345 | 347 |
348 | Show total number of commits for pathspec. 349 |
350 |
354 | 355 | -h, --help, --usage 356 | 357 | 359 |
360 | Show usage information. 361 |
362 |
366 | 367 | -V, --version 368 | 369 | 371 |
372 | Show current version number. 373 |
374 |
378 |
379 | 380 | Notes 381 | ----- 382 | 383 | Like standard Git builtins, ``git-follow`` supports an optional pathspec delimiter ``--`` to help disambiguate options, option arguments, and refs from pathspecs. 384 | 385 | Examples 386 | -------- 387 | 388 | Display commits on branch *topic* which affected *blame.c* 389 | 390 | .. code-block:: sh 391 | 392 | git follow --branch topic -- blame.c 393 | 394 | Display first commit where Git initiated tracking of *branch.c* 395 | 396 | .. code-block:: sh 397 | 398 | git follow --first -- branch.c 399 | 400 | Display last *5* commits which affected *column.c* 401 | 402 | .. code-block:: sh 403 | 404 | git follow --last 5 -- column.c 405 | 406 | Display last commit where lines *5-* were affected in *diff.c* 407 | 408 | .. code-block:: sh 409 | 410 | git follow --last --lines 5 -- diff.c 411 | 412 | Display last *3* commits where lines *10-15* were affected in *bisect.c* 413 | 414 | .. code-block:: sh 415 | 416 | git follow --last 3 --lines 10,15 -- bisect.c 417 | 418 | Display commits where function *funcname* was affected in *archive.c* 419 | 420 | .. code-block:: sh 421 | 422 | git follow --func funcname -- archive.c 423 | 424 | Display commits in range from *aa03428* to *b354ef9* which affected *worktree.c* 425 | 426 | .. code-block:: sh 427 | 428 | git follow --range aa03428,b354ef9 -- worktree.c 429 | 430 | Display commits in range from tag *v1.5.3* to tag *v1.5.4* which affected *apply.c* 431 | 432 | .. code-block:: sh 433 | 434 | git follow --range v1.5.3,v1.5.4 -- apply.c 435 | 436 | Display commits up to tag *v1.5.3* which affected *graph.c* 437 | 438 | .. code-block:: sh 439 | 440 | git follow --tag v1.5.3 -- graph.c 441 | 442 | Display total number of commits which affected *rebase.c* 443 | 444 | .. code-block:: sh 445 | 446 | git follow --total -- rebase.c 447 | 448 | See Also 449 | -------- 450 | 451 | * `git(1) `_ 452 | * `gitrevisions(1) `_ 453 | * `git-branch(1) `_ 454 | * `git-check-ref-format(1) `_ 455 | * `git-config(1) `_ 456 | * `git-diff(1) `_ 457 | * `git-log(1) `_ 458 | * `git-remote(1) `_ 459 | * `git-tag(1) `_ 460 | -------------------------------------------------------------------------------- /git-follow: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | ### 4 | ### git-follow - Follow lifetime changes of a pathspec in Git. 5 | ### 6 | ### Copyright (C) 2017 Nickolas Burr 7 | ### 8 | 9 | use 5.008; 10 | use strict; 11 | use warnings; 12 | use lib "/usr/local/opt/git-follow/src"; 13 | use Cwd qw(getcwd); 14 | use File::Basename qw(basename); 15 | use Getopt::Long qw( 16 | Configure 17 | GetOptions 18 | ); 19 | use GitFollow::Cli::OptionsNormalizer qw( 20 | format 21 | normalize 22 | ); 23 | use GitFollow::Config qw( 24 | get_config 25 | has_config 26 | ); 27 | use GitFollow::Log qw( 28 | print_total 29 | set_refspec 30 | $DEFAULT_LOG_FMT 31 | $INVALID_PATH_ERR 32 | $INVALID_PATH_WITHIN_RANGE_ERR 33 | $INVALID_REPO_ERR 34 | $INVALID_REPO_HINT 35 | ); 36 | use GitFollow::Environment qw($GIT_PATH); 37 | use GitFollow::Metadata qw( 38 | print_usage 39 | print_version 40 | ); 41 | use GitFollow::Repository::ObjectUtils qw( 42 | is_object 43 | is_repo 44 | ); 45 | 46 | # If --version (or -V) was given as an option, 47 | # print the current release version and exit. 48 | print_version() if grep { $_ eq "--version" or $_ eq "-V" } @ARGV; 49 | 50 | # If git-follow was executed without arguments, 51 | # print usage information and exit. 52 | print_usage() unless @ARGV; 53 | 54 | my ($pathspec, $refspec, @refs) = undef; 55 | 56 | # Diff modes and their git-log(1) option counterparts. 57 | my %diffopts = ( 58 | "inline" => "none", 59 | "sxs" => "plain", 60 | "colorsxs" => "color", 61 | ); 62 | 63 | my %git_log = ( 64 | "pager_mode" => "--paginate", 65 | "command" => "log", 66 | ); 67 | 68 | my %git_log_opts = ( 69 | "diff" => "--word-diff=%s", 70 | "m" => "-m", 71 | "follow" => "--follow", 72 | "format" => "--format=%s", 73 | "graph" => "--graph", 74 | "patch" => "--patch-with-stat", 75 | ); 76 | 77 | # follow.pager.disable configuration. Replace --paginate with --no-pager if set to true. 78 | $git_log{"pager_mode"} = "--no-pager" if has_config("pager", "disable") and get_config("pager", "disable") eq "true"; 79 | 80 | # follow.diff.mode configuration. 81 | if (has_config("diff", "mode")) { 82 | my $diffmode = get_config("diff", "mode"); 83 | 84 | die sprintf("Invalid value '%s' specified for follow.diff.mode\n", $diffmode) unless grep { $_ eq $diffmode } keys %diffopts; 85 | 86 | # Set corresponding --word-diff config value. 87 | $git_log_opts{"diff"} = sprintf($git_log_opts{"diff"}, $diffopts{$diffmode}); 88 | } 89 | 90 | # follow.log.format configuration. 91 | $git_log_opts{"format"} = sprintf( 92 | $git_log_opts{"format"}, 93 | (has_config("log", "format") ? get_config("log", "format") : $DEFAULT_LOG_FMT) 94 | ); 95 | 96 | print_usage() unless @ARGV; 97 | 98 | # Validate we're inside a Git repository. 99 | die sprintf("%s\n%s", sprintf($INVALID_REPO_ERR, getcwd), $INVALID_REPO_HINT) unless is_repo(); 100 | 101 | $pathspec = ($ARGV[$#ARGV] eq ".") 102 | ? basename(getcwd) : $ARGV[$#ARGV]; 103 | 104 | # If --total (or -T) was given as an option, print 105 | # total number of revisions for pathspec and exit. 106 | print_total($pathspec) if grep { $_ eq "--total" or $_ eq "-T" } @ARGV; 107 | 108 | my @apts = (); 109 | my %dispatch = ( 110 | # Set alias, passthrough options and option arguments. 111 | "set_option" => sub { 112 | push @apts, &format('log', $pathspec, @_); 113 | }, 114 | "set_pager" => sub { 115 | $git_log{"pager_mode"} = "--paginate"; 116 | }, 117 | "set_refspec" => sub { 118 | set_refspec((@_, \$refspec)); 119 | }, 120 | "set_flag" => sub { 121 | my $option = shift; 122 | 123 | # Get formatted git-log(1) option from the unary option given. 124 | $git_log_opts{$option} = &format('log', $pathspec, $option); 125 | normalize(\%git_log_opts, $option); 126 | }, 127 | "print_total" => sub { 128 | print_total($pathspec) 129 | }, 130 | "print_usage" => \&print_usage, 131 | "print_version" => \&print_version, 132 | ); 133 | 134 | Configure( 135 | "no_auto_abbrev", 136 | "no_ignore_case", 137 | "require_order", 138 | ); 139 | 140 | GetOptions( 141 | 'branch|b=s{1,1}' => $dispatch{"set_refspec"}, 142 | 'first|f' => $dispatch{"set_option"}, 143 | 'func|F=s{1,1}' => $dispatch{"set_option"}, 144 | 'last|l=i{0,1}' => $dispatch{"set_option"}, 145 | 'lines|L=s{1,1}' => $dispatch{"set_option"}, 146 | 'no-merges|M' => $dispatch{"set_flag"}, 147 | 'no-patch|N' => $dispatch{"set_flag"}, 148 | 'no-renames|O' => $dispatch{"set_flag"}, 149 | 'pager|p' => $dispatch{"set_pager"}, 150 | 'pickaxe|P=s{1,1}' => $dispatch{"set_option"}, 151 | 'range|r=s{1,1}' => $dispatch{"set_refspec"}, 152 | 'reverse|R' => $dispatch{"set_flag"}, 153 | 'tag|t=s{1,1}' => $dispatch{"set_refspec"}, 154 | 'total|T' => $dispatch{"print_total"}, 155 | 'usage|help|h' => $dispatch{"print_usage"}, 156 | 'version|V' => $dispatch{"print_version"}, 157 | ) or print_usage(); 158 | 159 | # Set default ref to HEAD if not given explicitly 160 | # via option --branch, --range, or --tag. 161 | $refspec = "HEAD" unless defined $refspec; 162 | 163 | # Attempt split at .. range delimiter. 164 | @refs = split /\.{2}/, $refspec; 165 | 166 | # Verify pathspec is valid given each ref in @refs. 167 | foreach my $ref (@refs) { 168 | die sprintf($INVALID_PATH_WITHIN_RANGE_ERR, $pathspec, $refspec) unless is_object($ref, $pathspec); 169 | } 170 | 171 | system $GIT_PATH, $git_log{"pager_mode"}, $git_log{"command"}, @apts, values %git_log_opts, $refspec, "--", $pathspec; 172 | 173 | 1; 174 | 175 | __END__ 176 | 177 | =pod 178 | 179 | =encoding UTF-8 180 | 181 | =head1 NAME 182 | 183 | git-follow - Follow lifetime changes of a pathspec in Git. 184 | 185 | =head1 VERSION 186 | 187 | version 1.1.5 188 | 189 | =head1 DESCRIPTION 190 | 191 | Follow lifetime changes of a pathspec in Git. git-follow(1) makes analyzing changes of a pathspec trivial with robust options and simplified log output. 192 | 193 | =head1 CONFIGURATION 194 | 195 | Configuration values set via git-config(1) can be used to customize the behavior of git-follow. 196 | 197 | follow.diff.mode 198 | Diff mode. Choices include inline, sxs, and colorsxs. See --word-diff, --color-words, et al. of git-log(1). 199 | 200 | follow.log.format 201 | Log format. See --format of git-log(1) for syntax. 202 | 203 | follow.pager.disable 204 | Disable pager. Defaults to false. Set to true to disable pager. See --no-pager of git(1). 205 | 206 | =head1 OPTIONS 207 | 208 | -b, --branch 209 | Show commits specific to a branch. 210 | 211 | -f, --first 212 | Show first commit where Git initiated tracking of pathspec. 213 | 214 | -F, --func 215 | Show commits which affected function in pathspec. See -L of git-log(1). 216 | 217 | -l, --last [] 218 | Show last commits which affected pathspec. Omitting defaults to last commit. 219 | 220 | -L, --lines [,] 221 | Show commits which affected lines to in pathspec. Omitting defaults to EOF. 222 | 223 | -M, --no-merges 224 | Show commits which have a maximum of one parent. See --no-merges of git-log(1). 225 | 226 | -N, --no-patch 227 | Suppress diff output. See --no-patch of git-log(1). 228 | 229 | -O, --no-renames 230 | Disable rename detection. See --no-renames of git-log(1). 231 | 232 | -p, --pager 233 | Force pager when invoking git-log(1). Overrides follow.pager.disable config value. 234 | 235 | -P, --pickaxe 236 | Show commits which change the number of occurrences of in pathspec. See -S of git-log(1). 237 | 238 | -r, --range [,] 239 | Show commits in range to . Omitting defaults to HEAD. See git-revisions(1). 240 | 241 | -R, --reverse 242 | Show commits in reverse chronological order. See --walk-reflogs of git-log(1). 243 | 244 | -t, --tag 245 | Show commits specific to a tag. 246 | 247 | -T, --total 248 | Show total number of commits for pathspec. 249 | 250 | -V, --version 251 | Show current release version. 252 | 253 | =head1 NOTES 254 | 255 | Like standard Git builtins, git-follow supports an optional pathspec delimiter [--] to help disambiguate options, option arguments, and refs from pathspecs. 256 | 257 | =head1 EXAMPLES 258 | 259 | Display commits on branch 'topic' 260 | git follow --branch topic -- blame.c 261 | 262 | Display first commit where Git initiated tracking 263 | git follow --first -- branch.c 264 | 265 | Display last 5 commits 266 | git follow --last 5 -- Makefile 267 | 268 | Display last commit where lines 5 through EOF were affected 269 | git follow --last --lines 5 -- apply.c 270 | 271 | Display last 3 commits where lines 10 through 15 were affected 272 | git follow --last 3 --lines 10,15 -- bisect.c 273 | 274 | Display commits where function `funcname' was affected 275 | git follow --func funcname -- archive.c 276 | 277 | Display commits in range from aa03428 to b354ef9 278 | git follow --range aa03428,b354ef9 -- worktree.c 279 | 280 | Display commits in range from v1.5.3 to v1.5.4 281 | git follow --range v1.5.3,v1.5.4 -- apply.c 282 | 283 | Display commits up to tag v1.5.3 284 | git follow --tag v1.5.3 -- graph.c 285 | 286 | Display total number of commits 287 | git follow --total -- rebase.c 288 | 289 | =head1 BUGS 290 | 291 | https://github.com/nickolasburr/git-follow/issues 292 | 293 | =head1 AUTHOR 294 | 295 | Written by Nickolas Burr 296 | 297 | =head1 SEE ALSO 298 | 299 | git(1), git-branch(1), git-check-ref-format(1), git-config(1), git-diff(1), git-log(1), git-remote(1), git-revisions(1), git-tag(1) 300 | 301 | =cut 302 | 303 | # vim: syntax=perl 304 | -------------------------------------------------------------------------------- /man/git-follow.1.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickolasburr/git-follow/fd26243589d864f8862c959521bfc2a21741c574/man/git-follow.1.gz -------------------------------------------------------------------------------- /src/GitFollow/Blame.pm: -------------------------------------------------------------------------------- 1 | ### 2 | ### git-follow - Follow lifetime changes of a pathspec in Git. 3 | ### 4 | ### Copyright (C) 2017 Nickolas Burr 5 | ### 6 | 7 | package GitFollow::Blame; 8 | use 5.008; 9 | use strict; 10 | use warnings; 11 | use Exporter qw(import); 12 | 13 | sub get_hunk; 14 | sub get_hunks; 15 | sub get_line; 16 | sub get_lines; 17 | -------------------------------------------------------------------------------- /src/GitFollow/Cli/OptionsNormalizer.pm: -------------------------------------------------------------------------------- 1 | ### 2 | ### git-follow - Follow lifetime changes of a pathspec in Git. 3 | ### 4 | ### Copyright (C) 2023 Nickolas Burr 5 | ### 6 | package GitFollow::Cli::OptionsNormalizer; 7 | 8 | use 5.008; 9 | use strict; 10 | use warnings; 11 | use Exporter qw(import); 12 | use GitFollow::Log qw(parse_opts); 13 | use GitFollow::Stdlib::NumberUtils qw(is_numeric); 14 | 15 | our @EXPORT_OK = qw( 16 | format 17 | normalize 18 | ); 19 | 20 | # git-follow long options and their imcompatible 21 | # git- long/short option counterparts. 22 | my %conflicts = ( 23 | "no-merges" => { 24 | "log" => ["m"], 25 | }, 26 | "no-patch" => { 27 | "log" => ["patch"], 28 | }, 29 | "no-renames" => { 30 | "log" => ["follow"], 31 | }, 32 | "reverse" => { 33 | "log" => [ 34 | "graph", 35 | "follow", 36 | ], 37 | }, 38 | ); 39 | 40 | # Format handlers by command and option(s). 41 | my %handlers = ( 42 | "log" => \&parse_opts, 43 | ); 44 | 45 | sub format; 46 | sub normalize; 47 | 48 | # Format alias, passthrough options, 49 | # and option arguments for commands. 50 | sub format { 51 | my $cmd = shift; 52 | die sprintf("No command handler for '%s'\n", $cmd) if not exists $handlers{$cmd}; 53 | return $handlers{$cmd}->(@_); 54 | } 55 | 56 | # Remove incompatible/conflicting options. 57 | sub normalize { 58 | my ($opts, $opt, $cmd) = @_; 59 | $cmd = 'log' if not defined $cmd; 60 | 61 | if (exists $conflicts{$opt}) { 62 | my $copts = $conflicts{$opt}->{$cmd}; 63 | 64 | foreach my $copt (values @$copts) { 65 | delete $opts->{$copt} if exists $opts->{$copt}; 66 | } 67 | } 68 | } 69 | 70 | 1; 71 | -------------------------------------------------------------------------------- /src/GitFollow/Config.pm: -------------------------------------------------------------------------------- 1 | ### 2 | ### git-follow - Follow lifetime changes of a pathspec in Git. 3 | ### 4 | ### Copyright (C) 2023 Nickolas Burr 5 | ### 6 | package GitFollow::Config; 7 | 8 | use 5.008; 9 | use strict; 10 | use warnings; 11 | use Exporter qw(import); 12 | use GitFollow::Environment qw($GIT_PATH); 13 | 14 | our @EXPORT_OK = qw( 15 | get_config 16 | has_config 17 | ); 18 | 19 | sub get_config; 20 | sub has_config; 21 | 22 | # Get git-config(1) value. 23 | sub get_config { 24 | my ($grp, $key) = @_; 25 | my $config = undef; 26 | 27 | system("$GIT_PATH config follow.$grp.$key >/dev/null"); 28 | 29 | $config = (!$?) 30 | ? `$GIT_PATH config follow.$grp.$key` 31 | : `$GIT_PATH config follow.$grp$key`; 32 | 33 | # Strip trailing newline from config value. 34 | chomp $config; 35 | return $config; 36 | } 37 | 38 | # Check if git-config(1) key exists. 39 | sub has_config { 40 | my ($grp, $key) = @_; 41 | 42 | system("$GIT_PATH config follow.$grp.$key >/dev/null"); 43 | return 1 unless $?; 44 | 45 | system("$GIT_PATH config follow.$grp$key >/dev/null"); 46 | !($? >> 8); 47 | } 48 | 49 | 1; 50 | -------------------------------------------------------------------------------- /src/GitFollow/Environment.pm: -------------------------------------------------------------------------------- 1 | ### 2 | ### git-follow - Follow lifetime changes of a pathspec in Git. 3 | ### 4 | ### Copyright (C) 2023 Nickolas Burr 5 | ### 6 | package GitFollow::Environment; 7 | 8 | use 5.008; 9 | use strict; 10 | use warnings; 11 | use Exporter qw(import); 12 | 13 | our @EXPORT_OK = qw( 14 | $GIT_PATH 15 | ); 16 | 17 | our $GIT_PATH = exists $ENV{'GIT_PATH'} 18 | ? $ENV{'GIT_PATH'} : '/usr/bin/git'; 19 | 20 | 1; 21 | -------------------------------------------------------------------------------- /src/GitFollow/Log.pm: -------------------------------------------------------------------------------- 1 | ### 2 | ### git-follow - Follow lifetime changes of a pathspec in Git. 3 | ### 4 | ### Copyright (C) 2017 Nickolas Burr 5 | ### 6 | package GitFollow::Log; 7 | 8 | use 5.008; 9 | use strict; 10 | use warnings; 11 | use Exporter qw(import); 12 | use GitFollow::Environment qw($GIT_PATH); 13 | 14 | our @EXPORT_OK = qw( 15 | parse_opts 16 | print_total 17 | set_refspec 18 | $DEFAULT_LOG_FMT 19 | $INVALID_PATH_ERR 20 | $INVALID_PATH_WITHIN_RANGE_ERR 21 | $INVALID_REPO_ERR 22 | $INVALID_REPO_HINT 23 | ); 24 | 25 | our %parts = ( 26 | "hash" => "%C(bold cyan)%h%Creset", 27 | "tree" => "%C(bold magenta)%t%Creset", 28 | "entry" => "%s", 29 | "name" => "%C(bold blue)%an%Creset", 30 | "email" => "%C(bold yellow)%ae%Creset", 31 | "time" => "%C(bold green)%cr%Creset", 32 | ); 33 | 34 | # Default git-log(1) format. 35 | our $DEFAULT_LOG_FMT = "$parts{'hash'} ($parts{'tree'}) - $parts{'entry'} - $parts{'name'} <$parts{'email'}> [$parts{'time'}]"; 36 | 37 | ### 38 | ### Environment variables. 39 | ### 40 | 41 | our $GIT_FOLLOW_DIFF_MODE = undef; 42 | our $GIT_FOLLOW_LOG_FORMAT = undef; 43 | our $GIT_FOLLOW_NO_PAGER = undef; 44 | 45 | ### 46 | ### User errors, notices, hints, etc. 47 | ### 48 | 49 | our $INVALID_REFNAME = "%s is not a valid %s.\n"; 50 | our $INVALID_NUM_ARG = "%s is not a valid number.\n"; 51 | our $INVALID_REF_COMBO = "Only one --branch or one --tag option can be specified at a time.\n"; 52 | our $INVALID_REPO_ERR = "%s is not a Git repository.\n"; 53 | our $INVALID_REPO_HINT = "FYI: If you don't want to change directories, you can run 'git -C /path/to/repository follow ...'\n"; 54 | our $INVALID_PATH_ERR = "%s is not a valid pathspec.\n"; 55 | our $INVALID_PATH_WITHIN_RANGE_ERR = "%s is not a valid pathspec within range %s.\n"; 56 | 57 | ### 58 | ### git-follow(1) subroutines. 59 | ### 60 | 61 | sub parse_opts; 62 | sub get_rev_range; 63 | sub print_total; 64 | sub set_refspec; 65 | 66 | # Get revision range via start and end boundaries. 67 | sub get_rev_range { 68 | my $range = shift; 69 | my ($start, $end) = split ',', $range; 70 | 71 | # If no end revision was given, default to HEAD. 72 | $end = "HEAD" unless defined $end; 73 | return "$start..$end"; 74 | } 75 | 76 | # Format alias, passthrough options 77 | # and option arguments for git-log(1). 78 | sub parse_opts { 79 | my ($pathspec, $opt, @args) = @_; 80 | 81 | if ($opt eq "first") { 82 | return "--diff-filter=A"; 83 | } elsif ($opt eq "func") { 84 | my $funcname = shift @args; 85 | return "-L:$funcname:$pathspec"; 86 | } elsif ($opt eq "last") { 87 | my $num = shift @args; 88 | 89 | if (!$num) { 90 | $num = 1; 91 | } 92 | 93 | die sprintf($INVALID_NUM_ARG, $num) unless is_numeric($num); 94 | return "--max-count=$num"; 95 | } elsif ($opt eq "lines") { 96 | my $lines = shift @args; 97 | my ($start, $end) = split ',', $lines; 98 | 99 | if (defined $end) { 100 | return "-L$start,$end:$pathspec"; 101 | } else { 102 | return "-L$start:$pathspec" 103 | } 104 | } elsif ($opt eq "pickaxe") { 105 | my $subject = shift @args; 106 | return "-S$subject"; 107 | } else { 108 | return "--$opt"; 109 | } 110 | } 111 | 112 | # Update package-level `$refspec` with ref given via --branch or --tag. 113 | sub set_refspec { 114 | my ($opt, $ref, $refspec) = @_; 115 | 116 | # If `$refspec` is already defined, notify the user and emit an error, 117 | # as options `--branch`, `--range`, and `--tag` are mutually exclusive. 118 | die "$INVALID_REF_COMBO" unless length $refspec; 119 | 120 | if ($opt eq "range") { 121 | $$refspec = get_rev_range($ref); 122 | } else { 123 | my $refs = `$GIT_PATH $opt --list`; 124 | my $remotes = `$GIT_PATH branch -r` if $opt eq "branch"; 125 | $refs = $refs . $remotes if defined $remotes; 126 | 127 | # Filter asterisk, escape codes from `git {branch,tag} --list`. 128 | $refs =~ s/\*//gi; 129 | $refs =~ s/\033\[\d*(;\d*)*m//g; 130 | 131 | # Split refspecs into an array, trim whitespace from each element. 132 | my @refspecs = split "\n", $refs; 133 | @refspecs = grep { $_ =~ s/^\s+//; $_; } @refspecs; 134 | 135 | # If `$ref` is indeed a valid refspec, update `$refspec`. 136 | if (grep /^$ref$/, @refspecs) { 137 | $$refspec = $ref; 138 | } else { 139 | # Otherwise, emit an error specific to 140 | # the option given and exit the script. 141 | die sprintf($INVALID_REFNAME, $ref, $opt); 142 | } 143 | } 144 | } 145 | 146 | # Print total number of commits for pathspec. 147 | sub print_total { 148 | my $pathspec = shift; 149 | 150 | # Whether to use rename detection or stop at renames. 151 | my $fopt = (grep { $_ eq "--no-renames" || $_ eq "-O" } @ARGV) 152 | ? "--no-renames" : "--follow"; 153 | 154 | # Use pathspec, if defined. Otherwise, 155 | # get the last element in @ARGV array. 156 | my $path = (defined $pathspec) 157 | ? $pathspec : $ARGV[$#ARGV]; 158 | 159 | # Array of abbreviated commit hashes. 160 | my @hashes = `$GIT_PATH log $fopt --format=\"%h\" -- $path`; 161 | 162 | print scalar @hashes; 163 | print "\n"; 164 | 165 | exit 0; 166 | } 167 | 168 | 1; 169 | -------------------------------------------------------------------------------- /src/GitFollow/Metadata.pm: -------------------------------------------------------------------------------- 1 | ### 2 | ### git-follow - Follow lifetime changes of a pathspec in Git. 3 | ### 4 | ### Copyright (C) 2023 Nickolas Burr 5 | ### 6 | package GitFollow::Metadata; 7 | 8 | use 5.008; 9 | use strict; 10 | use warnings; 11 | use Exporter qw(import); 12 | 13 | our @EXPORT_OK = qw( 14 | get_version 15 | print_version 16 | print_usage 17 | $GIT_FOLLOW_VERSION 18 | ); 19 | 20 | # Current release version. 21 | our $GIT_FOLLOW_VERSION = "1.1.5"; 22 | my $USAGE_SYNOPSIS = <<"END_USAGE_SYNOPSIS"; 23 | 24 | Usage: git follow [OPTIONS] [--] 25 | 26 | OPTIONS: 27 | 28 | -b, --branch Show commits for pathspec, specific to a branch. 29 | -f, --first Show first commit where Git initiated tracking of pathspec. 30 | -F, --func Show commits which affected function in pathspec. 31 | -l, --last [] Show last commits which affected pathspec. Omitting defaults to last commit. 32 | -L, --lines [] Show commits which affected lines through in pathspec. Omitting defaults to EOF. 33 | -M, --no-merges Show commits which have a maximum of one parent. See --no-merges of git-log(1). 34 | -N, --no-patch Suppress diff output. See --no-patch of git-log(1). 35 | -O, --no-renames Disable rename detection. See --no-renames of git-log(1). 36 | -p, --pager Force pager when invoking git-log(1). Overrides follow.pager.disable config value. 37 | -P, --pickaxe Show commits which change the number of occurrences of in pathspec. See -S of git-log(1). 38 | -r, --range [] Show commits in range to which affected pathspec. Omitting defaults to HEAD. See gitrevisions(1). 39 | -R, --reverse Show commits in reverse chronological order. See --walk-reflogs of git-log(1). 40 | -t, --tag Show commits for pathspec, specific to a tag. 41 | -T, --total Show total number of commits for pathspec. 42 | -V, --version Show current release version. 43 | 44 | END_USAGE_SYNOPSIS 45 | 46 | sub get_version; 47 | sub print_version; 48 | sub print_usage; 49 | 50 | # Get current release version. 51 | sub get_version { 52 | return $GIT_FOLLOW_VERSION; 53 | } 54 | 55 | # Print current release version. 56 | sub print_version { 57 | print "$GIT_FOLLOW_VERSION\n"; 58 | exit 0; 59 | } 60 | 61 | # Print usage information. 62 | sub print_usage { 63 | my $code = shift; 64 | $code = 0 if not defined $code; 65 | 66 | print "$USAGE_SYNOPSIS"; 67 | exit $code; 68 | } 69 | 70 | 1; 71 | -------------------------------------------------------------------------------- /src/GitFollow/Repository/ObjectUtils.pm: -------------------------------------------------------------------------------- 1 | ### 2 | ### git-follow - Follow lifetime changes of a pathspec in Git. 3 | ### 4 | ### Copyright (C) 2023 Nickolas Burr 5 | ### 6 | package GitFollow::Repository::ObjectUtils; 7 | 8 | use 5.008; 9 | use strict; 10 | use warnings; 11 | use Exporter qw(import); 12 | use GitFollow::Environment qw($GIT_PATH); 13 | 14 | our @EXPORT_OK = qw( 15 | is_object 16 | is_repo 17 | ); 18 | 19 | sub is_object; 20 | sub is_repo; 21 | 22 | # Determine if pathspec is a valid file object. 23 | sub is_object { 24 | my ($refspec, $pathspec) = @_; 25 | 26 | # Validate pathspec via git-cat-file(1). 27 | system("$GIT_PATH cat-file -e $refspec:$pathspec &>/dev/null"); 28 | !($? >> 8); 29 | } 30 | 31 | # Determine if we're inside a Git repository. 32 | sub is_repo { 33 | system("$GIT_PATH rev-parse --is-inside-work-tree &>/dev/null"); 34 | !($? >> 8); 35 | } 36 | 37 | 1; 38 | -------------------------------------------------------------------------------- /src/GitFollow/Stdlib/NumberUtils.pm: -------------------------------------------------------------------------------- 1 | ### 2 | ### git-follow - Follow lifetime changes of a pathspec in Git. 3 | ### 4 | ### Copyright (C) 2023 Nickolas Burr 5 | ### 6 | package GitFollow::Stdlib::NumberUtils; 7 | 8 | use 5.008; 9 | use strict; 10 | use warnings; 11 | use Exporter qw(import); 12 | 13 | our @EXPORT_OK = qw(is_numeric); 14 | 15 | sub is_numeric; 16 | 17 | # Determine if value is numeric. 18 | sub is_numeric { 19 | my $num = shift; 20 | return (defined $num) 21 | ? ($num =~ /^\d+$/ ? 1 : 0) : 0; 22 | } 23 | -------------------------------------------------------------------------------- /tools/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | PREFIX="$1" 5 | 6 | if [[ ! -d "$PREFIX" ]]; then 7 | printf '%s is not a valid directory.\n' "$PREFIX" 8 | exit 1 9 | fi 10 | 11 | TARGET="git-follow" 12 | MANDIR="man" 13 | SRCDIR="src" 14 | DEFDIR="/usr/local" 15 | 16 | BINDIR="$PREFIX/bin" 17 | OPTDIR="$PREFIX/opt" 18 | MDLDIR="$OPTDIR/$TARGET" 19 | MDLSRC="$MDLDIR/$SRCDIR" 20 | 21 | MANPAGE="$TARGET.1.gz" 22 | MANDEST="$PREFIX/share/man/man1" 23 | 24 | INSTALL="/usr/bin/install" 25 | OPTIONS="-c" 26 | 27 | CP="/bin/cp" 28 | CPOPTS="-rf" 29 | 30 | MKDIR="/bin/mkdir" 31 | MKDIROPTS="-p" 32 | 33 | RM="/bin/rm" 34 | RMOPTS="-rf" 35 | 36 | SED="/usr/bin/sed" 37 | SEDOPTS="-i ''" 38 | SEDEXPR="s@$DEFDIR@$PREFIX@g" 39 | 40 | builtin cd .. 41 | eval "$CP $MANDIR/$MANPAGE $MANDEST/$MANPAGE" 42 | 43 | # Set absolute path for 'use lib' directive. 44 | eval "$SED $SEDOPTS $SEDEXPR $TARGET" 45 | eval "$INSTALL $OPTIONS $TARGET $BINDIR/$TARGET" 46 | [[ -d "$MDLDIR" ]] && eval "$RM $RMOPTS $MDLDIR" 47 | 48 | eval "$MKDIR $MKDIROPTS $MDLDIR" 49 | eval "$CP $CPOPTS $SRCDIR $MDLSRC" 50 | -------------------------------------------------------------------------------- /tools/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ### 4 | ### test.sh: Integrity test of git-follow executable. 5 | ### 6 | 7 | if [[ -n "$CDPATH" ]]; then 8 | unset CDPATH 9 | fi 10 | 11 | builtin cd .. 12 | 13 | TARGET="git-follow" 14 | MDLDIR="/usr/local/opt/git-follow/src" 15 | SRCDIR="src" 16 | 17 | SED="/usr/bin/sed" 18 | SEDOPTS="-i ''" 19 | SEDEXPR="s@$MDLDIR@$SRCDIR@g" 20 | 21 | # Update 'use lib' directive to 'src' for testing purposes. 22 | eval "$SED $SEDOPTS $SEDEXPR $TARGET" 23 | 24 | ERROR=0 25 | TESTS=( 26 | "--last=5 --no-merges --no-renames" 27 | "-l 5 -M -O" 28 | "--first --no-patch --branch=origin/master" 29 | "-f -N -b origin/master" 30 | "--last --no-merges --no-patch" 31 | "-l -M -N" 32 | "--func=in_array --no-renames" 33 | "-F in_array -O" 34 | "--pickaxe=git_track_map_aliases --last --no-patch" 35 | "-P git_track_map_aliases -l -N" 36 | "--range 954829d,67bfd35 --no-patch" 37 | "-r 954829d,67bfd35 -N" 38 | "--branch origin/master --last --no-merges" 39 | "-b origin/master -l -M" 40 | "--last=3 --lines=20,35 --no-merges --no-renames" 41 | "-l 3 -L 20,35 -M -O" 42 | "--reverse --last=5 --no-merges --no-renames" 43 | "-R -l 5 -M -O" 44 | ) 45 | 46 | for OPTIONS in "${TESTS[@]}"; do 47 | eval "./$TARGET $OPTIONS -- $TARGET" >/dev/null 2>&1 48 | 49 | if [[ $? -eq 0 ]]; then 50 | printf 'OK ./%s %s -- %s\n' "$TARGET" "$OPTIONS" "$TARGET" 51 | else 52 | printf 'ERROR ./%s %s -- %s\n' "$TARGET" "$OPTIONS" "$TARGET" 53 | ERROR=1 54 | break 55 | fi 56 | done 57 | 58 | SEDEXPR="s@$SRCDIR@$MDLDIR@g" 59 | 60 | # Reset 'use lib' directive to original path. 61 | eval "$SED $SEDOPTS $SEDEXPR $TARGET" 62 | exit $ERROR 63 | -------------------------------------------------------------------------------- /tools/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | PREFIX="$1" 5 | 6 | if [[ ! -d "$PREFIX" ]]; then 7 | printf '%s is not a valid directory.\n' "$PREFIX" 8 | exit 1 9 | fi 10 | 11 | TARGET="git-follow" 12 | 13 | BINDIR="$PREFIX/bin" 14 | OPTDIR="$PREFIX/opt" 15 | MDLDIR="$OPTDIR/$TARGET" 16 | 17 | MANPAGE="$TARGET.1.gz" 18 | MANDEST="$PREFIX/share/man/man1" 19 | 20 | RM="rm" 21 | RMOPTS="-rf" 22 | 23 | eval "$RM $RMOPTS $BINDIR/$TARGET $MANDEST/$MANPAGE" 24 | [[ -d "$MDLDIR" ]] && eval "$RM $RMOPTS $MDLDIR" 25 | --------------------------------------------------------------------------------